@kubun/plugin-workflow 0.17.0 → 0.18.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.js +12 -3
- package/lib/engine.d.ts +5 -1
- package/lib/engine.js +131 -17
- package/lib/index.d.ts +22 -2
- package/lib/index.js +7 -2
- package/lib/instance-cleanup.d.ts +24 -0
- package/lib/instance-cleanup.js +28 -0
- package/lib/schema.js +3 -5
- package/package.json +14 -14
package/lib/control-plane.js
CHANGED
|
@@ -44,8 +44,17 @@ export function createWorkflowControlPlaneHelpers(getP2P) {
|
|
|
44
44
|
discoverWorkflows: async (groupID, options)=>(await getP2P()).discoverWorkflows(groupID, options),
|
|
45
45
|
listWorkflows: async (groupID, targetDID)=>(await getP2P()).listWorkflows(groupID, targetDID),
|
|
46
46
|
workflowStatus: async (groupID, targetDID, instanceID)=>(await getP2P()).workflowStatus(groupID, targetDID, instanceID),
|
|
47
|
-
enqueueWorkflow: async (groupID, targetDID, name, params, opts)=>
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
enqueueWorkflow: async (groupID, targetDID, name, params, opts)=>{
|
|
48
|
+
const p2p = await getP2P();
|
|
49
|
+
return unwrap(await p2p.enqueueWorkflow(groupID, targetDID, name, params, opts));
|
|
50
|
+
},
|
|
51
|
+
cancelWorkflow: async (groupID, targetDID, instanceID)=>{
|
|
52
|
+
const p2p = await getP2P();
|
|
53
|
+
return unwrap(await p2p.cancelWorkflow(groupID, targetDID, instanceID));
|
|
54
|
+
},
|
|
55
|
+
retryWorkflow: async (groupID, targetDID, instanceID)=>{
|
|
56
|
+
const p2p = await getP2P();
|
|
57
|
+
return unwrap(await p2p.retryWorkflow(groupID, targetDID, instanceID));
|
|
58
|
+
}
|
|
50
59
|
};
|
|
51
60
|
}
|
package/lib/engine.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Logger } from '@kubun/logger';
|
|
|
2
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
|
+
import { type ResolvedFinishedInstanceCleanup } from './instance-cleanup.js';
|
|
5
6
|
export declare function backoff(attempts: number, policy?: RetryPolicy): number;
|
|
6
7
|
export declare function parseEvery(spec: string): number;
|
|
7
8
|
export declare class WorkflowRetryError extends Error {
|
|
@@ -15,15 +16,18 @@ export type WorkflowEngineDeps = {
|
|
|
15
16
|
getStore: () => Promise<WorkflowStoreAPI>;
|
|
16
17
|
logger: Logger;
|
|
17
18
|
now?: () => number;
|
|
18
|
-
getRandomID
|
|
19
|
+
getRandomID: GetRandomID;
|
|
19
20
|
tickMs?: number;
|
|
20
21
|
disposeTimeoutMs?: number;
|
|
21
22
|
ready?: () => Promise<void>;
|
|
23
|
+
finishedInstanceCleanup?: ResolvedFinishedInstanceCleanup | null;
|
|
24
|
+
cleanupBatchSize?: number;
|
|
22
25
|
};
|
|
23
26
|
export type WorkflowEngine = {
|
|
24
27
|
api: WorkflowAPI;
|
|
25
28
|
recover(): Promise<Array<string>>;
|
|
26
29
|
tick(): Promise<void>;
|
|
30
|
+
cleanupFinished(): Promise<number>;
|
|
27
31
|
dispose(): Promise<void>;
|
|
28
32
|
};
|
|
29
33
|
export declare function createWorkflowEngine(deps: WorkflowEngineDeps): WorkflowEngine;
|
package/lib/engine.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { getWorkflowStore } from '@kubun/store-workflow';
|
|
1
2
|
import { EventEmitter } from '@sozai/event';
|
|
2
3
|
import { createFlow } from '@sozai/flow';
|
|
3
4
|
import { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
|
|
5
|
+
import { FINISHED_INSTANCE_CLEANUP_BATCH_SIZE } from './instance-cleanup.js';
|
|
4
6
|
// One runner may hold an instance's lease for this long before another boot may
|
|
5
7
|
// reclaim it as stranded.
|
|
6
8
|
const LEASE_MS = 30_000;
|
|
@@ -62,7 +64,8 @@ function toStatus(row) {
|
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
// The active (pending/running) instance for (name, singletonKey) if one exists,
|
|
65
|
-
// else the most recent terminal by updated_at
|
|
67
|
+
// else the most recent terminal by updated_at (a tie goes to the highest
|
|
68
|
+
// instance_id, as the finished-instance cleanup keeps), else null. Instances are promoted
|
|
66
69
|
// with singleton_key = subject, so the subject keys the lookup.
|
|
67
70
|
async function currentInstanceRow(s, name, singletonKey) {
|
|
68
71
|
const rows = await s.list({
|
|
@@ -70,7 +73,7 @@ async function currentInstanceRow(s, name, singletonKey) {
|
|
|
70
73
|
});
|
|
71
74
|
const forSubject = rows.filter((r)=>r.singleton_key === singletonKey);
|
|
72
75
|
const active = forSubject.find((r)=>r.status === 'pending' || r.status === 'running');
|
|
73
|
-
return active ?? forSubject.reduce((best, r)=>best == null || r.updated_at > best.updated_at ? r : best, null);
|
|
76
|
+
return active ?? forSubject.reduce((best, r)=>best == null || r.updated_at > best.updated_at || r.updated_at === best.updated_at && r.instance_id > best.instance_id ? r : best, null);
|
|
74
77
|
}
|
|
75
78
|
// Project an adaptive schedule row as a PeriodicSync. `currentStatus` is the
|
|
76
79
|
// schedule subject's current instance (see `currentInstanceRow`), null if never
|
|
@@ -97,7 +100,7 @@ export function createWorkflowEngine(deps) {
|
|
|
97
100
|
const registry = new Map();
|
|
98
101
|
// A per-process identity for lease ownership. A fresh engine over the same DB
|
|
99
102
|
// (a "reboot") gets a new one, which is what makes crash-reclaim observable.
|
|
100
|
-
const bootID =
|
|
103
|
+
const bootID = deps.getRandomID();
|
|
101
104
|
// Instances currently being driven in THIS process, so `cancel` can signal the
|
|
102
105
|
// running flow via its AbortController.
|
|
103
106
|
const running = new Map();
|
|
@@ -183,9 +186,12 @@ export function createWorkflowEngine(deps) {
|
|
|
183
186
|
handlers: def.handlers,
|
|
184
187
|
stateValidator: def.stateValidator
|
|
185
188
|
});
|
|
189
|
+
const initialAction = row.next_action ?? def.initialAction;
|
|
190
|
+
let currentAction = initialAction;
|
|
191
|
+
let stepInputState = row.state;
|
|
186
192
|
const flow = generate({
|
|
187
|
-
state:
|
|
188
|
-
action:
|
|
193
|
+
state: stepInputState,
|
|
194
|
+
action: initialAction,
|
|
189
195
|
signal: controller.signal
|
|
190
196
|
});
|
|
191
197
|
events.fire('started', {
|
|
@@ -207,11 +213,14 @@ export function createWorkflowEngine(deps) {
|
|
|
207
213
|
const held = await s.renew(instanceID, bootID, LEASE_MS, now());
|
|
208
214
|
if (!held) return;
|
|
209
215
|
const nextAction = value.status === 'action' ? value.action : null;
|
|
216
|
+
const checkpointState = flow.getState();
|
|
210
217
|
await s.checkpoint(instanceID, {
|
|
211
|
-
state:
|
|
218
|
+
state: checkpointState,
|
|
212
219
|
nextAction,
|
|
213
220
|
status: 'running'
|
|
214
221
|
}, now());
|
|
222
|
+
stepInputState = checkpointState;
|
|
223
|
+
currentAction = nextAction;
|
|
215
224
|
events.fire('checkpoint', {
|
|
216
225
|
instanceID,
|
|
217
226
|
name: def.name
|
|
@@ -241,7 +250,7 @@ export function createWorkflowEngine(deps) {
|
|
|
241
250
|
fireTerminal(def, instanceID, 'cancelled');
|
|
242
251
|
return;
|
|
243
252
|
}
|
|
244
|
-
// status === 'error'.
|
|
253
|
+
// status === 'error'. Persist the failing step with the state it received.
|
|
245
254
|
const error = String(value.error);
|
|
246
255
|
const attempts = row.attempts + 1;
|
|
247
256
|
const maxAttempts = def.retry?.maxAttempts ?? 1;
|
|
@@ -249,8 +258,8 @@ export function createWorkflowEngine(deps) {
|
|
|
249
258
|
// Retry rides the queue: re-queue `pending` with a future run gate; the
|
|
250
259
|
// scheduler re-claims it when due. Not terminal, so no terminal event.
|
|
251
260
|
await s.fail(instanceID, {
|
|
252
|
-
state:
|
|
253
|
-
nextAction:
|
|
261
|
+
state: stepInputState,
|
|
262
|
+
nextAction: currentAction,
|
|
254
263
|
error,
|
|
255
264
|
attempts,
|
|
256
265
|
runAfter: now() + backoff(attempts, def.retry),
|
|
@@ -259,8 +268,8 @@ export function createWorkflowEngine(deps) {
|
|
|
259
268
|
return;
|
|
260
269
|
}
|
|
261
270
|
const wrote = await s.fail(instanceID, {
|
|
262
|
-
state:
|
|
263
|
-
nextAction:
|
|
271
|
+
state: stepInputState,
|
|
272
|
+
nextAction: currentAction,
|
|
264
273
|
error,
|
|
265
274
|
attempts,
|
|
266
275
|
runAfter: now(),
|
|
@@ -280,6 +289,58 @@ export function createWorkflowEngine(deps) {
|
|
|
280
289
|
running.delete(instanceID);
|
|
281
290
|
}
|
|
282
291
|
}
|
|
292
|
+
// --- Finished-instance retention cleanup ---
|
|
293
|
+
const cleanup = deps.finishedInstanceCleanup ?? null;
|
|
294
|
+
const cleanupBatchSize = deps.cleanupBatchSize ?? FINISHED_INSTANCE_CLEANUP_BATCH_SIZE;
|
|
295
|
+
// Due immediately: the first scheduler pass after `ready` runs it.
|
|
296
|
+
let nextCleanupAt = Number.NEGATIVE_INFINITY;
|
|
297
|
+
let cleanupRun = null;
|
|
298
|
+
// One batch per statement until a short batch, or until dispose(). Errors are
|
|
299
|
+
// logged, never thrown: the next interval retries.
|
|
300
|
+
async function collectFinished(retentionMs) {
|
|
301
|
+
let total = 0;
|
|
302
|
+
try {
|
|
303
|
+
const s = await store();
|
|
304
|
+
const before = now() - retentionMs;
|
|
305
|
+
while(!disposed){
|
|
306
|
+
const removed = await s.purgeFinished({
|
|
307
|
+
before,
|
|
308
|
+
limit: cleanupBatchSize
|
|
309
|
+
});
|
|
310
|
+
total += removed;
|
|
311
|
+
if (removed < cleanupBatchSize) break;
|
|
312
|
+
}
|
|
313
|
+
if (total > 0) {
|
|
314
|
+
logger.debug('workflow cleanup removed {count} finished instances', {
|
|
315
|
+
count: total
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
} catch (cause) {
|
|
319
|
+
logger.warn('workflow finished-instance cleanup failed', {
|
|
320
|
+
error: cause
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return total;
|
|
324
|
+
}
|
|
325
|
+
function cleanupFinished() {
|
|
326
|
+
if (cleanup == null || disposed) return Promise.resolve(0);
|
|
327
|
+
if (cleanupRun == null) {
|
|
328
|
+
const run = collectFinished(cleanup.retentionMs).finally(()=>{
|
|
329
|
+
cleanupRun = null;
|
|
330
|
+
});
|
|
331
|
+
cleanupRun = run;
|
|
332
|
+
// Tracked so dispose() drains a run in progress before the store closes.
|
|
333
|
+
track(run.then(()=>{}));
|
|
334
|
+
}
|
|
335
|
+
return cleanupRun;
|
|
336
|
+
}
|
|
337
|
+
// Called from each scheduler pass; starts a run once the interval is due. The
|
|
338
|
+
// pass does not wait for it, so a long cleanup never delays claims.
|
|
339
|
+
function maybeCleanup(at) {
|
|
340
|
+
if (cleanup == null || cleanupRun != null || at < nextCleanupAt) return;
|
|
341
|
+
nextCleanupAt = at + cleanup.intervalMs;
|
|
342
|
+
void cleanupFinished();
|
|
343
|
+
}
|
|
283
344
|
// Per-lane concurrency. Defaults to 1; `defineQueue` raises a lane's cap.
|
|
284
345
|
const concurrency = new Map();
|
|
285
346
|
function laneConcurrency(lane) {
|
|
@@ -379,6 +440,7 @@ export function createWorkflowEngine(deps) {
|
|
|
379
440
|
// in-flight when dispose() fired from claiming fresh work after shutdown began.
|
|
380
441
|
if (disposed) return;
|
|
381
442
|
const at = now();
|
|
443
|
+
maybeCleanup(at);
|
|
382
444
|
// Promote due schedules first so their instances are claimable this pass.
|
|
383
445
|
await promoteSchedules(s, at);
|
|
384
446
|
if (disposed) return;
|
|
@@ -470,7 +532,7 @@ export function createWorkflowEngine(deps) {
|
|
|
470
532
|
async schedule (name, params, when) {
|
|
471
533
|
const s = await store();
|
|
472
534
|
const everyMs = parseEvery(when.every);
|
|
473
|
-
const id =
|
|
535
|
+
const id = deps.getRandomID();
|
|
474
536
|
const at = now();
|
|
475
537
|
// First fire one interval out: a restart storm must not refire everything
|
|
476
538
|
// that was ever scheduled.
|
|
@@ -486,7 +548,8 @@ export function createWorkflowEngine(deps) {
|
|
|
486
548
|
const handle = {
|
|
487
549
|
id,
|
|
488
550
|
async disable () {
|
|
489
|
-
|
|
551
|
+
const s2 = await store();
|
|
552
|
+
await s2.setScheduleEnabled(id, false, now());
|
|
490
553
|
},
|
|
491
554
|
async enable () {
|
|
492
555
|
// Re-arm one interval out from re-enable so a long-disabled schedule
|
|
@@ -498,7 +561,8 @@ export function createWorkflowEngine(deps) {
|
|
|
498
561
|
},
|
|
499
562
|
async cancel () {
|
|
500
563
|
// No store delete today: permanent disable is the cancel semantics.
|
|
501
|
-
|
|
564
|
+
const s2 = await store();
|
|
565
|
+
await s2.setScheduleEnabled(id, false, now());
|
|
502
566
|
}
|
|
503
567
|
};
|
|
504
568
|
return handle;
|
|
@@ -525,7 +589,8 @@ export function createWorkflowEngine(deps) {
|
|
|
525
589
|
const handle = {
|
|
526
590
|
id,
|
|
527
591
|
async disable () {
|
|
528
|
-
|
|
592
|
+
const s2 = await store();
|
|
593
|
+
await s2.setScheduleEnabled(id, false, now());
|
|
529
594
|
},
|
|
530
595
|
async enable () {
|
|
531
596
|
// Re-arm eagerly (now + changed), mirroring reactivation above.
|
|
@@ -540,7 +605,8 @@ export function createWorkflowEngine(deps) {
|
|
|
540
605
|
await s2.setScheduleEnabled(id, true, reAt);
|
|
541
606
|
},
|
|
542
607
|
async cancel () {
|
|
543
|
-
|
|
608
|
+
const s2 = await store();
|
|
609
|
+
await s2.setScheduleEnabled(id, false, now());
|
|
544
610
|
}
|
|
545
611
|
};
|
|
546
612
|
return handle;
|
|
@@ -551,7 +617,7 @@ export function createWorkflowEngine(deps) {
|
|
|
551
617
|
throw new Error(`Workflow "${name}" is not registered`);
|
|
552
618
|
}
|
|
553
619
|
const s = await store();
|
|
554
|
-
const instanceID =
|
|
620
|
+
const instanceID = deps.getRandomID();
|
|
555
621
|
const createdAt = now();
|
|
556
622
|
const initialAction = params !== undefined ? {
|
|
557
623
|
name: def.initialAction.name,
|
|
@@ -585,6 +651,53 @@ export function createWorkflowEngine(deps) {
|
|
|
585
651
|
instanceID
|
|
586
652
|
};
|
|
587
653
|
},
|
|
654
|
+
async enqueueTx (stores, name, params, opts) {
|
|
655
|
+
const def = registry.get(name);
|
|
656
|
+
if (def == null) {
|
|
657
|
+
throw new Error(`Workflow "${name}" is not registered`);
|
|
658
|
+
}
|
|
659
|
+
// Resolve the store from the CALLER's request-scoped provider, not the
|
|
660
|
+
// top-level `store()` — when `stores` is a tx-scoped StoreProvider
|
|
661
|
+
// (e.g. from `db.withTransaction`), this binds the returned store to
|
|
662
|
+
// that already-open transaction.
|
|
663
|
+
const s = await getWorkflowStore(stores);
|
|
664
|
+
const instanceID = deps.getRandomID();
|
|
665
|
+
const createdAt = now();
|
|
666
|
+
const initialAction = params !== undefined ? {
|
|
667
|
+
name: def.initialAction.name,
|
|
668
|
+
params
|
|
669
|
+
} : def.initialAction;
|
|
670
|
+
const input = {
|
|
671
|
+
instanceID,
|
|
672
|
+
name,
|
|
673
|
+
queue: opts?.queue ?? def.queue ?? name,
|
|
674
|
+
idempotencyKey: opts?.idempotencyKey ?? null,
|
|
675
|
+
singletonKey: opts?.singletonKey ?? null,
|
|
676
|
+
priority: opts?.priority ?? 0,
|
|
677
|
+
state: {},
|
|
678
|
+
nextAction: initialAction,
|
|
679
|
+
runAfter: createdAt,
|
|
680
|
+
createdAt
|
|
681
|
+
};
|
|
682
|
+
// insertInstanceTx runs directly on the tx-bound store — no nested
|
|
683
|
+
// transaction — inheriting atomicity from the caller's own transaction.
|
|
684
|
+
const result = await s.insertInstanceTx(input);
|
|
685
|
+
// Idempotent or singleton hit: return the existing instance, drive nothing.
|
|
686
|
+
if (!result.created) {
|
|
687
|
+
return {
|
|
688
|
+
instanceID: result.row.instance_id
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
// Defer the wake until AFTER the caller's transaction commits: `stores`
|
|
692
|
+
// IS that transaction here, so an inline scheduleTick() (as `enqueue`
|
|
693
|
+
// does above) could wake the scheduler to claim a row whose insert has
|
|
694
|
+
// not committed yet. onCommit fires post-commit, or never if the
|
|
695
|
+
// transaction rolls back.
|
|
696
|
+
stores.onCommit(()=>scheduleTick());
|
|
697
|
+
return {
|
|
698
|
+
instanceID
|
|
699
|
+
};
|
|
700
|
+
},
|
|
588
701
|
async cancel (instanceID) {
|
|
589
702
|
const controller = running.get(instanceID);
|
|
590
703
|
if (controller != null) {
|
|
@@ -720,6 +833,7 @@ export function createWorkflowEngine(deps) {
|
|
|
720
833
|
track(p);
|
|
721
834
|
return p;
|
|
722
835
|
},
|
|
836
|
+
cleanupFinished,
|
|
723
837
|
async dispose () {
|
|
724
838
|
disposed = true;
|
|
725
839
|
if (interval != null) {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
import { type FinishedInstanceCleanupOptions } from './instance-cleanup.js';
|
|
3
|
+
export type WorkflowPluginOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Retention cleanup of finished workflow instances. Every run of a workflow,
|
|
6
|
+
* scheduled ones included, leaves an instance row; once it has been finished
|
|
7
|
+
* (completed, failed or cancelled) for `retentionMs`, the cleanup deletes it.
|
|
8
|
+
* Defaults: runs every 6 hours, keeps 30 days. Omit it for the defaults,
|
|
9
|
+
* pass an object to change either value, or `false` to keep every instance.
|
|
10
|
+
*
|
|
11
|
+
* It never deletes a pending or running instance, nor the newest instance of
|
|
12
|
+
* a periodic sync's subject, which is what its status and
|
|
13
|
+
* `getCurrentInstance` report. A deleted instance is unknown to `get`, cannot be retried,
|
|
14
|
+
* and no longer holds its idempotency key: an enqueue reusing that key
|
|
15
|
+
* creates a new instance.
|
|
16
|
+
*
|
|
17
|
+
* Each value must be a positive finite number, or the plugin throws when it
|
|
18
|
+
* is created.
|
|
19
|
+
*/
|
|
20
|
+
finishedInstanceCleanup?: FinishedInstanceCleanupOptions;
|
|
21
|
+
};
|
|
22
|
+
export declare function createWorkflowPlugin(options?: WorkflowPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
|
|
4
23
|
export { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
|
|
5
24
|
export { createWorkflowControlPlaneHelpers, WorkflowCommandRefusedError, type WorkflowControlPlaneHelpers, } from './control-plane.js';
|
|
6
25
|
export { defineWorkflow, isRemotelyCommandable, isRemotelyObservable } from './definition.js';
|
|
7
26
|
export type { WorkflowEngine, WorkflowEngineDeps } from './engine.js';
|
|
8
27
|
export { createWorkflowEngine, parseEvery, WorkflowRetryError } from './engine.js';
|
|
28
|
+
export { DEFAULT_FINISHED_INSTANCE_CLEANUP_INTERVAL_MS, DEFAULT_FINISHED_INSTANCE_RETENTION_MS, FINISHED_INSTANCE_CLEANUP_BATCH_SIZE, type FinishedInstanceCleanupOptions, type ResolvedFinishedInstanceCleanup, resolveFinishedInstanceCleanup, } from './instance-cleanup.js';
|
|
9
29
|
export type { WorkflowContext, WorkflowEventResult } from './schema.js';
|
|
10
30
|
export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents, } from './schema.js';
|
package/lib/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { getWorkflowStore, workflowStoreDefinition } from '@kubun/store-workflow';
|
|
2
2
|
import { createWorkflowControlPlaneHelpers } from './control-plane.js';
|
|
3
3
|
import { createWorkflowEngine } from './engine.js';
|
|
4
|
+
import { resolveFinishedInstanceCleanup } from './instance-cleanup.js';
|
|
4
5
|
import { createWorkflowContextFactory, createWorkflowSchemaExtension } from './schema.js';
|
|
5
|
-
export function createWorkflowPlugin(
|
|
6
|
+
export function createWorkflowPlugin(options = {}) {
|
|
7
|
+
// Validated here, so a bad value fails when the plugin is created.
|
|
8
|
+
const cleanupSettings = resolveFinishedInstanceCleanup(options.finishedInstanceCleanup);
|
|
6
9
|
return (params)=>{
|
|
7
10
|
params.db.register(workflowStoreDefinition);
|
|
8
11
|
const logger = params.getLogger('workflow');
|
|
@@ -14,7 +17,8 @@ export function createWorkflowPlugin(_options = {}) {
|
|
|
14
17
|
// the interval claim no work until `ready()` resolves, and an identity
|
|
15
18
|
// mismatch leaves the engine inert rather than driving jobs on a database it
|
|
16
19
|
// was rejected from.
|
|
17
|
-
ready: ()=>params.engine.ready()
|
|
20
|
+
ready: ()=>params.engine.ready(),
|
|
21
|
+
finishedInstanceCleanup: cleanupSettings
|
|
18
22
|
});
|
|
19
23
|
// Re-drive stranded instances once on boot, but only after the host engine
|
|
20
24
|
// verifies its database identity — a re-driven instance mints under the engine's
|
|
@@ -44,4 +48,5 @@ export { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
|
|
|
44
48
|
export { createWorkflowControlPlaneHelpers, WorkflowCommandRefusedError } from './control-plane.js';
|
|
45
49
|
export { defineWorkflow, isRemotelyCommandable, isRemotelyObservable } from './definition.js';
|
|
46
50
|
export { createWorkflowEngine, parseEvery, WorkflowRetryError } from './engine.js';
|
|
51
|
+
export { DEFAULT_FINISHED_INSTANCE_CLEANUP_INTERVAL_MS, DEFAULT_FINISHED_INSTANCE_RETENTION_MS, FINISHED_INSTANCE_CLEANUP_BATCH_SIZE, resolveFinishedInstanceCleanup } from './instance-cleanup.js';
|
|
47
52
|
export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents } from './schema.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention cleanup of finished workflow instances (completed, failed or
|
|
3
|
+
* cancelled). `false` turns it off; an object (or the option left out) turns
|
|
4
|
+
* it on, each value defaulting when absent.
|
|
5
|
+
*/
|
|
6
|
+
export type FinishedInstanceCleanupOptions = false | {
|
|
7
|
+
/** How often the cleanup runs, in milliseconds. Default 6 hours. */
|
|
8
|
+
intervalMs?: number;
|
|
9
|
+
/** How long a finished instance is kept, in milliseconds. Default 30 days. */
|
|
10
|
+
retentionMs?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare const DEFAULT_FINISHED_INSTANCE_CLEANUP_INTERVAL_MS: number;
|
|
13
|
+
export declare const DEFAULT_FINISHED_INSTANCE_RETENTION_MS: number;
|
|
14
|
+
/** The most rows one cleanup statement deletes. */
|
|
15
|
+
export declare const FINISHED_INSTANCE_CLEANUP_BATCH_SIZE = 500;
|
|
16
|
+
export type ResolvedFinishedInstanceCleanup = {
|
|
17
|
+
intervalMs: number;
|
|
18
|
+
retentionMs: number;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The cleanup settings `options` describe, or null when cleanup is off.
|
|
22
|
+
* Throws on a value that is not a positive whole number of milliseconds.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveFinishedInstanceCleanup(options: FinishedInstanceCleanupOptions | undefined): ResolvedFinishedInstanceCleanup | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention cleanup of finished workflow instances (completed, failed or
|
|
3
|
+
* cancelled). `false` turns it off; an object (or the option left out) turns
|
|
4
|
+
* it on, each value defaulting when absent.
|
|
5
|
+
*/ export const DEFAULT_FINISHED_INSTANCE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
6
|
+
export const DEFAULT_FINISHED_INSTANCE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
/** The most rows one cleanup statement deletes. */ export const FINISHED_INSTANCE_CLEANUP_BATCH_SIZE = 500;
|
|
8
|
+
function assertDuration(value, label) {
|
|
9
|
+
// Whole milliseconds only: the cutoff is compared with integer timestamp
|
|
10
|
+
// columns, which Postgres will not compare with a fractional parameter.
|
|
11
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
12
|
+
throw new Error(`finishedInstanceCleanup.${label} must be a positive whole number of milliseconds`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The cleanup settings `options` describe, or null when cleanup is off.
|
|
18
|
+
* Throws on a value that is not a positive whole number of milliseconds.
|
|
19
|
+
*/ export function resolveFinishedInstanceCleanup(options) {
|
|
20
|
+
if (options === false) return null;
|
|
21
|
+
if (options != null && (typeof options !== 'object' || Array.isArray(options))) {
|
|
22
|
+
throw new Error('finishedInstanceCleanup must be false or an object');
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
intervalMs: assertDuration(options?.intervalMs ?? DEFAULT_FINISHED_INSTANCE_CLEANUP_INTERVAL_MS, 'intervalMs'),
|
|
26
|
+
retentionMs: assertDuration(options?.retentionMs ?? DEFAULT_FINISHED_INSTANCE_RETENTION_MS, 'retentionMs')
|
|
27
|
+
};
|
|
28
|
+
}
|
package/lib/schema.js
CHANGED
|
@@ -143,6 +143,9 @@ export function createWorkflowContextFactory(api) {
|
|
|
143
143
|
// connector exposes numeric time (`ConnectorSyncEvent.duration`). `Int` would
|
|
144
144
|
// overflow GraphQL's 32-bit range for a millisecond epoch. `state`/`next_action`
|
|
145
145
|
// have no field here — the projection cannot leak them.
|
|
146
|
+
// Retry rejects missing, active, or non-resumable instances and instances whose
|
|
147
|
+
// singleton key is already held. Cancellation only requests a transition, so
|
|
148
|
+
// the returned status may still be non-terminal.
|
|
146
149
|
const SDL = `
|
|
147
150
|
type WorkflowInstanceStatus {
|
|
148
151
|
instanceID: ID!
|
|
@@ -183,12 +186,7 @@ extend type Query {
|
|
|
183
186
|
extend type Mutation {
|
|
184
187
|
setPeriodicSyncEnabled(id: ID!, enabled: Boolean!): PeriodicSync!
|
|
185
188
|
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
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
190
|
cancelWorkflow(instanceID: ID!): WorkflowInstanceStatus!
|
|
193
191
|
}
|
|
194
192
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/plugin-workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,23 +15,23 @@
|
|
|
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-p2p-api": "^0.
|
|
24
|
-
"@kubun/plugin-workflow-api": "^0.
|
|
25
|
-
"@kubun/store-workflow": "^0.
|
|
26
|
-
"@sozai/event": "^0.1.
|
|
18
|
+
"@kubun/db": "^0.18.0",
|
|
19
|
+
"@kubun/engine": "^0.18.0",
|
|
20
|
+
"@kubun/graphql": "^0.18.0",
|
|
21
|
+
"@kubun/id": "^0.18.0",
|
|
22
|
+
"@kubun/logger": "^0.18.0",
|
|
23
|
+
"@kubun/plugin-p2p-api": "^0.18.0",
|
|
24
|
+
"@kubun/plugin-workflow-api": "^0.18.0",
|
|
25
|
+
"@kubun/store-workflow": "^0.18.0",
|
|
26
|
+
"@sozai/event": "^0.1.6",
|
|
27
27
|
"@sozai/flow": "^0.3.1",
|
|
28
28
|
"@sozai/runtime": "^0.1.0",
|
|
29
|
-
"@sozai/schema": "^0.1.
|
|
29
|
+
"@sozai/schema": "^0.1.4"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@kubun/db-better-sqlite": "^0.
|
|
33
|
-
"@kubun/db-postgres": "^0.
|
|
34
|
-
"@testcontainers/postgresql": "^12.1
|
|
32
|
+
"@kubun/db-better-sqlite": "^0.18.0",
|
|
33
|
+
"@kubun/db-postgres": "^0.18.0",
|
|
34
|
+
"@testcontainers/postgresql": "^12.2.1"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|