@kubun/plugin-workflow 0.17.0 → 0.17.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.
@@ -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)=>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))
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?: 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, else null. Instances are promoted
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 = (deps.getRandomID ?? (()=>crypto.randomUUID()))();
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();
@@ -280,6 +283,58 @@ export function createWorkflowEngine(deps) {
280
283
  running.delete(instanceID);
281
284
  }
282
285
  }
286
+ // --- Finished-instance retention cleanup ---
287
+ const cleanup = deps.finishedInstanceCleanup ?? null;
288
+ const cleanupBatchSize = deps.cleanupBatchSize ?? FINISHED_INSTANCE_CLEANUP_BATCH_SIZE;
289
+ // Due immediately: the first scheduler pass after `ready` runs it.
290
+ let nextCleanupAt = Number.NEGATIVE_INFINITY;
291
+ let cleanupRun = null;
292
+ // One batch per statement until a short batch, or until dispose(). Errors are
293
+ // logged, never thrown: the next interval retries.
294
+ async function collectFinished(retentionMs) {
295
+ let total = 0;
296
+ try {
297
+ const s = await store();
298
+ const before = now() - retentionMs;
299
+ while(!disposed){
300
+ const removed = await s.purgeFinished({
301
+ before,
302
+ limit: cleanupBatchSize
303
+ });
304
+ total += removed;
305
+ if (removed < cleanupBatchSize) break;
306
+ }
307
+ if (total > 0) {
308
+ logger.debug('workflow cleanup removed {count} finished instances', {
309
+ count: total
310
+ });
311
+ }
312
+ } catch (cause) {
313
+ logger.warn('workflow finished-instance cleanup failed', {
314
+ error: cause
315
+ });
316
+ }
317
+ return total;
318
+ }
319
+ function cleanupFinished() {
320
+ if (cleanup == null || disposed) return Promise.resolve(0);
321
+ if (cleanupRun == null) {
322
+ const run = collectFinished(cleanup.retentionMs).finally(()=>{
323
+ cleanupRun = null;
324
+ });
325
+ cleanupRun = run;
326
+ // Tracked so dispose() drains a run in progress before the store closes.
327
+ track(run.then(()=>{}));
328
+ }
329
+ return cleanupRun;
330
+ }
331
+ // Called from each scheduler pass; starts a run once the interval is due. The
332
+ // pass does not wait for it, so a long cleanup never delays claims.
333
+ function maybeCleanup(at) {
334
+ if (cleanup == null || cleanupRun != null || at < nextCleanupAt) return;
335
+ nextCleanupAt = at + cleanup.intervalMs;
336
+ void cleanupFinished();
337
+ }
283
338
  // Per-lane concurrency. Defaults to 1; `defineQueue` raises a lane's cap.
284
339
  const concurrency = new Map();
