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.
Files changed (149) hide show
  1. package/README.md +62 -9
  2. package/dist/application/backgroundTasks.js +21 -4
  3. package/dist/application/cleanupTasks.js +7 -3
  4. package/dist/application/contextFactory.d.ts +6 -3
  5. package/dist/application/contextFactory.js +8 -0
  6. package/dist/application/dependencyCompletions.d.ts +53 -0
  7. package/dist/application/dependencyCompletions.js +123 -0
  8. package/dist/application/dependencyProcessor.d.ts +5 -0
  9. package/dist/application/dependencyProcessor.js +25 -13
  10. package/dist/application/flowFailureRecovery.d.ts +20 -0
  11. package/dist/application/flowFailureRecovery.js +94 -0
  12. package/dist/application/flowParentBackpatch.d.ts +27 -0
  13. package/dist/application/flowParentBackpatch.js +120 -0
  14. package/dist/application/operations/ack.d.ts +6 -3
  15. package/dist/application/operations/ack.js +36 -10
  16. package/dist/application/operations/ackHelpers.d.ts +5 -4
  17. package/dist/application/operations/ackHelpers.js +4 -10
  18. package/dist/application/operations/customId.d.ts +3 -0
  19. package/dist/application/operations/customId.js +10 -0
  20. package/dist/application/operations/flowPush.d.ts +9 -0
  21. package/dist/application/operations/flowPush.js +112 -0
  22. package/dist/application/operations/flowTopologyValidation.d.ts +3 -0
  23. package/dist/application/operations/flowTopologyValidation.js +101 -0
  24. package/dist/application/operations/flowValidation.d.ts +3 -0
  25. package/dist/application/operations/flowValidation.js +166 -0
  26. package/dist/application/operations/jobManagement.d.ts +3 -0
  27. package/dist/application/operations/jobManagement.js +10 -4
  28. package/dist/application/operations/push.d.ts +6 -1
  29. package/dist/application/operations/push.js +2 -2
  30. package/dist/application/operations/pushInsert.d.ts +11 -2
  31. package/dist/application/operations/pushInsert.js +20 -8
  32. package/dist/application/operations/pushLocks.d.ts +1 -0
  33. package/dist/application/operations/pushLocks.js +5 -1
  34. package/dist/application/operations/queryOperations.js +7 -6
  35. package/dist/application/queueManager.d.ts +7 -0
  36. package/dist/application/queueManager.js +261 -67
  37. package/dist/application/types.d.ts +3 -1
  38. package/dist/cli/commandRouter.d.ts +1 -1
  39. package/dist/client/flow.d.ts +3 -6
  40. package/dist/client/flow.js +71 -210
  41. package/dist/client/flowAtomic.d.ts +9 -0
  42. package/dist/client/flowAtomic.js +24 -0
  43. package/dist/client/flowJobCoreMethods.d.ts +28 -0
  44. package/dist/client/flowJobCoreMethods.js +151 -0
  45. package/dist/client/flowJobDependencies.d.ts +7 -0
  46. package/dist/client/flowJobDependencies.js +64 -0
  47. package/dist/client/flowJobFactory.d.ts +11 -24
  48. package/dist/client/flowJobFactory.js +91 -360
  49. package/dist/client/flowJobMoveMethods.d.ts +16 -0
  50. package/dist/client/flowJobMoveMethods.js +112 -0
  51. package/dist/client/flowJobTypes.d.ts +29 -0
  52. package/dist/client/flowJobTypes.js +6 -0
  53. package/dist/client/flowLegacyPlan.d.ts +16 -0
  54. package/dist/client/flowLegacyPlan.js +118 -0
  55. package/dist/client/flowOptions.d.ts +10 -0
  56. package/dist/client/flowOptions.js +44 -0
  57. package/dist/client/flowPlan.d.ts +16 -0
  58. package/dist/client/flowPlan.js +101 -0
  59. package/dist/client/flowPush.js +4 -1
  60. package/dist/client/flowReader.d.ts +10 -0
  61. package/dist/client/flowReader.js +147 -0
  62. package/dist/client/flowTypes.d.ts +2 -2
  63. package/dist/client/jobHelpers.js +2 -0
  64. package/dist/client/workflow/clock.d.ts +3 -1
  65. package/dist/client/workflow/clock.js +18 -1
  66. package/dist/client/workflow/compensationChild.d.ts +30 -0
  67. package/dist/client/workflow/compensationChild.js +65 -0
  68. package/dist/client/workflow/compensationPass.d.ts +16 -0
  69. package/dist/client/workflow/compensationPass.js +96 -0
  70. package/dist/client/workflow/compensationSupport.d.ts +24 -0
  71. package/dist/client/workflow/compensationSupport.js +57 -0
  72. package/dist/client/workflow/compensator.d.ts +11 -50
  73. package/dist/client/workflow/compensator.js +22 -374
  74. package/dist/client/workflow/definitionGuard.d.ts +11 -0
  75. package/dist/client/workflow/definitionGuard.js +22 -0
  76. package/dist/client/workflow/engine.d.ts +2 -2
  77. package/dist/client/workflow/engine.js +8 -8
  78. package/dist/client/workflow/eventTypes.d.ts +44 -0
  79. package/dist/client/workflow/eventTypes.js +2 -0
  80. package/dist/client/workflow/executionTypes.d.ts +133 -0
  81. package/dist/client/workflow/executionTypes.js +2 -0
  82. package/dist/client/workflow/executor.d.ts +4 -17
  83. package/dist/client/workflow/executor.js +50 -208
  84. package/dist/client/workflow/executorLifecycle.d.ts +18 -0
  85. package/dist/client/workflow/executorLifecycle.js +69 -0
  86. package/dist/client/workflow/executorNodes.d.ts +17 -0
  87. package/dist/client/workflow/executorNodes.js +138 -0
  88. package/dist/client/workflow/identity.d.ts +2 -0
  89. package/dist/client/workflow/identity.js +8 -0
  90. package/dist/client/workflow/index.d.ts +1 -1
  91. package/dist/client/workflow/loops.d.ts +3 -5
  92. package/dist/client/workflow/loops.js +25 -23
  93. package/dist/client/workflow/mapRunner.d.ts +4 -0
  94. package/dist/client/workflow/mapRunner.js +45 -0
  95. package/dist/client/workflow/recovery.js +10 -3
  96. package/dist/client/workflow/runner.d.ts +2 -21
  97. package/dist/client/workflow/runner.js +39 -104
  98. package/dist/client/workflow/runnerTiming.d.ts +14 -0
  99. package/dist/client/workflow/runnerTiming.js +66 -0
  100. package/dist/client/workflow/stepTypes.d.ts +171 -0
  101. package/dist/client/workflow/stepTypes.js +3 -0
  102. package/dist/client/workflow/store.d.ts +17 -17
  103. package/dist/client/workflow/store.js +65 -100
  104. package/dist/client/workflow/storeExecutionCodec.d.ts +11 -0
  105. package/dist/client/workflow/storeExecutionCodec.js +34 -0
  106. package/dist/client/workflow/storeListing.d.ts +11 -0
  107. package/dist/client/workflow/storeListing.js +45 -0
  108. package/dist/client/workflow/storeMaintenance.d.ts +4 -0
  109. package/dist/client/workflow/storeMaintenance.js +40 -0
  110. package/dist/client/workflow/storeSignals.d.ts +9 -0
  111. package/dist/client/workflow/storeSignals.js +38 -2
  112. package/dist/client/workflow/subWorkflowRunner.d.ts +13 -0
  113. package/dist/client/workflow/subWorkflowRunner.js +40 -0
  114. package/dist/client/workflow/types.d.ts +4 -356
  115. package/dist/client/workflow/types.js +1 -3
  116. package/dist/client/workflow/waitFor.js +39 -26
  117. package/dist/client/workflow/workflow.d.ts +16 -59
  118. package/dist/client/workflow/workflow.js +53 -179
  119. package/dist/client/workflow/workflowDecisions.d.ts +11 -0
  120. package/dist/client/workflow/workflowDecisions.js +27 -0
  121. package/dist/client/workflow/workflowDefinition.d.ts +16 -0
  122. package/dist/client/workflow/workflowDefinition.js +123 -0
  123. package/dist/client/workflow/workflowIntrospection.d.ts +5 -0
  124. package/dist/client/workflow/workflowIntrospection.js +46 -0
  125. package/dist/client/workflow/workflowValidation.d.ts +44 -0
  126. package/dist/client/workflow/workflowValidation.js +143 -0
  127. package/dist/domain/types/command.d.ts +7 -1
  128. package/dist/domain/types/flow.d.ts +25 -0
  129. package/dist/domain/types/flow.js +1 -0
  130. package/dist/infrastructure/persistence/dependencyCompletionSchema.d.ts +6 -0
  131. package/dist/infrastructure/persistence/dependencyCompletionSchema.js +16 -0
  132. package/dist/infrastructure/persistence/dependencyCompletionStore.d.ts +38 -0
  133. package/dist/infrastructure/persistence/dependencyCompletionStore.js +105 -0
  134. package/dist/infrastructure/persistence/schema.d.ts +2 -5
  135. package/dist/infrastructure/persistence/schema.js +57 -3
  136. package/dist/infrastructure/persistence/sqlite.d.ts +35 -0
  137. package/dist/infrastructure/persistence/sqlite.js +238 -12
  138. package/dist/infrastructure/persistence/sqliteBatch.js +10 -4
  139. package/dist/infrastructure/persistence/sqliteSerializer.d.ts +5 -0
  140. package/dist/infrastructure/persistence/sqliteSerializer.js +28 -5
  141. package/dist/infrastructure/persistence/statements.d.ts +4 -0
  142. package/dist/infrastructure/persistence/statements.js +8 -2
  143. package/dist/infrastructure/server/handlerRoutes.js +3 -0
  144. package/dist/infrastructure/server/handlers/advanced.js +2 -2
  145. package/dist/infrastructure/server/handlers/flow.d.ts +7 -0
  146. package/dist/infrastructure/server/handlers/flow.js +11 -0
  147. package/dist/infrastructure/server/handlers/index.d.ts +1 -0
  148. package/dist/infrastructure/server/handlers/index.js +1 -0
  149. package/package.json +4 -2
