octaflow 0.17.1 → 0.17.2

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.
@@ -34,6 +34,20 @@ interface EnqueueOptions {
34
34
  */
35
35
  interface Dispatcher {
36
36
  enqueueStep(payload: DispatchStepPayload, options?: EnqueueOptions): Promise<Result<void, FlowErrorShape>>;
37
+ /**
38
+ * **Optional capability.** Whatever the dispatcher must do *outside* a store
39
+ * transaction before it can enqueue inside one — queue DDL, warming a queue
40
+ * cache. The engine calls it once, before its first transactional dispatch,
41
+ * and never while a transaction is open.
42
+ *
43
+ * Why it exists: a dispatcher that lazily creates its queue on first enqueue
44
+ * does so on its own connection. On a multi-connection database that merely
45
+ * costs a second connection; on a single-connection database (an embedded
46
+ * PGlite) it deadlocks — the transaction holds the only connection and waits
47
+ * for a query that waits for the transaction. `prepare` moves that work to
48
+ * where no transaction is open.
49
+ */
50
+ prepare?(): Promise<Result<void, FlowErrorShape>>;
37
51
  /**
38
52
  * **Optional capability.** Enqueue inside the caller's store transaction, so
39
53
  * the job and the state change that produced it commit together.
@@ -1,6 +1,6 @@
1
1
  import "../index-BURAYZui.js";
2
2
  import { n as Logger } from "../logger-BCus3AOa.js";
3
- import { r as Dispatcher } from "../dispatcher-Cbb-KjY_.js";
3
+ import { r as Dispatcher } from "../dispatcher-g7Ct0J6w.js";
4
4
  import { z } from "zod";
5
5
  import { PgBoss } from "pg-boss";
6
6
  //#region src/dispatcher-pgboss/payload.d.ts
@@ -68,8 +68,15 @@ declare function resolveStartIdempotencyKey(payload: WireStartPayload, jobId: st
68
68
  //#endregion
69
69
  //#region src/dispatcher-pgboss/queue.d.ts
70
70
  /**
71
- * Create the step queue and its dead-letter queue (idempotent). Call once at
72
- * startup; the dispatcher also calls it lazily on first enqueue.
71
+ * Create the step queue and its dead-letter queue if they do not exist. Call
72
+ * once at startup; the dispatcher also runs it from `prepare` and lazily on a
73
+ * plain first enqueue.
74
+ *
75
+ * Only what is missing is created: pg-boss's `createQueue` evicts the queue's
76
+ * entry from its per-instance cache, and a `send` that misses that cache reads
77
+ * the queue on pg-boss's *own* connection — inside a store transaction on a
78
+ * single-connection database that is a deadlock. An existing queue is left
79
+ * alone, cache entry and all.
73
80
  */
74
81
  declare function ensureStepQueue(boss: PgBoss, queueName: string, config?: StepQueueConfig): Promise<void>;
75
82
  interface PgBossDispatcherDeps {
@@ -57,8 +57,15 @@ function dlqName(queueName) {
57
57
  return `${queueName}-dlq`;
58
58
  }
59
59
  /**
60
- * Create the step queue and its dead-letter queue (idempotent). Call once at
61
- * startup; the dispatcher also calls it lazily on first enqueue.
60
+ * Create the step queue and its dead-letter queue if they do not exist. Call
61
+ * once at startup; the dispatcher also runs it from `prepare` and lazily on a
62
+ * plain first enqueue.
63
+ *
64
+ * Only what is missing is created: pg-boss's `createQueue` evicts the queue's
65
+ * entry from its per-instance cache, and a `send` that misses that cache reads
66
+ * the queue on pg-boss's *own* connection — inside a store transaction on a
67
+ * single-connection database that is a deadlock. An existing queue is left
68
+ * alone, cache entry and all.
62
69
  */
63
70
  async function ensureStepQueue(boss, queueName, config = {}) {
64
71
  const cfg = {
@@ -70,12 +77,12 @@ async function ensureStepQueue(boss, queueName, config = {}) {
70
77
  if (error instanceof Error && error.message.includes("already exists")) return;
71
78
  throw error;
72
79
  };
73
- try {
80
+ if (!await boss.getQueue(dlq)) try {
74
81
  await boss.createQueue(dlq, { retryLimit: 0 });
75
82
  } catch (e) {
76
83
  swallowExists(e);
77
84
  }
78
- try {
85
+ if (!await boss.getQueue(queueName)) try {
79
86
  await boss.createQueue(queueName, {
80
87
  retryLimit: cfg.retryLimit,
81
88
  retryDelay: cfg.retryDelay,
@@ -86,6 +93,24 @@ async function ensureStepQueue(boss, queueName, config = {}) {
86
93
  swallowExists(e);
87
94
  }
88
95
  }
96
+ /** A job id that cannot exist — see {@link fillQueueCache}. */
97
+ const NIL_JOB_ID = "00000000-0000-0000-0000-000000000000";
98
+ /**
99
+ * pg-boss keeps a per-instance cache of queue rows and consults it on every
100
+ * `send` — on a miss it reads the queue on its *own* connection, whatever `db`
101
+ * the send was handed. `createQueue` evicts the entry rather than filling it,
102
+ * and the `PgBoss` facade keeps the cache private. So after creating our queue
103
+ * we fill the entry here, outside any transaction, through the one public call
104
+ * that reads the queue via the cache and then changes nothing: cancelling a job
105
+ * id that cannot exist. (A boss that does expose `getQueueCache` — a bare
106
+ * manager — is asked directly.) Without this the first transactional send would
107
+ * read on a second connection — a deadlock on a single-connection database.
108
+ */
109
+ async function fillQueueCache(boss, queueName) {
110
+ const cache = boss.getQueueCache;
111
+ if (typeof cache === "function") await cache.call(boss, queueName);
112
+ else await boss.cancel(queueName, NIL_JOB_ID);
113
+ }
89
114
  /** A flow-core `Dispatcher` backed by pg-boss. */
90
115
  function createPgBossDispatcher(deps) {
91
116
  const { boss, queueName, partitionKey } = deps;
@@ -93,7 +118,23 @@ function createPgBossDispatcher(deps) {
93
118
  ...DEFAULT_STEP_QUEUE_CONFIG,
94
119
  ...deps.config
95
120
  };
96
- let ensured = false;
121
+ /**
122
+ * Queue DDL and cache warm, once. Memoised as a promise so concurrent first
123
+ * sends share it; cleared on failure so the next attempt retries. The engine
124
+ * calls this through `prepare` before its first transaction; a direct
125
+ * `enqueueStep` outside any transaction is free to trigger it lazily.
126
+ */
127
+ let prepared = null;
128
+ function ensurePrepared() {
129
+ prepared ??= (async () => {
130
+ await ensureStepQueue(boss, queueName, cfg);
131
+ await fillQueueCache(boss, queueName);
132
+ })().catch((error) => {
133
+ prepared = null;
134
+ throw error;
135
+ });
136
+ return prepared;
137
+ }
97
138
  /**
98
139
  * Adapt flow's `SqlExecutor` to the shape pg-boss wants for a caller-supplied
99
140
  * connection. The two are the same idea under different names.
@@ -118,10 +159,7 @@ function createPgBossDispatcher(deps) {
118
159
  message: `Invalid step payload: ${parsed.error.message}`
119
160
  }
120
161
  };
121
- if (!ensured) {
122
- await ensureStepQueue(boss, queueName, cfg);
123
- ensured = true;
124
- }
162
+ await ensurePrepared();
125
163
  const startAfter = options?.startAfterSeconds;
126
164
  const db = handle === void 0 ? void 0 : asPgBossDb(handle);
127
165
  if (handle !== void 0 && !db) return {
@@ -159,6 +197,24 @@ function createPgBossDispatcher(deps) {
159
197
  }
160
198
  }
161
199
  return {
200
+ /** Queue DDL + cache fill, outside any transaction. See `Dispatcher.prepare`. */
201
+ async prepare() {
202
+ try {
203
+ await ensurePrepared();
204
+ return {
205
+ ok: true,
206
+ value: void 0
207
+ };
208
+ } catch (error) {
209
+ return {
210
+ ok: false,
211
+ error: {
212
+ key: "queue_error",
213
+ message: error instanceof Error ? error.message : "Failed to prepare step queue"
214
+ }
215
+ };
216
+ }
217
+ },
162
218
  enqueueStep: (payload, options) => send(payload, options),
163
219
  /**
164
220
  * Enqueue on the store's open transaction, so the job and the state change
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { $ as computeReadiness, A as StepConditionHandler, B as StepStatus, C as defineWaitStep, D as StartOptions, E as RetryPolicy, F as StepHandlerNotFoundError, G as WorkflowNotFoundError, H as WorkflowCreatedResult, I as StepHandlerRegistry, J as WorkflowStatus, K as WorkflowNotRetryableError, L as StepId, M as StepError, N as StepExecutionContext, O as StepCompensateHandler, P as StepHandler, Q as ReadinessPlan, R as StepRecord, S as defineSubWorkflowStep, T as InvalidWorkflowDefinitionError, U as WorkflowDefinition, V as WaitTimeoutPolicy, W as WorkflowId, X as JoinRule, Y as WorkflowWithSteps, Z as PlannedSkip, _ as WorkflowOutput, a as STEP_DISPLAY_STATUS, b as defineSleepStep, c as WORKFLOW_STATUS_SCHEMA, d as toPublicWorkflow, et as isTerminalStepStatus, f as StepDeps, g as TypedWorkflow, h as TypedStepContext, i as PublicWorkflowStep, it as ok, j as StepDefinition, k as StepCompensationContext, l as toDisplayStepStatus, m as TypedStep, n as PUBLIC_WORKFLOW_STEP_SCHEMA, nt as Result, o as STEP_DISPLAY_STATUS_SCHEMA, p as StepOutput, q as WorkflowRecord, r as PublicWorkflow, rt as err, s as StepDisplayStatus, t as PUBLIC_WORKFLOW_SCHEMA, tt as FlowErrorShape, u as toPublicStep, v as buildWorkflow, w as FlowError, x as defineStep, y as defineMapStep, z as StepRegistration } from "./index-BURAYZui.js";
2
2
  import { n as Logger, r as noopLogger, t as LogAttributes } from "./logger-BCus3AOa.js";
3
3
  import { A as WorkflowStore, C as CreateWorkflowStep, D as ListWorkflowsFilters, E as FinishWorkflowParams, O as ReopenWorkflowParams, 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 TransactionalScope, 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-B5KgSJfF.js";
4
- import { i as EnqueueOptions, n as DispatchStepPayload, r as Dispatcher, t as DispatchKind } from "./dispatcher-Cbb-KjY_.js";
4
+ import { i as EnqueueOptions, n as DispatchStepPayload, r as Dispatcher, t as DispatchKind } from "./dispatcher-g7Ct0J6w.js";
5
5
  import { a as WorkflowCompletedArgs, i as BuildStepContextArgs, n as BeforeStartArgs, o as WorkflowHooks, r as BeforeStartResult, t as AfterStepArgs } from "./hooks-BdxIcc92.js";
6
6
  //#region src/core/registry.d.ts
7
7
  /**
package/dist/index.js CHANGED
@@ -318,15 +318,34 @@ function createWorkflowEngine(deps) {
318
318
  */
319
319
  const atomicDispatch = typeof store.runInTransaction === "function" && typeof dispatcher.enqueueStepIn === "function";
320
320
  /**
321
+ * The dispatcher's one-time setup (`Dispatcher.prepare`), run before the first
322
+ * transaction opens and never inside one. Memoised as a promise so concurrent
323
+ * first dispatches share it; cleared on failure so the next attempt retries.
324
+ */
325
+ let prepared = null;
326
+ function ensurePrepared() {
327
+ if (!dispatcher.prepare) return Promise.resolve();
328
+ prepared ??= dispatcher.prepare().then((result) => {
329
+ if (!result.ok) throw new Error(`Dispatcher failed to prepare: ${result.error.message}`);
330
+ }).catch((error) => {
331
+ prepared = null;
332
+ throw error;
333
+ });
334
+ return prepared;
335
+ }
336
+ /**
321
337
  * Run `fn` atomically when both halves support it, otherwise run it directly.
322
338
  * `fn` receives the scope to write through — pass it to `dispatchReadyStep`.
323
339
  *
324
340
  * Only writes and enqueues belong in here. Anything that can run user code
325
341
  * (saga compensation, hooks) must stay outside, or a rollback handler making a
326
- * network call would hold a database transaction open while it does.
342
+ * network call would hold a database transaction open while it does. The
343
+ * dispatcher's own setup is the same kind of thing — it runs before the
344
+ * transaction, not in it (see `Dispatcher.prepare`).
327
345
  */
328
346
  async function withDispatchScope(fn) {
329
347
  if (!atomicDispatch) return fn(void 0);
348
+ await ensurePrepared();
330
349
  return store.runInTransaction((scope) => fn(scope));
331
350
  }
332
351
  const nowIso = () => now().toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octaflow",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
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",