@kubun/plugin-workflow 0.15.0 → 0.16.0
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/control-plane.d.ts +16 -0
- package/lib/control-plane.js +51 -0
- package/lib/definition.d.ts +2 -0
- package/lib/definition.js +10 -0
- package/lib/engine.d.ts +11 -2
- package/lib/engine.js +169 -21
- package/lib/index.d.ts +3 -2
- package/lib/index.js +22 -6
- package/lib/schema.d.ts +8 -7
- package/lib/schema.js +27 -8
- package/package.json +11 -10
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WorkflowCommandRefusalReason, WorkflowControlPlaneAPI, WorkflowDiscoverOptions, WorkflowDiscoverReply } from '@kubun/plugin-p2p-api';
|
|
2
|
+
import type { EnqueueOptions, InstanceStatus } from '@kubun/plugin-workflow-api';
|
|
3
|
+
export declare class WorkflowCommandRefusedError extends Error {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(reason: WorkflowCommandRefusalReason);
|
|
6
|
+
get reason(): WorkflowCommandRefusalReason;
|
|
7
|
+
}
|
|
8
|
+
export type WorkflowControlPlaneHelpers = {
|
|
9
|
+
discoverWorkflows(groupID: string, options?: WorkflowDiscoverOptions): Promise<Array<WorkflowDiscoverReply>>;
|
|
10
|
+
listWorkflows(groupID: string, targetDID: string): Promise<Array<InstanceStatus>>;
|
|
11
|
+
workflowStatus(groupID: string, targetDID: string, instanceID: string): Promise<InstanceStatus>;
|
|
12
|
+
enqueueWorkflow(groupID: string, targetDID: string, name: string, params?: unknown, opts?: EnqueueOptions): Promise<InstanceStatus>;
|
|
13
|
+
cancelWorkflow(groupID: string, targetDID: string, instanceID: string): Promise<InstanceStatus>;
|
|
14
|
+
retryWorkflow(groupID: string, targetDID: string, instanceID: string): Promise<InstanceStatus>;
|
|
15
|
+
};
|
|
16
|
+
export declare function createWorkflowControlPlaneHelpers(getP2P: () => Promise<WorkflowControlPlaneAPI>): WorkflowControlPlaneHelpers;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A directed command refused by the remote peer. The p2p API returns refusals as
|
|
2
|
+
// DATA (enkaku collapses a handler throw to one generic error); this helper turns
|
|
3
|
+
// them back into a typed local throw, so app code sees a thrown `reason`.
|
|
4
|
+
export class WorkflowCommandRefusedError extends Error {
|
|
5
|
+
#reason;
|
|
6
|
+
constructor(reason){
|
|
7
|
+
super(refusalMessage(reason));
|
|
8
|
+
this.name = 'WorkflowCommandRefusedError';
|
|
9
|
+
this.#reason = reason;
|
|
10
|
+
}
|
|
11
|
+
get reason() {
|
|
12
|
+
return this.#reason;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// `not_found` avoids "no such instance": the remote refuses a missing instance and
|
|
16
|
+
// a non-commandable one identically, so the message must not claim it doesn't
|
|
17
|
+
// exist — only that it could not be commanded.
|
|
18
|
+
function refusalMessage(reason) {
|
|
19
|
+
switch(reason){
|
|
20
|
+
case 'not_commandable':
|
|
21
|
+
return 'workflow did not opt into remote command';
|
|
22
|
+
case 'not_found':
|
|
23
|
+
return 'workflow instance is not commandable (missing or not opted in)';
|
|
24
|
+
case 'not_failed':
|
|
25
|
+
return 'workflow instance is not in a failed state';
|
|
26
|
+
case 'no_next_action':
|
|
27
|
+
return 'workflow instance has no resumable action to retry';
|
|
28
|
+
case 'singleton':
|
|
29
|
+
return 'a live instance already holds the same singleton key';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function unwrap(result) {
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
throw new WorkflowCommandRefusedError(result.reason);
|
|
35
|
+
}
|
|
36
|
+
return result.status;
|
|
37
|
+
}
|
|
38
|
+
// `getP2P` resolves the p2p control plane lazily: the workflow API is built before
|
|
39
|
+
// every plugin factory returns, so `engine.getAPI('p2p')` cannot be awaited at
|
|
40
|
+
// build time. A device with no p2p plugin rejects here — the correct failure for a
|
|
41
|
+
// remote op with no transport.
|
|
42
|
+
export function createWorkflowControlPlaneHelpers(getP2P) {
|
|
43
|
+
return {
|
|
44
|
+
discoverWorkflows: async (groupID, options)=>(await getP2P()).discoverWorkflows(groupID, options),
|
|
45
|
+
listWorkflows: async (groupID, targetDID)=>(await getP2P()).listWorkflows(groupID, targetDID),
|
|
46
|
+
workflowStatus: async (groupID, targetDID, instanceID)=>(await getP2P()).workflowStatus(groupID, targetDID, instanceID),
|
|
47
|
+
enqueueWorkflow: async (groupID, targetDID, name, params, opts)=>unwrap(await (await getP2P()).enqueueWorkflow(groupID, targetDID, name, params, opts)),
|
|
48
|
+
cancelWorkflow: async (groupID, targetDID, instanceID)=>unwrap(await (await getP2P()).cancelWorkflow(groupID, targetDID, instanceID)),
|
|
49
|
+
retryWorkflow: async (groupID, targetDID, instanceID)=>unwrap(await (await getP2P()).retryWorkflow(groupID, targetDID, instanceID))
|
|
50
|
+
};
|
|
51
|
+
}
|
package/lib/definition.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { WorkflowDefinition } from '@kubun/plugin-workflow-api';
|
|
2
2
|
import type { HandlersRecord } from '@sozai/flow';
|
|
3
3
|
export declare function defineWorkflow<State extends Record<string, unknown>, Handlers extends HandlersRecord<State> = HandlersRecord<State>>(def: WorkflowDefinition<State, Handlers>): WorkflowDefinition<State, Handlers>;
|
|
4
|
+
export declare function isRemotelyObservable(def: Pick<WorkflowDefinition, 'remote'>): boolean;
|
|
5
|
+
export declare function isRemotelyCommandable(def: Pick<WorkflowDefinition, 'remote'>): boolean;
|
package/lib/definition.js
CHANGED
|
@@ -6,3 +6,13 @@ export function defineWorkflow(def) {
|
|
|
6
6
|
}
|
|
7
7
|
return def;
|
|
8
8
|
}
|
|
9
|
+
// Single normalize point for def.remote: absent field/sub-field, or a
|
|
10
|
+
// non-true value (including null, which optional booleans can read as),
|
|
11
|
+
// default closed. Typed on just the `remote` field so any WorkflowDefinition
|
|
12
|
+
// (whatever its State/Handlers) is accepted.
|
|
13
|
+
export function isRemotelyObservable(def) {
|
|
14
|
+
return def.remote?.observe === true;
|
|
15
|
+
}
|
|
16
|
+
export function isRemotelyCommandable(def) {
|
|
17
|
+
return def.remote?.command === true;
|
|
18
|
+
}
|
package/lib/engine.d.ts
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import type { Logger } from '@kubun/logger';
|
|
2
|
-
import type { RetryPolicy, WorkflowAPI } from '@kubun/plugin-workflow-api';
|
|
2
|
+
import type { RetryPolicy, RetryRefusalReason, WorkflowAPI } from '@kubun/plugin-workflow-api';
|
|
3
3
|
import type { WorkflowStoreAPI } from '@kubun/store-workflow';
|
|
4
4
|
import type { GetRandomID } from '@sozai/runtime';
|
|
5
5
|
export declare function backoff(attempts: number, policy?: RetryPolicy): number;
|
|
6
6
|
export declare function parseEvery(spec: string): number;
|
|
7
|
+
export declare class WorkflowRetryError extends Error {
|
|
8
|
+
#private;
|
|
9
|
+
constructor(reason: RetryRefusalReason, message: string, options?: {
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
});
|
|
12
|
+
get reason(): RetryRefusalReason;
|
|
13
|
+
}
|
|
7
14
|
export type WorkflowEngineDeps = {
|
|
8
15
|
getStore: () => Promise<WorkflowStoreAPI>;
|
|
9
16
|
logger: Logger;
|
|
10
17
|
now?: () => number;
|
|
11
18
|
getRandomID?: GetRandomID;
|
|
12
19
|
tickMs?: number;
|
|
20
|
+
disposeTimeoutMs?: number;
|
|
21
|
+
ready?: () => Promise<void>;
|
|
13
22
|
};
|
|
14
23
|
export type WorkflowEngine = {
|
|
15
24
|
api: WorkflowAPI;
|
|
16
25
|
recover(): Promise<Array<string>>;
|
|
17
26
|
tick(): Promise<void>;
|
|
18
|
-
dispose(): void
|
|
27
|
+
dispose(): Promise<void>;
|
|
19
28
|
};
|
|
20
29
|
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) {
|
|
@@ -32,6 +34,19 @@ const TERMINAL = new Set([
|
|
|
32
34
|
'failed',
|
|
33
35
|
'cancelled'
|
|
34
36
|
]);
|
|
37
|
+
// Typed refusal thrown by `retry` when it does not re-arm. `reason` lets a caller
|
|
38
|
+
// (or a GraphQL error consumer) branch on why, rather than parsing a message.
|
|
39
|
+
export class WorkflowRetryError extends Error {
|
|
40
|
+
#reason;
|
|
41
|
+
constructor(reason, message, options){
|
|
42
|
+
super(message, options);
|
|
43
|
+
this.name = 'WorkflowRetryError';
|
|
44
|
+
this.#reason = reason;
|
|
45
|
+
}
|
|
46
|
+
get reason() {
|
|
47
|
+
return this.#reason;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
35
50
|
function toStatus(row) {
|
|
36
51
|
return {
|
|
37
52
|
instanceID: row.instance_id,
|
|
@@ -86,6 +101,13 @@ export function createWorkflowEngine(deps) {
|
|
|
86
101
|
const running = new Map();
|
|
87
102
|
// In-flight drives, tracked so nudges are not lost and errors are logged.
|
|
88
103
|
const inflight = new Set();
|
|
104
|
+
// Set by dispose(): stops the scheduler from claiming/driving new work while
|
|
105
|
+
// the existing in-flight drives are drained.
|
|
106
|
+
let disposed = false;
|
|
107
|
+
// Cleared once the host engine's identity readiness gate resolves. While
|
|
108
|
+
// false the scheduler claims nothing; a mismatched identity leaves it false
|
|
109
|
+
// forever (see the ready() wiring below).
|
|
110
|
+
let identityReady = deps.ready == null;
|
|
89
111
|
let storePromise;
|
|
90
112
|
function store() {
|
|
91
113
|
storePromise ??= deps.getStore();
|
|
@@ -342,13 +364,25 @@ export function createWorkflowEngine(deps) {
|
|
|
342
364
|
}
|
|
343
365
|
}
|
|
344
366
|
async function tick() {
|
|
367
|
+
// After dispose() no new work is claimed: a straggler tick (a queued nudge or
|
|
368
|
+
// the interval firing before it was cleared) must not start fresh drives while
|
|
369
|
+
// shutdown drains the in-flight ones. `identityReady` gates the same way — the
|
|
370
|
+
// scheduler claims nothing until the host engine verifies its database identity,
|
|
371
|
+
// and a mismatch bars it permanently.
|
|
372
|
+
if (disposed || !identityReady) return;
|
|
345
373
|
try {
|
|
346
374
|
const s = await store();
|
|
375
|
+
// Re-check after every await that precedes a claim: dispose() may have run
|
|
376
|
+
// during the gap. Bailing here, not only at entry, keeps a tick already
|
|
377
|
+
// in-flight when dispose() fired from claiming fresh work after shutdown began.
|
|
378
|
+
if (disposed) return;
|
|
347
379
|
const at = now();
|
|
348
380
|
// Promote due schedules first so their instances are claimable this pass.
|
|
349
381
|
await promoteSchedules(s, at);
|
|
382
|
+
if (disposed) return;
|
|
350
383
|
const lanes = await s.listRunnableQueues(at);
|
|
351
384
|
for (const lane of lanes){
|
|
385
|
+
if (disposed) return;
|
|
352
386
|
const cap = laneConcurrency(lane) - await s.countRunning(lane, at);
|
|
353
387
|
if (cap <= 0) continue;
|
|
354
388
|
// Clamp to available tokens; consume only for rows actually claimed
|
|
@@ -357,6 +391,9 @@ export function createWorkflowEngine(deps) {
|
|
|
357
391
|
if (grant <= 0) continue;
|
|
358
392
|
const rows = await s.claimDue(lane, grant, bootID, LEASE_MS, at);
|
|
359
393
|
consumeTokens(lane, rows.length);
|
|
394
|
+
// If dispose() fired during claimDue, leave the just-claimed rows for
|
|
395
|
+
// crash-reclaim rather than starting drives no abort sweep will cancel.
|
|
396
|
+
if (disposed) return;
|
|
360
397
|
for (const row of rows)track(run(row));
|
|
361
398
|
}
|
|
362
399
|
} catch (cause) {
|
|
@@ -378,12 +415,32 @@ export function createWorkflowEngine(deps) {
|
|
|
378
415
|
});
|
|
379
416
|
}
|
|
380
417
|
const tickMs = deps.tickMs ?? DEFAULT_TICK_MS;
|
|
418
|
+
const disposeTimeoutMs = deps.disposeTimeoutMs ?? DEFAULT_DISPOSE_TIMEOUT_MS;
|
|
381
419
|
let interval = setInterval(()=>{
|
|
382
|
-
|
|
420
|
+
// Tracked, not fire-and-forget: dispose()'s drain must be able to await an
|
|
421
|
+
// interval tick that is mid-pass, or its claim/promote writes could land
|
|
422
|
+
// after dispose() resolves and the store closes.
|
|
423
|
+
track(tick());
|
|
383
424
|
}, tickMs);
|
|
384
425
|
// Allow the process to exit even while the scheduler interval is pending.
|
|
385
426
|
const intervalHandle = interval;
|
|
386
427
|
if (typeof intervalHandle?.unref === 'function') intervalHandle.unref();
|
|
428
|
+
// Wire the identity readiness gate. On resolve, open the gate and drive any work
|
|
429
|
+
// enqueued while it was closed. On rejection (an identity mismatch) keep the gate
|
|
430
|
+
// shut forever AND drop the interval, so the engine is quiescent rather than merely
|
|
431
|
+
// barred at the HLC mint — which remains the fail-closed backstop if a drive slips
|
|
432
|
+
// through.
|
|
433
|
+
if (deps.ready != null) {
|
|
434
|
+
void deps.ready().then(()=>{
|
|
435
|
+
identityReady = true;
|
|
436
|
+
scheduleTick();
|
|
437
|
+
}, ()=>{
|
|
438
|
+
if (interval != null) {
|
|
439
|
+
clearInterval(interval);
|
|
440
|
+
interval = null;
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
387
444
|
const api = {
|
|
388
445
|
register (def) {
|
|
389
446
|
const erased = def;
|
|
@@ -401,6 +458,12 @@ export function createWorkflowEngine(deps) {
|
|
|
401
458
|
isManagedWorkflow (name) {
|
|
402
459
|
return registry.get(name)?.managed === true;
|
|
403
460
|
},
|
|
461
|
+
isRemotelyObservable (name) {
|
|
462
|
+
return registry.get(name)?.remote?.observe === true;
|
|
463
|
+
},
|
|
464
|
+
isRemotelyCommandable (name) {
|
|
465
|
+
return registry.get(name)?.remote?.command === true;
|
|
466
|
+
},
|
|
404
467
|
defineQueue,
|
|
405
468
|
async schedule (name, params, when) {
|
|
406
469
|
const s = await store();
|
|
@@ -504,23 +567,10 @@ export function createWorkflowEngine(deps) {
|
|
|
504
567
|
runAfter: createdAt,
|
|
505
568
|
createdAt
|
|
506
569
|
};
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
// A concurrent insert may lose the singleton race and surface the
|
|
512
|
-
// partial-unique constraint as a thrown error. Treat it as "singleton
|
|
513
|
-
// already active": return the existing active instance rather than throw.
|
|
514
|
-
if (opts?.singletonKey != null) {
|
|
515
|
-
const active = (await s.list({
|
|
516
|
-
name
|
|
517
|
-
})).find((r)=>r.singleton_key === opts.singletonKey && (r.status === 'pending' || r.status === 'running'));
|
|
518
|
-
if (active != null) return {
|
|
519
|
-
instanceID: active.instance_id
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
throw cause;
|
|
523
|
-
}
|
|
570
|
+
// insertInstance is atomic against a concurrent racer: an idempotency_key
|
|
571
|
+
// or active-singleton collision returns the winner's row as created:false
|
|
572
|
+
// rather than throwing, so no constraint-race catch is needed here.
|
|
573
|
+
const result = await s.insertInstance(input);
|
|
524
574
|
// Idempotent or singleton hit: return the existing instance, drive nothing.
|
|
525
575
|
if (!result.created) {
|
|
526
576
|
return {
|
|
@@ -554,6 +604,54 @@ export function createWorkflowEngine(deps) {
|
|
|
554
604
|
name: row.name
|
|
555
605
|
});
|
|
556
606
|
},
|
|
607
|
+
async retry (instanceID) {
|
|
608
|
+
const s = await store();
|
|
609
|
+
// Read first to classify a would-be no-op into a precise refusal; the
|
|
610
|
+
// store's guarded UPDATE remains the source of truth (a status flipped
|
|
611
|
+
// between this read and the write surfaces as an unapplied re-arm below).
|
|
612
|
+
const row = await s.get(instanceID);
|
|
613
|
+
if (row == null) {
|
|
614
|
+
throw new WorkflowRetryError('not_found', `Workflow instance "${instanceID}" not found`);
|
|
615
|
+
}
|
|
616
|
+
if (row.status !== 'failed') {
|
|
617
|
+
throw new WorkflowRetryError('not_failed', `Workflow instance "${instanceID}" is ${row.status}, not failed`);
|
|
618
|
+
}
|
|
619
|
+
if (row.next_action == null) {
|
|
620
|
+
throw new WorkflowRetryError('no_next_action', `Workflow instance "${instanceID}" has no resumable action to retry`);
|
|
621
|
+
}
|
|
622
|
+
if (row.singleton_key != null) {
|
|
623
|
+
const live = (await s.list({
|
|
624
|
+
name: row.name
|
|
625
|
+
})).find((r)=>r.singleton_key === row.singleton_key && r.instance_id !== instanceID && (r.status === 'pending' || r.status === 'running'));
|
|
626
|
+
if (live != null) {
|
|
627
|
+
throw new WorkflowRetryError('singleton', `Workflow "${row.name}" already has a live instance for singleton "${row.singleton_key}"`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
let rearmed;
|
|
631
|
+
try {
|
|
632
|
+
rearmed = await s.retryFailed(instanceID, now());
|
|
633
|
+
} catch (cause) {
|
|
634
|
+
// The only constraint the guarded UPDATE can violate is the singleton
|
|
635
|
+
// partial-unique index: a concurrent enqueue won the race after our
|
|
636
|
+
// pre-check. Surface it as the singleton refusal, not a raw driver error.
|
|
637
|
+
if (row.singleton_key != null) {
|
|
638
|
+
// biome-ignore lint/style/useErrorCause: cause is forwarded to super via the third options arg
|
|
639
|
+
throw new WorkflowRetryError('singleton', `Workflow "${row.name}" already has a live instance for singleton "${row.singleton_key}"`, {
|
|
640
|
+
cause
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
throw cause;
|
|
644
|
+
}
|
|
645
|
+
if (rearmed == null) {
|
|
646
|
+
// The status guard did not match: a concurrent retry (or reclaim) flipped
|
|
647
|
+
// it out of `failed` first. Never a false success.
|
|
648
|
+
throw new WorkflowRetryError('not_failed', `Workflow instance "${instanceID}" was no longer failed when re-armed`);
|
|
649
|
+
}
|
|
650
|
+
// Wake the scheduler so the re-armed instance is claimed without waiting
|
|
651
|
+
// for the poll interval, exactly as enqueue does.
|
|
652
|
+
scheduleTick();
|
|
653
|
+
return toStatus(rearmed);
|
|
654
|
+
},
|
|
557
655
|
async get (instanceID) {
|
|
558
656
|
const s = await store();
|
|
559
657
|
const row = await s.get(instanceID);
|
|
@@ -606,17 +704,67 @@ export function createWorkflowEngine(deps) {
|
|
|
606
704
|
// resumes them. The returned ids are the pre-tick stranded set.
|
|
607
705
|
const s = await store();
|
|
608
706
|
const stranded = await s.listRecoverable(now());
|
|
609
|
-
|
|
707
|
+
// Tracked like every other tick so a concurrent dispose() drains this
|
|
708
|
+
// pass's store writes rather than resolving out from under them.
|
|
709
|
+
const p = tick();
|
|
710
|
+
track(p);
|
|
711
|
+
await p;
|
|
610
712
|
return stranded.map((r)=>r.instance_id);
|
|
611
713
|
},
|
|
612
|
-
tick
|
|
613
|
-
dispose
|
|
714
|
+
// Tracked so dispose()'s drain awaits a manual tick that is mid-pass; an
|
|
715
|
+
// untracked one could issue promote/claim writes after dispose() resolved.
|
|
716
|
+
tick: ()=>{
|
|
717
|
+
const p = tick();
|
|
718
|
+
track(p);
|
|
719
|
+
return p;
|
|
720
|
+
},
|
|
721
|
+
async dispose () {
|
|
722
|
+
disposed = true;
|
|
614
723
|
if (interval != null) {
|
|
615
724
|
clearInterval(interval);
|
|
616
725
|
interval = null;
|
|
617
726
|
}
|
|
727
|
+
// Abort every live drive, then drain to quiescence: an aborted run still
|
|
728
|
+
// issues its `cancelled` terminal write, and aborting frees a lane so a
|
|
729
|
+
// straggler drive may appear — so loop until nothing is in flight rather than
|
|
730
|
+
// awaiting a single snapshot. `disposed` keeps tick() from adding new work, so
|
|
731
|
+
// a cooperative drive set terminates and the caller can then safely close the
|
|
732
|
+
// store.
|
|
618
733
|
for (const controller of running.values())controller.abort();
|
|
619
734
|
running.clear();
|
|
735
|
+
let drainedDone = false;
|
|
736
|
+
const drained = (async ()=>{
|
|
737
|
+
while(inflight.size > 0)await Promise.allSettled([
|
|
738
|
+
...inflight
|
|
739
|
+
]);
|
|
740
|
+
drainedDone = true;
|
|
741
|
+
})();
|
|
742
|
+
// Bound the wait: a handler that never observes its abort signal leaves an
|
|
743
|
+
// un-settling drive in `inflight`, and an unbounded drain would wedge shutdown
|
|
744
|
+
// forever. Resolve best-effort on the deadline instead; the timer is unref'd so
|
|
745
|
+
// it never holds the process open. On timeout genuine writes may still be in
|
|
746
|
+
// flight, so a caller that then closes the store can race them — the deadline
|
|
747
|
+
// trades that residual for a bounded shutdown, and only fires for a
|
|
748
|
+
// non-settling drive (logged).
|
|
749
|
+
let timer;
|
|
750
|
+
const deadline = new Promise((resolve)=>{
|
|
751
|
+
timer = setTimeout(resolve, disposeTimeoutMs);
|
|
752
|
+
timer.unref?.();
|
|
753
|
+
});
|
|
754
|
+
try {
|
|
755
|
+
await Promise.race([
|
|
756
|
+
drained,
|
|
757
|
+
deadline
|
|
758
|
+
]);
|
|
759
|
+
} finally{
|
|
760
|
+
if (timer != null) clearTimeout(timer);
|
|
761
|
+
}
|
|
762
|
+
if (!drainedDone) {
|
|
763
|
+
logger.warn('workflow dispose timed out draining in-flight drives', {
|
|
764
|
+
inflight: inflight.size,
|
|
765
|
+
disposeTimeoutMs
|
|
766
|
+
});
|
|
767
|
+
}
|
|
620
768
|
}
|
|
621
769
|
};
|
|
622
770
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
|
|
|
2
2
|
export type WorkflowPluginOptions = Record<string, never>;
|
|
3
3
|
export declare function createWorkflowPlugin(_options?: WorkflowPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
|
|
4
4
|
export { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
|
|
5
|
-
export {
|
|
5
|
+
export { createWorkflowControlPlaneHelpers, WorkflowCommandRefusedError, type WorkflowControlPlaneHelpers, } from './control-plane.js';
|
|
6
|
+
export { defineWorkflow, isRemotelyCommandable, isRemotelyObservable } from './definition.js';
|
|
6
7
|
export type { WorkflowEngine, WorkflowEngineDeps } from './engine.js';
|
|
7
|
-
export { createWorkflowEngine, parseEvery } from './engine.js';
|
|
8
|
+
export { createWorkflowEngine, parseEvery, WorkflowRetryError } from './engine.js';
|
|
8
9
|
export type { WorkflowContext, WorkflowEventResult } from './schema.js';
|
|
9
10
|
export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents, } from './schema.js';
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getWorkflowStore, workflowStoreDefinition } from '@kubun/store-workflow';
|
|
2
|
+
import { createWorkflowControlPlaneHelpers } from './control-plane.js';
|
|
2
3
|
import { createWorkflowEngine } from './engine.js';
|
|
3
4
|
import { createWorkflowContextFactory, createWorkflowSchemaExtension } from './schema.js';
|
|
4
5
|
export function createWorkflowPlugin(_options = {}) {
|
|
@@ -8,17 +9,31 @@ export function createWorkflowPlugin(_options = {}) {
|
|
|
8
9
|
const engine = createWorkflowEngine({
|
|
9
10
|
getStore: ()=>getWorkflowStore(params.db),
|
|
10
11
|
logger,
|
|
11
|
-
getRandomID: ()=>params.runtime.getRandomID()
|
|
12
|
+
getRandomID: ()=>params.runtime.getRandomID(),
|
|
13
|
+
// Gate the scheduler on the host engine's identity verification: nudges and
|
|
14
|
+
// the interval claim no work until `ready()` resolves, and an identity
|
|
15
|
+
// mismatch leaves the engine inert rather than driving jobs on a database it
|
|
16
|
+
// was rejected from.
|
|
17
|
+
ready: ()=>params.engine.ready()
|
|
12
18
|
});
|
|
13
|
-
//
|
|
14
|
-
|
|
19
|
+
// Re-drive stranded instances once on boot, but only after the host engine
|
|
20
|
+
// verifies its database identity — a re-driven instance mints under the engine's
|
|
21
|
+
// clock, so recovery waits behind `ready()` rather than tripping the mint gate.
|
|
22
|
+
void params.engine.ready().then(()=>engine.recover()).catch((error)=>{
|
|
15
23
|
logger.error('workflow recovery failed', {
|
|
16
24
|
error
|
|
17
25
|
});
|
|
18
26
|
});
|
|
27
|
+
// Caller-side control-plane helpers over the p2p plugin's typed API. Resolved
|
|
28
|
+
// lazily (getAPI cannot be awaited while factories are still returning); a device
|
|
29
|
+
// with no p2p plugin rejects on first use.
|
|
30
|
+
const controlPlane = createWorkflowControlPlaneHelpers(()=>params.engine.getAPI('p2p'));
|
|
19
31
|
return {
|
|
20
32
|
name: 'workflow',
|
|
21
|
-
api:
|
|
33
|
+
api: {
|
|
34
|
+
...engine.api,
|
|
35
|
+
...controlPlane
|
|
36
|
+
},
|
|
22
37
|
schemaExtension: ()=>createWorkflowSchemaExtension(),
|
|
23
38
|
createContextFactory: ()=>createWorkflowContextFactory(engine.api),
|
|
24
39
|
dispose: ()=>engine.dispose()
|
|
@@ -26,6 +41,7 @@ export function createWorkflowPlugin(_options = {}) {
|
|
|
26
41
|
};
|
|
27
42
|
}
|
|
28
43
|
export { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
|
|
29
|
-
export {
|
|
30
|
-
export {
|
|
44
|
+
export { createWorkflowControlPlaneHelpers, WorkflowCommandRefusedError } from './control-plane.js';
|
|
45
|
+
export { defineWorkflow, isRemotelyCommandable, isRemotelyObservable } from './definition.js';
|
|
46
|
+
export { createWorkflowEngine, parseEvery, WorkflowRetryError } from './engine.js';
|
|
31
47
|
export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents } from './schema.js';
|
package/lib/schema.d.ts
CHANGED
|
@@ -9,14 +9,13 @@ export type WorkflowEventResult = {
|
|
|
9
9
|
/**
|
|
10
10
|
* Subscribe to a workflow's lifecycle events, yielding GraphQL result shapes.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* A manual async iterator, not an `async function*`, so a consumer's `return()`
|
|
13
|
+
* removes all listeners immediately; a `for await` wrapper would queue `return()`
|
|
14
|
+
* behind a parked `next()` and leak the listeners until the next event — for an
|
|
15
|
+
* idle subscription, potentially never.
|
|
16
16
|
*
|
|
17
|
-
* `fromEmitter`
|
|
18
|
-
*
|
|
19
|
-
* listen-only `EventsSource`, so the fan-in is done by hand over `on`.
|
|
17
|
+
* `fromEmitter` handles a single event name; `WorkflowEvents` has five keys and the
|
|
18
|
+
* API exposes only the listen-only `EventsSource`, so the fan-in is done by hand.
|
|
20
19
|
*/
|
|
21
20
|
export declare function subscribeToWorkflowEvents(events: EventsSource<WorkflowEvents>, instanceID?: string | null): AsyncIterableIterator<WorkflowEventResult>;
|
|
22
21
|
/**
|
|
@@ -30,6 +29,8 @@ export declare function subscribeToWorkflowEvents(events: EventsSource<WorkflowE
|
|
|
30
29
|
export type WorkflowContext = {
|
|
31
30
|
get(instanceID: string): Promise<InstanceStatus | null>;
|
|
32
31
|
list(filter: ListFilter): Promise<Array<InstanceStatus>>;
|
|
32
|
+
retry(instanceID: string): Promise<InstanceStatus>;
|
|
33
|
+
cancel(instanceID: string): Promise<void>;
|
|
33
34
|
subscribeToWorkflowEvents(instanceID?: string | null): AsyncIterableIterator<WorkflowEventResult>;
|
|
34
35
|
getPeriodicSync(scheduleID: string): Promise<PeriodicSync | null>;
|
|
35
36
|
listPeriodicSyncs(workflowName?: string): Promise<Array<PeriodicSync>>;
|
package/lib/schema.js
CHANGED
|
@@ -14,14 +14,13 @@ const EVENT_TYPES = [
|
|
|
14
14
|
/**
|
|
15
15
|
* Subscribe to a workflow's lifecycle events, yielding GraphQL result shapes.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* A manual async iterator, not an `async function*`, so a consumer's `return()`
|
|
18
|
+
* removes all listeners immediately; a `for await` wrapper would queue `return()`
|
|
19
|
+
* behind a parked `next()` and leak the listeners until the next event — for an
|
|
20
|
+
* idle subscription, potentially never.
|
|
21
21
|
*
|
|
22
|
-
* `fromEmitter`
|
|
23
|
-
*
|
|
24
|
-
* listen-only `EventsSource`, so the fan-in is done by hand over `on`.
|
|
22
|
+
* `fromEmitter` handles a single event name; `WorkflowEvents` has five keys and the
|
|
23
|
+
* API exposes only the listen-only `EventsSource`, so the fan-in is done by hand.
|
|
25
24
|
*/ export function subscribeToWorkflowEvents(events, instanceID) {
|
|
26
25
|
const filter = instanceID != null ? (data)=>data.instanceID === instanceID : undefined;
|
|
27
26
|
let isDone = false;
|
|
@@ -118,6 +117,8 @@ export function createWorkflowContextFactory(api) {
|
|
|
118
117
|
return ()=>({
|
|
119
118
|
get: (instanceID)=>api.get(instanceID),
|
|
120
119
|
list: (filter)=>api.list(filter),
|
|
120
|
+
retry: (instanceID)=>api.retry(instanceID),
|
|
121
|
+
cancel: (instanceID)=>api.cancel(instanceID),
|
|
121
122
|
subscribeToWorkflowEvents: (instanceID)=>subscribeToWorkflowEvents(api.events, instanceID),
|
|
122
123
|
getPeriodicSync: async (scheduleID)=>{
|
|
123
124
|
const sync = await api.getPeriodicSync(scheduleID);
|
|
@@ -182,6 +183,13 @@ extend type Query {
|
|
|
182
183
|
extend type Mutation {
|
|
183
184
|
setPeriodicSyncEnabled(id: ID!, enabled: Boolean!): PeriodicSync!
|
|
184
185
|
setPeriodicSyncPolicy(id: ID!, policy: JSON!): PeriodicSync!
|
|
186
|
+
# Operator re-arm of a failed instance. Errors (not a null result) when the
|
|
187
|
+
# instance is missing, not failed, has no resumable action, or a live
|
|
188
|
+
# singleton already holds its key.
|
|
189
|
+
retryWorkflow(instanceID: ID!): WorkflowInstanceStatus!
|
|
190
|
+
# Operator-initiated cancel. Async: cancel() only requests cancellation, so the
|
|
191
|
+
# returned status is a re-fetch right after and may not yet be terminal.
|
|
192
|
+
cancelWorkflow(instanceID: ID!): WorkflowInstanceStatus!
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
extend type Subscription {
|
|
@@ -204,7 +212,18 @@ export function createWorkflowSchemaExtension() {
|
|
|
204
212
|
},
|
|
205
213
|
mutationFields: {
|
|
206
214
|
setPeriodicSyncEnabled: (_source, args, context)=>requireWorkflow(context).setPeriodicSyncEnabled(decodeScheduleID(args.id), args.enabled),
|
|
207
|
-
setPeriodicSyncPolicy: (_source, args, context)=>requireWorkflow(context).setPeriodicSyncPolicy(decodeScheduleID(args.id), args.policy)
|
|
215
|
+
setPeriodicSyncPolicy: (_source, args, context)=>requireWorkflow(context).setPeriodicSyncPolicy(decodeScheduleID(args.id), args.policy),
|
|
216
|
+
retryWorkflow: (_source, args, context)=>requireWorkflow(context).retry(args.instanceID),
|
|
217
|
+
cancelWorkflow: async (_source, args, context)=>{
|
|
218
|
+
const ctx = requireWorkflow(context);
|
|
219
|
+
const instanceID = args.instanceID;
|
|
220
|
+
await ctx.cancel(instanceID);
|
|
221
|
+
const status = await ctx.get(instanceID);
|
|
222
|
+
if (status == null) {
|
|
223
|
+
throw new Error(`Workflow instance "${instanceID}" not found`);
|
|
224
|
+
}
|
|
225
|
+
return status;
|
|
226
|
+
}
|
|
208
227
|
},
|
|
209
228
|
typeFields: {
|
|
210
229
|
PeriodicSync: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/plugin-workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,21 +15,22 @@
|
|
|
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-
|
|
24
|
-
"@kubun/
|
|
18
|
+
"@kubun/db": "^0.16.0",
|
|
19
|
+
"@kubun/engine": "^0.16.0",
|
|
20
|
+
"@kubun/graphql": "^0.16.0",
|
|
21
|
+
"@kubun/id": "^0.16.0",
|
|
22
|
+
"@kubun/logger": "^0.16.0",
|
|
23
|
+
"@kubun/plugin-p2p-api": "^0.16.0",
|
|
24
|
+
"@kubun/plugin-workflow-api": "^0.16.0",
|
|
25
|
+
"@kubun/store-workflow": "^0.16.0",
|
|
25
26
|
"@sozai/event": "^0.1.3",
|
|
26
27
|
"@sozai/flow": "^0.3.1",
|
|
27
28
|
"@sozai/runtime": "^0.1.0",
|
|
28
29
|
"@sozai/schema": "^0.1.2"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
|
-
"@kubun/db-better-sqlite": "^0.
|
|
32
|
-
"@kubun/db-postgres": "^0.
|
|
32
|
+
"@kubun/db-better-sqlite": "^0.16.0",
|
|
33
|
+
"@kubun/db-postgres": "^0.16.0",
|
|
33
34
|
"@testcontainers/postgresql": "^12.1.0"
|
|
34
35
|
},
|
|
35
36
|
"publishConfig": {
|