UNPKG

@penkov/tasks_queue

Version:

A lightweight PostgreSQL-backed task queue system with scheduling, retries, backoff strategies, and priority handling. Designed for efficiency and observability in modern Node.js applications.

210 lines (209 loc) 8.09 kB
import { ActiveChildState } from "./active-child-state.js"; import { MultiStepPayload } from "./multi-step-payload.js"; import { TaskContext, TaskStateSnapshot } from "./tasks-model.js"; import { TasksWorker } from "./tasks-worker.js"; /** * Base worker for stateful multi-step workflows built on top of parent-child tasks. * * `MultiStepTask` expects task payload to follow the {@link MultiStepPayload} contract: * - `workflowPayload` stores orchestration state managed by the workflow itself * - `userPayload` stores domain-specific business data * - `activeChild` stores the currently running child task state, if any * * This is a strict contract. `MultiStepTask` should be scheduled and resumed only with payloads * produced from {@link MultiStepPayload} (for example via `new MultiStepPayload(...)` or * `MultiStepPayload.forUserPayload(...)`). Passing an arbitrary plain object as task payload is * not supported for this abstraction, because orchestration state, active child tracking, and * continuation logic all depend on the `MultiStepPayload` envelope shape. * * Execution flow: * 1. If `activeChild` is empty, the worker calls {@link processNext}. * 2. If `activeChild` is present, the worker loads the child task through `context.findTask(...)`. * 3. Once the child is terminal, the worker clears `activeChild`, persists updated payload, * and dispatches to {@link childFinished} or {@link childFailed}. * 4. Default `childFinished(...)` continues with {@link processNext}, while default * `childFailed(...)` fails the parent task. * * This class is intended for workflows where orchestration decisions depend on child task * results, custom transitions, or non-trivial recovery behavior. If the workflow is strictly * sequential and follows a simple happy-path "run all steps or fail" model, prefer * {@link SequentialTask}. * * The abstraction supports one active child at a time. Parallel branches and workflow graphs * are intentionally out of scope. * * Example: * ```ts * type VideoWorkflowPayload = { * stage: "scan" | "after-scan" | "encode" | "metadata" | "done"; * }; * * type VideoPayload = { * videoId: number; * sourcePath: string; * encodedPath?: string; * }; * * class EncodeUploadedVideoTask extends MultiStepTask<VideoPayload> { * constructor( * private readonly videosDao: VideosDao, * private readonly transcoderBackend: TranscoderBackend, * ) { * super(); * } * * protected async processNext( * payload: MultiStepPayload<VideoPayload>, * context: TaskContext, * ): Promise<void> { * const stage = payload.workflowPayload["stage"] as VideoWorkflowPayload["stage"]; * switch (stage) { * case "scan": * context.spawnChild({ * queue: "scan-video-antivirus", * payload: { * videoId: payload.userPayload.videoId, * path: payload.userPayload.sourcePath, * }, * }); * break; * case "after-scan": * if (payload.workflowPayload["virusesFound"] === true) { * await this.videosDao.updateStatus(payload.userPayload.videoId, "virus"); * context.setPayload( * payload.copy({ * workflowPayload: { * ...payload.workflowPayload, * stage: "done", * }, * }).toJson, * ); * } else { * context.setPayload( * payload.copy({ * workflowPayload: { * ...payload.workflowPayload, * stage: "encode", * }, * }).toJson, * ); * context.spawnChild({ * queue: "encode-video-file", * payload: { * videoId: payload.userPayload.videoId, * path: payload.userPayload.sourcePath, * }, * }); * } * break; * case "metadata": * if (payload.userPayload.encodedPath) { * const metadata = await this.transcoderBackend.readMetadata( * payload.userPayload.encodedPath, * ); * await this.videosDao.saveMetadata(payload.userPayload.videoId, metadata); * } * await this.videosDao.updateStatus(payload.userPayload.videoId, "ready"); * context.setPayload( * payload.copy({ * workflowPayload: { * ...payload.workflowPayload, * stage: "done", * }, * }).toJson, * ); * break; * case "encode": * case "done": * break; * } * } * * protected override async childFinished( * payload: MultiStepPayload<VideoPayload>, * childTask: TaskStateSnapshot, * context: TaskContext, * ): Promise<void> { * const stage = payload.workflowPayload["stage"] as VideoWorkflowPayload["stage"]; * switch (stage) { * case "scan": * context.setPayload( * payload.copy({ * workflowPayload: { * ...payload.workflowPayload, * stage: "after-scan", * virusesFound: childTask.result.map((r) => r["virusesFound"] === true).getOrElseValue(false), * }, * }).toJson, * ); * await this.processNext( * payload.copy({ * workflowPayload: { * ...payload.workflowPayload, * stage: "after-scan", * virusesFound: childTask.result.map((r) => r["virusesFound"] === true).getOrElseValue(false), * }, * }), * context, * ); * break; * case "encode": { * const encodedPath = childTask.result.map((r) => String(r["encodedPath"])).getOrElseValue(""); * await this.videosDao.updateEncodedPath(payload.userPayload.videoId, encodedPath); * const nextPayload = payload.copy({ * workflowPayload: { * ...payload.workflowPayload, * stage: "metadata", * }, * userPayload: { * ...payload.userPayload, * encodedPath, * }, * }); * context.setPayload(nextPayload.toJson); * await this.processNext(nextPayload, context); * break; * } * case "after-scan": * case "metadata": * case "done": * await this.processNext(payload, context); * break; * } * } * } * ``` */ export declare abstract class MultiStepTask<TUserPayload extends object = Record<string, unknown>> extends TasksWorker { /** * Continue workflow processing after a child task has been loaded and verified to exist. * * The method clears `activeChildId` from payload, persists the cleaned payload through the context, * and dispatches control to the corresponding child-status hook. * * @param payload current parent payload * @param childTask loaded child task snapshot * @param context task runtime context */ private handleResolvedChild; /** * Process the next workflow step when there is no active child task. */ protected abstract processNext(payload: MultiStepPayload<TUserPayload>, context: TaskContext): Promise<void>; /** * Called when the active child has finished successfully. * * Default behavior continues with the next workflow step. */ protected childFinished(payload: MultiStepPayload<TUserPayload>, _childTask: TaskStateSnapshot, context: TaskContext, _activeChild: ActiveChildState): Promise<void>; /** * Called when the active child has reached terminal `error`. * * Default behavior fails the parent task. */ protected childFailed(payload: MultiStepPayload<TUserPayload>, childTask: { id: number; error?: string; }, _context: TaskContext, _activeChild: ActiveChildState): Promise<void>; process(payload: unknown, context: TaskContext): Promise<void>; }