@@ -0,0 +1,94 @@
1
+ import { shardIndex } from '../shared/hash';
2
+ import { reconcileDependencyCompletionPins, } from './dependencyCompletions';
3
+ function findQueuedJob(ctx, id) {
4
+ const location = ctx.jobIndex.get(id);
5
+ if (location?.type !== 'queue')
6
+ return null;
7
+ const shard = ctx.shards[location.shardIdx];
8
+ return (shard.getQueue(location.queueName).find(id) ??
9
+ shard.waitingDeps.get(id) ??
10
+ shard.waitingChildren.get(id) ??
11
+ null);
12
+ }
13
+ function promote(parent, ctx) {
14
+ const idx = shardIndex(parent.queue);
15
+ const shard = ctx.shards[idx];
16
+ if (shard.waitingDeps.delete(parent.id)) {
17
+ shard.unregisterDependencies(parent.id, parent.dependsOn);
18
+ }
19
+ shard.waitingChildren.delete(parent.id);
20
+ const queue = shard.getQueue(parent.queue);
21
+ if (!queue.find(parent.id)) {
22
+ parent.runAt = Date.now();
23
+ queue.push(parent);
24
+ shard.incrementQueued(parent.id, false, parent.createdAt, parent.queue, parent.runAt);
25
+ shard.notify(parent.queue);
26
+ }
27
+ ctx.storage.updateFlowParentResolution(parent);
28
+ }
29
+ function failParent(parent, childId, error, ctx) {
30
+ const shard = ctx.shards[shardIndex(parent.queue)];
31
+ if (shard.waitingDeps.delete(parent.id)) {
32
+ shard.unregisterDependencies(parent.id, parent.dependsOn);
33
+ }
34
+ shard.waitingChildren.delete(parent.id);
35
+ const queued = shard.getQueue(parent.queue).remove(parent.id);
36
+ if (queued)
37
+ shard.decrementQueued(parent.id);
38
+ const message = `Child job ${String(childId)} failed: ${error}`;
39
+ const entry = shard.addToDlq(parent, "unknown" /* FailureReason.Unknown */, message);
40
+ ctx.storage.commitFailedJob(parent.id, entry, null);
41
+ ctx.jobIndex.set(parent.id, { type: 'dlq', queueName: parent.queue });
42
+ ctx.dependencyResults.releaseConsumer(parent.id);
43
+ }
44
+ /** Replay the durable flow-failure outbox before workers can observe recovered jobs. */
45
+ export function recoverFlowFailures(ctx) {
46
+ for (const record of ctx.storage.loadFlowFailures()) {
47
+ const parent = findQueuedJob(ctx, record.parentId);
48
+ if (!parent) {
49
+ if (ctx.jobIndex.get(record.parentId)?.type !== 'queue') {
50
+ ctx.storage.deleteFlowFailure(record.parentId, record.childId);
51
+ }
52
+ continue;
53
+ }
54
+ const childKey = `${record.childQueue}:${String(record.childId)}`;
55
+ if (record.mode === 'fail') {
56
+ failParent(parent, record.childId, record.error, ctx);
57
+ ctx.storage.deleteFlowFailure(record.parentId, record.childId);
58
+ continue;
59
+ }
60
+ if (record.mode === 'continue') {
61
+ const values = ctx.failedChildrenValues.get(parent.id) ?? {};
62
+ values[childKey] = record.error;
63
+ ctx.failedChildrenValues.set(parent.id, values);
64
+ const dependencies = [...parent.dependsOn];
65
+ ctx.shards[shardIndex(parent.queue)].unregisterDependencies(parent.id, dependencies);
66
+ for (const dependency of dependencies) {
67
+ ctx.dependencyResults.releaseDependency(parent.id, dependency);
68
+ }
69
+ parent.dependsOn = [];
70
+ promote(parent, ctx);
71
+ continue;
72
+ }
73
+ if (record.mode === 'ignore') {
74
+ const values = ctx.ignoredChildrenFailures.get(parent.id) ?? {};
75
+ values[childKey] = record.error;
76
+ ctx.ignoredChildrenFailures.set(parent.id, values);
77
+ }
78
+ const dependencyIndex = parent.dependsOn.indexOf(record.childId);
79
+ if (dependencyIndex !== -1) {
80
+ parent.dependsOn.splice(dependencyIndex, 1);
81
+ ctx.shards[shardIndex(parent.queue)].unregisterDependencies(parent.id, [record.childId]);
82
+ ctx.dependencyResults.releaseDependency(parent.id, record.childId);
83
+ ctx.storage.updateFlowParentResolution(parent);
84
+ }
85
+ const ready = parent.dependsOn.length === 0 ||
86
+ parent.dependsOn.every((dependency) => ctx.completedJobs.has(dependency) || (ctx.depCompletions?.has(dependency) ?? false));
87
+ if (ready)
88
+ promote(parent, ctx);
89
+ if (record.mode === 'remove') {
90
+ ctx.storage.deleteFlowFailure(record.parentId, record.childId);
91
+ }
92
+ }
93
+ reconcileDependencyCompletionPins(ctx);
94
+ }
@@ -0,0 +1,27 @@
1
+ import type { Job, JobId } from '../domain/types/job';
2
+ import type { JobLocation } from '../domain/types/queue';
3
+ import type { Shard } from '../domain/queue/shard';
4
+ import type { SqliteStorage } from '../infrastructure/persistence/sqlite';
5
+ import type { RWLock } from '../shared/lock';
6
+ import type { MapLike, SetLike } from '../shared/lru';
7
+ export interface FlowParentBackpatchContext {
8
+ readonly storage: SqliteStorage | null;
9
+ readonly customIdLock: RWLock;
10
+ readonly shards: Shard[];
11
+ readonly shardLocks: RWLock[];
12
+ readonly processingShards: Map<JobId, Job>[];
13
+ readonly processingLocks: RWLock[];
14
+ readonly jobIndex: Map<JobId, JobLocation>;
15
+ readonly completedJobsData: MapLike<JobId, Job>;
16
+ readonly depCompletions: SetLike<JobId>;
17
+ }
18
+ /** Reject a backpatch that would steal a child from an existing real parent. */
19
+ export declare function assertFlowParentOwnership(child: Job, parentId: JobId): void;
20
+ export declare function isDeclaredFlowChild(parent: Job, childId: JobId): boolean;
21
+ export declare function canAcceptRemovedFlowChild(parent: Job | null, childId: JobId, depCompletions: SetLike<JobId>): boolean;
22
+ export declare function flowChildFailureError(child: Job, shards: Shard[]): string | undefined;
23
+ /**
24
+ * Replace the legacy `pending` parent marker for an edge already declared by
25
+ * the parent. This operation never changes parent scheduling or terminal state.
26
+ */
27
+ export declare function backpatchDeclaredFlowChild(child: Job, parent: Job, ctx: FlowParentBackpatchContext): Promise<Job>;
@@ -0,0 +1,120 @@
1
+ import { processingShardIndex, shardIndex } from '../shared/hash';
2
+ function parentFromData(job) {
3
+ if (!job.data || typeof job.data !== 'object' || Array.isArray(job.data))
4
+ return null;
5
+ const value = job.data.__parentId;
6
+ return value === undefined || value === null ? null : String(value);
7
+ }
8
+ /** Reject a backpatch that would steal a child from an existing real parent. */
9
+ export function assertFlowParentOwnership(child, parentId) {
10
+ const candidates = [child.parentId ? String(child.parentId) : null, parentFromData(child)];
11
+ for (const owner of candidates) {
12
+ if (owner && owner !== 'pending' && owner !== String(parentId)) {
13
+ throw new Error(`Child job ${String(child.id)} already belongs to parent ${owner}`);
14
+ }
15
+ }
16
+ }
17
+ export function isDeclaredFlowChild(parent, childId) {
18
+ return parent.childrenIds.includes(childId);
19
+ }
20
+ export function canAcceptRemovedFlowChild(parent, childId, depCompletions) {
21
+ return Boolean(parent && isDeclaredFlowChild(parent, childId) && depCompletions.has(childId));
22
+ }
23
+ function queuedJob(id, location, ctx) {
24
+ const shard = ctx.shards[location.shardIdx];
25
+ return (shard.getQueue(location.queueName).find(id) ??
26
+ shard.waitingDeps.get(id) ??
27
+ shard.waitingChildren.get(id) ??
28
+ null);
29
+ }
30
+ function dlqJob(id, queue, ctx) {
31
+ return ctx.shards[shardIndex(queue)].getDlq(queue).find((job) => job.id === id) ?? null;
32
+ }
33
+ export function flowChildFailureError(child, shards) {
34
+ const entry = shards[shardIndex(child.queue)]
35
+ .getDlqEntries(child.queue)
36
+ .find((candidate) => candidate.job.id === child.id);
37
+ return entry?.error ?? undefined;
38
+ }
39
+ /** Resolve the canonical in-memory object while all relevant locks are held. */
40
+ function lockedJob(id, fallback, ctx) {
41
+ const location = ctx.jobIndex.get(id);
42
+ if (!location)
43
+ return ctx.completedJobsData.get(id) ?? fallback;
44
+ if (location.type === 'queue')
45
+ return queuedJob(id, location, ctx) ?? fallback;
46
+ if (location.type === 'processing') {
47
+ return ctx.processingShards[location.shardIdx].get(id) ?? fallback;
48
+ }
49
+ if (location.type === 'completed')
50
+ return ctx.completedJobsData.get(id) ?? fallback;
51
+ return dlqJob(id, location.queueName, ctx) ?? fallback;
52
+ }
53
+ async function acquireBackpatchLocks(child, parent, ctx) {
54
+ const guards = [];
55
+ try {
56
+ guards.push(await ctx.customIdLock.acquireWrite());
57
+ const shardIndexes = [...new Set([shardIndex(child.queue), shardIndex(parent.queue)])].sort((a, b) => a - b);
58
+ for (const index of shardIndexes)
59
+ guards.push(await ctx.shardLocks[index].acquireWrite());
60
+ const processingIndexes = [
61
+ ...new Set([processingShardIndex(child.id), processingShardIndex(parent.id)]),
62
+ ].sort((a, b) => a - b);
63
+ for (const index of processingIndexes) {
64
+ guards.push(await ctx.processingLocks[index].acquireWrite());
65
+ }
66
+ return guards;
67
+ }
68
+ catch (error) {
69
+ for (let index = guards.length - 1; index >= 0; index--)
70
+ guards[index].release();
71
+ throw error;
72
+ }
73
+ }
74
+ function linkedData(child, parent) {
75
+ const source = child.data && typeof child.data === 'object' && !Array.isArray(child.data)
76
+ ? child.data
77
+ : {};
78
+ return {
79
+ ...source,
80
+ __parentId: String(parent.id),
81
+ __parentQueue: parent.queue,
82
+ };
83
+ }
84
+ function mutateChild(target, parentId, data) {
85
+ target.parentId = parentId;
86
+ target.data = data;
87
+ }
88
+ /**
89
+ * Replace the legacy `pending` parent marker for an edge already declared by
90
+ * the parent. This operation never changes parent scheduling or terminal state.
91
+ */
92
+ export async function backpatchDeclaredFlowChild(child, parent, ctx) {
93
+ const guards = await acquireBackpatchLocks(child, parent, ctx);
94
+ try {
95
+ const lockedParent = lockedJob(parent.id, parent, ctx);
96
+ if (!ctx.jobIndex.has(child.id) &&
97
+ !ctx.completedJobsData.has(child.id) &&
98
+ ctx.depCompletions.has(child.id)) {
99
+ return child;
100
+ }
101
+ const lockedChild = lockedJob(child.id, child, ctx);
102
+ if (!isDeclaredFlowChild(lockedParent, child.id)) {
103
+ throw new Error(`Parent job ${String(parent.id)} is not linkable`);
104
+ }
105
+ assertFlowParentOwnership(lockedChild, parent.id);
106
+ const data = linkedData(lockedChild, lockedParent);
107
+ const linkedChild = { ...lockedChild, parentId: lockedParent.id, data };
108
+ ctx.storage?.backpatchFlowChild(linkedChild, lockedChild.parentId);
109
+ const targets = [child, lockedChild, dlqJob(child.id, child.queue, ctx)];
110
+ for (const target of targets) {
111
+ if (target)
112
+ mutateChild(target, lockedParent.id, data);
113
+ }
114
+ return linkedChild;
115
+ }
116
+ finally {
117
+ for (let index = guards.length - 1; index >= 0; index--)
118
+ guards[index].release();
119
+ }
120
+ }
@@ -9,6 +9,7 @@ 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
  /** Ack operation context */
