bunqueue 2.8.50 → 2.8.52

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 (40) hide show
  1. package/README.md +43 -0
  2. package/dist/application/backgroundTasks.js +21 -4
  3. package/dist/application/cleanupTasks.js +7 -3
  4. package/dist/application/contextFactory.d.ts +2 -1
  5. package/dist/application/contextFactory.js +5 -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 +3 -0
  11. package/dist/application/flowFailureRecovery.js +3 -1
  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 +3 -1
  15. package/dist/application/operations/ack.js +2 -2
  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/jobManagement.d.ts +3 -0
  21. package/dist/application/operations/jobManagement.js +10 -4
  22. package/dist/application/operations/push.d.ts +3 -1
  23. package/dist/application/operations/pushInsert.d.ts +4 -1
  24. package/dist/application/operations/pushInsert.js +2 -0
  25. package/dist/application/queueManager.d.ts +2 -0
  26. package/dist/application/queueManager.js +134 -75
  27. package/dist/application/types.d.ts +3 -1
  28. package/dist/client/flowPlan.js +8 -0
  29. package/dist/client/flowTypes.d.ts +2 -2
  30. package/dist/infrastructure/persistence/dependencyCompletionSchema.d.ts +6 -0
  31. package/dist/infrastructure/persistence/dependencyCompletionSchema.js +16 -0
  32. package/dist/infrastructure/persistence/dependencyCompletionStore.d.ts +38 -0
  33. package/dist/infrastructure/persistence/dependencyCompletionStore.js +105 -0
  34. package/dist/infrastructure/persistence/schema.d.ts +2 -5
  35. package/dist/infrastructure/persistence/schema.js +6 -1
  36. package/dist/infrastructure/persistence/sqlite.d.ts +20 -1
  37. package/dist/infrastructure/persistence/sqlite.js +119 -15
  38. package/dist/infrastructure/persistence/sqliteSerializer.d.ts +3 -0
  39. package/dist/infrastructure/persistence/sqliteSerializer.js +10 -0
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -212,6 +212,49 @@ Worker("emails", lambda job: {"sent": True}, concurrency=10).run()
212
212
  Every SDK is certified against the same public
