@fullstackhouse/open-mercato-durable-work 0.1.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.
Files changed (105) hide show
  1. package/README.md +136 -0
  2. package/dist/core/errors.js +81 -0
  3. package/dist/core/errors.js.map +7 -0
  4. package/dist/core/ids.js +30 -0
  5. package/dist/core/ids.js.map +7 -0
  6. package/dist/core/reconciler.js +149 -0
  7. package/dist/core/reconciler.js.map +7 -0
  8. package/dist/core/registry.js +72 -0
  9. package/dist/core/registry.js.map +7 -0
  10. package/dist/core/run-slice.js +210 -0
  11. package/dist/core/run-slice.js.map +7 -0
  12. package/dist/core/schema.js +100 -0
  13. package/dist/core/schema.js.map +7 -0
  14. package/dist/core/service.js +161 -0
  15. package/dist/core/service.js.map +7 -0
  16. package/dist/core/store.js +516 -0
  17. package/dist/core/store.js.map +7 -0
  18. package/dist/core/terminal.js +53 -0
  19. package/dist/core/terminal.js.map +7 -0
  20. package/dist/core/types.js +1 -0
  21. package/dist/core/types.js.map +7 -0
  22. package/dist/core/worker.js +111 -0
  23. package/dist/core/worker.js.map +7 -0
  24. package/dist/index.js +99 -0
  25. package/dist/index.js.map +7 -0
  26. package/dist/modules/durable_work/acl.js +10 -0
  27. package/dist/modules/durable_work/acl.js.map +7 -0
  28. package/dist/modules/durable_work/api/jobs/[id]/redrive.js +20 -0
  29. package/dist/modules/durable_work/api/jobs/[id]/redrive.js.map +7 -0
  30. package/dist/modules/durable_work/api/jobs/[id]/route.js +26 -0
  31. package/dist/modules/durable_work/api/jobs/[id]/route.js.map +7 -0
  32. package/dist/modules/durable_work/api/jobs/route.js +24 -0
  33. package/dist/modules/durable_work/api/jobs/route.js.map +7 -0
  34. package/dist/modules/durable_work/cli.js +72 -0
  35. package/dist/modules/durable_work/cli.js.map +7 -0
  36. package/dist/modules/durable_work/data/entities.js +163 -0
  37. package/dist/modules/durable_work/data/entities.js.map +7 -0
  38. package/dist/modules/durable_work/di.js +34 -0
  39. package/dist/modules/durable_work/di.js.map +7 -0
  40. package/dist/modules/durable_work/events.js +26 -0
  41. package/dist/modules/durable_work/events.js.map +7 -0
  42. package/dist/modules/durable_work/index.js +17 -0
  43. package/dist/modules/durable_work/index.js.map +7 -0
  44. package/dist/modules/durable_work/lib/route-helpers.js +66 -0
  45. package/dist/modules/durable_work/lib/route-helpers.js.map +7 -0
  46. package/dist/modules/durable_work/migrations/Migration20260908120000.js +17 -0
  47. package/dist/modules/durable_work/migrations/Migration20260908120000.js.map +7 -0
  48. package/dist/modules/durable_work/setup.js +12 -0
  49. package/dist/modules/durable_work/setup.js.map +7 -0
  50. package/dist/om/config.js +49 -0
  51. package/dist/om/config.js.map +7 -0
  52. package/dist/om/progress-mirror.js +49 -0
  53. package/dist/om/progress-mirror.js.map +7 -0
  54. package/dist/om/sql-executor-mikro.js +48 -0
  55. package/dist/om/sql-executor-mikro.js.map +7 -0
  56. package/dist/transport/bullmq.js +144 -0
  57. package/dist/transport/bullmq.js.map +7 -0
  58. package/dist/transport/conformance.js +177 -0
  59. package/dist/transport/conformance.js.map +7 -0
  60. package/dist/transport/memory.js +139 -0
  61. package/dist/transport/memory.js.map +7 -0
  62. package/dist/transport/pgboss.js +176 -0
  63. package/dist/transport/pgboss.js.map +7 -0
  64. package/dist/transport/types.js +1 -0
  65. package/dist/transport/types.js.map +7 -0
  66. package/generated/entities/durable_work_job/index.ts +42 -0
  67. package/generated/entities.ids.generated.ts +9 -0
  68. package/package.json +145 -0
  69. package/src/core/__tests__/registry.test.ts +43 -0
  70. package/src/core/errors.ts +104 -0
  71. package/src/core/ids.ts +58 -0
  72. package/src/core/reconciler.ts +242 -0
  73. package/src/core/registry.ts +199 -0
  74. package/src/core/run-slice.ts +343 -0
  75. package/src/core/schema.ts +114 -0
  76. package/src/core/service.ts +222 -0
  77. package/src/core/store.ts +786 -0
  78. package/src/core/terminal.ts +107 -0
  79. package/src/core/types.ts +120 -0
  80. package/src/core/worker.ts +169 -0
  81. package/src/index.ts +100 -0
  82. package/src/modules/durable_work/__integration__/TC-DW-001.spec.ts +51 -0
  83. package/src/modules/durable_work/__tests__/metadata.test.ts +13 -0
  84. package/src/modules/durable_work/__tests__/schema-agreement.test.ts +52 -0
  85. package/src/modules/durable_work/acl.ts +6 -0
  86. package/src/modules/durable_work/api/jobs/[id]/redrive.ts +27 -0
  87. package/src/modules/durable_work/api/jobs/[id]/route.ts +27 -0
  88. package/src/modules/durable_work/api/jobs/route.ts +26 -0
  89. package/src/modules/durable_work/cli.ts +91 -0
  90. package/src/modules/durable_work/data/entities.ts +158 -0
  91. package/src/modules/durable_work/di.ts +41 -0
  92. package/src/modules/durable_work/events.ts +30 -0
  93. package/src/modules/durable_work/index.ts +16 -0
  94. package/src/modules/durable_work/lib/route-helpers.ts +83 -0
  95. package/src/modules/durable_work/migrations/Migration20260908120000.ts +24 -0
  96. package/src/modules/durable_work/setup.ts +10 -0
  97. package/src/om/__tests__/sql-executor-mikro.test.ts +83 -0
  98. package/src/om/config.ts +65 -0
  99. package/src/om/progress-mirror.ts +80 -0
  100. package/src/om/sql-executor-mikro.ts +104 -0
  101. package/src/transport/bullmq.ts +213 -0
  102. package/src/transport/conformance.ts +218 -0
  103. package/src/transport/memory.ts +191 -0
  104. package/src/transport/pgboss.ts +250 -0
  105. package/src/transport/types.ts +81 -0
