UNPKG

durable-execution

Version:

A durable execution engine for running tasks durably and resiliently

1,397 lines (1,312 loc) 53.6 kB
import { Context, Effect } from 'effect' import { createMutex, type Mutex } from '@gpahal/std/promises' import { DurableExecutionError, type DurableExecutionErrorStorageValue } from './errors' import type { SerializerInternal } from './serializer' import { type CancelledTaskExecution, type CompletedTaskExecution, type FailedTaskExecution, type FinalizeFailedTaskExecution, type ParentTaskExecutionSummary, type ReadyTaskExecution, type RunningTaskExecution, type TaskExecution, type TaskExecutionStatus, type TaskExecutionSummary, type TaskRetryOptions, type TimedOutTaskExecution, type WaitingForChildrenTaskExecution, type WaitingForFinalizeTaskExecution, } from './task' /** * The storage interface for persisting task execution state. Implementations must ensure * all operations are atomic to maintain consistency in distributed environments. * * If the implementation is not atomic, use {@link TaskExecutionsStorageWithMutex} to wrap the * storage implementation and make all operations atomic. * * If the implementation doesn't support batching methods, use * {@link TaskExecutionsStorageWithBatching} to wrap the storage implementation and implement the * batching methods. * * ## Implementation Requirements * * - **Atomicity**: All operations must be atomic (use transactions where available) * - **Concurrency**: Support multiple parallel transactions without deadlocks * - **Consistency**: Ensure data consistency across all operations * - **Durability**: Data must persist across process restarts * * ## Required Database Indexes * * For optimal performance, create these indexes in your storage backend: * * - **Unique Indexes**: * - `uniqueIndex(executionId)` * - `uniqueIndex(sleepingTaskUniqueId)` - sparse/partial index where not null * * - **Non-unique Indexes (order matters for performance)**: * - `index(status, startAt)` - for processing ready task executions * - `index(status, onChildrenFinishedProcessingStatus, activeChildrenCount, updatedAt)` - for * processing parent task executions when children are finished processing * - `index(closeStatus, updatedAt)` - for task execution closure handling * - `index(status, isSleepingTask, expiresAt)` - for task execution timeout handling * - `index(onChildrenFinishedProcessingExpiresAt)` - for recovery during processing parent task * executions when children are finished processing * - `index(closeExpiresAt)` - for task execution closure handling recovery * - `index(executorId, needsPromiseCancellation, updatedAt)` - for task execution cancellation * - `index(parent.executionId, isFinished)` - for child task execution queries * - `index(isFinished, closeStatus, updatedAt)` - for finished task execution cleanup * * ## Available Implementations * * - {@link InMemoryTaskExecutionsStorage} - For development/testing only * - [Drizzle ORM Storage](https://github.com/gpahal/durable-execution/tree/main/durable-execution-storage-drizzle) - * Production-ready PostgreSQL/MySQL support * - [Convex Storage](https://github.com/gpahal/durable-execution/tree/main/durable-execution-storage-convex) - * Production-ready Convex support * * @example * ```ts * // Custom storage implementation * class MongoDBStorage implements TaskExecutionsStorage { * async insertMany(executions: ReadonlyArray<TaskExecutionStorageValue>) { * await this.collection.insertMany(executions) * } * * async getManyById(requests: ReadonlyArray<{executionId: string}>) { * const ids = requests.map(r => r.executionId) * const docs = await this.collection.find({ executionId: { $in: ids } }) * return requests.map(req => * docs.find(doc => doc.executionId === req.executionId) || undefined * ) * } * * // ... implement other methods * } * ``` * * @category Storage */ export type TaskExecutionsStorage = { /** * Insert many task executions. * * @param executions - The task executions to insert. */ insertMany: (executions: ReadonlyArray<TaskExecutionStorageValue>) => void | Promise<void> /** * Get many task executions by id and optionally filter them. * * @param requests - The requests to get the task executions. * @returns The task executions. */ getManyById: ( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters }>, ) => | Array<TaskExecutionStorageValue | undefined> | Promise<Array<TaskExecutionStorageValue | undefined>> /** * Get many task executions by sleeping task unique id. * * @param requests - The requests to get the task executions. * @returns The task executions. */ getManyBySleepingTaskUniqueId: ( requests: ReadonlyArray<{ sleepingTaskUniqueId: string }>, ) => | Array<TaskExecutionStorageValue | undefined> | Promise<Array<TaskExecutionStorageValue | undefined>> /** * Update many task executions by id and optionally filter them. Each request should be atomic. * * @param requests - The requests to update the task executions. */ updateManyById: ( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters update: TaskExecutionStorageUpdate }>, ) => void | Promise<void> /** * Update many task executions by id and insert children task executions if updated. Each request * should be atomic. * * @param requests - The requests to update the task executions. */ updateManyByIdAndInsertChildrenIfUpdated: ( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters update: TaskExecutionStorageUpdate childrenTaskExecutionsToInsertIfAnyUpdated: ReadonlyArray<TaskExecutionStorageValue> }>, ) => void | Promise<void> /** * Update task executions by status and start at less than and return the task executions that * were updated. The task executions are ordered by `startAt` ascending. * * Update `expiresAt = updateExpiresAtWithStartedAt + existingTaskExecution.timeoutMs`. * * @param request - The request to update the task executions. * @param request.status - The status of the task executions to update. * @param request.startAtLessThan - The start at less than of the task executions to update. * @param request.update - The update object. * @param request.updateExpiresAtWithStartedAt - The `startedAt` value to update the expires at * with. * @param request.limit - The maximum number of task executions to update. * @returns The task executions that were updated. */ updateByStatusAndStartAtLessThanAndReturn: (request: { status: TaskExecutionStatus startAtLessThan: number update: TaskExecutionStorageUpdate updateExpiresAtWithStartedAt: number limit: number }) => Array<TaskExecutionStorageValue> | Promise<Array<TaskExecutionStorageValue>> /** * Update task executions by status and on children finished processing status and active * children task executions count zero and return the task executions that were updated. The task * executions are ordered by `updatedAt` ascending. * * @param request - The request to update the task executions. * @param request.status - The status of the task executions to update. * @param request.onChildrenFinishedProcessingStatus - The on children finished processing status * of the task executions to update. * @param request.update - The update object. * @param request.limit - The maximum number of task executions to update. * @returns The task executions that were updated. */ updateByStatusAndOnChildrenFinishedProcessingStatusAndActiveChildrenCountZeroAndReturn: (request: { status: TaskExecutionStatus onChildrenFinishedProcessingStatus: TaskExecutionOnChildrenFinishedProcessingStatus update: TaskExecutionStorageUpdate limit: number }) => Array<TaskExecutionStorageValue> | Promise<Array<TaskExecutionStorageValue>> /** * Update task executions by close status and return the task executions that were updated. The * task executions are ordered by `updatedAt` ascending. * * @param request - The request to update the task executions. * @param request.closeStatus - The close status of the task executions to update. * @param request.update - The update object. * @param request.limit - The maximum number of task executions to update. * @returns The task executions that were updated. */ updateByCloseStatusAndReturn: (request: { closeStatus: TaskExecutionCloseStatus update: TaskExecutionStorageUpdate limit: number }) => Array<TaskExecutionStorageValue> | Promise<Array<TaskExecutionStorageValue>> /** * Update task executions by is sleeping task and expires at less than and return the number of * task executions that were updated. The task executions are ordered by `expiresAt` ascending. * * @param request - The request to update the task executions. * @param request.status - The status of the task executions to update. * @param request.isSleepingTask - The is sleeping task of the task executions to update. * @param request.expiresAtLessThan - The expires at less than of the task executions to update. * @param request.update - The update object. * @param request.limit - The maximum number of task executions to update. * @returns The number of task executions that were updated. */ updateByStatusAndIsSleepingTaskAndExpiresAtLessThan: (request: { status: TaskExecutionStatus isSleepingTask: boolean expiresAtLessThan: number update: TaskExecutionStorageUpdate limit: number }) => number | Promise<number> /** * Update task executions by on children finished processing expires at less than and return the * number of task executions that were updated. The task executions are ordered by * `onChildrenFinishedProcessingExpiresAt` ascending. * * @param request - The request to update the task executions. * @param request.onChildrenFinishedProcessingExpiresAtLessThan - The on children finished * processing expires at less than of the task executions to update. * @param request.update - The update object. * @param request.limit - The maximum number of task executions to update. * @returns The number of task executions that were updated. */ updateByOnChildrenFinishedProcessingExpiresAtLessThan: (request: { onChildrenFinishedProcessingExpiresAtLessThan: number update: TaskExecutionStorageUpdate limit: number }) => number | Promise<number> /** * Update task executions by close expires at less than and return the number of task executions * that were updated. The task executions are ordered by `closeExpiresAt` ascending. * * @param request - The request to update the task executions. * @param request.closeExpiresAtLessThan - The close expires at less than of the task executions * to update. * @param request.update - The update object. * @param request.limit - The maximum number of task executions to update. * @returns The task executions that were updated. */ updateByCloseExpiresAtLessThan: (request: { closeExpiresAtLessThan: number update: TaskExecutionStorageUpdate limit: number }) => number | Promise<number> /** * Update task executions by executor id and needs promise cancellation. The task executions are * ordered by `updatedAt` ascending. * * @param request - The request to update the task executions. * @param request.executorId - The id of the executor. * @param request.needsPromiseCancellation - The needs promise cancellation of the task executions * to update. * @param request.update - The update object. * @param request.limit - The maximum number of task executions to update. * @returns The task executions that were updated. */ updateByExecutorIdAndNeedsPromiseCancellationAndReturn: (request: { executorId: string needsPromiseCancellation: boolean update: TaskExecutionStorageUpdate limit: number }) => Array<TaskExecutionStorageValue> | Promise<Array<TaskExecutionStorageValue>> /** * Get many task executions by parent execution id. * * @param requests - The requests to get the task executions. * @returns The task executions. */ getManyByParentExecutionId: ( requests: ReadonlyArray<{ parentExecutionId: string }>, ) => Array<Array<TaskExecutionStorageValue>> | Promise<Array<Array<TaskExecutionStorageValue>>> /** * Update many task executions by parent execution id and is finished. Each request should be * atomic. * * @param requests - The requests to update the task executions. */ updateManyByParentExecutionIdAndIsFinished: ( requests: ReadonlyArray<{ parentExecutionId: string isFinished: boolean update: TaskExecutionStorageUpdate }>, ) => void | Promise<void> /** * Update task executions by is finished and close status. Also, decrement parent active children * count for all these task executions atomically along with the update. The task executions are * ordered by `updatedAt` ascending. * * @param request - The request to update the task executions. * @param request.isFinished - The is finished of the task executions to update. * @param request.closeStatus - The close status of the task executions to update. * @param request.update - The update object. * @param request.limit - The maximum number of task executions to update. * @returns The number of task executions that were updated. */ updateAndDecrementParentActiveChildrenCountByIsFinishedAndCloseStatus: (request: { isFinished: boolean closeStatus: TaskExecutionCloseStatus update: TaskExecutionStorageUpdate limit: number }) => number | Promise<number> /** * Delete task execution by id. This is used for testing. Ideally the storage implementation should * have a test and production mode and this method should be a no-op in production. * * @param request - The request to delete the task execution. * @param request.executionId - The id of the task execution to delete. */ deleteById: (request: { executionId: string }) => void | Promise<void> /** * Delete all task executions. This is used for testing. Ideally the storage implementation should * have a test and production mode and this method should be a no-op in production. */ deleteAll: () => void | Promise<void> } /** * Service for the task executions storage. * * @category Storage */ export class TaskExecutionsStorageService extends Context.Tag('TaskExecutionsStorageService')< TaskExecutionsStorageService, TaskExecutionsStorage >() {} /** * Complete storage representation of a task execution including all metadata, relationships and * state tracking fields. * * This type represents the full database record for a task execution. All fields are used * internally by the executor for state management, recovery and coordination. * * ## Field Categories * * - **Identity**: `taskId`, `executionId`, `root`, `parent` * - **Configuration**: `retryOptions`, `timeoutMs`, `sleepMsBeforeRun` * - **State**: `status`, `isFinished`, `input`, `output`, `error` * - **Timing**: `startAt`, `startedAt`, `expiresAt`, `finishedAt`, etc. * - **Relationships**: `children`, `finalize`, `activeChildrenCount` * - **Recovery**: `closeStatus`, `onChildrenFinishedProcessingStatus`, `needsPromiseCancellation` * * @category Storage */ export type TaskExecutionStorageValue = { /** * The root task execution. */ root?: TaskExecutionSummary /** * The parent task execution. */ parent?: ParentTaskExecutionSummary /** * The id of the task. */ taskId: string /** * The id of the execution. */ executionId: string /** * Whether the task execution is a sleeping task execution. */ isSleepingTask: boolean /** * The unique id of the sleeping task execution. It is only present for sleeping task executions. */ sleepingTaskUniqueId?: string /** * The retry options of the task execution. */ retryOptions: TaskRetryOptions /** * The sleep ms before run of the task execution. */ sleepMsBeforeRun: number /** * The timeout ms of the task execution. */ timeoutMs: number /** * Whether the children task executions are sequential. It is only present for * `waiting_for_children` status. */ areChildrenSequential: boolean /** * The input of the task execution. */ input: string /** * The id of the executor. */ executorId?: string /** * The status of the execution. */ status: TaskExecutionStatus /** * Whether the execution is finished. Set on finish. */ isFinished: boolean /** * The run output of the task execution. Deleted after the task execution is finished. */ runOutput?: string /** * The output of the task execution. */ output?: string /** * The error of the execution. */ error?: DurableExecutionErrorStorageValue /** * The number of attempts the execution has been retried. */ retryAttempts: number /** * The start time of the task execution. Used for delaying the execution. Set on enqueue. */ startAt: number /** * The time the task execution started. Set on start. */ startedAt?: number /** * The time the task execution expires. It is used to recover from process failures. Set on * start. */ expiresAt?: number /** * The time the task execution waiting for children starts. */ waitingForChildrenStartedAt?: number /** * The time the task execution waiting for finalize starts. */ waitingForFinalizeStartedAt?: number /** * The time the task execution finished. Set on finish. */ finishedAt?: number /** * The children task executions of the execution. It is only present for `waiting_for_children` * status. */ children?: Array<TaskExecutionSummary> /** * The number of active children task executions. It is only present for `waiting_for_children` * status. */ activeChildrenCount: number /** * The status of the on children finished processing. */ onChildrenFinishedProcessingStatus: TaskExecutionOnChildrenFinishedProcessingStatus /** * The time the on children finished processing expires. It is used to recover from process * failures. Set on on children finished processing start. */ onChildrenFinishedProcessingExpiresAt?: number /** * The time the on children task executions finished processing finished. Set after on children * finished processing finishes. */ onChildrenFinishedProcessingFinishedAt?: number /** * The finalize task execution of the execution. */ finalize?: TaskExecutionSummary /** * Whether the execution is closed. Once the execution is finished, the closing process will * update this field in the background. */ closeStatus: TaskExecutionCloseStatus /** * The time the task execution close expires. It is used to recover from process failures. Set on * closing process start. */ closeExpiresAt?: number /** * The time the task execution was closed. Set on closing process finish. */ closedAt?: number /** * Whether the execution needs a promise cancellation. Set on cancellation. */ needsPromiseCancellation: boolean /** * The time the task execution was created. */ createdAt: number /** * The time the task execution was updated. */ updatedAt: number } /** * The processing status for handling child task completion. * * - `idle`: Not processing child completions * - `processing`: Currently processing child task completions * - `processed`: Child task completions have been processed * * @category Storage */ export type TaskExecutionOnChildrenFinishedProcessingStatus = 'idle' | 'processing' | 'processed' /** * The status of the task execution cleanup/closure process. * * - `idle`: No closure process started * - `ready`: The task execution is ready to be closed * - `closing`: Currently performing cleanup (cancelling children, updating parent, etc.) * - `closed`: All cleanup completed * * @category Storage */ export type TaskExecutionCloseStatus = 'idle' | 'ready' | 'closing' | 'closed' /** * Creates a new task execution storage record with proper initial state. * * This factory function initializes all required fields for a task execution record and handles * the different initialization logic for sleeping vs regular tasks. * * ## Key Initialization Logic * * - **Regular tasks**: Start in `ready` status, no expiration time * - **Sleeping tasks**: Start in `running` status with expiration time set * - **Start timing**: `startAt` is delayed by `sleepMsBeforeRun` for rate limiting * - **State tracking**: All process states start as `idle` * * @param params - Task execution creation parameters * @param params.now - Current timestamp in milliseconds * @param params.root - Root task execution summary (optional) * @param params.parent - Parent task execution summary (optional) * @param params.taskId - Unique identifier for the task type * @param params.executionId - Unique identifier for this execution * @param params.sleepingTaskUniqueId - Unique identifier for sleeping tasks (optional) * @param params.retryOptions - Retry configuration for the task * @param params.sleepMsBeforeRun - Delay before execution starts (milliseconds) * @param params.timeoutMs - Maximum execution time (milliseconds) * @param params.areChildrenSequential - Whether child tasks should run sequentially * @param params.input - Serialized input data for the task * @returns Initialized task execution storage value * * @category Storage * @internal */ export function createTaskExecutionStorageValue({ now, root, parent, taskId, executionId, sleepingTaskUniqueId, retryOptions, sleepMsBeforeRun, timeoutMs, areChildrenSequential, input, }: { now: number root?: TaskExecutionSummary parent?: ParentTaskExecutionSummary taskId: string executionId: string sleepingTaskUniqueId?: string retryOptions: TaskRetryOptions sleepMsBeforeRun: number timeoutMs: number areChildrenSequential?: boolean input: string }): TaskExecutionStorageValue { const isSleepingTask = sleepingTaskUniqueId != null const value: TaskExecutionStorageValue = { root, parent, taskId, executionId, isSleepingTask, sleepingTaskUniqueId, retryOptions, sleepMsBeforeRun, timeoutMs, areChildrenSequential: areChildrenSequential ?? false, input, // Sleeping tasks start as 'running' (waiting for external wakeup) // Regular tasks start as 'ready' (waiting for executor to pick them up) status: isSleepingTask ? 'running' : 'ready', isFinished: false, retryAttempts: 0, // Delay execution by sleepMsBeforeRun startAt: now + sleepMsBeforeRun, activeChildrenCount: 0, onChildrenFinishedProcessingStatus: 'idle', closeStatus: 'idle', needsPromiseCancellation: false, createdAt: now, updatedAt: now, } // Only sleeping tasks have expiration times on creation (for timeout handling) // Other tasks have expiration times set on start if (isSleepingTask) { value.expiresAt = now + sleepMsBeforeRun + timeoutMs value.startedAt = now } return value } /** * The filters for task execution storage get by id. Storage values are filtered by the filters * before being returned. * * @category Storage */ export type TaskExecutionStorageGetByIdFilters = { isSleepingTask?: boolean status?: TaskExecutionStatus isFinished?: boolean } /** * The update for a task execution. See {@link TaskExecutionStorageValue} for more details about * the fields. * * @category Storage */ export type TaskExecutionStorageUpdate = { executorId?: string status?: TaskExecutionStatus isFinished?: boolean runOutput?: string output?: string error?: DurableExecutionErrorStorageValue retryAttempts?: number startAt?: number startedAt?: number expiresAt?: number waitingForChildrenStartedAt?: number waitingForFinalizeStartedAt?: number finishedAt?: number children?: ReadonlyArray<TaskExecutionSummary> activeChildrenCount?: number onChildrenFinishedProcessingStatus?: TaskExecutionOnChildrenFinishedProcessingStatus onChildrenFinishedProcessingExpiresAt?: number onChildrenFinishedProcessingFinishedAt?: number finalize?: TaskExecutionSummary closeStatus?: TaskExecutionCloseStatus closeExpiresAt?: number closedAt?: number needsPromiseCancellation?: boolean updatedAt: number unset?: { executorId?: boolean runOutput?: boolean error?: boolean startedAt?: boolean expiresAt?: boolean onChildrenFinishedProcessingExpiresAt?: boolean closeExpiresAt?: boolean } } /** * Applies a task execution storage update to a task execution storage value. This is used to * update the task execution storage value in the storage implementation before returning it to the * executor. * * @param execution - The task execution storage value to update. * @param update - The update to apply. * @param updateExpiresAtWithStartedAt - The expiresAt field will be set to the sum of the * startedAt field and the timeoutMs field. * @returns The updated task execution storage value. * * @category Storage */ export function applyTaskExecutionStorageUpdate( execution: TaskExecutionStorageValue, update: TaskExecutionStorageUpdate, updateExpiresAtWithStartedAt?: number, ): TaskExecutionStorageValue { for (const key in update) { if (key === 'unset') { for (const unsetKey in update.unset) { // @ts-expect-error - This is safe because we know the key is valid if (update.unset[unsetKey]) { // @ts-expect-error - This is safe because we know the key is valid execution[unsetKey] = undefined } } } else { // @ts-expect-error - This is safe because we know the key is valid // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment execution[key] = update[key] } } if (updateExpiresAtWithStartedAt) { execution.expiresAt = updateExpiresAtWithStartedAt + execution.timeoutMs } return execution } /** * Convert a task execution storage value to a task execution. * * @category Storage */ export const convertTaskExecutionStorageValueToTaskExecution = Effect.fn(function* <TOutput>( execution: TaskExecutionStorageValue, serializer: SerializerInternal, ) { const input = yield* serializer.deserialize(execution.input) const output = execution.output ? yield* serializer.deserialize<TOutput>(execution.output) : undefined switch (execution.status) { case 'ready': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, status: 'ready', error: execution.error, retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies ReadyTaskExecution as TaskExecution<TOutput> } case 'running': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, executorId: execution.executorId!, status: 'running', error: execution.error, retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), startedAt: new Date(execution.startedAt!), expiresAt: new Date(execution.expiresAt!), createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies RunningTaskExecution as TaskExecution<TOutput> } case 'failed': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, status: 'failed', error: execution.error!, retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), startedAt: new Date(execution.startedAt!), expiresAt: new Date(execution.expiresAt!), finishedAt: new Date(execution.finishedAt!), createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies FailedTaskExecution as TaskExecution<TOutput> } case 'timed_out': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, status: 'timed_out', error: execution.error!, retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), startedAt: new Date(execution.startedAt!), expiresAt: new Date(execution.expiresAt!), finishedAt: new Date(execution.finishedAt!), createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies TimedOutTaskExecution as TaskExecution<TOutput> } case 'waiting_for_children': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, status: 'waiting_for_children', retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), startedAt: new Date(execution.startedAt!), expiresAt: new Date(execution.expiresAt!), waitingForChildrenStartedAt: new Date(execution.waitingForChildrenStartedAt!), children: execution.children ?? [], activeChildrenCount: execution.activeChildrenCount, createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies WaitingForChildrenTaskExecution as TaskExecution<TOutput> } case 'waiting_for_finalize': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, status: 'waiting_for_finalize', retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), startedAt: new Date(execution.startedAt!), expiresAt: new Date(execution.expiresAt!), waitingForChildrenStartedAt: new Date(execution.waitingForChildrenStartedAt!), waitingForFinalizeStartedAt: new Date(execution.waitingForFinalizeStartedAt!), children: execution.children ?? [], activeChildrenCount: execution.activeChildrenCount, finalize: execution.finalize!, createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies WaitingForFinalizeTaskExecution as TaskExecution<TOutput> } case 'finalize_failed': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, status: 'finalize_failed', error: execution.error!, retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), startedAt: new Date(execution.startedAt!), expiresAt: new Date(execution.expiresAt!), waitingForChildrenStartedAt: new Date(execution.waitingForChildrenStartedAt!), waitingForFinalizeStartedAt: new Date(execution.waitingForFinalizeStartedAt!), finishedAt: new Date(execution.finishedAt!), children: execution.children ?? [], activeChildrenCount: execution.activeChildrenCount, finalize: execution.finalize!, createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies FinalizeFailedTaskExecution as TaskExecution<TOutput> } case 'completed': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, status: 'completed', output: output!, retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), startedAt: new Date(execution.startedAt!), expiresAt: new Date(execution.expiresAt!), waitingForChildrenStartedAt: execution.waitingForChildrenStartedAt ? new Date(execution.waitingForChildrenStartedAt) : undefined, waitingForFinalizeStartedAt: execution.waitingForFinalizeStartedAt ? new Date(execution.waitingForFinalizeStartedAt) : undefined, finishedAt: new Date(execution.finishedAt!), children: execution.children ?? [], activeChildrenCount: execution.activeChildrenCount, finalize: execution.finalize, createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies CompletedTaskExecution<TOutput> as TaskExecution<TOutput> } case 'cancelled': { return { root: execution.root, parent: execution.parent, taskId: execution.taskId, executionId: execution.executionId, sleepingTaskUniqueId: execution.sleepingTaskUniqueId, areChildrenSequential: execution.areChildrenSequential, retryOptions: execution.retryOptions, sleepMsBeforeRun: execution.sleepMsBeforeRun, timeoutMs: execution.timeoutMs, input, executorId: execution.executorId, status: 'cancelled', error: execution.error!, retryAttempts: execution.retryAttempts, startAt: new Date(execution.startAt), startedAt: execution.startedAt ? new Date(execution.startedAt) : undefined, expiresAt: execution.expiresAt ? new Date(execution.expiresAt) : undefined, waitingForChildrenStartedAt: execution.waitingForChildrenStartedAt ? new Date(execution.waitingForChildrenStartedAt) : undefined, waitingForFinalizeStartedAt: execution.waitingForFinalizeStartedAt ? new Date(execution.waitingForFinalizeStartedAt) : undefined, finishedAt: new Date(execution.finishedAt!), children: execution.children ?? [], activeChildrenCount: execution.activeChildrenCount, finalize: execution.finalize, createdAt: new Date(execution.createdAt), updatedAt: new Date(execution.updatedAt), } satisfies CancelledTaskExecution as TaskExecution<TOutput> } default: { return yield* Effect.fail( DurableExecutionError.nonRetryable( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Invalid task execution status [status=${execution.status}]`, ), ) } } }) /** * Wraps a {@link TaskExecutionsStorage} and makes all operations atomic by protecting them with a * mutex. Only use this if the underlying storage is not atomic. * * @category Storage */ export class TaskExecutionsStorageWithMutex implements TaskExecutionsStorage { private readonly storage: TaskExecutionsStorage private readonly mutex: Mutex constructor(storage: TaskExecutionsStorage) { this.storage = storage this.mutex = createMutex() } async withMutex<T>(fn: () => T | Promise<T>): Promise<T> { await this.mutex.acquire() try { return await fn() } finally { this.mutex.release() } } async insertMany(executions: ReadonlyArray<TaskExecutionStorageValue>): Promise<void> { return await this.withMutex(() => this.storage.insertMany(executions)) } async getManyById( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters }>, ): Promise<Array<TaskExecutionStorageValue | undefined>> { return await this.withMutex(() => this.storage.getManyById(requests)) } async getManyBySleepingTaskUniqueId( requests: ReadonlyArray<{ sleepingTaskUniqueId: string }>, ): Promise<Array<TaskExecutionStorageValue | undefined>> { return await this.withMutex(() => this.storage.getManyBySleepingTaskUniqueId(requests)) } async updateManyById( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters update: TaskExecutionStorageUpdate }>, ): Promise<void> { await this.withMutex(() => this.storage.updateManyById(requests)) } async updateManyByIdAndInsertChildrenIfUpdated( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters update: TaskExecutionStorageUpdate childrenTaskExecutionsToInsertIfAnyUpdated: ReadonlyArray<TaskExecutionStorageValue> }>, ): Promise<void> { return await this.withMutex(() => this.storage.updateManyByIdAndInsertChildrenIfUpdated(requests), ) } async updateByStatusAndStartAtLessThanAndReturn(request: { status: TaskExecutionStatus startAtLessThan: number update: TaskExecutionStorageUpdate updateExpiresAtWithStartedAt: number limit: number }): Promise<Array<TaskExecutionStorageValue>> { return await this.withMutex(() => this.storage.updateByStatusAndStartAtLessThanAndReturn(request), ) } async updateByStatusAndOnChildrenFinishedProcessingStatusAndActiveChildrenCountZeroAndReturn(request: { status: TaskExecutionStatus onChildrenFinishedProcessingStatus: TaskExecutionOnChildrenFinishedProcessingStatus update: TaskExecutionStorageUpdate limit: number }): Promise<Array<TaskExecutionStorageValue>> { return await this.withMutex(() => this.storage.updateByStatusAndOnChildrenFinishedProcessingStatusAndActiveChildrenCountZeroAndReturn( request, ), ) } async updateByCloseStatusAndReturn(request: { closeStatus: TaskExecutionCloseStatus update: TaskExecutionStorageUpdate limit: number }): Promise<Array<TaskExecutionStorageValue>> { return await this.withMutex(() => this.storage.updateByCloseStatusAndReturn(request)) } async updateByStatusAndIsSleepingTaskAndExpiresAtLessThan(request: { status: TaskExecutionStatus isSleepingTask: boolean expiresAtLessThan: number update: TaskExecutionStorageUpdate limit: number }): Promise<number> { return await this.withMutex(() => this.storage.updateByStatusAndIsSleepingTaskAndExpiresAtLessThan(request), ) } async updateByOnChildrenFinishedProcessingExpiresAtLessThan(request: { onChildrenFinishedProcessingExpiresAtLessThan: number update: TaskExecutionStorageUpdate limit: number }): Promise<number> { return await this.withMutex(() => this.storage.updateByOnChildrenFinishedProcessingExpiresAtLessThan(request), ) } async updateByCloseExpiresAtLessThan(request: { closeExpiresAtLessThan: number update: TaskExecutionStorageUpdate limit: number }): Promise<number> { return await this.withMutex(() => this.storage.updateByCloseExpiresAtLessThan(request)) } async updateByExecutorIdAndNeedsPromiseCancellationAndReturn(request: { executorId: string needsPromiseCancellation: boolean update: TaskExecutionStorageUpdate limit: number }): Promise<Array<TaskExecutionStorageValue>> { return await this.withMutex(() => this.storage.updateByExecutorIdAndNeedsPromiseCancellationAndReturn(request), ) } async getManyByParentExecutionId( requests: ReadonlyArray<{ parentExecutionId: string }>, ): Promise<Array<Array<TaskExecutionStorageValue>>> { return await this.withMutex(() => this.storage.getManyByParentExecutionId(requests)) } async updateManyByParentExecutionIdAndIsFinished( requests: ReadonlyArray<{ parentExecutionId: string isFinished: boolean update: TaskExecutionStorageUpdate }>, ): Promise<void> { return await this.withMutex(() => this.storage.updateManyByParentExecutionIdAndIsFinished(requests), ) } async updateAndDecrementParentActiveChildrenCountByIsFinishedAndCloseStatus(request: { isFinished: boolean closeStatus: TaskExecutionCloseStatus update: TaskExecutionStorageUpdate limit: number }): Promise<number> { return await this.withMutex(() => this.storage.updateAndDecrementParentActiveChildrenCountByIsFinishedAndCloseStatus(request), ) } async deleteById(request: { executionId: string }): Promise<void> { return await this.withMutex(() => this.storage.deleteById(request)) } async deleteAll(): Promise<void> { return await this.withMutex(() => this.storage.deleteAll()) } } /** * A storage interface for task executions without batching. It is similar to * {@link TaskExecutionsStorage} but without the batching methods. * * @category Storage */ export type TaskExecutionsStorageWithoutBatching = Omit< TaskExecutionsStorage, | 'getManyById' | 'getManyBySleepingTaskUniqueId' | 'updateManyById' | 'updateManyByIdAndInsertChildrenIfUpdated' | 'getManyByParentExecutionId' | 'updateManyByParentExecutionIdAndIsFinished' > & { /** * Get task execution by id and optionally filter them. * * @param request - The request object. * @param request.executionId - The id of the task execution to get. * @param request.filters - The filters to filter the task execution. * @returns The task execution. */ getById: (request: { executionId: string filters?: TaskExecutionStorageGetByIdFilters }) => TaskExecutionStorageValue | undefined | Promise<TaskExecutionStorageValue | undefined> /** * Get task execution by sleeping task unique id. * * @param request - The request object. * @param request.sleepingTaskUniqueId - The unique id of the sleeping task to get. * @returns The task execution. */ getBySleepingTaskUniqueId: (request: { sleepingTaskUniqueId: string }) => TaskExecutionStorageValue | undefined | Promise<TaskExecutionStorageValue | undefined> /** * Update task execution by id and optionally filter them. * * @param request - The request object. * @param request.executionId - The id of the task execution to update. * @param request.filters - The filters to filter the task execution. * @param request.update - The update object. */ updateById: (request: { executionId: string filters?: TaskExecutionStorageGetByIdFilters update: TaskExecutionStorageUpdate }) => void | Promise<void> /** * Update task execution by id and insert children task executions if updated. * * @param request - The request object. * @param request.executionId - The id of the task execution to update. * @param request.filters - The filters to filter the task execution. * @param request.update - The update object. * @param request.childrenTaskExecutionsToInsertIfAnyUpdated - The children task executions to * insert if the task execution was updated. */ updateByIdAndInsertChildrenIfUpdated: (request: { executionId: string filters?: TaskExecutionStorageGetByIdFilters update: TaskExecutionStorageUpdate childrenTaskExecutionsToInsertIfAnyUpdated: ReadonlyArray<TaskExecutionStorageValue> }) => void | Promise<void> /** * Get task executions by parent execution id. * * @param request - The request object. * @param request.parentExecutionId - The id of the parent task execution to get. * @returns The task executions. */ getByParentExecutionId: (request: { parentExecutionId: string }) => Array<TaskExecutionStorageValue> | Promise<Array<TaskExecutionStorageValue>> /** * Update task executions by parent execution id and is finished. * * @param request - The request object. * @param request.parentExecutionId - The id of the parent task execution to update. * @param request.isFinished - The is finished of the task executions to update. * @param request.update - The update object. */ updateByParentExecutionIdAndIsFinished: (request: { parentExecutionId: string isFinished: boolean update: TaskExecutionStorageUpdate }) => void | Promise<void> } /** * Wraps a {@link TaskExecutionsStorageWithoutBatching} and implements batching methods. * * @category Storage */ export class TaskExecutionsStorageWithBatching implements TaskExecutionsStorage { private readonly storage: TaskExecutionsStorageWithoutBatching private readonly disableParallelRequests: boolean constructor(storage: TaskExecutionsStorageWithoutBatching, disableParallelRequests = false) { this.storage = storage this.disableParallelRequests = disableParallelRequests } async insertMany(executions: ReadonlyArray<TaskExecutionStorageValue>): Promise<void> { return await this.storage.insertMany(executions) } async getManyById( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters }>, ): Promise<Array<TaskExecutionStorageValue | undefined>> { if (this.disableParallelRequests) { const results: Array<TaskExecutionStorageValue | undefined> = [] for (const request of requests) { const result = await this.storage.getById(request) results.push(result) } return results } return await Promise.all(requests.map((request) => this.storage.getById(request))) } async getManyBySleepingTaskUniqueId( requests: ReadonlyArray<{ sleepingTaskUniqueId: string }>, ): Promise<Array<TaskExecutionStorageValue | undefined>> { if (this.disableParallelRequests) { const results: Array<TaskExecutionStorageValue | undefined> = [] for (const request of requests) { const result = await this.storage.getBySleepingTaskUniqueId(request) results.push(result) } return results } return await Promise.all( requests.map((request) => this.storage.getBySleepingTaskUniqueId(request)), ) } async updateManyById( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters update: TaskExecutionStorageUpdate }>, ): Promise<void> { if (this.disableParallelRequests) { for (const request of requests) { await this.storage.updateById(request) } return } await Promise.all(requests.map((request) => this.storage.updateById(request))) } async updateManyByIdAndInsertChildrenIfUpdated( requests: ReadonlyArray<{ executionId: string filters?: TaskExecutionStorageGetByIdFilters update: TaskExecutionStorageUpdate childrenTaskExecutionsToInsertIfAnyUpdated: ReadonlyArray<TaskExecutionStorageValue> }>, ): Promise<void> { if (this.disableParallelRequests) { for (const request of requests) { await this.storage.updateByIdAndInsertChildrenIfUpdated(request) } return } await Promise.all( requests.map((request) => this.storage.updateByIdAndInsertChildrenIfUpdated(request)), ) } async updateByStatusAndStartAtLessThanAndReturn(request: { status: TaskExecutionStatus startAtLessThan: number update: TaskExecutionStorageUpdate updateExpiresAtWithStartedAt: number limit: number }): Promise<Array<TaskExecutionStorageValue>> { return await this.storage.updateByStatusAndStartAtLessThanAndReturn(request) } async updateByStatusAndOnChildrenFinishedProcessingStatusAndActiveChildrenCountZ