213
213
  [wire protocol](https://github.com/egeominotti/bunqueue/blob/main/docs/protocol.md) and conformance suite.
214
214
 
215
+ ### Atomic flows, in every SDK
216
+
217
+ Every official `FlowProducer` resolves all job IDs and reciprocal dependency
218
+ edges locally, then sends one `PUSHF` command. The broker validates the complete
219
+ graph and commits it atomically, so a worker cannot observe a leaf from a
220
+ partially-created flow.
221
+
222
+ ```typescript
223
+ import { FlowProducer } from 'bunqueue-client';
224
+
225
+ const flows = new FlowProducer({ host: 'localhost', port: 6789 });
226
+ const root = await flows.add({
227
+ name: 'publish-release',
228
+ queueName: 'release',
229
+ data: { version: 'candidate-42' },
230
+ children: [
231
+ { name: 'unit-tests', queueName: 'checks', data: { suite: 'unit' } },
232
+ { name: 'sdk-tests', queueName: 'checks', data: { suite: 'sdk' } },
233
+ ],
234
+ });
235
+
236
+ console.log(root.job.id, root.children?.map(({ job }) => job.id));
237
+ await flows.close();
238
+ ```
239
+
240
+ The repository records the contracts and the test strategy beside each
241
+ implementation:
242
+
243
+ | SDK | Runtime invariants | Generated tests | Mutation engine |
244
+ | --- | --- | --- | --- |
245
+ | [TypeScript](./sdk/typescript/README.md) | [contract](./sdk/typescript/INVARIANTS.md) | fast-check | StrykerJS |
246
+ | [Python](./sdk/python/README.md) | [contract](./sdk/python/INVARIANTS.md) | Hypothesis | mutmut |
247
+ | [PHP](./sdk/php/README.md) | [contract](./sdk/php/INVARIANTS.md) | Eris | Infection |
248
+ | [Go](./sdk/go/README.md) | [contract](./sdk/go/INVARIANTS.md) | Rapid | Gremlins |
249
+ | [Rust](./sdk/rust/README.md) | [contract](./sdk/rust/INVARIANTS.md) | proptest | cargo-mutants |
250
+ | [Elixir](./sdk/elixir/README.md) | [contract](./sdk/elixir/INVARIANTS.md) | StreamData | Muex |
251
+
252
+ Property campaigns run in the ordinary SDK gate with deterministic replay
253
+ seeds. Mutation campaigns run separately against the pure planners and
254
+ snapshot validators. Contributors can reproduce the complete isolated SDK
255
+ gate with `bun run test:sandbox:sdk`; language-specific commands live in each
256
+ SDK README and `AGENTS.md`.
257
+
215
258
  [SDK guide (all six languages) →](https://bunqueue.dev/guide/sdks/)
216
259
 
217
260
  ## Simple Mode
@@ -10,10 +10,11 @@ import * as dlqOps from './dlqManager';
10
10
  import { checkExpiredLocks } from './lockManager';
11
11
  import { cleanup } from './cleanupTasks';
12
12
  import { checkStalledJobs } from './stallDetection';
13
- import { processPendingDependencies } from './dependencyProcessor';
13
+ import { checkpointDependencyPromotion, dependencyReadyState, processPendingDependencies, } from './dependencyProcessor';
14
14
  import { handleTaskError, handleTaskSuccess, getTaskErrorStats } from './taskErrorTracking';
15
15
  import { runMonitoringChecks } from './monitoringChecks';
16
- import { isCorruptDependsOn } from '../infrastructure/persistence/sqliteSerializer';
16
+ import { isCorruptDependsOn, persistedJobState, } from '../infrastructure/persistence/sqliteSerializer';
17
+ import { reconcileDependencyCompletionPins } from './dependencyCompletions';
17
18
  export { getTaskErrorStats };
18
19
  /**
19
20
  * Start all background tasks
@@ -195,8 +196,14 @@ function quarantineCorruptDependsOn(ctx, job) {
195
196
  export function recover(ctx) {
196
197
  if (!ctx.storage)
197
198
  return;
199
+ // Keep the full pre-reconciliation window outside the bounded RAM tracker.
200
+ // A restart may lower maxCompletedJobs; pruning before Phase 2 reconstructs
201
+ // reverse edges could discard an old proof still owned by a waiting parent.
202
+ const dependencyCompletions = ctx.storage.loadDependencyCompletions();
198
203
  // Load completed job IDs from SQLite for dependency checking
199
204
  const completedInDb = ctx.storage.loadCompletedJobIds();
205
+ for (const record of dependencyCompletions)
206
+ completedInDb.add(record.jobId);
200
207
  // Load DLQ job IDs so Phase 1 can skip stale active rows for DLQ'd jobs
201
208
  // (legacy DBs predate the DLQ-row cleanup fix in failJob).
202
209
  const dlqJobIds = ctx.storage.loadDlqJobIds();
@@ -309,9 +316,15 @@ export function recover(ctx) {
309
316
  continue;
310
317
  }
311
318
  // Check if job has unmet dependencies
312
- // Check both in-memory completedJobs AND SQLite job_results table
319
+ // A ready persisted state is an authoritative checkpoint: dependency
320
+ // proofs are deliberately bounded and may have expired after promotion.
313
321
  const hasDependencies = job.dependsOn && job.dependsOn.length > 0;
322
+ const recoveredState = persistedJobState(job);
323
+ const wasAlreadyPromoted = recoveredState === 'waiting' ||
324
+ recoveredState === 'prioritized' ||
325
+ recoveredState === 'delayed';
314
326
  const needsWaitingDeps = hasDependencies &&
327
+ !wasAlreadyPromoted &&
315
328
  !job.dependsOn.every((depId) => ctx.completedJobs.has(depId) || completedInDb.has(depId));
316
329
  if (needsWaitingDeps) {
317
330
  // Job is waiting for dependencies - don't add to main queue
@@ -323,8 +336,11 @@ export function recover(ctx) {
323
336
  // Job is ready to process
324
337
  shard.getQueue(job.queue).push(job);
325
338
  // Update running counters for O(1) stats and temporal index
326
- const isDelayed = job.runAt > now;
339
+ const state = dependencyReadyState(job, now);
340
+ const isDelayed = state === 'delayed';
327
341
  shard.incrementQueued(job.id, isDelayed, job.createdAt, job.queue, job.runAt);
342
+ if (hasDependencies)
343
+ checkpointDependencyPromotion(job, state, now, ctx.storage);
328
344
  }
329
345
  ctx.jobIndex.set(job.id, { type: 'queue', shardIdx: idx, queueName: job.queue });
330
346
  ctx.dependencyResults.registerConsumer(job.id, job.dependsOn);
@@ -416,6 +432,7 @@ export function recover(ctx) {
416
432
  if (completedBatch.length < batchSize)
417
433
  break;
418
434
  }
435
+ reconcileDependencyCompletionPins(ctx);
419
436
  }
420
437
  // Re-export for backward compatibility
421
438
  export { processPendingDependencies };
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { processingShardIndex, SHARD_COUNT } from '../shared/hash';
6
6
  import { withWriteLock } from '../shared/lock';
7
+ import { releaseDependencyCompletionPins } from './dependencyCompletions';
7
8
  /**
8
9
  * Main cleanup function - called periodically to maintain system health
9
10
  * Cleans orphaned entries, stale data, and manages memory
@@ -72,6 +73,7 @@ async function cleanStaleWaitingDependencies(ctx, now) {
72
73
  continue;
73
74
  const removed = await withWriteLock(ctx.shardLocks[i], () => {
74
75
  let count = 0;
76
+ const released = [];
75
77
  for (const id of staleIds) {
76
78
  const job = shard.waitingDeps.get(id);
77
79
  if (!job || now - job.createdAt <= depTimeout)
@@ -81,6 +83,7 @@ async function cleanStaleWaitingDependencies(ctx, now) {
81
83
  ctx.storage?.deleteJob(job.id);
82
84
  shard.waitingDeps.delete(job.id);
83
85
  shard.unregisterDependencies(job.id, job.dependsOn);
86
+ released.push(...job.dependsOn);
84
87
  if (job.uniqueKey && shard.getUniqueKeyEntry(job.queue, job.uniqueKey)?.jobId === job.id) {
85
88
  shard.releaseUniqueKey(job.queue, job.uniqueKey);
86
89
  }
@@ -91,10 +94,11 @@ async function cleanStaleWaitingDependencies(ctx, now) {
91
94
  ctx.jobIndex.delete(job.id);
92
95
  count++;
93
96
  }
94
- return count;
97
+ return { count, released };
95
98
  });
96
- if (removed > 0) {
97
- ctx.dashboardEmit?.('cleanup:stale-deps-removed', { count: removed });
99
+ releaseDependencyCompletionPins(removed.released, ctx);
100
+ if (removed.count > 0) {
101
+ ctx.dashboardEmit?.('cleanup:stale-deps-removed', { count: removed.count });
98
102
  }
99
103
  }
100
104
  }
@@ -13,6 +13,7 @@ import type { WorkerManager } from './workerManager';
13
13
  import type { EventsManager } from './eventsManager';
14
14
  import type { MonitoringState } from './monitoringChecks';
15
15
  import type { DependencyResultTracker } from './dependencyResultTracker';
16
+ import type { DependencyCompletionTracker } from './dependencyCompletions';
16
17
  import type { LockContext, BackgroundContext, StatsContext } from './types';
17
18
  import type { PushContext } from './operations/push';
18
19
  import type { PullContext } from './operations/pull';
@@ -35,7 +36,7 @@ export interface ContextDependencies {
35
36
  jobIndex: Map<JobId, JobLocation>;
36
37
  completedJobs: BoundedSet<JobId>;
37
38
  completedJobsData: BoundedMap<JobId, Job>;
38
- depCompletions?: BoundedSet<JobId>;
39
+ depCompletions?: DependencyCompletionTracker;
39
40
  timedOutJobs?: BoundedSet<JobId>;
40
41
  jobResults: LRUMap<JobId, unknown>;
41
42
  dependencyResults: DependencyResultTracker;
@@ -35,6 +35,7 @@ export class ContextFactory {
35
35
  jobIndex: this.deps.jobIndex,
36
36
  completedJobs: this.deps.completedJobs,
37
37
  depCompletions: this.deps.depCompletions,
38
+ maxDependencyCompletions: this.deps.config.maxCompletedJobs,
38
39
  timedOutJobs: this.deps.timedOutJobs,
39
40
  jobResults: this.deps.jobResults,
40
41
  dependencyResults: this.deps.dependencyResults,
@@ -86,6 +87,7 @@ export class ContextFactory {
86
87
  completedJobs: this.deps.completedJobs,
87
88
  completedJobsData: this.deps.completedJobsData,
88
89
  depCompletions: this.deps.depCompletions,
90
+ maxDependencyCompletions: this.deps.config.maxCompletedJobs,
89
91
  timedOutJobs: this.deps.timedOutJobs,
90
92
  jobResults: this.deps.jobResults,
91
93
  dependencyResults: this.deps.dependencyResults,
@@ -120,6 +122,7 @@ export class ContextFactory {
120
122
  completedJobs: this.deps.completedJobs,
121
123
  completedJobsData: this.deps.completedJobsData,
122
124
  depCompletions: this.deps.depCompletions,
125
+ maxDependencyCompletions: this.deps.config.maxCompletedJobs,
123
126
  jobResults: this.deps.jobResults,
124
127
  dependencyResults: this.deps.dependencyResults,
125
128
  jobIndex: this.deps.jobIndex,
@@ -150,6 +153,8 @@ export class ContextFactory {
150
153
  jobLocks: this.deps.jobLocks,
151
154
  clientJobs: this.deps.clientJobs,
152
155
  dependencyResults: this.deps.dependencyResults,
156
+ depCompletions: this.deps.depCompletions,
157
+ maxDependencyCompletions: this.deps.config.maxCompletedJobs,
153
158
  webhookManager: this.deps.webhookManager,
154
159
  eventsManager: this.deps.eventsManager,
155
160
  repeatChain: this.deps.repeatChain,
@@ -0,0 +1,53 @@
1
+ import type { Job, JobId } from '../domain/types/job';
2
+ import type { Shard } from '../domain/queue/shard';
3
+ import type { SqliteStorage } from '../infrastructure/persistence/sqlite';
4
+ import type { SetLike } from '../shared/lru';
5
+ interface CompletionRecord {
6
+ jobId: JobId;
7
+ pinned: boolean;
8
+ }
9
+ /**
10
+ * Payload-free completion evidence has two retention classes:
11
+ * recent IDs are FIFO-bounded, while IDs referenced by live dependency edges
12
+ * stay pinned until their final consumer is durably resolved or removed.
13
+ */
14
+ export declare class DependencyCompletionTracker implements SetLike<JobId> {
15
+ private readonly onRecentEvict?;
16
+ private readonly recent;
17
+ private readonly pinned;
18
+ private readonly maxRecent;
19
+ constructor(maxRecent: number, onRecentEvict?: ((jobId: JobId) => void) | undefined);
20
+ add(jobId: JobId): void;
21
+ pin(jobId: JobId): void;
22
+ unpin(jobId: JobId, retainAsRecent: boolean): void;
23
+ hydrate(records: Iterable<CompletionRecord>): void;
24
+ pinnedValues(): IterableIterator<JobId>;
25
+ has(jobId: JobId): boolean;
26
+ delete(jobId: JobId): boolean;
27
+ clear(): void;
28
+ get size(): number;
29
+ }
30
+ export interface DependencyCompletionContext {
31
+ storage: SqliteStorage | null;
32
+ shards: Shard[];
33
+ depCompletions?: DependencyCompletionTracker;
34
+ maxDependencyCompletions: number;
35
+ }
36
+ export declare function hasDependencyWaiters(shards: Shard[], jobId: JobId): boolean;
37
+ /** Persist a removeOnComplete transition before publishing its RAM evidence. */
38
+ export declare function commitRemovedCompletion(job: Pick<Job, 'id' | 'queue'>, ctx: DependencyCompletionContext, completedAt?: number): void;
39
+ /**
40
+ * Protect recent proofs when a newly accepted parent waits on another
41
+ * dependency. SQLite is updated first so RAM never advertises a stronger
42
+ * durability guarantee than the database.
43
+ */
44
+ export declare function pinReferencedCompletions(dependencyIds: Iterable<JobId>, ctx: Pick<DependencyCompletionContext, 'storage' | 'depCompletions'>): void;
45
+ /**
46
+ * Unpin candidates only after every shard has released its reverse edge.
47
+ * Storage returns the complete retained set because pruning can also evict
48
+ * older recent entries; hydrating from it keeps both RAM tiers exact.
49
+ */
50
+ export declare function releaseDependencyCompletionPins(dependencyIds: Iterable<JobId>, ctx: DependencyCompletionContext): void;
51
+ /** Rebuild pin ownership from the authoritative reverse dependency indexes. */
52
+ export declare function reconcileDependencyCompletionPins(ctx: DependencyCompletionContext): void;
53
+ export {};
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Payload-free completion evidence has two retention classes:
3
+ * recent IDs are FIFO-bounded, while IDs referenced by live dependency edges
4
+ * stay pinned until their final consumer is durably resolved or removed.
5
+ */
6
+ export class DependencyCompletionTracker {
7
+ onRecentEvict;
8
+ recent = new Set();
9
+ pinned = new Set();
10
+ maxRecent;
11
+ constructor(maxRecent, onRecentEvict) {
12
+ this.onRecentEvict = onRecentEvict;
13
+ this.maxRecent = Math.max(1, Math.trunc(maxRecent));
14
+ }
15
+ add(jobId) {
16
+ if (this.pinned.has(jobId) || this.recent.has(jobId))
17
+ return;
18
+ if (this.recent.size >= this.maxRecent) {
19
+ const oldest = this.recent.values().next().value;
20
+ if (oldest !== undefined) {
21
+ this.recent.delete(oldest);
22
+ this.onRecentEvict?.(oldest);
23
+ }
24
+ }
25
+ this.recent.add(jobId);
26
+ }
27
+ pin(jobId) {
28
+ this.recent.delete(jobId);
29
+ this.pinned.add(jobId);
30
+ }
31
+ unpin(jobId, retainAsRecent) {
32
+ this.pinned.delete(jobId);
33
+ if (retainAsRecent)
34
+ this.recent.add(jobId);
35
+ else
36
+ this.recent.delete(jobId);
37
+ }
38
+ hydrate(records) {
39
+ this.clear();
40
+ for (const record of records) {
41
+ if (record.pinned)
42
+ this.pin(record.jobId);
43
+ else
44
+ this.add(record.jobId);
45
+ }
46
+ }
47
+ pinnedValues() {
48
+ return this.pinned.values();
49
+ }
50
+ has(jobId) {
51
+ return this.pinned.has(jobId) || this.recent.has(jobId);
52
+ }
53
+ delete(jobId) {
54
+ const wasPinned = this.pinned.delete(jobId);
55
+ return this.recent.delete(jobId) || wasPinned;
56
+ }
57
+ clear() {
58
+ this.recent.clear();
59
+ this.pinned.clear();
60
+ }
61
+ get size() {
62
+ return this.recent.size + this.pinned.size;
63
+ }
64
+ }
65
+ export function hasDependencyWaiters(shards, jobId) {
66
+ return shards.some((shard) => (shard.getJobsWaitingFor(jobId)?.size ?? 0) > 0);
67
+ }
68
+ /** Persist a removeOnComplete transition before publishing its RAM evidence. */
69
+ export function commitRemovedCompletion(job, ctx, completedAt = Date.now()) {
70
+ const pinned = hasDependencyWaiters(ctx.shards, job.id);
71
+ ctx.storage?.commitRemovedCompletion(job, ctx.maxDependencyCompletions, pinned, completedAt);
72
+ if (pinned)
73
+ ctx.depCompletions?.pin(job.id);
74
+ else
75
+ ctx.depCompletions?.add(job.id);
76
+ }
77
+ /**
78
+ * Protect recent proofs when a newly accepted parent waits on another
79
+ * dependency. SQLite is updated first so RAM never advertises a stronger
80
+ * durability guarantee than the database.
81
+ */
82
+ export function pinReferencedCompletions(dependencyIds, ctx) {
83
+ const ids = [...new Set(dependencyIds)].filter((id) => ctx.depCompletions?.has(id) ?? false);
84
+ if (ids.length === 0)
85
+ return;
86
+ ctx.storage?.pinDependencyCompletions(ids);
87
+ for (const id of ids)
88
+ ctx.depCompletions?.pin(id);
89
+ }
90
+ /**
91
+ * Unpin candidates only after every shard has released its reverse edge.
92
+ * Storage returns the complete retained set because pruning can also evict
93
+ * older recent entries; hydrating from it keeps both RAM tiers exact.
94
+ */
95
+ export function releaseDependencyCompletionPins(dependencyIds, ctx) {
96
+ const candidates = [...new Set(dependencyIds)].filter((id) => !hasDependencyWaiters(ctx.shards, id));
97
+ if (candidates.length === 0)
98
+ return;
99
+ if (ctx.storage) {
100
+ const records = ctx.storage.unpinDependencyCompletions(candidates, ctx.maxDependencyCompletions);
101
+ ctx.depCompletions?.hydrate(records);
102
+ return;
103
+ }
104
+ for (const id of candidates)
105
+ ctx.depCompletions?.unpin(id, true);
106
+ }
107
+ /** Rebuild pin ownership from the authoritative reverse dependency indexes. */
108
+ export function reconcileDependencyCompletionPins(ctx) {
109
+ const referenced = new Set();
110
+ for (const shard of ctx.shards) {
111
+ for (const dependencyId of shard.dependencyIndex.keys())
112
+ referenced.add(dependencyId);
113
+ }
114
+ if (ctx.storage) {
115
+ const records = ctx.storage.reconcileDependencyCompletionPins(referenced, ctx.maxDependencyCompletions);
116
+ ctx.depCompletions?.hydrate(records);
117
+ return;
118
+ }
119
+ for (const id of ctx.depCompletions?.pinnedValues() ?? []) {
120
+ if (!referenced.has(id))
121
+ ctx.depCompletions?.unpin(id, true);
122
+ }
123
+ }
@@ -2,9 +2,14 @@
2
2
  * Dependency Processor - Job dependency resolution
3
3
  * Uses reverse index for O(m) where m = jobs waiting on completed deps
4
4
  */
5
+ import type { Job } from '../domain/types/job';
5
6
  import type { BackgroundContext } from './types';
7
+ type DependencyReadyState = 'delayed' | 'prioritized' | 'waiting';
8
+ export declare function dependencyReadyState(job: Job, now: number): DependencyReadyState;
9
+ export declare function checkpointDependencyPromotion(job: Job, state: DependencyReadyState, now: number, storage: BackgroundContext['storage']): void;
6
10
  /**
7
11
  * Process pending dependency checks
8
12
  * Resolves jobs whose dependencies have been completed
9
13
  */
10
14
  export declare function processPendingDependencies(ctx: BackgroundContext): Promise<void>;
15
+ export {};
@@ -5,6 +5,18 @@
5
5
  import { MAX_TIMELINE_ENTRIES } from '../domain/types/job';
6
6
  import { SHARD_COUNT } from '../shared/hash';
7
7
  import { withWriteLock } from '../shared/lock';
8
+ import { releaseDependencyCompletionPins } from './dependencyCompletions';
9
+ export function dependencyReadyState(job, now) {
10
+ if (job.runAt > now)
11
+ return 'delayed';
12
+ return job.priority > 0 ? 'prioritized' : 'waiting';
13
+ }
14
+ export function checkpointDependencyPromotion(job, state, now, storage) {
15
+ if (job.timeline.at(-1)?.state !== state && job.timeline.length < MAX_TIMELINE_ENTRIES) {
16
+ job.timeline.push({ state, timestamp: now });
17
+ }
18
+ storage?.updateFlowParentResolution(job, state);
19
+ }
8
20
  /**
9
21
  * Process pending dependency checks
10
22
  * Resolves jobs whose dependencies have been completed
@@ -13,8 +25,10 @@ export async function processPendingDependencies(ctx) {
13
25
  if (ctx.pendingDepChecks.size === 0)
14
26
  return;
15
27
  const completedIds = Array.from(ctx.pendingDepChecks);
28
+ const completedNow = new Set(completedIds);
16
29
  ctx.pendingDepChecks.clear();
17
30
  const jobsToCheckByShard = new Map();
31
+ const releasedDependencyIds = new Set();
18
32
  // Find all jobs waiting for the completed dependencies
19
33
  for (const completedId of completedIds) {
20
34
  for (let i = 0; i < SHARD_COUNT; i++) {
@@ -42,37 +56,35 @@ export async function processPendingDependencies(ctx) {
42
56
  // dropped to bound memory), so also honor its bare-id depCompletions entry.
43
57
  for (const jobId of jobIdsToCheck) {
44
58
  const job = shard.waitingDeps.get(jobId);
45
- if (job?.dependsOn.every((dep) => ctx.completedJobs.has(dep) || (ctx.depCompletions?.has(dep) ?? false))) {
59
+ if (job?.dependsOn.every((dep) => completedNow.has(dep) ||
60
+ ctx.completedJobs.has(dep) ||
61
+ (ctx.depCompletions?.has(dep) ?? false))) {
46
62
  jobsToPromote.push(job);
47
63
  }
48
64
  }
49
65
  // Promote jobs with all dependencies satisfied
50
66
  if (jobsToPromote.length > 0) {
51
- promoteJobsToQueue(jobsToPromote, shard, ctx, i);
67
+ promoteJobsToQueue(jobsToPromote, shard, ctx, i, releasedDependencyIds);
52
68
  }
53
69
  });
54
70
  }));
55
- // NOTE: depCompletions is intentionally NOT pruned here. It is a FIFO
56
- // BoundedSet (same cap as completedJobs), so it self-bounds. Pruning eagerly
57
- // once "no waiters remain" would orphan a dependent pushed AFTER a
58
- // removeOnComplete parent completed — exactly the symmetry completedJobs
59
- // provides for normal parents (readiness holds for the whole bounded window).
71
+ releaseDependencyCompletionPins(releasedDependencyIds, ctx);
60
72
  }
61
73
  /** Move jobs from waitingDeps to the active queue */
62
- function promoteJobsToQueue(jobsToPromote, shard, ctx, shardIdx) {
74
+ function promoteJobsToQueue(jobsToPromote, shard, ctx, shardIdx, releasedDependencyIds) {
63
75
  const now = Date.now();
64
76
  for (const job of jobsToPromote) {
65
77
  if (shard.waitingDeps.has(job.id)) {
78
+ const state = dependencyReadyState(job, now);
79
+ checkpointDependencyPromotion(job, state, now, ctx.storage);
66
80
  shard.waitingDeps.delete(job.id);
67
81
  shard.unregisterDependencies(job.id, job.dependsOn);
82
+ for (const dependencyId of job.dependsOn)
83
+ releasedDependencyIds.add(dependencyId);
68
84
  shard.getQueue(job.queue).push(job);
69
- const isDelayed = job.runAt > now;
85
+ const isDelayed = state === 'delayed';
70
86
  shard.incrementQueued(job.id, isDelayed, job.createdAt, job.queue, job.runAt);
71
87
  ctx.jobIndex.set(job.id, { type: 'queue', shardIdx, queueName: job.queue });
72
- if (job.timeline.length < MAX_TIMELINE_ENTRIES) {
73
- const state = isDelayed ? 'delayed' : job.priority > 0 ? 'prioritized' : 'waiting';
74
- job.timeline.push({ state, timestamp: now });
75
- }
76
88
  }
77
89
  }
78
90
  if (jobsToPromote.length > 0) {
@@ -4,11 +4,14 @@ import type { Shard } from '../domain/queue/shard';
4
4
  import type { SqliteStorage } from '../infrastructure/persistence/sqlite';
5
5
  import type { SetLike } from '../shared/lru';
6
6
  import type { DependencyResultTracker } from './dependencyResultTracker';
7
+ import { type DependencyCompletionTracker } from './dependencyCompletions';
7
8
  export interface FlowFailureRecoveryContext {
8
9
  readonly storage: SqliteStorage;
9
10
  readonly shards: Shard[];
10
11
  readonly jobIndex: Map<JobId, JobLocation>;
11
12
  readonly completedJobs: SetLike<JobId>;
13
+ readonly depCompletions?: DependencyCompletionTracker;
14
+ readonly maxDependencyCompletions: number;
12
15
  readonly dependencyResults: DependencyResultTracker;
13
16
  readonly failedChildrenValues: Map<JobId, Record<string, string>>;
14
17
  readonly ignoredChildrenFailures: Map<JobId, Record<string, string>>;
@@ -1,4 +1,5 @@
1
1
  import { shardIndex } from '../shared/hash';
2
+ import { reconcileDependencyCompletionPins, } from './dependencyCompletions';
2
3
  function findQueuedJob(ctx, id) {
3
4
  const location = ctx.jobIndex.get(id);
4
5
  if (location?.type !== 'queue')
@@ -82,11 +83,12 @@ export function recoverFlowFailures(ctx) {
82
83
  ctx.storage.updateFlowParentResolution(parent);
83
84
  }
84
85
  const ready = parent.dependsOn.length === 0 ||
85
- parent.dependsOn.every((dependency) => ctx.completedJobs.has(dependency));
86
+ parent.dependsOn.every((dependency) => ctx.completedJobs.has(dependency) || (ctx.depCompletions?.has(dependency) ?? false));
86
87
  if (ready)
87
88
  promote(parent, ctx);
88
89
  if (record.mode === 'remove') {
89
90
  ctx.storage.deleteFlowFailure(record.parentId, record.childId);
90
91
  }
91
92
  }
93
+ reconcileDependencyCompletionPins(ctx);
92
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
+ }