feat(redis): durable dequeue via claims for the sorted-set transport - #412
feat(redis): durable dequeue via claims for the sorted-set transport#412Abhinav-kodes wants to merge 29 commits into
Conversation
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
shimib
left a comment
There was a problem hiding this comment.
Thanks for working on this! The lease-claiming architecture is a great addition to the durability story.
I've performed a deep dive into the implementation and verified the mechanics against our test suites. I found a few critical race/data-loss issues (items 1–5) and some minor regressions/nits.
I've pushed a fully tested reference branch with surgical fixes for the blocking items (1–4). You can pull or inspect the clean commits directly here: https://github.com/shimib/llm-d-async/tree/fix/claim-issues
🚨 Blocking / Critical Issues (Requesting Changes)
1. The global dedup marker breaks legitimate request-ID reuse (Silent Result Loss)
- Location:
pkg/redis/claim.go:59-61 - Issue:
terminalKeyisresult-terminal:<request ID>, and IDs are producer-supplied. The repo has an explicit contract that request IDs may be safely reused across submissions (e.g.,redis_sortedset_producer.go:206-208clears stale cancellation markers precisely for this reason). With the current PR, a second submission reusing an ID withinresultDedupTTL(6h) has its terminal result suppressed as a "duplicate" in Lua, causing the client to poll forever. - Fix: Key the marker on
ID + RequestToken(result.Routing.RequestTokenis freshly random per enqueue, and redelivered copies carry the same token JSON so crash dedup still works). If empty, fall back to ID. Also namespace the key per queue to prevent cross-queue collisions.
2. Heartbeat renews foreign leases indefinitely (Deadlock on Crash)
- Location:
pkg/redis/claim.go:156-164 - Issue:
RENEWCLAIMis not token-guarded. If Instance A fails/parks a request to retry, it keeps the ID in its localclaimTokensmap. The retry flusher moves it back to pending, and Instance B claims it (overwriting the owner token in Redis). A's heartbeater continues to callrenewClaimevery tick. Because the script does not check the owner token, A's stale heartbeats succeed, constantly updating the index. If B crashes mid-flight, the lease never lapses because healthy A is renewing it indefinitely, wedging the request until the deadline grace finally terminates it. - Fix: Token-guard
RENEWCLAIM(if HGET owners[id] ~= token then return -1), and haveheartbeatClaimsdelete the localclaimTokensandretryOwnedhandles upon receiving a mismatch (-1), preventing both deadlocks and memory leaks.
3. retryOwned is never cleared when a retried request is re-claimed
- Location:
pkg/redis/claim.go:201-222 - Issue:
claimRequeststores the new handle but doesn't deleteretryOwned. If a request is claimed again on the same instance after a retry cycle, it remains marked asretryOwned. On graceful shutdown,sweepUnackedClaimswill skip sweeping it, forcing it to wait out its full 5-minute lease on Redis before redelivery. - Fix: Delete the ID from
r.retryOwnedupon successfulclaimRequest.
4. async_claim_depth reads double
- Location:
pkg/redis/claim.go:326 - Issue: The claimed hash holds two fields per request (
<id>and<id>:score), soHLenreports double the real claim count. - Fix: Use
ZCard(keys.idx)which accurately tracks unique active claims.
5. The deadline+30s lease cap races real results against DEADLINE_EXCEEDED
- Location:
pkg/redis/claim.go:189-196 - Issue: Once a running request goes 30s past its original deadline, the heartbeat can no longer renew the lease (expiry is capped in the past). The reclaimer sweeps it and pushes a
DEADLINE_EXCEEDEDterminal record. The real result (which may still complete successfully before the worker request timeout) is then suppressed as a duplicate. Onmain, late successes were safely delivered. - Fix: Set
reclaimGraceAfterDeadlineto comfortably exceed the worker's request timeout, or exempt active, in-flight (non-retryOwned) renewals from the deadline cap.
⚠️ Non-Blocking & Follow-ups
6. Double pending copies on retry queue crash
If a crash occurs while a request sits in the retry queue, the reclaimer will redeliver the original claimed payload (its lease lapses) while the retry-queue copy independently re-enters pending via the flusher. The dedup marker bounds the damage, but this should be added to the tradeoffs section of durable-dequeue.md.
7. Transient cross-instance quota double-count
gate.Apply runs before claimRequest, so multiple instances peeking the same head entry will both reserve quota before one loses the claim and releases it. This is a defensible tradeoff to avoid claiming churn, but deserves a brief code comment indicating it is deliberate.
8. Result flushing batching regression
flushResultBatch went from a single pipelined LPUSH block to serial EVALSHA round-trips per result. Under batch-workloads, this serial execution introduces a heavy performance regression. Pipelining these script calls using a Redis Pipeliner is highly recommended.
9. Deadline-expired metric double-counts
In sortedset_impl.go, RecordExceededDeadlineReq and the "Deadline expired" log fire before the claim is won, meaning every peeking instance will double-count it. Move these statements after claimed == true.
10. Flag validation
In compat.go, durations are truncated to seconds (ClaimLeaseTTL / time.Second). Sub-second durations (e.g. 500ms) truncate to 0 and silently fallback to the 5m default. Negative durations also pass through unchecked. Durations should be validated to be
11. Docs / Rolling upgrade
The claim that "rolling upgrade is safe" is true but worth a clarifying note: old instances still run ZPOPMIN destructively during the mixed-fleet window, meaning the old loss mode will persist until the upgrade completely finishes.
hhzhang16
left a comment
There was a problem hiding this comment.
I think this needs a smaller first slice. The hard-kill bug requires a claim, an owner-fenced heartbeat, an owner-fenced terminal ack, and expiry-based redelivery. This patch additionally introduces terminal-marker GC, a separate graceful-shutdown handback protocol, retry-specific ownership state, three tuning knobs on multiple config surfaces, and three new metrics. Those extra mechanisms are where several of the correctness problems are coming from.
Could we use RequestToken as the request-generation identity and the claim token as the ownership fence, then allow ACK to push a result only when the ownership token still matches? That removes the need for terminal marker and dedup TTL entirely as stale owners simply cannot publish. For the initial fix, graceful shutdown can stop heartbeating and let the same lease-recovery path handle unacked work. Immediate shutdown handback, additional tuning, and richer observability can follow in future MRs once the core hard-kill path is proven.
| _, err := pipe.Exec(ctx) | ||
| return err | ||
| }); err == nil { | ||
| // Renew + mark retryOwned so neither the reclaimer nor the shutdown | ||
| // sweep touches a request that is merely waiting out its backoff. | ||
| for _, entry := range entries { | ||
| if entry.requestID == "" { | ||
| continue | ||
| } |
There was a problem hiding this comment.
The retry-queue write before this section of code has no failure path after retryRedisOp exhausts its attempts. The retry message is consumed, while its claim handle remains live and continues heartbeating, which can permanently orphan the request. Please stop renewal/release ownership on persistence failure, and test a sustained outage rather than only recovery within the three attempts.
There was a problem hiding this comment.
Fixed in commit 5e1b4b2
When retryRedisOp exhausts all retry attempts in flushRetryBatch (or flushResultBatch), the error path now iterates through the entries and deletes their handles from r.claimTokens. This halts heartbeat renewals, allowing the claim lease to expire in Redis so that reclaimExpiredClaims can safely redeliver the request rather than leaving it permanently orphaned.
| var renewClaimScript = redis.NewScript(` | ||
| if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then | ||
| return 0 | ||
| end |
There was a problem hiding this comment.
This renewal needs to verify the ownership token. After lease expiry and takeover, the old replica still has its local handle and calls this every heartbeat
There was a problem hiding this comment.
Implemented in commits 0fb95b7 and d15a037.
renewClaimScript now guards renewal with HGET owners vs token. If a lease was taken over, the script returns -1 (or 0 if no claim exists). In heartbeatClaims, any result where res != 1 immediately deletes the local claimHandle from claimTokens, stopping all future renewals and preventing stale extensions.
| // Durable-dequeue tuning. Only consumed by the redis-sortedset | ||
| // transport; ignored when a transport config file supplies its own values. | ||
| ClaimLeaseTTL time.Duration | ||
| ClaimReclaimInterval time.Duration | ||
| ResultDedupTTL time.Duration |
There was a problem hiding this comment.
Is there a use case/need for all three top-level knobs?
|
100% agreed, @hhzhang16. This is an incredibly elegant pivot. Shifting to a token-guarded
By token-guarding -- KEYS: claimed, owners, idx, resultList
-- ARGV: id, resultJSON, token, listTTL
if redis.call('HGET', KEYS[2], ARGV[1]) ~= ARGV[3] then
return 0 -- Stale owner: blocked from writing result
end
redis.call('LPUSH', KEYS[4], ARGV[2])
if tonumber(ARGV[4]) > 0 then
redis.call('EXPIRE', KEYS[4], tonumber(ARGV[4]))
end
redis.call('HDEL', KEYS[1], ARGV[1])
redis.call('HDEL', KEYS[1], ARGV[1] .. ':score')
redis.call('HDEL', KEYS[2], ARGV[1])
redis.call('ZREM', KEYS[3], ARGV[1])
return 1 -- Successful owner-guarded ackI strongly endorse this simplified direction for the initial fix. It makes the core hard-kill path rock-solid and much easier to review and merge! |
|
Thanks @shimib and @hhzhang16 for the detailed reviews - very helpful. I agree the first slice got too big and the extra parts are where the bugs came from. I will update this PR to the smaller slice you both suggest:
This will fix the blocking items 1-5 structurally instead of patching them one by one. Non-blocking 6-11 will be addressed separately or noted in docs. I have seen your reference branch @shimib, thanks for sharing - I will use it as reference but implement the smaller slice here and update the PR shortly. |
- Use RequestToken as generation ID with claim token ownership fence - Remove global dedup marker, sweep, retryOwned, extra knobs/metrics - Fence heartbeat renewals, fix claim depth metric, increase grace - Graceful shutdown now relies on lease expiry like hard kill Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
…e dedup helper Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
-ClaimDepth now ZCard(idx) not HLen(claimed) (double) -drop unused requestToken field and dedup TTL surface -ResultDedupTTL removed per hhzhang16 small slice Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
|
@shimib , @hhzhang16 Known limitations for this small slice (already in docs):
|
hhzhang16
left a comment
There was a problem hiding this comment.
Thanks for reducing the scope! I think there's even more to reduce. I don't fully see the point of the graceful-with-workers integration test when it comes to this MR's scope. There's also the question of the top-level flags/compat wiring and some other details I'm not sure are fully necessary right now. Also, could you update the MR's description?
| // ACKRESULT records a terminal result and drops the claim atomically. | ||
| // Only the current owner may publish; stale owners are fenced. If no claim | ||
| // exists (direct result push without prior claim, e.g., tests), the push | ||
| // is allowed. | ||
| // | ||
| // KEYS: claimed, owners, idx, resultList | ||
| // ARGV: id, resultJSON, token, listTTLSeconds | ||
| // Returns 1 when the result was recorded, 0 when fenced as stale. | ||
| var ackResultScript = redis.NewScript(` | ||
| local owner = redis.call('HGET', KEYS[2], ARGV[1]) | ||
| if owner and owner ~= ARGV[3] then | ||
| return 0 |
There was a problem hiding this comment.
This is still allowing stale owners -- if the winning owner already acked and deleted the claim, a stale owner would see no owner and proceed to publish another result. I think that this would be better solved by requiring an exact owner-token match with missing owners also returning 0
There was a problem hiding this comment.
Fixed in commit 9509245 (fix(redis): require exact owner match in ackResultScript without empty-token bypass).
ackResultScript now strictly requires an exact owner-token match (if not owner or owner ~= ARGV[3] or ARGV[3] == "" then return 0 end), fencing any missing owners or empty tokens. Test cases have been updated to register explicit claim tokens rather than relying on an empty token bypass.
| if err != nil { | ||
| return "", false, err | ||
| } | ||
| keys := newClaimKeys(queueName) |
There was a problem hiding this comment.
I don't think the requestToken comments can be resolved because this is still using ReqID() (same in line 206). This is kind of dangerous because a same-flow ID reuse can let an old result borrow the new generation's claim token and can ack the wrong claim
There was a problem hiding this comment.
This is protected across both layers in commits 0fb95b7, d15a037, and 9509245:
-
In-memory token isolation (
claimKey): In Go memory,claimTokensis keyed byclaimKey(reqID, reqToken)(reqID + "\x00" + reqToken"). When an older execution (Gen 1) completes,ackResultuses result.Routing.RequestTokenand looks up only"req-1\x00gen-1". It cannot load Gen 2's token"req-1\x00gen-2". -
Redis-level fencing (
ackResultScript): In Redis, when Gen 2 is claimed,keys.owners[reqID]is updated to Gen 2's token. When Gen 1 attempts to ack with its old tokentoken1 (or ""),ackResultScriptevaluatesif not owner or owner ~= token or token == "" then return 0 endand strictly fences it (returns 0), neither publishing the stale result nor dropping Gen 2's claim.
| if res == -1 { | ||
| r.claimTokens.Delete(id) | ||
| } |
There was a problem hiding this comment.
Local handles should be removed for any result other than 1 (including 0)
…ags/WithWorkers - claim: require exact owner==token (allow only empty+missing for direct pushes), store handles by ID+RequestToken (claimKey) so ID reuse does not borrow new generation token; heartbeat deletes on any res !=1 - retry: generation key for renew, res !=1 cleanup - server: defer --claim-lease-ttl/--claim-reclaim-interval CLI, keep JSON defaults 300/15000 via compat hardcode - integration: remove WithWorkers retry test (out of scope for hard-kill) - docs: deadline+30s->5m, flag->JSON table Fixes hhzhang16 follow-up 3 blockers; scope minimal per review Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
- docs: remove stale :score field bullet (removed in 4ee2310) - redis: revert Start to detached Background contexts per review, add StopHeartbeatForTest hook for HardKill (was using runner ctx) Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Remove ZeroWorkers graceful test (outside scope) and redisAccounting helper, keep only HardKill that directly validates claim expiry and takeover via StopHeartbeatForTest. Addresses hhzhang16 integration scope comment. Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Stale after removing result-terminal marker; now fencing. Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
…iry_loss_test.go File now contains only HardKill claim expiry/takeover test after narrowing per review; old name implied graceful shutdown. Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Address stale ‘This will be documented as’ phrasing Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
Synthesized config is passed through LoadSortedSetConfig which already applies 300/15000 defaults. Remove hardcode and test assertions per review. Addresses hhzhang16 comment on compat.go:213 Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
…SetConfig Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
…y-token bypass Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
|
Thanks @hhzhang16 and @shimib for the review! I have updated the PR with all the requested changes:
All tests (make test, race detector, integration tests) and make lint pass cleanly. Please check again when you get time! |
|
@hhzhang16 waiting for you LGTM to merge |
|
Thanks for the updates, this is looking much cleaner. Most of my earlier threads can be resolved (I seem unable to resolve them though, reached out to llm-d about it). No new comments, but the ones I still see as needing to be addressed or clarified are:
I’m fine with narrowing the documented guarantee or scope and tracking follow-ups where appropriate. Once these are addressed or clarified, I should be able to approve. |
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
|
I've replied and clarified each of the 5 points in their respective threads above. In the latest commit 382838e, I added dedicated unit tests in All tests are passing. Please let me know if this is good to merge! |
|
Thanks, I took another look at the latest update and while it's looking a lot better, I’m not quite ready to approve yet. The remaining items I see are:
Also, the hosted workflows haven’t run on the latest commit yet since it needs an approval from a maintainer @lioraron |
…ery docs Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
912d86f to
38331c5
Compare
Thanks @hhzhang16 for the review! I've updated the PR addressing all 4 points:
|
|
The specific local token-borrowing issue is fixed, but the broader durable identity concern is still there. Redis still keys the claimed payload, owner, and expiry index only by WRT the test: Flow A’s reclaimer is still running during the takeover. The test can therefore pass with A redelivering the expired claim and C consuming it, rather than proving recovery with the original owner fully inert. Also, Flow C’s consumer is not stopped during cleanup and the worker wait groups are never waited. The smallest sufficient fix here is to stop all of A’s flow loops before starting C and fully clean up both flows/workers. There also seems to be some missing cleanup in the test -- it never calls |
…w cleanup in takeover test Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
|
Thanks @hhzhang16 for pointing these out, I have updated both items in the latest commit:
|
Signed-off-by: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com>
What does this PR do?
The
redis-sortedsettransport now dequeues with apeek -> claim -> ackflow instead of a destructiveZPOPMIN:ID + RequestToken, isolating generation state and preventing old results from touching a new generation's claim.transport-configJSON usingclaim_lease_ttl_seconds(default:300, serves as crash-detection window with periodic heartbeats) andclaim_reclaim_interval_ms(default:15000), with non-negative validation.Why is this change needed?
Fixes #404. Dequeue was previously destructive (
ZPOPMIN) and lacked an in-flight recovery mechanism. Between the initial pop and result push, requests existed solely in process memory. ASIGKILL, OOM crash, or node failure silently dropped all accepted requests held by that worker.This change establishes at-least-once execution:
owners[id]==claimToken), preventing stale/expired workers from publishing.docs/guides/durable-dequeue.md.How was this tested?
pkg/redis/claim_test.go,pkg/redis/options_test.go).TestSortedSetFlow_ResultSustainedOutage_DropsClaimHandle,TestSortedSetFlow_RetrySustainedOutage_DropsClaimHandleinpkg/redis/sortedset_impl_test.go).TestLeaseExpiry_TakeoverRedeliversClaimsintest/integration/claim_expiry_loss_test.go).go test -race).Checklist
git commit -sper DCOmake testmake lintRelated Issues
Release Note