13
14
  export interface AckContext {
14
15
  storage: SqliteStorage | null;
@@ -19,7 +20,8 @@ export interface AckContext {
19
20
  completedJobs: SetLike<JobId>;
20
21
  completedJobsData: MapLike<JobId, Job>;
21
22
  /** Bare completion ids for removeOnComplete jobs so dependents can unblock */
22
- depCompletions?: SetLike<JobId>;
23
+ depCompletions?: DependencyCompletionTracker;
24
+ maxDependencyCompletions: number;
23
25
  jobResults: MapLike<JobId, unknown>;
24
26
  dependencyResults: DependencyResultTracker;
25
27
  jobIndex: Map<JobId, JobLocation>;
@@ -44,15 +46,16 @@ export interface AckContext {
44
46
  prev?: string;
45
47
  }) => void;
46
48
  onJobCompleted: (jobId: JobId) => void;
49
+ onJobFailed?: (jobId: JobId) => void;
47
50
  onJobsCompleted?: (jobIds: JobId[]) => void;
48
51
  needsBroadcast?: () => boolean;
49
52
  emitDashboardEvent?: (event: string, data: Record<string, unknown>) => void;
50
53
  hasPendingDeps?: () => boolean;
51
54
  onRepeat?: (job: Job) => void;
52
55
  /** Called when a child job with failParentOnFailure terminally fails */
53
- onChildTerminalFailure?: (childJob: Job, error: string | undefined) => void;
56
+ onChildTerminalFailure?: (childJob: Job, error: string | undefined) => Promise<void>;
54
57
  /** Called when a child job with removeDependencyOnFailure/ignoreDependencyOnFailure/continueParentOnFailure terminally fails */
55
- onChildDependencyOption?: (childJob: Job, error: string | undefined) => void;
58
+ onChildDependencyOption?: (childJob: Job, error: string | undefined) => Promise<void>;
56
59
  }
