@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
TypeScript
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>;
}