|
| 1 | +# Orphan Job Recovery Design |
| 2 | + |
| 3 | +- **Revision**: 1 |
| 4 | +- **Last Updated**: 2026-05-15 |
| 5 | +- **Issue**: [#434](https://github.com/llm-d-incubation/batch-gateway/issues/434) |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Problem |
| 10 | + |
| 11 | +When a batch-processor pod crashes (OOM kill, node eviction, pod deletion), jobs that were dequeued from the Redis priority queue can become **orphaned** — stuck in a non-terminal DB status with no processor working on them. |
| 12 | + |
| 13 | +The existing startup recovery (`recovery.go`) only handles **container restarts within the same pod** (it scans the local `emptyDir` workdir). If the pod is **replaced** (e.g., node eviction destroys `emptyDir`), those jobs are lost forever. |
| 14 | + |
| 15 | +### Orphan scenarios |
| 16 | + |
| 17 | +1. **Pod replacement**: Processor dequeues a job, then the pod is replaced. Workdir is gone; startup recovery on new pods can't find it. |
| 18 | +2. **Silent abandonment**: A worker fails to re-enqueue or transition a job to a terminal state (e.g., DB unreachable during error handling), logs the error and moves on. The pod is healthy, so startup recovery won't run. |
| 19 | + |
| 20 | +### The orphan window |
| 21 | + |
| 22 | +After `PQDequeue` atomically removes a job from Redis, if the pod crashes before the worker creates a workdir and transitions the status: |
| 23 | +- **Queue**: Job removed (won't be re-dequeued) |
| 24 | +- **Workdir**: Doesn't exist yet (no startup recovery can find it) |
| 25 | +- **DB**: Stuck with non-terminal status forever |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## Design Overview |
| 30 | + |
| 31 | +Three mechanisms work together to detect orphans, recover them, and guarantee data consistency: |
| 32 | + |
| 33 | +1. **In-flight tracker with heartbeat** — processor records job ownership in Redis, refreshes every 5 minutes |
| 34 | +2. **Orphan reconciler** (PR 2) — batch-gc periodically cross-references DB, queue, and in-flight hash to find and recover orphans |
| 35 | +3. **Compare-and-swap (CAS) on DB status transitions** — prevents a reconciled job from being overwritten by a stale processor |
| 36 | + |
| 37 | +### Data flow |
| 38 | + |
| 39 | +``` |
| 40 | +Normal path: |
| 41 | + PQDequeue → InFlightSet(jobID, podID) → process (heartbeat every 5m) |
| 42 | + → CAS terminal write → InFlightDelete(jobID) |
| 43 | +
|
| 44 | +Crash recovery: |
| 45 | + PQDequeue → InFlightSet(jobID, podID) → heartbeat → [POD CRASHES] |
| 46 | + ↓ |
| 47 | + [Reconciler cycle, ~60 min later] |
| 48 | + Query DB: non-terminal jobs |
| 49 | + Query queue: pending job IDs → jobID not in queue |
| 50 | + Query in-flight hash → heartbeat stale (>60 min old) |
| 51 | + Triage: re-enqueue or CAS to terminal |
| 52 | + InFlightDelete(jobID) → cleanup |
| 53 | +
|
| 54 | +False positive recovery (processor still alive but reconciler acted): |
| 55 | + Processor heartbeat reads DB status → sees unexpected status → cancels job context |
| 56 | + OR: Processor tries CAS terminal write → ErrConflict → processor aborts |
| 57 | +``` |
| 58 | + |
| 59 | +--- |
| 60 | + |
| 61 | +## Mechanism 1: In-Flight Tracker with Heartbeat |
| 62 | + |
| 63 | +### Redis data structure |
| 64 | + |
| 65 | +**Key**: `llmd_batch:inflight` (single Redis hash) |
| 66 | +**Fields**: job IDs |
| 67 | +**Values**: JSON `{"pid":"<hostname>","ts":<unix_seconds>}` |
| 68 | + |
| 69 | +Why a hash: `HSCAN` lets the reconciler iterate all in-flight jobs without knowing IDs upfront. Single key, no key-space pollution, `HSET`/`HDEL` are O(1). |
| 70 | + |
| 71 | +### Interface |
| 72 | + |
| 73 | +File: `internal/database/api/database.go` |
| 74 | + |
| 75 | +```go |
| 76 | +type InFlightEntry struct { |
| 77 | + ProcessorID string `json:"pid"` |
| 78 | + LastSeen int64 `json:"ts"` |
| 79 | +} |
| 80 | + |
| 81 | +type InFlightClient interface { |
| 82 | + store.BatchClientAdmin |
| 83 | + InFlightSet(ctx context.Context, jobID, processorID string) error |
| 84 | + InFlightDelete(ctx context.Context, jobID string) error |
| 85 | + InFlightGetAll(ctx context.Context) (map[string]*InFlightEntry, error) |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +Separate from `BatchPriorityQueueClient` so the reconciler gets `InFlightGetAll` without queue mutation methods. |
| 90 | + |
| 91 | +### Redis implementation |
| 92 | + |
| 93 | +File: `internal/database/redis/redis_inflight.go` |
| 94 | + |
| 95 | +- `InFlightSet`: `HSET llmd_batch:inflight <jobID> <json>` — O(1). Serves as both initial registration and heartbeat refresh. |
| 96 | +- `InFlightDelete`: `HDEL llmd_batch:inflight <jobID>` — O(1). |
| 97 | +- `InFlightGetAll`: `HSCAN llmd_batch:inflight 0 * 100` — paginated iteration. |
| 98 | + |
| 99 | +### Processor instrumentation |
| 100 | + |
| 101 | +**Registration** — after dequeue (`worker.go`): |
| 102 | +Call `InFlightSet(jobID, processorID)` immediately after `PQDequeue` succeeds. Non-fatal on error. |
| 103 | + |
| 104 | +> **Why not atomic with PQDequeue**: `PQDequeue` uses `BZMPOP` (blocking pop), which Redis forbids inside Lua scripts. The gap between the two calls is microseconds, while the reconciler runs every 60 minutes. Even if the reconciler scans in between, it would see a `validating` job and re-enqueue it — CAS prevents data corruption when a second processor picks up the duplicate. |
| 105 | +
|
| 106 | +**Heartbeat** — in `runJob()` (`job_runner.go`): |
| 107 | +A goroutine with a 5-minute ticker calls `InFlightSet` to refresh the timestamp. The goroutine stops when the job context is cancelled. |
| 108 | + |
| 109 | +**Cleanup** — deferred in `runJob()`: |
| 110 | +`InFlightDelete` as the first defer (LIFO = executes last, after terminal transitions and panic recovery). |
| 111 | + |
| 112 | +**Re-enqueue paths** (`worker.go`): |
| 113 | +All pre-launch paths that re-enqueue a job (DB fetch failure, malformed job, expired, cancelling, etc.) also call `InFlightDelete`. |
| 114 | + |
| 115 | +### Processor identity |
| 116 | + |
| 117 | +`os.Hostname()` = Kubernetes pod name. Already used in `cmd/batch-processor/main.go` for logging. |
| 118 | + |
| 119 | +--- |
| 120 | + |
| 121 | +## Mechanism 2: Orphan Reconciler |
| 122 | + |
| 123 | +> Implemented in PR 2. |
| 124 | +
|
| 125 | +Runs in **batch-gc** as a periodic scan. Default interval: 60 minutes (also used as the staleness threshold — ≥ 12× the heartbeat interval). |
| 126 | + |
| 127 | +### Algorithm |
| 128 | + |
| 129 | +1. Fetch non-terminal jobs from DB (paginated). |
| 130 | +2. Fetch queue membership via `PQGetIDs()` → `map[string]bool`. |
| 131 | +3. Fetch in-flight entries via `InFlightGetAll()`. |
| 132 | +4. For each non-terminal job: |
| 133 | + - In queue → skip (waiting to be picked up). |
| 134 | + - In-flight and not stale → skip (actively being processed). |
| 135 | + - Otherwise → orphan (triage). |
| 136 | +5. Triage: |
| 137 | + - `cancelling` → CAS to `cancelled`. |
| 138 | + - SLO expired → CAS to `expired`. |
| 139 | + - `validating` → re-enqueue. |
| 140 | + - `in_progress` / `finalizing` → CAS to `failed` (local files are lost). |
| 141 | +6. Cleanup stale in-flight entries. |
| 142 | +7. Cleanup terminal in-flight entries (processor crashed between CAS write and `InFlightDelete`). |
| 143 | + |
| 144 | +### PQGetIDs |
| 145 | + |
| 146 | +New method on `BatchPriorityQueueClient`. Implemented as a Lua script that iterates the sorted set server-side via `ZSCAN`, extracts IDs with `cjson.decode`, returns the ID list. Go side converts to `map[string]bool`. |
| 147 | + |
| 148 | +--- |
| 149 | + |
| 150 | +## Mechanism 3: Compare-and-Swap (CAS) on DB Status Transitions |
| 151 | + |
| 152 | +### Problem |
| 153 | + |
| 154 | +Without CAS, the reconciler and processor can race: the processor overwrites the reconciler's `failed` with `completed`, leading to status flicker or orphaned file records. |
| 155 | + |
| 156 | +### Design |
| 157 | + |
| 158 | +`DBUpdate` accepts an optional `expectedStatus []byte`. If non-nil, the update only proceeds if the current status matches; otherwise returns `ErrConflict`. |
| 159 | + |
| 160 | +- **PostgreSQL**: `UPDATE ... SET status = $new WHERE id = $id AND status = $expected` — 0 rows affected = conflict. |
| 161 | +- **Redis**: Lua script checks status field before writing. Single atomic call. |
| 162 | + |
| 163 | +Callers that don't need CAS pass `nil` (e.g., apiserver cancel handler). |
| 164 | + |
| 165 | +### Conflict handling |
| 166 | + |
| 167 | +Both processor and reconciler use CAS, so regardless of which writes first, the other detects the conflict and yields. |
| 168 | + |
| 169 | +--- |
| 170 | + |
| 171 | +## Risks and Mitigations |
| 172 | + |
| 173 | +| Risk | Mitigation | |
| 174 | +|------|------------| |
| 175 | +| False positive (reconciler recovers a job still being processed) | Heartbeat makes this unlikely (requires ~60 min of sustained Redis unreachability). CAS prevents data corruption. | |
| 176 | +| InFlightSet fails (Redis down at dequeue time) | Non-fatal. Reconciler detects orphan via DB + queue cross-reference. | |
| 177 | +| CAS conflict during normal operation | Only if reconciler and processor race. Processor sees `ErrConflict`, logs, aborts — no data loss. | |
| 178 | +| Large DB scan | Paginated (existing GC pattern). Active batch count bounded by throughput. | |
| 179 | +| Race: reconciler vs. startup recovery | Startup recovery runs before polling. `PQEnqueue` uses `ZADD NX` (idempotent). CAS prevents conflicting writes. | |
| 180 | +| In-flight hash unbounded | Entries ~100 bytes. Reconciler clears stale entries. | |
| 181 | + |
| 182 | +--- |
| 183 | + |
| 184 | +## Implementation Plan |
| 185 | + |
| 186 | +### PR 1: In-flight tracking + CAS + processor instrumentation |
| 187 | + |
| 188 | +Adds tracking infrastructure and processor instrumentation. Writes breadcrumbs but nothing reads them yet — zero behavioral risk. |
| 189 | + |
| 190 | +### PR 2: Orphan reconciler + GC wiring |
| 191 | + |
| 192 | +Builds reconciler logic and wires it into batch-gc. Activates orphan detection and recovery. |
| 193 | + |
| 194 | +### Follow-up: Hardening and observability |
| 195 | + |
| 196 | +- Prometheus metrics for reconciler |
| 197 | +- Reconciler dry-run mode validation |
0 commit comments