57
60
  /**
58
61
  * Acknowledge job completion
@@ -8,6 +8,7 @@ import { shardIndex, processingShardIndex } from '../../shared/hash';
8
8
  import { latencyTracker } from '../latencyTracker';
9
9
  import { throughputTracker } from '../throughputTracker';
10
10
  import { groupByProcShard, groupItemsByProcShard, extractJobs, extractJobsWithResults, groupByQueueShard, releaseResources, finalizeBatchAck, } from './ackHelpers';
11
+ import { commitRemovedCompletion, } from '../dependencyCompletions';
11
12
  /**
12
13
  * Acknowledge job completion
13
14
  */
@@ -52,13 +53,12 @@ export async function ackJob(jobId, result, ctx) {
52
53
  ctx.storage?.markCompleted(jobId, now, job.timeline);
53
54
  }
54
55
  else {
56
+ commitRemovedCompletion(job, ctx);
55
57
  ctx.jobIndex.delete(jobId);
56
- ctx.storage?.deleteJob(jobId);
57
58
  // removeOnComplete drops the full job (index + data + persisted row) to bound
58
59
  // memory, but dependent jobs gate readiness on completedJobs.has(parentId).
59
60
  // Record the bare completion id (no payload) so dependents still unblock,
60
61
  // without making the job appear in state/stats queries.
61
- ctx.depCompletions?.add(jobId);
62
62
  }
