@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,176 @@
|
|
|
1
|
+
import { deliveryId } from "../core/ids.js";
|
|
2
|
+
let cached = null;
|
|
3
|
+
async function pgboss() {
|
|
4
|
+
if (cached) return cached;
|
|
5
|
+
try {
|
|
6
|
+
cached = await import("pg-boss");
|
|
7
|
+
return cached;
|
|
8
|
+
} catch (error) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"The pgboss transport requires the optional peer dependency `pg-boss`. Install it, or use DURABLE_WORK_TRANSPORT=bullmq.",
|
|
11
|
+
{ cause: error }
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function asDb(tx) {
|
|
16
|
+
return {
|
|
17
|
+
async executeSql(text, values) {
|
|
18
|
+
const result = await tx.query(text, values);
|
|
19
|
+
return { rows: result.rows, rowCount: result.rowCount };
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const tickDelivery = (id) => ({ jobId: id, seq: 0, redrives: 0 });
|
|
24
|
+
class PgBossTransport {
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.name = "pgboss";
|
|
28
|
+
this.supportsTransactionalEnqueue = true;
|
|
29
|
+
this.boss = null;
|
|
30
|
+
this.starting = null;
|
|
31
|
+
this.ensuredQueues = /* @__PURE__ */ new Set();
|
|
32
|
+
this.workerIds = [];
|
|
33
|
+
this.ticks = [];
|
|
34
|
+
this.shutdown = new AbortController();
|
|
35
|
+
this.boss = options.instance ?? null;
|
|
36
|
+
this.ownsInstance = !options.instance;
|
|
37
|
+
}
|
|
38
|
+
async ready() {
|
|
39
|
+
if (this.boss) return this.boss;
|
|
40
|
+
if (!this.starting) {
|
|
41
|
+
this.starting = (async () => {
|
|
42
|
+
const { PgBoss } = await pgboss();
|
|
43
|
+
const instance = new PgBoss({ connectionString: this.options.connectionString, schema: this.options.schema ?? "durable_work_boss" });
|
|
44
|
+
await instance.start();
|
|
45
|
+
this.boss = instance;
|
|
46
|
+
return instance;
|
|
47
|
+
})();
|
|
48
|
+
}
|
|
49
|
+
return this.starting;
|
|
50
|
+
}
|
|
51
|
+
/** pg-boss 10+ requires a queue to exist before anything is sent to it. `expireInSeconds`
|
|
52
|
+
* belongs to the queue, not to the worker: it is how long a delivery may stay active before
|
|
53
|
+
* pg-boss reclaims it, so it must exceed a whole slice or work that is still running gets
|
|
54
|
+
* handed to a second worker — which the lease then refuses, wasting the slice. */
|
|
55
|
+
async ensureQueue(name, expireInSeconds) {
|
|
56
|
+
const boss = await this.ready();
|
|
57
|
+
if (this.ensuredQueues.has(name)) return boss;
|
|
58
|
+
await boss.createQueue(name, expireInSeconds ? { expireInSeconds } : void 0);
|
|
59
|
+
this.ensuredQueues.add(name);
|
|
60
|
+
return boss;
|
|
61
|
+
}
|
|
62
|
+
async enqueue(queue, delivery, opts) {
|
|
63
|
+
const boss = await this.ensureQueue(queue);
|
|
64
|
+
const key = deliveryId(delivery);
|
|
65
|
+
const sent = await boss.send(queue, delivery, {
|
|
66
|
+
// pg-boss job ids are uuids, so the delivery identity travels as the singleton key —
|
|
67
|
+
// which is also what makes a re-enqueue of the same delivery a no-op.
|
|
68
|
+
singletonKey: key,
|
|
69
|
+
startAfter: opts.delayMs && opts.delayMs > 0 ? Math.ceil(opts.delayMs / 1e3) : void 0,
|
|
70
|
+
retryLimit: opts.retry.attempts,
|
|
71
|
+
retryDelay: Math.max(1, Math.round(opts.retry.backoff.delayMs / 1e3)),
|
|
72
|
+
// pg-boss rejects a max delay unless backoff is on, so the cap travels only with it.
|
|
73
|
+
...opts.retry.backoff.type === "exponential" ? { retryBackoff: true, retryDelayMax: Math.max(1, Math.round(opts.retry.backoff.maxDelayMs / 1e3)) } : { retryBackoff: false },
|
|
74
|
+
...opts.tx ? { db: asDb(opts.tx) } : {}
|
|
75
|
+
});
|
|
76
|
+
return { transportJobId: sent ?? key };
|
|
77
|
+
}
|
|
78
|
+
async remove(queue, transportJobId) {
|
|
79
|
+
const boss = await this.ready();
|
|
80
|
+
await boss.deleteJob(queue, transportJobId).catch(() => void 0);
|
|
81
|
+
}
|
|
82
|
+
async getState(queue, transportJobId) {
|
|
83
|
+
const boss = await this.ready();
|
|
84
|
+
const job = await boss.getJobById(queue, transportJobId).catch(() => null);
|
|
85
|
+
if (!job) return "unknown";
|
|
86
|
+
switch (job.state) {
|
|
87
|
+
case "created":
|
|
88
|
+
case "retry":
|
|
89
|
+
return "waiting";
|
|
90
|
+
case "active":
|
|
91
|
+
return "active";
|
|
92
|
+
case "completed":
|
|
93
|
+
return "completed";
|
|
94
|
+
case "cancelled":
|
|
95
|
+
case "failed":
|
|
96
|
+
return "failed";
|
|
97
|
+
default:
|
|
98
|
+
return "unknown";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async upsertTick(opts) {
|
|
102
|
+
await this.ensureQueue(opts.queue);
|
|
103
|
+
const everySeconds = Math.max(1, Math.round(opts.everyMs / 1e3));
|
|
104
|
+
const fire = async () => {
|
|
105
|
+
if (this.shutdown.signal.aborted) return;
|
|
106
|
+
const boss = await this.ready();
|
|
107
|
+
await boss.send(opts.queue, tickDelivery(opts.id), { singletonKey: opts.id, singletonSeconds: everySeconds }).catch(() => void 0);
|
|
108
|
+
};
|
|
109
|
+
void fire();
|
|
110
|
+
const timer = setInterval(() => void fire(), opts.everyMs);
|
|
111
|
+
timer.unref?.();
|
|
112
|
+
this.ticks.push(timer);
|
|
113
|
+
}
|
|
114
|
+
async bind(queue, handler, opts) {
|
|
115
|
+
const boss = await this.ensureQueue(queue, Math.ceil(opts.activeTimeoutMs / 1e3));
|
|
116
|
+
const workerId = await boss.work(
|
|
117
|
+
queue,
|
|
118
|
+
{ batchSize: opts.concurrency },
|
|
119
|
+
async (jobs) => {
|
|
120
|
+
for (const job of jobs) {
|
|
121
|
+
const delivery = job.data;
|
|
122
|
+
const combined = new AbortController();
|
|
123
|
+
const relay = () => combined.abort();
|
|
124
|
+
job.signal?.addEventListener("abort", relay, { once: true });
|
|
125
|
+
this.shutdown.signal.addEventListener("abort", relay, { once: true });
|
|
126
|
+
try {
|
|
127
|
+
await handler(delivery, {
|
|
128
|
+
transportJobId: job.id,
|
|
129
|
+
// pg-boss does not expose the attempt on the job, so the adapter reports the
|
|
130
|
+
// first attempt and lets its own retry policy carry the rest. The consequence is
|
|
131
|
+
// narrow: the delay written to `next_run_at` is the base rather than a backed-off
|
|
132
|
+
// one, and pg-boss's own `retryBackoff` still spaces the real deliveries.
|
|
133
|
+
attempt: 1,
|
|
134
|
+
maxAttempts: 1,
|
|
135
|
+
signal: combined.signal,
|
|
136
|
+
handBack: async (next, handBackOpts) => {
|
|
137
|
+
await this.enqueue(queue, next, {
|
|
138
|
+
delayMs: handBackOpts?.delayMs,
|
|
139
|
+
retry: { attempts: 1, backoff: { type: "fixed", delayMs: 0, maxDelayMs: 0 } }
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error?.name === "NoFurtherAttempts") continue;
|
|
145
|
+
throw error;
|
|
146
|
+
} finally {
|
|
147
|
+
job.signal?.removeEventListener("abort", relay);
|
|
148
|
+
this.shutdown.signal.removeEventListener("abort", relay);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
this.workerIds.push({ queue, id: workerId });
|
|
154
|
+
return {
|
|
155
|
+
queue,
|
|
156
|
+
close: async () => {
|
|
157
|
+
const instance = await this.ready();
|
|
158
|
+
await instance.offWork(queue, { id: workerId }).catch(() => void 0);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async close(opts = {}) {
|
|
163
|
+
this.shutdown.abort();
|
|
164
|
+
for (const timer of this.ticks) clearInterval(timer);
|
|
165
|
+
this.ticks.length = 0;
|
|
166
|
+
if (!this.boss) return;
|
|
167
|
+
if (!this.ownsInstance) return;
|
|
168
|
+
await this.boss.stop({ graceful: true, close: true, timeout: opts.timeoutMs ?? 3e4 }).catch(() => void 0);
|
|
169
|
+
this.boss = null;
|
|
170
|
+
this.starting = null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export {
|
|
174
|
+
PgBossTransport
|
|
175
|
+
};
|
|
176
|
+
//# sourceMappingURL=pgboss.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/transport/pgboss.ts"],
|
|
4
|
+
"sourcesContent": ["// The pg-boss adapter: production without Redis, and the only adapter that can enqueue a\n// delivery inside the caller's transaction.\n//\n// That one capability is why it exists. `send(..., { db })` composes its statements on a\n// client the caller supplies, so a domain row, its job row and its delivery all commit or all\n// roll back. With any other transport there is a window between commit and enqueue where a\n// crash leaves a job nobody will ever deliver \u2014 the reconciler closes it, but closing it after\n// fifteen minutes is not the same as never opening it.\n//\n// pg-boss is a peer dependency and is imported lazily.\n\nimport { deliveryId } from '../core/ids'\nimport type { Delivery, SqlExecutor } from '../core/types'\nimport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n TransportAdapter,\n} from './types'\n\ntype PgBossModule = typeof import('pg-boss')\n// pg-boss 12 exports the class by name, not as a default.\ntype PgBossInstance = InstanceType<PgBossModule['PgBoss']>\ntype PgBossJob = import('pg-boss').Job<Delivery> & { signal?: AbortSignal }\n\nexport type PgBossTransportOptions = {\n connectionString: string\n /** Keeps pg-boss's own tables out of `public`, so they are obviously not the app's. */\n schema?: string\n /** Reuse an already-started instance instead of owning its lifecycle. */\n instance?: PgBossInstance\n}\n\nlet cached: PgBossModule | null = null\nasync function pgboss(): Promise<PgBossModule> {\n if (cached) return cached\n try {\n cached = await import('pg-boss')\n return cached\n } catch (error) {\n throw new Error(\n 'The pgboss transport requires the optional peer dependency `pg-boss`. Install it, or use DURABLE_WORK_TRANSPORT=bullmq.',\n { cause: error },\n )\n }\n}\n\n/**\n * Adapts a `SqlExecutor` to the shape pg-boss expects from a caller-supplied client.\n *\n * pg-boss only ever calls `executeSql`, so the whole surface is one method. Passing our own\n * executor through \u2014 rather than requiring a raw `pg` client \u2014 is what lets the caller's\n * transaction be a MikroORM one, a node-postgres one, or the harness's, without any of them\n * knowing about the others.\n */\nfunction asDb(tx: SqlExecutor) {\n return {\n async executeSql(text: string, values: unknown[]) {\n const result = await tx.query(text, values)\n return { rows: result.rows as Record<string, unknown>[], rowCount: result.rowCount }\n },\n }\n}\n\n/** A tick, in the same shape as any other delivery. */\nconst tickDelivery = (id: string): Delivery => ({ jobId: id, seq: 0, redrives: 0 })\n\nexport class PgBossTransport implements TransportAdapter {\n readonly name = 'pgboss' as const\n readonly supportsTransactionalEnqueue = true\n\n private boss: PgBossInstance | null = null\n private starting: Promise<PgBossInstance> | null = null\n private readonly ownsInstance: boolean\n private readonly ensuredQueues = new Set<string>()\n private readonly workerIds: Array<{ queue: string; id: string }> = []\n private readonly ticks: NodeJS.Timeout[] = []\n private readonly shutdown = new AbortController()\n\n constructor(private readonly options: PgBossTransportOptions) {\n this.boss = options.instance ?? null\n this.ownsInstance = !options.instance\n }\n\n private async ready(): Promise<PgBossInstance> {\n if (this.boss) return this.boss\n if (!this.starting) {\n this.starting = (async () => {\n const { PgBoss } = await pgboss()\n const instance = new PgBoss({ connectionString: this.options.connectionString, schema: this.options.schema ?? 'durable_work_boss' })\n await instance.start()\n this.boss = instance\n return instance\n })()\n }\n return this.starting\n }\n\n /** pg-boss 10+ requires a queue to exist before anything is sent to it. `expireInSeconds`\n * belongs to the queue, not to the worker: it is how long a delivery may stay active before\n * pg-boss reclaims it, so it must exceed a whole slice or work that is still running gets\n * handed to a second worker \u2014 which the lease then refuses, wasting the slice. */\n private async ensureQueue(name: string, expireInSeconds?: number): Promise<PgBossInstance> {\n const boss = await this.ready()\n if (this.ensuredQueues.has(name)) return boss\n await boss.createQueue(name, expireInSeconds ? { expireInSeconds } : undefined)\n this.ensuredQueues.add(name)\n return boss\n }\n\n async enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {\n const boss = await this.ensureQueue(queue)\n const key = deliveryId(delivery)\n const sent = await boss.send(queue, delivery as unknown as object, {\n // pg-boss job ids are uuids, so the delivery identity travels as the singleton key \u2014\n // which is also what makes a re-enqueue of the same delivery a no-op.\n singletonKey: key,\n startAfter: opts.delayMs && opts.delayMs > 0 ? Math.ceil(opts.delayMs / 1000) : undefined,\n retryLimit: opts.retry.attempts,\n retryDelay: Math.max(1, Math.round(opts.retry.backoff.delayMs / 1000)),\n // pg-boss rejects a max delay unless backoff is on, so the cap travels only with it.\n ...(opts.retry.backoff.type === 'exponential'\n ? { retryBackoff: true, retryDelayMax: Math.max(1, Math.round(opts.retry.backoff.maxDelayMs / 1000)) }\n : { retryBackoff: false }),\n ...(opts.tx ? { db: asDb(opts.tx) } : {}),\n })\n // `send` returns null when the singleton key collapsed this into an existing job. That is\n // the intended outcome, not a failure: the delivery is already scheduled.\n return { transportJobId: sent ?? key }\n }\n\n async remove(queue: string, transportJobId: string): Promise<void> {\n const boss = await this.ready()\n await boss.deleteJob(queue, transportJobId).catch(() => undefined)\n }\n\n async getState(queue: string, transportJobId: string): Promise<DeliveryState> {\n const boss = await this.ready()\n const job = await boss.getJobById(queue, transportJobId).catch(() => null)\n if (!job) return 'unknown'\n switch (job.state) {\n case 'created':\n case 'retry':\n return 'waiting'\n case 'active':\n return 'active'\n case 'completed':\n return 'completed'\n case 'cancelled':\n case 'failed':\n return 'failed'\n default:\n return 'unknown'\n }\n }\n\n async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {\n await this.ensureQueue(opts.queue)\n // pg-boss's own scheduler is cron-based, so its finest granularity is a minute \u2014 too\n // coarse for a repair loop. A per-process timer with a singleton key gives the cadence we\n // need and still collapses the fleet's ticks into one job per window. The trade-off is\n // stated rather than hidden: with zero workers up there is no tick, exactly as with a\n // broker-owned schedule that nobody polls.\n const everySeconds = Math.max(1, Math.round(opts.everyMs / 1000))\n const fire = async () => {\n if (this.shutdown.signal.aborted) return\n const boss = await this.ready()\n // Delivery-shaped, like every other payload on every adapter: a tick is a delivery\n // whose handler happens to ignore it, not a second kind of message.\n await boss\n .send(opts.queue, tickDelivery(opts.id), { singletonKey: opts.id, singletonSeconds: everySeconds })\n .catch(() => undefined)\n }\n void fire()\n const timer = setInterval(() => void fire(), opts.everyMs)\n timer.unref?.()\n this.ticks.push(timer)\n }\n\n async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {\n const boss = await this.ensureQueue(queue, Math.ceil(opts.activeTimeoutMs / 1000))\n\n const workerId = await boss.work<Delivery>(\n queue,\n { batchSize: opts.concurrency },\n async (jobs: PgBossJob[]) => {\n for (const job of jobs) {\n // Whatever arrived is handed on unexamined. An adapter that inspects payloads\n // decides what counts as a real delivery, and this one used to skip anything\n // without a `jobId` \u2014 which silently swallowed every reconciler tick.\n const delivery = job.data\n\n const combined = new AbortController()\n const relay = () => combined.abort()\n job.signal?.addEventListener('abort', relay, { once: true })\n this.shutdown.signal.addEventListener('abort', relay, { once: true })\n\n try {\n await handler(delivery, {\n transportJobId: job.id,\n // pg-boss does not expose the attempt on the job, so the adapter reports the\n // first attempt and lets its own retry policy carry the rest. The consequence is\n // narrow: the delay written to `next_run_at` is the base rather than a backed-off\n // one, and pg-boss's own `retryBackoff` still spaces the real deliveries.\n attempt: 1,\n maxAttempts: 1,\n signal: combined.signal,\n handBack: async (next, handBackOpts) => {\n // No native hand-back: send the next delivery and let this one complete. The\n // row is already at `seq + 1`, so the new key cannot collide with this job.\n await this.enqueue(queue, next, {\n delayMs: handBackOpts?.delayMs,\n retry: { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } },\n })\n },\n })\n } catch (error) {\n if ((error as { name?: string })?.name === 'NoFurtherAttempts') continue // settled; no retry wanted\n throw error\n } finally {\n job.signal?.removeEventListener('abort', relay)\n this.shutdown.signal.removeEventListener('abort', relay)\n }\n }\n },\n )\n\n this.workerIds.push({ queue, id: workerId })\n return {\n queue,\n close: async () => {\n const instance = await this.ready()\n await instance.offWork(queue, { id: workerId }).catch(() => undefined)\n },\n }\n }\n\n async close(opts: { timeoutMs?: number } = {}): Promise<void> {\n this.shutdown.abort()\n for (const timer of this.ticks) clearInterval(timer)\n this.ticks.length = 0\n if (!this.boss) return\n if (!this.ownsInstance) return\n await this.boss.stop({ graceful: true, close: true, timeout: opts.timeoutMs ?? 30_000 }).catch(() => undefined)\n this.boss = null\n this.starting = null\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const id = "id";
|
|
2
|
+
export const tenant_id = "tenant_id";
|
|
3
|
+
export const organization_id = "organization_id";
|
|
4
|
+
export const kind = "kind";
|
|
5
|
+
export const status = "status";
|
|
6
|
+
export const created_by = "created_by";
|
|
7
|
+
export const created_at = "created_at";
|
|
8
|
+
export const updated_at = "updated_at";
|
|
9
|
+
export const input = "input";
|
|
10
|
+
export const checkpoint = "checkpoint";
|
|
11
|
+
export const meta = "meta";
|
|
12
|
+
export const idempotency_key = "idempotency_key";
|
|
13
|
+
export const lock_key = "lock_key";
|
|
14
|
+
export const subject_type = "subject_type";
|
|
15
|
+
export const subject_id = "subject_id";
|
|
16
|
+
export const progress_job_id = "progress_job_id";
|
|
17
|
+
export const lease_owner = "lease_owner";
|
|
18
|
+
export const lease_epoch = "lease_epoch";
|
|
19
|
+
export const lease_expires_at = "lease_expires_at";
|
|
20
|
+
export const heartbeat_at = "heartbeat_at";
|
|
21
|
+
export const queue_name = "queue_name";
|
|
22
|
+
export const queue_job_id = "queue_job_id";
|
|
23
|
+
export const continuation_seq = "continuation_seq";
|
|
24
|
+
export const redrives = "redrives";
|
|
25
|
+
export const next_run_at = "next_run_at";
|
|
26
|
+
export const pending_since = "pending_since";
|
|
27
|
+
export const redrives_since_commit = "redrives_since_commit";
|
|
28
|
+
export const consecutive_failures = "consecutive_failures";
|
|
29
|
+
export const interruptions = "interruptions";
|
|
30
|
+
export const mirror_attempts = "mirror_attempts";
|
|
31
|
+
export const last_committed_at = "last_committed_at";
|
|
32
|
+
export const started_at = "started_at";
|
|
33
|
+
export const finished_at = "finished_at";
|
|
34
|
+
export const parked_at = "parked_at";
|
|
35
|
+
export const cancel_requested_at = "cancel_requested_at";
|
|
36
|
+
export const cancelled_by = "cancelled_by";
|
|
37
|
+
export const error_class = "error_class";
|
|
38
|
+
export const error_code = "error_code";
|
|
39
|
+
export const error_message = "error_message";
|
|
40
|
+
export const domain_mirrored_at = "domain_mirrored_at";
|
|
41
|
+
export const processed_count = "processed_count";
|
|
42
|
+
export const total_count = "total_count";
|
package/package.json
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fullstackhouse/open-mercato-durable-work",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Durable at-least-once background work for Open Mercato apps: a leased job record in Postgres with epoch fencing, bounded resumable slices, a server-side reconciler, fenced cancel and an operator API. Pluggable transport (BullMQ or pg-boss).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Full Stack House",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/fullstackhouse/open-mercato-durable.git",
|
|
12
|
+
"directory": "packages/durable-work"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"open-mercato",
|
|
16
|
+
"background-jobs",
|
|
17
|
+
"durable",
|
|
18
|
+
"at-least-once",
|
|
19
|
+
"queue",
|
|
20
|
+
"bullmq",
|
|
21
|
+
"pg-boss"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"src",
|
|
30
|
+
"generated",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "node build.mjs && node scripts/gen-entity-ids.mjs",
|
|
35
|
+
"prepack": "yarn build",
|
|
36
|
+
"watch": "node watch.mjs",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"lint": "eslint src",
|
|
41
|
+
"gen:entity-ids": "node scripts/gen-entity-ids.mjs"
|
|
42
|
+
},
|
|
43
|
+
"exports": {
|
|
44
|
+
".": {
|
|
45
|
+
"types": "./src/index.ts",
|
|
46
|
+
"default": "./dist/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./*.ts": {
|
|
49
|
+
"types": "./src/*.ts",
|
|
50
|
+
"default": "./dist/*.js"
|
|
51
|
+
},
|
|
52
|
+
"./*.tsx": {
|
|
53
|
+
"types": "./src/*.tsx",
|
|
54
|
+
"default": "./dist/*.js"
|
|
55
|
+
},
|
|
56
|
+
"./*.json": "./src/*.json",
|
|
57
|
+
"./*": {
|
|
58
|
+
"types": [
|
|
59
|
+
"./src/*.ts",
|
|
60
|
+
"./src/*.tsx"
|
|
61
|
+
],
|
|
62
|
+
"default": "./dist/*.js"
|
|
63
|
+
},
|
|
64
|
+
"./*/*.json": "./src/*/*.json",
|
|
65
|
+
"./*/*": {
|
|
66
|
+
"types": [
|
|
67
|
+
"./src/*/*.ts",
|
|
68
|
+
"./src/*/*.tsx"
|
|
69
|
+
],
|
|
70
|
+
"default": "./dist/*/*.js"
|
|
71
|
+
},
|
|
72
|
+
"./*/*/*.json": "./src/*/*/*.json",
|
|
73
|
+
"./*/*/*": {
|
|
74
|
+
"types": [
|
|
75
|
+
"./src/*/*/*.ts",
|
|
76
|
+
"./src/*/*/*.tsx"
|
|
77
|
+
],
|
|
78
|
+
"default": "./dist/*/*/*.js"
|
|
79
|
+
},
|
|
80
|
+
"./*/*/*/*.json": "./src/*/*/*/*.json",
|
|
81
|
+
"./*/*/*/*": {
|
|
82
|
+
"types": [
|
|
83
|
+
"./src/*/*/*/*.ts",
|
|
84
|
+
"./src/*/*/*/*.tsx"
|
|
85
|
+
],
|
|
86
|
+
"default": "./dist/*/*/*/*.js"
|
|
87
|
+
},
|
|
88
|
+
"./*/*/*/*/*.json": "./src/*/*/*/*/*.json",
|
|
89
|
+
"./*/*/*/*/*": {
|
|
90
|
+
"types": [
|
|
91
|
+
"./src/*/*/*/*/*.ts",
|
|
92
|
+
"./src/*/*/*/*/*.tsx"
|
|
93
|
+
],
|
|
94
|
+
"default": "./dist/*/*/*/*/*.js"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"peerDependencies": {
|
|
98
|
+
"@mikro-orm/core": ">=7.0.0",
|
|
99
|
+
"@mikro-orm/migrations": ">=7.0.0",
|
|
100
|
+
"@mikro-orm/postgresql": ">=7.0.0",
|
|
101
|
+
"@open-mercato/core": ">=0.7.0 <0.8.0",
|
|
102
|
+
"@open-mercato/shared": ">=0.7.0 <0.8.0",
|
|
103
|
+
"bullmq": "^5.0.0 || ^6.0.0",
|
|
104
|
+
"ioredis": ">=5.0.0",
|
|
105
|
+
"next": ">=15.0.0",
|
|
106
|
+
"pg": ">=8.0.0",
|
|
107
|
+
"pg-boss": ">=12.0.0 <13.0.0",
|
|
108
|
+
"zod": ">=3.25.0"
|
|
109
|
+
},
|
|
110
|
+
"peerDependenciesMeta": {
|
|
111
|
+
"bullmq": {
|
|
112
|
+
"optional": true
|
|
113
|
+
},
|
|
114
|
+
"ioredis": {
|
|
115
|
+
"optional": true
|
|
116
|
+
},
|
|
117
|
+
"pg-boss": {
|
|
118
|
+
"optional": true
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"devDependencies": {
|
|
122
|
+
"@eslint/js": "^9.0.0",
|
|
123
|
+
"@mikro-orm/core": "^7.1.8",
|
|
124
|
+
"@mikro-orm/migrations": "^7.1.8",
|
|
125
|
+
"@mikro-orm/postgresql": "^7.1.8",
|
|
126
|
+
"@open-mercato/core": "0.7.0",
|
|
127
|
+
"@open-mercato/shared": "0.7.0",
|
|
128
|
+
"@types/node": "^24.10.1",
|
|
129
|
+
"@types/pg": "^8.20.0",
|
|
130
|
+
"awilix": "^13.0.5",
|
|
131
|
+
"bullmq": "^6.3.4",
|
|
132
|
+
"esbuild": "^0.25.0",
|
|
133
|
+
"eslint": "^9.0.0",
|
|
134
|
+
"glob": "^13.0.6",
|
|
135
|
+
"globals": "^16.0.0",
|
|
136
|
+
"ioredis": "^5.11.1",
|
|
137
|
+
"next": "16.3.0",
|
|
138
|
+
"pg": "^8.23.0",
|
|
139
|
+
"pg-boss": "^12.30.0",
|
|
140
|
+
"typescript": "^5.9.3",
|
|
141
|
+
"typescript-eslint": "^8.0.0",
|
|
142
|
+
"vitest": "^3.0.0",
|
|
143
|
+
"zod": "^4.4.3"
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { KindRegistry } from '../registry'
|
|
2
|
+
|
|
3
|
+
import type { SliceOutcome } from '../types'
|
|
4
|
+
|
|
5
|
+
const kind = (id: string, step: () => Promise<SliceOutcome> = async () => 'drained') => ({ kind: id, queue: 'durable-work.test', step })
|
|
6
|
+
|
|
7
|
+
describe('KindRegistry', () => {
|
|
8
|
+
it('accepts the same handler registered twice', () => {
|
|
9
|
+
// `register` runs on every container build, which in a web process is every request. A
|
|
10
|
+
// registry that treated re-registration as a conflict would crash the second request.
|
|
11
|
+
const registry = new KindRegistry()
|
|
12
|
+
const definition = kind('test.a')
|
|
13
|
+
registry.register(definition)
|
|
14
|
+
expect(() => registry.register(definition)).not.toThrow()
|
|
15
|
+
expect(registry.list()).toHaveLength(1)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('refuses two different handlers under one id', () => {
|
|
19
|
+
// Accepting the second silently would make "which code runs this job?" depend on the order
|
|
20
|
+
// containers happened to be built in.
|
|
21
|
+
const registry = new KindRegistry()
|
|
22
|
+
registry.register(kind('test.b'))
|
|
23
|
+
expect(() => registry.register(kind('test.b', async () => 'budget'))).toThrow(/Duplicate durable job kind/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('refuses a domain mirror with no way back', () => {
|
|
27
|
+
// An operator could otherwise re-drive the job while the domain row stayed terminal.
|
|
28
|
+
const registry = new KindRegistry()
|
|
29
|
+
expect(() => registry.register({ ...kind('test.c'), onTransition: async () => ({ matched: 1 }) })).toThrow(
|
|
30
|
+
/onTransition without onRedrive/,
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('applies defaults, including parking rather than re-driving an orphan', () => {
|
|
35
|
+
const registry = new KindRegistry()
|
|
36
|
+
registry.register(kind('test.d'))
|
|
37
|
+
const resolved = registry.require('test.d')
|
|
38
|
+
// A job nobody declared idempotent is not re-run automatically just because its worker died.
|
|
39
|
+
expect(resolved.orphanPolicy).toBe('park')
|
|
40
|
+
expect(resolved.lease.ttlMs).toBeGreaterThan(0)
|
|
41
|
+
expect(resolved.budget.maxConsecutiveFailures).toBeGreaterThan(0)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The error taxonomy. Anything a step throws that is not one of these is `transient`: an
|
|
2
|
+
// unrecognised failure is far more often a blip than a dead end, and the cost of guessing
|
|
3
|
+
// wrong is one retry rather than a multi-day run thrown away.
|
|
4
|
+
|
|
5
|
+
import type { ErrorClass } from './types'
|
|
6
|
+
|
|
7
|
+
/** Retry this delivery. The default class for anything unrecognised. */
|
|
8
|
+
export class TransientError extends Error {
|
|
9
|
+
readonly durableErrorClass: ErrorClass = 'transient'
|
|
10
|
+
constructor(message: string, options?: { cause?: unknown; code?: string }) {
|
|
11
|
+
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined)
|
|
12
|
+
this.name = 'TransientError'
|
|
13
|
+
this.code = options?.code
|
|
14
|
+
}
|
|
15
|
+
readonly code?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Fail the job now, without further transport attempts. Still re-drivable by an operator. */
|
|
19
|
+
export class TerminalError extends Error {
|
|
20
|
+
readonly durableErrorClass: ErrorClass = 'terminal'
|
|
21
|
+
constructor(message: string, options?: { cause?: unknown; code?: string }) {
|
|
22
|
+
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined)
|
|
23
|
+
this.name = 'TerminalError'
|
|
24
|
+
this.code = options?.code
|
|
25
|
+
}
|
|
26
|
+
readonly code?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Fail the job now and refuse a plain re-drive: an operator must pass `{ force: true }`.
|
|
30
|
+
* For failures where running the work again is known to be wrong, not merely useless. */
|
|
31
|
+
export class UnrecoverableError extends Error {
|
|
32
|
+
readonly durableErrorClass: ErrorClass = 'unrecoverable'
|
|
33
|
+
constructor(message: string, options?: { cause?: unknown; code?: string }) {
|
|
34
|
+
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined)
|
|
35
|
+
this.name = 'UnrecoverableError'
|
|
36
|
+
this.code = options?.code
|
|
37
|
+
}
|
|
38
|
+
readonly code?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Thrown by `fencedWrite` when the lease was lost or taken while the slice was running. The
|
|
42
|
+
* transaction it guards has already rolled back, so nothing the slice believed it wrote is
|
|
43
|
+
* in the database. Never retried inside the slice: the job now belongs to someone else. */
|
|
44
|
+
export class LeaseLostError extends Error {
|
|
45
|
+
readonly durableErrorClass: ErrorClass = 'transient'
|
|
46
|
+
constructor(readonly lease: { jobId: string; owner: string; epoch: number }) {
|
|
47
|
+
super(`Lease lost for job ${lease.jobId} (owner ${lease.owner}, epoch ${lease.epoch})`)
|
|
48
|
+
this.name = 'LeaseLostError'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Another live job already holds this `lockKey` in this scope. The single-runner guarantee,
|
|
53
|
+
* surfaced as a 409 rather than a duplicate run. */
|
|
54
|
+
export class LockKeyHeldError extends Error {
|
|
55
|
+
readonly durableErrorClass: ErrorClass = 'terminal'
|
|
56
|
+
constructor(
|
|
57
|
+
readonly lockKey: string,
|
|
58
|
+
readonly heldBy?: string,
|
|
59
|
+
) {
|
|
60
|
+
super(`Lock key ${JSON.stringify(lockKey)} is held by a live job${heldBy ? ` (${heldBy})` : ''}`)
|
|
61
|
+
this.name = 'LockKeyHeldError'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Signals the transport to end this delivery without scheduling another attempt. The job's
|
|
66
|
+
* own state already says what happened; a further attempt would claim a row that refuses it. */
|
|
67
|
+
export class NoFurtherAttempts extends Error {
|
|
68
|
+
constructor(readonly reason: string) {
|
|
69
|
+
super(`No further transport attempts: ${reason}`)
|
|
70
|
+
this.name = 'NoFurtherAttempts'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A registry lookup for a kind no process registered. */
|
|
75
|
+
export class UnknownKindError extends Error {
|
|
76
|
+
constructor(readonly kind: string) {
|
|
77
|
+
super(`No handler registered for durable job kind ${JSON.stringify(kind)}`)
|
|
78
|
+
this.name = 'UnknownKindError'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const CLASSES: ReadonlySet<string> = new Set<ErrorClass>(['transient', 'terminal', 'unrecoverable'])
|
|
83
|
+
|
|
84
|
+
/** Reads the class off an error, defaulting to `transient`.
|
|
85
|
+
*
|
|
86
|
+
* Matches on a `durableErrorClass` property rather than `instanceof`, so an error that
|
|
87
|
+
* crossed a package boundary — two copies of this package in one install, a re-thrown cause,
|
|
88
|
+
* a structured-clone — is still classified correctly. Getting this wrong the other way would
|
|
89
|
+
* silently downgrade an `unrecoverable` to a retry loop. */
|
|
90
|
+
export function classifyError(error: unknown): ErrorClass {
|
|
91
|
+
const candidate = (error as { durableErrorClass?: unknown } | null | undefined)?.durableErrorClass
|
|
92
|
+
return typeof candidate === 'string' && CLASSES.has(candidate) ? (candidate as ErrorClass) : 'transient'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The `error_code` to persist, if the error carries one. */
|
|
96
|
+
export function errorCodeOf(error: unknown): string | null {
|
|
97
|
+
const code = (error as { code?: unknown } | null | undefined)?.code
|
|
98
|
+
return typeof code === 'string' && code.length > 0 ? code : null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function errorMessageOf(error: unknown): string {
|
|
102
|
+
if (error instanceof Error) return error.message
|
|
103
|
+
return typeof error === 'string' ? error : 'Unknown error'
|
|
104
|
+
}
|
package/src/core/ids.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Delivery identity and queue naming.
|
|
2
|
+
|
|
3
|
+
import type { Delivery } from './types'
|
|
4
|
+
|
|
5
|
+
/** The id a transport carries for one delivery of one job.
|
|
6
|
+
*
|
|
7
|
+
* Colon-free on purpose: BullMQ 6 rejects `:` in a job id (it is the separator in its own
|
|
8
|
+
* Redis keys), and the id has to be usable unchanged as a pg-boss `singletonKey` too.
|
|
9
|
+
*
|
|
10
|
+
* It encodes `(jobId, seq, redrives)` because that triple IS the fence: a delivery whose seq
|
|
11
|
+
* or redrives no longer match the row is a straggler from a previous slice or re-drive, and
|
|
12
|
+
* `claim` refuses it. Making the id carry the triple means a duplicate delivery is refused by
|
|
13
|
+
* the database rather than deduplicated by the broker, which is the only version of that
|
|
14
|
+
* guarantee that survives a broker restart. */
|
|
15
|
+
export function deliveryId(delivery: Delivery): string {
|
|
16
|
+
return `dw-${delivery.jobId}-${delivery.seq}-${delivery.redrives}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DELIVERY_ID = /^dw-(.+)-(\d+)-(\d+)$/
|
|
20
|
+
|
|
21
|
+
export function parseDeliveryId(id: string): Delivery | null {
|
|
22
|
+
const match = DELIVERY_ID.exec(id)
|
|
23
|
+
if (!match) return null
|
|
24
|
+
return { jobId: match[1]!, seq: Number(match[2]), redrives: Number(match[3]) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Queues are named per kind group so one worker process can bind a subset of kinds.
|
|
29
|
+
*
|
|
30
|
+
* A period, not a colon. pg-boss validates queue names against
|
|
31
|
+
* `[alphanumeric, _, -, ., /]` and rejects a colon outright, and BullMQ gives `:` special
|
|
32
|
+
* meaning inside its own Redis keys. One name has to work on every adapter — a per-adapter
|
|
33
|
+
* rewrite would mean the queue an operator sees in the code is not the queue they can find
|
|
34
|
+
* in the broker.
|
|
35
|
+
*/
|
|
36
|
+
export function queueNameFor(group: string): string {
|
|
37
|
+
return `durable-work.${group}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The characters every supported broker accepts in a queue name. */
|
|
41
|
+
export const PORTABLE_QUEUE_NAME = /^[A-Za-z0-9_\-./]+$/
|
|
42
|
+
|
|
43
|
+
/** The idempotency key handed to a slice, and the one it should forward to any external
|
|
44
|
+
* side effect. Stable across retries of the same slice, different for the next slice. */
|
|
45
|
+
export function sliceIdempotencyKey(jobId: string, seq: number): string {
|
|
46
|
+
return `${jobId}:${seq}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Identifies one worker process for the lifetime of that process.
|
|
50
|
+
*
|
|
51
|
+
* The random suffix is what makes "is this lease mine?" answerable after a crash: a restarted
|
|
52
|
+
* process on the same host must not match the lease its predecessor held, or a stalled
|
|
53
|
+
* redelivery to the new process would be accepted while the old row still looks alive. */
|
|
54
|
+
export function makeOwnerId(prefix = 'dw'): string {
|
|
55
|
+
const random = Math.random().toString(36).slice(2, 10)
|
|
56
|
+
const pid = typeof process !== 'undefined' && process.pid ? process.pid : 0
|
|
57
|
+
return `${prefix}-${pid}-${Date.now().toString(36)}-${random}`
|
|
58
|
+
}
|