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

205 lines
6.5 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
*/
2026-06-02 00:22:04 -05:00
import type { BackendConsumer, Message, PubSubBackend } from "../backend/types.js";
2026-04-05 22:44:45 -05:00
import type { Flow } from "../processor/flow.js";
2026-06-02 00:22:04 -05:00
import {
MessagingHandlerError,
TooManyRequestsError,
messagingDeliveryError,
messagingHandlerError,
messagingLifecycleError,
2026-06-02 06:03:36 -05:00
messagingTimeoutError,
2026-06-02 00:22:04 -05:00
} from "../errors.js";
2026-06-02 06:03:36 -05:00
import { Duration, Effect, Schedule } from "effect";
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;
2026-06-02 00:22:04 -05:00
const isTooManyRequestsError = S.is(TooManyRequestsError);
2026-06-01 20:26:47 -05:00
const concurrency = options.concurrency ?? 1;
const rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000;
2026-06-02 06:03:36 -05:00
const rateLimitTimeoutMs = options.rateLimitTimeoutMs ?? 7_200_000;
2026-04-05 21:09:33 -05:00
2026-06-02 00:22:04 -05:00
const runHandler = (
message: T,
properties: Record<string, string>,
flow: FlowContext,
): Effect.Effect<void, TooManyRequestsError | MessagingHandlerError> =>
Effect.tryPromise({
try: () => options.handler(message, properties, flow),
catch: (error) =>
isTooManyRequestsError(error)
? error
: messagingHandlerError(options.topic, options.subscription, error),
});
2026-04-05 21:09:33 -05:00
2026-06-02 00:22:04 -05:00
const handleWithRetry = Effect.fn("Consumer.handleWithRetry")(function* (
message: Message<T>,
flow: FlowContext,
) {
const callHandler = runHandler(message.value(), message.properties(), flow);
yield* callHandler.pipe(
2026-06-02 06:03:36 -05:00
Effect.tapError((error) =>
isTooManyRequestsError(error)
? Effect.logWarning("[Consumer] Rate limited, retrying", {
topic: options.topic,
subscription: options.subscription,
retryMs: rateLimitRetryMs,
})
: Effect.void,
),
Effect.retry({
schedule: Schedule.spaced(Duration.millis(rateLimitRetryMs)),
while: isTooManyRequestsError,
}),
Effect.timeoutOrElse({
duration: Duration.millis(rateLimitTimeoutMs),
orElse: () => Effect.fail(messagingTimeoutError("rate-limit", rateLimitTimeoutMs)),
}),
Effect.mapError((error) =>
isTooManyRequestsError(error)
? messagingHandlerError(options.topic, options.subscription, error)
: error,
2026-06-02 00:22:04 -05:00
),
);
});
const consumeOnce = Effect.fn("Consumer.consumeOnce")(function* (flow: FlowContext) {
const currentBackend = backend;
if (currentBackend === null) {
return yield* messagingLifecycleError(
`${options.topic}:${options.subscription}`,
"receive",
"Consumer backend not started",
);
2026-04-05 21:09:33 -05:00
}
2026-06-02 00:22:04 -05:00
const message = yield* Effect.tryPromise({
try: () => currentBackend.receive(2000),
catch: (error) => messagingDeliveryError(options.topic, "receive", error),
});
if (message === null) return;
yield* handleWithRetry(message, flow).pipe(
Effect.flatMap(() =>
Effect.tryPromise({
try: () => currentBackend.acknowledge(message),
catch: (error) => messagingDeliveryError(options.topic, "acknowledge", error),
}),
),
Effect.catch((error) =>
Effect.tryPromise({
try: () => currentBackend.negativeAcknowledge(message),
catch: (nakError) => messagingDeliveryError(options.topic, "negative-acknowledge", nakError),
}).pipe(
Effect.catch((nakError) =>
Effect.logError("[Consumer] Failed to negative-acknowledge message", {
error: nakError.message,
topic: nakError.topic,
}),
),
Effect.flatMap(() => Effect.fail(error)),
),
),
);
});
const consumeLoop = Effect.fn("Consumer.consumeLoop")(function* (flow: FlowContext) {
yield* Effect.whileLoop({
while: () => running,
body: () =>
consumeOnce(flow).pipe(
Effect.catch((error) => {
if (!running) return Effect.void;
return Effect.logError("[Consumer] Error in consume loop", {
error: error.message,
topic: options.topic,
subscription: options.subscription,
}).pipe(
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
);
}),
),
step: () => undefined,
});
});
2026-06-01 20:26:47 -05:00
return {
2026-06-02 00:22:04 -05:00
start: (flow) =>
Effect.runPromise(
Effect.gen(function* () {
backend = yield* Effect.tryPromise({
try: () =>
options.pubsub.createConsumer<T>({
topic: options.topic,
subscription: options.subscription,
initialPosition: options.initialPosition ?? "latest",
}),
catch: (error) =>
messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error),
});
2026-04-05 21:09:33 -05:00
2026-06-02 00:22:04 -05:00
running = true;
const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index);
yield* Effect.forEach(workerIndexes, () => consumeLoop(flow), {
concurrency: "unbounded",
discard: true,
});
}),
),
stop: () =>
Effect.runPromise(
Effect.gen(function* () {
running = false;
const currentBackend = backend;
backend = null;
if (currentBackend !== null) {
yield* Effect.tryPromise({
try: () => currentBackend.close(),
catch: (error) =>
messagingLifecycleError(`${options.topic}:${options.subscription}`, "close-consumer", error),
});
}
}),
),
};
2026-04-05 21:09:33 -05:00
}