This commit is contained in:
elpresidank 2026-05-12 08:06:58 -05:00
parent e8c7a4f6e0
commit ffd97375a8
160 changed files with 6704 additions and 1895 deletions

View file

@ -7,6 +7,7 @@
import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js";
import type { Flow } from "../processor/flow.js";
import { TooManyRequestsError } from "../errors.js";
import * as S from "effect/Schema";
export type MessageHandler<T> = (
message: T,
@ -14,11 +15,11 @@ export type MessageHandler<T> = (
flow: FlowContext,
) => Promise<void>;
export interface FlowContext {
export interface FlowContext<Requirements = never> {
id: string;
name: string;
/** Reference to the owning Flow instance, giving handlers access to producers and parameters. */
flow: Flow;
flow: Flow<Requirements>;
}
export interface ConsumerOptions<T> {
@ -36,11 +37,13 @@ export class Consumer<T> {
private backend: BackendConsumer<T> | null = null;
private running = false;
private abortController = new AbortController();
private readonly options: ConsumerOptions<T>;
private readonly concurrency: number;
private readonly rateLimitRetryMs: number;
constructor(private readonly options: ConsumerOptions<T>) {
constructor(options: ConsumerOptions<T>) {
this.options = options;
this.concurrency = options.concurrency ?? 1;
this.rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000;
}
@ -65,7 +68,7 @@ export class Consumer<T> {
async stop(): Promise<void> {
this.running = false;
this.abortController.abort();
if (this.backend) {
if (this.backend !== null) {
await this.backend.close();
this.backend = null;
}
@ -75,17 +78,23 @@ export class Consumer<T> {
while (this.running) {
let msg: Message<T> | null = null;
try {
msg = await this.backend!.receive(2000);
if (!msg) continue;
const backend = this.backend;
if (backend === null) throw new Error("Consumer backend not started");
msg = await backend.receive(2000);
if (msg === null) continue;
await this.handleWithRetry(msg, flow);
await this.backend!.acknowledge(msg);
await backend.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[Consumer] Error in consume loop:", err);
if (msg) {
if (msg !== null) {
try {
await this.backend!.negativeAcknowledge(msg);
const backend = this.backend;
if (backend !== null) {
await backend.negativeAcknowledge(msg);
}
} catch (nakErr) {
console.error("[Consumer] Failed to nak message:", nakErr);
}
@ -99,7 +108,7 @@ export class Consumer<T> {
try {
await this.options.handler(msg.value(), msg.properties(), flow);
} catch (err) {
if (err instanceof TooManyRequestsError) {
if (S.is(TooManyRequestsError)(err)) {
console.warn(`[Consumer] Rate limited, retrying in ${this.rateLimitRetryMs}ms`);
await sleep(this.rateLimitRetryMs);
await this.options.handler(msg.value(), msg.properties(), flow);

View file

@ -2,3 +2,37 @@ export { Producer } from "./producer.js";
export { Consumer, type MessageHandler, type FlowContext, type ConsumerOptions } from "./consumer.js";
export { Subscriber, AsyncQueue } from "./subscriber.js";
export { RequestResponse, type RequestResponseOptions } from "./request-response.js";
export {
ConsumerFactory,
ConsumerFactoryLive,
FlowRuntime,
FlowRuntimeLive,
MessagingRuntimeLive,
ProducerFactory,
ProducerFactoryLive,
RequestResponseFactory,
RequestResponseFactoryLive,
makeEffectConsumerFromPubSub,
makeEffectProducerFromPubSub,
makeEffectProducerHandle,
makeEffectRequestResponseFromPubSub,
makeConsumerFactoryService,
makeProducerFactoryService,
makeRequestResponseFactoryService,
runEffectConsumerScoped,
runEffectProducerScoped,
runEffectRequestResponseScoped,
runFlowScoped,
type ConsumerFactoryService,
type EffectConsumer,
type EffectConsumerOptions,
type EffectMessageHandler,
type EffectProducer,
type EffectProducerOptions,
type EffectRequestOptions,
type EffectRequestResponse,
type EffectRequestResponseOptions,
type FlowRuntimeService,
type ProducerFactoryService,
type RequestResponseFactoryService,
} from "./runtime.js";

View file

@ -6,34 +6,44 @@
import type { PubSubBackend, BackendProducer } from "../backend/types.js";
import type { ProducerMetrics } from "../metrics/prometheus.js";
import { Effect } from "effect";
import { makeEffectProducerHandle, type EffectProducer } from "./runtime.js";
export class Producer<T> {
private backend: BackendProducer<T> | null = null;
private running = false;
private effectProducer: EffectProducer<T> | null = null;
private readonly pubsub: PubSubBackend;
private readonly topic: string;
private readonly metrics: ProducerMetrics | undefined;
constructor(
private readonly pubsub: PubSubBackend,
private readonly topic: string,
private readonly metrics?: ProducerMetrics,
) {}
constructor(pubsub: PubSubBackend, topic: string, metrics?: ProducerMetrics) {
this.pubsub = pubsub;
this.topic = topic;
this.metrics = metrics;
}
async start(): Promise<void> {
this.backend = await this.pubsub.createProducer<T>({ topic: this.topic });
this.running = true;
this.effectProducer = makeEffectProducerHandle(this.backend, {
topic: this.topic,
...(this.metrics === undefined ? {} : { metrics: this.metrics }),
});
}
async send(id: string, message: T): Promise<void> {
if (!this.backend) throw new Error("Producer not started");
if (this.effectProducer === null) throw new Error("Producer not started");
await this.backend.send(message, { id });
this.metrics?.inc();
await Effect.runPromise(this.effectProducer.send(id, message));
}
async stop(): Promise<void> {
this.running = false;
if (this.backend) {
await this.backend.flush();
await this.backend.close();
if (this.effectProducer !== null) {
await Effect.runPromise(
this.effectProducer.flush.pipe(
Effect.flatMap(() => this.effectProducer === null ? Effect.void : this.effectProducer.close),
),
);
this.effectProducer = null;
this.backend = null;
}
}

View file

@ -23,7 +23,7 @@ export class RequestResponse<TReq, TRes> {
private producer: Producer<TReq>;
private subscriber: Subscriber<TRes>;
constructor(private readonly options: RequestResponseOptions) {
constructor(options: RequestResponseOptions) {
this.producer = new Producer<TReq>(options.pubsub, options.requestTopic);
this.subscriber = new Subscriber<TRes>(
options.pubsub,
@ -77,7 +77,7 @@ export class RequestResponse<TReq, TRes> {
const response = await queue.pop(remaining);
if (recipient) {
if (recipient !== undefined) {
const isFinal = await recipient(response);
if (isFinal) return response;
} else {

View file

@ -0,0 +1,612 @@
/**
* Effect-native messaging factories and scoped runtime helpers.
*/
import { randomUUID } from "node:crypto";
import { Context, Duration, Effect, Fiber, Layer, Queue, Scope } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
import type {
BackendConsumer,
BackendProducer,
CreateConsumerOptions,
CreateProducerOptions,
Message,
} from "../backend/types.js";
import { PubSub, type PubSubService } from "../backend/pubsub.js";
import {
flowRuntimeError,
messagingDeliveryError,
messagingHandlerError,
messagingLifecycleError,
messagingTimeoutError,
TooManyRequestsError,
type FlowRuntimeError,
type MessagingDeliveryError,
type MessagingLifecycleError,
type MessagingTimeoutError,
type PubSubError,
} from "../errors.js";
import type { ProducerMetrics } from "../metrics/prometheus.js";
import type { FlowContext } from "./consumer.js";
import type { Flow } from "../processor/flow.js";
import type { SpecRuntimeRequirements } from "../spec/types.js";
import {
loadMessagingRuntimeConfig,
type MessagingRuntimeConfig,
} from "../runtime/messaging-config.js";
const isTooManyRequestsError = S.is(TooManyRequestsError);
export type EffectMessageHandler<T, E = never, R = never> = (
message: T,
properties: Record<string, string>,
flow: FlowContext<R>,
) => Effect.Effect<void, E, R>;
export interface EffectProducerOptions {
readonly topic: string;
readonly schema?: S.Top;
readonly metrics?: ProducerMetrics;
}
export interface EffectProducer<T> {
readonly send: (id: string, message: T) => Effect.Effect<void, MessagingDeliveryError>;
readonly flush: Effect.Effect<void, MessagingDeliveryError>;
readonly close: Effect.Effect<void, MessagingDeliveryError>;
}
export interface EffectConsumerOptions<T, E = never, R = never> {
readonly topic: string;
readonly subscription: string;
readonly handler: EffectMessageHandler<T, E, R>;
readonly concurrency?: number;
readonly initialPosition?: "latest" | "earliest";
readonly schema?: S.Top;
readonly receiveTimeoutMs?: number;
readonly errorBackoffMs?: number;
readonly rateLimitRetryMs?: number;
}
export interface EffectConsumer {
readonly stop: Effect.Effect<void, MessagingLifecycleError>;
readonly fibers: ReadonlyArray<Fiber.Fiber<void, never>>;
}
export interface EffectRequestResponseOptions {
readonly requestTopic: string;
readonly responseTopic: string;
readonly subscription: string;
readonly requestSchema?: S.Top;
readonly responseSchema?: S.Top;
}
export interface EffectRequestOptions<TRes, E = never, R = never> {
readonly timeoutMs?: number;
readonly recipient?: (response: TRes) => Effect.Effect<boolean, E, R>;
}
export interface EffectRequestResponse<TReq, TRes> {
readonly request: <E = never, R = never>(
request: TReq,
options?: EffectRequestOptions<TRes, E, R>,
) => Effect.Effect<TRes, MessagingDeliveryError | MessagingTimeoutError | E, R>;
readonly stop: Effect.Effect<void, MessagingLifecycleError | MessagingDeliveryError>;
}
export interface ProducerFactoryService {
readonly make: <T>(
options: EffectProducerOptions,
) => Effect.Effect<EffectProducer<T>, PubSubError, Scope.Scope>;
}
export interface ConsumerFactoryService {
readonly run: <T, E = never, R = never>(
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) => Effect.Effect<EffectConsumer, PubSubError, Scope.Scope | R>;
}
export interface RequestResponseFactoryService {
readonly make: <TReq, TRes>(
options: EffectRequestResponseOptions,
) => Effect.Effect<EffectRequestResponse<TReq, TRes>, PubSubError, Scope.Scope>;
}
export interface FlowRuntimeService {
readonly run: <Requirements = never>(
flow: Flow<Requirements>,
) => Effect.Effect<void, FlowRuntimeError, SpecRuntimeRequirements | Requirements>;
}
export class ProducerFactory extends Context.Service<ProducerFactory, ProducerFactoryService>()(
"@trustgraph/base/messaging/runtime/ProducerFactory",
) {}
export class ConsumerFactory extends Context.Service<ConsumerFactory, ConsumerFactoryService>()(
"@trustgraph/base/messaging/runtime/ConsumerFactory",
) {}
export class RequestResponseFactory extends Context.Service<
RequestResponseFactory,
RequestResponseFactoryService
>()("@trustgraph/base/messaging/runtime/RequestResponseFactory") {}
export class FlowRuntime extends Context.Service<FlowRuntime, FlowRuntimeService>()(
"@trustgraph/base/messaging/runtime/FlowRuntime",
) {}
export function makeEffectProducerHandle<T>(
backend: BackendProducer<T>,
options: EffectProducerOptions,
): EffectProducer<T> {
return {
send: Effect.fn(`Producer.send:${options.topic}`)((id: string, message: T) =>
Effect.tryPromise({
try: () => backend.send(message, { id }),
catch: (error) => messagingDeliveryError(options.topic, "send", error),
}).pipe(
Effect.tap(() =>
options.metrics === undefined
? Effect.void
: Effect.sync(() => {
options.metrics?.inc();
}),
),
),
),
flush: Effect.tryPromise({
try: () => backend.flush(),
catch: (error) => messagingDeliveryError(options.topic, "flush", error),
}),
close: Effect.tryPromise({
try: () => backend.close(),
catch: (error) => messagingDeliveryError(options.topic, "close", error),
}),
};
}
export const makeEffectProducerFromPubSub = Effect.fn("makeEffectProducerFromPubSub")(function* <T>(
pubsub: PubSubService,
options: EffectProducerOptions,
) {
const createOptions: CreateProducerOptions = options.schema === undefined
? { topic: options.topic }
: { topic: options.topic, schema: options.schema };
const backend = yield* pubsub.createProducer<T>(createOptions);
const producer = makeEffectProducerHandle(backend, options);
yield* Effect.addFinalizer(() =>
producer.close.pipe(
Effect.catch((error) =>
Effect.logError("[Producer] Failed to close producer", {
error: error.message,
topic: error.topic,
}),
),
),
);
return producer;
});
const closeConsumerBackend = <T>(
backend: BackendConsumer<T>,
topic: string,
subscription: string,
) =>
Effect.tryPromise({
try: () => backend.close(),
catch: (error) => messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error),
});
const acknowledgeMessage = <T>(
backend: BackendConsumer<T>,
message: Message<T>,
topic: string,
) =>
Effect.tryPromise({
try: () => backend.acknowledge(message),
catch: (error) => messagingDeliveryError(topic, "acknowledge", error),
});
const negativeAcknowledgeMessage = <T>(
backend: BackendConsumer<T>,
message: Message<T>,
topic: string,
) =>
Effect.tryPromise({
try: () => backend.negativeAcknowledge(message),
catch: (error) => messagingDeliveryError(topic, "negative-acknowledge", error),
});
const receiveMessage = <T>(
backend: BackendConsumer<T>,
topic: string,
timeoutMs: number,
) =>
Effect.tryPromise({
try: () => backend.receive(timeoutMs),
catch: (error) => messagingDeliveryError(topic, "receive", error),
});
const handleMessageWithRetry = Effect.fn("handleMessageWithRetry")(function* <T, E, R>(
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
message: Message<T>,
config: MessagingRuntimeConfig,
) {
const runHandler = Effect.fn(`Consumer.handler:${options.topic}`)(() =>
options.handler(message.value(), message.properties(), flow).pipe(
Effect.mapError((error) => messagingHandlerError(options.topic, options.subscription, error)),
),
);
return yield* options.handler(message.value(), message.properties(), flow).pipe(
Effect.catch((error) => {
if (isTooManyRequestsError(error)) {
return Effect.gen(function* () {
yield* Effect.logWarning("[Consumer] Rate limited, retrying", {
topic: options.topic,
subscription: options.subscription,
retryMs: config.rateLimitRetryMs,
});
yield* Effect.sleep(Duration.millis(config.rateLimitRetryMs));
yield* runHandler();
});
}
return Effect.fail(messagingHandlerError(options.topic, options.subscription, error));
}),
);
});
const processConsumerMessage = Effect.fn("processConsumerMessage")(function* <T, E, R>(
backend: BackendConsumer<T>,
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
message: Message<T>,
config: MessagingRuntimeConfig,
) {
yield* handleMessageWithRetry(options, flow, message, config).pipe(
Effect.flatMap(() => acknowledgeMessage(backend, message, options.topic)),
Effect.catch((error) =>
negativeAcknowledgeMessage(backend, message, options.topic).pipe(
Effect.catch((nakError) =>
Effect.logError("[Consumer] Failed to negative-acknowledge message", {
error: nakError.message,
topic: nakError.topic,
}),
),
Effect.flatMap(() =>
Effect.logError("[Consumer] Message handling failed", {
error: error.message,
topic: options.topic,
subscription: options.subscription,
}),
),
),
),
);
});
const consumerLoop = <T, E, R>(
backend: BackendConsumer<T>,
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
config: MessagingRuntimeConfig,
): Effect.Effect<void, never, R> =>
Effect.whileLoop({
while: () => true,
body: () =>
receiveMessage(backend, options.topic, options.receiveTimeoutMs ?? config.consumerReceiveTimeoutMs).pipe(
Effect.flatMap((message) =>
message === null
? Effect.sleep(Duration.millis(options.receiveTimeoutMs ?? config.consumerReceiveTimeoutMs))
: processConsumerMessage(backend, options, flow, message, config),
),
Effect.catch((error) =>
Effect.logError("[Consumer] Receive loop failed", {
error: error.message,
topic: options.topic,
subscription: options.subscription,
}).pipe(
Effect.flatMap(() =>
Effect.sleep(Duration.millis(options.errorBackoffMs ?? config.consumerErrorBackoffMs)),
),
),
),
),
step: () => undefined,
});
export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPubSub")(function* <T, E, R>(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) {
const createOptions: CreateConsumerOptions = {
topic: options.topic,
subscription: options.subscription,
...(options.initialPosition === undefined ? {} : { initialPosition: options.initialPosition }),
...(options.schema === undefined ? {} : { schema: options.schema }),
};
const backend = yield* pubsub.createConsumer<T>(createOptions);
const concurrency = Math.max(1, options.concurrency ?? 1);
const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index);
const fibers = yield* Effect.forEach(workerIndexes, () =>
consumerLoop(backend, options, flow, {
...config,
rateLimitRetryMs: options.rateLimitRetryMs ?? config.rateLimitRetryMs,
}).pipe(Effect.forkChild),
);
const stop = Effect.fn(`Consumer.stop:${options.topic}`)(function* () {
yield* Effect.forEach(fibers, Fiber.interrupt, { discard: true });
yield* closeConsumerBackend(backend, options.topic, options.subscription);
});
yield* Effect.addFinalizer(() =>
stop().pipe(
Effect.catch((error) =>
Effect.logError("[Consumer] Failed to stop consumer", {
error: error.message,
resource: error.resource,
operation: error.operation,
}),
),
),
);
return {
fibers,
stop: stop(),
} satisfies EffectConsumer;
});
const dispatchResponseLoop = <T>(
backend: BackendConsumer<T>,
responseTopic: string,
subscribers: Map<string, Queue.Queue<T>>,
config: MessagingRuntimeConfig,
): Effect.Effect<void> =>
Effect.whileLoop({
while: () => true,
body: () =>
receiveMessage(backend, responseTopic, config.consumerReceiveTimeoutMs).pipe(
Effect.flatMap((message) => {
if (message === null) {
return Effect.sleep(Duration.millis(config.consumerReceiveTimeoutMs));
}
const id = message.properties().id;
const queue = id === undefined ? undefined : subscribers.get(id);
return Effect.gen(function* () {
if (queue !== undefined) {
yield* Queue.offer(queue, message.value());
}
yield* acknowledgeMessage(backend, message, responseTopic);
});
}),
Effect.catch((error) =>
Effect.logError("[RequestResponse] Response dispatch failed", {
error: error.message,
topic: responseTopic,
}).pipe(Effect.flatMap(() => Effect.sleep(Duration.millis(config.consumerErrorBackoffMs)))),
),
),
step: () => undefined,
});
const waitForResponse = Effect.fn("waitForResponse")(function* <TRes, E, R>(
queue: Queue.Queue<TRes>,
options: EffectRequestOptions<TRes, E, R> | undefined,
) {
while (true) {
const response = yield* Queue.take(queue);
if (options?.recipient === undefined) {
return response;
}
const complete = yield* options.recipient(response);
if (complete) {
return response;
}
}
});
export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestResponseFromPubSub")(function* <
TReq,
TRes,
>(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
options: EffectRequestResponseOptions,
) {
const producer = yield* makeEffectProducerFromPubSub<TReq>(pubsub, {
topic: options.requestTopic,
...(options.requestSchema === undefined ? {} : { schema: options.requestSchema }),
});
const createOptions: CreateConsumerOptions = {
topic: options.responseTopic,
subscription: options.subscription,
...(options.responseSchema === undefined ? {} : { schema: options.responseSchema }),
};
const backend = yield* pubsub.createConsumer<TRes>(createOptions);
const subscribers = new Map<string, Queue.Queue<TRes>>();
const fiber = yield* dispatchResponseLoop(backend, options.responseTopic, subscribers, config).pipe(Effect.forkChild);
const stop = Effect.fn(`RequestResponse.stop:${options.requestTopic}`)(function* () {
yield* Fiber.interrupt(fiber);
yield* producer.close;
yield* closeConsumerBackend(backend, options.responseTopic, options.subscription);
});
yield* Effect.addFinalizer(() =>
stop().pipe(
Effect.catch((error) =>
Effect.logError("[RequestResponse] Failed to stop runtime", {
error: error.message,
}),
),
),
);
return {
request: <E = never, R = never>(
request: TReq,
requestOptions?: EffectRequestOptions<TRes, E, R>,
) => {
const id = randomUUID();
const timeoutMs = requestOptions?.timeoutMs ?? config.requestTimeoutMs;
return Effect.acquireUseRelease(
Queue.unbounded<TRes>().pipe(
Effect.tap((queue) =>
Effect.sync(() => {
subscribers.set(id, queue);
}),
),
),
(queue) =>
Effect.gen(function* () {
yield* producer.send(id, request);
const result = yield* waitForResponse(queue, requestOptions).pipe(
Effect.timeoutOption(Duration.millis(timeoutMs)),
);
return yield* O.match(result, {
onNone: () => Effect.fail(messagingTimeoutError("request-response", timeoutMs)),
onSome: Effect.succeed,
});
}),
(queue) =>
Effect.sync(() => {
subscribers.delete(id);
}).pipe(
Effect.flatMap(() => Queue.shutdown(queue)),
Effect.ignore,
),
);
},
stop: stop(),
} satisfies EffectRequestResponse<TReq, TRes>;
});
export function makeProducerFactoryService(pubsub: PubSubService): ProducerFactoryService {
return {
make: Effect.fn("ProducerFactory.make")(<T>(options: EffectProducerOptions) =>
makeEffectProducerFromPubSub<T>(pubsub, options),
),
};
}
export function makeConsumerFactoryService(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
): ConsumerFactoryService {
return {
run: Effect.fn("ConsumerFactory.run")(<T, E = never, R = never>(
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) =>
makeEffectConsumerFromPubSub(pubsub, config, options, flow),
),
};
}
export function makeRequestResponseFactoryService(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
): RequestResponseFactoryService {
const make = Effect.fn("RequestResponseFactory.make")(function* <TReq, TRes>(
options: EffectRequestResponseOptions,
) {
return yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options);
}) as RequestResponseFactoryService["make"];
return { make };
}
export const ProducerFactoryLive = Layer.effect(
ProducerFactory,
Effect.gen(function* () {
const pubsub = yield* PubSub;
return ProducerFactory.of(makeProducerFactoryService(pubsub));
}),
);
export const ConsumerFactoryLive = Layer.effect(
ConsumerFactory,
Effect.gen(function* () {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();
return ConsumerFactory.of(makeConsumerFactoryService(pubsub, config));
}),
);
export const RequestResponseFactoryLive = Layer.effect(
RequestResponseFactory,
Effect.gen(function* () {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();
return RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, config));
}),
);
export const runFlowRuntimeScoped = Effect.fn("FlowRuntime.run")(function* <Requirements = never>(
flow: Flow<Requirements>,
) {
yield* flow.startEffect().pipe(
Effect.mapError((error) => flowRuntimeError(flow.name, "start", error)),
);
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
flow.clearResources();
}),
);
});
export const FlowRuntimeLive = Layer.succeed(
FlowRuntime,
FlowRuntime.of({
run: runFlowRuntimeScoped,
}),
);
export const MessagingRuntimeLive = Layer.mergeAll(
ProducerFactoryLive,
ConsumerFactoryLive,
RequestResponseFactoryLive,
FlowRuntimeLive,
);
export const runEffectProducerScoped = Effect.fn("runEffectProducerScoped")(function* <T>(
options: EffectProducerOptions,
) {
const pubsub = yield* PubSub;
return yield* makeEffectProducerFromPubSub<T>(pubsub, options);
});
export const runEffectConsumerScoped = Effect.fn("runEffectConsumerScoped")(function* <T, E = never, R = never>(
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();
return yield* makeEffectConsumerFromPubSub(pubsub, config, options, flow);
});
export const runEffectRequestResponseScoped = Effect.fn("runEffectRequestResponseScoped")(function* <TReq, TRes>(
options: EffectRequestResponseOptions,
) {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();
return yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options);
});
export const runFlowScoped = Effect.fn("runFlowScoped")(function* (
flow: Flow,
) {
yield* runFlowRuntimeScoped(flow);
});

View file

@ -19,7 +19,7 @@ export class AsyncQueue<T> {
push(item: T): void {
const waiter = this.waiters.shift();
if (waiter) {
if (waiter !== undefined) {
waiter(item);
} else {
this.buffer.push(item);
@ -34,7 +34,7 @@ export class AsyncQueue<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const waiter = (value: T) => {
if (timer) clearTimeout(timer);
if (timer !== undefined) clearTimeout(timer);
resolve(value);
};
@ -58,17 +58,20 @@ export class AsyncQueue<T> {
export class Subscriber<T> {
private backend: BackendConsumer<T> | null = null;
private running = false;
private readonly pubsub: PubSubBackend;
private readonly topic: string;
private readonly subscription: string;
// ID-specific subscriptions (request/response correlation)
private idSubscribers = new Map<string, Resolver<T>>();
// Wildcard subscribers (receive all messages)
private allSubscribers = new Map<string, Resolver<T>>();
constructor(
private readonly pubsub: PubSubBackend,
private readonly topic: string,
private readonly subscription: string,
) {}
constructor(pubsub: PubSubBackend, topic: string, subscription: string) {
this.pubsub = pubsub;
this.topic = topic;
this.subscription = subscription;
}
async start(): Promise<void> {
this.backend = await this.pubsub.createConsumer<T>({
@ -78,13 +81,13 @@ export class Subscriber<T> {
this.running = true;
// Start the dispatch loop (fire and forget — runs until stop)
this.dispatchLoop().catch((err) => {
if (this.running) console.error("[Subscriber] dispatch loop error:", err);
if (this.running === true) console.error("[Subscriber] dispatch loop error:", err);
});
}
async stop(): Promise<void> {
this.running = false;
if (this.backend) {
if (this.backend !== null) {
await this.backend.close();
this.backend = null;
}
@ -114,8 +117,11 @@ export class Subscriber<T> {
let consecutiveErrors = 0;
while (this.running) {
try {
const msg = await this.backend!.receive(2000);
if (!msg) continue;
const backend = this.backend;
if (backend === null) throw new Error("Subscriber backend not started");
const msg = await backend.receive(2000);
if (msg === null) continue;
consecutiveErrors = 0;
@ -124,9 +130,9 @@ export class Subscriber<T> {
const value = msg.value();
// Route to ID-specific subscriber
if (id) {
if (id !== undefined && id.length > 0) {
const sub = this.idSubscribers.get(id);
if (sub) {
if (sub !== undefined) {
sub.queue.push(value);
}
}
@ -136,7 +142,7 @@ export class Subscriber<T> {
sub.queue.push(value);
}
await this.backend!.acknowledge(msg);
await backend.acknowledge(msg);
} catch (err) {
if (!this.running) break;
consecutiveErrors++;