Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ Notable changes between releases. Detailed migration notes for storage transitio

- Queue-storage job lists without a kind filter select receipt candidate IDs before loading their ready bodies. Kind filters still apply before the limit.

### Fixed

- Bound outstanding worker finalization to the larger of runtime worker capacity
and twice the completion batch size times the shard count. Finished handlers
retain their worker slot while waiting for finalizer capacity, preventing an
unbounded backlog of tasks retaining job payloads when durable completion falls
behind. Attempt heartbeats remain active until finalization settles.
- Complete retried queue-storage jobs and jobs carrying metadata by compact claim
batch identity and item index, avoiding a scan of every retained claim batch.

## [0.6.14] — 2026-11-08

Recommended for everyone on 0.6.13 using queue storage. A busy queue no longer stalls promotion of the others, prune backs off cleanly under lock contention, new queue lanes are usable by every runtime role, and jobs no longer stay running, run again or get misreported after priority aging.
Expand Down
100 changes: 85 additions & 15 deletions awa-model/src/queue_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2118,6 +2118,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 @@ -6978,14 +7034,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 @@ -7012,18 +7076,23 @@ 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 NOT EXISTS (
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
WHERE closures.claim_slot = claim_batches.claim_slot
Expand Down Expand Up @@ -7060,7 +7129,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 @@ -7152,6 +7220,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
2 changes: 2 additions & 0 deletions awa-worker/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,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