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.

209 lines (208 loc) 8.53 kB
import { Collection } from "scats"; import { ActiveChildState } from "./active-child-state.js"; import { MultiStepPayload } from "./multi-step-payload.js"; import { MultiStepTask } from "./multi-step-task.js"; import { TaskContext, TaskStateSnapshot } from "./tasks-model.js"; /** * Linear workflow helper built on top of {@link MultiStepTask}. * * `SequentialTask` receives an ordered list of step names and uses that list as the canonical * workflow order. * * Like {@link MultiStepTask}, this abstraction requires task payload to use the * {@link MultiStepPayload} envelope. New parent tasks should be scheduled with * `MultiStepPayload.forUserPayload(...)` or an equivalent `new MultiStepPayload(...)` instance, * and resumed parent executions are expected to keep the same envelope shape. * * Step resolution rules: * - on every parent execution, the current step is resolved from `workflowPayload.step` * - if `workflowPayload.step` is missing, `SequentialTask` falls back to the first configured * step from the constructor `Collection<TStep>` * - when that fallback is used, the resolved first step is persisted back into * `workflowPayload.step` before `processStep(...)` runs * - after a child task finishes successfully, `workflowPayload.step` is advanced to the next * configured item in the same ordered list * - if the current step is already the last configured item, no further automatic transition * happens * * This means the configured step list defines transition order, while the persisted * `workflowPayload.step` defines the current position inside that order. Fresh parent tasks may * omit `workflowPayload.step` entirely and let the workflow start from the first configured step * automatically. * * This leaves subclasses with a single responsibility: implement `processStep(...)` and branch * on the current step. * * Special `TaskContext.setPayload(...)` semantics inside `processStep(...)`: * - unlike plain `MultiStepTask`, sequential step handlers pass only the next `userPayload` * - `SequentialTask` wraps that value back into the full `MultiStepPayload` envelope * - `workflowPayload.step` remains owned by `SequentialTask` and should not be managed by * subclasses directly * * The abstraction is intentionally happy-path only: the built-in behavior is "complete all * configured steps or fail the parent task". If a workflow needs branching recovery logic, * compensation, or custom error transitions, use {@link MultiStepTask} directly instead. * * If the current step is the last one in the configured sequence, successful child completion * does not advance to a new step and no additional processing is triggered automatically. * * Example: * ```ts * type VideoStep = "scan" | "encode" | "metadata"; * * type VideoPayload = { * videoId: number; * sourcePath: string; * encodedPath?: string; * }; * * class ProcessUploadedVideoTask extends SequentialTask<VideoStep, VideoPayload> { * static readonly QUEUE_NAME = "process-uploaded-video"; * * constructor( * private readonly videosDao: VideosDao, * private readonly tasks: TasksPoolsService, * ) { * super(Collection.of("scan", "encode", "metadata")); * } * * async onApplicationBootstrap() { * this.tasks.registerWorker(ProcessUploadedVideoTask.QUEUE_NAME, this); * } * * protected async processStep( * step: VideoStep, * payload: VideoPayload, * context: TaskContext, * ): Promise<void> { * switch (step) { * case "scan": * context.spawnChild({ * queue: "scan-video-antivirus", * payload: { * videoId: payload.videoId, * path: payload.sourcePath, * }, * }); * break; * case "encode": * const scanPassed = await this.videosDao.markCleanIfNoVirus(payload.videoId); * if (!scanPassed) { * await this.videosDao.updateStatus(payload.videoId, "virus"); * break; * } * context.spawnChild({ * queue: "encode-video-file", * payload: { * videoId: payload.videoId, * path: payload.sourcePath, * }, * }); * break; * case "metadata": * if (payload.encodedPath) { * const encodedPath = payload.encodedPath; * const metadata = await readVideoMetadata(encodedPath); * await this.videosDao.updateMetadata(payload.videoId, metadata); * } * break; * } * } * } * * await tasks.schedule({ * queue: ProcessUploadedVideoTask.QUEUE_NAME, * payload: MultiStepPayload.forUserPayload({ * videoId: 42, * sourcePath: "/uploads/video.mp4", * }).toJson, * }); * * // Fresh sequential workflow: `workflowPayload.step` may be omitted. * // The first parent execution will start from "scan" and persist it automatically. * ``` */ export declare abstract class SequentialTask<TStep extends string, TUserPayload extends object> extends MultiStepTask<TUserPayload> { private readonly steps; constructor(steps: Collection<TStep>); /** * Resolve the next configured step for the current step name. * * @param currentStep current workflow step * @returns next step if one exists */ private nextStep; /** * Process a concrete sequential step using the user payload for that step. * * @param step current workflow step * @param payload user payload * @param context task runtime context */ protected abstract processStep(step: TStep, payload: TUserPayload, context: TaskContext): Promise<void>; /** * Advance workflow state to the next configured step and continue processing. * * This helper is shared by both successful child completion and allowed child failure. */ private continueToNextStep; /** * Adapt runtime context for sequential steps so step handlers work with user payload only. * * In `SequentialTask`, `context.setPayload(...)` inside `processStep(...)` accepts only the next * `userPayload`. This adapter preserves all other context operations while wrapping user payloads * back into the persisted multi-step envelope with the current sequential step. */ private processStepContext; private clearedResolvedChildContext; private runStep; /** * Delegate no-child processing to {@link processStep}. * * @param payload current multi-step payload * @param context task runtime context */ protected processNext(payload: MultiStepPayload<TUserPayload>, context: TaskContext): Promise<void>; /** * Advance workflow state to the next configured step after successful child completion. * * Default behavior: * - update `workflowPayload.step` to the next configured step * - persist updated payload * - immediately continue with `processStep(...)` * - if that step finishes without `spawnChild(...)`, continue again until a * child is spawned or the configured step list is exhausted * * @param payload current multi-step payload * @param _childTask completed child snapshot * @param context task runtime context */ protected childFinished(payload: MultiStepPayload<TUserPayload>, _childTask: TaskStateSnapshot, context: TaskContext, _activeChild: ActiveChildState): Promise<void>; /** * Continue to the next configured step when the failed child was marked with `allowFailure=true`. * * When `allowFailure` is not set, SequentialTask keeps the default MultiStepTask behavior and * fails the parent task. */ protected childFailed(payload: MultiStepPayload<TUserPayload>, childTask: { id: number; error?: string; }, context: TaskContext, activeChild: ActiveChildState): Promise<void>; /** * Extract the current sequential step from workflow payload. * * @param payload current multi-step payload * @returns configured step name if present */ private stepFromPayload; /** * Resolve current step from payload, falling back to the first configured step. * * This lets newly created sequential workflows omit `workflowPayload.step` * and start from the beginning of the configured sequence. * * @param payload current multi-step payload * @returns current step name if one can be resolved */ private currentStep; }