Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Notable changes between releases. Detailed migration notes for storage transitio
- Heartbeat, deadline and callback rescue of lease-backed attempts reach the stale attempts behind a full batch of attempts whose retained ready bodies are ambiguous. Before, every pass selected the same ambiguous attempts first, left them running, and rescued nothing else. A pass now sets all of a batch's ambiguous attempts aside at once.
- Admin job lists and kind counts no longer read other claim slots' receipt closure partitions when they read one slot's open claims.
- A `Completed` lifecycle event, and the job a `Completed` follow-up enqueue spec receives, no longer carry the finished attempt's `heartbeat_at` and `deadline_at`; both are `None`, as a read of the completed job returns them.
- Worker finalization is bounded to the larger of the runtime's worker capacity and twice the completion batch size times the completion shard count. A finished handler keeps its worker slot while it waits for finalizer capacity, so when durable completion falls behind the runtime stops claiming instead of holding an unbounded backlog of finished attempts and their payloads in memory. Attempt heartbeats continue until finalization settles.
- Retried queue-storage jobs and jobs carrying metadata complete by their compact claim batch and item index, instead of reading every retained claim batch.
- Lifecycle hooks (`on_event`, `on_event_kind`) no longer accumulate without bound when they are slower than job throughput. Each attempt's `Started` and outcome hooks now hold its finalizer slot until they return, so slow hooks slow claiming instead of growing worker memory, and a worker-finalized outcome hook runs after that attempt's `Started` hook has returned. Maintenance's `Rescued` and duplicate `Cancelled` events allow 1,024 outstanding hook tasks per worker and drop further events with a warning.

