Spec: Job Queue — States, Leases & Reaper¶
File: .ai/specs/core-engine/2026-07-02-job-queue-leases.md Status: Draft Owners: Storage lane Related: PR-030/031; composite-key-layout spec (jobs CF, nil-tenant namespace); tombstone-cascade spec (cascade journal rides this queue)
1. Overview¶
Defines job states, the lease protocol that guarantees single-consumer processing without distributed locks, retry/backoff and poison handling, and the reaper that reclaims work from dead workers. Serves ingestion jobs, embed jobs, cascade journals, and system jobs (nil-tenant namespace).
2. Problem Statement¶
Stateless workers polling shared work need exactly one guarantee: no job is processed by two workers simultaneously, and no job is lost when a worker dies mid-flight. Without a defined state machine, "leased" jobs from crashed workers hang forever; without poison handling, one malformed document loops a worker eternally; without atomic lease acquisition, two pollers grab the same job.
3. Proposed Solution¶
State machine pending → leased → completed | failed(retryable) | dead, where every transition is an atomic state-move: one WriteBatch deletes the old state-keyed row and puts the new one (the jobs CF is operational state, not history — the append-only doctrine governs version CFs, not this queue; the audit trail is the job's event log embedded in its value). Leases carry a TTL (default 300s) extended by Heartbeat; the reaper (background task, interval 30s) moves expired leases back to pending with attempt+1 and exponential backoff (base 10s, factor 4, cap 1h), dead-lettering after max_attempts (default 5).
4. Architecture¶
enqueue ─▶ [pending] ─PollJobs(atomic move)─▶ [leased(worker,deadline)]
▲ │Heartbeat: extend
│reaper: TTL expired, attempts<max ├─Submit ok──▶ [completed]
│ ├─Submit err─▶ [failed]→backoff→[pending]
└───────────────────────────────────┘attempts==max─▶ [dead] (+ alert metric)
5. Data Models¶
Key per composite-key-layout spec: [scope][state 1][priority 1][createdAt 8][jobId 16] — ready-job discovery is a bounded prefix scan on [scope][pending], FIFO within priority. Value: { kind, payload(msgpack), attempts, worker_id?, lease_deadline?, events: [(ts, transition, detail)], idempotency_key }. Job kinds v1: ingest:{format}, embed, cascade, system:{task}. System jobs use SystemScope (nil namespace).
6. API Contracts¶
Engine-internal: enqueue, lease_next(scope_filter, kinds) -> Option<Job> (atomic move), extend(job_ids, worker), complete(job, result), fail(job, reason, retryable). gRPC surface per grpc-contracts spec (PollJobs stream wraps lease_next long-poll; Heartbeat wraps extend; Submit wraps complete/fail). Lease conflicts (stale worker submits after reap) return ABORTED; the submission is discarded (the re-leased attempt owns the job).
7. UI/UX¶
Operator: telha jobs ls --state dead, telha jobs retry <id> (dead→pending, resets attempts), telha jobs show <id> (event log). Dead-letter count exported as an alerting metric.
8. Configuration¶
jobs.lease_ttl (300s), jobs.reaper_interval (30s), jobs.max_attempts (5), jobs.backoff{base,factor,cap}, per-kind overrides allowed.
9. Alternatives Considered¶
Redis/BullMQ for core-internal jobs (rejected: core must not depend on external infrastructure; BullMQ remains app-layer-only). Append-only job history in the queue CF itself (rejected: state-scan efficiency requires state-in-key; history preserved in the value's event log instead). Fencing tokens on Submit (adopted in minimal form: lease generation number checked on submit — covers the stale-worker race without full fencing machinery).
10. Implementation Approach¶
PR-030: this spec → model + key layout → operations with lease-generation check → reaper → contention and recovery tests. PR-031 exposes gRPC. Cascade journal (tombstone-cascade spec) enqueues as kind=cascade with its own resume payload.
11. Migration Path¶
Greenfield. Kind payloads are msgpack with a version byte (same evolution rule as version-record-model spec).
12. Success Metrics¶
Contention test: N concurrent pollers, zero double-leases across 100k jobs. Recovery: kill-9 worker, job re-leased after TTL, completes on retry. Poison: malformed job dead-letters after exactly max_attempts with full event log. Stale-submit: reaped worker's late Submit rejected via lease generation.
13. Open Questions¶
- OQ-1: Per-tenant fairness — can one tenant's 1M-job backlog starve others under a global poller? Stance: v1 pollers round-robin known active tenant scopes; measure, and revisit with weighted fair queuing in Phase 4 (EPIC-403 territory).
14. Changelog¶
- 2026-07-02 — v0.1: initial draft for peer review.
- 2026-07-05 — v0.2 (PR-060 additions, both additive): (1)
JobRecord.max_attempts: Option<u32>— per-job attempt ceiling overridingjobs.max_attempts, set viaenqueue_with_max_attempts(clarify jobs budget one attempt per ranked candidate + the steward escalation; old rows decode with the global default). (2) Per-kind backoff-base overrides (jobs.backoff_base_secs_by_kind, internal map, not an env key): an override is both base and at-least-cap, soclarify:temporalretries pace at the escalation window instead of the generic exponential schedule. Consulted by both the fail path and the reaper.