Task 3 review — RedisStore (durable, HA-safe jobs.Store)
Base 9869f5e, Head bf9af49. Verified against source (internal/jobs/redisstore.go,
internal/jobs/redisstore_test.go), the plan's Task 3 spec + Global Constraints, and
internal/jobs/store.go (the interface + LocalStore it must match). Ran
go build ./..., go vet ./..., and go test -race -count=3 -v ./internal/jobs/...
myself — all green, all 11 Redis tests + 6 LocalStore tests pass 3× under -race, no
skips.
Spec Compliance — ⚠️
Implements every Store method with the specified key layout (job:{id} hash,
job:{id}:payload, jobs:ready:{class}, jobs:processing, idem:{tenant}:{key},
job:{parent}:children), SETNX dedup, RPOPLPUSH claim, reclaim-and-requeue, TTLs.
Builds/vets/tests clean, race-clean 3×. One real gap found by hand-tracing (see
Issues): an unclaimed queued job that outlives jobTTL can be claimed into a
corrupted, blank record rather than erroring — a TTL/list-lifetime mismatch the
EXISTS-guard pattern used elsewhere in this same file should have covered. Not a
duplicate-run bug (the core at-most-once invariant holds), but it's exactly the kind
of durability edge case this task exists to close, so it keeps this from a clean ✅.
Strengths
- Dedup atomicity is correctly reasoned and correctly implemented. Traced the
full race:writeRecord(hash+payload) always completes before the same
goroutine attemptsSETNX, so by the time anySETNXsucceeds, the winner's
record is guaranteed fully written — a loser'sGeton the winner's id can never
observe a half-written job. The loser's own orphan hash is deleted (redisstore.go:184)
and, critically, the loserreturns (redisstore.go:186) before reaching the
LPushenqueue step (redisstore.go:201) — so an orphan is never pushed onto a
ready list and is structurally unclaimable, even in the narrow window before the
Delruns.TestRedisCreateDedupAtomic(16 concurrent creates, race-checked)
confirms exactly one job is created and exactly one is claimable. - Claim-once is a genuine single EVAL.
claimScript(redisstore.go:101-108) does
RPOPLPUSH+ the processing-stamp +HINCRBYatomically in one round trip; Redis's
single-threaded script execution makes two concurrent claimers structurally unable
to receive the same id.TestRedisClaimOnce(8 claimers, 20 jobs, race-checked)
confirms exactly 20 claims total, each id exactly once. - ReclaimStale idempotency is correctly guarded. The
LREM ... > 0check
(redisstore.go:125) means a second scan (whether from the same replica or a
concurrent one — Lua scripts serialize on Redis) can't re-requeue an id already
moved; the boundary (heartbeat < cutoff, strict) matchesLocalStore's
Heartbeat.Before(cutoff)exactly. - Restart/HA durability is actually tested, not just asserted:
TestRedisRestartDurability
builds a secondRedisStoreagainst the same miniredis instance and confirms
record, payload, status, timestamps, and attempts all survive. - UpdateStatus WATCH/MULTI loop is correct and bounded (
updateStatusMaxRetries = 50,
redisstore.go:65/288-298) — no infinite spin; on exhaustion it returns an explicit
error rather than silently dropping the update. - Heartbeat/SetResult correctly guard against resurrecting expired jobs via an
EXISTScheck before theHSET(redisstore.go:139-151) — this is exactly the right
pattern; see Issues for where it's missing. - Good behavior parity with
LocalStore: same dedup-returns-full-existing-record
semantics, same terminal-state shape, sameClaimNext(ctx, class)signature with
the interface doc (store.go:30-34) explicitly noting the class arg is advisory
for Redis and that convergence withLocalStore's priority ordering is a Task 7
obligation (manager loops classes interactive→system→batch) — this is stated
correctly in both the interface comment and the implementation comment, not just
asserted in the report. - Test suite deviates from the brief's literal "skip if absent" wording but matches
the actual, more-recent precedent (internal/pool/redispool_test.goalready uses
miniredis.RunT(t)unconditionally, no gate/skip) — this is the right call, not a
deviation worth flagging.
Issues
Important — redisstore.go:101-108 (claimScript), :116-135 (reclaimScript): missing EXISTS guard lets a claim resurrect a TTL-expired job as a corrupted record.
job:{id} and job:{id}:payload get an absolute jobTTL (24h) set once, at
creation (redisstore.go:219,:224) — nothing refreshes it on claim, heartbeat, or
status update. But jobs:ready:{class} list entries have no TTL at all. If a
job sits unclaimed past jobTTL (batch-class starvation, a manager outage, etc. —
plausible over the lifetime of a durable HA queue, which is the entire point of this
task), its hash and payload expire while its id is still sitting in the ready list.
When it's eventually popped by claimScript, RPOPLPUSH succeeds (line 102) and the
script proceeds straight to HSET k 'status' 'processing' ... (line 105) — Redis
HSET auto-vivifies a hash that doesn't exist, silently creating a new one with
only status/started_at/heartbeat/updated_at/attempts and none of the real fields
(tenant, model, doc_hash, etc. are all ""). ClaimNext's subsequent s.Get
(line 250) then succeeds (HGetAll is non-empty) instead of returning ErrNotFound,
so the manager receives a claimed, "processing" job with blank tenant/model/payload.
Worse: Payload() (redisstore.go:339-355) checks Exists(jobKey(id)) — which is now
true, since HSET just recreated it — then does Get(payloadKey(id)), which returns
redis.Nil because the payload key really is gone, and that's mapped to
return []byte{}, nil (not an error). So the manager could dispatch an OCR call
with an empty payload for a job with no identifying fields, rather than failing
loudly. The exact same asymmetry exists in reclaimScript's HSET at line 128 (lower
risk there, since a false HGET status on a vanished hash just fails the
== 'processing' check and the id is skipped/leaked in jobs:processing, not
corrupted). This is the same class of hazard the author explicitly guarded against in
heartbeatScript/setResultScript (comment at line 43-44: "a plain HSET would
otherwise create it") — the guard just wasn't extended to claimScript/reclaimScript.
Not a duplicate-run bug (at-most-once claim still holds structurally); it's a
silent-data-corruption / silent-empty-payload bug under a real (if currently
low-probability, given the 24h TTL vs. the 30-min GPU_QUEUE_WAIT_MS) durability
edge case. Recommend: add an EXISTS check in claimScript before the HSET
stamp — on miss, LREM the id back out of jobs:processing (it's already
unrecoverable) and return false so ClaimNext treats it as "try again," rather
than handing back a corrupted job.
Minor — redisstore.go:184, :209: two fire-and-forget Redis calls with unchecked errors.
s.rdb.Del(ctx, jobKey(job.ID), payloadKey(job.ID)) (orphan cleanup on dedup loss)
and s.rdb.Expire(ctx, ck, s.jobTTL) (children-set TTL) both ignore their .Err().
Neither is a correctness issue today (the orphan is unclaimable regardless of whether
Del succeeds, since it's never enqueued; a missing children-set TTL just means it
lives to jobTTL off the last SAdd instead of refreshing) — worth a //nolint-style
comment or a debug log so a real failure isn't completely invisible, but not
blocking.
Minor — no active reaper for jobs stuck past GPU_QUEUE_WAIT_MS while still queued.
Already called out in the report as a known P2 gap ("no Delete/GC of terminal jobs
beyond TTL"). Combined with the Important issue above, this means the only thing
currently preventing the corrupted-claim scenario is "in practice nothing sits
unclaimed for 24h." Worth a one-line note in the Task 7 handoff, not a Task 3 blocker.
Cross-task note (ClaimNext error handling / class-scoping for Task 7)
- ClaimNext swallowing all Redis errors as "empty" (
redisstore.go:245-249) is real
and matches the report's own caveat. I traced it:claimScript.Run(...).Text()
returns the same(nil, false)whether the ready list is genuinely empty or Redis
is unreachable — there is no way for a caller to distinguish "no work" from
"infrastructure is down" fromClaimNext's return value alone. Note this is
qualitatively different fromredispool.go's fail-open pattern (which the report
cites as precedent):redispool's fail-open still admits work via a local
fallback pool when Redis errors;RedisStore.ClaimNext's "swallow as empty" means
no work is claimed at all — for an HA pair, a real Redis outage would silently
stall every job across every replica, with nothing in the job-store layer
distinguishing that from a quiet queue. Given CONVENTIONS' explicit requirement
that queue state be user-visible ("never a bare spinner") and that429/503are
graceful degradation rather than silent drops, a silent stall at the store layer
is the failure mode CONVENTIONS is trying to avoid, just one layer down. - My call: don't change the
Storeinterface for this alone (it would touch
LocalStoretoo, for no behavioral benefit there —LocalStorehas no analogous
failure mode). Instead, make it an explicit Task 7 obligation: (a) the manager
must not treat "N consecutive empty claims across all classes" the same as "queue
is empty, back off normally" without also tracking whether those empties are
correlated with actual Redis errors; (b) add an independent Redis health signal
(e.g., a periodicPINGalongside the pool's own health tracking, or reuse
whateverredispool.goalready surfaces for its fail-open decision) so
operators/alerts can distinguish "no work" from "store unreachable" — this should
not rely onClaimNext's return value. This should be written into the Task 7
section of the plan (or CONVENTIONS if it's meant to bind future stores too), not
left as an implicit assumption. - Class-scoping: confirmed correct and honestly documented —
ClaimNextis
genuinely per-class (onereadyKey(class)), and both theStoreinterface doc
(store.go:30-34) and the implementation comment state plainly that priority
ordering across classes only holds once Task 7's manager loops
interactive→system→batch per call. No parity gap today; just flagging that Task 7
must actually implement that loop order for the "interactive beats batch" invariant
to hold under Redis coordination —LocalStore's in-process priority scan doesn't
need this, so it's easy to forget when wiring the Redis path.
Assessment — Task quality: Needs fixes
The concurrency/atomicity primitives that were the explicit point of this task —
dedup-once, claim-once, idempotent reclaim, WATCH/MULTI optimistic updates, restart
durability — are all correctly reasoned, correctly implemented, and genuinely
exercised by race-checked concurrent tests (verified independently, not just taking
the report's word for it). That's the hard part and it's solid.
What holds this back from Approved is the claimScript/reclaimScript missing the
same EXISTS guard the author correctly applied to heartbeatScript/setResultScript
— a real, traceable path to a corrupted "processing" job with a silently-empty
payload, in a file whose entire mandate is durability under exactly this kind of
edge case (jobs that sit around for a while under HA/outage conditions). It's a
small, mechanical fix (one more EXISTS check + a cleanup branch in claimScript,
mirroring the pattern already in the same file), not a redesign. Recommend fixing
that before merge, and carrying the ClaimNext-error-handling note into Task 7 as an
explicit obligation rather than an implicit assumption.