feat(jobs): harden supersede lanes — TTL refresh + fenced releaseLane - #70
Conversation
Backports two lane-baton improvements from the Zealot port (same Zealot→template directionality already used for handler-scoped laneKey, which is already present here). - watchLane now REFRESHES the lane TTL each poll while this job still holds it. Without it, a holder running past LANE_TTL_SEC (300s) lets its lane expire; the key reads back null and a later usurp by a newer job is missed for stale long-runners. An absent lane still counts as "held" (unchanged) — only a different holder is a usurp. - Add fenced `releaseLane` (atomic GET-and-DEL-if-mine via one Lua eval) and use it in enqueue.ts: if `queue.add` throws after `claimLane`, roll the claim back so a job that never got created can't leave a phantom holder that supersedes the real prior job. The fence ensures a concurrent newer claim isn't clobbered by the rollback. Tests (packages/db/src/lanes/lanes.test.ts, ioredis-mock): releaseLane is fenced (non-holder no-op, holder drops), and watchLane refreshes the TTL while holding. makeSupersedingJob suite still green (usurp path unchanged). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…vacant-lane reclaim Backports the rest of the Zealot lane hardening: claimLane atomically tombstones the displaced holder (superseded:<jobId> → usurper, 7d TTL) and stretches the claim TTL by the job's delay; makeSupersedingJob checks the tombstone at start and re-asserts a lapsed baton iff the lane is vacant (NX); releaseLane also rolls back the tombstone it created (fenced) and logs instead of swallowing failures; enqueue claims at spill time with fenced rollback, and the drain pass rolls back its claim when the re-add fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed a second commit (e493789) backporting the rest of the Zealot lane hardening, so this PR now carries the full primitive: 3. Per-job superseded tombstone (durable edge)
4. Delay-aware claim TTLThe claim happens at enqueue but is only refreshed once the job runs, so 5. Vacant-lane reclaim at job startQueue wait is unbounded, so a baton can still lapse. On start, 6. Rollbacks on every claim site + fenced tombstone cleanup
Validation
Noted divergence from Zealot (not ported)Zealot also force-mints a fresh jobId for superseding jobs (BullMQ dedupes re-adds by jobId) and skips the overflow buffer for delayed jobs. Template already mints 🤖 Generated with Claude Code |
… the usurper Re-claiming a buffered row that was displaced by a newer direct enqueue (cron, bypass, or a concurrent instance) tombstoned the usurper back — mutual tombstones meant neither job ran for 7 days. The drain now checks the row's own tombstone first and deletes displaced rows without re-adding. Also: the drain's rollback no longer releases a spill-time self-claim when the re-add fails (the baton must survive while the row stays buffered), and the spill-time claim stretches its TTL by the job delay like the other two claim sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Follow-up commit from an adversarial review of the backport: Fixed — drain could counter-tombstone a legitimate usurper (mutual tombstones → neither job runs). If a buffered row's lane was taken by a newer direct enqueue (cron/cronTrigger go direct even while overflowing, so does Fixed — drain rollback released the spill-time baton. When the re-add fails transiently, the rollback was releasing what was actually a self-claim of the baton taken at spill time, leaving the lane vacant until retry. The rollback now skips self-claims. Test: Consistency — spill-time claim now stretches its TTL by the job delay, matching the direct-enqueue and drain claim sites. Known limitation (not addressed here): if both an older and a newer job's batons lapse while queued (two >300s waits on one lane), the older job starts first, reclaims the vacant lane, and the newer one aborts — newest payload dropped. Since jobIds are uuidv7 (time-ordered), the watch/reclaim could compare ids and never let an older job usurp a newer one — but that breaks if a caller supplies a non-uuidv7
🤖 Generated with Claude Code |
reclaimLaneIfVacant let an older job re-assert a vacant lane and usurp a newer queued job whose baton also lapsed — last-wins flipped to stale-wins exactly under the congestion the lanes exist for (found independently by two reviews). reclaimLane now claims over a vacant lane OR an older holder (uuidv7 jobIds are time-ordered, so string compare = age compare) and tombstones the displaced holder; a newer holder is never touched. flushOutbox: spills accepted mid-drain after the shutdown flush gives up sat unarmed in the accumulator and their awaits hung forever — they are now rejected loudly. Also corrected the accumulate() comment that claimed the finally re-arms stragglers (it deliberately does not). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Closed the remaining open finding (independently confirmed by a second review on the Zealot side): Fixed — baton-expiry inversion (stale-wins). Also fixed — shutdown flush could strand spill awaits. Spills accepted mid-drain after
🤖 Generated with Claude Code |
|
Landed on One change applied on top ( Review notes (non-blocking):
Validation on current |
Backports two supersede-lane hardening improvements discovered while porting this primitive into Zealot (the same Zealot→template direction as the already-landed handler-scoped
laneKey— that one is already present onmain, so it's not re-touched here).1.
watchLanerefreshes the TTL while it holdsToday a holder running past
LANE_TTL_SEC(300s) lets its lane key expire. The key then reads backnull, so a later usurp by a newer job is missed for stale long-runners.watchLanenowpexpires the lane each poll whileholder === jobId. An absent (expired) lane still counts as "held" — only a different holder is a usurp (unchanged).2. Fenced
releaseLane+ enqueue rollbackenqueue.tsclaims the lane beforequeue.add, but ifaddthrows, the lane is left held by a job that never got created — a phantom holder that wrongly supersedes the real prior job. Added an atomic, fencedreleaseLane(GET-and-DEL-if-mine in one Lua eval, so a concurrent newer claim isn't clobbered) and wired the rollback intoenqueue.ts.Validation
packages/db/src/lanes/lanes.test.ts(ioredis-mock):releaseLaneis fenced (non-holder no-op; holder drops), andwatchLanerefreshes the TTL while holding → 6/6.makeSupersedingJobsuite still green (usurp path unchanged) → 6/6.Opened as a draft — initiated from a fork (no write access); flagging for review of the directionality.
🤖 Generated with Claude Code