fix(queue): don't count never-executed transient releases against max_attempts
`claim` pre-increments `attempt` before the handler runs, so a real execution failure legitimately counts toward `max_attempts`. But the two TRANSIENT release paths — gate saturation (server at capacity) and script-disabled-at- fire-time — re-queue the message WITHOUT executing it, and previously used the same `nack` that leaves the pre-increment in place. Under sustained overload a message could therefore be dead-lettered after N gate-rejections having run zero times (each rejection burning a retry). Add a non-counting `release` to both `QueueRepo` and `GroupQueueRepo` (re-queue + `attempt = GREATEST(attempt - 1, 0)`, mirroring `nack` otherwise) and route the two transient paths through a new `Dispatcher::q_release`. A real handler failure still uses `nack` and counts. No schema change. Pinned by a new `queue_release` DB test (release undoes the claim increment; nack keeps it) and the updated `disabled_queue_consumer_...` dispatcher test (now asserts a release, not a nack). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,16 @@ pub trait GroupQueueRepo: Send + Sync {
|
||||
retry_delay: chrono::Duration,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// TRANSIENT release (handler never ran): re-queue AND undo `claim`'s
|
||||
/// pre-increment of `attempt`, so a non-execution doesn't burn the retry
|
||||
/// budget. Mirrors `queue_repo::release`. Floors `attempt` at 0.
|
||||
async fn release(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
retry_delay: chrono::Duration,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// §11.6 D3: a message exhausted its attempts — move it to the group dead-
|
||||
/// letter store (`group_dead_letters`) and delete it from the live queue, in
|
||||
/// one transaction (mirrors `queue_repo::dead_letter`). Filtered by
|
||||
@@ -221,6 +231,30 @@ impl GroupQueueRepo for PostgresGroupQueueRepo {
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn release(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
retry_delay: chrono::Duration,
|
||||
) -> Result<bool, GroupQueueRepoError> {
|
||||
// TRANSIENT release (handler never ran): re-queue AND undo `claim`'s
|
||||
// pre-increment of `attempt` so a non-execution doesn't burn the retry
|
||||
// budget. Mirrors `queue_repo::release`. Floors at 0.
|
||||
let next = Utc::now() + retry_delay;
|
||||
let res = sqlx::query(
|
||||
"UPDATE group_queue_messages \
|
||||
SET claim_token = NULL, claimed_at = NULL, deliver_after = $3, \
|
||||
attempt = GREATEST(attempt - 1, 0) \
|
||||
WHERE id = $1 AND claim_token = $2",
|
||||
)
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.bind(next)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user