Skip to content

fix(redis): keep the reap sweep alive when a claimed payload is gone - #89

Closed
claudear wants to merge 1 commit into
mainfrom
fix/reap-missing-payload
Closed

claudear wants to merge 1 commit into
mainfrom
fix/reap-missing-payload

Conversation

@claudear

Copy link
Copy Markdown

The error

Sentry CLOUD-3R40 — RuntimeException: Queue delivery payload is missing, 100 events, thrown from Broker\Redis::reap() under maintain() on the Swoole adapter's maintenance tick.

at Utopia\Queue\Broker\Redis->reap  (src/Queue/Broker/Redis.php:457)
at Utopia\Queue\Broker\Redis->maintain (src/Queue/Broker/Redis.php:268)
at Utopia\Queue\Adapter->maintain (src/Queue/Adapter.php:226)
   (src/Queue/Adapter/Swoole.php:357)

Root cause

reap() read the ownership record and then the payload as two separate commands, and treated "owner present, payload gone" as impossible — "Only legacy payloads expire while processing" — by throwing. Two ordinary situations produce exactly that state:

  • A concurrent settle. settle.lua deletes the owner key and the job key in one atomic call. A commit landing between this sweep's GET owner and GET job leaves the sweep holding a token for a delivery that no longer exists. On a busy queue a sweep scans up to 2000 claims, two round trips each, while workers commit throughout.
  • Eviction. claim.lua stores the job key without a TTL, so under an allkeys-* maxmemory policy a claimed payload can be evicted while its owner key survives.

The throw did more than log noise: it aborted the sweep, so every claim behind the broken one went unrecovered, and where the state persisted, that queue's reaping never made progress again.

The fix

A payload-less claim cannot be requeued — there is no payload to put back. It is now parked on the dead list like an exhausted claim, through the same reclaim script the rest of the sweep uses, so the decision stays atomic:

  • ownership must still match, so a delivery another consumer already settled is left alone (the race above becomes a no-op, counted as retained);
  • a live heartbeat still wins, so a worker actually holding the delivery keeps it;
  • reclaim.lua only insists on the payload key when it is replaying a payload — the case where its absence means someone else got there first. Parking needs no payload.

Legacy claims (no ownership record) keep their existing behaviour: dropped from the processing list.

Tests

Written first, against real Redis; all three reproduced the exact exception at Redis.php:457 before the fix.

  • testReapClearsAnOwnedClaimWhosePayloadIsGone — the claim is cleared, parked on the dead list, its ownership record removed and the processing counter settled.
  • testReapKeepsSweepingPastAClaimWhosePayloadIsGone — the stranded claim behind the broken one is still recovered.
  • testAHeartbeatedClaimWithoutItsPayloadIsLeftAlone — ownership plus a live heartbeat means the sweep does not take the delivery from its worker.

Full unit suite (143) and e2e suite (149, KEDA excluded — no kind cluster locally) pass against the compose services. PHPStan reports no new errors.

🤖 Generated with Claude Code

reap() threw `Queue delivery payload is missing` whenever a claim on the
processing list had an ownership record but no payload, on the assumption
that only legacy deliveries could lose one. Two ordinary situations break
that assumption: a settle in another worker deletes the job key between
this sweep's owner read and its payload read, and maxmemory eviction can
take a claimed payload, which is stored without a TTL.

The throw aborted the whole sweep -- every claim behind the broken one
went unrecovered, and where the state persisted, maintenance never made
progress again.

A payload-less claim cannot be requeued, so it is now parked on the dead
list like an exhausted one, through the same reclaim script: ownership
still has to match and a live heartbeat still wins, so a worker actually
holding the delivery keeps it, and a claim someone else already settled
is left alone. reclaim.lua only insists on the payload key when it is
replaying a payload, which is the case where its absence means another
consumer got there first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Thanks for contributing! This repository is a read-only mirror; development for this library happens in packages/queue in the utopia-php monorepo. Please open this pull request there instead.

@github-actions github-actions Bot closed this Sep 22, 2026
@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The production change appears behaviorally sound, but the explicit repository testing requirement must be satisfied before merging.

Fix All in Claude CodeFindings

  1. P2 Tests Mirror Redis Internals ▶
Fix with agent prompt
### Issue 1
tests/Queue/E2E/Adapter/RedisBrokerRecoveryTest.php:105-106
These tests hard-code and mutate the Redis `.claims`, `.jobs`, `.owners`, and `.stats` key layout, then assert those same storage details. This violates the repository directive to test observable behavior rather than mirror source implementation or configuration. A harmless key-layout refactor would break these tests even if broker behavior remained correct. Replace the duplicated key construction and internal-state checks with behavior-oriented fault injection and broker-visible recovery outcomes. The repository requirement must be satisfied before merging; the same pattern also appears at lines 114–115, 124–125, and 141.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR keeps Redis claim reaping operational when an owned delivery loses its payload. It classifies the claim as unrecoverable, atomically parks its PID on the dead list when ownership remains valid and no heartbeat exists, and adds recovery coverage.

  • Preserves live, heartbeated claims even when their payload key is absent.
  • Allows dead parking without requiring the missing job key while retaining the payload check for normal requeueing.
  • Verifies that a missing payload no longer prevents later claims in the sweep from being recovered.
  • The new tests are excessively coupled to Redis key-layout internals and violate the repository’s explicit testing rule.

Reviews (1) · Last reviewed commit: "fix(redis): keep the reap sweep alive wh..."

Comment on lines +105 to +106
$this->expire('.claims.*');
$this->redis->del($this->namespace . '.jobs.recovery.' . $claimed->getPid());

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 Tests Mirror Redis Internals

These tests hard-code and mutate the Redis .claims, .jobs, .owners, and .stats key layout, then assert those same storage details. This violates the repository directive to test observable behavior rather than mirror source implementation or configuration. A harmless key-layout refactor would break these tests even if broker behavior remained correct. Replace the duplicated key construction and internal-state checks with behavior-oriented fault injection and broker-visible recovery outcomes. The repository requirement must be satisfied before merging; the same pattern also appears at lines 114–115, 124–125, and 141.

Context Used: Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/Queue/E2E/Adapter/RedisBrokerRecoveryTest.php
Line: 105-106

Comment:
**Tests Mirror Redis Internals**

These tests hard-code and mutate the Redis `.claims`, `.jobs`, `.owners`, and `.stats` key layout, then assert those same storage details. This violates the repository directive to test observable behavior rather than mirror source implementation or configuration. A harmless key-layout refactor would break these tests even if broker behavior remained correct. Replace the duplicated key construction and internal-state checks with behavior-oriented fault injection and broker-visible recovery outcomes. The repository requirement must be satisfied before merging; the same pattern also appears at lines 114–115, 124–125, and 141.

**Context Used:** Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

@claudear
claudear deleted the fix/reap-missing-payload branch September 22, 2026 06:36
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