octaflow 0.14.0 → 0.15.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/README.md +1 -1
- package/dist/{dispatcher-BydjyrgU.d.ts → dispatcher-CnMTwfCc.d.ts} +11 -0
- package/dist/dispatcher-pgboss/index.d.ts +1 -1
- package/dist/dispatcher-pgboss/index.js +30 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +116 -37
- package/dist/{observability-BsrvbhDr.d.ts → observability-3tcmRwIw.d.ts} +26 -1
- package/dist/store-pg/index.d.ts +1 -1
- package/dist/store-pg/index.js +20 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ workflow is expressed**.
|
|
|
104
104
|
| **Model** | declarative — a static DAG value | imperative workflow code | imperative step functions | imperative tasks | job queue; flows are **trees** |
|
|
105
105
|
| **Fan-in / diamond deps** | yes | yes | yes | yes | no — a job can't be shared by two branches |
|
|
106
106
|
| **Inspect before running** | yes — the DAG is a value | no — the graph is the execution trace | no | no | yes — the flow tree is data |
|
|
107
|
-
| **Web dashboard** | **none** — build one
|
|
107
|
+
| **Web dashboard** | **none** — [build one](https://octabits-io.github.io/octaflow/running/live-progress/) | yes | yes | yes | via third-party UIs |
|
|
108
108
|
| **Languages** | TypeScript | polyglot SDKs | TS, Python, Go, Kotlin | TS | Node (+ ports) |
|
|
109
109
|
| **Maturity** | **pre-1.0** | mature | mature | mature | mature, widely deployed |
|
|
110
110
|
|
|
@@ -21,6 +21,17 @@ interface EnqueueOptions {
|
|
|
21
21
|
*/
|
|
22
22
|
interface Dispatcher {
|
|
23
23
|
enqueueStep(payload: DispatchStepPayload, options?: EnqueueOptions): Promise<Result<void, FlowErrorShape>>;
|
|
24
|
+
/**
|
|
25
|
+
* **Optional capability.** Enqueue inside the caller's store transaction, so
|
|
26
|
+
* the job and the state change that produced it commit together.
|
|
27
|
+
*
|
|
28
|
+
* `handle` comes from `WorkflowStore.runInTransaction` and is opaque to the
|
|
29
|
+
* engine — the store and dispatcher agree on its meaning. Implement this only
|
|
30
|
+
* when the queue lives in the same database as the store (pg-boss on the same
|
|
31
|
+
* Postgres does; SQS or a separate Redis cannot). Without it the engine writes
|
|
32
|
+
* state and then enqueues, which is at-least-once with a crash window.
|
|
33
|
+
*/
|
|
34
|
+
enqueueStepIn?(handle: unknown, payload: DispatchStepPayload, options?: EnqueueOptions): Promise<Result<void, FlowErrorShape>>;
|
|
24
35
|
}
|
|
25
36
|
//#endregion
|
|
26
37
|
export { Dispatcher as n, EnqueueOptions as r, DispatchStepPayload as t };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "../index-D_fOYfmf.js";
|
|
2
2
|
import { n as Logger } from "../logger-BCus3AOa.js";
|
|
3
|
-
import { n as Dispatcher } from "../dispatcher-
|
|
3
|
+
import { n as Dispatcher } from "../dispatcher-CnMTwfCc.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { PgBoss } from "pg-boss";
|
|
6
6
|
//#region src/dispatcher-pgboss/payload.d.ts
|
|
@@ -73,7 +73,16 @@ function createPgBossDispatcher(deps) {
|
|
|
73
73
|
...deps.config
|
|
74
74
|
};
|
|
75
75
|
let ensured = false;
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Adapt flow's `SqlExecutor` to the shape pg-boss wants for a caller-supplied
|
|
78
|
+
* connection. The two are the same idea under different names.
|
|
79
|
+
*/
|
|
80
|
+
function asPgBossDb(handle) {
|
|
81
|
+
const exec = handle;
|
|
82
|
+
if (!exec || typeof exec.query !== "function") return void 0;
|
|
83
|
+
return { executeSql: (text, values) => exec.query(text, values) };
|
|
84
|
+
}
|
|
85
|
+
async function send(payload, options, handle) {
|
|
77
86
|
try {
|
|
78
87
|
const wire = {
|
|
79
88
|
partitionKey,
|
|
@@ -92,11 +101,20 @@ function createPgBossDispatcher(deps) {
|
|
|
92
101
|
ensured = true;
|
|
93
102
|
}
|
|
94
103
|
const startAfter = options?.startAfterSeconds;
|
|
104
|
+
const db = handle === void 0 ? void 0 : asPgBossDb(handle);
|
|
105
|
+
if (handle !== void 0 && !db) return {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: {
|
|
108
|
+
key: "queue_error",
|
|
109
|
+
message: "Transaction handle is not a SqlExecutor — is the store store-pg?"
|
|
110
|
+
}
|
|
111
|
+
};
|
|
95
112
|
if (!await boss.send(queueName, parsed.data, {
|
|
96
113
|
retryLimit: cfg.retryLimit,
|
|
97
114
|
retryDelay: cfg.retryDelay,
|
|
98
115
|
expireInSeconds: cfg.expireInSeconds,
|
|
99
|
-
...startAfter != null && startAfter > 0 ? { startAfter } : {}
|
|
116
|
+
...startAfter != null && startAfter > 0 ? { startAfter } : {},
|
|
117
|
+
...db ? { db } : {}
|
|
100
118
|
})) return {
|
|
101
119
|
ok: false,
|
|
102
120
|
error: {
|
|
@@ -117,7 +135,16 @@ function createPgBossDispatcher(deps) {
|
|
|
117
135
|
}
|
|
118
136
|
};
|
|
119
137
|
}
|
|
120
|
-
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
enqueueStep: (payload, options) => send(payload, options),
|
|
141
|
+
/**
|
|
142
|
+
* Enqueue on the store's open transaction, so the job and the state change
|
|
143
|
+
* that produced it commit together. `handle` is the transaction-bound
|
|
144
|
+
* `SqlExecutor` that `createWorkflowStore.runInTransaction` hands out.
|
|
145
|
+
*/
|
|
146
|
+
enqueueStepIn: (handle, payload, options) => send(payload, options, handle)
|
|
147
|
+
};
|
|
121
148
|
}
|
|
122
149
|
/** A pg-boss worker that drives `executeStep` for step jobs. */
|
|
123
150
|
function createPgBossStepWorker(deps) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { A as StepError, B as WorkflowDefinition, C as FlowError, D as StepCompensateHandler, E as StartOptions, F as StepId, G as WorkflowWithSteps, H as WorkflowNotFoundError, I as StepRecord, J as err, K as FlowErrorShape, L as StepRegistration, M as StepHandler, N as StepHandlerNotFoundError, O as StepCompensationContext, P as StepHandlerRegistry, R as StepStatus, S as defineWaitStep, T as RetryPolicy, U as WorkflowRecord, V as WorkflowId, W as WorkflowStatus, Y as ok, _ as buildWorkflow, a as STEP_DISPLAY_STATUS, b as defineStep, c as WORKFLOW_STATUS_SCHEMA, d as toPublicWorkflow, f as StepOutput, g as WorkflowOutput, h as TypedWorkflow, i as PublicWorkflowStep, j as StepExecutionContext, k as StepDefinition, l as toDisplayStepStatus, m as TypedStepContext, n as PUBLIC_WORKFLOW_STEP_SCHEMA, o as STEP_DISPLAY_STATUS_SCHEMA, p as TypedStep, q as Result, r as PublicWorkflow, s as StepDisplayStatus, t as PUBLIC_WORKFLOW_SCHEMA, u as toPublicStep, v as defineMapStep, w as InvalidWorkflowDefinitionError, x as defineSubWorkflowStep, y as defineSleepStep, z as WorkflowCreatedResult } from "./index-D_fOYfmf.js";
|
|
2
2
|
import { n as Logger, r as noopLogger, t as LogAttributes } from "./logger-BCus3AOa.js";
|
|
3
|
-
import { C as CreateWorkflowStep, D as ListWorkflowsFilters, E as FinishWorkflowParams, O as
|
|
4
|
-
import { n as Dispatcher, r as EnqueueOptions, t as DispatchStepPayload } from "./dispatcher-
|
|
3
|
+
import { C as CreateWorkflowStep, D as ListWorkflowsFilters, E as FinishWorkflowParams, O as TransactionalScope, S as CreateWorkflowParams, T as FailStepParams, _ as StepGateDecision, a as FlowTracer, b as AddChildStep, c as RecordingTracer, d as noopObserver, f as noopTracer, g as StepGate, h as RateRule, i as FlowSpan, k as WorkflowStore, l as createRecordingObserver, m as InMemoryStepGateConfig, n as FlowEventType, o as RecordedSpan, p as ConcurrencyRule, r as FlowObserver, s as RecordingObserver, t as FlowEvent, u as createRecordingTracer, v as StepGateRequest, w as CreatedWorkflow, x as CompleteStepParams, y as createInMemoryStepGate } from "./observability-3tcmRwIw.js";
|
|
4
|
+
import { n as Dispatcher, r as EnqueueOptions, t as DispatchStepPayload } from "./dispatcher-CnMTwfCc.js";
|
|
5
5
|
import { a as WorkflowCompletedArgs, i as BuildStepContextArgs, n as BeforeStartArgs, o as WorkflowHooks, r as BeforeStartResult, t as AfterStepArgs } from "./hooks-CDRFQZbT.js";
|
|
6
6
|
//#region src/core/registry.d.ts
|
|
7
7
|
/**
|
|
@@ -134,4 +134,4 @@ declare function createInMemoryWorkflowStore(partitionKey?: string): WorkflowSto
|
|
|
134
134
|
};
|
|
135
135
|
};
|
|
136
136
|
//#endregion
|
|
137
|
-
export { AddChildStep, AfterStepArgs, BeforeStartArgs, BeforeStartResult, BuildStepContextArgs, CompleteStepParams, ConcurrencyRule, CreateWorkflowParams, CreateWorkflowStep, CreatedWorkflow, DispatchStepPayload, Dispatcher, EnqueueOptions, FailStepParams, FinishWorkflowParams, FlowError, FlowErrorShape, FlowEvent, FlowEventType, FlowObserver, FlowSpan, FlowTracer, InMemoryStepGateConfig, InvalidWorkflowDefinitionError, ListWorkflowsFilters, LogAttributes, Logger, PUBLIC_WORKFLOW_SCHEMA, PUBLIC_WORKFLOW_STEP_SCHEMA, PublicWorkflow, PublicWorkflowStep, RateRule, RecordedSpan, RecordingObserver, RecordingTracer, Result, RetryPolicy, STEP_DISPLAY_STATUS, STEP_DISPLAY_STATUS_SCHEMA, StartOptions, StepCompensateHandler, StepCompensationContext, StepDefinition, StepDisplayStatus, StepError, StepExecutionContext, StepGate, StepGateDecision, StepGateRequest, StepHandler, StepHandlerNotFoundError, StepHandlerRegistry, StepId, StepOutput, StepRecord, StepRegistration, StepStatus, TypedStep, TypedStepContext, TypedWorkflow, WORKFLOW_STATUS_SCHEMA, WorkflowCompletedArgs, WorkflowCreatedResult, WorkflowDefinition, WorkflowEngine, WorkflowEngineConfig, WorkflowEngineDeps, WorkflowHooks, WorkflowId, WorkflowNotFoundError, WorkflowOutput, WorkflowRecord, WorkflowStatus, WorkflowStore, WorkflowWithSteps, buildWorkflow, createInMemoryStepGate, createInMemoryWorkflowStore, createRecordingObserver, createRecordingTracer, createStepHandlerRegistry, createWorkflowEngine, defineMapStep, defineSleepStep, defineStep, defineSubWorkflowStep, defineWaitStep, err, explicitRetryability, isRetryableError, markRetryable, nonRetryableError, noopLogger, noopObserver, noopTracer, ok, retryableError, toDisplayStepStatus, toPublicStep, toPublicWorkflow };
|
|
137
|
+
export { AddChildStep, AfterStepArgs, BeforeStartArgs, BeforeStartResult, BuildStepContextArgs, CompleteStepParams, ConcurrencyRule, CreateWorkflowParams, CreateWorkflowStep, CreatedWorkflow, DispatchStepPayload, Dispatcher, EnqueueOptions, FailStepParams, FinishWorkflowParams, FlowError, FlowErrorShape, FlowEvent, FlowEventType, FlowObserver, FlowSpan, FlowTracer, InMemoryStepGateConfig, InvalidWorkflowDefinitionError, ListWorkflowsFilters, LogAttributes, Logger, PUBLIC_WORKFLOW_SCHEMA, PUBLIC_WORKFLOW_STEP_SCHEMA, PublicWorkflow, PublicWorkflowStep, RateRule, RecordedSpan, RecordingObserver, RecordingTracer, Result, RetryPolicy, STEP_DISPLAY_STATUS, STEP_DISPLAY_STATUS_SCHEMA, StartOptions, StepCompensateHandler, StepCompensationContext, StepDefinition, StepDisplayStatus, StepError, StepExecutionContext, StepGate, StepGateDecision, StepGateRequest, StepHandler, StepHandlerNotFoundError, StepHandlerRegistry, StepId, StepOutput, StepRecord, StepRegistration, StepStatus, TransactionalScope, TypedStep, TypedStepContext, TypedWorkflow, WORKFLOW_STATUS_SCHEMA, WorkflowCompletedArgs, WorkflowCreatedResult, WorkflowDefinition, WorkflowEngine, WorkflowEngineConfig, WorkflowEngineDeps, WorkflowHooks, WorkflowId, WorkflowNotFoundError, WorkflowOutput, WorkflowRecord, WorkflowStatus, WorkflowStore, WorkflowWithSteps, buildWorkflow, createInMemoryStepGate, createInMemoryWorkflowStore, createRecordingObserver, createRecordingTracer, createStepHandlerRegistry, createWorkflowEngine, defineMapStep, defineSleepStep, defineStep, defineSubWorkflowStep, defineWaitStep, err, explicitRetryability, isRetryableError, markRetryable, nonRetryableError, noopLogger, noopObserver, noopTracer, ok, retryableError, toDisplayStepStatus, toPublicStep, toPublicWorkflow };
|
package/dist/index.js
CHANGED
|
@@ -221,6 +221,26 @@ function createWorkflowEngine(deps) {
|
|
|
221
221
|
const stepExpirySeconds = deps.config?.stepExpirySeconds ?? 600;
|
|
222
222
|
const stuckStepBufferSeconds = deps.config?.stuckStepBufferSeconds ?? 300;
|
|
223
223
|
const defaultRetryable = deps.config?.defaultRetryable;
|
|
224
|
+
/**
|
|
225
|
+
* Both halves must opt in: the store has to offer a transaction and the
|
|
226
|
+
* dispatcher has to be able to enqueue inside it. When they do, a state change
|
|
227
|
+
* and the dispatches it unlocks commit atomically — there is no window in
|
|
228
|
+
* which a step is `pending` with no job behind it. When either cannot, the
|
|
229
|
+
* engine writes and then enqueues, exactly as before.
|
|
230
|
+
*/
|
|
231
|
+
const atomicDispatch = typeof store.runInTransaction === "function" && typeof dispatcher.enqueueStepIn === "function";
|
|
232
|
+
/**
|
|
233
|
+
* Run `fn` atomically when both halves support it, otherwise run it directly.
|
|
234
|
+
* `fn` receives the scope to write through — pass it to `dispatchReadyStep`.
|
|
235
|
+
*
|
|
236
|
+
* Only writes and enqueues belong in here. Anything that can run user code
|
|
237
|
+
* (saga compensation, hooks) must stay outside, or a rollback handler making a
|
|
238
|
+
* network call would hold a database transaction open while it does.
|
|
239
|
+
*/
|
|
240
|
+
async function withDispatchScope(fn) {
|
|
241
|
+
if (!atomicDispatch) return fn(void 0);
|
|
242
|
+
return store.runInTransaction((scope) => fn(scope));
|
|
243
|
+
}
|
|
224
244
|
const nowIso = () => now().toISOString();
|
|
225
245
|
/** Emit a run-history / metrics event. Guarded — a faulty observer never breaks a run. */
|
|
226
246
|
function emit(event) {
|
|
@@ -249,9 +269,10 @@ function createWorkflowEngine(deps) {
|
|
|
249
269
|
* and awaits `resumeStep`; everything else is enqueued (with any durable delay).
|
|
250
270
|
* Returns whether the step was enqueued.
|
|
251
271
|
*/
|
|
252
|
-
async function dispatchReadyStep(workflowId, stepId, stepKey, stepType) {
|
|
272
|
+
async function dispatchReadyStep(workflowId, stepId, stepKey, stepType, scope) {
|
|
273
|
+
const st = scope?.store ?? store;
|
|
253
274
|
if (isWaitStep(stepType)) {
|
|
254
|
-
await
|
|
275
|
+
await st.markStepWaiting(stepId);
|
|
255
276
|
logger.info("Step is waiting for an event", {
|
|
256
277
|
workflowId,
|
|
257
278
|
stepId,
|
|
@@ -266,12 +287,14 @@ function createWorkflowEngine(deps) {
|
|
|
266
287
|
});
|
|
267
288
|
return false;
|
|
268
289
|
}
|
|
269
|
-
const
|
|
290
|
+
const payload = {
|
|
270
291
|
workflowId,
|
|
271
292
|
stepId,
|
|
272
293
|
stepKey,
|
|
273
294
|
stepType
|
|
274
|
-
}
|
|
295
|
+
};
|
|
296
|
+
const delay = readyStepDelay(stepType);
|
|
297
|
+
const result = scope ? await dispatcher.enqueueStepIn(scope.handle, payload, delay) : await dispatcher.enqueueStep(payload, delay);
|
|
275
298
|
if (!result.ok) {
|
|
276
299
|
logger.error("Failed to enqueue step", new Error(result.error.message), {
|
|
277
300
|
workflowId,
|
|
@@ -377,21 +400,38 @@ function createWorkflowEngine(deps) {
|
|
|
377
400
|
};
|
|
378
401
|
}
|
|
379
402
|
const { steps } = definition;
|
|
380
|
-
const created = await
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
403
|
+
const { created, enqueuedStepKeys } = await withDispatchScope(async (scope) => {
|
|
404
|
+
const created = await (scope?.store ?? store).createWorkflow({
|
|
405
|
+
type: definition.type,
|
|
406
|
+
input,
|
|
407
|
+
entityRef: options?.entityRef,
|
|
408
|
+
metadata,
|
|
409
|
+
idempotencyKey: options?.idempotencyKey,
|
|
410
|
+
parentWorkflowId: options?.parentWorkflowId,
|
|
411
|
+
parentStepId: options?.parentStepId,
|
|
412
|
+
startedAt: nowIso(),
|
|
413
|
+
steps: steps.map((s) => ({
|
|
414
|
+
key: s.key,
|
|
415
|
+
type: s.type,
|
|
416
|
+
dependencies: s.dependencies ?? [],
|
|
417
|
+
input: s.input ?? null
|
|
418
|
+
}))
|
|
419
|
+
});
|
|
420
|
+
if (created.alreadyExisted) return {
|
|
421
|
+
created,
|
|
422
|
+
enqueuedStepKeys: []
|
|
423
|
+
};
|
|
424
|
+
const readySteps = steps.filter((s) => !s.dependencies || s.dependencies.length === 0);
|
|
425
|
+
const keys = [];
|
|
426
|
+
for (const readyStep of readySteps) {
|
|
427
|
+
const dbStep = created.steps.find((s) => s.key === readyStep.key);
|
|
428
|
+
if (!dbStep) continue;
|
|
429
|
+
if (await dispatchReadyStep(created.workflowId, dbStep.id, readyStep.key, readyStep.type, scope)) keys.push(readyStep.key);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
created,
|
|
433
|
+
enqueuedStepKeys: keys
|
|
434
|
+
};
|
|
395
435
|
});
|
|
396
436
|
const workflowId = created.workflowId;
|
|
397
437
|
if (created.alreadyExisted) {
|
|
@@ -413,13 +453,6 @@ function createWorkflowEngine(deps) {
|
|
|
413
453
|
workflowId,
|
|
414
454
|
workflowType: definition.type
|
|
415
455
|
});
|
|
416
|
-
const readySteps = steps.filter((s) => !s.dependencies || s.dependencies.length === 0);
|
|
417
|
-
const enqueuedStepKeys = [];
|
|
418
|
-
for (const readyStep of readySteps) {
|
|
419
|
-
const dbStep = created.steps.find((s) => s.key === readyStep.key);
|
|
420
|
-
if (!dbStep) continue;
|
|
421
|
-
if (await dispatchReadyStep(workflowId, dbStep.id, readyStep.key, readyStep.type)) enqueuedStepKeys.push(readyStep.key);
|
|
422
|
-
}
|
|
423
456
|
logger.info("Workflow started", {
|
|
424
457
|
workflowId,
|
|
425
458
|
type: definition.type,
|
|
@@ -468,6 +501,20 @@ function createWorkflowEngine(deps) {
|
|
|
468
501
|
}
|
|
469
502
|
};
|
|
470
503
|
if (step.status !== "pending") {
|
|
504
|
+
const workflowLive = workflow.status === "running" || workflow.status === "pending";
|
|
505
|
+
if (step.status === "completed" && workflowLive) {
|
|
506
|
+
logger.info("Re-driving advance for a redelivered completed step", {
|
|
507
|
+
workflowId,
|
|
508
|
+
stepId,
|
|
509
|
+
stepKey: step.key
|
|
510
|
+
});
|
|
511
|
+
if (step.parentStepId != null) await advanceAfterChildCompleted(workflowId, step);
|
|
512
|
+
else await advanceAfterStepCompleted(workflowId, stepId, step.output ?? {});
|
|
513
|
+
return {
|
|
514
|
+
ok: true,
|
|
515
|
+
value: void 0
|
|
516
|
+
};
|
|
517
|
+
}
|
|
471
518
|
logger.info("Skipping already-processed step", {
|
|
472
519
|
workflowId,
|
|
473
520
|
stepId,
|
|
@@ -791,32 +838,60 @@ function createWorkflowEngine(deps) {
|
|
|
791
838
|
};
|
|
792
839
|
}
|
|
793
840
|
async function onStepCompleted(workflowId, stepId, output) {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
841
|
+
await finalizeAfterStepCompleted(workflowId, stepId, output, await withDispatchScope(async (scope) => {
|
|
842
|
+
await (scope?.store ?? store).completeStep({
|
|
843
|
+
workflowId,
|
|
844
|
+
stepId,
|
|
845
|
+
output,
|
|
846
|
+
completedAt: nowIso()
|
|
847
|
+
});
|
|
848
|
+
return dispatchNewlyReady(workflowId, stepId, scope);
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* The readiness half of a completion, without the write. Split out so a
|
|
853
|
+
* redelivered job for an already-completed step can re-drive it — see the
|
|
854
|
+
* recovery branch in {@link executeStep}. Idempotent: dispatching a step that
|
|
855
|
+
* is already queued is harmless, because claiming it is atomic.
|
|
856
|
+
*/
|
|
857
|
+
async function advanceAfterStepCompleted(workflowId, stepId, output) {
|
|
858
|
+
await finalizeAfterStepCompleted(workflowId, stepId, output, await withDispatchScope((scope) => dispatchNewlyReady(workflowId, stepId, scope)));
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Transactional half: read readiness and dispatch whatever just became ready.
|
|
862
|
+
* Contains only store writes and enqueues — never user code.
|
|
863
|
+
*/
|
|
864
|
+
async function dispatchNewlyReady(workflowId, stepId, scope) {
|
|
865
|
+
const keyedSteps = (await (scope?.store ?? store).listSteps(workflowId)).filter((s) => s.parentStepId == null);
|
|
802
866
|
const completedKeys = new Set(keyedSteps.filter((s) => s.status === "completed").map((s) => s.key));
|
|
803
867
|
const newlyReady = keyedSteps.filter((s) => {
|
|
804
868
|
if (s.status !== "pending") return false;
|
|
805
869
|
return (s.dependencies ?? []).every((dep) => completedKeys.has(dep));
|
|
806
870
|
});
|
|
807
|
-
for (const readyStep of newlyReady) await dispatchReadyStep(workflowId, readyStep.id, readyStep.key, readyStep.type);
|
|
871
|
+
for (const readyStep of newlyReady) await dispatchReadyStep(workflowId, readyStep.id, readyStep.key, readyStep.type, scope);
|
|
872
|
+
return {
|
|
873
|
+
keyedSteps,
|
|
874
|
+
newlyReadyCount: newlyReady.length
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Non-transactional half: decide whether the workflow is now failed or done.
|
|
879
|
+
* Deliberately outside any transaction — the failure path runs saga
|
|
880
|
+
* compensation, i.e. user handlers that may do network I/O.
|
|
881
|
+
*/
|
|
882
|
+
async function finalizeAfterStepCompleted(workflowId, stepId, output, { keyedSteps, newlyReadyCount }) {
|
|
808
883
|
if (keyedSteps.some((s) => s.id !== stepId && s.status === "failed")) {
|
|
809
884
|
await checkWorkflowFailure(workflowId);
|
|
810
885
|
return;
|
|
811
886
|
}
|
|
812
|
-
if (keyedSteps.every((s) => s.id === stepId || s.status === "completed" || s.status === "skipped") &&
|
|
887
|
+
if (keyedSteps.every((s) => s.id === stepId || s.status === "completed" || s.status === "skipped") && newlyReadyCount === 0) {
|
|
813
888
|
const aggregatedOutput = {};
|
|
814
889
|
for (const s of keyedSteps) if (s.status === "completed" || s.id === stepId) aggregatedOutput[s.key] = s.id === stepId ? output : s.output ?? null;
|
|
815
890
|
await store.finishWorkflow({
|
|
816
891
|
workflowId,
|
|
817
892
|
status: "completed",
|
|
818
893
|
output: aggregatedOutput,
|
|
819
|
-
completedAt
|
|
894
|
+
completedAt: nowIso()
|
|
820
895
|
});
|
|
821
896
|
logger.info("Workflow completed", { workflowId });
|
|
822
897
|
emit({
|
|
@@ -877,6 +952,10 @@ function createWorkflowEngine(deps) {
|
|
|
877
952
|
output,
|
|
878
953
|
completedAt: nowIso()
|
|
879
954
|
});
|
|
955
|
+
await advanceAfterChildCompleted(workflowId, childStep);
|
|
956
|
+
}
|
|
957
|
+
/** The aggregation half of a map child's completion, without the write. */
|
|
958
|
+
async function advanceAfterChildCompleted(workflowId, childStep) {
|
|
880
959
|
const parentId = childStep.parentStepId;
|
|
881
960
|
const children = await store.listChildSteps(parentId);
|
|
882
961
|
if (children.some((c) => c.status === "failed")) {
|
|
@@ -61,6 +61,18 @@ interface ListWorkflowsFilters {
|
|
|
61
61
|
entityRef?: string;
|
|
62
62
|
limit?: number;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* A store bound to an open transaction, plus an opaque handle a capable
|
|
66
|
+
* {@link Dispatcher} can join so a job and the state change that produced it
|
|
67
|
+
* commit together. Core never inspects `handle` — it belongs to the adapter
|
|
68
|
+
* pair (e.g. store-pg hands dispatcher-pgboss its transaction-bound executor).
|
|
69
|
+
*/
|
|
70
|
+
interface TransactionalScope {
|
|
71
|
+
/** A store whose writes go through the open transaction. */
|
|
72
|
+
store: WorkflowStore;
|
|
73
|
+
/** Opaque; pass to `Dispatcher.enqueueStepIn`. */
|
|
74
|
+
handle: unknown;
|
|
75
|
+
}
|
|
64
76
|
/**
|
|
65
77
|
* Persistence boundary for the engine. Implementations are **partition-scoped at
|
|
66
78
|
* construction** (e.g. bound to one tenant) — exactly like the engine — so methods
|
|
@@ -72,6 +84,19 @@ interface ListWorkflowsFilters {
|
|
|
72
84
|
* are computed from `listSteps`.
|
|
73
85
|
*/
|
|
74
86
|
interface WorkflowStore {
|
|
87
|
+
/**
|
|
88
|
+
* **Optional capability.** Run `fn` inside one store transaction.
|
|
89
|
+
*
|
|
90
|
+
* When the dispatcher also implements `enqueueStepIn`, the engine uses both so
|
|
91
|
+
* that a state change and the dispatches it unlocks commit atomically —
|
|
92
|
+
* closing the window where a crash leaves a step `pending` with no job behind
|
|
93
|
+
* it. A store that cannot offer this simply omits it, and the engine falls
|
|
94
|
+
* back to writing then enqueueing.
|
|
95
|
+
*
|
|
96
|
+
* Implementations MUST run every operation on `scope.store` inside the same
|
|
97
|
+
* transaction, and MUST roll back if `fn` throws.
|
|
98
|
+
*/
|
|
99
|
+
runInTransaction?<T>(fn: (scope: TransactionalScope) => Promise<T>): Promise<T>;
|
|
75
100
|
/** Create the workflow + all step rows atomically (transactional). */
|
|
76
101
|
createWorkflow(params: CreateWorkflowParams): Promise<CreatedWorkflow>;
|
|
77
102
|
getWorkflow(workflowId: WorkflowId): Promise<WorkflowRecord | null>;
|
|
@@ -246,4 +271,4 @@ interface RecordingTracer extends FlowTracer {
|
|
|
246
271
|
}
|
|
247
272
|
declare function createRecordingTracer(): RecordingTracer;
|
|
248
273
|
//#endregion
|
|
249
|
-
export { CreateWorkflowStep as C, ListWorkflowsFilters as D, FinishWorkflowParams as E,
|
|
274
|
+
export { CreateWorkflowStep as C, ListWorkflowsFilters as D, FinishWorkflowParams as E, TransactionalScope as O, CreateWorkflowParams as S, FailStepParams as T, StepGateDecision as _, FlowTracer as a, AddChildStep as b, RecordingTracer as c, noopObserver as d, noopTracer as f, StepGate as g, RateRule as h, FlowSpan as i, WorkflowStore as k, createRecordingObserver as l, InMemoryStepGateConfig as m, FlowEventType as n, RecordedSpan as o, ConcurrencyRule as p, FlowObserver as r, RecordingObserver as s, FlowEvent as t, createRecordingTracer as u, StepGateRequest as v, CreatedWorkflow as w, CompleteStepParams as x, createInMemoryStepGate as y };
|
package/dist/store-pg/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { V as WorkflowId } from "../index-D_fOYfmf.js";
|
|
2
|
-
import {
|
|
2
|
+
import { g as StepGate, h as RateRule, k as WorkflowStore, p as ConcurrencyRule, r as FlowObserver, t as FlowEvent } from "../observability-3tcmRwIw.js";
|
|
3
3
|
import { Pool } from "pg";
|
|
4
4
|
//#region src/store-pg/ddl.d.ts
|
|
5
5
|
/**
|
package/dist/store-pg/index.js
CHANGED
|
@@ -386,7 +386,27 @@ function createWorkflowStore(deps) {
|
|
|
386
386
|
cutoff
|
|
387
387
|
])).rows.map(mapStep);
|
|
388
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Optional transaction capability (see `WorkflowStore.runInTransaction`). Hands
|
|
391
|
+
* back a store bound to the open transaction plus the transaction-bound
|
|
392
|
+
* executor as the opaque handle — `createPgBossDispatcher` knows how to enqueue
|
|
393
|
+
* through it, so a job and the state change that produced it commit together.
|
|
394
|
+
*
|
|
395
|
+
* Recursing is not supported (adapters never nest transactions); a nested call
|
|
396
|
+
* would reuse the same connection via `poolExecutor`, so it is simply the same
|
|
397
|
+
* transaction rather than a new one.
|
|
398
|
+
*/
|
|
399
|
+
async function runInTransaction(fn) {
|
|
400
|
+
return exec.transaction((tx) => fn({
|
|
401
|
+
store: createWorkflowStore({
|
|
402
|
+
...deps,
|
|
403
|
+
exec: tx
|
|
404
|
+
}),
|
|
405
|
+
handle: tx
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
389
408
|
return {
|
|
409
|
+
runInTransaction,
|
|
390
410
|
createWorkflow,
|
|
391
411
|
getWorkflow,
|
|
392
412
|
getStep,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "octaflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Durable DAG workflow engine: Zod-typed steps over Postgres + pg-boss, with an optional AI add-on (token/cost/quota). Layered single package.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|