@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,191 @@
|
|
|
1
|
+
// An in-process transport, for unit tests and fault injection.
|
|
2
|
+
//
|
|
3
|
+
// Not a toy: it implements the same delivery semantics as the real adapters — attempts,
|
|
4
|
+
// backoff, delayed availability, hand-back that spends no attempt, a real abort signal on
|
|
5
|
+
// close — and it runs the same conformance suite. Its purpose is to make failures *reachable*
|
|
6
|
+
// (duplicate a delivery, stall one, drop one) that a real broker only produces by accident.
|
|
7
|
+
//
|
|
8
|
+
// Explicitly not for production: nothing survives the process.
|
|
9
|
+
|
|
10
|
+
import { deliveryId } from '../core/ids'
|
|
11
|
+
import type { Delivery, RetrySettings } from '../core/types'
|
|
12
|
+
import type {
|
|
13
|
+
BindOptions,
|
|
14
|
+
BoundWorker,
|
|
15
|
+
DeliveryHandler,
|
|
16
|
+
DeliveryState,
|
|
17
|
+
EnqueueOptions,
|
|
18
|
+
TransportAdapter,
|
|
19
|
+
} from './types'
|
|
20
|
+
|
|
21
|
+
type Entry = {
|
|
22
|
+
id: string
|
|
23
|
+
queue: string
|
|
24
|
+
delivery: Delivery
|
|
25
|
+
availableAt: number
|
|
26
|
+
attempt: number
|
|
27
|
+
retry: RetrySettings
|
|
28
|
+
state: DeliveryState
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function backoffFor(retry: RetrySettings, attempt: number): number {
|
|
32
|
+
const { type, delayMs, maxDelayMs } = retry.backoff
|
|
33
|
+
const raw = type === 'fixed' ? delayMs : delayMs * 2 ** Math.max(0, attempt - 1)
|
|
34
|
+
return Math.min(raw, maxDelayMs)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type MemoryFaults = {
|
|
38
|
+
/** Deliver each accepted delivery twice. The lease must refuse the second. */
|
|
39
|
+
duplicateDeliveries?: boolean
|
|
40
|
+
/** Swallow enqueues matching this predicate, simulating a broker that lost the message.
|
|
41
|
+
* The reconciler is what must notice. */
|
|
42
|
+
dropEnqueue?: (delivery: Delivery) => boolean
|
|
43
|
+
/** Hold a delivery indefinitely instead of running it. */
|
|
44
|
+
stall?: (delivery: Delivery) => boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class MemoryTransport implements TransportAdapter {
|
|
48
|
+
readonly name = 'memory' as const
|
|
49
|
+
readonly supportsTransactionalEnqueue = false
|
|
50
|
+
|
|
51
|
+
private readonly entries = new Map<string, Entry>()
|
|
52
|
+
private readonly handlers = new Map<string, { handler: DeliveryHandler; opts: BindOptions }>()
|
|
53
|
+
private readonly ticks = new Map<string, { queue: string; everyMs: number; timer: NodeJS.Timeout }>()
|
|
54
|
+
private readonly inFlight = new Set<Promise<void>>()
|
|
55
|
+
private readonly abort = new AbortController()
|
|
56
|
+
private pump: NodeJS.Timeout | null = null
|
|
57
|
+
private closed = false
|
|
58
|
+
|
|
59
|
+
constructor(private readonly faults: MemoryFaults = {}) {}
|
|
60
|
+
|
|
61
|
+
async enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {
|
|
62
|
+
const id = deliveryId(delivery)
|
|
63
|
+
if (this.faults.dropEnqueue?.(delivery)) return { transportJobId: id }
|
|
64
|
+
// Same id, same delivery: the broker deduplicates, which is what a caller-supplied job id
|
|
65
|
+
// buys. The database refuses a duplicate anyway; this only saves the wasted claim.
|
|
66
|
+
this.entries.set(id, {
|
|
67
|
+
id,
|
|
68
|
+
queue,
|
|
69
|
+
delivery,
|
|
70
|
+
availableAt: Date.now() + (opts.delayMs ?? 0),
|
|
71
|
+
attempt: 0,
|
|
72
|
+
retry: opts.retry,
|
|
73
|
+
state: (opts.delayMs ?? 0) > 0 ? 'delayed' : 'waiting',
|
|
74
|
+
})
|
|
75
|
+
this.start()
|
|
76
|
+
return { transportJobId: id }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async remove(_queue: string, transportJobId: string): Promise<void> {
|
|
80
|
+
this.entries.delete(transportJobId)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getState(_queue: string, transportJobId: string): Promise<DeliveryState> {
|
|
84
|
+
return this.entries.get(transportJobId)?.state ?? 'unknown'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {
|
|
88
|
+
this.ticks.get(opts.id)?.timer.unref?.()
|
|
89
|
+
clearInterval(this.ticks.get(opts.id)?.timer)
|
|
90
|
+
const timer = setInterval(() => {
|
|
91
|
+
void this.enqueue(opts.queue, { jobId: opts.id, seq: 0, redrives: 0 }, { retry: TICK_RETRY })
|
|
92
|
+
}, opts.everyMs)
|
|
93
|
+
timer.unref?.()
|
|
94
|
+
this.ticks.set(opts.id, { queue: opts.queue, everyMs: opts.everyMs, timer })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {
|
|
98
|
+
this.handlers.set(queue, { handler, opts })
|
|
99
|
+
this.start()
|
|
100
|
+
return {
|
|
101
|
+
queue,
|
|
102
|
+
close: async () => {
|
|
103
|
+
this.handlers.delete(queue)
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async close(opts: { timeoutMs?: number } = {}): Promise<void> {
|
|
109
|
+
this.closed = true
|
|
110
|
+
this.abort.abort()
|
|
111
|
+
for (const tick of this.ticks.values()) clearInterval(tick.timer)
|
|
112
|
+
this.ticks.clear()
|
|
113
|
+
if (this.pump) clearInterval(this.pump)
|
|
114
|
+
this.pump = null
|
|
115
|
+
// Bounded: a handler that ignores its abort signal must not be able to hang shutdown
|
|
116
|
+
// forever. Dropping the wait is the honest outcome — the lease still protects the row.
|
|
117
|
+
const deadline = new Promise<void>((resolve) => setTimeout(resolve, opts.timeoutMs ?? 30_000).unref?.())
|
|
118
|
+
await Promise.race([Promise.allSettled([...this.inFlight]).then(() => undefined), deadline])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test hook: how many deliveries the broker still holds. */
|
|
122
|
+
size(): number {
|
|
123
|
+
return this.entries.size
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private start(): void {
|
|
127
|
+
if (this.pump || this.closed) return
|
|
128
|
+
this.pump = setInterval(() => void this.drain(), 5)
|
|
129
|
+
this.pump.unref?.()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async drain(): Promise<void> {
|
|
133
|
+
if (this.closed) return
|
|
134
|
+
const now = Date.now()
|
|
135
|
+
for (const entry of [...this.entries.values()]) {
|
|
136
|
+
if (entry.state === 'active' || entry.availableAt > now) continue
|
|
137
|
+
const bound = this.handlers.get(entry.queue)
|
|
138
|
+
if (!bound) continue
|
|
139
|
+
if (this.faults.stall?.(entry.delivery)) continue
|
|
140
|
+
const running = [...this.inFlight].length
|
|
141
|
+
if (running >= bound.opts.concurrency) return
|
|
142
|
+
entry.state = 'active'
|
|
143
|
+
entry.attempt += 1
|
|
144
|
+
const promise = this.run(entry, bound.handler, bound.opts).finally(() => this.inFlight.delete(promise))
|
|
145
|
+
this.inFlight.add(promise)
|
|
146
|
+
if (this.faults.duplicateDeliveries) {
|
|
147
|
+
const twin = this.run({ ...entry }, bound.handler, bound.opts).finally(() => this.inFlight.delete(twin))
|
|
148
|
+
this.inFlight.add(twin)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async run(entry: Entry, handler: DeliveryHandler, opts: BindOptions): Promise<void> {
|
|
154
|
+
let handedBack = false
|
|
155
|
+
try {
|
|
156
|
+
await handler(entry.delivery, {
|
|
157
|
+
transportJobId: entry.id,
|
|
158
|
+
attempt: entry.attempt,
|
|
159
|
+
maxAttempts: entry.retry.attempts,
|
|
160
|
+
signal: this.abort.signal,
|
|
161
|
+
handBack: async (next, handBackOpts) => {
|
|
162
|
+
handedBack = true
|
|
163
|
+
this.entries.delete(entry.id)
|
|
164
|
+
await this.enqueue(entry.queue, next, { retry: entry.retry, delayMs: handBackOpts?.delayMs })
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
if (!handedBack) {
|
|
168
|
+
entry.state = 'completed'
|
|
169
|
+
this.entries.delete(entry.id)
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if ((error as { name?: string })?.name === 'NoFurtherAttempts') {
|
|
173
|
+
entry.state = 'failed'
|
|
174
|
+
this.entries.delete(entry.id)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (entry.attempt >= entry.retry.attempts) {
|
|
178
|
+
entry.state = 'failed'
|
|
179
|
+
this.entries.delete(entry.id)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
entry.state = 'delayed'
|
|
183
|
+
entry.availableAt = Date.now() + backoffFor(entry.retry, entry.attempt)
|
|
184
|
+
} finally {
|
|
185
|
+
void opts
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The tick is a heartbeat, not work: one attempt, no backoff to reason about. */
|
|
191
|
+
const TICK_RETRY: RetrySettings = { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } }
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// The pg-boss adapter: production without Redis, and the only adapter that can enqueue a
|
|
2
|
+
// delivery inside the caller's transaction.
|
|
3
|
+
//
|
|
4
|
+
// That one capability is why it exists. `send(..., { db })` composes its statements on a
|
|
5
|
+
// client the caller supplies, so a domain row, its job row and its delivery all commit or all
|
|
6
|
+
// roll back. With any other transport there is a window between commit and enqueue where a
|
|
7
|
+
// crash leaves a job nobody will ever deliver — the reconciler closes it, but closing it after
|
|
8
|
+
// fifteen minutes is not the same as never opening it.
|
|
9
|
+
//
|
|
10
|
+
// pg-boss is a peer dependency and is imported lazily.
|
|
11
|
+
|
|
12
|
+
import { deliveryId } from '../core/ids'
|
|
13
|
+
import type { Delivery, SqlExecutor } from '../core/types'
|
|
14
|
+
import type {
|
|
15
|
+
BindOptions,
|
|
16
|
+
BoundWorker,
|
|
17
|
+
DeliveryHandler,
|
|
18
|
+
DeliveryState,
|
|
19
|
+
EnqueueOptions,
|
|
20
|
+
TransportAdapter,
|
|
21
|
+
} from './types'
|
|
22
|
+
|
|
23
|
+
type PgBossModule = typeof import('pg-boss')
|
|
24
|
+
// pg-boss 12 exports the class by name, not as a default.
|
|
25
|
+
type PgBossInstance = InstanceType<PgBossModule['PgBoss']>
|
|
26
|
+
type PgBossJob = import('pg-boss').Job<Delivery> & { signal?: AbortSignal }
|
|
27
|
+
|
|
28
|
+
export type PgBossTransportOptions = {
|
|
29
|
+
connectionString: string
|
|
30
|
+
/** Keeps pg-boss's own tables out of `public`, so they are obviously not the app's. */
|
|
31
|
+
schema?: string
|
|
32
|
+
/** Reuse an already-started instance instead of owning its lifecycle. */
|
|
33
|
+
instance?: PgBossInstance
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let cached: PgBossModule | null = null
|
|
37
|
+
async function pgboss(): Promise<PgBossModule> {
|
|
38
|
+
if (cached) return cached
|
|
39
|
+
try {
|
|
40
|
+
cached = await import('pg-boss')
|
|
41
|
+
return cached
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'The pgboss transport requires the optional peer dependency `pg-boss`. Install it, or use DURABLE_WORK_TRANSPORT=bullmq.',
|
|
45
|
+
{ cause: error },
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adapts a `SqlExecutor` to the shape pg-boss expects from a caller-supplied client.
|
|
52
|
+
*
|
|
53
|
+
* pg-boss only ever calls `executeSql`, so the whole surface is one method. Passing our own
|
|
54
|
+
* executor through — rather than requiring a raw `pg` client — is what lets the caller's
|
|
55
|
+
* transaction be a MikroORM one, a node-postgres one, or the harness's, without any of them
|
|
56
|
+
* knowing about the others.
|
|
57
|
+
*/
|
|
58
|
+
function asDb(tx: SqlExecutor) {
|
|
59
|
+
return {
|
|
60
|
+
async executeSql(text: string, values: unknown[]) {
|
|
61
|
+
const result = await tx.query(text, values)
|
|
62
|
+
return { rows: result.rows as Record<string, unknown>[], rowCount: result.rowCount }
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A tick, in the same shape as any other delivery. */
|
|
68
|
+
const tickDelivery = (id: string): Delivery => ({ jobId: id, seq: 0, redrives: 0 })
|
|
69
|
+
|
|
70
|
+
export class PgBossTransport implements TransportAdapter {
|
|
71
|
+
readonly name = 'pgboss' as const
|
|
72
|
+
readonly supportsTransactionalEnqueue = true
|
|
73
|
+
|
|
74
|
+
private boss: PgBossInstance | null = null
|
|
75
|
+
private starting: Promise<PgBossInstance> | null = null
|
|
76
|
+
private readonly ownsInstance: boolean
|
|
77
|
+
private readonly ensuredQueues = new Set<string>()
|
|
78
|
+
private readonly workerIds: Array<{ queue: string; id: string }> = []
|
|
79
|
+
private readonly ticks: NodeJS.Timeout[] = []
|
|
80
|
+
private readonly shutdown = new AbortController()
|
|
81
|
+
|
|
82
|
+
constructor(private readonly options: PgBossTransportOptions) {
|
|
83
|
+
this.boss = options.instance ?? null
|
|
84
|
+
this.ownsInstance = !options.instance
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async ready(): Promise<PgBossInstance> {
|
|
88
|
+
if (this.boss) return this.boss
|
|
89
|
+
if (!this.starting) {
|
|
90
|
+
this.starting = (async () => {
|
|
91
|
+
const { PgBoss } = await pgboss()
|
|
92
|
+
const instance = new PgBoss({ connectionString: this.options.connectionString, schema: this.options.schema ?? 'durable_work_boss' })
|
|
93
|
+
await instance.start()
|
|
94
|
+
this.boss = instance
|
|
95
|
+
return instance
|
|
96
|
+
})()
|
|
97
|
+
}
|
|
98
|
+
return this.starting
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** pg-boss 10+ requires a queue to exist before anything is sent to it. `expireInSeconds`
|
|
102
|
+
* belongs to the queue, not to the worker: it is how long a delivery may stay active before
|
|
103
|
+
* pg-boss reclaims it, so it must exceed a whole slice or work that is still running gets
|
|
104
|
+
* handed to a second worker — which the lease then refuses, wasting the slice. */
|
|
105
|
+
private async ensureQueue(name: string, expireInSeconds?: number): Promise<PgBossInstance> {
|
|
106
|
+
const boss = await this.ready()
|
|
107
|
+
if (this.ensuredQueues.has(name)) return boss
|
|
108
|
+
await boss.createQueue(name, expireInSeconds ? { expireInSeconds } : undefined)
|
|
109
|
+
this.ensuredQueues.add(name)
|
|
110
|
+
return boss
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {
|
|
114
|
+
const boss = await this.ensureQueue(queue)
|
|
115
|
+
const key = deliveryId(delivery)
|
|
116
|
+
const sent = await boss.send(queue, delivery as unknown as object, {
|
|
117
|
+
// pg-boss job ids are uuids, so the delivery identity travels as the singleton key —
|
|
118
|
+
// which is also what makes a re-enqueue of the same delivery a no-op.
|
|
119
|
+
singletonKey: key,
|
|
120
|
+
startAfter: opts.delayMs && opts.delayMs > 0 ? Math.ceil(opts.delayMs / 1000) : undefined,
|
|
121
|
+
retryLimit: opts.retry.attempts,
|
|
122
|
+
retryDelay: Math.max(1, Math.round(opts.retry.backoff.delayMs / 1000)),
|
|
123
|
+
// pg-boss rejects a max delay unless backoff is on, so the cap travels only with it.
|
|
124
|
+
...(opts.retry.backoff.type === 'exponential'
|
|
125
|
+
? { retryBackoff: true, retryDelayMax: Math.max(1, Math.round(opts.retry.backoff.maxDelayMs / 1000)) }
|
|
126
|
+
: { retryBackoff: false }),
|
|
127
|
+
...(opts.tx ? { db: asDb(opts.tx) } : {}),
|
|
128
|
+
})
|
|
129
|
+
// `send` returns null when the singleton key collapsed this into an existing job. That is
|
|
130
|
+
// the intended outcome, not a failure: the delivery is already scheduled.
|
|
131
|
+
return { transportJobId: sent ?? key }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async remove(queue: string, transportJobId: string): Promise<void> {
|
|
135
|
+
const boss = await this.ready()
|
|
136
|
+
await boss.deleteJob(queue, transportJobId).catch(() => undefined)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async getState(queue: string, transportJobId: string): Promise<DeliveryState> {
|
|
140
|
+
const boss = await this.ready()
|
|
141
|
+
const job = await boss.getJobById(queue, transportJobId).catch(() => null)
|
|
142
|
+
if (!job) return 'unknown'
|
|
143
|
+
switch (job.state) {
|
|
144
|
+
case 'created':
|
|
145
|
+
case 'retry':
|
|
146
|
+
return 'waiting'
|
|
147
|
+
case 'active':
|
|
148
|
+
return 'active'
|
|
149
|
+
case 'completed':
|
|
150
|
+
return 'completed'
|
|
151
|
+
case 'cancelled':
|
|
152
|
+
case 'failed':
|
|
153
|
+
return 'failed'
|
|
154
|
+
default:
|
|
155
|
+
return 'unknown'
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {
|
|
160
|
+
await this.ensureQueue(opts.queue)
|
|
161
|
+
// pg-boss's own scheduler is cron-based, so its finest granularity is a minute — too
|
|
162
|
+
// coarse for a repair loop. A per-process timer with a singleton key gives the cadence we
|
|
163
|
+
// need and still collapses the fleet's ticks into one job per window. The trade-off is
|
|
164
|
+
// stated rather than hidden: with zero workers up there is no tick, exactly as with a
|
|
165
|
+
// broker-owned schedule that nobody polls.
|
|
166
|
+
const everySeconds = Math.max(1, Math.round(opts.everyMs / 1000))
|
|
167
|
+
const fire = async () => {
|
|
168
|
+
if (this.shutdown.signal.aborted) return
|
|
169
|
+
const boss = await this.ready()
|
|
170
|
+
// Delivery-shaped, like every other payload on every adapter: a tick is a delivery
|
|
171
|
+
// whose handler happens to ignore it, not a second kind of message.
|
|
172
|
+
await boss
|
|
173
|
+
.send(opts.queue, tickDelivery(opts.id), { singletonKey: opts.id, singletonSeconds: everySeconds })
|
|
174
|
+
.catch(() => undefined)
|
|
175
|
+
}
|
|
176
|
+
void fire()
|
|
177
|
+
const timer = setInterval(() => void fire(), opts.everyMs)
|
|
178
|
+
timer.unref?.()
|
|
179
|
+
this.ticks.push(timer)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {
|
|
183
|
+
const boss = await this.ensureQueue(queue, Math.ceil(opts.activeTimeoutMs / 1000))
|
|
184
|
+
|
|
185
|
+
const workerId = await boss.work<Delivery>(
|
|
186
|
+
queue,
|
|
187
|
+
{ batchSize: opts.concurrency },
|
|
188
|
+
async (jobs: PgBossJob[]) => {
|
|
189
|
+
for (const job of jobs) {
|
|
190
|
+
// Whatever arrived is handed on unexamined. An adapter that inspects payloads
|
|
191
|
+
// decides what counts as a real delivery, and this one used to skip anything
|
|
192
|
+
// without a `jobId` — which silently swallowed every reconciler tick.
|
|
193
|
+
const delivery = job.data
|
|
194
|
+
|
|
195
|
+
const combined = new AbortController()
|
|
196
|
+
const relay = () => combined.abort()
|
|
197
|
+
job.signal?.addEventListener('abort', relay, { once: true })
|
|
198
|
+
this.shutdown.signal.addEventListener('abort', relay, { once: true })
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await handler(delivery, {
|
|
202
|
+
transportJobId: job.id,
|
|
203
|
+
// pg-boss does not expose the attempt on the job, so the adapter reports the
|
|
204
|
+
// first attempt and lets its own retry policy carry the rest. The consequence is
|
|
205
|
+
// narrow: the delay written to `next_run_at` is the base rather than a backed-off
|
|
206
|
+
// one, and pg-boss's own `retryBackoff` still spaces the real deliveries.
|
|
207
|
+
attempt: 1,
|
|
208
|
+
maxAttempts: 1,
|
|
209
|
+
signal: combined.signal,
|
|
210
|
+
handBack: async (next, handBackOpts) => {
|
|
211
|
+
// No native hand-back: send the next delivery and let this one complete. The
|
|
212
|
+
// row is already at `seq + 1`, so the new key cannot collide with this job.
|
|
213
|
+
await this.enqueue(queue, next, {
|
|
214
|
+
delayMs: handBackOpts?.delayMs,
|
|
215
|
+
retry: { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } },
|
|
216
|
+
})
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if ((error as { name?: string })?.name === 'NoFurtherAttempts') continue // settled; no retry wanted
|
|
221
|
+
throw error
|
|
222
|
+
} finally {
|
|
223
|
+
job.signal?.removeEventListener('abort', relay)
|
|
224
|
+
this.shutdown.signal.removeEventListener('abort', relay)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
this.workerIds.push({ queue, id: workerId })
|
|
231
|
+
return {
|
|
232
|
+
queue,
|
|
233
|
+
close: async () => {
|
|
234
|
+
const instance = await this.ready()
|
|
235
|
+
await instance.offWork(queue, { id: workerId }).catch(() => undefined)
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async close(opts: { timeoutMs?: number } = {}): Promise<void> {
|
|
241
|
+
this.shutdown.abort()
|
|
242
|
+
for (const timer of this.ticks) clearInterval(timer)
|
|
243
|
+
this.ticks.length = 0
|
|
244
|
+
if (!this.boss) return
|
|
245
|
+
if (!this.ownsInstance) return
|
|
246
|
+
await this.boss.stop({ graceful: true, close: true, timeout: opts.timeoutMs ?? 30_000 }).catch(() => undefined)
|
|
247
|
+
this.boss = null
|
|
248
|
+
this.starting = null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// What the mechanism needs from a broker, and nothing more.
|
|
2
|
+
//
|
|
3
|
+
// The interface is small on purpose. Lease, epoch fencing, slices and budgets all live above
|
|
4
|
+
// it, in the job row — because none of the candidate brokers fences (pg-boss completes a job
|
|
5
|
+
// on `state = 'active'` alone; neither it nor Graphile Worker has an epoch column), so that
|
|
6
|
+
// guarantee had to be ours regardless. Once it is ours, what remains for a transport is
|
|
7
|
+
// genuinely just delivery, and a transport becomes a replaceable detail rather than the
|
|
8
|
+
// design's centre. See docs/adr/0001-transport-adapters.md.
|
|
9
|
+
|
|
10
|
+
import type { Delivery, RetrySettings, SqlExecutor } from '../core/types'
|
|
11
|
+
|
|
12
|
+
export type TransportName = 'memory' | 'bullmq' | 'pgboss'
|
|
13
|
+
|
|
14
|
+
/** The state of a delivery, as far as the broker knows. Used to tell "the broker still holds
|
|
15
|
+
* this" from "nothing is scheduled" — the reconciler's two very different situations. */
|
|
16
|
+
export type DeliveryState = 'waiting' | 'delayed' | 'active' | 'completed' | 'failed' | 'unknown'
|
|
17
|
+
|
|
18
|
+
export type EnqueueOptions = {
|
|
19
|
+
/** How long the broker should hold the delivery before making it available. */
|
|
20
|
+
delayMs?: number
|
|
21
|
+
retry: RetrySettings
|
|
22
|
+
/**
|
|
23
|
+
* Enqueue inside the caller's transaction, so the job row and its delivery commit together.
|
|
24
|
+
*
|
|
25
|
+
* Only pg-boss can honour this — it accepts a caller-supplied client on `send`. Adapters
|
|
26
|
+
* that cannot must ignore it, and the service must therefore enqueue *after* commit for
|
|
27
|
+
* them. This is the one capability difference between the adapters that callers can see.
|
|
28
|
+
*/
|
|
29
|
+
tx?: SqlExecutor
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type HandlerContext = {
|
|
33
|
+
transportJobId: string
|
|
34
|
+
/** 1-based. Together with the kind's retry settings this decides the next delay. */
|
|
35
|
+
attempt: number
|
|
36
|
+
maxAttempts: number
|
|
37
|
+
/** Aborts when the process is shutting down, so a slice can stop at a batch boundary
|
|
38
|
+
* instead of being killed between two writes. */
|
|
39
|
+
signal: AbortSignal
|
|
40
|
+
/**
|
|
41
|
+
* Hands the rest of the work back as a fresh delivery, without spending a retry attempt.
|
|
42
|
+
*
|
|
43
|
+
* Native where the broker supports it (BullMQ moves the job to delayed and keeps its id);
|
|
44
|
+
* emulated elsewhere by enqueuing the next delivery and completing the current one. Either
|
|
45
|
+
* way the contract is the same: after `handBack` resolves, the handler must return.
|
|
46
|
+
*/
|
|
47
|
+
handBack(next: Delivery, opts?: { delayMs?: number }): Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type DeliveryHandler = (delivery: Delivery, ctx: HandlerContext) => Promise<void>
|
|
51
|
+
|
|
52
|
+
export type BindOptions = {
|
|
53
|
+
concurrency: number
|
|
54
|
+
/** How long a delivery may be in flight before the broker considers the worker dead. Must
|
|
55
|
+
* exceed the slice budget, or the broker will redeliver work that is still running — which
|
|
56
|
+
* the lease then refuses, wasting a whole slice. */
|
|
57
|
+
activeTimeoutMs: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BoundWorker {
|
|
61
|
+
queue: string
|
|
62
|
+
close(opts?: { timeoutMs?: number }): Promise<void>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TransportAdapter {
|
|
66
|
+
readonly name: TransportName
|
|
67
|
+
/** True when `enqueue` honours `tx`. Callers branch on this rather than on the name. */
|
|
68
|
+
readonly supportsTransactionalEnqueue: boolean
|
|
69
|
+
|
|
70
|
+
enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }>
|
|
71
|
+
remove(queue: string, transportJobId: string): Promise<void>
|
|
72
|
+
getState(queue: string, transportJobId: string): Promise<DeliveryState>
|
|
73
|
+
|
|
74
|
+
/** A repeating delivery, used for the reconciler tick. Idempotent by `id`. */
|
|
75
|
+
upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void>
|
|
76
|
+
|
|
77
|
+
bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker>
|
|
78
|
+
|
|
79
|
+
/** Stops accepting work and waits, up to `timeoutMs`, for in-flight deliveries to end. */
|
|
80
|
+
close(opts?: { timeoutMs?: number }): Promise<void>
|
|
81
|
+
}
|