diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2a39fcea..722e7519f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/awa-model/src/queue_storage.rs b/awa-model/src/queue_storage.rs
index 457e7fb96..abdb2a0c5 100644
--- a/awa-model/src/queue_storage.rs
+++ b/awa-model/src/queue_storage.rs
@@ -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
@@ -6978,14 +7034,22 @@ impl QueueStorage {
.expect("receipt-backed slow completion requires receipt_id")
})
.collect();
+ let receipt_claim_batch_ids: Vec