Skip to content

Commit 154ca19

Browse files
mpstatonclaude
andcommitted
fix(record-surrealdb-resolver): a stuck or malformed message can no longer take a NATS subject down
Closes the defect behind `domain.list: timeout`, which made reach-edu's corpora look missing. They were never missing — all nine were in SurrealDB, reachable in about a second. The service had lost the ability to answer anything on that subject, for every client, and only a restart recovered it. Three compounding defects in the consumer idiom, all closed here. A subscription consumed with `for await` is strictly sequential, so one awaited call that never settles halts the subject permanently — later messages are delivered by NATS and dropped, silently. `msg.json()` sat outside the try, so a single malformed payload threw out of the loop and killed the consumer while the NATS subscription stayed registered and monitoring still reported the subject healthy. And the loop lived in a bare `void (async () => {})()`, so its death was an unhandled rejection with nothing in the logs. New nats-loop.ts owns the parse, a per-message deadline, the always-answer guarantee, and the loop's own death. It takes any AsyncIterable of reply-shaped messages, which is what makes these failure modes testable — the old bug was unreachable from the existing suite because every test went through a real SurrealDB and none exercised the loop. Deliberately still sequential. Processing concurrently would also fix head-of-line blocking, but it would reorder writes on subjects like domain.create.requested, and that semantic change is not needed: bounded sequential turns "dead forever" into "one slow message", which is the actual bug. getDb() is now bounded and shares one in-flight handshake. surrealdb's connect() against a WSS endpoint has no deadline of its own, so a stalled handshake never settled, `db` was never assigned, and the loop parked on it. The 10s ceiling is a wide margin over the ~1.1s measured against Surreal Cloud from inside the container. Because every consumer in the file begins with `await getDb()`, this one change closes the observed hang for all ten of them. Five paths now route through serveSubject and get all four protections: domain.list, domain.assemble, tag.suggest, domain.create, and both source.fetch/source.retry — the last of which had no try block at all, so any throw killed both subjects instantly. The other five keep their hand-written bodies with the parse moved inside the try and a .catch() on the consumer; they have no per-message deadline, which the issue records rather than glosses. Verified live against the running stack after rebuilding the container: reach-edu returns its 9 domains, humain-vc 7, unfiltered 16. A malformed payload answers ok:false in 3ms and the subject keeps serving — the exact regression. 9 new unit tests, and 87 tests across 7 suites all passing. Only this service changed. The same idiom appears across the other NATS services; nats-loop.ts was written to be liftable and that rollout is tracked separately. Fixes #83 Files changed: - services/record-surrealdb-resolver/src/nats-loop.ts (new) - services/record-surrealdb-resolver/test/nats-loop.test.ts (new) - services/record-surrealdb-resolver/src/surreal.ts - services/record-surrealdb-resolver/src/domains.ts - context-v/issues/One-Stuck-Message-Kills-A-NATS-Subject-Until-Restart.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019a8tSPbFdvF1pKtADnWyDg
1 parent b87541d commit 154ca19

5 files changed

Lines changed: 421 additions & 80 deletions

File tree

context-v/issues/One-Stuck-Message-Kills-A-NATS-Subject-Until-Restart.md

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ authors:
77
- Michael Staton
88
augmented_with:
99
- Claude Code on Claude Opus 5
10-
semantic_version: 0.0.0.1
10+
semantic_version: 0.0.1.0
11+
date_first_published: 2026-08-08
1112
tags:
1213
- Issue
1314
- Augment-It
1415
- NATS
1516
- Record-SurrealDB-Resolver
1617
- Resilience
1718
- Error-Handling
18-
status: Open · Diagnosed
19+
status: Shipped
1920
---
2021

2122
# One stuck message kills a NATS subject until restart
@@ -153,14 +154,50 @@ services, so this is a **pattern-level** defect rather than a single-file one
153154
4. **Never let the consumer die silently.** Attach a `.catch()` to the loop that
154155
logs, so if it ever does exit there is a line in the logs instead of silence.
155156

156-
## Verification
157+
## Verification — done 2026-08-08
157158

158-
- Unit coverage for the three defects: a payload that is not valid JSON, a
159-
`getDb()` that never settles, and a slow request that must not block a fast one
160-
behind it.
161-
- `domain.list` answers for `reach-edu` (9 domains), `humain-vc`, and an empty
162-
filter.
163-
- Full suite green.
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.
164201

165202
## Related
166203

services/record-surrealdb-resolver/src/domains.ts

Lines changed: 88 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import { type NatsConnection } from '@nats-io/transport-node';
1919
import type { Surreal } from 'surrealdb';
2020
import { getDb } from './surreal';
21+
import { serveSubject } from './nats-loop';
2122

