Cutover plan: coordination: local → redis
Status: EXECUTED 2026-08-26. Both this cutover and the §3a gpu_ids fix are live and verified in production. Kept as the record of what was done and how to roll back.
Scope: the gateway (ahu-gpu-manager). No engine repo was touched.
Execution record (2026-08-26)
§3a gpu_ids — done first. Investigation refined the finding: doc-classifier-svc: gpu_ids:[1] is CORRECT (the classifier genuinely runs on H100 GPU 1 — DeviceIDs ["1"], sole process, 1541 MiB). The other four were wrong, and three of them (cleanup-3b, tei-embeddings, gpu-server) pointed at index [1] — the classifier's own GPU — so a classifier fault would have dropped three unrelated B200-backed upstreams from rotation. gpu_ids removed from those four; doc-classifier-svc keeps its own. Verified after: qwen-35b 200 in 50 ms, cleanup-3b 200 in 34 ms.
Cutover — all 7 verification steps passed:
| step | result |
|---|---|
| V1 health | 200 |
| V2 sync path | qwen-35b 200/51 ms, cleanup-3b 200/34 ms — latency unchanged |
| V3 admission | 24 batch → 16 queued, interactive 79–83 ms, 0 queued, 0 errors |
| V4 leases | gwslots:cleanup-3b = 8 during load = batch_max exactly |
| V5 job path | POST /jobs → 202; job:<id> + job:<id>:payload present in Redis |
| V6 durability | same job id: redis → HTTP 200 after restart, local → HTTP 404 JOB_UNKNOWN |
| V7 fail-open | Redis paused: 3/3 calls HTTP 200; full recovery after unpause |
Rollback was rehearsed, not just documented — flipping back to local and forward to redis both worked in ~20 s, and the job record survived the round trip intact.
One cosmetic artefact: pausing Redis for V7 briefly marked ahu-platform-redis unhealthy on the board. It self-recovered (failing streak 0) within one healthcheck interval; fleet back to 49/49 healthy.
0. Verdict up front
The change itself is two lines of selection and a config flag. The code it switches on is the strongest part of the codebase, and it now passes against a real Redis, not just an emulator.
But be clear about what it buys, because the honest answer is "insurance, not throughput":
today (local) |
after (redis) |
|
|---|---|---|
| Gateway restart mid-job | job lost → client resubmits on JOB_UNKNOWN |
job survives, resumes |
| Second gateway instance | impossible (each would count slots separately) | supported, no extra config |
| Redis dies | unaffected | admission fails open to local (verified) |
| Throughput / latency | — | unchanged |
Exposure being insured: ~20 async jobs/day (gpu-server 12.9/day, azure-di-onprem 7.3/day). Longest observed job 300 s. So a restart at a bad moment costs at most a handful of jobs, each already recoverable by client resubmit — which the OCR engine implements (gpu-job-client.ts:303).
Do it because it removes a class of failure and unlocks HA. Don't do it expecting a performance change.
1. What actually changes
cmd/gateway/main.go, two selections:
// line 45-49
if cfg.Coordination == "redis" { pools[u.ID] = pool.NewRedisPool(rdb, u.ID, u.Slots) }
else { pools[u.ID] = pool.NewLocal(u.Slots) }
// line 95-100
if cfg.Coordination == "redis" { store = jobs.NewRedis(rdb) }
else { store = jobs.NewLocal() }
Everything else is already Redis-backed today, under coordination: local: idempotency (idem.NewRedis), the audit stream (audit.NewEmitter), the response cache (jobs.RedisCache), and refusal memory (jobs.RedisNegativeCache). Redis is already a hard dependency — this cutover does not add one, it extends an existing one.
No new config, no migration, no instance ID. Lease identity is a random per-lease ID in a ZSET keyed by expiry (gwslots:<upstream>), so a second instance joins with zero configuration.
Design worth knowing before you operate it
redisPool is two-stage. Stage 1 is an ordinary local pool (preserving class priority + FIFO); stage 2 acquires a global lease. That is why fail-open is cheap: if Redis is unreachable, stage 2 is skipped and stage 1 alone admits. Job records carry a 24 h TTL (jobs and idempotency keys alike) — more predictable than LocalStore, which keeps them until the process dies.
2. The main risk is already retired
Both Redis implementations were covered only by miniredis — an in-process Go reimplementation. The store leans on EVAL/Lua, RPOPLPUSH and WATCH/MULTI; the pool leans on Lua-scripted lease acquire/release and key expiry. An emulator agreeing with itself does not prove the real server agrees.
So I added TEST_REDIS_ADDR to both suites and ran them against real Redis 7 (appendonly):
internal/jobs 19/19 PASS (dedup, atomic claim, stale reclaim, restart durability, fan-out)
internal/pool 10/10 PASS (incl. LeaseExpiryFreesSlotAfterCrash,
TwoInstancesShareCap, FailOpenWhenRedisDown)
TestFailOpenWhenRedisDown needed real work rather than a skip — it is the property the whole decision rests on. Real-Redis mode now points the client at a closed port, which is how a dead server actually presents (connection refused), a more faithful simulation than miniredis's injected errors.
Committed on master (test/redis-path-against-real-redis). Default is still miniredis, so go test ./... needs no services.
3. Do these two FIRST — they are cheaper and one is a live landmine
3a. gpu_ids still name H100 GPUs — fix before enabling shedding
Five upstreams declare GPU indices from before the B200 migration:
qwen-35b gpu_ids: [0] # actually runs on 6 endpoints across os-b200-02
cleanup-3b gpu_ids: [1]
tei-embeddings gpu_ids: [1]
gpu-server gpu_ids: [1]
doc-classifier-svc gpu_ids: [1]
gpu_health.dcgm_url points at 192.168.83.20:9400 — the H100. Monitor.state is map[int]*gpuState keyed by bare GPU index with no host dimension, so gpu_ids: [0] means "GPU 0 on whichever DCGM is configured".
couple_faults: true is live, and Guard() is wired (main.go → reg.SetGPUGuard). So if H100 GPU 0 ever faults, the guard drops all six B200 qwen-35b endpoints from rotation — a total outage of the main model caused by an unrelated, near-idle GPU.
Probability today is low: couple_xid: false, so isFault reduces to TempC >= 90, and an idle H100 will not hit that. But the failure mode is total and the fix is a config edit. Decide the intent — either point dcgm_url at the B200 node and renumber gpu_ids, or drop gpu_ids from upstreams whose GPUs that exporter cannot see.
3b. The audit stream is unbounded
ahu.ai.audit holds 122,293 entries / ~1.15 GB, and there is no XTRIM/MAXLEN anywhere in internal/audit. Redis runs with maxmemory 0 and maxmemory-policy noeviction, in a container with no memory limit. 102 GB is free on the host, so this is a slow burn, not an emergency — but noeviction means that when it does fill, writes start failing rather than old data being dropped. Worth a MAXLEN ~ trim before leaning on Redis harder.
4. Cutover
Low-risk, reversible in under a minute. ~20 jobs/day means almost any window is quiet; still, avoid running it while an OCR batch is in flight.
Pre-flight
docker exec ahu-platform-redis redis-cli PING→PONG; confirmappendonly yes(it is).- Confirm no jobs in flight — the audit shows no
gpu-server/azure-di-onpremcalls in the last 5 minutes. cp deploy/gateway.yaml deploy/gateway.yaml.bak-pre-redis-coordination- Note the current image tag for rollback.
Change — one line in deploy/gateway.yaml:
coordination: redis # was: local
redis_url is already correct (redis://ahu-platform-redis:6379/0).
Apply
cd /home/efran/ahu-gpu-manager/deploy && docker compose up -d gateway
Confirm it took — the startup line names the mode:
gpu-server compat façade enabled (coordination=redis)
5. Verification (in order; stop and roll back on any failure)
- Health —
curl -s localhost:8200/healthz→ 200. - Sync path unaffected — one
cleanup-3bchat call → 200. - Admission still queues correctly — rerun the saturation test: 24 concurrent batch on
cleanup-3b(batch_max: 8) must yield 8 admitted / 16 queued, with interactive fired into the backlog staying < 150 ms and never queued. This is the same test that proved the local pool on 2026-08-26 (avg_queue 257 ms, max 500 ms, 0 errors); the numbers should be close, plus a small Redis round-trip. - Leases appear —
redis-cli ZCARD gwslots:cleanup-3bshould be > 0 during load and drain to 0 after. - Job path end-to-end — run one real OCR extraction; it must complete normally.
- The actual point — submit a job, restart the gateway mid-flight, confirm the job still resolves instead of returning
JOB_UNKNOWN. This is the only step that proves the cutover did anything; skipping it means shipping untested insurance. - Fail-open — optional but valuable:
docker pause ahu-platform-redis, confirm sync calls still succeed (degraded to local admission), thendocker unpause. Verified in test; worth seeing once in production.
6. Rollback
cp deploy/gateway.yaml.bak-pre-redis-coordination deploy/gateway.yaml
docker compose up -d gateway # log must read coordination=local
Cost: jobs created in Redis are orphaned and expire on their own 24 h TTL; anything in flight at the moment of rollback is lost — the same exposure as today's normal restart. No cleanup required. Leftover gwslots:* keys are harmless and expire by score.
7. What this does NOT give you
It is not HA yet. There is still one gateway container. coordination: redis makes shared state possible; actual HA is a separate phase:
- Phase A (this plan): durable jobs + shared slot accounting. Single instance. Removes restart data-loss.
- Phase B (later): a second gateway instance behind a load balancer. Needs Phase A first, but no further gateway config — leases are self-identifying, and
TestTwoInstancesShareCapcovers exactly this. The open work in Phase B is the LB and the edge nginx, not the gateway.
Also unchanged: throughput, latency, slot counts, and every shedding decision — shed_batch stays false regardless.
8. Recommended sequence
- Now / before the demo: nothing. The queue is not the demo risk (7 days: 5 of 92,652 interactive calls ever queued; zero
QUEUE_TIMEOUTin 30 days). - After the demo, cheapest first: fix
gpu_ids/dcgm_url(§3a) — a live landmine and a config-only fix. - Then: trim the audit stream (§3b).
- Then: this cutover, with §5.6 actually performed.
- Later, if HA is wanted: Phase B.