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