bunqueue 2.8.49 → 2.8.51
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 +62 -9
- package/dist/application/backgroundTasks.js +21 -4
- package/dist/application/cleanupTasks.js +7 -3
- package/dist/application/contextFactory.d.ts +6 -3
- package/dist/application/contextFactory.js +8 -0
- package/dist/application/dependencyCompletions.d.ts +53 -0
- package/dist/application/dependencyCompletions.js +123 -0
- package/dist/application/dependencyProcessor.d.ts +5 -0
- package/dist/application/dependencyProcessor.js +25 -13
- package/dist/application/flowFailureRecovery.d.ts +20 -0
- package/dist/application/flowFailureRecovery.js +94 -0
- package/dist/application/flowParentBackpatch.d.ts +27 -0
- package/dist/application/flowParentBackpatch.js +120 -0
- package/dist/application/operations/ack.d.ts +6 -3
- package/dist/application/operations/ack.js +36 -10
- package/dist/application/operations/ackHelpers.d.ts +5 -4
- package/dist/application/operations/ackHelpers.js +4 -10
- package/dist/application/operations/customId.d.ts +3 -0
- package/dist/application/operations/customId.js +10 -0
- package/dist/application/operations/flowPush.d.ts +9 -0
- package/dist/application/operations/flowPush.js +112 -0
- package/dist/application/operations/flowTopologyValidation.d.ts +3 -0
- package/dist/application/operations/flowTopologyValidation.js +101 -0
- package/dist/application/operations/flowValidation.d.ts +3 -0
- package/dist/application/operations/flowValidation.js +166 -0
- package/dist/application/operations/jobManagement.d.ts +3 -0
- package/dist/application/operations/jobManagement.js +10 -4
- package/dist/application/operations/push.d.ts +6 -1
- package/dist/application/operations/push.js +2 -2
- package/dist/application/operations/pushInsert.d.ts +11 -2
- package/dist/application/operations/pushInsert.js +20 -8
- package/dist/application/operations/pushLocks.d.ts +1 -0
- package/dist/application/operations/pushLocks.js +5 -1
- package/dist/application/operations/queryOperations.js +7 -6
- package/dist/application/queueManager.d.ts +7 -0
- package/dist/application/queueManager.js +261 -67
- package/dist/application/types.d.ts +3 -1
- package/dist/cli/commandRouter.d.ts +1 -1
- package/dist/client/flow.d.ts +3 -6
- package/dist/client/flow.js +71 -210
- package/dist/client/flowAtomic.d.ts +9 -0
- package/dist/client/flowAtomic.js +24 -0
- package/dist/client/flowJobCoreMethods.d.ts +28 -0
- package/dist/client/flowJobCoreMethods.js +151 -0
- package/dist/client/flowJobDependencies.d.ts +7 -0
- package/dist/client/flowJobDependencies.js +64 -0
- package/dist/client/flowJobFactory.d.ts +11 -24
- package/dist/client/flowJobFactory.js +91 -360
- package/dist/client/flowJobMoveMethods.d.ts +16 -0
- package/dist/client/flowJobMoveMethods.js +112 -0
- package/dist/client/flowJobTypes.d.ts +29 -0
- package/dist/client/flowJobTypes.js +6 -0
- package/dist/client/flowLegacyPlan.d.ts +16 -0
- package/dist/client/flowLegacyPlan.js +118 -0
- package/dist/client/flowOptions.d.ts +10 -0
- package/dist/client/flowOptions.js +44 -0
- package/dist/client/flowPlan.d.ts +16 -0
- package/dist/client/flowPlan.js +101 -0
- package/dist/client/flowPush.js +4 -1
- package/dist/client/flowReader.d.ts +10 -0
- package/dist/client/flowReader.js +147 -0
- package/dist/client/flowTypes.d.ts +2 -2
- package/dist/client/jobHelpers.js +2 -0
- package/dist/client/workflow/clock.d.ts +3 -1
- package/dist/client/workflow/clock.js +18 -1
- package/dist/client/workflow/compensationChild.d.ts +30 -0
- package/dist/client/workflow/compensationChild.js +65 -0
- package/dist/client/workflow/compensationPass.d.ts +16 -0
- package/dist/client/workflow/compensationPass.js +96 -0
- package/dist/client/workflow/compensationSupport.d.ts +24 -0
- package/dist/client/workflow/compensationSupport.js +57 -0
- package/dist/client/workflow/compensator.d.ts +11 -50
- package/dist/client/workflow/compensator.js +22 -374
- package/dist/client/workflow/definitionGuard.d.ts +11 -0
- package/dist/client/workflow/definitionGuard.js +22 -0
- package/dist/client/workflow/engine.d.ts +2 -2
- package/dist/client/workflow/engine.js +8 -8
- package/dist/client/workflow/eventTypes.d.ts +44 -0
- package/dist/client/workflow/eventTypes.js +2 -0
- package/dist/client/workflow/executionTypes.d.ts +133 -0
- package/dist/client/workflow/executionTypes.js +2 -0
- package/dist/client/workflow/executor.d.ts +4 -17
- package/dist/client/workflow/executor.js +50 -208
- package/dist/client/workflow/executorLifecycle.d.ts +18 -0
- package/dist/client/workflow/executorLifecycle.js +69 -0
- package/dist/client/workflow/executorNodes.d.ts +17 -0
- package/dist/client/workflow/executorNodes.js +138 -0
- package/dist/client/workflow/identity.d.ts +2 -0
- package/dist/client/workflow/identity.js +8 -0
- package/dist/client/workflow/index.d.ts +1 -1
- package/dist/client/workflow/loops.d.ts +3 -5
- package/dist/client/workflow/loops.js +25 -23
- package/dist/client/workflow/mapRunner.d.ts +4 -0
- package/dist/client/workflow/mapRunner.js +45 -0
- package/dist/client/workflow/recovery.js +10 -3
- package/dist/client/workflow/runner.d.ts +2 -21
- package/dist/client/workflow/runner.js +39 -104
- package/dist/client/workflow/runnerTiming.d.ts +14 -0
- package/dist/client/workflow/runnerTiming.js +66 -0
- package/dist/client/workflow/stepTypes.d.ts +171 -0
- package/dist/client/workflow/stepTypes.js +3 -0
- package/dist/client/workflow/store.d.ts +17 -17
- package/dist/client/workflow/store.js +65 -100
- package/dist/client/workflow/storeExecutionCodec.d.ts +11 -0
- package/dist/client/workflow/storeExecutionCodec.js +34 -0
- package/dist/client/workflow/storeListing.d.ts +11 -0
- package/dist/client/workflow/storeListing.js +45 -0
- package/dist/client/workflow/storeMaintenance.d.ts +4 -0
- package/dist/client/workflow/storeMaintenance.js +40 -0
- package/dist/client/workflow/storeSignals.d.ts +9 -0
- package/dist/client/workflow/storeSignals.js +38 -2
- package/dist/client/workflow/subWorkflowRunner.d.ts +13 -0
- package/dist/client/workflow/subWorkflowRunner.js +40 -0
- package/dist/client/workflow/types.d.ts +4 -356
- package/dist/client/workflow/types.js +1 -3
- package/dist/client/workflow/waitFor.js +39 -26
- package/dist/client/workflow/workflow.d.ts +16 -59
- package/dist/client/workflow/workflow.js +53 -179
- package/dist/client/workflow/workflowDecisions.d.ts +11 -0
- package/dist/client/workflow/workflowDecisions.js +27 -0
- package/dist/client/workflow/workflowDefinition.d.ts +16 -0
- package/dist/client/workflow/workflowDefinition.js +123 -0
- package/dist/client/workflow/workflowIntrospection.d.ts +5 -0
- package/dist/client/workflow/workflowIntrospection.js +46 -0
- package/dist/client/workflow/workflowValidation.d.ts +44 -0
- package/dist/client/workflow/workflowValidation.js +143 -0
- package/dist/domain/types/command.d.ts +7 -1
- package/dist/domain/types/flow.d.ts +25 -0
- package/dist/domain/types/flow.js +1 -0
- package/dist/infrastructure/persistence/dependencyCompletionSchema.d.ts +6 -0
- package/dist/infrastructure/persistence/dependencyCompletionSchema.js +16 -0
- package/dist/infrastructure/persistence/dependencyCompletionStore.d.ts +38 -0
- package/dist/infrastructure/persistence/dependencyCompletionStore.js +105 -0
- package/dist/infrastructure/persistence/schema.d.ts +2 -5
- package/dist/infrastructure/persistence/schema.js +57 -3
- package/dist/infrastructure/persistence/sqlite.d.ts +35 -0
- package/dist/infrastructure/persistence/sqlite.js +238 -12
- package/dist/infrastructure/persistence/sqliteBatch.js +10 -4
- package/dist/infrastructure/persistence/sqliteSerializer.d.ts +5 -0
- package/dist/infrastructure/persistence/sqliteSerializer.js +28 -5
- package/dist/infrastructure/persistence/statements.d.ts +4 -0
- package/dist/infrastructure/persistence/statements.js +8 -2
- package/dist/infrastructure/server/handlerRoutes.js +3 -0
- package/dist/infrastructure/server/handlers/advanced.js +2 -2
- package/dist/infrastructure/server/handlers/flow.d.ts +7 -0
- package/dist/infrastructure/server/handlers/flow.js +11 -0
- package/dist/infrastructure/server/handlers/index.d.ts +1 -0
- package/dist/infrastructure/server/handlers/index.js +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
function assertMatchingIds(actual, expected, name) {
|
|
2
|
+
if (!Array.isArray(actual) ||
|
|
3
|
+
actual.length !== expected.length ||
|
|
4
|
+
actual.some((id, index) => id !== expected[index])) {
|
|
5
|
+
throw new Error(`${name} does not match the canonical flow topology`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function assertAcyclic(topology) {
|
|
9
|
+
const remaining = new Map([...topology].map(([id, links]) => [id, links.dependencies.length]));
|
|
10
|
+
const dependents = new Map();
|
|
11
|
+
for (const [id, links] of topology) {
|
|
12
|
+
for (const dependency of links.dependencies) {
|
|
13
|
+
const list = dependents.get(dependency) ?? [];
|
|
14
|
+
list.push(id);
|
|
15
|
+
dependents.set(dependency, list);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const ready = [...remaining].filter(([, count]) => count === 0).map(([id]) => id);
|
|
19
|
+
let visited = 0;
|
|
20
|
+
while (ready.length > 0) {
|
|
21
|
+
const id = ready.pop();
|
|
22
|
+
if (id === undefined)
|
|
23
|
+
throw new Error('flow topology traversal failed');
|
|
24
|
+
visited++;
|
|
25
|
+
for (const dependent of dependents.get(id) ?? []) {
|
|
26
|
+
const count = remaining.get(dependent);
|
|
27
|
+
if (count === undefined)
|
|
28
|
+
throw new Error(`flow dependency not found: ${dependent}`);
|
|
29
|
+
const next = count - 1;
|
|
30
|
+
remaining.set(dependent, next);
|
|
31
|
+
if (next === 0)
|
|
32
|
+
ready.push(dependent);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (visited !== topology.size)
|
|
36
|
+
throw new Error('flow contains a dependency cycle');
|
|
37
|
+
}
|
|
38
|
+
function buildTopology(job) {
|
|
39
|
+
const dependencies = (job.input.dependsOn ?? []).map(String);
|
|
40
|
+
const children = (job.input.childrenIds ?? []).map(String);
|
|
41
|
+
const dependencySet = new Set(dependencies);
|
|
42
|
+
const childSet = new Set(children);
|
|
43
|
+
if (dependencySet.size !== dependencies.length) {
|
|
44
|
+
throw new Error(`duplicate dependency in flow job: ${String(job.id)}`);
|
|
45
|
+
}
|
|
46
|
+
if (childSet.size !== children.length) {
|
|
47
|
+
throw new Error(`duplicate child in flow job: ${String(job.id)}`);
|
|
48
|
+
}
|
|
49
|
+
return { dependencies, dependencySet, children, childSet };
|
|
50
|
+
}
|
|
51
|
+
/** Validate references, symmetric parent edges, metadata, and acyclicity in O(V+E). */
|
|
52
|
+
export function validateFlowTopology(batch, dataById) {
|
|
53
|
+
const jobsById = new Map(batch.jobs.map((job) => [String(job.id), job]));
|
|
54
|
+
const topology = new Map(batch.jobs.map((job) => [String(job.id), buildTopology(job)]));
|
|
55
|
+
for (const job of batch.jobs) {
|
|
56
|
+
const id = String(job.id);
|
|
57
|
+
const links = topology.get(id);
|
|
58
|
+
if (!links)
|
|
59
|
+
throw new Error(`flow topology missing job: ${id}`);
|
|
60
|
+
for (const dependency of links.dependencies) {
|
|
61
|
+
if (!jobsById.has(dependency))
|
|
62
|
+
throw new Error(`flow dependency not found: ${dependency}`);
|
|
63
|
+
if (dependency === id)
|
|
64
|
+
throw new Error(`flow job cannot depend on itself: ${id}`);
|
|
65
|
+
}
|
|
66
|
+
for (const child of links.children) {
|
|
67
|
+
if (!links.dependencySet.has(child)) {
|
|
68
|
+
throw new Error(`flow child is missing from dependencies: ${child}`);
|
|
69
|
+
}
|
|
70
|
+
const childJob = jobsById.get(child);
|
|
71
|
+
if (!childJob)
|
|
72
|
+
throw new Error(`flow child not found: ${child}`);
|
|
73
|
+
if (String(childJob.input.parentId) !== id) {
|
|
74
|
+
throw new Error(`flow child ${child} does not point back to parent ${id}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const parentId = job.input.parentId ? String(job.input.parentId) : null;
|
|
78
|
+
if (parentId) {
|
|
79
|
+
const parent = jobsById.get(parentId);
|
|
80
|
+
if (!parent)
|
|
81
|
+
throw new Error(`flow parent not found: ${parentId}`);
|
|
82
|
+
const parentLinks = topology.get(parentId);
|
|
83
|
+
if (!parentLinks?.childSet.has(id)) {
|
|
84
|
+
throw new Error(`flow parent ${parentId} does not own child ${id}`);
|
|
85
|
+
}
|
|
86
|
+
const data = dataById.get(id);
|
|
87
|
+
if (!data)
|
|
88
|
+
throw new Error(`flow metadata missing for child ${id}`);
|
|
89
|
+
if (data.__parentId !== parentId || data.__parentQueue !== parent.queue) {
|
|
90
|
+
throw new Error(`flow child ${id} metadata does not match parent ${parentId}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (links.children.length > 0) {
|
|
94
|
+
const data = dataById.get(id);
|
|
95
|
+
if (!data)
|
|
96
|
+
throw new Error(`flow metadata missing for parent ${id}`);
|
|
97
|
+
assertMatchingIds(data.__childrenIds, links.children, `flow parent ${id} children`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
assertAcyclic(topology);
|
|
101
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { validateFlowTopology } from './flowTopologyValidation';
|
|
2
|
+
const MAX_FLOW_JOBS = 10_000;
|
|
3
|
+
const MAX_JOB_DATA_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
const MAX_FLOW_DATA_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
const QUEUE_RE = /^[a-zA-Z0-9_\-.:]+$/;
|
|
6
|
+
const BOOLEAN_OPTIONS = [
|
|
7
|
+
'lifo',
|
|
8
|
+
'removeOnComplete',
|
|
9
|
+
'removeOnFail',
|
|
10
|
+
'durable',
|
|
11
|
+
'failParentOnFailure',
|
|
12
|
+
'removeDependencyOnFailure',
|
|
13
|
+
'continueParentOnFailure',
|
|
14
|
+
'ignoreDependencyOnFailure',
|
|
15
|
+
];
|
|
16
|
+
function numeric(value, name, min, max, integer = false) {
|
|
17
|
+
if (value === undefined || value === null)
|
|
18
|
+
return;
|
|
19
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
20
|
+
throw new Error(`${name} must be a finite number`);
|
|
21
|
+
}
|
|
22
|
+
if (integer && !Number.isInteger(value))
|
|
23
|
+
throw new Error(`${name} must be an integer`);
|
|
24
|
+
if (value < min || value > max) {
|
|
25
|
+
throw new Error(`${name} must be between ${min} and ${max}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function validateOptions(input) {
|
|
29
|
+
numeric(input.priority, 'priority', -1_000_000, 1_000_000, true);
|
|
30
|
+
numeric(input.delay, 'delay', 0, 365 * 24 * 60 * 60 * 1_000);
|
|
31
|
+
numeric(input.timeout, 'timeout', 0, 24 * 60 * 60 * 1_000);
|
|
32
|
+
numeric(input.ttl, 'ttl', 0, 365 * 24 * 60 * 60 * 1_000);
|
|
33
|
+
numeric(input.maxAttempts, 'attempts', 1, 1_000, true);
|
|
34
|
+
numeric(input.stallTimeout, 'stallTimeout', 0, 24 * 60 * 60 * 1_000);
|
|
35
|
+
numeric(input.stackTraceLimit, 'stackTraceLimit', 0, 10_000, true);
|
|
36
|
+
numeric(input.keepLogs, 'keepLogs', 0, 1_000_000, true);
|
|
37
|
+
numeric(input.sizeLimit, 'sizeLimit', 0, MAX_JOB_DATA_BYTES, true);
|
|
38
|
+
numeric(input.timestamp, 'timestamp', 0, Number.MAX_SAFE_INTEGER);
|
|
39
|
+
if (typeof input.backoff === 'object' && input.backoff !== null) {
|
|
40
|
+
if (input.backoff.type !== 'fixed' && input.backoff.type !== 'exponential') {
|
|
41
|
+
throw new Error("backoff.type must be 'fixed' or 'exponential'");
|
|
42
|
+
}
|
|
43
|
+
numeric(input.backoff.delay, 'backoff.delay', 0, 24 * 60 * 60 * 1_000);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
numeric(input.backoff, 'backoff', 0, 24 * 60 * 60 * 1_000);
|
|
47
|
+
}
|
|
48
|
+
for (const name of BOOLEAN_OPTIONS) {
|
|
49
|
+
const value = input[name];
|
|
50
|
+
if (value !== undefined && typeof value !== 'boolean') {
|
|
51
|
+
throw new Error(`${name} must be a boolean`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (input.groupId !== undefined &&
|
|
55
|
+
(typeof input.groupId !== 'string' || input.groupId.length > 256)) {
|
|
56
|
+
throw new Error('groupId must be a string of at most 256 characters');
|
|
57
|
+
}
|
|
58
|
+
if (input.tags !== undefined &&
|
|
59
|
+
(!Array.isArray(input.tags) ||
|
|
60
|
+
input.tags.some((tag) => typeof tag !== 'string' || tag.length > 256))) {
|
|
61
|
+
throw new Error('tags must be an array of strings of at most 256 characters');
|
|
62
|
+
}
|
|
63
|
+
if (input.repeat !== undefined) {
|
|
64
|
+
throw new Error('repeat is not supported inside an atomic flow');
|
|
65
|
+
}
|
|
66
|
+
if (input.uniqueKey !== undefined || input.dedup !== undefined) {
|
|
67
|
+
throw new Error('deduplication is not supported inside an atomic flow');
|
|
68
|
+
}
|
|
69
|
+
if (input.debounceId !== undefined || input.debounceTtl !== undefined) {
|
|
70
|
+
throw new Error('debounce is not supported inside an atomic flow');
|
|
71
|
+
}
|
|
72
|
+
const policies = [
|
|
73
|
+
input.failParentOnFailure,
|
|
74
|
+
input.removeDependencyOnFailure,
|
|
75
|
+
input.continueParentOnFailure,
|
|
76
|
+
input.ignoreDependencyOnFailure,
|
|
77
|
+
].filter((enabled) => enabled === true);
|
|
78
|
+
if (policies.length > 1) {
|
|
79
|
+
throw new Error('flow failure policies are mutually exclusive');
|
|
80
|
+
}
|
|
81
|
+
if (policies.length === 1 && !input.parentId) {
|
|
82
|
+
throw new Error('a flow failure policy requires a parent');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function validateData(data) {
|
|
86
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
87
|
+
throw new Error('flow job data must be an object');
|
|
88
|
+
}
|
|
89
|
+
let encoded;
|
|
90
|
+
try {
|
|
91
|
+
encoded = JSON.stringify(data);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error('job data must be JSON serializable');
|
|
95
|
+
}
|
|
96
|
+
if (encoded === undefined)
|
|
97
|
+
throw new Error('job data must be JSON serializable');
|
|
98
|
+
if (encoded.length > MAX_JOB_DATA_BYTES) {
|
|
99
|
+
throw new Error('Job data too large (max 10MB)');
|
|
100
|
+
}
|
|
101
|
+
const value = data;
|
|
102
|
+
if (typeof value.name !== 'string' || value.name.length === 0 || value.name.length > 256) {
|
|
103
|
+
throw new Error('flow job data.name must be a non-empty string of at most 256 characters');
|
|
104
|
+
}
|
|
105
|
+
return { encodedLength: encoded.length, value };
|
|
106
|
+
}
|
|
107
|
+
function validateIdList(value, name) {
|
|
108
|
+
if (value !== undefined &&
|
|
109
|
+
(!Array.isArray(value) ||
|
|
110
|
+
value.some((id) => typeof id !== 'string' || id.length === 0 || id.length > 1_024))) {
|
|
111
|
+
throw new Error(`${name} must be an array of valid string job ids`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Validate the whole graph before any lock, storage write, or queue mutation. */
|
|
115
|
+
export function validateAtomicFlowBatch(batch) {
|
|
116
|
+
if (!batch || typeof batch !== 'object' || !Array.isArray(batch.jobs)) {
|
|
117
|
+
throw new Error('flow jobs must be an array');
|
|
118
|
+
}
|
|
119
|
+
if (batch.jobs.length > MAX_FLOW_JOBS) {
|
|
120
|
+
throw new Error(`flow exceeds the ${MAX_FLOW_JOBS} job limit`);
|
|
121
|
+
}
|
|
122
|
+
const ids = new Set();
|
|
123
|
+
const dataById = new Map();
|
|
124
|
+
let totalDataBytes = 0;
|
|
125
|
+
for (const [index, job] of batch.jobs.entries()) {
|
|
126
|
+
if (!job || typeof job !== 'object')
|
|
127
|
+
throw new Error(`flow jobs[${index}] must be an object`);
|
|
128
|
+
if (typeof job.id !== 'string')
|
|
129
|
+
throw new Error('flow job id must be a string');
|
|
130
|
+
const id = job.id;
|
|
131
|
+
if (!id || id.length > 1_024)
|
|
132
|
+
throw new Error('flow job id is invalid');
|
|
133
|
+
if (id.includes(':'))
|
|
134
|
+
throw new Error('flow job id cannot contain a colon');
|
|
135
|
+
if (ids.has(id))
|
|
136
|
+
throw new Error(`duplicate flow job id: ${id}`);
|
|
137
|
+
ids.add(id);
|
|
138
|
+
if (typeof job.queue !== 'string' ||
|
|
139
|
+
!job.queue ||
|
|
140
|
+
job.queue.length > 256 ||
|
|
141
|
+
!QUEUE_RE.test(job.queue)) {
|
|
142
|
+
throw new Error(`invalid flow queue: ${job.queue}`);
|
|
143
|
+
}
|
|
144
|
+
if (!job.input || typeof job.input !== 'object') {
|
|
145
|
+
throw new Error(`flow job input is invalid: ${id}`);
|
|
146
|
+
}
|
|
147
|
+
if (job.input.customId !== undefined &&
|
|
148
|
+
(typeof job.input.customId !== 'string' || job.input.customId !== id)) {
|
|
149
|
+
throw new Error(`custom id does not match planned id: ${id}`);
|
|
150
|
+
}
|
|
151
|
+
if (job.input.parentId !== undefined &&
|
|
152
|
+
(typeof job.input.parentId !== 'string' || job.input.parentId.length === 0)) {
|
|
153
|
+
throw new Error(`flow parent id is invalid: ${id}`);
|
|
154
|
+
}
|
|
155
|
+
validateIdList(job.input.dependsOn, 'dependsOn');
|
|
156
|
+
validateIdList(job.input.childrenIds, 'childrenIds');
|
|
157
|
+
const validatedData = validateData(job.input.data);
|
|
158
|
+
totalDataBytes += validatedData.encodedLength;
|
|
159
|
+
if (totalDataBytes > MAX_FLOW_DATA_BYTES) {
|
|
160
|
+
throw new Error('flow data is too large (max 64MB per atomic batch)');
|
|
161
|
+
}
|
|
162
|
+
dataById.set(id, validatedData.value);
|
|
163
|
+
validateOptions(job.input);
|
|
164
|
+
}
|
|
165
|
+
validateFlowTopology(batch, dataById);
|
|
166
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { WebhookManager } from '../webhookManager';
|
|
|
10
10
|
import type { EventsManager } from '../eventsManager';
|
|
11
11
|
import { type RWLock } from '../../shared/lock';
|
|
12
12
|
import type { DependencyResultTracker } from '../dependencyResultTracker';
|
|
13
|
+
import { type DependencyCompletionTracker } from '../dependencyCompletions';
|
|
13
14
|
export { discardJob, moveJobToDelayed } from './jobMoveOperations';
|
|
14
15
|
/** Context for job management operations */
|
|
15
16
|
export interface JobManagementContext {
|
|
@@ -25,6 +26,8 @@ export interface JobManagementContext {
|
|
|
25
26
|
eventsManager: EventsManager;
|
|
26
27
|
repeatChain?: Map<JobId, JobId>;
|
|
27
28
|
dependencyResults: DependencyResultTracker;
|
|
29
|
+
depCompletions?: DependencyCompletionTracker;
|
|
30
|
+
maxDependencyCompletions: number;
|
|
28
31
|
}
|
|
29
32
|
/** Cancel a job (remove from queue) */
|
|
30
33
|
export declare function cancelJob(jobId: JobId, ctx: JobManagementContext): Promise<boolean>;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { processingShardIndex } from '../../shared/hash';
|
|
6
6
|
import { webhookLog } from '../../shared/logger';
|
|
7
7
|
import { withWriteLock } from '../../shared/lock';
|
|
8
|
+
import { releaseDependencyCompletionPins, } from '../dependencyCompletions';
|
|
8
9
|
export { discardJob, moveJobToDelayed } from './jobMoveOperations';
|
|
9
10
|
/** Cancel a job (remove from queue) */
|
|
10
11
|
export async function cancelJob(jobId, ctx) {
|
|
@@ -23,7 +24,7 @@ export async function cancelJob(jobId, ctx) {
|
|
|
23
24
|
ctx.jobIndex.delete(jobId);
|
|
24
25
|
ctx.storage?.deleteJob(jobId);
|
|
25
26
|
ctx.dependencyResults.releaseConsumer(jobId);
|
|
26
|
-
return { success: true, queueName: location.queueName };
|
|
27
|
+
return { success: true, queueName: location.queueName, released: [] };
|
|
27
28
|
}
|
|
28
29
|
// Not in the run queue — it may be parked in waitingChildren (moved via
|
|
29
30
|
// moveToWaitingChildren, which already released its resources and does not
|
|
@@ -34,7 +35,7 @@ export async function cancelJob(jobId, ctx) {
|
|
|
34
35
|
ctx.jobIndex.delete(jobId);
|
|
35
36
|
ctx.storage?.deleteJob(jobId);
|
|
36
37
|
ctx.dependencyResults.releaseConsumer(jobId);
|
|
37
|
-
return { success: true, queueName: location.queueName };
|
|
38
|
+
return { success: true, queueName: location.queueName, released: [] };
|
|
38
39
|
}
|
|
39
40
|
// Or parked in waitingDeps (flow-chain dependent inserted by push.ts while
|
|
40
41
|
// its predecessors are unresolved). These are NOT counted in the queued
|
|
@@ -50,11 +51,16 @@ export async function cancelJob(jobId, ctx) {
|
|
|
50
51
|
ctx.jobIndex.delete(jobId);
|
|
51
52
|
ctx.storage?.deleteJob(jobId);
|
|
52
53
|
ctx.dependencyResults.releaseConsumer(jobId);
|
|
53
|
-
return {
|
|
54
|
+
return {
|
|
55
|
+
success: true,
|
|
56
|
+
queueName: location.queueName,
|
|
57
|
+
released: waiting.dependsOn,
|
|
58
|
+
};
|
|
54
59
|
}
|
|
55
|
-
return { success: false, queueName: location.queueName };
|
|
60
|
+
return { success: false, queueName: location.queueName, released: [] };
|
|
56
61
|
});
|
|
57
62
|
if (result.success) {
|
|
63
|
+
releaseDependencyCompletionPins(result.released, ctx);
|
|
58
64
|
// Emit removed event (BullMQ v5)
|
|
59
65
|
ctx.eventsManager.broadcast({
|
|
60
66
|
eventType: "removed" /* EventType.Removed */,
|
|
@@ -9,15 +9,19 @@ import type { SqliteStorage } from '../../infrastructure/persistence/sqlite';
|
|
|
9
9
|
import type { RWLock } from '../../shared/lock';
|
|
10
10
|
import type { SetLike, MapLike } from '../../shared/lru';
|
|
11
11
|
import type { DependencyResultTracker } from '../dependencyResultTracker';
|
|
12
|
+
import type { DependencyCompletionTracker } from '../dependencyCompletions';
|
|
12
13
|
/** Push operation context */
|
|
13
14
|
export interface PushContext {
|
|
14
15
|
storage: SqliteStorage | null;
|
|
15
16
|
shards: Shard[];
|
|
16
17
|
shardLocks: RWLock[];
|
|
18
|
+
/** Serializes deterministic IDs before any shard lock is acquired. */
|
|
19
|
+
customIdLock: RWLock;
|
|
17
20
|
completedJobs: SetLike<JobId>;
|
|
18
21
|
completedJobsData: MapLike<JobId, Job>;
|
|
19
22
|
/** Bare completion ids for removeOnComplete jobs so dependents start ready */
|
|
20
|
-
depCompletions?:
|
|
23
|
+
depCompletions?: DependencyCompletionTracker;
|
|
24
|
+
maxDependencyCompletions: number;
|
|
21
25
|
/** Timeout markers — cleared on custom-id reuse so a recycled id starts clean */
|
|
22
26
|
timedOutJobs?: SetLike<JobId>;
|
|
23
27
|
jobResults: MapLike<JobId, unknown>;
|
|
@@ -34,6 +38,7 @@ export interface PushContext {
|
|
|
34
38
|
timestamp: number;
|
|
35
39
|
}) => void;
|
|
36
40
|
dashboardEmit?: (event: string, data: Record<string, unknown>) => void;
|
|
41
|
+
registerQueueName?: (queue: string) => void;
|
|
37
42
|
}
|
|
38
43
|
/**
|
|
39
44
|
* Push a single job to queue
|
|
@@ -116,7 +116,7 @@ export async function pushJob(queue, input, ctx) {
|
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
// Insert to shard
|
|
119
|
-
insertJobToShard(job, queue, shard, idx, ctx);
|
|
119
|
+
insertJobToShard(job, { queue, shard, shardIdx: idx }, ctx);
|
|
120
120
|
shard.notify(queue);
|
|
121
121
|
result = { job, persisted: true };
|
|
122
122
|
});
|
|
@@ -172,7 +172,7 @@ export async function pushJobBatch(queue, inputs, ctx) {
|
|
|
172
172
|
continue;
|
|
173
173
|
}
|
|
174
174
|
// Insert to shard
|
|
175
|
-
insertJobToShard(job, queue, shard, idx, ctx);
|
|
175
|
+
insertJobToShard(job, { queue, shard, shardIdx: idx }, ctx);
|
|
176
176
|
jobsToInsert.push(job);
|
|
177
177
|
if (input.durable)
|
|
178
178
|
durableJobs.push(job);
|
|
@@ -4,13 +4,22 @@ import type { JobLocation } from '../../domain/types/queue';
|
|
|
4
4
|
import type { SetLike } from '../../shared/lru';
|
|
5
5
|
import type { MapLike } from '../../shared/lru';
|
|
6
6
|
import type { DependencyResultTracker } from '../dependencyResultTracker';
|
|
7
|
+
import { type DependencyCompletionTracker } from '../dependencyCompletions';
|
|
8
|
+
import type { SqliteStorage } from '../../infrastructure/persistence/sqlite';
|
|
7
9
|
export interface PushInsertContext {
|
|
10
|
+
storage: SqliteStorage | null;
|
|
8
11
|
completedJobs: SetLike<JobId>;
|
|
9
|
-
depCompletions?:
|
|
12
|
+
depCompletions?: DependencyCompletionTracker;
|
|
10
13
|
jobResults: MapLike<JobId, unknown>;
|
|
11
14
|
dependencyResults: DependencyResultTracker;
|
|
12
15
|
jobIndex: Map<JobId, JobLocation>;
|
|
13
16
|
dashboardEmit?: (event: string, data: Record<string, unknown>) => void;
|
|
14
17
|
}
|
|
18
|
+
/** Resolve the first externally visible state without mutating queue structures. */
|
|
19
|
+
export declare function initialJobState(job: Job, ctx: Pick<PushInsertContext, 'completedJobs' | 'depCompletions'>, now?: number): 'waiting-children' | 'delayed' | 'prioritized' | 'waiting';
|
|
15
20
|
/** Insert a new job into the runnable heap or dependency wait set. */
|
|
16
|
-
export declare function insertJobToShard(job: Job,
|
|
21
|
+
export declare function insertJobToShard(job: Job, target: {
|
|
22
|
+
queue: string;
|
|
23
|
+
shard: Shard;
|
|
24
|
+
shardIdx: number;
|
|
25
|
+
}, ctx: PushInsertContext, recordTimeline?: boolean): void;
|
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const needsWaiting =
|
|
1
|
+
import { pinReferencedCompletions, } from '../dependencyCompletions';
|
|
2
|
+
/** Resolve the first externally visible state without mutating queue structures. */
|
|
3
|
+
export function initialJobState(job, ctx, now = Date.now()) {
|
|
4
|
+
const needsWaiting = job.dependsOn.length > 0 &&
|
|
5
5
|
!job.dependsOn.every((depId) => ctx.completedJobs.has(depId) || (ctx.depCompletions?.has(depId) ?? false));
|
|
6
|
+
if (needsWaiting)
|
|
7
|
+
return 'waiting-children';
|
|
8
|
+
if (job.runAt > now)
|
|
9
|
+
return 'delayed';
|
|
10
|
+
return job.priority > 0 ? 'prioritized' : 'waiting';
|
|
11
|
+
}
|
|
12
|
+
/** Insert a new job into the runnable heap or dependency wait set. */
|
|
13
|
+
export function insertJobToShard(job, target, ctx, recordTimeline = true) {
|
|
14
|
+
const { queue, shard, shardIdx } = target;
|
|
6
15
|
const now = Date.now();
|
|
7
|
-
|
|
16
|
+
const state = initialJobState(job, ctx, now);
|
|
17
|
+
if (state === 'waiting-children') {
|
|
18
|
+
pinReferencedCompletions(job.dependsOn, ctx);
|
|
8
19
|
shard.waitingDeps.set(job.id, job);
|
|
9
20
|
shard.registerDependencies(job.id, job.dependsOn);
|
|
10
|
-
|
|
21
|
+
if (recordTimeline)
|
|
22
|
+
job.timeline.push({ state, timestamp: now });
|
|
11
23
|
ctx.dashboardEmit?.('job:waiting-children', {
|
|
12
24
|
jobId: String(job.id),
|
|
13
25
|
queue,
|
|
@@ -18,8 +30,8 @@ export function insertJobToShard(job, queue, shard, shardIdx, ctx) {
|
|
|
18
30
|
shard.getQueue(queue).push(job);
|
|
19
31
|
const isDelayed = job.runAt > now;
|
|
20
32
|
shard.incrementQueued(job.id, isDelayed, job.createdAt, queue, job.runAt);
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
if (recordTimeline)
|
|
34
|
+
job.timeline.push({ state, timestamp: now });
|
|
23
35
|
}
|
|
24
36
|
ctx.jobIndex.set(job.id, { type: 'queue', shardIdx, queueName: queue });
|
|
25
37
|
ctx.dependencyResults.registerConsumer(job.id, job.dependsOn);
|
|
@@ -22,9 +22,12 @@ function requiredShardIndexes(queue, inputs, jobIndex) {
|
|
|
22
22
|
*/
|
|
23
23
|
export async function withPushWriteLocks(queue, inputs, ctx, fn) {
|
|
24
24
|
while (true) {
|
|
25
|
-
const
|
|
25
|
+
const customIdGuard = inputs.some((input) => input.customId)
|
|
26
|
+
? await ctx.customIdLock.acquireWrite()
|
|
27
|
+
: null;
|
|
26
28
|
const guards = [];
|
|
27
29
|
try {
|
|
30
|
+
const indexes = requiredShardIndexes(queue, inputs, ctx.jobIndex);
|
|
28
31
|
for (const index of indexes) {
|
|
29
32
|
guards.push(await ctx.shardLocks[index].acquireWrite());
|
|
30
33
|
}
|
|
@@ -38,6 +41,7 @@ export async function withPushWriteLocks(queue, inputs, ctx, fn) {
|
|
|
38
41
|
for (let i = guards.length - 1; i >= 0; i--) {
|
|
39
42
|
guards[i].release();
|
|
40
43
|
}
|
|
44
|
+
customIdGuard?.release();
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
}
|
|
@@ -361,9 +361,10 @@ function querySqliteByLogicalState(storage, queue, sqlFilteredStates, opts) {
|
|
|
361
361
|
if (opts.excludedIds.size === 0) {
|
|
362
362
|
return storage.queryJobsByLogicalStates(queue, sqlFilteredStates, opts);
|
|
363
363
|
}
|
|
364
|
-
// waitingDeps/waitingChildren rows
|
|
365
|
-
//
|
|
366
|
-
//
|
|
364
|
+
// Legacy waitingDeps/waitingChildren rows may be persisted as waiting/delayed,
|
|
365
|
+
// while current inserts retain waiting-children. Fetch enough rows to account
|
|
366
|
+
// for every possible exclusion, then paginate the logical result so parked
|
|
367
|
+
// jobs cannot create short or shifted pages.
|
|
367
368
|
const pageEnd = opts.offset + opts.limit;
|
|
368
369
|
const jobs = storage.queryJobsByLogicalStates(queue, sqlFilteredStates, {
|
|
369
370
|
limit: pageEnd + opts.excludedIds.size,
|
|
@@ -419,9 +420,9 @@ export function getJobs(queue, shardIdx, options, ctx) {
|
|
|
419
420
|
s !== 'waiting-children' &&
|
|
420
421
|
s !== 'paused' &&
|
|
421
422
|
!(isPaused && (s === 'waiting' || s === 'prioritized')));
|
|
422
|
-
// In-memory parked state is authoritative over the persisted row.
|
|
423
|
-
//
|
|
424
|
-
// a pull can still be stored as active.
|
|
423
|
+
// In-memory parked state is authoritative over the persisted row. Current
|
|
424
|
+
// inserts retain waiting-children; legacy rows may still be waiting/delayed,
|
|
425
|
+
// while parents moved after a pull can still be stored as active.
|
|
425
426
|
const parkedJobs = states.includes('waiting-children') || sqlFilteredStates.length > 0
|
|
426
427
|
? collectWaitingChildrenJobs(shard, queue)
|
|
427
428
|
: [];
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Core orchestrator for all queue operations
|
|
4
4
|
*/
|
|
5
5
|
import type { Job, JobId, JobInput, JobLock, LockToken } from '../domain/types/job';
|
|
6
|
+
import type { AtomicFlowBatchInput, AtomicFlowBatchResult } from '../domain/types/flow';
|
|
6
7
|
import type { JobLocation, JobEvent } from '../domain/types/queue';
|
|
7
8
|
import type { CronJob, CronJobInput } from '../domain/types/cron';
|
|
8
9
|
import type { JobLogEntry, CreateWorkerOptions } from '../domain/types/worker';
|
|
@@ -24,6 +25,7 @@ export declare class QueueManager {
|
|
|
24
25
|
private readonly storage;
|
|
25
26
|
private readonly shards;
|
|
26
27
|
private readonly shardLocks;
|
|
28
|
+
private readonly customIdLock;
|
|
27
29
|
private readonly processingShards;
|
|
28
30
|
private readonly processingLocks;
|
|
29
31
|
private readonly jobIndex;
|
|
@@ -67,6 +69,7 @@ export declare class QueueManager {
|
|
|
67
69
|
private handleRepeat;
|
|
68
70
|
push(queue: string, input: JobInput): Promise<Job>;
|
|
69
71
|
pushBatch(queue: string, inputs: JobInput[]): Promise<JobId[]>;
|
|
72
|
+
pushFlow(batch: AtomicFlowBatchInput): Promise<AtomicFlowBatchResult>;
|
|
70
73
|
pull(queue: string, timeoutMs?: number, signal?: AbortSignal): Promise<Job | null>;
|
|
71
74
|
pullWithLock(queue: string, owner: string, timeoutMs?: number, lockTtl?: number, signal?: AbortSignal): Promise<{
|
|
72
75
|
job: Job | null;
|
|
@@ -184,6 +187,8 @@ export declare class QueueManager {
|
|
|
184
187
|
listQueues(): string[];
|
|
185
188
|
private registerQueueName;
|
|
186
189
|
private unregisterQueueName;
|
|
190
|
+
private releaseCompletionPins;
|
|
191
|
+
private reconcileCompletionPins;
|
|
187
192
|
clean(queue: string, graceMs: number, state?: string, limit?: number): JobId[];
|
|
188
193
|
getCountsPerPriority(queue: string): Record<number, number>;
|
|
189
194
|
getJobs(queue: string, options?: {
|
|
@@ -278,6 +283,8 @@ export declare class QueueManager {
|
|
|
278
283
|
getDepCompletions(): SetLike<JobId>;
|
|
279
284
|
getShards(): Shard[];
|
|
280
285
|
private onJobCompleted;
|
|
286
|
+
/** Release failure metadata owned by a parent that reached terminal failure. */
|
|
287
|
+
private onJobFailed;
|
|
281
288
|
/** Check if completing this job completes an entire flow */
|
|
282
289
|
private checkFlowCompleted;
|
|
283
290
|
/**
|