2223
// --- helpers ---------------------------------------------------------------
2324

@@ -381,22 +382,18 @@ export async function applyTag(
381382
// --- NATS handler registration --------------------------------------------
382383

383384
export function registerDomainHandlers(nc: NatsConnection): void {
385+
// serveSubject owns the parse, the per-message deadline, the always-answer
386+
// guarantee, and the loop's own death. See ./nats-loop.ts — the bare
387+
// `for await` this replaces is what silently took domain.list out of service
388+
// for a whole process lifetime.
384389
const handle = <T>(subject: string, fn: (db: Surreal, args: T) => Promise<unknown>): void => {
385-
void (async () => {
386-
const sub = nc.subscribe(subject);
387-
for await (const msg of sub) {
388-
const args = msg.json() as T;
389-
try {
390-
const db = await getDb();
391-
await ensureDomainSchema(db);
392-
const result = await fn(db, args);
393-
if (msg.reply) msg.respond(JSON.stringify({ ok: true, ...(result as object) }));
394-
} catch (err: unknown) {
395-
const error = err instanceof Error ? err.message : String(err);
396-
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
397-
}
398-
}
399-
})();
390+
void serveSubject<T>(subject, nc.subscribe(subject), async (args) => {
391+
const db = await getDb();
392+
await ensureDomainSchema(db);
393+
return fn(db, args);
394+
}).catch((err: unknown) => {
395+
console.error(JSON.stringify({ level: 'error', subject, msg: 'consumer exited', error: String(err) }));
396+
});
400397
};
401398

402399
// Curator liveness — fire-and-forget broadcast after a mutation commits, so
@@ -414,39 +411,39 @@ export function registerDomainHandlers(nc: NatsConnection): void {
414411

415412
// domain.create — DB upsert + write the filesystem index.md (content-ingest,
416413
// filesystem-authoritative). Cross-service request over NATS.
417-
void (async () => {
418-
const sub = nc.subscribe('domain.create.requested');
419-
for await (const msg of sub) {
420-
const args = msg.json() as { type: string; slug: string; title: string; client_slug: string; tags?: string[]; actor?: Actor };
421-
try {
422-
const db = await getDb();
423-
await ensureDomainSchema(db);
424-
const { domain } = await createDomain(db, args);
425-
const created_at = new Date().toISOString().slice(0, 10);
426-
const reply = await nc.request(
427-
'corpus.domain.write_index.requested',
428-
JSON.stringify({
429-
client_slug: args.client_slug,
430-
type: domain.type,
431-
slug: domain.slug,
432-
title: domain.title,
433-
client_slugs: domain.client_slugs,
434-
tags: domain.tags,
435-
created_at,
436-
created_by: args.actor?.didi_id ?? null,
437-
}),
438-
{ timeout: 15_000 },
439-
);
440-
const fileRes = reply.json() as { ok: boolean; corpus_path?: string; error?: string };
441-
if (!fileRes.ok) throw new Error(`index.md write failed: ${fileRes.error ?? 'unknown'}`);
442-
broadcast('domain.created', { type: domain.type, slug: domain.slug, client_slug: args.client_slug, actor: args.actor ?? null });
443-
if (msg.reply) msg.respond(JSON.stringify({ ok: true, domain, corpus_path: fileRes.corpus_path }));
444-
} catch (err: unknown) {
445-
const error = err instanceof Error ? err.message : String(err);
446-
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
447-
}
448-
}
449-
})();
414+
void serveSubject<{ type: string; slug: string; title: string; client_slug: string; tags?: string[]; actor?: Actor }>(
415+
'domain.create.requested',
416+
nc.subscribe('domain.create.requested'),
417+
async (args) => {
418+
const db = await getDb();
419+
await ensureDomainSchema(db);
420+
const { domain } = await createDomain(db, args);
421+
const created_at = new Date().toISOString().slice(0, 10);
422+
const reply = await nc.request(
423+
'corpus.domain.write_index.requested',
424+
JSON.stringify({
425+
client_slug: args.client_slug,
426+
type: domain.type,
427+
slug: domain.slug,
428+
title: domain.title,
429+
client_slugs: domain.client_slugs,
430+
tags: domain.tags,
431+
created_at,
432+
created_by: args.actor?.didi_id ?? null,
433+
}),
434+
{ timeout: 15_000 },
435+
);
436+
const fileRes = reply.json() as { ok: boolean; corpus_path?: string; error?: string };
437+
if (!fileRes.ok) throw new Error(`index.md write failed: ${fileRes.error ?? 'unknown'}`);
438+
broadcast('domain.created', { type: domain.type, slug: domain.slug, client_slug: args.client_slug, actor: args.actor ?? null });
439+
return { domain, corpus_path: fileRes.corpus_path };
440+
},
441+
// Wider than the default: this one makes a cross-service NATS request with
442+
// its own 15s timeout, then a filesystem write on the other side.
443+
{ timeoutMs: 28_000 },
444+
).catch((err: unknown) => {
445+
console.error(JSON.stringify({ level: 'error', subject: 'domain.create.requested', msg: 'consumer exited', error: String(err) }));
446+
});
450447

451448
handle('domain.list.requested', listDomains);
452449
handle('domain.assemble.requested', assembleDomain);
@@ -463,8 +460,8 @@ export function registerDomainHandlers(nc: NatsConnection): void {
463460
void (async () => {
464461
const sub = nc.subscribe('domain.retype.requested');
465462
for await (const msg of sub) {
466-
const args = msg.json() as { type: string; slug: string; new_type: string; actor?: Actor };
467463
try {
464+
const args = msg.json() as { type: string; slug: string; new_type: string; actor?: Actor };
468465
const db = await getDb();
469466
await ensureDomainSchema(db);
470467
const { domain } = await retypeDomain(db, args);
@@ -489,15 +486,17 @@ export function registerDomainHandlers(nc: NatsConnection): void {
489486
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
490487
}
491488
}
492-
})();
489+
})().catch((err: unknown) => {
490+
console.error(JSON.stringify({ level: 'error', file: 'domains.ts', msg: 'consumer exited', error: String(err) }));
491+
});
493492

494493
// source.add — DB registry + usage, then cross-call content-ingest to Jina-fetch
495494
// metadata and write the per-source file. Update the registry title + usage path.
496495
void (async () => {
497496
const sub = nc.subscribe('source.add.requested');
498497
for await (const msg of sub) {
499-
const args = msg.json() as { url: string; domain_type: string; domain_slug: string; client_slug: string; actor?: Actor };
500498
try {
499+
const args = msg.json() as { url: string; domain_type: string; domain_slug: string; client_slug: string; actor?: Actor };
501500
const db = await getDb();
502501
await ensureDomainSchema(db);
503502
const { source } = await addSource(db, args);
@@ -531,7 +530,9 @@ export function registerDomainHandlers(nc: NatsConnection): void {
531530
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
532531
}
533532
}
534-
})();
533+
})().catch((err: unknown) => {
534+
console.error(JSON.stringify({ level: 'error', file: 'domains.ts', msg: 'consumer exited', error: String(err) }));
535+
});
535536

536537
type SourceRef = { source_uuid: string; domain_type: string; domain_slug: string; client_slug: string; actor?: Actor };
537538
const usageOf = async (db: Surreal, a: SourceRef) =>
@@ -575,14 +576,22 @@ export function registerDomainHandlers(nc: NatsConnection): void {
575576
return { ok: false, error: err instanceof Error ? err.message : String(err) };
576577
}
577578
}
579+
// runSourceFetch already returns {ok:false} rather than throwing, but the
580+
// parse and the respond sat outside any guard — one malformed payload took
581+
// both fetch subjects down permanently. serveSubject owns that now.
582+
// runSourceFetch's own {ok} shape is preserved by returning it directly:
583+
// serveSubject spreads the result, and an explicit ok:false in the payload
584+
// overrides the wrapper's ok:true.
578585
const fetchHandler = (subject: string, noCache: boolean): void => {
579-
void (async () => {
580-
const sub = nc.subscribe(subject);
581-
for await (const msg of sub) {
582-
const res = await runSourceFetch(msg.json() as SourceRef, noCache);
583-
if (msg.reply) msg.respond(JSON.stringify(res));
584-
}
585-
})();
586+
void serveSubject<SourceRef>(
587+
subject,
588+
nc.subscribe(subject),
589+
(ref) => runSourceFetch(ref, noCache),
590+
// Jina fetches a remote URL through content-ingest; give it room.
591+
{ timeoutMs: 28_000 },
592+
).catch((err: unknown) => {
593+
console.error(JSON.stringify({ level: 'error', subject, msg: 'consumer exited', error: String(err) }));
594+
});
586595
};
587596
fetchHandler('source.fetch.requested', false);
588597
fetchHandler('source.retry.requested', true);
@@ -592,8 +601,8 @@ export function registerDomainHandlers(nc: NatsConnection): void {
592601
void (async () => {
593602
const sub = nc.subscribe('source.remove.requested');
594603
for await (const msg of sub) {
595-
const a = msg.json() as SourceRef;
596604
try {
605+
const a = msg.json() as SourceRef;
597606
const db = await getDb();
598607
await ensureDomainSchema(db);
599608
const usage = await usageOf(db, a);
@@ -611,14 +620,16 @@ export function registerDomainHandlers(nc: NatsConnection): void {
611620
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
612621
}
613622
}
614-
})();
623+
})().catch((err: unknown) => {
624+
console.error(JSON.stringify({ level: 'error', file: 'domains.ts', msg: 'consumer exited', error: String(err) }));
625+
});
615626

616627
// source.update — patch the registry's bibliographic fields + the file frontmatter.
617628
void (async () => {
618629
const sub = nc.subscribe('source.update.requested');
619630
for await (const msg of sub) {
620-
const a = msg.json() as SourceRef & { fields: Record<string, string>; authors?: string[] };
621631
try {
632+
const a = msg.json() as SourceRef & { fields: Record<string, string>; authors?: string[] };
622633
const db = await getDb();
623634
await ensureDomainSchema(db);
624635
const fields = a.fields ?? {};
@@ -667,16 +678,18 @@ export function registerDomainHandlers(nc: NatsConnection): void {
667678
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
668679
}
669680
}
670-
})();
681+
})().catch((err: unknown) => {
682+
console.error(JSON.stringify({ level: 'error', file: 'domains.ts', msg: 'consumer exited', error: String(err) }));
683+
});
671684