@@ -0,0 +1,343 @@
1
+ // The worker body: what happens between a delivery arriving and the job row being right again.
2
+ //
3
+ // The shape is dictated by one rule — after every fenced statement, "matched zero rows" means
4
+ // somebody else owns this job now. When that happens the delivery ends *quietly*: no
5
+ // hand-back (it would redeliver an identity the row refuses), no rethrow (that costs a
6
+ // transport attempt and redelivers the same refused identity), no failure counted (the row is
7
+ // not ours to count against). The new driver owns the row and this delivery is history.
8
+
9
+ import { LeaseLostError, NoFurtherAttempts, classifyError, errorCodeOf, errorMessageOf } from './errors'
10
+ import { nextAttemptDelayMs, type ResolvedKind, type SliceContext } from './registry'
11
+ import { sliceIdempotencyKey } from './ids'
12
+ import {
13
+ assertLease,
14
+ claim,
15
+ failSlice,
16
+ heartbeat,
17
+ releaseLease,
18
+ writeCheckpoint,
19
+ yieldSlice,
20
+ } from './store'
21
+ import { runAfterTransition, runTerminalTransition } from './terminal'
22
+ import type { Delivery, DurableJob, Lease, Scope, SliceOutcome, SqlExecutor, SqlTransactor } from './types'
23
+ import type { HandlerContext } from '../transport/types'
24
+
25
+ export type RunSliceDeps = {
26
+ sql: SqlTransactor
27
+ kind: ResolvedKind
28
+ owner: string
29
+ /** Structured observability. Every branch below reports; silence is the one outcome that
30
+ * would make a stuck job impossible to explain after the fact. */
31
+ log?: (event: string, fields: Record<string, unknown>) => void
32
+ now?: () => number
33
+ }
34
+
35
+ export type RunSliceResult =
36
+ | { outcome: 'refused'; reason: 'identity' | 'leased' }
37
+ | { outcome: 'lease_lost' }
38
+ | { outcome: 'completed' }
39
+ | { outcome: 'failed'; verdict: 'unrecoverable' | 'retry_exhausted' }
40
+ | { outcome: 'cancelled' }
41
+ | { outcome: 'yielded'; seq: number; redrives: number }
42
+ | { outcome: 'retry' }
43
+
44
+ /**
45
+ * Runs one slice of one job.
46
+ *
47
+ * @param delivery what the transport delivered — the identity that has to still match the row
48
+ * @param ctx the transport's handle on this delivery (attempt number, signal, hand-back)
49
+ */
50
+ export async function runSlice(
51
+ deps: RunSliceDeps,
52
+ delivery: Delivery,
53
+ scope: Scope,
54
+ ctx: HandlerContext,
55
+ ): Promise<RunSliceResult> {
56
+ const { sql, kind, owner } = deps
57
+ const log = deps.log ?? (() => undefined)
58
+ const now = deps.now ?? (() => Date.now())
59
+
60
+ const claimed = await claim(sql, delivery.jobId, scope, delivery, owner, kind.lease.ttlMs)
61
+ if (!claimed) {
62
+ // Either the identity moved on (a straggling redelivery) or the lease is still alive
63
+ // (a duplicate delivery racing its twin). Both end the same way; the distinction is
64
+ // recorded because it is the difference between a broker quirk and a real overlap.
65
+ log('durable_work.delivery_refused', { jobId: delivery.jobId, seq: delivery.seq, redrives: delivery.redrives })
66
+ return { outcome: 'refused', reason: 'identity' }
67
+ }
68
+
69
+ const lease: Lease = { jobId: claimed.id, owner, epoch: claimed.leaseEpoch, ttlMs: kind.lease.ttlMs }
70
+ const abort = new AbortController()
71
+ const deadline = now() + kind.lease.sliceBudgetMs
72
+ let cancelObserved = false
73
+ let leaseLost = false
74
+
75
+ const onExternalAbort = () => abort.abort()
76
+ ctx.signal.addEventListener('abort', onExternalAbort, { once: true })
77
+
78
+ // The heartbeat is what keeps the lease alive and what notices a cancellation. A third of
79
+ // the TTL leaves room for two missed beats before anyone else may take the job.
80
+ const beat = async (patch?: Parameters<typeof heartbeat>[2]) => {
81
+ const result = await heartbeat(sql, lease, patch)
82
+ if (!result) {
83
+ leaseLost = true
84
+ abort.abort()
85
+ throw new LeaseLostError(lease)
86
+ }
87
+ if (result.cancelRequested && !cancelObserved) {
88
+ cancelObserved = true
89
+ abort.abort()
90
+ }
91
+ }
92
+ const timer = setInterval(() => {
93
+ void beat().catch(() => undefined)
94
+ }, Math.max(1_000, Math.floor(kind.lease.ttlMs / 3)))
95
+ timer.unref?.()
96
+
97
+ const finish = () => {
98
+ clearInterval(timer)
99
+ ctx.signal.removeEventListener('abort', onExternalAbort)
100
+ }
101
+
102
+ try {
103
+ // A verdict already on the claimed row means a previous delivery decided this job is over
104
+ // but could not commit that decision — the domain mirror failed. Retry the decision, never
105
+ // the work: the page that produced the verdict is not run again.
106
+ const verdict = claimed.errorCode
107
+ if (verdict === 'unrecoverable' || verdict === 'retry_exhausted') {
108
+ return await retryTerminalFail(deps, claimed, lease, scope, ctx, verdict, log)
109
+ }
110
+
111
+ // The step's errors and the terminal paths' errors need opposite handling, so they are
112
+ // caught separately. Collapsing them into one catch let an error raised *by* the cancel
113
+ // path fall back into that same path and mirror the cancellation twice.
114
+ let outcome: SliceOutcome
115
+ try {
116
+ outcome = await kind.step(makeContext(deps, claimed, lease, scope, abort, deadline, beat, now))
117
+ } catch (error) {
118
+ if (leaseLost || error instanceof LeaseLostError) {
119
+ log('durable_work.lease_lost', { jobId: lease.jobId, seq: delivery.seq, redrives: delivery.redrives, epoch: lease.epoch })
120
+ return { outcome: 'lease_lost' }
121
+ }
122
+ if (cancelObserved) return await cancel(deps, lease, scope, ctx, log)
123
+ // An abort from the transport (shutdown) is not a failure: hand the rest back so the
124
+ // next process resumes from the committed cursor rather than replaying the slice.
125
+ if (ctx.signal.aborted) return await handBack(deps, lease, ctx, { interrupted: true }, log)
126
+ return await fail(deps, lease, scope, ctx, error, log)
127
+ }
128
+
129
+ switch (cancelObserved ? 'cancelled' : outcome) {
130
+ case 'drained':
131
+ try {
132
+ return await complete(deps, lease, scope, ctx, log)
133
+ } catch (error) {
134
+ // A mirror rollback on the completion path IS treated as a slice failure: the work
135
+ // is done but the system does not yet agree it is, and retrying the slice is how
136
+ // that gets resolved — `step` runs, finds nothing left, drains again, and the
137
+ // terminal transaction is retried. The cancellation path deliberately differs.
138
+ return await fail(deps, lease, scope, ctx, error, log)
139
+ }
140
+ case 'cancelled':
141
+ return await cancel(deps, lease, scope, ctx, log)
142
+ case 'budget':
143
+ return await handBack(deps, lease, ctx, { interrupted: ctx.signal.aborted }, log)
144
+ }
145
+ } finally {
146
+ finish()
147
+ }
148
+ }
149
+
150
+ function makeContext(
151
+ deps: RunSliceDeps,
152
+ job: DurableJob,
153
+ lease: Lease,
154
+ scope: Scope,
155
+ abort: AbortController,
156
+ deadline: number,
157
+ beat: (patch?: Parameters<typeof heartbeat>[2]) => Promise<void>,
158
+ now: () => number,
159
+ ): SliceContext {
160
+ const { sql, kind } = deps
161
+ return {
162
+ job,
163
+ scope,
164
+ lease,
165
+ input: job.input,
166
+ checkpoint: job.checkpoint ?? null,
167
+ signal: abort.signal,
168
+ budgetMs: kind.lease.sliceBudgetMs,
169
+ idempotencyKey: sliceIdempotencyKey(job.id, job.continuationSeq),
170
+ heartbeat: beat,
171
+ checkpoint_: async (state, patch) => {
172
+ const ok = await writeCheckpoint(sql, lease, state, patch)
173
+ if (!ok) {
174
+ abort.abort()
175
+ throw new LeaseLostError(lease)
176
+ }
177
+ },
178
+ fencedWrite: async <T>(fn: (tx: SqlExecutor) => Promise<T>): Promise<T> => {
179
+ return sql.transaction(async (tx) => {
180
+ // Asserted *inside* the transaction, before the caller's writes, so the lease check
181
+ // and those writes commit or roll back together. Checking it outside would prove only
182
+ // that the lease was held a moment before.
183
+ if (!(await assertLease(tx, lease))) {
184
+ abort.abort()
185
+ throw new LeaseLostError(lease)
186
+ }
187
+ return fn(tx)
188
+ })
189
+ },
190
+ shouldYield: () => abort.signal.aborted || now() >= deadline,
191
+ }
192
+ }
193
+
194
+ async function complete(
195
+ deps: RunSliceDeps,
196
+ lease: Lease,
197
+ scope: Scope,
198
+ ctx: HandlerContext,
199
+ log: NonNullable<RunSliceDeps['log']>,
200
+ ): Promise<RunSliceResult> {
201
+ const result = await runTerminalTransition(deps.sql, deps.kind, lease, scope, { type: 'complete' })
202
+ if (!result) {
203
+ log('durable_work.lease_lost', { jobId: lease.jobId, at: 'complete' })
204
+ return { outcome: 'lease_lost' }
205
+ }
206
+ log('durable_work.job_completed', { jobId: lease.jobId })
207
+ await runAfterTransition(deps.kind, result.job, scope, (e) => log('durable_work.after_transition_failed', { jobId: lease.jobId, error: errorMessageOf(e) }))
208
+ void ctx
209
+ return { outcome: 'completed' }
210
+ }
211
+
212
+ async function cancel(
213
+ deps: RunSliceDeps,
214
+ lease: Lease,
215
+ scope: Scope,
216
+ ctx: HandlerContext,
217
+ log: NonNullable<RunSliceDeps['log']>,
218
+ ): Promise<RunSliceResult> {
219
+ try {
220
+ const result = await runTerminalTransition(deps.sql, deps.kind, lease, scope, { type: 'cancel' })
221
+ if (!result) return { outcome: 'lease_lost' }
222
+ log('durable_work.job_cancelled', { jobId: lease.jobId })
223
+ await runAfterTransition(deps.kind, result.job, scope, (e) => log('durable_work.after_transition_failed', { jobId: lease.jobId, error: errorMessageOf(e) }))
224
+ return { outcome: 'cancelled' }
225
+ } catch (error) {
226
+ // A mirror failure during a cancellation is not a slice failure. Counting it would burn
227
+ // the retry budget and could end the job as `failed` — but a cancellation must always end
228
+ // as `cancelled`, or the operator who asked for it is told something untrue.
229
+ await releaseLease(deps.sql, lease, { nextAttemptDelayMs: nextAttemptDelayMs(deps.kind, ctx.attempt) })
230
+ log('durable_work.cancel_mirror_failed', { jobId: lease.jobId, error: errorMessageOf(error) })
231
+ throw error
232
+ }
233
+ }
234
+
235
+ async function handBack(
236
+ deps: RunSliceDeps,
237
+ lease: Lease,
238
+ ctx: HandlerContext,
239
+ opts: { interrupted: boolean },
240
+ log: NonNullable<RunSliceDeps['log']>,
241
+ ): Promise<RunSliceResult> {
242
+ const next = await yieldSlice(deps.sql, lease, opts)
243
+ if (!next) {
244
+ log('durable_work.lease_lost', { jobId: lease.jobId, at: 'yield' })
245
+ return { outcome: 'lease_lost' }
246
+ }
247
+ // The row is already `pending` at seq + 1, so this id cannot collide with anything the
248
+ // broker still holds. If the hand-back itself fails, the reconciler is the backstop — which
249
+ // is why it is safe to log and move on rather than unwind.
250
+ try {
251
+ await ctx.handBack({ jobId: lease.jobId, seq: next.seq, redrives: next.redrives })
252
+ } catch (error) {
253
+ log('durable_work.hand_back_failed', { jobId: lease.jobId, seq: next.seq, error: errorMessageOf(error) })
254
+ }
255
+ log('durable_work.job_yielded', { jobId: lease.jobId, seq: next.seq, interrupted: opts.interrupted })
256
+ return { outcome: 'yielded', ...next }
257
+ }
258
+
259
+ async function fail(
260
+ deps: RunSliceDeps,
261
+ lease: Lease,
262
+ scope: Scope,
263
+ ctx: HandlerContext,
264
+ error: unknown,
265
+ log: NonNullable<RunSliceDeps['log']>,
266
+ ): Promise<RunSliceResult> {
267
+ const { sql, kind } = deps
268
+ const errorClass = kind.classify?.(error) ?? classifyError(error)
269
+ const delayMs = errorClass === 'transient' ? nextAttemptDelayMs(kind, ctx.attempt) : null
270
+
271
+ const outcome = await failSlice(
272
+ sql,
273
+ lease,
274
+ { message: errorMessageOf(error), code: errorCodeOf(error), class: errorClass },
275
+ { nextAttemptDelayMs: delayMs, maxConsecutiveFailures: kind.budget.maxConsecutiveFailures },
276
+ )
277
+ if (!outcome) {
278
+ log('durable_work.lease_lost', { jobId: lease.jobId, at: 'fail' })
279
+ return { outcome: 'lease_lost' }
280
+ }
281
+
282
+ // A `terminal` error has no verdict of its own but must not be retried either; the statement
283
+ // above only mints `retry_exhausted` and `unrecoverable`, so terminal is decided here.
284
+ const verdict = outcome.verdict ?? (errorClass === 'terminal' ? 'retry_exhausted' : null)
285
+ if (!verdict) {
286
+ log('durable_work.slice_failed', { jobId: lease.jobId, consecutiveFailures: outcome.consecutiveFailures, error: errorMessageOf(error) })
287
+ throw error // the transport retries this delivery; the released lease lets the retry claim
288
+ }
289
+
290
+ try {
291
+ const result = await runTerminalTransition(sql, kind, lease, scope, {
292
+ type: 'fail',
293
+ code: verdict,
294
+ class: errorClass,
295
+ message: errorMessageOf(error),
296
+ })
297
+ if (!result) return { outcome: 'lease_lost' }
298
+ log('durable_work.job_failed', { jobId: lease.jobId, verdict, error: errorMessageOf(error) })
299
+ await runAfterTransition(kind, result.job, scope, (e) => log('durable_work.after_transition_failed', { jobId: lease.jobId, error: errorMessageOf(e) }))
300
+ // Ends the delivery without a further attempt: the row is already `failed`, so a retry
301
+ // would claim a row that refuses it and waste the attempt.
302
+ throw new NoFurtherAttempts(verdict)
303
+ } catch (terminalError) {
304
+ if (terminalError instanceof NoFurtherAttempts) throw terminalError
305
+ // The mirror failed. The row keeps the verdict and stays `running` with its lease
306
+ // released, so the transport's next attempt re-runs the *decision* and never the work;
307
+ // when the attempts run out the reconciler parks it with the verdict preserved.
308
+ log('durable_work.terminal_mirror_failed', { jobId: lease.jobId, verdict, error: errorMessageOf(terminalError) })
309
+ throw error
310
+ }
311
+ }
312
+
313
+ /** A delivery that claimed a row already carrying a verdict: retry the decision, not the work. */
314
+ async function retryTerminalFail(
315
+ deps: RunSliceDeps,
316
+ job: DurableJob,
317
+ lease: Lease,
318
+ scope: Scope,
319
+ ctx: HandlerContext,
320
+ verdict: 'unrecoverable' | 'retry_exhausted',
321
+ log: NonNullable<RunSliceDeps['log']>,
322
+ ): Promise<RunSliceResult> {
323
+ try {
324
+ const result = await runTerminalTransition(deps.sql, deps.kind, lease, scope, {
325
+ type: 'fail',
326
+ code: verdict,
327
+ class: job.errorClass ?? 'terminal',
328
+ message: job.errorMessage,
329
+ })
330
+ if (!result) return { outcome: 'lease_lost' }
331
+ log('durable_work.job_failed', { jobId: lease.jobId, verdict, retriedTerminal: true })
332
+ await runAfterTransition(deps.kind, result.job, scope, (e) => log('durable_work.after_transition_failed', { jobId: lease.jobId, error: errorMessageOf(e) }))
333
+ throw new NoFurtherAttempts(verdict)
334
+ } catch (error) {
335
+ if (error instanceof NoFurtherAttempts) throw error
336
+ // This path re-acquired the lease (the claim did), and `failSlice` never ran — so nothing
337
+ // else will release it. Without this release the row would sit under a live lease, every
338
+ // remaining transport retry would be refused by `claim`, and the retry chain would end.
339
+ await releaseLease(deps.sql, lease, { nextAttemptDelayMs: nextAttemptDelayMs(deps.kind, ctx.attempt) })
340
+ log('durable_work.terminal_mirror_failed', { jobId: lease.jobId, verdict, retriedTerminal: true, error: errorMessageOf(error) })
341
+ throw error
342
+ }
343
+ }
@@ -0,0 +1,114 @@
1
+ // The table. One statement list, used by the OM module's migration and by the harness, so
2
+ // what CI exercises and what a host migrates are the same DDL rather than two texts that
3
+ // drift. Re-homed from core's `progress_jobs` onto a package-owned table (ADR 0003); the
4
+ // column set and index set are the archived spec's §2, unchanged apart from the home.
5
+
6
+ export const TABLE = 'durable_work_jobs'
7
+
8
+ /** The zero uuid stands in for "no organization" in the single-runner index.
9
+ * Postgres treats NULLs as distinct in a unique index, so a null organization would let two
10
+ * tenant-wide jobs hold the same lock key. Coalescing is what makes the guarantee real, and
11
+ * it keeps PG14 support (PG15's NULLS NOT DISTINCT was the alternative). */
12
+ export const NO_ORG = '00000000-0000-0000-0000-000000000000'
13
+
14
+ export const CREATE_TABLE = `
15
+ create table if not exists ${TABLE} (
16
+ id uuid primary key,
17
+ tenant_id uuid not null,
18
+ organization_id uuid null,
19
+ kind text not null,
20
+ status text not null,
21
+ created_by uuid null,
22
+ created_at timestamptz not null default now(),
23
+ updated_at timestamptz not null default now(),
24
+
25
+ input jsonb null,
26
+ checkpoint jsonb null,
27
+ meta jsonb null,
28
+
29
+ idempotency_key text null,
30
+ lock_key text null,
31
+ subject_type text null,
32
+ subject_id text null,
33
+ progress_job_id uuid null,
34
+
35
+ lease_owner text null,
36
+ lease_epoch bigint not null default 0,
37
+ lease_expires_at timestamptz null,
38
+ heartbeat_at timestamptz null,
39
+
40
+ queue_name text null,
41
+ queue_job_id text null,
42
+ continuation_seq int not null default 0,
43
+ redrives int not null default 0,
44
+ next_run_at timestamptz null,
45
+ pending_since timestamptz null,
46
+
47
+ redrives_since_commit int not null default 0,
48
+ consecutive_failures int not null default 0,
49
+ interruptions int not null default 0,
50
+ mirror_attempts int not null default 0,
51
+ last_committed_at timestamptz null,
52
+
53
+ started_at timestamptz null,
54
+ finished_at timestamptz null,
55
+ parked_at timestamptz null,
56
+ cancel_requested_at timestamptz null,
57
+ cancelled_by uuid null,
58
+ error_class text null,
59
+ error_code text null,
60
+ error_message text null,
61
+ domain_mirrored_at timestamptz null,
62
+
63
+ processed_count int not null default 0,
64
+ total_count int null
65
+ )`
66
+
67
+ /** fillfactor 80 leaves room for HOT updates. Heartbeats rewrite the row every few seconds for
68
+ * the length of a multi-day run, and a HOT update avoids touching any index — which is only
69
+ * true while no indexed column is written by the heartbeat statement. That is a real
70
+ * constraint on the statements below, not a hint: `heartbeat_at` is deliberately unindexed. */
71
+ export const SET_FILLFACTOR = `alter table ${TABLE} set (fillfactor = 80)`
72
+
73
+ export const CREATE_INDEXES: readonly string[] = [
74
+ // Single-runner. At most one live job per (lock_key, tenant, org).
75
+ `create unique index if not exists durable_work_jobs_one_live_per_lock_key
76
+ on ${TABLE} (lock_key, tenant_id, coalesce(organization_id, '${NO_ORG}'::uuid))
77
+ where lock_key is not null and status in ('pending','running')`,
78
+
79
+ // Idempotency. Re-issuing the same key returns the existing job instead of starting a second.
80
+ `create unique index if not exists durable_work_jobs_idempotency_uq
81
+ on ${TABLE} (tenant_id, idempotency_key)
82
+ where idempotency_key is not null`,
83
+
84
+ // Reconciler scans. Each predicate deliberately excludes every column a heartbeat writes,
85
+ // so a heartbeat never has to update an index (see fillfactor above).
86
+ `create index if not exists durable_work_jobs_running_idx on ${TABLE} (tenant_id) where status = 'running'`,
87
+ `create index if not exists durable_work_jobs_pending_idx on ${TABLE} (pending_since) where status = 'pending'`,
88
+ `create index if not exists durable_work_jobs_cancelling_idx on ${TABLE} (cancel_requested_at)
89
+ where cancel_requested_at is not null and status in ('pending','running')`,
90
+ `create index if not exists durable_work_jobs_subject_idx on ${TABLE} (subject_type, subject_id) where subject_type is not null`,
91
+ `create index if not exists durable_work_jobs_retention_idx on ${TABLE} (finished_at)
92
+ where status in ('completed','failed','cancelled')`,
93
+ ]
94
+
95
+ export const DROP_INDEXES: readonly string[] = [
96
+ 'drop index if exists durable_work_jobs_one_live_per_lock_key',
97
+ 'drop index if exists durable_work_jobs_idempotency_uq',
98
+ 'drop index if exists durable_work_jobs_running_idx',
99
+ 'drop index if exists durable_work_jobs_pending_idx',
100
+ 'drop index if exists durable_work_jobs_cancelling_idx',
101
+ 'drop index if exists durable_work_jobs_subject_idx',
102
+ 'drop index if exists durable_work_jobs_retention_idx',
103
+ ]
104
+
105
+ export const DROP_TABLE = `drop table if exists ${TABLE}`
106
+
107
+ /** Every DDL statement in order. Idempotent: safe to run against a database that already has
108
+ * the table, which is what makes it usable from both the migration and a test's setup. */
109
+ export const SCHEMA_STATEMENTS: readonly string[] = [CREATE_TABLE, SET_FILLFACTOR, ...CREATE_INDEXES]
110
+
111
+ /** The index name Postgres reports on a single-runner violation. The store maps that specific
112
+ * violation to `LockKeyHeldError`; every other unique violation is a real bug and propagates. */
113
+ export const LOCK_KEY_INDEX = 'durable_work_jobs_one_live_per_lock_key'
114
+ export const IDEMPOTENCY_INDEX = 'durable_work_jobs_idempotency_uq'
@@ -0,0 +1,222 @@
1
+ // The API everything else talks to: starting work, operating on it, and reading it.
2
+ //
3
+ // Deliberately thin. The guarantees live in the statements and in `runSlice`; this is where
4
+ // they are composed into the handful of operations a caller actually performs.
5
+
6
+ import { randomUUID } from 'node:crypto'
7
+
8
+ import { LockKeyHeldError } from './errors'
9
+ import { queueNameFor } from './ids'
10
+ import { reconcileOnce, type ReconcileReport } from './reconciler'
11
+ import { registry as globalRegistry, type KindRegistry } from './registry'
12
+ import { runAfterTransition } from './terminal'
13
+ import {
14
+ cancelPending,
15
+ findLiveByLockKey,
16
+ getJob,
17
+ listJobs,
18
+ markMirrored,
19
+ operatorRedrive,
20
+ requestCancel,
21
+ type ListFilter,
22
+ } from './store'
23
+ import * as store from './store'
24
+ import { enqueueJob } from './worker'
25
+ import type { DurableJob, Scope, SqlExecutor, SqlTransactor, StartJobInput } from './types'
26
+ import type { TransportAdapter } from '../transport/types'
27
+
28
+ export type StartResult = {
29
+ job: DurableJob
30
+ /** False when an existing job was returned for a repeated idempotency key. */
31
+ created: boolean
32
+ /**
33
+ * Publishes the delivery.
34
+ *
35
+ * Separate from `start` on purpose. With a transport that cannot enqueue inside the caller's
36
+ * transaction, enqueuing before the commit would publish a delivery for a job that may never
37
+ * exist. So the caller commits first and then calls this — and if the process dies in
38
+ * between, the reconciler picks the job up.
39
+ */
40
+ enqueue: () => Promise<void>
41
+ }
42
+
43
+ export type RedriveRefusal = { refused: 'lock_key_held' | 'not_redrivable' | 'unrecoverable_requires_force'; heldBy?: string }
44
+
45
+ export type DurableWorkServiceDeps = {
46
+ sql: SqlTransactor
47
+ transport: TransportAdapter
48
+ registry?: KindRegistry
49
+ graceMs?: number
50
+ log?: (event: string, fields: Record<string, unknown>) => void
51
+ }
52
+
53
+ export class DurableWorkService {
54
+ private readonly registry: KindRegistry
55
+
56
+ constructor(private readonly deps: DurableWorkServiceDeps) {
57
+ this.registry = deps.registry ?? globalRegistry
58
+ }
59
+
60
+ /**
61
+ * Creates a job.
62
+ *
63
+ * `tx` is the caller's transaction, and passing it is the whole point for anyone whose
64
+ * domain row and job row must agree: they commit together or not at all.
65
+ */
66
+ async start(input: StartJobInput, scope: Scope, opts: { tx?: SqlExecutor } = {}): Promise<StartResult> {
67
+ const kind = this.registry.require(input.kind)
68
+ const sql = opts.tx ?? this.deps.sql
69
+ const queue = kind.queue || queueNameFor('default')
70
+
71
+ const { job, created } = await store.insertJob(sql, randomUUID(), scope, input, queue)
72
+
73
+ return {
74
+ job,
75
+ created,
76
+ enqueue: async () => {
77
+ if (!created) return // the existing job already has, or will get, a delivery
78
+ await enqueueJob(this.deps.sql, this.deps.transport, kind, job)
79
+ },
80
+ }
81
+ }
82
+
83
+ /** Starts a job and publishes it, transactionally where the transport allows it. */
84
+ async startAndEnqueue(input: StartJobInput, scope: Scope): Promise<StartResult> {
85
+ if (this.deps.transport.supportsTransactionalEnqueue) {
86
+ const kind = this.registry.require(input.kind)
87
+ return this.deps.sql.transaction(async (tx) => {
88
+ const started = await this.start(input, scope, { tx })
89
+ if (started.created) await enqueueJob(this.deps.sql, this.deps.transport, kind, started.job, { tx })
90
+ return { ...started, enqueue: async () => undefined }
91
+ })
92
+ }
93
+ const started = await this.start(input, scope)
94
+ await started.enqueue()
95
+ return started
96
+ }
97
+
98
+ get(id: string, scope: Scope): Promise<DurableJob | null> {
99
+ return getJob(this.deps.sql, id, scope)
100
+ }
101
+
102
+ list(scope: Scope, filter: ListFilter = {}): Promise<{ items: DurableJob[]; total: number }> {
103
+ return listJobs(this.deps.sql, scope, filter)
104
+ }
105
+
106
+ /**
107
+ * Asks a job to stop.
108
+ *
109
+ * Never writes a terminal status directly on a running job: the driver has to be given the
110
+ * chance to stop cleanly at a boundary, and it observes this at its next heartbeat. A job
111
+ * that nobody is driving is ended here and now, because there is nobody to observe anything.
112
+ */
113
+ async cancel(id: string, scope: Scope, by: string | null = null): Promise<DurableJob | null> {
114
+ const requested = await requestCancel(this.deps.sql, id, scope, by)
115
+ if (!requested) return null
116
+
117
+ if (requested.status === 'pending') {
118
+ const kind = this.registry.get(requested.kind)
119
+ const ended = await this.deps.sql
120
+ .transaction(async (tx) => {
121
+ const row = await cancelPending(tx, id)
122
+ if (!row) return null
123
+ if (kind?.onCancel) await kind.onCancel(row, scope, tx)
124
+ if (kind?.onTransition) {
125
+ const { matched } = await kind.onTransition(row, scope, tx)
126
+ if (matched < 1) throw new Error(`Domain mirror matched no rows for job ${row.id}`)
127
+ }
128
+ await markMirrored(tx, row.id)
129
+ return row
130
+ })
131
+ // A failed mirror must not lose the operator's request: the row keeps
132
+ // `cancel_requested_at`, so the reconciler settles it on a later tick.
133
+ .catch(() => null)
134
+
135
+ if (ended) {
136
+ if (requested.queueJobId && requested.queueName) {
137
+ await this.deps.transport.remove(requested.queueName, requested.queueJobId).catch(() => undefined)
138
+ }
139
+ if (kind) await runAfterTransition(kind, ended, scope)
140
+ return ended
141
+ }
142
+ }
143
+ return requested
144
+ }
145
+
146
+ /**
147
+ * The operator's way back from a failed job.
148
+ *
149
+ * Refuses rather than guesses in three situations, because each has a different answer:
150
+ * another live job holds the lock key (wait or cancel that one), the job is not in a state
151
+ * a re-drive applies to (completed and cancelled jobs are done), and an unrecoverable
152
+ * failure (someone must say explicitly that running it again is right).
153
+ */
154
+ async redrive(
155
+ id: string,
156
+ scope: Scope,
157
+ opts: { force?: boolean } = {},
158
+ ): Promise<DurableJob | RedriveRefusal> {
159
+ const existing = await getJob(this.deps.sql, id, scope)
160
+ if (!existing) return { refused: 'not_redrivable' }
161
+ if (!opts.force && existing.errorCode === 'unrecoverable') return { refused: 'unrecoverable_requires_force' }
162
+
163
+ if (existing.lockKey) {
164
+ const holder = await findLiveByLockKey(this.deps.sql, scope, existing.lockKey)
165
+ if (holder && holder.id !== id) return { refused: 'lock_key_held', heldBy: holder.id }
166
+ }
167
+
168
+ const kind = this.registry.get(existing.kind)
169
+ try {
170
+ const redriven = await this.deps.sql.transaction(async (tx) => {
171
+ const row = await operatorRedrive(tx, id, scope, {
172
+ graceMs: this.deps.graceMs ?? 20_000,
173
+ pendingTtlMs: kind?.lease.pendingTtlMs ?? 900_000,
174
+ force: opts.force ?? false,
175
+ })
176
+ if (!row) return null
177
+ // The domain row is re-opened in the same transaction that re-opens the job row. A
178
+ // mirror with no way back would leave an operator able to restart the job while the
179
+ // domain record stayed terminal.
180
+ if (kind?.onRedrive) {
181
+ const { matched } = await kind.onRedrive(row, scope, tx)
182
+ if (matched < 1) throw new Error(`Domain re-open matched no rows for job ${row.id}`)
183
+ }
184
+ return row
185
+ })
186
+ if (!redriven) return { refused: 'not_redrivable' }
187
+
188
+ if (kind) {
189
+ await enqueueJob(this.deps.sql, this.deps.transport, kind, redriven)
190
+ if (kind.onAfterRedrive) await kind.onAfterRedrive(redriven, scope).catch(() => undefined)
191
+ }
192
+ this.deps.log?.('durable_work.job_redriven', { jobId: id, by: 'operator', redrives: redriven.redrives })
193
+ return redriven
194
+ } catch (error) {
195
+ // The partial unique index is the last word on the single-runner guarantee: a job that
196
+ // started between the check above and this statement raises here, and the answer is the
197
+ // same refusal rather than a second live runner.
198
+ if ((error as { code?: string })?.code === '23505') {
199
+ const holder = existing.lockKey ? await findLiveByLockKey(this.deps.sql, scope, existing.lockKey) : null
200
+ return { refused: 'lock_key_held', heldBy: holder?.id }
201
+ }
202
+ throw error
203
+ }
204
+ }
205
+
206
+ reconcile(opts: { batchSize?: number; tenantId?: string } = {}): Promise<ReconcileReport> {
207
+ return reconcileOnce({
208
+ sql: this.deps.sql,
209
+ registry: this.registry,
210
+ graceMs: this.deps.graceMs,
211
+ log: this.deps.log,
212
+ batchSize: opts.batchSize,
213
+ tenantId: opts.tenantId,
214
+ enqueue: async (job) => {
215
+ const kind = this.registry.get(job.kind)
216
+ if (kind) await enqueueJob(this.deps.sql, this.deps.transport, kind, job)
217
+ },
218
+ })
219
+ }
220
+ }
221
+
222
+ export { LockKeyHeldError }