@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 +2 -1
- package/lib/engine.js +78 -4
- package/package.json +10 -10
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
19
|
-
"@kubun/engine": "^0.
|
|
20
|
-
"@kubun/graphql": "^0.
|
|
21
|
-
"@kubun/id": "^0.
|
|
22
|
-
"@kubun/logger": "^0.
|
|
23
|
-
"@kubun/plugin-workflow-api": "^0.
|
|
24
|
-
"@kubun/store-workflow": "^0.
|
|
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.
|
|
32
|
-
"@kubun/db-postgres": "^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": {
|