Client pattern — producer + consumer skeletons
Why this exists / when to use what
This file is the copy-paste reference for new producers and consumers that talk to the federation broker. The shape is taken verbatim from the production reference impls (
services/ocr-api/src/amqp.tsfor the producer,services/ocr-worker/src/amqp.tsfor the consumer). Both files are ~100 lines each — small enough to duplicate per project rather than factor into a shared library. Premature factoring across repo boundaries is worse than measured duplication.Use these skeletons when standing up a new worker (
fraud-enricher-v2,edgar-cik-resolver,edgar-filing-ingester). Readbroker.mdfirst for topology andenvelope.mdfor payload shapes.
Connection requirements (recap)
| Setting | Value | Reason |
|---|---|---|
heartbeat |
600s | OCR / SEC downloads can stall a channel > 60s. See Cane audit. |
| Channel | createConfirmChannel() |
Producer needs confirms; consumer benefits from the same channel API. |
| Prefetch | 1 | Slow in-flight job must not stall other reserved messages. |
| Ack mode | manual | Ack only after the writer write succeeds. |
delivery_mode |
2 (persistent) | Survive broker restart. |
MAX_ATTEMPTS |
5 (env override) | After this, nack(requeue=false) → DLX. |
Producer skeleton
// producer.ts — generic federation broker producer.
//
// Pattern (copied from services/ocr-api/src/amqp.ts):
// - One confirm channel.
// - Reconnect on close (idempotent connect()).
// - publish() awaits the confirm — fail-loud on broker hiccup.
import amqplib, { type Channel, type ChannelModel } from "amqplib";
import { randomUUID } from "node:crypto";
export interface EnvelopeV1<P = unknown> {
version: 1; id: string; ts: number; attempt: number; payload: P;
}
const URL_REDACT = /:\/\/[^:]+:[^@]+@/;
export interface ProducerOpts {
url: string; // amqp://federation:****@node-eleven:5672/federation
exchange: string; // "federation.work"
routingKey: string; // queue name, e.g. "ocr.jobs" / "fraud.rows"
}
export class AmqpProducer {
private conn: ChannelModel | null = null;
private channel: Channel | null = null;
constructor(private opts: ProducerOpts) {}
redactedUrl(): string { return this.opts.url.replace(URL_REDACT, "://***:***@"); }
isReady(): boolean { return !!this.channel; }
async connect(): Promise<void> {
if (this.channel) return;
const heartbeat = Number(process.env.AMQP_HEARTBEAT_SEC ?? 600);
const conn = await amqplib.connect(this.opts.url, { heartbeat });
const channel = await conn.createConfirmChannel();
conn.on("close", () => { this.conn = null; this.channel = null; });
conn.on("error", (e) => console.error(`[amqp.producer] ${e.message}`));
this.conn = conn; this.channel = channel;
console.log(`[amqp.producer] connected exchange=${this.opts.exchange} rk=${this.opts.routingKey} heartbeat=${heartbeat}s`);
}
async publish<P>(payload: P, attempt = 1): Promise<EnvelopeV1<P>> {
if (!this.channel) await this.connect();
const env: EnvelopeV1<P> = {
version: 1,
id: randomUUID(),
ts: Math.floor(Date.now() / 1000),
attempt,
payload,
};
const buf = Buffer.from(JSON.stringify(env));
await new Promise<void>((resolve, reject) => {
this.channel!.publish(
this.opts.exchange,
this.opts.routingKey,
buf,
{ contentType: "application/json", deliveryMode: 2, messageId: env.id, correlationId: env.id },
(err) => err ? reject(err) : resolve(),
);
});
return env;
}
async close(): Promise<void> {
try { await this.channel?.close(); } catch {}
try { await this.conn?.close(); } catch {}
this.channel = null; this.conn = null;
}
}
Consumer skeleton
// consumer.ts — generic federation broker consumer.
//
// Pattern (copied from services/ocr-worker/src/amqp.ts):
// - One channel, prefetch=1, manual ack.
// - Heartbeat 600s — non-negotiable.
// - safeAck/safeNack swallow IllegalOperationError if the channel
// closed between handler-await and ack call.
// - Transient failure → republish with attempt+1, ack original.
// This keeps retries off the DLX so the broker UI shows queue-depth
// fluctuation, not DLQ growth.
// - attempt > MAX_ATTEMPTS → nack(requeue=false), broker DLX-routes
// to federation.dlx → <domain>.dlq for human inspection.
import amqplib, { type Channel, type ChannelModel, type ConsumeMessage } from "amqplib";
export interface EnvelopeV1<P = unknown> {
version: 1; id: string; ts: number; attempt: number; payload: P;
}
const URL_REDACT = /:\/\/[^:]+:[^@]+@/;
export interface ConsumerOpts {
url: string;
exchange: string; // "federation.work"
queue: string; // "ocr.jobs" / "fraud.rows" / ...
routingKey: string; // == queue name
prefetch?: number; // default 1
}
export type HandlerResult = "ack" | "retry" | "dlq";
export class AmqpConsumer {
private conn: ChannelModel | null = null;
private channel: Channel | null = null;
constructor(private opts: ConsumerOpts) {}
redactedUrl(): string { return this.opts.url.replace(URL_REDACT, "://***:***@"); }
async connect(): Promise<void> {
if (this.channel) return;
const heartbeat = Number(process.env.AMQP_HEARTBEAT_SEC ?? 600);
const conn = await amqplib.connect(this.opts.url, { heartbeat });
const channel = await conn.createConfirmChannel();
await channel.prefetch(this.opts.prefetch ?? 1);
await channel.checkQueue(this.opts.queue); // existence check only — do NOT redeclare
conn.on("close", () => { this.conn = null; this.channel = null; });
conn.on("error", (e) => console.error(`[amqp.consumer] ${e.message}`));
this.conn = conn; this.channel = channel;
console.log(`[amqp.consumer] connected queue=${this.opts.queue} prefetch=${this.opts.prefetch ?? 1} heartbeat=${heartbeat}s`);
}
private safeAck(msg: ConsumeMessage): void { try { this.channel?.ack(msg); } catch (e) { console.warn(`[amqp] safe-ack: ${(e as Error).message}`); } }
private safeNack(msg: ConsumeMessage, requeue: boolean): void {
try { this.channel?.nack(msg, false, requeue); } catch (e) { console.warn(`[amqp] safe-nack: ${(e as Error).message}`); }
}
async consume<P>(handler: (env: EnvelopeV1<P>) => Promise<HandlerResult>): Promise<void> {
if (!this.channel) await this.connect();
await this.channel!.consume(this.opts.queue, async (msg) => {
if (!msg) return;
let env: EnvelopeV1<P>;
try {
env = JSON.parse(msg.content.toString("utf-8"));
} catch (e) {
console.error(`[consumer] bad JSON, sending to DLQ: ${(e as Error).message}`);
return this.safeNack(msg, false);
}
if (env.version !== 1) {
console.error(`[consumer] unknown envelope.version=${(env as any).version}, sending to DLQ`);
return this.safeNack(msg, false);
}
try {
const result = await handler(env);
if (result === "ack") return this.safeAck(msg);
if (result === "dlq") return this.safeNack(msg, false);
if (result === "retry") {
// Republish with attempt+1, then ack the original.
// Caller's handler must NOT have done a partial side-effect
// it can't repeat (e.g. don't half-write to SQLite then retry).
// …producer.publish(env.payload, env.attempt + 1)…
return this.safeAck(msg);
}
} catch (e) {
console.error(`[consumer] handler threw: ${(e as Error).message}`);
return this.safeNack(msg, false);
}
}, { noAck: false });
}
async close(): Promise<void> {
try { await this.channel?.close(); } catch {}
try { await this.conn?.close(); } catch {}
this.channel = null; this.conn = null;
}
}
Handler skeleton (worker side)
const consumer = new AmqpConsumer({
url: process.env.AMQP_URL!,
exchange: "federation.work",
queue: "fraud.rows",
routingKey: "fraud.rows",
});
const producer = new AmqpProducer({
url: process.env.AMQP_URL!,
exchange: "federation.work",
routingKey: "fraud.rows",
});
const MAX_ATTEMPTS = Number(process.env.MAX_ATTEMPTS ?? 5);
await consumer.consume<FraudRowPayload>(async (env) => {
const { id, attempt, payload } = env;
try {
// 1. Do the work (may take minutes — heartbeat 600s covers it).
await enrichFraudRow(payload);
// 2. Persist outcome via the writer.
// (Same shape as services/ocr-worker/src/writer-client.ts)
await writer.finishFraudRow(payload.corpus_id, payload.row_no, result);
return "ack";
} catch (e) {
console.error(`[handler] ${id} attempt=${attempt} failed: ${(e as Error).message}`);
if (attempt >= MAX_ATTEMPTS) {
await writer.failFraudRow(payload.corpus_id, payload.row_no, (e as Error).message);
return "dlq";
}
await producer.publish(payload, attempt + 1); // transient retry
return "retry";
}
});
Operational notes
Heartbeat audit lesson
services/ocr-worker/src/amqp.ts had a heartbeat fix landed in commit
19eb545. The same bug was independently observed on the
edgar-filing-ingester (Cane audit 2026-05-19, finding 2,
/tmp/cane-audit-report.md). Any new consumer that omits the 600s
heartbeat will hit this within hours of going to prod. It is not a
"maybe", it is "when".
DLQ replay
When a message lands in <domain>.dlq:
Inspect via mgmt UI (
http://node-eleven:15672/#/queues/federation/ocr.dlq).If the failure was a fixable bug — fix the worker, redeploy, then shovel the DLQ back to its source queue:
ssh rooot@node-eleven 'docker exec gpumon-rabbitmq rabbitmqadmin -V federation \ get queue=ocr.dlq ackmode=ack_requeue_false count=100 -f raw_json' \ | jq -r '.[].payload' | while read env; do # republish via your producer, attempt=1 … doneIf the failure was data-poison (malformed PDF, dead CIK), leave it in the DLQ. It is a forensic record.
Reconnect resilience
Both skeletons leave the close / error handlers no-op on the
connection itself — the next connect() call re-establishes. Wrap
your top-level driver in a while(true) with a 5s backoff:
for (;;) {
try {
await consumer.consume(handler);
// consume() returns immediately; the handler callback drives the loop
await new Promise(r => setTimeout(r, 60_000)); // keep-alive
} catch (e) {
console.error(`[driver] fatal: ${(e as Error).message}`);
await consumer.close();
await new Promise(r => setTimeout(r, 5000));
}
}
Cross-references
- Reference producer impl:
services/ocr-api/src/amqp.ts - Reference consumer impl:
services/ocr-worker/src/amqp.ts - Writer client impl (the "persist outcome" side):
services/ocr-worker/src/writer-client.ts - Envelope spec:
envelope.md - Broker topology:
broker.md - Audit lesson (heartbeat bug):
/tmp/cane-audit-report.md