Skip to content

fix(queue): preserve occurrence identity across recovery - #294

Merged
Joncallim merged 2 commits into
codex/pr198-producer-5a2-core-diagnosticsfrom
codex/pr198-producer-5a2-r2-occurrence-recovery
Jul 28, 2026
Merged

fix(queue): preserve occurrence identity across recovery#294
Joncallim merged 2 commits into
codex/pr198-producer-5a2-core-diagnosticsfrom
codex/pr198-producer-5a2-r2-occurrence-recovery

Conversation

@Joncallim

@Joncallim Joncallim commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Source Issue

Issue: #179

Summary

This stacked child closes the queue occurrence, ownership, recovery, replay, and shutdown gaps identified in review of PR #293 and PR #294.

  • gives every queued occurrence a closed versioned envelope with an opaque UUID, so identical jobs remain independent
  • makes claim, retry, dead-letter, promotion, acknowledgement, release, and recovery transitions atomic and ownership-aware in Redis
  • stores bounded opaque acknowledgement receipts keyed only by occurrence ID and exact claim nonce; recovery or a later worker cannot masquerade as the original acknowledgement replay
  • stores release receipts separately, so returning a post-stop claim to ready cannot manufacture acknowledgement success
  • validates complete Redis-authored claim markers and distinguishes applied, exact replay, and stale ownership
  • rotates bounded recovery pages, drains startup backlog, and revisits retained work on an independent periodic schedule
  • rechecks shutdown immediately after every blocking claim, releases post-stop claims before task lookup or business work, and lets work already across the business boundary finish before disconnect
  • adds a required real-Redis proof using the existing isolated CI Redis service and database 13

Stack

Review remediation

  • Exact acknowledgement replay now requires a server-time-scored receipt containing only occurrenceId:claimNonce.
  • Receipt cleanup is atomic, expires entries after the supported 15-minute response-loss replay window, prunes at most 100 entries per transition, and fails closed at a hard 50,000-entry cap instead of deleting a recent receipt.
  • A two-client Redis proof shows B can recover A, A remains stale before and after B's different-nonce acknowledgement, and only A's own lost-response replay becomes already_applied.
  • release() atomically returns an exact owned occurrence to ready, removes its claim marker, records a separate bounded release receipt for exact replay, and writes no acknowledgement receipt.
  • Approval, answers, and task jobs arriving only after stop() are released without task lookup, attempt creation, logs, or business processing. Existing in-flight drain behavior remains intact.
  • The 100-fresh-entry fixture reads Redis server time immediately before seeding, avoiding timing-sensitive aging after the 125-entry recovery pass.

Validation

  • npx vitest run __tests__/core-diagnostic-output-closure.test.ts — 14/14
  • disposable Redis 8.8: npx vitest run __tests__/queue-occurrence-recovery.redis.test.ts --reporter=verbose — 3/3, database empty before/after
  • npm run test:unit:zero-skip — 112 files passed and 2 environment-gated files skipped locally; 1,681 passed and 10 environment-gated tests skipped; the queue Redis file was executed separately 3/3
  • npm run lint -- --max-warnings=0
  • npx tsc --noEmit
  • npm run build
  • git diff --check

Real-Redis markers are emitted only after their matching proof completes:

  • QUEUE_OCCURRENCE_REDIS_MULTIPLICITY_OK
  • QUEUE_OCCURRENCE_REDIS_RECOVERY_OK
  • QUEUE_OCCURRENCE_REDIS_SHUTDOWN_OK

Mutation evidence

Temporary local mutations, all restored before commit, proved the focused suite fails when:

  • acknowledgement replay stops requiring the exact receipt lookup;
  • any one of the three immediate post-claim shutdown guards is removed;
  • occurrence identity, full marker validation, periodic recovery, or stale-owner failure from the earlier review pass is weakened.

Scope

Only the two queue/runtime implementation files, the existing focused closure test, and the narrow real-Redis queue proof are changed. Redis listeners, documentation, workflows, database schema, and parent review threads are untouched.

@Joncallim
Joncallim marked this pull request as ready for review July 28, 2026 12:05
@Joncallim
Joncallim merged commit f589d99 into codex/pr198-producer-5a2-core-diagnostics Jul 28, 2026
5 of 6 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8d911a10e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/worker/queue.ts
Comment on lines +750 to +753
try {
this.parseEnvelope(raw)
} catch {
throw new Error('Queue retry occurrence is invalid')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Convert legacy retry entries before parsing envelopes

During an upgrade from the parent version, the retry sorted set can contain pending raw job objects because the old retry() implementation stored JSON.stringify(nextJob) rather than an envelope. Rejecting every such member here leaves it permanently due; runtime.ts awaits promoteDueRetries() without recovery, so one legacy retry terminates the worker and causes the same failure after every restart. Accept legacy jobs for claim-time conversion or migrate them atomically before enforcing envelopes.

Useful? React with 👍 / 👎.

Comment thread web/worker/runtime.ts
Comment on lines +524 to +526
queueRecoveryTimer = setInterval(
() => void recoverQueueWork(),
Math.min(stuckJobRecoveryMs, MAX_QUEUE_RECOVERY_INTERVAL_MS),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Renew active claims before running periodic recovery

When processTask, processApproval, or processAnsweredQuestions runs longer than stuckJobRecoveryMs—15 minutes by default—this timer invokes recovery even though claim timestamps are never renewed. Recovery then removes the live claim and requeues its occurrence; the original terminal transition fails as stale, and this or another worker executes the same business operation again, potentially duplicating the local repository edits that Forge workers can apply by default. Add a cross-worker-safe lease heartbeat or otherwise prove owner liveness before periodic recovery.

AGENTS.md reference: AGENTS.md:L30-L35

Useful? React with 👍 / 👎.

Comment thread web/worker/runtime.ts
Comment on lines +657 to 661
if (!shuttingDown) {
shuttingDown = true
clearWorkerTimers()
}
await done.catch(() => {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a bounded Redis-outage shutdown path

If Redis becomes unavailable while a claim, promotion, recovery, or transition command is pending, stop() can now wait forever because queue clients use maxRetriesPerRequest: null and are disconnected only after run() reaches its finally block. The previous implementation disconnected them from stop(), so SIGINT/SIGTERM could unblock the loop; preserve graceful in-flight completion, but impose a deadline followed by cancellation or disconnect.

Useful? React with 👍 / 👎.

Comment thread web/worker/queue.ts
Comment on lines +797 to +800
job = this.parse(raw)
} catch {
throw new Error('Queue recovery found an invalid occurrence envelope')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Quarantine malformed entries instead of aborting recovery

If an invalid ready payload reaches the processing list and the worker exits after BRPOPLPUSH but before its normal discard completes, this branch throws without removing or rotating that entry. Each periodic scan eventually encounters the same poison entry and aborts, leaving valid stale occurrences behind it stranded indefinitely. Quarantine or rotate the malformed entry while retaining fail-closed validation so recovery can continue.

Useful? React with 👍 / 👎.

@Joncallim
Joncallim deleted the codex/pr198-producer-5a2-r2-occurrence-recovery branch August 2, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant