think
16px
820px

Task 2 report — Durable job store (Store interface + LocalStore)

Branch: feat/gateway-p2-jobs-ocr · New package: internal/jobs
(Note: this file previously held a stale report from an earlier plan's "Task 2 / registry"; overwritten for the gateway-P2 job store task.)

Files

  • internal/jobs/job.goJob struct, JobStatus type + StatusQueued|Processing|Completed|Failed consts, clone() helper.
  • internal/jobs/store.goStore interface, ErrNotFound, LocalStore (in-memory) + NewLocal().
  • internal/jobs/store_test.go — namespace-safe tests (unique t2-* ids/tenants, filtered assertions, no bare counts).

Design — mirrors the pool.Pool Local/Redis split

Store is the durable-state interface (Redis impl is Task 3, fan-out is Task 8 — not built here). LocalStore is mutex-guarded with three maps:
- byID map[string]*Job — canonical records.
- payloads map[string][]byte — the (possibly large) submit payload is kept OUT of the Job record and stored separately, retrievable via Payload().
- idem map[tenant]map[key]id — the idempotency index.

All accessors return clone()d copies (deep-copying Pages and Result) so callers can't mutate stored state outside the lock.

Method semantics

  • Create(ctx, *Job, payload) — dedups on (Tenant, IdemKey). If the key exists, returns existing=true, copies the found job back into the caller's *Job (so job.ID points at the existing job), and does NOT insert a second record or overwrite the payload. Otherwise assigns ID = audit.NewID() (ULID, monotonic-time so CreatedAt ordering is stable), sets Status=queued, CreatedAt/UpdatedAt, stores a copy of the payload. Jobs with an empty IdemKey are never deduped (fan-out children).
  • Get / Payload — return copies; ErrNotFound on unknown id (API maps to 404 JOB_UNKNOWN).
  • ClaimNext(ctx, class) — scans all queued jobs and picks the best by (classRank, CreatedAt, ID) where classRank = interactive(0) < system(1) < batch(2) (matches pool.rank so admission and claiming agree). Transitions queued→processing, sets StartedAt+Heartbeat=now, Attempts++. The class arg is advisory (kept for Redis per-class-list parity); Local scans all classes in priority order so a second claim never re-hands a processing job.
  • Heartbeat — refreshes the liveness clock + UpdatedAt.
  • UpdateStatus(id, fn) — applies the mutation under lock, then bumps UpdatedAt.
  • SetResult(id, status, result, errorCode) — terminal write; copies the result slice.
  • ListChildren(parentID) — returns only jobs whose ParentID == parentID, sorted by CreatedAt then ID.
  • ReclaimStale(visibilityTimeout) — acks-late recovery: any processing job whose Heartbeat is older than the timeout is set back to queued (Attempts is NOT bumped here — it increments on the subsequent re-claim, per the plan's "Attempts already ++'d" wording). Returns the reclaim count.

RED → GREEN

RED (before impl): go test ./internal/jobs/ → build failed — undefined: Job / NewLocal / StatusQueued / StatusProcessing / ....
GREEN (after impl): all 6 tests pass —
- TestCreateDedup — resubmit same (tenant,key) → existing=true, same id, original payload preserved; cross-tenant same key = distinct job.
- TestClaimNextPriority — batch enqueued before interactive, interactive claimed first; second claim hands the batch job; third claim returns nothing.
- TestReclaimStale — forced-stale heartbeat re-queued and re-claimable; Attempts→2 after re-claim.
- TestListChildren — only the target parent's children; unrelated parent's child excluded.
- TestSetResultAndGet, TestGetUnknown.

Verification

  • go build ./... — OK
  • go vet ./... — OK
  • go test ./... — all packages pass (jobs 0.009s); additive, no existing package behavior changed.

Concerns / notes for later tasks

  • ClaimNext's full-map scan is O(n) — fine for Local (dev/small). Task 3's Redis impl uses per-class ready lists (BRPOPLPUSH) for O(1) claim + real cross-replica once-only semantics.
  • Attempts increments only at claim time (not at reclaim), so a job reclaimed but never re-claimed keeps its prior count — matches the plan text; confirm this matches the manager's audit attempts expectation in Task 7.