285
340
  function laneConcurrency(lane) {
@@ -379,6 +434,7 @@ export function createWorkflowEngine(deps) {
379
434
  // in-flight when dispose() fired from claiming fresh work after shutdown began.
380
435
  if (disposed) return;
381
436
  const at = now();
437
+ maybeCleanup(at);
382
438
  // Promote due schedules first so their instances are claimable this pass.
383
439
  await promoteSchedules(s, at);
384
440
  if (disposed) return;
@@ -470,7 +526,7 @@ export function createWorkflowEngine(deps) {
470
526
  async schedule (name, params, when) {
471
527
  const s = await store();
472
528
  const everyMs = parseEvery(when.every);
473
- const id = crypto.randomUUID();
529
+ const id = deps.getRandomID();
474
530
  const at = now();
475
531
  // First fire one interval out: a restart storm must not refire everything
476
532
  // that was ever scheduled.
@@ -486,7 +542,8 @@ export function createWorkflowEngine(deps) {
486
542
  const handle = {
487
543
  id,
488
544
  async disable () {
489
- await (await store()).setScheduleEnabled(id, false, now());
545
+ const s2 = await store();
546
+ await s2.setScheduleEnabled(id, false, now());
490
547
  },
491
548
  async enable () {
492
549
  // Re-arm one interval out from re-enable so a long-disabled schedule
@@ -498,7 +555,8 @@ export function createWorkflowEngine(deps) {
498
555
  },
499
556
  async cancel () {
500
557
  // No store delete today: permanent disable is the cancel semantics.
501
- await (await store()).setScheduleEnabled(id, false, now());
558
+ const s2 = await store();
559
+ await s2.setScheduleEnabled(id, false, now());
502
560
  }
503
561
  };
504
562
  return handle;
@@ -525,7 +583,8 @@ export function createWorkflowEngine(deps) {
525
583
  const handle = {
526
584
  id,
527
585
  async disable () {
528
- await (await store()).setScheduleEnabled(id, false, now());
586
+ const s2 = await store();
587
+ await s2.setScheduleEnabled(id, false, now());
529
588
  },
530
589
  async enable () {
531
590
  // Re-arm eagerly (now + changed), mirroring reactivation above.
@@ -540,7 +599,8 @@ export function createWorkflowEngine(deps) {
540
599
  await s2.setScheduleEnabled(id, true, reAt);
541
600
  },
542
601
  async cancel () {
543
- await (await store()).setScheduleEnabled(id, false, now());
602
+ const s2 = await store();
603
+ await s2.setScheduleEnabled(id, false, now());
544
604
  }
545
605
  };
546
606
  return handle;
@@ -551,7 +611,7 @@ export function createWorkflowEngine(deps) {
551
611
  throw new Error(`Workflow "${name}" is not registered`);
552
612
  }
553
613
  const s = await store();
554
- const instanceID = crypto.randomUUID();
614
+ const instanceID = deps.getRandomID();
555
615
  const createdAt = now();
556
616
  const initialAction = params !== undefined ? {
557
617
  name: def.initialAction.name,
@@ -585,6 +645,53 @@ export function createWorkflowEngine(deps) {
585
645
  instanceID
586
646
  };
587
647
  },
648
+ async enqueueTx (stores, name, params, opts) {
649
+ const def = registry.get(name);
650
+ if (def == null) {
651
+ throw new Error(`Workflow "${name}" is not registered`);
652
+ }
653
+ // Resolve the store from the CALLER's request-scoped provider, not the
654
+ // top-level `store()` — when `stores` is a tx-scoped StoreProvider
655
+ // (e.g. from `db.withTransaction`), this binds the returned store to
656
+ // that already-open transaction.
657
+ const s = await getWorkflowStore(stores);
658
+ const instanceID = deps.getRandomID();
659
+ const createdAt = now();
660
+ const initialAction = params !== undefined ? {
661
+ name: def.initialAction.name,
662
+ params
663
+ } : def.initialAction;
664
+ const input = {
665
+ instanceID,
666
+ name,
667
+ queue: opts?.queue ?? def.queue ?? name,
668
+ idempotencyKey: opts?.idempotencyKey ?? null,
669
+ singletonKey: opts?.singletonKey ?? null,
670
+ priority: opts?.priority ?? 0,
671
+ state: {},
672
+ nextAction: initialAction,
673
+ runAfter: createdAt,
674
+ createdAt
675
+ };
676
+ // insertInstanceTx runs directly on the tx-bound store — no nested
677
+ // transaction — inheriting atomicity from the caller's own transaction.
678
+ const result = await s.insertInstanceTx(input);
679
+ // Idempotent or singleton hit: return the existing instance, drive nothing.
680
+ if (!result.created) {
681
+ return {
682
+ instanceID: result.row.instance_id
683
+ };
684
+ }
685
+ // Defer the wake until AFTER the caller's transaction commits: `stores`
686
+ // IS that transaction here, so an inline scheduleTick() (as `enqueue`
687
+ // does above) could wake the scheduler to claim a row whose insert has
688
+ // not committed yet. onCommit fires post-commit, or never if the
689
+ // transaction rolls back.
690
+ stores.onCommit(()=>scheduleTick());
691
+ return {
692
+ instanceID
693
+ };
694
+ },
588
695
  async cancel (instanceID) {
589
696
  const controller = running.get(instanceID);
590
697
  if (controller != null) {
@@ -720,6 +827,7 @@ export function createWorkflowEngine(deps) {
720
827
  track(p);
721
828
  return p;
722
829
  },
830
+ cleanupFinished,
723
831
  async dispose () {
724
832
  disposed = true;
725
833
  if (interval != null) {
package/lib/index.d.ts CHANGED
@@ -1,10 +1,30 @@
1
1
  import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
2
- export type WorkflowPluginOptions = Record<string, never>;
3
- export declare function createWorkflowPlugin(_options?: WorkflowPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
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(_options = {}) {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-workflow",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -16,13 +16,13 @@
16
16
  ],
17
17
  "dependencies": {
18
18
  "@kubun/db": "^0.17.0",
19
- "@kubun/engine": "^0.17.0",
20
- "@kubun/graphql": "^0.17.0",
19
+ "@kubun/engine": "^0.17.2",
20
+ "@kubun/graphql": "^0.17.2",
21
21
  "@kubun/id": "^0.17.0",
22
22
  "@kubun/logger": "^0.17.0",
23
23
  "@kubun/plugin-p2p-api": "^0.17.0",
24
- "@kubun/plugin-workflow-api": "^0.17.0",
25
- "@kubun/store-workflow": "^0.17.0",
24
+ "@kubun/plugin-workflow-api": "^0.17.1",
25
+ "@kubun/store-workflow": "^0.17.1",
26
26
  "@sozai/event": "^0.1.3",
27
27
  "@sozai/flow": "^0.3.1",
28
28
  "@sozai/runtime": "^0.1.0",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@kubun/db-better-sqlite": "^0.17.0",
33
- "@kubun/db-postgres": "^0.17.0",
33
+ "@kubun/db-postgres": "^0.17.1",
34
34
  "@testcontainers/postgresql": "^12.1.0"
35
35
  },
36
36
  "publishConfig": {