trustgraph/ts/packages/base/src/processor/flow-processor.ts

420 lines
13 KiB
TypeScript
Raw Normal View History

2026-04-05 21:09:33 -05:00
/**
* Flow-aware processor that manages dynamic flow instances.
*
* Subscribes to config-push topic and dynamically creates/destroys
* flow instances based on the configuration received.
*
2026-04-05 21:09:33 -05:00
* Python reference: trustgraph-base/trustgraph/base/flow_processor.py
*/
2026-06-01 16:22:25 -05:00
import {
2026-06-01 20:26:47 -05:00
makeAsyncProcessor,
type AsyncProcessorRuntime,
type ConfigHandler,
2026-06-01 16:22:25 -05:00
type EffectConfigHandler,
2026-06-01 20:26:47 -05:00
type ProcessorRuntime,
2026-06-01 16:22:25 -05:00
type ProcessorConfig,
} from "./async-processor.js";
2026-04-05 21:09:33 -05:00
import type { Spec } from "../spec/types.js";
2026-06-01 16:22:25 -05:00
import type { BackendConsumer, PubSubBackend } from "../backend/types.js";
2026-04-05 21:09:33 -05:00
import { Flow, type FlowDefinition } from "./flow.js";
import { topics } from "../schema/topics.js";
2026-05-12 08:06:58 -05:00
import {
pubSubError,
type FlowRuntimeError,
type ProcessorLifecycleError,
type PubSubError,
} from "../errors.js";
import {
ConsumerFactory,
FlowRuntime,
ProducerFactory,
RequestResponseFactory,
makeConsumerFactoryService,
makeProducerFactoryService,
makeRequestResponseFactoryService,
runFlowRuntimeScoped,
} from "../messaging/runtime.js";
import { makePubSubService, PubSub } from "../backend/pubsub.js";
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
import { Duration, Effect, Exit, Scope } from "effect";
2026-06-02 00:22:04 -05:00
import * as Predicate from "effect/Predicate";
2026-05-12 08:06:58 -05:00
import * as S from "effect/Schema";
interface ConfigPush {
version: number;
config: Record<string, unknown>;
}
2026-04-05 21:09:33 -05:00
2026-05-12 08:06:58 -05:00
interface ActiveFlow {
readonly scope: Scope.Closeable;
}
2026-06-01 16:22:25 -05:00
export interface FlowProcessorRuntimeOptions<
FlowRequirements = never,
ConfigHandlerError = never,
ConfigHandlerRequirements = never,
> {
readonly id: string;
readonly pubsub: PubSubBackend;
readonly specifications: ReadonlyArray<Spec<FlowRequirements>>;
readonly configHandlers?: ReadonlyArray<
EffectConfigHandler<ConfigHandlerError, ConfigHandlerRequirements>
>;
readonly isRunning?: () => boolean;
}
2026-06-01 20:26:47 -05:00
type FlowProcessorRuntimeRequirements<FlowRequirements> =
| PubSub
| FlowRuntime
| ProducerFactory
| ConsumerFactory
| RequestResponseFactory
| Scope.Scope
| FlowRequirements;
export type FlowProcessorStartEffect<FlowRequirements> = Effect.Effect<
void,
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
FlowProcessorRuntimeRequirements<FlowRequirements>
>;
export interface FlowProcessorRuntime<FlowRequirements = never>
extends ProcessorRuntime<
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
FlowProcessorRuntimeRequirements<FlowRequirements>
> {
readonly config: ProcessorConfig;
readonly pubsub: PubSubBackend;
readonly configHandlers: ConfigHandler[];
readonly isRunning: () => boolean;
readonly registerConfigHandler: (handler: ConfigHandler) => void;
2026-06-02 00:22:04 -05:00
readonly registerSpecification: (spec: Spec<FlowRequirements>) => void;
2026-06-01 20:26:47 -05:00
readonly specifications: ReadonlyArray<Spec<FlowRequirements>>;
}
export interface MakeFlowProcessorOptions<FlowRequirements = never> {
readonly specifications?: ReadonlyArray<Spec<FlowRequirements>>;
readonly provide?: (
effect: FlowProcessorStartEffect<FlowRequirements>,
) => FlowProcessorStartEffect<FlowRequirements>;
}
2026-05-12 08:06:58 -05:00
const ConfigPushSchema = S.Struct({
version: S.Number,
config: S.Record(S.String, S.Unknown),
});
2026-06-02 00:22:04 -05:00
const isStringRecord = (value: unknown): value is Record<string, unknown> =>
Predicate.isObject(value) && !Array.isArray(value);
const isTopicsRecord = (value: unknown): value is Record<string, string> =>
isStringRecord(value) && Object.values(value).every((item) => typeof item === "string");
const isFlowDefinition = (value: unknown): value is FlowDefinition => {
if (!isStringRecord(value)) return false;
const topics = value.topics;
const parameters = value.parameters;
return (topics === undefined || isTopicsRecord(topics)) &&
(parameters === undefined || isStringRecord(parameters));
};
2026-06-01 16:22:25 -05:00
export function runFlowProcessorDefinitionScoped<
FlowRequirements = never,
ConfigHandlerError = never,
ConfigHandlerRequirements = never,
>(
options: FlowProcessorRuntimeOptions<
FlowRequirements,
ConfigHandlerError,
ConfigHandlerRequirements
>,
): Effect.Effect<
void,
PubSubError | FlowRuntimeError | ConfigHandlerError,
2026-05-12 08:06:58 -05:00
| PubSub
| FlowRuntime
| ProducerFactory
| ConsumerFactory
| RequestResponseFactory
| Scope.Scope
| FlowRequirements
2026-06-01 16:22:25 -05:00
| ConfigHandlerRequirements
2026-05-12 08:06:58 -05:00
> {
2026-06-01 16:22:25 -05:00
const flows = new Map<string, ActiveFlow>();
let configConsumer: BackendConsumer<ConfigPush> | null = null;
let lastFlowsJson = "";
const isRunning = options.isRunning ?? (() => true);
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const closeFlowEffect = (name: string, activeFlow: ActiveFlow): Effect.Effect<void> =>
Scope.close(activeFlow.scope, Exit.void).pipe(
Effect.tap(() => Effect.log(`[${options.id}] Flow "${name}" stopped`)),
);
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const closeAllFlowsEffect = Effect.gen(function* () {
const activeFlows = Array.from(flows.entries());
for (const [name, activeFlow] of activeFlows) {
yield* closeFlowEffect(name, activeFlow);
}
flows.clear();
});
2026-05-12 08:06:58 -05:00
2026-06-01 16:22:25 -05:00
const closeConfigConsumerEffect = (): Effect.Effect<void> => {
const consumer = configConsumer;
configConsumer = null;
if (consumer === null) {
return Effect.void;
}
return Effect.tryPromise({
try: () => consumer.close(),
catch: (error) => pubSubError("close:config-push", error),
}).pipe(
Effect.catch((error) =>
Effect.logError(`[${options.id}] Failed to close config consumer`, {
error: error.message,
}),
2026-05-12 08:06:58 -05:00
),
);
2026-06-01 16:22:25 -05:00
};
2026-05-12 08:06:58 -05:00
2026-06-01 16:22:25 -05:00
const startFlowEffect = (
name: string,
definition: FlowDefinition,
): Effect.Effect<
ActiveFlow,
FlowRuntimeError,
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
> =>
Effect.gen(function* () {
const flowRuntime = yield* FlowRuntime;
const scope = yield* Scope.make();
const flow = new Flow<FlowRequirements>(
name,
options.id,
options.pubsub,
definition,
options.specifications,
);
return yield* flowRuntime.run(flow).pipe(
Scope.provide(scope),
Effect.as({ scope } satisfies ActiveFlow),
Effect.catch((error) =>
Scope.close(scope, Exit.void).pipe(
Effect.flatMap(() => Effect.fail(error)),
),
2026-05-12 08:06:58 -05:00
),
);
2026-04-05 21:09:33 -05:00
});
2026-06-01 16:22:25 -05:00
const onConfigureFlowsEffect = (
2026-05-12 08:06:58 -05:00
config: Record<string, unknown>,
_version: number,
): Effect.Effect<
void,
FlowRuntimeError,
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
2026-06-01 16:22:25 -05:00
> =>
Effect.gen(function* () {
2026-06-02 00:22:04 -05:00
const flowDefs = config.flows;
2026-05-12 08:06:58 -05:00
if (flowDefs === undefined) {
2026-06-01 16:22:25 -05:00
yield* Effect.log(`[${options.id}] No flows in config push, skipping`);
2026-05-12 08:06:58 -05:00
return;
}
2026-06-02 00:22:04 -05:00
if (!isStringRecord(flowDefs)) {
yield* Effect.logWarning(`[${options.id}] Skipping config push: flows is not an object`);
return;
}
2026-05-12 08:06:58 -05:00
const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe(
Effect.catch((error) => Effect.succeed(String(error))),
);
2026-06-01 16:22:25 -05:00
if (lastFlowsJson.length > 0 && flowsJson === lastFlowsJson && flows.size > 0) {
yield* Effect.log(`[${options.id}] Flow definitions unchanged, skipping restart`);
2026-05-12 08:06:58 -05:00
return;
}
2026-06-01 16:22:25 -05:00
lastFlowsJson = flowsJson;
2026-06-01 16:22:25 -05:00
for (const [name, activeFlow] of flows) {
2026-05-12 08:06:58 -05:00
if (!(name in flowDefs)) {
2026-06-01 16:22:25 -05:00
yield* Effect.log(`[${options.id}] Stopping removed flow: ${name}`);
yield* closeFlowEffect(name, activeFlow);
flows.delete(name);
2026-05-12 08:06:58 -05:00
}
}
2026-05-12 08:06:58 -05:00
for (const [name, defn] of Object.entries(flowDefs)) {
2026-06-02 00:22:04 -05:00
if (!isFlowDefinition(defn)) {
2026-06-01 16:22:25 -05:00
yield* Effect.logWarning(`[${options.id}] Skipping flow "${name}": definition is not an object`);
2026-05-12 08:06:58 -05:00
continue;
}
2026-06-01 16:22:25 -05:00
const existing = flows.get(name);
2026-05-12 08:06:58 -05:00
if (existing !== undefined) {
2026-06-01 16:22:25 -05:00
yield* Effect.log(`[${options.id}] Restarting flow "${name}" with updated config`);
yield* closeFlowEffect(name, existing);
flows.delete(name);
}
2026-06-01 16:22:25 -05:00
yield* Effect.log(`[${options.id}] Starting flow "${name}"`);
const activeFlow = yield* startFlowEffect(name, defn);
flows.set(name, activeFlow);
yield* Effect.log(`[${options.id}] Flow "${name}" started`);
}
2026-05-12 08:06:58 -05:00
});
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
const processNextConfigPushEffect = (): Effect.Effect<
2026-05-12 08:06:58 -05:00
void,
never,
2026-06-01 16:22:25 -05:00
| FlowRuntime
| ProducerFactory
| ConsumerFactory
| RequestResponseFactory
| FlowRequirements
| ConfigHandlerRequirements
> =>
Effect.gen(function* () {
const consumer = configConsumer;
2026-05-12 08:06:58 -05:00
if (consumer === null) {
yield* Effect.sleep(Duration.millis(1000));
return;
2026-04-05 21:09:33 -05:00
}
2026-05-12 08:06:58 -05:00
const msg = yield* Effect.tryPromise({
try: () => consumer.receive(2000),
catch: (error) => pubSubError("receive:config-push", error),
});
if (msg === null) {
return;
}
2026-05-12 08:06:58 -05:00
const push = msg.value();
2026-06-01 16:22:25 -05:00
yield* Effect.log(`[${options.id}] Received config push version=${push.version}`);
2026-05-12 08:06:58 -05:00
2026-06-01 16:22:25 -05:00
yield* onConfigureFlowsEffect(push.config, push.version);
2026-05-12 08:06:58 -05:00
2026-06-01 16:22:25 -05:00
for (const handler of options.configHandlers ?? []) {
yield* handler(push.config, push.version);
2026-04-05 21:09:33 -05:00
}
2026-05-12 08:06:58 -05:00
yield* Effect.tryPromise({
try: () => consumer.acknowledge(msg),
catch: (error) => pubSubError("acknowledge:config-push", error),
});
}).pipe(
Effect.catch((error) => {
2026-06-01 16:22:25 -05:00
if (!isRunning()) {
2026-05-12 08:06:58 -05:00
return Effect.void;
}
2026-06-01 16:22:25 -05:00
return Effect.logError(`[${options.id}] Config consumer error`, {
error: error instanceof Error ? error.message : String(error),
2026-05-12 08:06:58 -05:00
}).pipe(
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
);
}),
);
2026-04-05 21:09:33 -05:00
2026-06-01 16:22:25 -05:00
return Effect.gen(function* () {
const pubsub = yield* PubSub;
configConsumer = yield* pubsub.createConsumer<ConfigPush>({
topic: topics.configPush,
subscription: `${options.id}-config-push`,
initialPosition: "earliest",
schema: ConfigPushSchema,
2026-05-12 08:06:58 -05:00
});
2026-06-01 16:22:25 -05:00
yield* Effect.addFinalizer(() =>
closeConfigConsumerEffect().pipe(
Effect.flatMap(() => closeAllFlowsEffect),
),
);
yield* Effect.log(`[${options.id}] Listening for config pushes on ${topics.configPush}`);
yield* Effect.whileLoop({
while: isRunning,
body: processNextConfigPushEffect,
step: () => undefined,
});
});
}
2026-06-01 20:26:47 -05:00
export function makeFlowProcessor<FlowRequirements = never>(
config: ProcessorConfig,
options: MakeFlowProcessorOptions<FlowRequirements> = {},
): FlowProcessorRuntime<FlowRequirements> {
const specifications: Array<Spec<FlowRequirements>> = [
...(options.specifications ?? []),
];
let processor: FlowProcessorRuntime<FlowRequirements>;
const base: AsyncProcessorRuntime<
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
FlowProcessorRuntimeRequirements<FlowRequirements>
> = makeAsyncProcessor(config, {
runEffect: (runtime) => {
const configHandlers = runtime.configHandlers.map(
(handler): EffectConfigHandler<PubSubError> =>
(pushedConfig, version) =>
Effect.tryPromise({
try: () => handler(pushedConfig, version),
catch: (error) => pubSubError("config-handler", error),
}),
);
return runFlowProcessorDefinitionScoped({
id: runtime.config.id,
pubsub: runtime.pubsub,
specifications,
configHandlers,
isRunning: runtime.isRunning,
});
},
});
2026-05-12 08:06:58 -05:00
2026-06-02 00:22:04 -05:00
const makeStartEffect = (): FlowProcessorStartEffect<FlowRequirements> => {
const effect = base.startEffect;
2026-06-01 20:26:47 -05:00
return options.provide?.(effect) ?? effect;
};
2026-06-01 20:26:47 -05:00
processor = {
...base,
specifications,
registerSpecification: (spec) => {
2026-06-02 00:22:04 -05:00
specifications.push(spec);
2026-06-01 20:26:47 -05:00
},
2026-06-02 00:22:04 -05:00
get startEffect() {
return makeStartEffect();
2026-06-01 20:26:47 -05:00
},
2026-06-02 00:22:04 -05:00
start: (context) =>
Effect.runPromiseWith(context)(
Effect.gen(function* () {
const pubsub = makePubSubService(base.pubsub);
const messagingConfig = yield* loadMessagingRuntimeConfig();
const start = processor.startEffect.pipe(
Effect.provideService(PubSub, pubsub),
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
Effect.provideService(
RequestResponseFactory,
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
),
Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
);
yield* Effect.scoped(start);
}),
),
2026-06-01 20:26:47 -05:00
};
2026-05-12 08:06:58 -05:00
2026-06-01 20:26:47 -05:00
return processor;
}
2026-06-01 20:26:47 -05:00
export type FlowProcessor<FlowRequirements = never> = FlowProcessorRuntime<FlowRequirements>;
export const FlowProcessor = makeFlowProcessor as unknown as {
new <FlowRequirements = never>(
config: ProcessorConfig,
): FlowProcessor<FlowRequirements>;
<FlowRequirements = never>(
config: ProcessorConfig,
): FlowProcessor<FlowRequirements>;
};