@kubun/plugin-workflow 0.14.1 → 0.15.1

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/lib/engine.d.ts CHANGED
@@ -10,11 +10,12 @@ export type WorkflowEngineDeps = {
10
10
  now?: () => number;
11
11
  getRandomID?: GetRandomID;
12
12
  tickMs?: number;
13
+ disposeTimeoutMs?: number;
13
14
  };
14
15
  export type WorkflowEngine = {
15
16
  api: WorkflowAPI;
16
17
  recover(): Promise<Array<string>>;
17
18
  tick(): Promise<void>;
18
- dispose(): void;
19
+ dispose(): Promise<void>;
19
20
  };
20
21
  export declare function createWorkflowEngine(deps: WorkflowEngineDeps): WorkflowEngine;
package/lib/engine.js CHANGED
@@ -6,6 +6,8 @@ import { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
6
6
  const LEASE_MS = 30_000;
7
7
  // Default scheduler cadence; injectable via deps so tests drive `tick()` directly.
8
8
  const DEFAULT_TICK_MS = 1_000;
9
+ // Default upper bound for dispose()'s best-effort drain of in-flight drives.
10
+ const DEFAULT_DISPOSE_TIMEOUT_MS = 5_000;
9
11
  // Backoff for a failed attempt (1-based). `fixed` returns `baseMs`; `exponential`
10
12
  // doubles per attempt, capped at `maxMs`. Pure so it is unit-testable.
11
13
  export function backoff(attempts, policy) {
@@ -86,6 +88,9 @@ export function createWorkflowEngine(deps) {
86
88
  const running = new Map();
87
89
  // In-flight drives, tracked so nudges are not lost and errors are logged.
88
90
  const inflight = new Set();
91
+ // Set by dispose(): stops the scheduler from claiming/driving new work while
92
+ // the existing in-flight drives are drained.
93
+ let disposed = false;
89
94
  let storePromise;
90
95
  function store() {
91
96
  storePromise ??= deps.getStore();
@@ -342,13 +347,24 @@ export function createWorkflowEngine(deps) {
342
347
  }
343
348
  }
344
349
  async function tick() {
350
+ // After dispose() no new work is claimed: a straggler tick (a queued nudge
351
+ // or the interval firing before it was cleared) must not start fresh drives
352
+ // while shutdown drains the in-flight ones.
353
+ if (disposed) return;
345
354
  try {
346
355
  const s = await store();
356
+ // Re-check after every await that precedes a claim: dispose() may have run
357
+ // during the gap. Bailing here — rather than only at entry — is what keeps
358
+ // a tick that was already in-flight when dispose() fired from claiming and
359
+ // driving fresh work after shutdown began.
360
+ if (disposed) return;
347
361
  const at = now();
348
362
  // Promote due schedules first so their instances are claimable this pass.
349
363
  await promoteSchedules(s, at);
364
+ if (disposed) return;
350
365
  const lanes = await s.listRunnableQueues(at);
351
366
  for (const lane of lanes){
367
+ if (disposed) return;
352
368
  const cap = laneConcurrency(lane) - await s.countRunning(lane, at);
353
369
  if (cap <= 0) continue;
354
370
  // Clamp to available tokens; consume only for rows actually claimed
@@ -357,6 +373,9 @@ export function createWorkflowEngine(deps) {
357
373
  if (grant <= 0) continue;
358
374
  const rows = await s.claimDue(lane, grant, bootID, LEASE_MS, at);
359
375
  consumeTokens(lane, rows.length);
376
+ // If dispose() fired during claimDue, leave the just-claimed rows for
377
+ // crash-reclaim rather than starting drives no abort sweep will cancel.
378
+ if (disposed) return;
360
379
  for (const row of rows)track(run(row));
361
380
  }
362
381
  } catch (cause) {
@@ -378,8 +397,12 @@ export function createWorkflowEngine(deps) {
378
397
  });
379
398
  }
380
399
  const tickMs = deps.tickMs ?? DEFAULT_TICK_MS;
400
+ const disposeTimeoutMs = deps.disposeTimeoutMs ?? DEFAULT_DISPOSE_TIMEOUT_MS;
381
401
  let interval = setInterval(()=>{
382
- void tick();
402
+ // Tracked, not fire-and-forget: dispose()'s drain must be able to await an
403
+ // interval tick that is mid-pass, or its claim/promote writes could land
404
+ // after dispose() resolves and the store closes.
405
+ track(tick());
383
406
  }, tickMs);
384
407
  // Allow the process to exit even while the scheduler interval is pending.
385
408
  const intervalHandle = interval;
@@ -606,17 +629,68 @@ export function createWorkflowEngine(deps) {
606
629
  // resumes them. The returned ids are the pre-tick stranded set.
607
630
  const s = await store();
608
631
  const stranded = await s.listRecoverable(now());
609
- await tick();
632
+ // Tracked like every other tick so a concurrent dispose() drains this
633
+ // pass's store writes rather than resolving out from under them.
634
+ const p = tick();
635
+ track(p);
636
+ await p;
610
637
  return stranded.map((r)=>r.instance_id);
611
638
  },
612
- tick,
613
- dispose () {
639
+ // Tracked so dispose()'s drain awaits a manual tick that is mid-pass; an
640
+ // untracked one could issue promote/claim writes after dispose() resolved.
641
+ tick: ()=>{
642
+ const p = tick();
643
+ track(p);
644
+ return p;
645
+ },
646
+ async dispose () {
647
+ disposed = true;
614
648
  if (interval != null) {
615
649
  clearInterval(interval);
616
650
  interval = null;
617
651
  }
652
+ // Abort every live drive, then drain to quiescence: an aborted run still
653
+ // issues its `cancelled` terminal write, and aborting frees a lane so a
654
+ // straggler drive may appear — so loop until nothing is in flight rather
655
+ // than awaiting a single snapshot. `disposed` keeps tick() from adding new
656
+ // work, so a cooperative drive set terminates. When the drain wins the
657
+ // race below, no scheduled write is still in flight and the caller can
658
+ // safely close the store.
618
659
  for (const controller of running.values())controller.abort();
619
660
  running.clear();
661
+ let drainedDone = false;
662
+ const drained = (async ()=>{
663
+ while(inflight.size > 0)await Promise.allSettled([
664
+ ...inflight
665
+ ]);
666
+ drainedDone = true;
667
+ })();
668
+ // Bound the wait: a handler that never observes its abort signal leaves an
669
+ // un-settling drive in `inflight`, and an unbounded drain would wedge
670
+ // shutdown forever. Resolve best-effort on the deadline instead; the timer
671
+ // is unref'd so it never itself holds the process open. NOTE: on timeout,
672
+ // genuine writes may still be in flight, so a caller that then closes the
673
+ // store can race them — the deadline trades that residual for a bounded
674
+ // shutdown. It only fires when a drive is not settling, which is logged.
675
+ let timer;
676
+ const deadline = new Promise((resolve)=>{
677
+ timer = setTimeout(resolve, disposeTimeoutMs);
678
+ timer.unref?.();
679
+ });
680
+ try {
681
+ await Promise.race([
682
+ drained,
683
+ deadline
684
+ ]);
685
+ } finally{
686
+ if (timer != null) clearTimeout(timer);
687
+ }
688
+ if (!drainedDone) {
689
+ logger.warn('workflow dispose timed out draining in-flight drives', {
690
+ inflight: inflight.size,
691
+ disposeTimeoutMs
692
+ });
693
+ }
620
694
  }
621
695
  };
622
696
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-workflow",
3
- "version": "0.14.1",
3
+ "version": "0.15.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,21 +15,21 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/db": "^0.14.0",
19
- "@kubun/engine": "^0.14.1",
20
- "@kubun/graphql": "^0.14.1",
21
- "@kubun/id": "^0.14.0",
22
- "@kubun/logger": "^0.14.0",
23
- "@kubun/plugin-workflow-api": "^0.14.1",
24
- "@kubun/store-workflow": "^0.14.1",
18
+ "@kubun/db": "^0.15.0",
19
+ "@kubun/engine": "^0.15.0",
20
+ "@kubun/graphql": "^0.15.0",
21
+ "@kubun/id": "^0.15.0",
22
+ "@kubun/logger": "^0.15.0",
23
+ "@kubun/plugin-workflow-api": "^0.15.0",
24
+ "@kubun/store-workflow": "^0.15.0",
25
25
  "@sozai/event": "^0.1.3",
26
26
  "@sozai/flow": "^0.3.1",
27
27
  "@sozai/runtime": "^0.1.0",
28
28
  "@sozai/schema": "^0.1.2"
29
29
  },
30
30
  "devDependencies": {
31
- "@kubun/db-better-sqlite": "^0.14.0",
32
- "@kubun/db-postgres": "^0.14.0",
31
+ "@kubun/db-better-sqlite": "^0.15.0",
32
+ "@kubun/db-postgres": "^0.15.0",
33
33
  "@testcontainers/postgresql": "^12.1.0"
34
34
  },
35
35
  "publishConfig": {