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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class Consumer<T> {
|
|
|
|
|
private backend: BackendConsumer<T> | null = null;
|
|
|
|
|
private running = false;
|
|
|
|
|
private abortController = new AbortController();
|
2026-05-12 08:06:58 -05:00
|
|
|
private readonly options: ConsumerOptions<T>;
|
2026-04-05 21:09:33 -05:00
|
|
|
|
|
|
|
|
private readonly concurrency: number;
|
|
|
|
|
private readonly rateLimitRetryMs: number;
|
|
|
|
|
|
2026-05-12 08:06:58 -05:00
|
|
|
constructor(options: ConsumerOptions<T>) {
|
|
|
|
|
this.options = options;
|
2026-04-05 21:09:33 -05:00
|
|
|
this.concurrency = options.concurrency ?? 1;
|
|
|
|
|
this.rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async start(flow: FlowContext): Promise<void> {
|
|
|
|
|
this.backend = await this.options.pubsub.createConsumer<T>({
|
|
|
|
|
topic: this.options.topic,
|
|
|
|
|
subscription: this.options.subscription,
|
|
|
|
|
initialPosition: this.options.initialPosition ?? "latest",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.running = true;
|
|
|
|
|
|
|
|
|
|
// Spawn concurrent consumer tasks
|
|
|
|
|
const tasks = Array.from({ length: this.concurrency }, () =>
|
|
|
|
|
this.consumeLoop(flow),
|
|
|
|
|
);
|
|
|
|
|
// Run all concurrently — first rejection stops all
|
|
|
|
|
await Promise.all(tasks);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async stop(): Promise<void> {
|
|
|
|
|
this.running = false;
|
|
|
|
|
this.abortController.abort();
|
2026-05-12 08:06:58 -05:00
|
|
|
if (this.backend !== null) {
|
2026-04-05 21:09:33 -05:00
|
|
|
await this.backend.close();
|
|
|
|
|
this.backend = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async consumeLoop(flow: FlowContext): Promise<void> {
|
|
|
|
|
while (this.running) {
|
feat: add Docker entrypoints, LLM providers, pipeline hardening, workbench pages
Phase 9 — four parallel workstreams:
- Stream A: 14 Docker entrypoints for containerized deployment
- Stream B: Pipeline hardening — robust JSON parsing, LLM retry logic,
consumer negative-ack, FalkorDB test import fix
- Stream C: Azure OpenAI, OpenAI-compatible, and Mistral LLM providers
- Stream D: Workbench Prompts, Token Cost, Knowledge Cores pages +
Settings feature switches
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:22:55 -05:00
|
|
|
let msg: Message<T> | null = null;
|
2026-04-05 21:09:33 -05:00
|
|
|
try {
|
2026-05-12 08:06:58 -05:00
|
|
|
const backend = this.backend;
|
|
|
|
|
if (backend === null) throw new Error("Consumer backend not started");
|
|
|
|
|
|
|
|
|
|
msg = await backend.receive(2000);
|
|
|
|
|
if (msg === null) continue;
|
2026-04-05 21:09:33 -05:00
|
|
|
|
|
|
|
|
await this.handleWithRetry(msg, flow);
|
2026-05-12 08:06:58 -05:00
|
|
|
await backend.acknowledge(msg);
|
2026-04-05 21:09:33 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
if (!this.running) break;
|
|
|
|
|
console.error("[Consumer] Error in consume loop:", err);
|
2026-05-12 08:06:58 -05:00
|
|
|
if (msg !== null) {
|
feat: add Docker entrypoints, LLM providers, pipeline hardening, workbench pages
Phase 9 — four parallel workstreams:
- Stream A: 14 Docker entrypoints for containerized deployment
- Stream B: Pipeline hardening — robust JSON parsing, LLM retry logic,
consumer negative-ack, FalkorDB test import fix
- Stream C: Azure OpenAI, OpenAI-compatible, and Mistral LLM providers
- Stream D: Workbench Prompts, Token Cost, Knowledge Cores pages +
Settings feature switches
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:22:55 -05:00
|
|
|
try {
|
2026-05-12 08:06:58 -05:00
|
|
|
const backend = this.backend;
|
|
|
|
|
if (backend !== null) {
|
|
|
|
|
await backend.negativeAcknowledge(msg);
|
|
|
|
|
}
|
feat: add Docker entrypoints, LLM providers, pipeline hardening, workbench pages
Phase 9 — four parallel workstreams:
- Stream A: 14 Docker entrypoints for containerized deployment
- Stream B: Pipeline hardening — robust JSON parsing, LLM retry logic,
consumer negative-ack, FalkorDB test import fix
- Stream C: Azure OpenAI, OpenAI-compatible, and Mistral LLM providers
- Stream D: Workbench Prompts, Token Cost, Knowledge Cores pages +
Settings feature switches
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:22:55 -05:00
|
|
|
} catch (nakErr) {
|
|
|
|
|
console.error("[Consumer] Failed to nak message:", nakErr);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 21:09:33 -05:00
|
|
|
await sleep(1000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleWithRetry(msg: Message<T>, flow: FlowContext): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await this.options.handler(msg.value(), msg.properties(), flow);
|
|
|
|
|
} catch (err) {
|
2026-05-12 08:06:58 -05:00
|
|
|
if (S.is(TooManyRequestsError)(err)) {
|
2026-04-05 21:09:33 -05:00
|
|
|
console.warn(`[Consumer] Rate limited, retrying in ${this.rateLimitRetryMs}ms`);
|
|
|
|
|
await sleep(this.rateLimitRetryMs);
|
|
|
|
|
await this.options.handler(msg.value(), msg.properties(), flow);
|
|
|
|
|
} else {
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
}
|