672685
// source.attach — hang an operator-uploaded binary (PDF the analyst downloaded
673686
// themselves) under the source. Identity (url) is unchanged; this just sets the
674687
// content artifact + marks the usage fetched.
675688
void (async () => {
676689
const sub = nc.subscribe('source.attach.requested');
677690
for await (const msg of sub) {
678-
const a = msg.json() as SourceRef & { filename: string; content_base64: string; content_type?: string };
679691
try {
692+
const a = msg.json() as SourceRef & { filename: string; content_base64: string; content_type?: string };
680693
const db = await getDb();
681694
await ensureDomainSchema(db);
682695
const usage = await usageOf(db, a);
@@ -702,14 +715,16 @@ export function registerDomainHandlers(nc: NatsConnection): void {
702715
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
703716
}
704717
}
705-
})();
718+
})().catch((err: unknown) => {
719+
console.error(JSON.stringify({ level: 'error', file: 'domains.ts', msg: 'consumer exited', error: String(err) }));
720+
});
706721

707722
// extract.add — append a pasted extract to the source's file (needs a file = a source_slug).
708723
void (async () => {
709724
const sub = nc.subscribe('extract.add.requested');
710725
for await (const msg of sub) {
711-
const args = msg.json() as { source_uuid: string; domain_type: string; domain_slug: string; client_slug: string; kind: string; text: string; actor?: Actor };
712726
try {
727+
const args = msg.json() as { source_uuid: string; domain_type: string; domain_slug: string; client_slug: string; kind: string; text: string; actor?: Actor };
713728
const db = await getDb();
714729
await ensureDomainSchema(db);
715730
const usage = first<{ source_slug?: string }>(
@@ -740,7 +755,9 @@ export function registerDomainHandlers(nc: NatsConnection): void {
740755
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
741756
}
742757
}
743-
})();
758+
})().catch((err: unknown) => {
759+
console.error(JSON.stringify({ level: 'error', file: 'domains.ts', msg: 'consumer exited', error: String(err) }));
760+
});
744761

745762
handle('tag.suggest.requested', suggestTags);
746763

@@ -749,8 +766,8 @@ export function registerDomainHandlers(nc: NatsConnection): void {
749766
void (async () => {
750767
const sub = nc.subscribe('tag.apply.requested');
751768
for await (const msg of sub) {
752-
const a = msg.json() as { source_uuid: string; domain_type: string; domain_slug: string; client_slug: string; tag: string; op?: 'add' | 'remove'; actor?: Actor };
753769
try {
770+
const a = msg.json() as { source_uuid: string; domain_type: string; domain_slug: string; client_slug: string; tag: string; op?: 'add' | 'remove'; actor?: Actor };
754771
const db = await getDb();
755772
await ensureDomainSchema(db);
756773
const res = await applyTag(db, a);
@@ -774,5 +791,7 @@ export function registerDomainHandlers(nc: NatsConnection): void {
774791
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
775792
}
776793
}
777-
})();
794+
})().catch((err: unknown) => {
795+
console.error(JSON.stringify({ level: 'error', file: 'domains.ts', msg: 'consumer exited', error: String(err) }));
796+
});
778797
}

0 commit comments

Comments
 (0)