@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.
- package/README.md +136 -0
- package/dist/core/errors.js +81 -0
- package/dist/core/errors.js.map +7 -0
- package/dist/core/ids.js +30 -0
- package/dist/core/ids.js.map +7 -0
- package/dist/core/reconciler.js +149 -0
- package/dist/core/reconciler.js.map +7 -0
- package/dist/core/registry.js +72 -0
- package/dist/core/registry.js.map +7 -0
- package/dist/core/run-slice.js +210 -0
- package/dist/core/run-slice.js.map +7 -0
- package/dist/core/schema.js +100 -0
- package/dist/core/schema.js.map +7 -0
- package/dist/core/service.js +161 -0
- package/dist/core/service.js.map +7 -0
- package/dist/core/store.js +516 -0
- package/dist/core/store.js.map +7 -0
- package/dist/core/terminal.js +53 -0
- package/dist/core/terminal.js.map +7 -0
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +7 -0
- package/dist/core/worker.js +111 -0
- package/dist/core/worker.js.map +7 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/durable_work/acl.js +10 -0
- package/dist/modules/durable_work/acl.js.map +7 -0
- package/dist/modules/durable_work/api/jobs/[id]/redrive.js +20 -0
- package/dist/modules/durable_work/api/jobs/[id]/redrive.js.map +7 -0
- package/dist/modules/durable_work/api/jobs/[id]/route.js +26 -0
- package/dist/modules/durable_work/api/jobs/[id]/route.js.map +7 -0
- package/dist/modules/durable_work/api/jobs/route.js +24 -0
- package/dist/modules/durable_work/api/jobs/route.js.map +7 -0
- package/dist/modules/durable_work/cli.js +72 -0
- package/dist/modules/durable_work/cli.js.map +7 -0
- package/dist/modules/durable_work/data/entities.js +163 -0
- package/dist/modules/durable_work/data/entities.js.map +7 -0
- package/dist/modules/durable_work/di.js +34 -0
- package/dist/modules/durable_work/di.js.map +7 -0
- package/dist/modules/durable_work/events.js +26 -0
- package/dist/modules/durable_work/events.js.map +7 -0
- package/dist/modules/durable_work/index.js +17 -0
- package/dist/modules/durable_work/index.js.map +7 -0
- package/dist/modules/durable_work/lib/route-helpers.js +66 -0
- package/dist/modules/durable_work/lib/route-helpers.js.map +7 -0
- package/dist/modules/durable_work/migrations/Migration20260908120000.js +17 -0
- package/dist/modules/durable_work/migrations/Migration20260908120000.js.map +7 -0
- package/dist/modules/durable_work/setup.js +12 -0
- package/dist/modules/durable_work/setup.js.map +7 -0
- package/dist/om/config.js +49 -0
- package/dist/om/config.js.map +7 -0
- package/dist/om/progress-mirror.js +49 -0
- package/dist/om/progress-mirror.js.map +7 -0
- package/dist/om/sql-executor-mikro.js +48 -0
- package/dist/om/sql-executor-mikro.js.map +7 -0
- package/dist/transport/bullmq.js +144 -0
- package/dist/transport/bullmq.js.map +7 -0
- package/dist/transport/conformance.js +177 -0
- package/dist/transport/conformance.js.map +7 -0
- package/dist/transport/memory.js +139 -0
- package/dist/transport/memory.js.map +7 -0
- package/dist/transport/pgboss.js +176 -0
- package/dist/transport/pgboss.js.map +7 -0
- package/dist/transport/types.js +1 -0
- package/dist/transport/types.js.map +7 -0
- package/generated/entities/durable_work_job/index.ts +42 -0
- package/generated/entities.ids.generated.ts +9 -0
- package/package.json +145 -0
- package/src/core/__tests__/registry.test.ts +43 -0
- package/src/core/errors.ts +104 -0
- package/src/core/ids.ts +58 -0
- package/src/core/reconciler.ts +242 -0
- package/src/core/registry.ts +199 -0
- package/src/core/run-slice.ts +343 -0
- package/src/core/schema.ts +114 -0
- package/src/core/service.ts +222 -0
- package/src/core/store.ts +786 -0
- package/src/core/terminal.ts +107 -0
- package/src/core/types.ts +120 -0
- package/src/core/worker.ts +169 -0
- package/src/index.ts +100 -0
- package/src/modules/durable_work/__integration__/TC-DW-001.spec.ts +51 -0
- package/src/modules/durable_work/__tests__/metadata.test.ts +13 -0
- package/src/modules/durable_work/__tests__/schema-agreement.test.ts +52 -0
- package/src/modules/durable_work/acl.ts +6 -0
- package/src/modules/durable_work/api/jobs/[id]/redrive.ts +27 -0
- package/src/modules/durable_work/api/jobs/[id]/route.ts +27 -0
- package/src/modules/durable_work/api/jobs/route.ts +26 -0
- package/src/modules/durable_work/cli.ts +91 -0
- package/src/modules/durable_work/data/entities.ts +158 -0
- package/src/modules/durable_work/di.ts +41 -0
- package/src/modules/durable_work/events.ts +30 -0
- package/src/modules/durable_work/index.ts +16 -0
- package/src/modules/durable_work/lib/route-helpers.ts +83 -0
- package/src/modules/durable_work/migrations/Migration20260908120000.ts +24 -0
- package/src/modules/durable_work/setup.ts +10 -0
- package/src/om/__tests__/sql-executor-mikro.test.ts +83 -0
- package/src/om/config.ts +65 -0
- package/src/om/progress-mirror.ts +80 -0
- package/src/om/sql-executor-mikro.ts +104 -0
- package/src/transport/bullmq.ts +213 -0
- package/src/transport/conformance.ts +218 -0
- package/src/transport/memory.ts +191 -0
- package/src/transport/pgboss.ts +250 -0
- 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 }
|