Skip to content

Commit 2aa4e62

Browse files
authored
Merge pull request #86 from lossless-group/fix/nats-handler-resilience
Fix: a stuck or malformed message can no longer take a NATS subject down
2 parents 50c8451 + 154ca19 commit 2aa4e62

5 files changed

Lines changed: 582 additions & 71 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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

Comments
 (0)