|
| 1 | +--- |
| 2 | +title: "One stuck message kills a NATS subject until restart — `domain.list: timeout` and the sequential handler loop" |
| 3 | +lede: "reach-edu's corpora stopped loading, and the cause wasn't the data or the database: every handler in domains.ts consumes its subject with a `for await` loop, so a single request that never settles blocks that subject forever. Silent, total, invisible in the logs, and only a container restart clears it." |
| 4 | +date_created: 2026-08-08 |
| 5 | +date_modified: 2026-08-08 |
| 6 | +authors: |
| 7 | + - Michael Staton |
| 8 | +augmented_with: |
| 9 | + - Claude Code on Claude Opus 5 |
| 10 | +semantic_version: 0.0.1.0 |
| 11 | +date_first_published: 2026-08-08 |
| 12 | +tags: |
| 13 | + - Issue |
| 14 | + - Augment-It |
| 15 | + - NATS |
| 16 | + - Record-SurrealDB-Resolver |
| 17 | + - Resilience |
| 18 | + - Error-Handling |
| 19 | +status: Shipped |
| 20 | +--- |
| 21 | + |
| 22 | +# One stuck message kills a NATS subject until restart |
| 23 | + |
| 24 | +## Why Care? |
| 25 | + |
| 26 | +The operator saw `domain.list: timeout` and reasonably concluded that reach-edu's |
| 27 | +corpora had failed to load. They had not. All nine were in SurrealDB the whole |
| 28 | +time, reachable in about a second. What had failed was the *service's ability to |
| 29 | +answer any question on that subject at all* — for every client, including an |
| 30 | +empty filter. |
| 31 | + |
| 32 | +This is the worst shape a failure can take in this stack: **silent, total for the |
| 33 | +affected subject, and invisible in the logs.** The container reports healthy, the |
| 34 | +NATS subscription stays registered, the process never crashes, and nothing is |
| 35 | +written to stdout. The only signal is that replies stop coming, and the only |
| 36 | +recovery is a restart. |
| 37 | + |
| 38 | +It will recur. The conditions that cause it are ordinary. |
| 39 | + |
| 40 | +## Symptom |
| 41 | + |
| 42 | +``` |
| 43 | +❯ domain.list: timeout |
| 44 | +``` |
| 45 | + |
| 46 | +Corpora absent from the reach-edu workspace. Every other surface behaving |
| 47 | +normally. |
| 48 | + |
| 49 | +## What it was not |
| 50 | + |
| 51 | +Ruled out by measurement, in this order: |
| 52 | + |
| 53 | +| Suspected | Verdict | |
| 54 | +|---|---| |
| 55 | +| reach-edu's data missing | **No.** All 9 domains present in SurrealDB | |
| 56 | +| Client-specific | **No.** `{}`, `reach-edu` and `humain-vc` all timed out | |
| 57 | +| The `registerHandlers` rename shipped the same week | **No.** `domain.list.requested` is registered by `registerDomainHandlers` in `domains.ts`, untouched by that commit | |
| 58 | +| Service not running / crashed | **No.** Container up, `RestartCount=0`, booted clean | |
| 59 | +| SurrealDB unreachable from the container | **No.** DNS + TLS OK; full connect → signin → use → query in **1,107ms** | |
| 60 | +| The full-table `UPDATE` in `ensureDomainSchema` | **No.** Measured **538ms** across 210 `sources` and 254 `source_usages` | |
| 61 | +| Nothing subscribed to the subject | **No.** A misread of the NATS monitoring payload — `subs_detail` is empty, the real key is `subscriptions_list_detail`, and the subject *was* registered | |
| 62 | + |
| 63 | +## The evidence that located it |
| 64 | + |
| 65 | +NATS monitoring, before and after a single probe request: |
| 66 | + |
| 67 | +``` |
| 68 | +BEFORE: in_msgs=4 out_msgs=15 |
| 69 | +PROBE: timeout |
| 70 | +AFTER: in_msgs=4 out_msgs=16 |
| 71 | +``` |
| 72 | + |
| 73 | +`out_msgs` incremented — the server **delivered** the message to the resolver |
| 74 | +connection. `in_msgs` did not — the service **replied with nothing**. The message |
| 75 | +arrived, was consumed, and produced no response and no log line. |
| 76 | + |
| 77 | +A `docker restart` of the service restored it immediately: first call 1,952ms |
| 78 | +cold, second 118ms, all 9 domains returned. |
| 79 | + |
| 80 | +## Root cause |
| 81 | + |
| 82 | +Every handler in `services/record-surrealdb-resolver/src/domains.ts` is |
| 83 | +registered through this shape: |
| 84 | + |
| 85 | +```ts |
| 86 | +void (async () => { |
| 87 | + const sub = nc.subscribe(subject); |
| 88 | + for await (const msg of sub) { |
| 89 | + const args = msg.json() as T; // ← OUTSIDE the try |
| 90 | + try { |
| 91 | + const db = await getDb(); // ← no timeout |
| 92 | + await ensureDomainSchema(db); |
| 93 | + const result = await fn(db, args); |
| 94 | + if (msg.reply) msg.respond(JSON.stringify({ ok: true, ...(result as object) })); |
| 95 | + } catch (err: unknown) { |
| 96 | + if (msg.reply) msg.respond(JSON.stringify({ ok: false, error: String(err) })); |
| 97 | + } |
| 98 | + } |
| 99 | +})(); |
| 100 | +``` |
| 101 | + |
| 102 | +Three defects compound into the observed behaviour. |
| 103 | + |
| 104 | +### 1. `for await` is strictly sequential — one stuck message blocks the subject |
| 105 | + |
| 106 | +The loop awaits each message's full processing before pulling the next. A single |
| 107 | +request that never settles halts the queue **permanently**. Later requests are |
| 108 | +delivered by NATS and then dropped on the floor. Nothing times the loop out, |
| 109 | +nothing retries it, nothing reports it. |
| 110 | + |
| 111 | +### 2. `getDb()` has no timeout and caches only on success |
| 112 | + |
| 113 | +```ts |
| 114 | +export async function getDb(): Promise<Surreal> { |
| 115 | + if (db) return db; |
| 116 | + const instance = new Surreal(); |
| 117 | + await instance.connect(URL); // ← can hang indefinitely |
| 118 | + await signinAndUse(instance); |
| 119 | + db = instance; // ← only reached on success |
| 120 | + return db; |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +`connect()` against a WSS endpoint (Surreal Cloud) has no deadline. If the |
| 125 | +handshake stalls — a transient network blip at container start is enough — the |
| 126 | +promise never settles, `db` is never assigned, and combined with defect 1 the |
| 127 | +subject is dead for the lifetime of the process. |
| 128 | + |
| 129 | +### 3. `msg.json()` sits outside the try block |
| 130 | + |
| 131 | +A malformed payload throws out of the `for await` loop entirely. Because the |
| 132 | +loop lives in a bare `void (async () => {})()` with no `.catch()`, that becomes |
| 133 | +an unhandled rejection and the subscription's consumer is gone — silently. The |
| 134 | +NATS subscription stays registered server-side, so the subject *looks* healthy |
| 135 | +from monitoring while nothing consumes it. |
| 136 | + |
| 137 | +## Blast radius |
| 138 | + |
| 139 | +`domains.ts`'s `handle()` covers `domain.list.requested`, |
| 140 | +`domain.assemble.requested` and `tag.suggest.requested`, plus the hand-written |
| 141 | +loops for `domain.create.requested` and `domain.retype.requested`. The same |
| 142 | +`void (async () => { for await ... })()` idiom appears across the other NATS |
| 143 | +services, so this is a **pattern-level** defect rather than a single-file one — |
| 144 | +`domains.ts` is simply where it fired first, being the busiest cold-start path. |
| 145 | + |
| 146 | +## Fix |
| 147 | + |
| 148 | +1. **Bound `getDb()`.** A connect/signin deadline that rejects rather than hangs, |
| 149 | + so the `catch` can answer `{ok:false}` and the loop moves on. |
| 150 | +2. **Move `msg.json()` inside the try**, so a malformed payload answers with an |
| 151 | + error instead of destroying the subscription. |
| 152 | +3. **Stop one message blocking the queue.** Process each message without awaiting |
| 153 | + it in the loop body, so a slow or stuck request cannot starve the others. |
| 154 | +4. **Never let the consumer die silently.** Attach a `.catch()` to the loop that |
| 155 | + logs, so if it ever does exit there is a line in the logs instead of silence. |
| 156 | + |
| 157 | +## Verification — done 2026-08-08 |
| 158 | + |
| 159 | +**Unit** — 9 new tests in `test/nats-loop.test.ts`, covering each defect against |
| 160 | +a fake subscription with no broker and no database. The production bug was |
| 161 | +unreachable from the existing suite precisely because every test went through a |
| 162 | +real SurrealDB and none exercised the loop. |
| 163 | + |
| 164 | +**Live, against the running stack** after rebuilding the container: |
| 165 | + |
| 166 | +``` |
| 167 | +domain.list reach-edu 1857ms ok=true domains=9 |
| 168 | + upward-mobility, grant-prospecting-tools, future-of-work, |
| 169 | + workforce-development, frontier-job-demand, agent-workflow-maxxing, |
| 170 | + adult-literacy-numeracy, ncad-forge, rural-income-boosts |
| 171 | +domain.list humain-vc 111ms ok=true domains=7 |
| 172 | +domain.list (no filter) 161ms ok=true domains=16 |
| 173 | +
|
| 174 | +malformed payload 3ms ok=false "not json{{" is not valid JSON |
| 175 | +domain.list reach-edu (after) 112ms ok=true domains=9 ← SUBJECT SURVIVED |
| 176 | +``` |
| 177 | + |
| 178 | +That second block is the regression itself: under the old code the malformed |
| 179 | +payload threw out of the `for await` and every later request on the subject was |
| 180 | +dropped. It now answers in 3ms and the subject keeps serving. |
| 181 | + |
| 182 | +**Suite** — 87 tests across 7 suites, all passing (`bash scripts/test-all.sh`). |
| 183 | + |
| 184 | +## What was NOT fixed here |
| 185 | + |
| 186 | +Only `record-surrealdb-resolver` was changed. The same |
| 187 | +`void (async () => { for await ... })()` idiom appears across the other NATS |
| 188 | +services, and `nats-loop.ts` was deliberately written to be liftable — it takes |
| 189 | +any `AsyncIterable` of reply-shaped messages and has no dependency on this |
| 190 | +service. Rolling it out is tracked separately. |
| 191 | + |
| 192 | +Within this service, the ten consumers landed in two states. The five paths |
| 193 | +that route through `serveSubject` (`domain.list`, `domain.assemble`, |
| 194 | +`tag.suggest`, `domain.create`, and both `source.fetch`/`source.retry`) get all |
| 195 | +four protections. The remaining five (`domain.retype`, `source.add`, |
| 196 | +`source.remove`, `source.update`, `source.attach`, `extract.add`, `tag.apply`) |
| 197 | +got the parse moved inside their `try` and a `.catch()` on the consumer, but |
| 198 | +keep their own hand-written bodies and have no per-message deadline. That is |
| 199 | +acceptable because the deadline in `getDb()` closes the observed hang for all of |
| 200 | +them — every one begins with `await getDb()` — but they are not fully hardened. |
| 201 | + |
| 202 | +## Related |
| 203 | + |
| 204 | +- [[Structural-Refactors-Surfaced-by-the-Codebase-Graph]] — the same service's |
| 205 | + `registerHandlers` rename, ruled out here |
| 206 | +- `services/record-surrealdb-resolver/src/domains.ts` |
| 207 | +- `services/record-surrealdb-resolver/src/surreal.ts` |
0 commit comments