63
63
  if (result !== undefined)
64
64
  ctx.dependencyResults.retain(jobId, result);
@@ -91,11 +91,11 @@ export async function ackJob(jobId, result, ctx) {
91
91
  latencyTracker.ack.observe((Bun.nanoseconds() - startNs) / 1e6);
92
92
  }
93
93
  /** Move a permanently-failed job to DLQ (terminal path in failJob). */
94
- function moveFailedJobToDlq(job, jobId, error, shard, ctx) {
94
+ function moveFailedJobToDlq(input) {
95
+ const { job, jobId, error, shard, ctx, flowFailure } = input;
95
96
  const entry = shard.addToDlq(job, "max_attempts_exceeded" /* FailureReason.MaxAttemptsExceeded */, error ?? null);
96
97
  ctx.jobIndex.set(jobId, { type: 'dlq', queueName: job.queue });
97
- ctx.storage?.saveDlqEntry(entry);
98
- ctx.storage?.deleteJob(jobId);
98
+ ctx.storage?.commitFailedJob(jobId, entry, flowFailure);
99
99
  ctx.totalFailed.value++;
100
100
  if (ctx.perQueueMetrics) {
101
101
  const pq = ctx.perQueueMetrics.get(job.queue);
@@ -121,6 +121,29 @@ function moveFailedJobToDlq(job, jobId, error, shard, ctx) {
121
121
  });
122
122
  }
123
123
  }