## [0.6.14] — 2026-11-08

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ tx.commit().await?;
awa::admin::cancel_by_unique_key(&pool, "send_email", None, Some(&serde_json::json!({"to": "alice@example.com", "subject": "Welcome"})), None).await?;
```

Lifecycle hooks receive `Started` after a claim commits as the worker handler is about to run, plus outcome events (`Completed`, `Retried`, `Exhausted`, `Cancelled`) after the corresponding state transition commits. Hooks run in detached tasks, so they do not block job execution.
Lifecycle hooks receive `Started` after a claim commits as the worker handler is about to run, plus outcome events (`Completed`, `Retried`, `Exhausted`, `Cancelled`) after the corresponding state transition commits. Hooks run alongside job execution and never delay a job's state transition, but each attempt's hooks hold a finalizer slot until they return, so hooks slower than job throughput slow claiming.

Cancellation is cooperative for running handlers:

Expand Down
99 changes: 84 additions & 15 deletions awa-model/src/queue_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2150,6 +2150,62 @@ mod ready_segment_tests {
}
}

#[sqlx::test(migrations = false)]
async fn enqueue_batch_writes_more_segments_than_the_old_bind_limit(pool: sqlx::PgPool) {
use super::{QueueStorageConfig, RuntimeReadyRow};

crate::migrations::run(&pool).await.unwrap();
let store = QueueStorage::new(QueueStorageConfig {
schema: "awa_qs_segment_bind_limit".into(),
..Default::default()
})
.unwrap();
store.install(&pool).await.unwrap();

// Distinct microsecond timestamps guarantee one segment per row.
// Reading the clock for every row can coalesce adjacent timestamps
// and fail to exercise the old 8,191-segment bind limit.
const JOBS: i64 = 9_000;
let first_run_at = Utc::now() - Duration::seconds(1);
let rows = (0..JOBS)
.map(|seq| RuntimeReadyRow {
kind: "segment_test".into(),
queue: "segment-q".into(),
args: serde_json::json!({"seq": seq}),
priority: 2,
attempt: 0,
run_lease: 0,
max_attempts: 25,
run_at: first_run_at + Duration::microseconds(seq),
attempted_at: None,
created_at: first_run_at,
unique_key: None,
unique_states: None,
payload: serde_json::json!({}),
ordering_key: None,
})
.collect();
assert_eq!(
store.enqueue_runtime_rows(&pool, rows).await.unwrap(),
JOBS as usize
);
let counts: (i64, i64) = sqlx::query_as(&format!(
"SELECT (SELECT count(*) FROM {}.ready_entries), \
(SELECT count(*) FROM {}.ready_segments)",
store.schema(),
store.schema()
))
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(counts, (JOBS, JOBS));
let claimed = store
.claim_runtime_batch(&pool, "segment-q", 100, std::time::Duration::from_secs(30))
.await
.unwrap();
assert_eq!(claimed.len(), 100);
}

#[test]
fn ready_segments_split_on_run_at_boundaries() {
let first = Utc
Expand Down Expand Up @@ -7010,14 +7066,22 @@ impl QueueStorage {
.expect("receipt-backed slow completion requires receipt_id")
})
.collect();
let receipt_claim_batch_ids: Vec<Option<i64>> = receipt_claimed
.iter()
.map(|entry| entry.claim.claim_batch_id)
.collect();
let receipt_claim_batch_indices: Vec<Option<i32>> = receipt_claimed
.iter()
.map(|entry| entry.claim.claim_batch_index)
.collect();
let closure_rel = format!("{schema}.lease_claim_closures");
let closure_batch_rel = format!("{schema}.lease_claim_closure_batches");
let closed_evidence =
receipt_closed_evidence_sql(schema, &closure_rel, &closure_batch_rel, "claims");
let updated: Vec<(i64, i64)> = sqlx::query_as(&format!(
r#"
WITH completed(claim_slot, job_id, run_lease, receipt_id) AS (
SELECT * FROM unnest($1::int[], $2::bigint[], $3::bigint[], $4::bigint[])
WITH completed(claim_slot, job_id, run_lease, receipt_id, claim_batch_id, claim_batch_index) AS (
SELECT * FROM unnest($1::int[], $2::bigint[], $3::bigint[], $4::bigint[], $5::bigint[], $6::int[])
),
locked_row_claims AS (
SELECT claims.claim_slot, claims.job_id, claims.run_lease
Expand All @@ -7044,18 +7108,22 @@ impl QueueStorage {
items.job_id,
items.run_lease,
items.receipt_id
FROM {schema}.lease_claim_batches AS claim_batches
CROSS JOIN LATERAL unnest(
claim_batches.job_ids,
claim_batches.run_leases,
claim_batches.receipt_ids
) AS items(job_id, run_lease, receipt_id)
JOIN completed
ON completed.claim_slot = claim_batches.claim_slot
AND completed.job_id = items.job_id
AND completed.run_lease = items.run_lease
AND completed.receipt_id = items.receipt_id
WHERE claim_batches.job_ids && $2::bigint[]
FROM completed
JOIN {schema}.lease_claim_batches AS claim_batches
ON claim_batches.claim_slot = completed.claim_slot
AND claim_batches.batch_id = completed.claim_batch_id
CROSS JOIN LATERAL (
SELECT
claim_batches.job_ids[completed.claim_batch_index] AS job_id,
claim_batches.run_leases[completed.claim_batch_index] AS run_lease,
claim_batches.receipt_ids[completed.claim_batch_index] AS receipt_id
) AS items
WHERE completed.claim_batch_id IS NOT NULL
AND completed.claim_batch_index IS NOT NULL
AND completed.claim_batch_index BETWEEN 1 AND claim_batches.claimed_count
AND completed.job_id = items.job_id
AND completed.run_lease = items.run_lease
AND completed.receipt_id = items.receipt_id
AND NOT EXISTS (
SELECT 1
FROM {schema}.lease_claim_closures AS closures
Expand Down Expand Up @@ -7093,7 +7161,6 @@ impl QueueStorage {
WHERE dlq.job_id = items.job_id
AND dlq.run_lease = items.run_lease
)
FOR UPDATE OF claim_batches
),
locked_claims AS (
SELECT claim_slot, job_id, run_lease FROM locked_row_claims
Expand Down Expand Up @@ -7185,6 +7252,8 @@ impl QueueStorage {
.bind(&receipt_job_ids)
.bind(&receipt_run_leases)
.bind(&receipt_receipt_ids)
.bind(&receipt_claim_batch_ids)
.bind(&receipt_claim_batch_indices)
.fetch_all(tx.as_mut())
.await
.map_err(map_sqlx_error)?;
Expand Down
11 changes: 7 additions & 4 deletions awa-worker/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,10 +321,11 @@ impl ClientBuilder {
/// `Started` dispatch is scheduled after the claim commits and before the
/// worker handler is invoked. Outcome handlers run only after the
/// corresponding DB state transition commits. Hooks are best-effort
/// in-process notifications, not a durable workflow mechanism; because
/// they run in detached tasks, a very short job can complete before a
/// `Started` handler finishes. Capture any shared dependencies you need in
/// the closure environment.
/// in-process notifications, not a durable workflow mechanism. `Started`
/// runs alongside the handler, so a very short job can complete before a
/// `Started` handler finishes. Each attempt's hooks hold a finalizer slot
/// until they return, so hooks slower than job throughput slow claiming.
/// Capture any shared dependencies you need in the closure environment.
pub fn on_event<T, F, Fut>(mut self, handler: F) -> Self
where
T: JobArgs + DeserializeOwned + Send + Sync + 'static,
Expand Down Expand Up @@ -1640,6 +1641,8 @@ impl Client {
effective_storage.clone(),
self.dlq_policy.clone(),
self.finalizers.clone(),
self.service_cancel.clone(),
runtime_worker_capacity,
));

// Admin cancellation listener: fires the in-flight cancel flag
Expand Down
19 changes: 18 additions & 1 deletion awa-worker/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,19 @@ fn completion_sort_key(request: &CompletionRequest) -> (i32, i64, RunLease) {
#[derive(Clone)]
pub(crate) struct CompletionBatcherHandle {
shards: Vec<mpsc::Sender<CompletionRequest>>,
pipeline_capacity: usize,
}

impl CompletionBatcherHandle {
/// How many finished attempts may wait for finalization at once: enough
/// to keep every shard's in-flight and next batch full, and at least one
/// per worker.
pub fn finalizer_capacity(&self, runtime_worker_capacity: u32) -> usize {
self.pipeline_capacity
.max(runtime_worker_capacity.max(1) as usize)
.min(tokio::sync::Semaphore::MAX_PERMITS)
}

pub async fn complete(&self, job_id: i64, run_lease: RunLease) -> Result<bool, AwaError> {
self.complete_inner(job_id, run_lease, None, None).await
}
Expand Down Expand Up @@ -185,7 +195,14 @@ impl CompletionBatcher {
});
}

(Self { workers }, CompletionBatcherHandle { shards })
(
Self { workers },
CompletionBatcherHandle {
shards,
// Each shard needs an in-flight batch and a queued batch.
pipeline_capacity: batch_size.saturating_mul(shard_count).saturating_mul(2),
},
)
}

pub fn spawn(self) -> Vec<tokio::task::JoinHandle<()>> {
Expand Down
2 changes: 2 additions & 0 deletions awa-worker/src/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,8 @@ mod tests {
storage.clone(),
crate::executor::DlqPolicy::default(),
tokio_util::task::TaskTracker::new(),
CancellationToken::new(),
1,
));
let config = QueueConfig {
max_workers: 1,
Expand Down
Loading
Loading