trustgraph/ts/packages/base/src/messaging/consumer.ts

127 lines
3.7 KiB
TypeScript
Raw Normal View History

2026-04-05 21:09:33 -05:00
/**
* High-level consumer with concurrency, retry, and rate-limit handling.
*
* Python reference: trustgraph-base/trustgraph/base/consumer.py
*/
import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js";
2026-04-05 22:44:45 -05:00
import type { Flow } from "../processor/flow.js";
2026-04-05 21:09:33 -05:00
import { TooManyRequestsError } from "../errors.js";
2026-05-12 08:06:58 -05:00
import * as S from "effect/Schema";
2026-04-05 21:09:33 -05:00
export type MessageHandler<T> = (
message: T,
properties: Record<string, string>,
flow: FlowContext,
) => Promise<void>;
2026-05-12 08:06:58 -05:00
export interface FlowContext<Requirements = never> {
2026-04-05 21:09:33 -05:00
id: string;
name: string;
2026-04-05 22:44:45 -05:00
/** Reference to the owning Flow instance, giving handlers access to producers and parameters. */
2026-05-12 08:06:58 -05:00
flow: Flow<Requirements>;
2026-04-05 21:09:33 -05:00
}
export interface ConsumerOptions<T> {
pubsub: PubSubBackend;
topic: string;
subscription: string;
handler: MessageHandler<T>;
concurrency?: number;
initialPosition?: "latest" | "earliest";
rateLimitRetryMs?: number;
rateLimitTimeoutMs?: number;
}
2026-06-01 20:26:47 -05:00
declare const ConsumerMessageType: unique symbol;
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
export interface Consumer<T> {
readonly [ConsumerMessageType]?: (_: T) => T;
readonly start: (flow: FlowContext) => Promise<void>;
readonly stop: () => Promise<void>;
}
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
export function makeConsumer<T>(options: ConsumerOptions<T>): Consumer<T> {
let backend: BackendConsumer<T> | null = null;
let running = false;
let abortController = new AbortController();
const concurrency = options.concurrency ?? 1;
const rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000;
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
const handleWithRetry = async (msg: Message<T>, flow: FlowContext): Promise<void> => {
try {
await options.handler(msg.value(), msg.properties(), flow);
} catch (err) {
if (S.is(TooManyRequestsError)(err)) {
console.warn(`[Consumer] Rate limited, retrying in ${rateLimitRetryMs}ms`);
await sleep(rateLimitRetryMs);
await options.handler(msg.value(), msg.properties(), flow);
} else {
throw err;
}
2026-04-05 21:09:33 -05:00
}
2026-06-01 20:26:47 -05:00
};
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
const consumeLoop = async (flow: FlowContext): Promise<void> => {
while (running) {
let msg: Message<T> | null = null;
2026-04-05 21:09:33 -05:00
try {
2026-06-01 20:26:47 -05:00
const currentBackend = backend;
if (currentBackend === null) throw new Error("Consumer backend not started");
2026-05-12 08:06:58 -05:00
2026-06-01 20:26:47 -05:00
msg = await currentBackend.receive(2000);
2026-05-12 08:06:58 -05:00
if (msg === null) continue;
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
await handleWithRetry(msg, flow);
await currentBackend.acknowledge(msg);
2026-04-05 21:09:33 -05:00
} catch (err) {
2026-06-01 20:26:47 -05:00
if (!running) break;
2026-04-05 21:09:33 -05:00
console.error("[Consumer] Error in consume loop:", err);
2026-05-12 08:06:58 -05:00
if (msg !== null) {
try {
2026-06-01 20:26:47 -05:00
const currentBackend = backend;
if (currentBackend !== null) {
await currentBackend.negativeAcknowledge(msg);
2026-05-12 08:06:58 -05:00
}
} catch (nakErr) {
console.error("[Consumer] Failed to nak message:", nakErr);
}
}
2026-04-05 21:09:33 -05:00
await sleep(1000);
}
}
2026-06-01 20:26:47 -05:00
};
return {
start: async (flow) => {
backend = await options.pubsub.createConsumer<T>({
topic: options.topic,
subscription: options.subscription,
initialPosition: options.initialPosition ?? "latest",
});
running = true;
// Spawn concurrent consumer tasks.
const tasks = Array.from({ length: concurrency }, () =>
consumeLoop(flow),
);
// Run all concurrently: first rejection stops all.
await Promise.all(tasks);
},
stop: async () => {
running = false;
abortController.abort();
abortController = new AbortController();
if (backend !== null) {
await backend.close();
backend = null;
2026-04-05 21:09:33 -05:00
}
2026-06-01 20:26:47 -05:00
},
};
2026-04-05 21:09:33 -05:00
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}