bunqueue 2.8.58 → 2.8.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/application/dlqManager.d.ts +1 -1
- package/dist/application/dlqManager.js +1 -1
- package/dist/application/lockManager.js +1 -1
- package/dist/application/queue-manager/ack.js +1 -1
- package/dist/application/queue-manager/delivery.js +1 -1
- package/dist/application/statsManager.d.ts +1 -1
- package/dist/application/statsManager.js +1 -1
- package/dist/client/events.js +2 -2
- package/dist/client/flow.js +0 -1
- package/dist/client/queue/operations/add/single.js +1 -1
- package/dist/client/sandboxed/runtime/state.js +2 -2
- package/dist/client/tcp/connection.d.ts +1 -1
- package/dist/client/tcp/connection.js +1 -1
- package/dist/client/tcp/runtime/state.js +2 -2
- package/dist/client/types/metrics.d.ts +1 -1
- package/dist/client/worker/runtime/control.js +0 -1
- package/dist/client/worker/runtime/lifecycle.js +0 -1
- package/dist/client/worker/runtime/state.js +3 -3
- package/dist/client/workflow/compensationChild.d.ts +2 -0
- package/dist/client/workflow/compensationChild.js +3 -1
- package/dist/client/workflow/compensationPass.d.ts +1 -0
- package/dist/client/workflow/compensationPass.js +18 -2
- package/dist/client/workflow/compensator.d.ts +2 -1
- package/dist/client/workflow/compensator.js +11 -3
- package/dist/client/workflow/engine.js +1 -1
- package/dist/client/workflow/executionFence.d.ts +15 -0
- package/dist/client/workflow/executionFence.js +29 -0
- package/dist/client/workflow/executor.d.ts +5 -11
- package/dist/client/workflow/executor.js +48 -47
- package/dist/client/workflow/executorLifecycle.d.ts +1 -0
- package/dist/client/workflow/executorLifecycle.js +41 -10
- package/dist/client/workflow/executorNodes.d.ts +1 -0
- package/dist/client/workflow/executorNodes.js +45 -16
- package/dist/client/workflow/executorQueue.d.ts +9 -0
- package/dist/client/workflow/executorQueue.js +15 -0
- package/dist/client/workflow/forEachRunner.d.ts +4 -0
- package/dist/client/workflow/forEachRunner.js +70 -0
- package/dist/client/workflow/loops.d.ts +4 -5
- package/dist/client/workflow/loops.js +27 -93
- package/dist/client/workflow/mapRunner.d.ts +1 -1
- package/dist/client/workflow/mapRunner.js +11 -2
- package/dist/client/workflow/recovery.d.ts +1 -0
- package/dist/client/workflow/recovery.js +24 -18
- package/dist/client/workflow/rollbackControl.d.ts +1 -0
- package/dist/client/workflow/rollbackControl.js +5 -2
- package/dist/client/workflow/runner.d.ts +3 -1
- package/dist/client/workflow/runner.js +26 -3
- package/dist/client/workflow/stepTypes.js +1 -1
- package/dist/client/workflow/subWorkflowRunner.d.ts +1 -0
- package/dist/client/workflow/subWorkflowRunner.js +7 -0
- package/dist/client/workflow/waitFor.d.ts +4 -0
- package/dist/client/workflow/waitFor.js +23 -11
- package/dist/client/workflow/workflowDecisions.d.ts +1 -1
- package/dist/client/workflow/workflowDecisions.js +3 -1
- package/dist/infrastructure/persistence/sqlite.d.ts +1 -1
- package/dist/infrastructure/server/handler-routes/jobs.js +0 -3
- package/dist/infrastructure/server/handlerRoutes.d.ts +2 -2
- package/dist/infrastructure/server/handlerRoutes.js +2 -2
- package/dist/infrastructure/server/httpEndpoints.js +2 -0
- package/package.json +12 -10
package/README.md
CHANGED
|
@@ -67,6 +67,10 @@ docker run -d -p 6789:6789 -p 6790:6790 \
|
|
|
67
67
|
ghcr.io/egeominotti/bunqueue:latest
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
Release images also carry the exact package version. For reproducible
|
|
71
|
+
deployments, pin `ghcr.io/egeominotti/bunqueue:2.8.60`; `latest` points to the
|
|
72
|
+
same multi-arch image at release time.
|
|
73
|
+
|
|
70
74
|
Then produce and process from the language you already use:
|
|
71
75
|
|
|
72
76
|
```bash
|
|
@@ -85,7 +89,7 @@ new Worker('emails', async (job) => ({ sent: true }), { concurrency: 10 });
|
|
|
85
89
|
Python, PHP, Go, Rust and Elixir clients speak the same protocol — see
|
|
86
90
|
[One Queue, Any Language](#one-queue-any-language-sdks).
|
|
87
91
|
|
|
88
|
-
> Only the server and embedded mode are **Bun-only** (`bun >= 1.
|
|
92
|
+
> Only the server and embedded mode are **Bun-only** (`bun >= 1.4.0`,
|
|
89
93
|
> [bun.sh](https://bun.sh)); producers and workers can run anywhere.
|
|
90
94
|
|
|
91
95
|
[Quick Start guide →](https://bunqueue.dev/guide/quickstart/)
|
|
@@ -19,7 +19,7 @@ export interface DlqContext {
|
|
|
19
19
|
};
|
|
20
20
|
storage: SqliteStorage | null;
|
|
21
21
|
}
|
|
22
|
-
export { processAutoRetry, retryDlqByFilter, retryDlqJob, retryDlqJobs
|
|
22
|
+
export { processAutoRetry, retryDlqByFilter, retryDlqJob, retryDlqJobs } from './dlqRetry';
|
|
23
23
|
/** Get jobs from DLQ (backward compatible) */
|
|
24
24
|
export declare function getDlqJobs(queue: string, ctx: DlqContext, count?: number): Job[];
|
|
25
25
|
/** Get DLQ entries with full metadata */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { MAX_TIMELINE_ENTRIES } from '../domain/types/job';
|
|
6
6
|
import { setDlqRetryState } from '../domain/types/dlq';
|
|
7
7
|
import { shardIndex } from '../shared/hash';
|
|
8
|
-
export { processAutoRetry, retryDlqByFilter, retryDlqJob, retryDlqJobs
|
|
8
|
+
export { processAutoRetry, retryDlqByFilter, retryDlqJob, retryDlqJobs } from './dlqRetry';
|
|
9
9
|
/** Get jobs from DLQ (backward compatible) */
|
|
10
10
|
export function getDlqJobs(queue, ctx, count) {
|
|
11
11
|
const idx = shardIndex(queue);
|
|
@@ -78,7 +78,7 @@ export async function checkExpiredLocks(ctx) {
|
|
|
78
78
|
* Process a single expired lock (called with both locks already held)
|
|
79
79
|
* Lock hierarchy already satisfied: shardLock -> processingLock held by caller
|
|
80
80
|
*/
|
|
81
|
-
//
|
|
81
|
+
// oxlint-disable-next-line max-params -- lock-processing inner fn needs the full lock context (7 params)
|
|
82
82
|
function processExpiredLockInner(jobId, lock, job, shardIdx, procIdx, ctx, now) {
|
|
83
83
|
const shard = ctx.shards[shardIdx];
|
|
84
84
|
const queue = shard.getQueue(job.queue);
|
|
@@ -126,7 +126,7 @@ export class QueueManagerAck extends QueueManagerDelivery {
|
|
|
126
126
|
this.syncJobTimeout(item.id);
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
-
//
|
|
129
|
+
// oxlint-disable-next-line max-params -- public compatibility signature carries removal policy
|
|
130
130
|
async fail(jobId, error, token, unrecoverable = false, stack, removeOnFail) {
|
|
131
131
|
return this.failWithOptions(jobId, error, token, { unrecoverable, stack, removeOnFail });
|
|
132
132
|
}
|
|
@@ -40,7 +40,7 @@ export class QueueManagerDelivery extends QueueManagerState {
|
|
|
40
40
|
this.scheduleJobTimeout(job);
|
|
41
41
|
return jobs;
|
|
42
42
|
}
|
|
43
|
-
//
|
|
43
|
+
// oxlint-disable-next-line max-params -- public API includes cancellation and lock policy
|
|
44
44
|
async pullBatchWithLock(queue, count, owner, timeoutMs = 0, lockTtl = DEFAULT_LOCK_TTL, signal) {
|
|
45
45
|
const jobs = await pullJobBatch(queue, count, timeoutMs, this.contextFactory.getPullContext(), signal);
|
|
46
46
|
const tokens = jobs.map((job) => lockMgr.createLock(job.id, owner, this.contextFactory.getLockContext(), lockTtl) ?? '');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Provides system metrics and memory compaction utilities
|
|
4
4
|
*/
|
|
5
5
|
import type { StatsContext } from './types';
|
|
6
|
-
export { getAllQueueJobCounts, getPerQueueStats, getQueueJobCounts
|
|
6
|
+
export { getAllQueueJobCounts, getPerQueueStats, getQueueJobCounts } from './queueStatsAggregator';
|
|
7
7
|
export type { PerQueueStats, QueueJobCounts } from './queueStatsAggregator';
|
|
8
8
|
export interface QueueStats {
|
|
9
9
|
waiting: number;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Provides system metrics and memory compaction utilities
|
|
4
4
|
*/
|
|
5
5
|
import { SHARD_COUNT } from '../shared/hash';
|
|
6
|
-
export { getAllQueueJobCounts, getPerQueueStats, getQueueJobCounts
|
|
6
|
+
export { getAllQueueJobCounts, getPerQueueStats, getQueueJobCounts } from './queueStatsAggregator';
|
|
7
7
|
/**
|
|
8
8
|
* Get queue statistics - uses running counters + priority scan
|
|
9
9
|
*/
|
package/dist/client/events.js
CHANGED
|
@@ -41,11 +41,11 @@ export class QueueEvents extends EventEmitter {
|
|
|
41
41
|
this.queueKey = (options.prefixKey ?? '') + name;
|
|
42
42
|
this.start();
|
|
43
43
|
}
|
|
44
|
-
//
|
|
44
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback overload requires any[]
|
|
45
45
|
on(event, listener) {
|
|
46
46
|
return super.on(event, listener);
|
|
47
47
|
}
|
|
48
|
-
//
|
|
48
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback overload requires any[]
|
|
49
49
|
once(event, listener) {
|
|
50
50
|
return super.once(event, listener);
|
|
51
51
|
}
|
package/dist/client/flow.js
CHANGED
|
@@ -129,7 +129,6 @@ export class FlowProducer extends EventEmitter {
|
|
|
129
129
|
return plan.roots.map((root) => this.buildPlannedNode(root, snapshotsById));
|
|
130
130
|
}
|
|
131
131
|
/** Get a flow tree starting from a job (BullMQ v5 compatible). */
|
|
132
|
-
// biome-ignore lint/suspicious/useAwait: preserves the public Promise return contract.
|
|
133
132
|
async getFlow(opts) {
|
|
134
133
|
return readFlow({
|
|
135
134
|
embedded: this.embedded,
|
|
@@ -57,7 +57,7 @@ export async function add(context, jobName, data, options = {}) {
|
|
|
57
57
|
updateProgress: async (id, progress, message) => {
|
|
58
58
|
await manager.updateProgress(jobId(id), progress, message);
|
|
59
59
|
},
|
|
60
|
-
//
|
|
60
|
+
// oxlint-disable-next-line typescript/require-await -- public callback contract is asynchronous
|
|
61
61
|
log: async (id, message) => {
|
|
62
62
|
manager.addLog(jobId(id), message);
|
|
63
63
|
},
|
|
@@ -3,11 +3,11 @@ import { getSharedManager } from '../../manager';
|
|
|
3
3
|
import { getSharedPool } from '../../tcpPool';
|
|
4
4
|
import { createEmbeddedOps, createTcpOps } from '../queueOps';
|
|
5
5
|
export class SandboxedState extends EventEmitter {
|
|
6
|
-
//
|
|
6
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback listener is intentionally untyped
|
|
7
7
|
on(event, listener) {
|
|
8
8
|
return super.on(event, listener);
|
|
9
9
|
}
|
|
10
|
-
//
|
|
10
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback listener is intentionally untyped
|
|
11
11
|
once(event, listener) {
|
|
12
12
|
return super.once(event, listener);
|
|
13
13
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Stable low-level TCP connection import surface. */
|
|
2
2
|
export { CommandQueue } from './commandQueue';
|
|
3
|
-
export { buildClientTls, createConnection, tlsRequiresVerification
|
|
3
|
+
export { buildClientTls, createConnection, tlsRequiresVerification } from './transport';
|
|
4
4
|
export type { ConnectionEvents, ConnectionResult, ConnectionTarget } from './transport';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
/** Stable low-level TCP connection import surface. */
|
|
2
2
|
export { CommandQueue } from './commandQueue';
|
|
3
|
-
export { buildClientTls, createConnection, tlsRequiresVerification
|
|
3
|
+
export { buildClientTls, createConnection, tlsRequiresVerification } from './transport';
|
|
@@ -5,11 +5,11 @@ import { ReconnectManager } from '../reconnect';
|
|
|
5
5
|
import { DEFAULT_CONNECTION } from '../types';
|
|
6
6
|
/** Shared TCP client state and event contract. */
|
|
7
7
|
export class TcpClientState extends EventEmitter {
|
|
8
|
-
//
|
|
8
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback overload requires any[]
|
|
9
9
|
on(event, listener) {
|
|
10
10
|
return super.on(event, listener);
|
|
11
11
|
}
|
|
12
|
-
//
|
|
12
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback overload requires any[]
|
|
13
13
|
once(event, listener) {
|
|
14
14
|
return super.once(event, listener);
|
|
15
15
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export type { QueueMetrics, QueueMetricsMeta, QueueMetricType
|
|
1
|
+
export type { QueueMetrics, QueueMetricsMeta, QueueMetricType } from '../../domain/types/metrics';
|
|
@@ -152,7 +152,6 @@ export class WorkerControl extends WorkerState {
|
|
|
152
152
|
async startStalledCheckTimer() {
|
|
153
153
|
// No-op for API compatibility; stall detection is automatic.
|
|
154
154
|
}
|
|
155
|
-
// biome-ignore lint/suspicious/useAwait: public compatibility contract is asynchronous
|
|
156
155
|
async delay(milliseconds = 0, abortController) {
|
|
157
156
|
if (milliseconds <= 0)
|
|
158
157
|
return;
|
|
@@ -2,7 +2,6 @@ import { jobId } from '../../../domain/types/job';
|
|
|
2
2
|
import { getSharedManager } from '../../manager';
|
|
3
3
|
import { WorkerManual } from './manual';
|
|
4
4
|
export class WorkerLifecycle extends WorkerManual {
|
|
5
|
-
// biome-ignore lint/suspicious/useAwait: preserves the public async close contract
|
|
6
5
|
async close(force = false) {
|
|
7
6
|
if (this.closed)
|
|
8
7
|
return;
|
|
@@ -46,15 +46,15 @@ export class WorkerState extends EventEmitter {
|
|
|
46
46
|
lastDrainedEmit = 0;
|
|
47
47
|
stalledUnsubscribe = null;
|
|
48
48
|
stalledSubscription = null;
|
|
49
|
-
//
|
|
49
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback listener is intentionally untyped
|
|
50
50
|
on(event, listener) {
|
|
51
51
|
return super.on(event, listener);
|
|
52
52
|
}
|
|
53
|
-
//
|
|
53
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback listener is intentionally untyped
|
|
54
54
|
once(event, listener) {
|
|
55
55
|
return super.once(event, listener);
|
|
56
56
|
}
|
|
57
|
-
//
|
|
57
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- EventEmitter's fallback listener is intentionally untyped
|
|
58
58
|
off(event, listener) {
|
|
59
59
|
return super.off(event, listener);
|
|
60
60
|
}
|
|
@@ -11,6 +11,7 @@ export interface ChildUnwindRequest {
|
|
|
11
11
|
emitter: WorkflowEmitter | null;
|
|
12
12
|
workflows?: Map<string, Workflow>;
|
|
13
13
|
retryFailed?: boolean;
|
|
14
|
+
assertActive: () => void;
|
|
14
15
|
}
|
|
15
16
|
export type ChildUnwind = (request: ChildUnwindRequest) => Promise<'ran' | LostCompensationClaim>;
|
|
16
17
|
export interface UnwindChildRequest {
|
|
@@ -20,6 +21,7 @@ export interface UnwindChildRequest {
|
|
|
20
21
|
workflows?: Map<string, Workflow>;
|
|
21
22
|
runChild: ChildUnwind;
|
|
22
23
|
retryFailed?: boolean;
|
|
24
|
+
assertActive: () => void;
|
|
23
25
|
}
|
|
24
26
|
/** Run the child workflow's own unwind and propagate every uncertain outcome. */
|
|
25
27
|
export declare function unwindChild(request: UnwindChildRequest): Promise<void>;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { isLive } from './admission';
|
|
3
3
|
/** Run the child workflow's own unwind and propagate every uncertain outcome. */
|
|
4
4
|
export async function unwindChild(request) {
|
|
5
|
-
const { record, store, emitter, workflows, runChild, retryFailed } = request;
|
|
5
|
+
const { record, store, emitter, workflows, runChild, retryFailed, assertActive } = request;
|
|
6
|
+
assertActive();
|
|
6
7
|
const childId = record.childExecutionId;
|
|
7
8
|
if (!childId)
|
|
8
9
|
throw new Error('sub-workflow record carries no child execution id');
|
|
@@ -29,6 +30,7 @@ export async function unwindChild(request) {
|
|
|
29
30
|
emitter,
|
|
30
31
|
workflows,
|
|
31
32
|
retryFailed,
|
|
33
|
+
assertActive,
|
|
32
34
|
});
|
|
33
35
|
if (outcome !== 'ran') {
|
|
34
36
|
throw new Error(`child "${child.workflowName}" (${childId}) is being rolled back by another driver; ` +
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/** One checkpointed compensation pass. */
|
|
2
2
|
import { compensationContext, settle } from './compensationSupport';
|
|
3
3
|
import { unwindChild } from './compensationChild';
|
|
4
|
+
import { isWorkflowExecutionClosed } from './executionFence';
|
|
4
5
|
import { describeError } from './identity';
|
|
5
6
|
import { buildContext, findStepDef, runWithTimeout } from './runner';
|
|
6
7
|
import { decideUnwindAction } from './unwindPlan';
|
|
7
8
|
export async function unwind(pass, runChild) {
|
|
8
|
-
const { exec, wf, store, emitter, eligible, workflows, retryFailed } = pass;
|
|
9
|
+
const { exec, wf, store, emitter, eligible, workflows, retryFailed, assertActive } = pass;
|
|
9
10
|
const baseCtx = buildContext(exec);
|
|
10
11
|
let haltedAt = null;
|
|
11
12
|
let writeFailure;
|
|
13
|
+
assertActive();
|
|
12
14
|
exec.state = 'compensating';
|
|
13
15
|
try {
|
|
14
16
|
store.update(exec);
|
|
@@ -20,8 +22,10 @@ export async function unwind(pass, runChild) {
|
|
|
20
22
|
}
|
|
21
23
|
if (writeFailure === undefined) {
|
|
22
24
|
emitter?.emitWorkflow('workflow:compensating', exec.id, exec.workflowName, 'compensating');
|
|
25
|
+
assertActive();
|
|
23
26
|
}
|
|
24
27
|
for (const [name, record] of eligible) {
|
|
28
|
+
assertActive();
|
|
25
29
|
if (writeFailure !== undefined)
|
|
26
30
|
break;
|
|
27
31
|
const action = decideUnwindAction(wf, name, record, haltedAt !== null, retryFailed);
|
|
@@ -39,12 +43,22 @@ export async function unwind(pass, runChild) {
|
|
|
39
43
|
emitter?.emitStep('compensation:failed', exec.id, exec.workflowName, name, {
|
|
40
44
|
error: 'step no longer declared',
|
|
41
45
|
});
|
|
46
|
+
assertActive();
|
|
42
47
|
continue;
|
|
43
48
|
}
|
|
44
49
|
emitter?.emitStep('compensation:started', exec.id, exec.workflowName, name);
|
|
50
|
+
assertActive();
|
|
45
51
|
try {
|
|
46
52
|
if (action.kind === 'unwind-child') {
|
|
47
|
-
await unwindChild({
|
|
53
|
+
await unwindChild({
|
|
54
|
+
record,
|
|
55
|
+
store,
|
|
56
|
+
emitter,
|
|
57
|
+
workflows,
|
|
58
|
+
runChild,
|
|
59
|
+
retryFailed,
|
|
60
|
+
assertActive,
|
|
61
|
+
});
|
|
48
62
|
}
|
|
49
63
|
else {
|
|
50
64
|
const def = findStepDef(wf, name);
|
|
@@ -59,6 +73,8 @@ export async function unwind(pass, runChild) {
|
|
|
59
73
|
emitter?.emitStep('compensation:completed', exec.id, exec.workflowName, name);
|
|
60
74
|
}
|
|
61
75
|
catch (error) {
|
|
76
|
+
if (isWorkflowExecutionClosed(error))
|
|
77
|
+
throw error;
|
|
62
78
|
const message = describeError(error);
|
|
63
79
|
settle(record, 'compensation-failed', message);
|
|
64
80
|
emitter?.emitStep('compensation:failed', exec.id, exec.workflowName, name, {
|
|
@@ -25,9 +25,10 @@ export type UnwindOutcome = 'ran' | LostCompensationClaim;
|
|
|
25
25
|
/** Run compensation handlers in reverse start order for every eligible step. */
|
|
26
26
|
export declare function runCompensation(exec: Execution, wf: Workflow, store: WorkflowStore, emitter: WorkflowEmitter | null, workflows?: Map<string, Workflow>, opts?: {
|
|
27
27
|
retryFailed?: boolean;
|
|
28
|
+
assertActive?: () => void;
|
|
28
29
|
}): Promise<UnwindOutcome>;
|
|
29
30
|
/**
|
|
30
31
|
* Give up on a parked unwind and record an explicit skipped outcome for every
|
|
31
32
|
* remaining eligible step.
|
|
32
33
|
*/
|
|
33
|
-
export declare function abandonCompensation(exec: Execution, wf: Workflow, store: WorkflowStore, emitter: WorkflowEmitter | null): void;
|
|
34
|
+
export declare function abandonCompensation(exec: Execution, wf: Workflow, store: WorkflowStore, emitter: WorkflowEmitter | null, assertActive?: () => void): void;
|
|
@@ -9,6 +9,7 @@ import { unwind } from './compensationPass';
|
|
|
9
9
|
import { AbandonedCompensationError } from './compensationChild';
|
|
10
10
|
import { acquireCompensationClaim } from './compensationClaim';
|
|
11
11
|
import { settle, unwindSet } from './compensationSupport';
|
|
12
|
+
import { assertWorkflowActive } from './executionFence';
|
|
12
13
|
import { owesOutcome } from './unwindPlan';
|
|
13
14
|
export { AbandonedCompensationError, CommittedChildError } from './compensationChild';
|
|
14
15
|
/** Sentinel thrown when an execution deliberately parks for a signal. */
|
|
@@ -20,8 +21,10 @@ export class WaitForSignalError extends Error {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
/** Run compensation handlers in reverse start order for every eligible step. */
|
|
23
|
-
//
|
|
24
|
+
// oxlint-disable-next-line max-params -- registry and operator retry are independent inputs
|
|
24
25
|
export async function runCompensation(exec, wf, store, emitter, workflows, opts = {}) {
|
|
26
|
+
const assertActive = opts.assertActive ?? assertWorkflowActive;
|
|
27
|
+
assertActive();
|
|
25
28
|
// `failed` + `stuck` is the durable terminal form written by an explicit
|
|
26
29
|
// abandon. No caller, including an ancestor retry, may reopen that decision.
|
|
27
30
|
if (exec.state === 'failed' && exec.rollbackStatus === 'stuck') {
|
|
@@ -29,17 +32,19 @@ export async function runCompensation(exec, wf, store, emitter, workflows, opts
|
|
|
29
32
|
}
|
|
30
33
|
const eligible = unwindSet(exec, wf);
|
|
31
34
|
if (eligible.length === 0) {
|
|
35
|
+
assertActive();
|
|
32
36
|
exec.rollbackStatus = 'not-applicable';
|
|
33
37
|
if (exec.state !== 'failed' && exec.state !== 'completed')
|
|
34
38
|
exec.state = 'failed';
|
|
35
39
|
store.update(exec);
|
|
36
40
|
return 'ran';
|
|
37
41
|
}
|
|
42
|
+
assertActive();
|
|
38
43
|
const claim = acquireCompensationClaim(exec.id, store);
|
|
39
44
|
if (claim.kind === 'claim-lost')
|
|
40
45
|
return claim;
|
|
41
46
|
try {
|
|
42
|
-
await unwind({ exec, wf, store, emitter, eligible, workflows, ...opts }, ({ exec, workflow, store, emitter, workflows, retryFailed }) => runCompensation(exec, workflow, store, emitter, workflows, { retryFailed }));
|
|
47
|
+
await unwind({ exec, wf, store, emitter, eligible, workflows, ...opts, assertActive }, ({ exec, workflow, store, emitter, workflows, retryFailed, assertActive }) => runCompensation(exec, workflow, store, emitter, workflows, { retryFailed, assertActive }));
|
|
43
48
|
}
|
|
44
49
|
finally {
|
|
45
50
|
claim.release();
|
|
@@ -50,8 +55,9 @@ export async function runCompensation(exec, wf, store, emitter, workflows, opts
|
|
|
50
55
|
* Give up on a parked unwind and record an explicit skipped outcome for every
|
|
51
56
|
* remaining eligible step.
|
|
52
57
|
*/
|
|
53
|
-
export function abandonCompensation(exec, wf, store, emitter) {
|
|
58
|
+
export function abandonCompensation(exec, wf, store, emitter, assertActive = assertWorkflowActive) {
|
|
54
59
|
for (const [name, record] of unwindSet(exec, wf)) {
|
|
60
|
+
assertActive();
|
|
55
61
|
if (record.compensation)
|
|
56
62
|
continue;
|
|
57
63
|
if (!owesOutcome(wf, name, record))
|
|
@@ -59,7 +65,9 @@ export function abandonCompensation(exec, wf, store, emitter) {
|
|
|
59
65
|
const reason = 'unwind abandoned by operator';
|
|
60
66
|
settle(record, 'compensation-skipped', reason);
|
|
61
67
|
emitter?.emitStep('compensation:skipped', exec.id, exec.workflowName, name, { error: reason });
|
|
68
|
+
assertActive();
|
|
62
69
|
}
|
|
70
|
+
assertActive();
|
|
63
71
|
exec.state = 'failed';
|
|
64
72
|
exec.rollbackStatus = 'stuck';
|
|
65
73
|
store.update(exec);
|
|
@@ -136,7 +136,7 @@ export class Engine {
|
|
|
136
136
|
// Before the worker: a waitFor timer that fires during shutdown enqueues a step
|
|
137
137
|
// job, and a queue closing underneath it turns an orderly shutdown into a rejected
|
|
138
138
|
// add. Releasing the timers first makes the order irrelevant.
|
|
139
|
-
this.executor.close();
|
|
139
|
+
this.executor.close(force);
|
|
140
140
|
await this.worker.close(force);
|
|
141
141
|
this.queue.close();
|
|
142
142
|
this.store.close();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Lifecycle fence shared by every asynchronous workflow execution path. */
|
|
2
|
+
/** Internal control-flow signal used to abandon work owned by a closed Engine. */
|
|
3
|
+
export declare class WorkflowExecutionClosedError extends Error {
|
|
4
|
+
constructor();
|
|
5
|
+
}
|
|
6
|
+
export declare function isWorkflowExecutionClosed(error: unknown): boolean;
|
|
7
|
+
/** A graceful close leaves this active; only close(true) aborts in-flight control flow. */
|
|
8
|
+
export declare class WorkflowExecutionFence {
|
|
9
|
+
private readonly controller;
|
|
10
|
+
readonly assertActive: () => void;
|
|
11
|
+
isActive(): boolean;
|
|
12
|
+
close(force: boolean): void;
|
|
13
|
+
}
|
|
14
|
+
/** Default for isolated runner tests that do not own an Engine lifecycle. */
|
|
15
|
+
export declare const assertWorkflowActive: () => void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Lifecycle fence shared by every asynchronous workflow execution path. */
|
|
2
|
+
/** Internal control-flow signal used to abandon work owned by a closed Engine. */
|
|
3
|
+
export class WorkflowExecutionClosedError extends Error {
|
|
4
|
+
constructor() {
|
|
5
|
+
super('Workflow execution belongs to a force-closed Engine');
|
|
6
|
+
this.name = 'WorkflowExecutionClosedError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isWorkflowExecutionClosed(error) {
|
|
10
|
+
return (error instanceof WorkflowExecutionClosedError ||
|
|
11
|
+
(error instanceof AggregateError && error.errors.some(isWorkflowExecutionClosed)));
|
|
12
|
+
}
|
|
13
|
+
/** A graceful close leaves this active; only close(true) aborts in-flight control flow. */
|
|
14
|
+
export class WorkflowExecutionFence {
|
|
15
|
+
controller = new AbortController();
|
|
16
|
+
assertActive = () => {
|
|
17
|
+
if (this.controller.signal.aborted)
|
|
18
|
+
throw new WorkflowExecutionClosedError();
|
|
19
|
+
};
|
|
20
|
+
isActive() {
|
|
21
|
+
return !this.controller.signal.aborted;
|
|
22
|
+
}
|
|
23
|
+
close(force) {
|
|
24
|
+
if (force)
|
|
25
|
+
this.controller.abort();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Default for isolated runner tests that do not own an Engine lifecycle. */
|
|
29
|
+
export const assertWorkflowActive = () => undefined;
|
|
@@ -10,13 +10,14 @@ export declare class WorkflowExecutor {
|
|
|
10
10
|
private readonly emitter;
|
|
11
11
|
private readonly workflows;
|
|
12
12
|
private readonly timeoutTimers;
|
|
13
|
+
private readonly fence;
|
|
13
14
|
/**
|
|
14
15
|
* Release every armed waitFor timer. The engine owns the executor's lifetime, so
|
|
15
16
|
* `Engine.close()` must call this: an armed timer would otherwise fire into a
|
|
16
17
|
* closing queue, and a caller that keeps the process alive after closing one engine
|
|
17
18
|
* has no other handle on them.
|
|
18
19
|
*/
|
|
19
|
-
close(): void;
|
|
20
|
+
close(force?: boolean): void;
|
|
20
21
|
private readonly updateFn;
|
|
21
22
|
constructor(store: WorkflowStore, queue: Queue, emitter?: WorkflowEmitter | null);
|
|
22
23
|
register(workflow: Workflow): void;
|
|
@@ -24,16 +25,9 @@ export declare class WorkflowExecutor {
|
|
|
24
25
|
/**
|
|
25
26
|
* Nodes this process is currently executing, keyed `<execution>:<nodeIndex>`.
|
|
26
27
|
*
|
|
27
|
-
* The cursor
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `running` execution and is documented as callable on a live engine. Without this
|
|
31
|
-
* claim the node runs twice and each copy advances the run independently, doubling
|
|
32
|
-
* every side effect after it while the run still ends `completed`.
|
|
33
|
-
*
|
|
34
|
-
* A claim, not a queue-level dedup: a deterministic `jobId` was tried and could
|
|
35
|
-
* swallow a LEGITIMATE later re-enqueue of the same node, wedging the run forever.
|
|
36
|
-
* This drops only a duplicate that overlaps in time.
|
|
28
|
+
* The cursor rejects stale jobs but not two overlapping deliveries for the current
|
|
29
|
+
* node. This process-local claim drops that overlap. Queue-level dedup is unsuitable:
|
|
30
|
+
* a retained ID can swallow a legitimate recovery enqueue and wedge the run.
|
|
37
31
|
*/
|
|
38
32
|
private readonly nodesInFlight;
|
|
39
33
|
processStep(data: StepJobData): Promise<unknown>;
|