@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,242 @@
|
|
|
1
|
+
// The server-side repair loop: what makes "no job stays running forever" true.
|
|
2
|
+
//
|
|
3
|
+
// Three queries, in a fixed order, each selecting a bounded batch with `for update skip
|
|
4
|
+
// locked` in its own short transaction. The order is not cosmetic — see the note on Q3.
|
|
5
|
+
//
|
|
6
|
+
// Everything here is decided from the row, never from anything a process remembers. That is
|
|
7
|
+
// what lets any process run the reconciler, lets two run at once, and lets the whole fleet
|
|
8
|
+
// restart without losing track of a single job.
|
|
9
|
+
|
|
10
|
+
import { errorMessageOf } from './errors'
|
|
11
|
+
import type { KindRegistry, ResolvedKind } from './registry'
|
|
12
|
+
import { runAfterTransition, runTerminalTransition } from './terminal'
|
|
13
|
+
import {
|
|
14
|
+
cancelPending,
|
|
15
|
+
markMirrored,
|
|
16
|
+
park,
|
|
17
|
+
redrivePending,
|
|
18
|
+
selectCancelling,
|
|
19
|
+
selectOrphans,
|
|
20
|
+
selectStalePending,
|
|
21
|
+
takeOrphan,
|
|
22
|
+
} from './store'
|
|
23
|
+
import type { DurableJob, Lease, Scope, SqlTransactor } from './types'
|
|
24
|
+
|
|
25
|
+
export type ReconcileReport = {
|
|
26
|
+
scanned: number
|
|
27
|
+
cancelled: number
|
|
28
|
+
redriven: number
|
|
29
|
+
parked: number
|
|
30
|
+
errors: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ReconcilerDeps = {
|
|
34
|
+
sql: SqlTransactor
|
|
35
|
+
registry: KindRegistry
|
|
36
|
+
/** Enqueues a delivery for a job that has just been re-driven. */
|
|
37
|
+
enqueue: (job: DurableJob) => Promise<void>
|
|
38
|
+
/** How long past its expiry a lease is tolerated before the job counts as orphaned. Short,
|
|
39
|
+
* because lease expiry is a database-clock fact about a driver. */
|
|
40
|
+
graceMs?: number
|
|
41
|
+
batchSize?: number
|
|
42
|
+
/** Repair only this tenant's jobs. Unset means every tenant, which is what a single worker
|
|
43
|
+
* should do; set it to shard the loop across a fleet. */
|
|
44
|
+
tenantId?: string
|
|
45
|
+
log?: (event: string, fields: Record<string, unknown>) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_GRACE_MS = 20_000
|
|
49
|
+
const DEFAULT_BATCH = 100
|
|
50
|
+
/** Backoff between successive re-drives of the same job, so a job that keeps orphaning does
|
|
51
|
+
* not spin. Doubles per re-drive since the last committed unit, capped. */
|
|
52
|
+
const REDRIVE_BASE_MS = 15_000
|
|
53
|
+
const REDRIVE_CAP_MS = 600_000
|
|
54
|
+
|
|
55
|
+
const scopeOf = (job: DurableJob): Scope => ({ tenantId: job.tenantId, organizationId: job.organizationId })
|
|
56
|
+
const leaseOf = (job: DurableJob): Lease => ({ jobId: job.id, owner: job.leaseOwner ?? 'reconciler', epoch: job.leaseEpoch, ttlMs: 0 })
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One pass. Safe to run concurrently with itself: every query takes its rows with
|
|
60
|
+
* `skip locked`, so two reconcilers partition the work rather than fighting over it.
|
|
61
|
+
*/
|
|
62
|
+
export async function reconcileOnce(deps: ReconcilerDeps): Promise<ReconcileReport> {
|
|
63
|
+
const report: ReconcileReport = { scanned: 0, cancelled: 0, redriven: 0, parked: 0, errors: 0 }
|
|
64
|
+
const limit = deps.batchSize ?? DEFAULT_BATCH
|
|
65
|
+
const graceMs = deps.graceMs ?? DEFAULT_GRACE_MS
|
|
66
|
+
|
|
67
|
+
// Q3 — cancellations first.
|
|
68
|
+
//
|
|
69
|
+
// A running job whose driver died after an operator asked to cancel it matches both "dead
|
|
70
|
+
// cancel" and "orphan". Without a precedence, the orphan query would park it as orphaned
|
|
71
|
+
// with the cancellation never honoured, or re-drive it — restarting work somebody
|
|
72
|
+
// explicitly asked to stop. So cancellations are settled before anything else looks.
|
|
73
|
+
for (const job of await select(deps, (tx) => selectCancelling(tx, limit, deps.tenantId))) {
|
|
74
|
+
report.scanned += 1
|
|
75
|
+
try {
|
|
76
|
+
if (await endCancelled(deps, job)) report.cancelled += 1
|
|
77
|
+
} catch (error) {
|
|
78
|
+
report.errors += 1
|
|
79
|
+
deps.log?.('durable_work.reconcile_cancel_failed', { jobId: job.id, error: errorMessageOf(error) })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Q1 — orphans: a job whose driver stopped heartbeating.
|
|
84
|
+
for (const job of await select(deps, (tx) => selectOrphans(tx, { graceMs, pendingTtlMs: widestPendingTtl(deps), limit, tenantId: deps.tenantId }))) {
|
|
85
|
+
report.scanned += 1
|
|
86
|
+
try {
|
|
87
|
+
const outcome = await repairOrphan(deps, job, graceMs)
|
|
88
|
+
if (outcome === 'redriven') report.redriven += 1
|
|
89
|
+
if (outcome === 'parked') report.parked += 1
|
|
90
|
+
} catch (error) {
|
|
91
|
+
report.errors += 1
|
|
92
|
+
deps.log?.('durable_work.reconcile_orphan_failed', { jobId: job.id, error: errorMessageOf(error) })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Q2 — pending jobs whose delivery never arrived: a lost hand-back, or an enqueue that
|
|
97
|
+
// never reached the broker because the process died between commit and enqueue.
|
|
98
|
+
for (const job of await select(deps, (tx) => selectStalePending(tx, { pendingTtlMs: widestPendingTtl(deps), limit, tenantId: deps.tenantId }))) {
|
|
99
|
+
report.scanned += 1
|
|
100
|
+
try {
|
|
101
|
+
const outcome = await repairPending(deps, job)
|
|
102
|
+
if (outcome === 'redriven') report.redriven += 1
|
|
103
|
+
if (outcome === 'parked') report.parked += 1
|
|
104
|
+
} catch (error) {
|
|
105
|
+
report.errors += 1
|
|
106
|
+
deps.log?.('durable_work.reconcile_pending_failed', { jobId: job.id, error: errorMessageOf(error) })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return report
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Selection commits — and so releases its locks — before any per-row work runs. Holding a
|
|
114
|
+
* row lock across a domain mirror would block a second reconciler for the length of that
|
|
115
|
+
* mirror, and turn a slow domain into a stalled repair loop. */
|
|
116
|
+
async function select(deps: ReconcilerDeps, query: (tx: Parameters<Parameters<SqlTransactor['transaction']>[0]>[0]) => Promise<DurableJob[]>): Promise<DurableJob[]> {
|
|
117
|
+
return deps.sql.transaction(query)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Selection is a coarse filter, so it uses the widest tolerance any registered kind declares
|
|
121
|
+
* and lets the per-row statements re-check with that row's own kind. Selecting on the
|
|
122
|
+
* narrowest instead would silently exclude jobs of a more tolerant kind from being repaired
|
|
123
|
+
* at all; over-selecting only costs a re-check. */
|
|
124
|
+
function widestPendingTtl(deps: ReconcilerDeps): number {
|
|
125
|
+
const kinds = deps.registry.list()
|
|
126
|
+
return kinds.length ? Math.max(...kinds.map((k) => k.lease.pendingTtlMs)) : 900_000
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function endCancelled(deps: ReconcilerDeps, job: DurableJob): Promise<boolean> {
|
|
130
|
+
const kind = deps.registry.get(job.kind)
|
|
131
|
+
|
|
132
|
+
if (job.status === 'pending') {
|
|
133
|
+
// No lease to fence on; `pending` is the fence.
|
|
134
|
+
const ended = await deps.sql.transaction(async (tx) => {
|
|
135
|
+
const row = await cancelPending(tx, job.id)
|
|
136
|
+
if (!row) return null
|
|
137
|
+
if (kind?.onCancel) await kind.onCancel(row, scopeOf(row), tx)
|
|
138
|
+
if (kind?.onTransition) {
|
|
139
|
+
const { matched } = await kind.onTransition(row, scopeOf(row), tx)
|
|
140
|
+
if (matched < 1) throw new Error(`Domain mirror matched no rows for job ${row.id}`)
|
|
141
|
+
}
|
|
142
|
+
await markMirrored(tx, row.id)
|
|
143
|
+
return row
|
|
144
|
+
})
|
|
145
|
+
if (!ended) return false
|
|
146
|
+
if (kind) await runAfterTransition(kind, ended, scopeOf(ended))
|
|
147
|
+
deps.log?.('durable_work.job_cancelled', { jobId: job.id, by: 'reconciler' })
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A `running` job whose lease has expired: nobody is driving it, so the reconciler settles
|
|
152
|
+
// the cancellation on its behalf. A live lease is left alone — its own slice will observe
|
|
153
|
+
// the request at the next heartbeat, which is both faster and safer.
|
|
154
|
+
if (job.leaseExpiresAt && job.leaseExpiresAt.getTime() > Date.now()) return false
|
|
155
|
+
|
|
156
|
+
if (!kind) {
|
|
157
|
+
const parked = await park(deps.sql, job.id, 'no_handler', 'No handler registered for this kind')
|
|
158
|
+
return parked != null
|
|
159
|
+
}
|
|
160
|
+
const result = await runTerminalTransition(deps.sql, kind, leaseOf(job), scopeOf(job), { type: 'cancel' })
|
|
161
|
+
if (!result) return false
|
|
162
|
+
await runAfterTransition(kind, result.job, scopeOf(result.job))
|
|
163
|
+
deps.log?.('durable_work.job_cancelled', { jobId: job.id, by: 'reconciler' })
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function repairOrphan(deps: ReconcilerDeps, job: DurableJob, graceMs: number): Promise<'redriven' | 'parked' | 'skipped'> {
|
|
168
|
+
const kind = deps.registry.get(job.kind)
|
|
169
|
+
|
|
170
|
+
// In order; the first match wins.
|
|
171
|
+
if (!kind) return (await parkJob(deps, job, undefined, 'no_handler', 'No handler registered for this kind')) ? 'parked' : 'skipped'
|
|
172
|
+
if (job.errorCode === 'unrecoverable' || job.errorCode === 'retry_exhausted') {
|
|
173
|
+
// The slice already reached a conclusion but could not commit it. Park with that verdict
|
|
174
|
+
// preserved — the orphan policy is never consulted for a job that has already decided.
|
|
175
|
+
return (await parkJob(deps, job, kind, job.errorCode, job.errorMessage)) ? 'parked' : 'skipped'
|
|
176
|
+
}
|
|
177
|
+
if (kind.orphanPolicy !== 'redrive') {
|
|
178
|
+
return (await parkJob(deps, job, kind, 'orphaned', job.errorMessage ?? 'Worker stopped without releasing the lease')) ? 'parked' : 'skipped'
|
|
179
|
+
}
|
|
180
|
+
if (job.redrivesSinceCommit >= kind.budget.poisonRedrivesWithoutCommit) {
|
|
181
|
+
// Re-driven this many times without committing anything: the job is not making progress
|
|
182
|
+
// and re-running it again is guessing. A human decides from here.
|
|
183
|
+
return (await parkJob(deps, job, kind, 'poison', 'Re-driven repeatedly without committing progress')) ? 'parked' : 'skipped'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const backoffMs = Math.min(REDRIVE_BASE_MS * 2 ** job.redrivesSinceCommit, REDRIVE_CAP_MS)
|
|
187
|
+
const taken = await takeOrphan(deps.sql, job.id, { graceMs, pendingTtlMs: kind.lease.pendingTtlMs, backoffMs })
|
|
188
|
+
if (!taken) return 'skipped' // the row moved under us; another pass will see it
|
|
189
|
+
await deps.enqueue(taken)
|
|
190
|
+
deps.log?.('durable_work.job_orphaned', { jobId: job.id, redrives: taken.redrives, backoffMs })
|
|
191
|
+
return 'redriven'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function repairPending(deps: ReconcilerDeps, job: DurableJob): Promise<'redriven' | 'parked' | 'skipped'> {
|
|
195
|
+
const kind = deps.registry.get(job.kind)
|
|
196
|
+
if (!kind) return (await parkJob(deps, job, undefined, 'no_handler', 'No handler registered for this kind')) ? 'parked' : 'skipped'
|
|
197
|
+
|
|
198
|
+
// A lost hand-back is cheap and is not evidence that the work is bad, so this budget is
|
|
199
|
+
// deliberately wider than the poison budget the orphan path uses.
|
|
200
|
+
if (job.redrivesSinceCommit >= kind.budget.maxRedrives) {
|
|
201
|
+
return (await parkJob(deps, job, kind, 'never_started', 'Delivery never arrived after repeated re-drives')) ? 'parked' : 'skipped'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const redriven = await redrivePending(deps.sql, job.id, { pendingTtlMs: kind.lease.pendingTtlMs })
|
|
205
|
+
if (!redriven) return 'skipped'
|
|
206
|
+
await deps.enqueue(redriven)
|
|
207
|
+
deps.log?.('durable_work.job_redriven', { jobId: job.id, redrives: redriven.redrives, reason: 'never_started' })
|
|
208
|
+
return 'redriven'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Parks through the terminal protocol when the kind has a mirror to run, and through the
|
|
212
|
+
* plain statement when it does not — an unregistered kind has no domain row to agree with,
|
|
213
|
+
* so "no mirror" is a satisfied mirror rather than a pending one. */
|
|
214
|
+
async function parkJob(
|
|
215
|
+
deps: ReconcilerDeps,
|
|
216
|
+
job: DurableJob,
|
|
217
|
+
kind: ResolvedKind | undefined,
|
|
218
|
+
reason: string,
|
|
219
|
+
message: string | null,
|
|
220
|
+
): Promise<boolean> {
|
|
221
|
+
if (!kind?.onTransition) {
|
|
222
|
+
const parked = await park(deps.sql, job.id, reason as never, message)
|
|
223
|
+
if (parked) {
|
|
224
|
+
await deps.sql.query(`update durable_work_jobs set domain_mirrored_at = now() where id = $1`, [job.id])
|
|
225
|
+
deps.log?.('durable_work.job_parked', { jobId: job.id, reason })
|
|
226
|
+
}
|
|
227
|
+
return parked != null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const parked = await deps.sql.transaction(async (tx) => {
|
|
231
|
+
const row = await park(tx, job.id, reason as never, message)
|
|
232
|
+
if (!row) return null
|
|
233
|
+
const { matched } = await kind.onTransition!(row, scopeOf(row), tx)
|
|
234
|
+
if (matched < 1) throw new Error(`Domain mirror matched no rows for job ${row.id}`)
|
|
235
|
+
await markMirrored(tx, row.id)
|
|
236
|
+
return row
|
|
237
|
+
})
|
|
238
|
+
if (!parked) return false
|
|
239
|
+
await runAfterTransition(kind, parked, scopeOf(parked))
|
|
240
|
+
deps.log?.('durable_work.job_parked', { jobId: job.id, reason })
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// What a caller declares when they hand work to the mechanism, and the process-wide map of
|
|
2
|
+
// those declarations.
|
|
3
|
+
//
|
|
4
|
+
// The registry is process-wide rather than per-container because a job's handler has to be
|
|
5
|
+
// resolvable in every process that can act on the job: the worker runs its slices, the web
|
|
6
|
+
// process re-drives and cancels it, the reconciler parks it. A registration attached to a
|
|
7
|
+
// request-scoped container would be invisible to the next request.
|
|
8
|
+
|
|
9
|
+
import { UnknownKindError } from './errors'
|
|
10
|
+
import type {
|
|
11
|
+
BudgetSettings,
|
|
12
|
+
DurableJob,
|
|
13
|
+
ErrorClass,
|
|
14
|
+
Lease,
|
|
15
|
+
LeaseSettings,
|
|
16
|
+
RetrySettings,
|
|
17
|
+
Scope,
|
|
18
|
+
SliceOutcome,
|
|
19
|
+
SqlExecutor,
|
|
20
|
+
} from './types'
|
|
21
|
+
|
|
22
|
+
/** What a slice is given. Everything it needs to make progress and to stop safely. */
|
|
23
|
+
export interface SliceContext<TInput = unknown, TCheckpoint = unknown> {
|
|
24
|
+
job: DurableJob
|
|
25
|
+
scope: Scope
|
|
26
|
+
lease: Lease
|
|
27
|
+
input: TInput
|
|
28
|
+
checkpoint: TCheckpoint | null
|
|
29
|
+
|
|
30
|
+
/** Aborts on shutdown, on cancellation, and when the lease is lost. A slice that checks it
|
|
31
|
+
* at batch boundaries is the difference between a clean stop and a killed process. */
|
|
32
|
+
signal: AbortSignal
|
|
33
|
+
|
|
34
|
+
/** Milliseconds this slice may run before it should hand back. */
|
|
35
|
+
budgetMs: number
|
|
36
|
+
|
|
37
|
+
/** Stable per (job, slice). Forward it to any external side effect so a redelivered slice
|
|
38
|
+
* is recognised as the same request rather than a second one. */
|
|
39
|
+
idempotencyKey: string
|
|
40
|
+
|
|
41
|
+
/** Extends the lease and reports progress. `committed: true` records that a unit of work
|
|
42
|
+
* is durably written, which resets the failure and orphan budgets. Throws `LeaseLostError`
|
|
43
|
+
* when the lease is gone. */
|
|
44
|
+
heartbeat(patch?: { processedCount?: number; totalCount?: number | null; committed?: boolean }): Promise<void>
|
|
45
|
+
|
|
46
|
+
/** Records resume state and counts as a committed unit, under the fence. */
|
|
47
|
+
checkpoint_(state: TCheckpoint, patch?: { processedCount?: number; totalCount?: number | null }): Promise<void>
|
|
48
|
+
|
|
49
|
+
/** Runs `fn` in a transaction that also re-asserts this lease. If the lease is gone the
|
|
50
|
+
* transaction rolls back and `LeaseLostError` is thrown — so a worker that lost its lease
|
|
51
|
+
* mid-write cannot land a write that outlives its right to make one. */
|
|
52
|
+
fencedWrite<T>(fn: (tx: SqlExecutor) => Promise<T>): Promise<T>
|
|
53
|
+
|
|
54
|
+
/** True once the slice budget is spent or a stop was requested. Check it at boundaries. */
|
|
55
|
+
shouldYield(): boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface KindDefinition<TInput = unknown, TCheckpoint = unknown> {
|
|
59
|
+
kind: string
|
|
60
|
+
/** Which queue carries this kind. Kinds sharing a queue share a worker's concurrency. */
|
|
61
|
+
queue: string
|
|
62
|
+
/** ACL features an operator needs to re-drive this kind, beyond `durable_work.operate`. */
|
|
63
|
+
requiredFeatures?: string[]
|
|
64
|
+
concurrency?: number
|
|
65
|
+
|
|
66
|
+
lease?: Partial<LeaseSettings>
|
|
67
|
+
budget?: Partial<BudgetSettings>
|
|
68
|
+
retry?: { attempts?: number; backoff?: Partial<RetrySettings['backoff']> }
|
|
69
|
+
|
|
70
|
+
/** What the reconciler does with an orphan. Defaults to `park`: a job nobody declared
|
|
71
|
+
* idempotent is not re-run automatically just because its worker died. */
|
|
72
|
+
orphanPolicy?: 'redrive' | 'park'
|
|
73
|
+
|
|
74
|
+
/** One slice under a held lease. Return when the budget is spent or the signal aborts. */
|
|
75
|
+
step(ctx: SliceContext<TInput, TCheckpoint>): Promise<SliceOutcome>
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Mirrors a terminal transition onto the domain row, inside the terminal transaction.
|
|
79
|
+
*
|
|
80
|
+
* Must be idempotent and must return how many domain rows its update matched: `matched: 0`
|
|
81
|
+
* is treated exactly like a throw, because "mirrored" means "the domain row agrees", not
|
|
82
|
+
* "the callback ran". No events, no enqueues — those belong in `onAfterTransition`, which
|
|
83
|
+
* runs after the commit.
|
|
84
|
+
*/
|
|
85
|
+
onTransition?(job: DurableJob, scope: Scope, tx: SqlExecutor): Promise<{ matched: number }>
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Re-opens the domain row when an operator re-drives. The mirror image of `onTransition`,
|
|
89
|
+
* in the same transaction, with the same contract.
|
|
90
|
+
*
|
|
91
|
+
* Required whenever `onTransition` is declared — a mirror with no way back would leave an
|
|
92
|
+
* operator able to re-drive the job while the domain row stays terminal.
|
|
93
|
+
*/
|
|
94
|
+
onRedrive?(job: DurableJob, scope: Scope, tx: SqlExecutor): Promise<{ matched: number }>
|
|
95
|
+
|
|
96
|
+
/** Release external resources before a cancellation commits. Same transaction, same
|
|
97
|
+
* idempotency rule as `onTransition`. */
|
|
98
|
+
onCancel?(job: DurableJob, scope: Scope, tx: SqlExecutor): Promise<void>
|
|
99
|
+
|
|
100
|
+
/** After-commit hooks. Best-effort and at-most-once: a throw is logged, never retried, and
|
|
101
|
+
* never affects the committed row. Domain events and log writes belong here. */
|
|
102
|
+
onAfterTransition?(job: DurableJob, scope: Scope): Promise<void>
|
|
103
|
+
onAfterRedrive?(job: DurableJob, scope: Scope): Promise<void>
|
|
104
|
+
|
|
105
|
+
/** Override the default classification for errors this kind understands. */
|
|
106
|
+
classify?(error: unknown): ErrorClass | null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const DEFAULT_LEASE: LeaseSettings = { ttlMs: 60_000, sliceBudgetMs: 300_000, pendingTtlMs: 900_000 }
|
|
110
|
+
export const DEFAULT_BUDGET: BudgetSettings = { maxRedrives: 10, maxConsecutiveFailures: 5, poisonRedrivesWithoutCommit: 3 }
|
|
111
|
+
export const DEFAULT_RETRY: RetrySettings = { attempts: 5, backoff: { type: 'exponential', delayMs: 5_000, maxDelayMs: 300_000 } }
|
|
112
|
+
|
|
113
|
+
export type ResolvedKind<TInput = unknown, TCheckpoint = unknown> = KindDefinition<TInput, TCheckpoint> & {
|
|
114
|
+
lease: LeaseSettings
|
|
115
|
+
budget: BudgetSettings
|
|
116
|
+
retry: RetrySettings
|
|
117
|
+
orphanPolicy: 'redrive' | 'park'
|
|
118
|
+
concurrency: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function resolveKind<TInput, TCheckpoint>(definition: KindDefinition<TInput, TCheckpoint>): ResolvedKind<TInput, TCheckpoint> {
|
|
122
|
+
if (definition.onTransition && !definition.onRedrive) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Kind ${JSON.stringify(definition.kind)} declares onTransition without onRedrive: a domain mirror with no way back would leave an operator able to re-drive the job while the domain row stays terminal.`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
...definition,
|
|
129
|
+
lease: { ...DEFAULT_LEASE, ...definition.lease },
|
|
130
|
+
budget: { ...DEFAULT_BUDGET, ...definition.budget },
|
|
131
|
+
retry: {
|
|
132
|
+
attempts: definition.retry?.attempts ?? DEFAULT_RETRY.attempts,
|
|
133
|
+
backoff: { ...DEFAULT_RETRY.backoff, ...definition.retry?.backoff },
|
|
134
|
+
},
|
|
135
|
+
orphanPolicy: definition.orphanPolicy ?? 'park',
|
|
136
|
+
concurrency: definition.concurrency ?? 1,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The delay before the transport's next attempt, or null when none is coming. */
|
|
141
|
+
export function nextAttemptDelayMs(kind: ResolvedKind, attempt: number): number | null {
|
|
142
|
+
if (attempt >= kind.retry.attempts) return null
|
|
143
|
+
const { type, delayMs, maxDelayMs } = kind.retry.backoff
|
|
144
|
+
const raw = type === 'fixed' ? delayMs : delayMs * 2 ** Math.max(0, attempt - 1)
|
|
145
|
+
return Math.min(raw, maxDelayMs)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class KindRegistry {
|
|
149
|
+
private readonly kinds = new Map<string, ResolvedKind<never, never>>()
|
|
150
|
+
|
|
151
|
+
register<TInput, TCheckpoint>(definition: KindDefinition<TInput, TCheckpoint>): void {
|
|
152
|
+
const resolved = resolveKind(definition)
|
|
153
|
+
const existing = this.kinds.get(definition.kind)
|
|
154
|
+
// Re-registering the identical definition is a no-op so a module loaded twice (two entry
|
|
155
|
+
// points, a test re-import) is not a crash; a *different* definition under the same id is
|
|
156
|
+
// a genuine conflict and must not be resolved silently by last-write-wins.
|
|
157
|
+
if (existing && existing.step !== definition.step) {
|
|
158
|
+
throw new Error(`Duplicate durable job kind ${JSON.stringify(definition.kind)}: two different handlers registered under one id.`)
|
|
159
|
+
}
|
|
160
|
+
this.kinds.set(definition.kind, resolved as unknown as ResolvedKind<never, never>)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get(kind: string): ResolvedKind | undefined {
|
|
164
|
+
return this.kinds.get(kind) as ResolvedKind | undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
require(kind: string): ResolvedKind {
|
|
168
|
+
const found = this.get(kind)
|
|
169
|
+
if (!found) throw new UnknownKindError(kind)
|
|
170
|
+
return found
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
has(kind: string): boolean {
|
|
174
|
+
return this.kinds.has(kind)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
list(): ResolvedKind[] {
|
|
178
|
+
return [...this.kinds.values()] as ResolvedKind[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
queues(): string[] {
|
|
182
|
+
return [...new Set(this.list().map((k) => k.queue))]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
clear(): void {
|
|
186
|
+
this.kinds.clear()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The process-wide registry. Modules register into this at import time.
|
|
192
|
+
*
|
|
193
|
+
* Module-scoped, so there is exactly one per copy of this package in the process — which is why
|
|
194
|
+
* anything registering kinds must depend on this package as a PEER, never as a dependency. A
|
|
195
|
+
* nested second copy would give the adopter its own registry: kinds would register into one,
|
|
196
|
+
* the worker would read the other, and nothing would run. No error, no warning, just jobs that
|
|
197
|
+
* sit pending forever while the reconciler eventually parks them `no_handler`.
|
|
198
|
+
*/
|
|
199
|
+
export const registry = new KindRegistry()
|