124
+ function flowFailureRecord(job, error) {
125
+ if (!job.parentId)
126
+ return null;
127
+ let mode = null;
128
+ if (job.failParentOnFailure)
129
+ mode = 'fail';
130
+ else if (job.continueParentOnFailure)
131
+ mode = 'continue';
132
+ else if (job.ignoreDependencyOnFailure)
133
+ mode = 'ignore';
134
+ else if (job.removeDependencyOnFailure)
135
+ mode = 'remove';
136
+ if (!mode)
137
+ return null;
138
+ return {
139
+ parentId: job.parentId,
140
+ childId: job.id,
141
+ childQueue: job.queue,
142
+ mode,
143
+ error: error ?? 'unknown error',
144
+ createdAt: Date.now(),
145
+ };
146
+ }
124
147
  /**
125
148
  * Mark job as failed
126
149
  */
@@ -149,10 +172,12 @@ export async function failJob(jobId, error, ctx, unrecoverable = false, stack) {
149
172
  }
150
173
  const idx = shardIndex(job.queue);
151
174
  let wasRetried = false;
175
+ const willRetry = !unrecoverable && canRetry(job);
176
+ const flowFailure = willRetry ? null : flowFailureRecord(job, error);
152
177
  await withWriteLock(ctx.shardLocks[idx], () => {
153
178
  const shard = ctx.shards[idx];
154
179
  shard.releaseJobResources(job.queue, job.uniqueKey, job.groupId);
155
- if (!unrecoverable && canRetry(job)) {
180
+ if (willRetry) {
156
181
  const now = Date.now();
157
182
  job.runAt = now + calculateBackoff(job);
158
183
  shard.getQueue(job.queue).push(job);
@@ -166,7 +191,7 @@ export async function failJob(jobId, error, ctx, unrecoverable = false, stack) {
166
191
  }
167
192
  else if (job.removeOnFail) {
168
193
  ctx.jobIndex.delete(jobId);
169
- ctx.storage?.deleteJob(jobId);
194
+ ctx.storage?.commitFailedJob(jobId, null, flowFailure);
170
195
  ctx.totalFailed.value++;
171
196
  if (ctx.perQueueMetrics) {
172
197
  const pq = ctx.perQueueMetrics.get(job.queue);
@@ -184,7 +209,7 @@ export async function failJob(jobId, error, ctx, unrecoverable = false, stack) {
184
209
  }
185
210
  }
186
211
  else {
187
- moveFailedJobToDlq(job, jobId, error, shard, ctx);
212
+ moveFailedJobToDlq({ job, jobId, error, shard, ctx, flowFailure });
188
213
  }
189
214
  // Terminal failure and retry both release queue concurrency (and possibly
190
215
  // an active FIFO group), so another job may now be eligible.
@@ -210,17 +235,18 @@ export async function failJob(jobId, error, ctx, unrecoverable = false, stack) {
210
235
  }
211
236
  else {
212
237
  ctx.dependencyResults.releaseConsumer(jobId);
238
+ ctx.onJobFailed?.(jobId);
213
239
  }
214
240
  // BullMQ v5: failParentOnFailure — propagate terminal failure to parent
215
241
  if (!wasRetried && job.failParentOnFailure && job.parentId && ctx.onChildTerminalFailure) {
216
- ctx.onChildTerminalFailure(job, error);
242
+ await ctx.onChildTerminalFailure(job, error);
217
243
  }
218
244
  // removeDependencyOnFailure / ignoreDependencyOnFailure / continueParentOnFailure
219
245
  if (!wasRetried &&
220
246
  job.parentId &&
221
247
  ctx.onChildDependencyOption &&
222
248
  (job.removeDependencyOnFailure || job.ignoreDependencyOnFailure || job.continueParentOnFailure)) {
223
- ctx.onChildDependencyOption(job, error);
249
+ await ctx.onChildDependencyOption(job, error);
224
250
  }
225
251
  }
226
252
  /**
@@ -1,6 +1,4 @@
1
- /**
2
- * Ack Helpers - Shared batch processing utilities
3
- */
1
+ /** Shared batch acknowledgement utilities. */
4
2
  import type { Job, JobId } from '../../domain/types/job';
5
3
  import type { JobLocation, EventType } from '../../domain/types/queue';
6
4
  import type { Shard } from '../../domain/queue/shard';
@@ -8,6 +6,7 @@ import type { RWLock } from '../../shared/lock';
8
6
  import type { SetLike, MapLike } from '../../shared/lru';
9
7
  import type { SqliteStorage } from '../../infrastructure/persistence/sqlite';
10
8
  import type { DependencyResultTracker } from '../dependencyResultTracker';
9
+ import { type DependencyCompletionTracker } from '../dependencyCompletions';
11
10
  export interface ExtractedJob<T = unknown> {
12
11
  id: JobId;
13
12
  job: Job;
@@ -53,10 +52,12 @@ export declare function releaseResources(byQueueShard: Map<number, Job[]>, ctx:
53
52
  /** Context for finalize operations */
54
53
  export interface FinalizeContext {
55
54
  storage: SqliteStorage | null;
55
+ shards: Shard[];
56
56
  completedJobs: SetLike<JobId>;
57
57
  completedJobsData: MapLike<JobId, Job>;
58
58
  /** Bare completion ids for removeOnComplete jobs so dependents can unblock */
59
- depCompletions?: SetLike<JobId>;
59
+ depCompletions?: DependencyCompletionTracker;
60
+ maxDependencyCompletions: number;
60
61
  jobResults: MapLike<JobId, unknown>;
61
62
  dependencyResults?: DependencyResultTracker;
62
63
  jobIndex: Map<JobId, JobLocation>;
@@ -1,10 +1,8 @@
1
- /**
2
- * Ack Helpers - Shared batch processing utilities
3
- */
4
1
  import { MAX_TIMELINE_ENTRIES } from '../../domain/types/job';
5
2
  import { withWriteLock } from '../../shared/lock';
6
3
  import { shardIndex, processingShardIndex } from '../../shared/hash';
7
4
  import { throughputTracker } from '../throughputTracker';
5
+ import { commitRemovedCompletion, } from '../dependencyCompletions';
8
6
  /**
9
7
  * Group job IDs by processing shard
10
8
  * Returns Map<shardIndex, jobIds[]>
@@ -161,14 +159,10 @@ export function finalizeBatchAck(extractedJobs, ctx, includeResults) {
161
159
  ctx.completedJobs.add(jobId);
162
160
  }
163
161
  else {
162
+ commitRemovedCompletion(job, ctx, now);
164
163
  ctx.jobIndex.delete(jobId);
165
- if (hasStorage)
166
- storage.deleteJob(jobId);
167
- // removeOnComplete drops the full job to bound memory, but dependents gate
168
- // readiness on completedJobs.has(parentId). Record the bare completion id
169
- // (no payload) so dependent jobs still unblock, without surfacing the job
170
- // in state/stats queries.
171
- ctx.depCompletions?.add(jobId);
164
+ // The helper publishes payload-free dependency evidence only after its
165
+ // durable job-removal transaction commits.
172
166
  }
173
167
  }
174
168
  for (let i = 0; i < jobCount; i++) {
@@ -3,11 +3,14 @@ import { type Job, type JobId, type JobInput } from '../../domain/types/job';
3
3
  import type { JobLocation } from '../../domain/types/queue';
4
4
  import type { SqliteStorage } from '../../infrastructure/persistence/sqlite';
5
5
  import type { MapLike, SetLike } from '../../shared/lru';
6
+ import { type DependencyCompletionTracker } from '../dependencyCompletions';
6
7
  export interface CustomIdContext {
7
8
  storage: SqliteStorage | null;
8
9
  shards: Shard[];
9
10
  completedJobs: SetLike<JobId>;
10
11
  completedJobsData: MapLike<JobId, Job>;
12
+ depCompletions?: DependencyCompletionTracker;
13
+ maxDependencyCompletions: number;
11
14
  timedOutJobs?: SetLike<JobId>;
12
15
  jobResults: MapLike<JobId, unknown>;
13
16
  customIdMap: MapLike<string, JobId>;
@@ -1,5 +1,6 @@
1
1
  import { generateJobId, jobId } from '../../domain/types/job';
2
2
  import { shardIndex } from '../../shared/hash';
3
+ import { releaseDependencyCompletionPins, } from '../dependencyCompletions';
3
4
  /**
4
5
  * Enforce custom-ID idempotency while the caller holds the target shard lock.
5
6
  * Live generations are returned unchanged; terminal generations are retired
@@ -50,6 +51,15 @@ export function handleCustomId(input, ctx, lockedShardIndexes) {
50
51
  // id. The fresh jobs row is inserted only after this retirement completes.
51
52
  ctx.storage?.deleteDlqEntry(id);
52
53
  }
54
+ if (ctx.depCompletions?.has(id)) {
55
+ const hasUnresolvedConsumers = ctx.shards.some((shard) => (shard.getJobsWaitingFor(id)?.size ?? 0) > 0);
56
+ if (hasUnresolvedConsumers) {
57
+ throw new Error(`Custom ID ${String(id)} still has unresolved dependency consumers`);
58
+ }
59
+ releaseDependencyCompletionPins([id], ctx);
60
+ ctx.storage?.deleteDependencyCompletion(id);
61
+ ctx.depCompletions.delete(id);
62
+ }
53
63
  // A durable jobs row may outlive its in-memory tracking after an interrupted
54
64
  // cleanup. The storage insert upserts that orphan without adding a DELETE to
55
65
  // every custom-ID push.
@@ -0,0 +1,9 @@
1
+ import type { AtomicFlowBatchInput, AtomicFlowBatchResult } from '../../domain/types/flow';
2
+ import type { PushContext } from './push';
3
+ /**
4
+ * Commit a complete flow graph as one broker-side operation.
5
+ *
6
+ * SQLite is committed before the graph is published in memory. Workers acquire
7
+ * the same shard locks, so the first visible leaf always observes every edge.
8
+ */
9
+ export declare function pushFlowBatch(batch: AtomicFlowBatchInput, ctx: PushContext): Promise<AtomicFlowBatchResult>;
@@ -0,0 +1,112 @@
1
+ import { createJob } from '../../domain/types/job';
2
+ import { shardIndex } from '../../shared/hash';
3
+ import { latencyTracker } from '../latencyTracker';
4
+ import { throughputTracker } from '../throughputTracker';
5
+ import { initialJobState, insertJobToShard } from './pushInsert';
6
+ import { validateAtomicFlowBatch } from './flowValidation';
7
+ function existingJobError(id) {
8
+ return new Error(`Flow job ${String(id)} already exists`);
9
+ }
10
+ function assertIdsAvailable(batch, ctx) {
11
+ for (const planned of batch.jobs) {
12
+ if (ctx.jobIndex.has(planned.id) ||
13
+ ctx.completedJobs.has(planned.id) ||
14
+ (ctx.depCompletions?.has(planned.id) ?? false) ||
15
+ ctx.jobResults.has(planned.id) ||
16
+ (ctx.timedOutJobs?.has(planned.id) ?? false) ||
17
+ ctx.shards.some((shard) => (shard.getJobsWaitingFor(planned.id)?.size ?? 0) > 0) ||
18
+ ctx.storage?.getJob(planned.id) ||
19
+ ctx.storage?.hasDlqEntry(planned.id)) {
20
+ throw existingJobError(planned.id);
21
+ }
22
+ if (planned.input.customId) {
23
+ const owner = ctx.customIdMap.get(planned.input.customId);
24
+ if (owner !== undefined)
25
+ throw existingJobError(owner);
26
+ }
27
+ }
28
+ }
29
+ function prepareJobs(batch, ctx, now) {
30
+ return batch.jobs.map((planned) => {
31
+ const job = createJob(planned.id, planned.queue, planned.input, now);
32
+ job.timeline.push({ state: initialJobState(job, ctx, now), timestamp: now });
33
+ return job;
34
+ });
35
+ }
36
+ function publishJobs(jobs, ctx) {
37
+ const notifications = new Map();
38
+ for (const job of jobs) {
39
+ const idx = shardIndex(job.queue);
40
+ insertJobToShard(job, { queue: job.queue, shard: ctx.shards[idx], shardIdx: idx }, ctx, false);
41
+ if (job.customId)
42
+ ctx.customIdMap.set(job.customId, job.id);
43
+ notifications.set(job.queue, (notifications.get(job.queue) ?? 0) + 1);
44
+ }
45
+ return notifications;
46
+ }
47
+ async function acquireFlowLocks(batch, ctx) {
48
+ const guards = [];
49
+ try {
50
+ if (batch.jobs.some((job) => job.input.customId)) {
51
+ guards.push(await ctx.customIdLock.acquireWrite());
52
+ }
53
+ const indexes = [...new Set(batch.jobs.map((job) => shardIndex(job.queue)))].sort((a, b) => a - b);
54
+ for (const index of indexes)
55
+ guards.push(await ctx.shardLocks[index].acquireWrite());
56
+ return guards;
57
+ }
58
+ catch (error) {
59
+ for (let index = guards.length - 1; index >= 0; index--)
60
+ guards[index].release();
61
+ throw error;
62
+ }
63
+ }
64
+ /**
65
+ * Commit a complete flow graph as one broker-side operation.
66
+ *
67
+ * SQLite is committed before the graph is published in memory. Workers acquire
68
+ * the same shard locks, so the first visible leaf always observes every edge.
69
+ */
70
+ export async function pushFlowBatch(batch, ctx) {
71
+ const startNs = Bun.nanoseconds();
72
+ validateAtomicFlowBatch(batch);
73
+ if (batch.jobs.length === 0)
74
+ return { jobs: [] };
75
+ for (const queue of new Set(batch.jobs.map((job) => job.queue))) {
76
+ ctx.registerQueueName?.(queue);
77
+ }
78
+ const guards = await acquireFlowLocks(batch, ctx);
79
+ let jobs;
80
+ let notifications;
81
+ try {
82
+ assertIdsAvailable(batch, ctx);
83
+ const now = Date.now();
84
+ jobs = prepareJobs(batch, ctx, now);
85
+ ctx.storage?.insertJobsBatch(jobs, true);
86
+ notifications = publishJobs(jobs, ctx);
87
+ }
88
+ finally {
89
+ for (let index = guards.length - 1; index >= 0; index--)
90
+ guards[index].release();
91
+ }
92
+ for (const [queue, count] of notifications) {
93
+ ctx.shards[shardIndex(queue)].notifyBatch(queue, count);
94
+ }
95
+ ctx.totalPushed.value += BigInt(jobs.length);
96
+ throughputTracker.pushRate.increment(jobs.length);
97
+ const timestamp = Date.now();
98
+ for (const job of jobs) {
99
+ ctx.broadcast({
100
+ eventType: "pushed" /* EventType.Pushed */,
101
+ queue: job.queue,
102
+ jobId: job.id,
103
+ timestamp,
104
+ });
105
+ }
106
+ ctx.dashboardEmit?.('flow:pushed', {
107
+ jobs: jobs.length,
108
+ queues: notifications.size,
109
+ });
110
+ latencyTracker.push.observe((Bun.nanoseconds() - startNs) / 1e6);
111
+ return { jobs };
112
+ }
@@ -0,0 +1,3 @@
1
+ import type { AtomicFlowBatchInput } from '../../domain/types/flow';
2
+ /** Validate references, symmetric parent edges, metadata, and acyclicity in O(V+E). */
3
+ export declare function validateFlowTopology(batch: AtomicFlowBatchInput, dataById: ReadonlyMap<string, Record<string, unknown>>): void;