@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,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/om/sql-executor-mikro.ts"],
|
|
4
|
+
"sourcesContent": ["// Bridges the mechanism's `SqlExecutor` onto a MikroORM EntityManager.\n//\n// The mechanism talks SQL, not ORM, because every statement it issues is a compare-and-set\n// whose predicate is the guarantee. But a host's transaction is a MikroORM one, and a job row\n// that has to commit with a domain row has to be inside it. This adapter is how both are true\n// at once: the caller keeps their EntityManager, the mechanism keeps its statements.\n\nimport type { EntityManager } from '@mikro-orm/postgresql'\n\nimport type { SqlExecutor, SqlTransactor } from '../core/types'\n\ntype Connection = {\n execute(sql: string, params?: unknown[], method?: 'all' | 'get' | 'run', ctx?: unknown): Promise<unknown>\n}\n\ntype EntityManagerLike = EntityManager & { getTransactionContext?(): unknown }\n\n/** True when the statement returns rows the caller will read. */\nconst RETURNS_ROWS = /\\breturning\\b|^\\s*(select|with)\\b/i\n\ntype RunResult = { affectedRows?: number; rowCount?: number }\n\n/**\n * Rewrites Postgres's numbered placeholders into the positional ones MikroORM binds with.\n *\n * The statements are written in `$n` form because that is Postgres's own, and because the\n * failure harness runs them through node-postgres unchanged \u2014 the SQL that CI exercises is\n * character-for-character the SQL a host runs. MikroORM goes through Knex, which binds `?`\n * positionally, so the translation happens here rather than by writing the statements twice.\n *\n * A placeholder may legitimately appear more than once \u2014 the scope predicate reads `$3` twice,\n * to compare an organization and to test it for null \u2014 so the parameter list is rebuilt in\n * order of occurrence rather than reused as given.\n */\nexport function toPositional(text: string, params: readonly unknown[]): { text: string; params: unknown[] } {\n const ordered: unknown[] = []\n const rewritten = text.replace(/\\$(\\d+)/g, (_match, index: string) => {\n const position = Number(index)\n if (position < 1 || position > params.length) {\n throw new Error(`SQL references $${position} but ${params.length} parameter(s) were supplied`)\n }\n ordered.push(params[position - 1])\n return '?'\n })\n return { text: rewritten, params: ordered }\n}\n\nfunction executorFor(em: EntityManager): SqlExecutor {\n return {\n async query<R = Record<string, unknown>>(text: string, params: readonly unknown[] = []) {\n const manager = em as EntityManagerLike\n const connection = manager.getConnection() as unknown as Connection\n const bound = toPositional(text, params)\n\n // The transaction this EntityManager is inside, if any.\n //\n // Without it every statement runs on a pooled connection *outside* the transaction, and\n // the guarantees built on top quietly stop holding: `fencedWrite` no longer rolls back a\n // stale worker's writes, and a terminal transition no longer moves the job row and the\n // domain row together. Nothing fails \u2014 it just is not atomic any more, which is the\n // worst possible way for this to be wrong.\n const ctx = manager.getTransactionContext?.()\n\n if (RETURNS_ROWS.test(bound.text)) {\n const rows = (await connection.execute(bound.text, bound.params, 'all', ctx)) as R[]\n return { rows, rowCount: rows.length }\n }\n\n // A statement with no RETURNING has no rows to count, and counting them anyway reports\n // zero for an UPDATE that matched. That number is not cosmetic: a domain mirror returns\n // it as `matched`, and `matched: 0` is treated exactly like a throw \u2014 so every mirror\n // would look like it had failed while the write it made had already landed.\n const result = (await connection.execute(bound.text, bound.params, 'run', ctx)) as RunResult\n return { rows: [] as R[], rowCount: result?.affectedRows ?? result?.rowCount ?? 0 }\n },\n }\n}\n\n/**\n * Wraps an EntityManager as a transactor.\n *\n * Each transaction runs on a *forked* EntityManager. The mechanism's statements must not share\n * an identity map or a flush cycle with whatever the caller is doing \u2014 a heartbeat that\n * accidentally flushed a half-built domain entity would be a spectacular way to lose data.\n */\nexport function mikroExecutor(em: EntityManager): SqlTransactor {\n const base = executorFor(em)\n return {\n query: base.query,\n async transaction<T>(fn: (tx: SqlExecutor) => Promise<T>): Promise<T> {\n const forked = em.fork()\n // `transactional` gives the callback an EntityManager carrying the transaction context,\n // which `executorFor` then passes to every statement. Using the outer `em` here instead\n // would silently run them outside the transaction.\n return forked.transactional(async (trx) => fn(executorFor(trx as EntityManager)))\n },\n }\n}\n\n/** Wraps an EntityManager the caller has already opened a transaction on, so the mechanism's\n * statements join it rather than opening a second one. */\nexport function mikroTx(em: EntityManager): SqlExecutor {\n return executorFor(em)\n}\n"],
|
|
5
|
+
"mappings": "AAkBA,MAAM,eAAe;AAgBd,SAAS,aAAa,MAAc,QAAiE;AAC1G,QAAM,UAAqB,CAAC;AAC5B,QAAM,YAAY,KAAK,QAAQ,YAAY,CAAC,QAAQ,UAAkB;AACpE,UAAM,WAAW,OAAO,KAAK;AAC7B,QAAI,WAAW,KAAK,WAAW,OAAO,QAAQ;AAC5C,YAAM,IAAI,MAAM,mBAAmB,QAAQ,QAAQ,OAAO,MAAM,6BAA6B;AAAA,IAC/F;AACA,YAAQ,KAAK,OAAO,WAAW,CAAC,CAAC;AACjC,WAAO;AAAA,EACT,CAAC;AACD,SAAO,EAAE,MAAM,WAAW,QAAQ,QAAQ;AAC5C;AAEA,SAAS,YAAY,IAAgC;AACnD,SAAO;AAAA,IACL,MAAM,MAAmC,MAAc,SAA6B,CAAC,GAAG;AACtF,YAAM,UAAU;AAChB,YAAM,aAAa,QAAQ,cAAc;AACzC,YAAM,QAAQ,aAAa,MAAM,MAAM;AASvC,YAAM,MAAM,QAAQ,wBAAwB;AAE5C,UAAI,aAAa,KAAK,MAAM,IAAI,GAAG;AACjC,cAAM,OAAQ,MAAM,WAAW,QAAQ,MAAM,MAAM,MAAM,QAAQ,OAAO,GAAG;AAC3E,eAAO,EAAE,MAAM,UAAU,KAAK,OAAO;AAAA,MACvC;AAMA,YAAM,SAAU,MAAM,WAAW,QAAQ,MAAM,MAAM,MAAM,QAAQ,OAAO,GAAG;AAC7E,aAAO,EAAE,MAAM,CAAC,GAAU,UAAU,QAAQ,gBAAgB,QAAQ,YAAY,EAAE;AAAA,IACpF;AAAA,EACF;AACF;AASO,SAAS,cAAc,IAAkC;AAC9D,QAAM,OAAO,YAAY,EAAE;AAC3B,SAAO;AAAA,IACL,OAAO,KAAK;AAAA,IACZ,MAAM,YAAe,IAAiD;AACpE,YAAM,SAAS,GAAG,KAAK;AAIvB,aAAO,OAAO,cAAc,OAAO,QAAQ,GAAG,YAAY,GAAoB,CAAC,CAAC;AAAA,IAClF;AAAA,EACF;AACF;AAIO,SAAS,QAAQ,IAAgC;AACtD,SAAO,YAAY,EAAE;AACvB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { deliveryId } from "../core/ids.js";
|
|
2
|
+
let cached = null;
|
|
3
|
+
async function bullmq() {
|
|
4
|
+
if (cached) return cached;
|
|
5
|
+
try {
|
|
6
|
+
cached = await import("bullmq");
|
|
7
|
+
return cached;
|
|
8
|
+
} catch (error) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"The bullmq transport requires the optional peer dependencies `bullmq` and `ioredis`. Install them, or use DURABLE_WORK_TRANSPORT=pgboss.",
|
|
11
|
+
{ cause: error }
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function backoffFor(retry) {
|
|
16
|
+
return retry.backoff.type === "fixed" ? { type: "fixed", delay: retry.backoff.delayMs } : { type: "exponential", delay: retry.backoff.delayMs };
|
|
17
|
+
}
|
|
18
|
+
class BullMQTransport {
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.name = "bullmq";
|
|
22
|
+
/** BullMQ writes to Redis, so it cannot join a Postgres transaction. Callers that need a
|
|
23
|
+
* job row and its delivery to commit together must enqueue after commit — and accept the
|
|
24
|
+
* gap the reconciler exists to close. */
|
|
25
|
+
this.supportsTransactionalEnqueue = false;
|
|
26
|
+
this.queues = /* @__PURE__ */ new Map();
|
|
27
|
+
this.workers = [];
|
|
28
|
+
this.shutdown = new AbortController();
|
|
29
|
+
}
|
|
30
|
+
async queue(name) {
|
|
31
|
+
const existing = this.queues.get(name);
|
|
32
|
+
if (existing) return existing;
|
|
33
|
+
const { Queue } = await bullmq();
|
|
34
|
+
const queue = new Queue(name, { connection: this.options.connection, prefix: this.options.prefix });
|
|
35
|
+
this.queues.set(name, queue);
|
|
36
|
+
return queue;
|
|
37
|
+
}
|
|
38
|
+
async enqueue(queueName, delivery, opts) {
|
|
39
|
+
const queue = await this.queue(queueName);
|
|
40
|
+
const jobId = deliveryId(delivery);
|
|
41
|
+
await queue.add("delivery", delivery, {
|
|
42
|
+
jobId,
|
|
43
|
+
delay: opts.delayMs && opts.delayMs > 0 ? opts.delayMs : void 0,
|
|
44
|
+
attempts: opts.retry.attempts,
|
|
45
|
+
backoff: backoffFor(opts.retry),
|
|
46
|
+
removeOnComplete: { age: 3600, count: 1e3 },
|
|
47
|
+
removeOnFail: { age: 86400 }
|
|
48
|
+
});
|
|
49
|
+
return { transportJobId: jobId };
|
|
50
|
+
}
|
|
51
|
+
async remove(queueName, transportJobId) {
|
|
52
|
+
const queue = await this.queue(queueName);
|
|
53
|
+
await queue.remove(transportJobId).catch(() => void 0);
|
|
54
|
+
}
|
|
55
|
+
async getState(queueName, transportJobId) {
|
|
56
|
+
const queue = await this.queue(queueName);
|
|
57
|
+
const job = await queue.getJob(transportJobId);
|
|
58
|
+
if (!job) return "unknown";
|
|
59
|
+
const state = await job.getState();
|
|
60
|
+
switch (state) {
|
|
61
|
+
case "waiting":
|
|
62
|
+
case "waiting-children":
|
|
63
|
+
case "prioritized":
|
|
64
|
+
return "waiting";
|
|
65
|
+
case "delayed":
|
|
66
|
+
return "delayed";
|
|
67
|
+
case "active":
|
|
68
|
+
return "active";
|
|
69
|
+
case "completed":
|
|
70
|
+
return "completed";
|
|
71
|
+
case "failed":
|
|
72
|
+
return "failed";
|
|
73
|
+
default:
|
|
74
|
+
return "unknown";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async upsertTick(opts) {
|
|
78
|
+
const queue = await this.queue(opts.queue);
|
|
79
|
+
await queue.upsertJobScheduler(
|
|
80
|
+
opts.id,
|
|
81
|
+
{ every: opts.everyMs },
|
|
82
|
+
// Delivery-shaped like every other payload, so a bound handler never has to tell a tick
|
|
83
|
+
// from a delivery — and no adapter has to inspect a payload to decide.
|
|
84
|
+
{ name: "tick", data: { jobId: opts.id, seq: 0, redrives: 0 } }
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
async bind(queueName, handler, opts) {
|
|
88
|
+
const { Worker, DelayedError, UnrecoverableError } = await bullmq();
|
|
89
|
+
const worker = new Worker(
|
|
90
|
+
queueName,
|
|
91
|
+
async (job, token, signal) => {
|
|
92
|
+
const delivery = job.data;
|
|
93
|
+
let handedBack = false;
|
|
94
|
+
const combined = new AbortController();
|
|
95
|
+
const relay = () => combined.abort();
|
|
96
|
+
signal?.addEventListener("abort", relay, { once: true });
|
|
97
|
+
this.shutdown.signal.addEventListener("abort", relay, { once: true });
|
|
98
|
+
try {
|
|
99
|
+
await handler(delivery, {
|
|
100
|
+
transportJobId: job.id ?? deliveryId(delivery),
|
|
101
|
+
attempt: job.attemptsMade + 1,
|
|
102
|
+
maxAttempts: job.opts.attempts ?? 1,
|
|
103
|
+
signal: combined.signal,
|
|
104
|
+
handBack: async (next, handBackOpts) => {
|
|
105
|
+
handedBack = true;
|
|
106
|
+
await job.updateData(next);
|
|
107
|
+
await job.moveToDelayed(Date.now() + (handBackOpts?.delayMs ?? 0), token);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error?.name === "NoFurtherAttempts") {
|
|
112
|
+
throw new UnrecoverableError(error.message);
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
} finally {
|
|
116
|
+
signal?.removeEventListener("abort", relay);
|
|
117
|
+
this.shutdown.signal.removeEventListener("abort", relay);
|
|
118
|
+
}
|
|
119
|
+
if (handedBack) throw new DelayedError();
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
connection: this.options.connection,
|
|
123
|
+
prefix: this.options.prefix,
|
|
124
|
+
concurrency: opts.concurrency,
|
|
125
|
+
// Must exceed a whole slice, or BullMQ redelivers work that is still running — which
|
|
126
|
+
// the lease then refuses, wasting the slice and inflating the stall counter.
|
|
127
|
+
lockDuration: opts.activeTimeoutMs
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
worker.on("error", () => void 0);
|
|
131
|
+
this.workers.push(worker);
|
|
132
|
+
return { queue: queueName, close: async (o) => void await worker.close(o?.timeoutMs === 0) };
|
|
133
|
+
}
|
|
134
|
+
async close(opts = {}) {
|
|
135
|
+
this.shutdown.abort();
|
|
136
|
+
const deadline = new Promise((resolve) => setTimeout(resolve, opts.timeoutMs ?? 3e4).unref?.());
|
|
137
|
+
await Promise.race([Promise.allSettled(this.workers.map((w) => w.close())).then(() => void 0), deadline]);
|
|
138
|
+
await Promise.allSettled([...this.queues.values()].map((q) => q.close()));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export {
|
|
142
|
+
BullMQTransport
|
|
143
|
+
};
|
|
144
|
+
//# sourceMappingURL=bullmq.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/transport/bullmq.ts"],
|
|
4
|
+
"sourcesContent": ["// The BullMQ adapter: the production default wherever an Open Mercato app already runs Redis.\n//\n// `bullmq` and `ioredis` are optional peers and are imported lazily, so an app that only uses\n// the pg-boss adapter never has to install them.\n//\n// Supports BullMQ 5 and 6. Both carry everything the mechanism needs \u2014 a caller-supplied job\n// id, the three-argument processor (so a real AbortSignal), `moveToDelayed` for a hand-back\n// that spends no attempt, and job schedulers for the tick. The range matches\n// `@open-mercato/queue`'s own peer range deliberately: a host on 5 must be able to install\n// this package, because two BullMQ majors against one Redis is not a thing to arrange by\n// accident.\n\nimport { deliveryId } from '../core/ids'\nimport type { Delivery, RetrySettings } from '../core/types'\nimport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n TransportAdapter,\n} from './types'\n\ntype BullMQModule = typeof import('bullmq')\ntype BullQueue = InstanceType<BullMQModule['Queue']>\ntype BullJob = InstanceType<BullMQModule['Job']>\n// Structural rather than `InstanceType<Worker>`: the concrete worker type is parameterised by\n// the processor's return type and by a backend generic that differs between BullMQ 5 and 6,\n// and nothing here needs more of it than shutdown.\ntype ClosableWorker = { close(force?: boolean): Promise<void>; on(event: 'error', listener: () => void): unknown }\n\nexport type BullMQTransportOptions = {\n /** An ioredis connection or the options to build one. Passed through untouched. */\n connection: unknown\n prefix?: string\n}\n\nlet cached: BullMQModule | null = null\nasync function bullmq(): Promise<BullMQModule> {\n if (cached) return cached\n try {\n cached = await import('bullmq')\n return cached\n } catch (error) {\n throw new Error(\n 'The bullmq transport requires the optional peer dependencies `bullmq` and `ioredis`. Install them, or use DURABLE_WORK_TRANSPORT=pgboss.',\n { cause: error },\n )\n }\n}\n\nfunction backoffFor(retry: RetrySettings) {\n return retry.backoff.type === 'fixed'\n ? { type: 'fixed' as const, delay: retry.backoff.delayMs }\n : { type: 'exponential' as const, delay: retry.backoff.delayMs }\n}\n\nexport class BullMQTransport implements TransportAdapter {\n readonly name = 'bullmq' as const\n /** BullMQ writes to Redis, so it cannot join a Postgres transaction. Callers that need a\n * job row and its delivery to commit together must enqueue after commit \u2014 and accept the\n * gap the reconciler exists to close. */\n readonly supportsTransactionalEnqueue = false\n\n private readonly queues = new Map<string, BullQueue>()\n private readonly workers: ClosableWorker[] = []\n private readonly shutdown = new AbortController()\n\n constructor(private readonly options: BullMQTransportOptions) {}\n\n private async queue(name: string): Promise<BullQueue> {\n const existing = this.queues.get(name)\n if (existing) return existing\n const { Queue } = await bullmq()\n const queue = new Queue(name, { connection: this.options.connection as never, prefix: this.options.prefix })\n this.queues.set(name, queue)\n return queue\n }\n\n async enqueue(queueName: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {\n const queue = await this.queue(queueName)\n const jobId = deliveryId(delivery)\n // A caller-supplied job id makes the broker deduplicate a re-enqueue of the same delivery.\n // It is a convenience, not the guarantee: the lease refuses a duplicate regardless, which\n // is the version of this that survives a Redis flush.\n await queue.add('delivery', delivery, {\n jobId,\n delay: opts.delayMs && opts.delayMs > 0 ? opts.delayMs : undefined,\n attempts: opts.retry.attempts,\n backoff: backoffFor(opts.retry),\n removeOnComplete: { age: 3_600, count: 1_000 },\n removeOnFail: { age: 86_400 },\n })\n return { transportJobId: jobId }\n }\n\n async remove(queueName: string, transportJobId: string): Promise<void> {\n const queue = await this.queue(queueName)\n // A job that is currently active cannot be removed; that is fine \u2014 the lease is what stops\n // it, and this is only an optimisation to keep a cancelled job from being delivered.\n await queue.remove(transportJobId).catch(() => undefined)\n }\n\n async getState(queueName: string, transportJobId: string): Promise<DeliveryState> {\n const queue = await this.queue(queueName)\n const job = await queue.getJob(transportJobId)\n if (!job) return 'unknown'\n const state = await job.getState()\n switch (state) {\n case 'waiting':\n case 'waiting-children':\n case 'prioritized':\n return 'waiting'\n case 'delayed':\n return 'delayed'\n case 'active':\n return 'active'\n case 'completed':\n return 'completed'\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 const queue = await this.queue(opts.queue)\n // A job scheduler rather than a self-re-enqueueing job: the schedule lives in Redis, so it\n // survives every worker restarting at once, and one missed tick does not end the loop.\n await queue.upsertJobScheduler(\n opts.id,\n { every: opts.everyMs },\n // Delivery-shaped like every other payload, so a bound handler never has to tell a tick\n // from a delivery \u2014 and no adapter has to inspect a payload to decide.\n { name: 'tick', data: { jobId: opts.id, seq: 0, redrives: 0 } },\n )\n }\n\n async bind(queueName: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {\n const { Worker, DelayedError, UnrecoverableError } = await bullmq()\n\n const worker = new Worker(\n queueName,\n async (job: BullJob, token?: string, signal?: AbortSignal) => {\n const delivery = job.data as Delivery\n let handedBack = false\n\n // BullMQ's signal aborts when the job's lock is lost; ours aborts on shutdown. A slice\n // needs to stop for either reason, so it watches both.\n const combined = new AbortController()\n const relay = () => combined.abort()\n 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 ?? deliveryId(delivery),\n attempt: job.attemptsMade + 1,\n maxAttempts: job.opts.attempts ?? 1,\n signal: combined.signal,\n handBack: async (next, handBackOpts) => {\n handedBack = true\n // Native hand-back: the job keeps its id and its attempt count, and only its\n // payload moves on. That is what makes yielding free \u2014 a re-enqueue would\n // start a new job, and a retry would spend an attempt.\n await job.updateData(next as unknown as never)\n await job.moveToDelayed(Date.now() + (handBackOpts?.delayMs ?? 0), token)\n },\n })\n } catch (error) {\n // BullMQ retries on any throw, so \"there is nothing left to attempt\" has to be said\n // in its own vocabulary. Without this translation a job that has already reached a\n // terminal state is redelivered for every remaining attempt \u2014 each one refused by\n // the claim, each one a wasted slice and a misleading log line.\n if ((error as { name?: string })?.name === 'NoFurtherAttempts') {\n throw new UnrecoverableError((error as Error).message)\n }\n throw error\n } finally {\n signal?.removeEventListener('abort', relay)\n this.shutdown.signal.removeEventListener('abort', relay)\n }\n\n // BullMQ requires this to propagate out of the processor for the move to take effect.\n if (handedBack) throw new DelayedError()\n },\n {\n connection: this.options.connection as never,\n prefix: this.options.prefix,\n concurrency: opts.concurrency,\n // Must exceed a whole slice, or BullMQ redelivers work that is still running \u2014 which\n // the lease then refuses, wasting the slice and inflating the stall counter.\n lockDuration: opts.activeTimeoutMs,\n },\n )\n\n // Errors here are the broker's, not a job's; swallowing them would make a broken Redis\n // look like an idle queue.\n worker.on('error', () => undefined)\n this.workers.push(worker as unknown as ClosableWorker)\n return { queue: queueName, close: async (o) => void (await worker.close(o?.timeoutMs === 0)) }\n }\n\n async close(opts: { timeoutMs?: number } = {}): Promise<void> {\n // Abort first: in-flight slices see the signal and hand back at their next boundary,\n // rather than being cut off wherever they happen to be.\n this.shutdown.abort()\n const deadline = new Promise<void>((resolve) => setTimeout(resolve, opts.timeoutMs ?? 30_000).unref?.())\n await Promise.race([Promise.allSettled(this.workers.map((w) => w.close())).then(() => undefined), deadline])\n await Promise.allSettled([...this.queues.values()].map((q) => q.close()))\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const RETRY = { attempts: 3, backoff: { type: "fixed", delayMs: 200, maxDelayMs: 200 } };
|
|
2
|
+
const ONCE = { attempts: 1, backoff: { type: "fixed", delayMs: 0, maxDelayMs: 0 } };
|
|
3
|
+
const delivery = (jobId, seq = 0, redrives = 0) => ({ jobId, seq, redrives });
|
|
4
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
async function until(predicate, timeoutMs = 15e3) {
|
|
6
|
+
const deadline = Date.now() + timeoutMs;
|
|
7
|
+
while (Date.now() < deadline) {
|
|
8
|
+
if (predicate()) return;
|
|
9
|
+
await sleep(25);
|
|
10
|
+
}
|
|
11
|
+
throw new Error("condition was never met");
|
|
12
|
+
}
|
|
13
|
+
function transportConformance(hooks) {
|
|
14
|
+
const { it, expect, make } = hooks;
|
|
15
|
+
it("delivers an enqueued job to a bound handler", async () => {
|
|
16
|
+
const { transport, queue, close } = await make();
|
|
17
|
+
try {
|
|
18
|
+
const seen = [];
|
|
19
|
+
await transport.bind(queue, async (d) => void seen.push(d), { concurrency: 1, activeTimeoutMs: 3e4 });
|
|
20
|
+
await transport.enqueue(queue, delivery("job-1"), { retry: ONCE });
|
|
21
|
+
await until(() => seen.length === 1);
|
|
22
|
+
expect(seen[0]).toEqual(delivery("job-1"));
|
|
23
|
+
} finally {
|
|
24
|
+
await close();
|
|
25
|
+
}
|
|
26
|
+
}, 6e4);
|
|
27
|
+
it("honours a delay before making the delivery available", async () => {
|
|
28
|
+
const { transport, queue, close } = await make();
|
|
29
|
+
try {
|
|
30
|
+
let firstSeenAt = 0;
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
await transport.bind(queue, async () => void (firstSeenAt = Date.now()), { concurrency: 1, activeTimeoutMs: 3e4 });
|
|
33
|
+
await transport.enqueue(queue, delivery("job-delay"), { retry: ONCE, delayMs: 1500 });
|
|
34
|
+
await until(() => firstSeenAt > 0);
|
|
35
|
+
expect(firstSeenAt - start).toBeGreaterThanOrEqual(1e3);
|
|
36
|
+
} finally {
|
|
37
|
+
await close();
|
|
38
|
+
}
|
|
39
|
+
}, 6e4);
|
|
40
|
+
it("retries a handler that throws, up to the attempt limit", async () => {
|
|
41
|
+
const { transport, queue, close } = await make();
|
|
42
|
+
try {
|
|
43
|
+
let attempts = 0;
|
|
44
|
+
await transport.bind(
|
|
45
|
+
queue,
|
|
46
|
+
async () => {
|
|
47
|
+
attempts += 1;
|
|
48
|
+
throw new Error("nope");
|
|
49
|
+
},
|
|
50
|
+
{ concurrency: 1, activeTimeoutMs: 3e4 }
|
|
51
|
+
);
|
|
52
|
+
await transport.enqueue(queue, delivery("job-retry"), { retry: RETRY });
|
|
53
|
+
await until(() => attempts >= 2, 2e4);
|
|
54
|
+
expect(attempts).toBeGreaterThanOrEqual(2);
|
|
55
|
+
} finally {
|
|
56
|
+
await close();
|
|
57
|
+
}
|
|
58
|
+
}, 6e4);
|
|
59
|
+
it("stops retrying when the handler signals there is nothing left to attempt", async () => {
|
|
60
|
+
const { transport, queue, close } = await make();
|
|
61
|
+
try {
|
|
62
|
+
let attempts = 0;
|
|
63
|
+
await transport.bind(
|
|
64
|
+
queue,
|
|
65
|
+
async () => {
|
|
66
|
+
attempts += 1;
|
|
67
|
+
const error = new Error("settled");
|
|
68
|
+
error.name = "NoFurtherAttempts";
|
|
69
|
+
throw error;
|
|
70
|
+
},
|
|
71
|
+
{ concurrency: 1, activeTimeoutMs: 3e4 }
|
|
72
|
+
);
|
|
73
|
+
await transport.enqueue(queue, delivery("job-final"), { retry: RETRY });
|
|
74
|
+
await until(() => attempts >= 1);
|
|
75
|
+
await sleep(1500);
|
|
76
|
+
expect(attempts).toBe(1);
|
|
77
|
+
} finally {
|
|
78
|
+
await close();
|
|
79
|
+
}
|
|
80
|
+
}, 6e4);
|
|
81
|
+
it("hands work back as a new delivery without spending an attempt", async () => {
|
|
82
|
+
const { transport, queue, close } = await make();
|
|
83
|
+
try {
|
|
84
|
+
const seen = [];
|
|
85
|
+
const attemptsSeen = [];
|
|
86
|
+
await transport.bind(
|
|
87
|
+
queue,
|
|
88
|
+
async (d, ctx) => {
|
|
89
|
+
seen.push(d);
|
|
90
|
+
attemptsSeen.push(ctx.attempt);
|
|
91
|
+
if (d.seq < 2) await ctx.handBack({ ...d, seq: d.seq + 1 });
|
|
92
|
+
},
|
|
93
|
+
{ concurrency: 1, activeTimeoutMs: 3e4 }
|
|
94
|
+
);
|
|
95
|
+
await transport.enqueue(queue, delivery("job-yield"), { retry: RETRY });
|
|
96
|
+
await until(() => seen.length === 3, 2e4);
|
|
97
|
+
expect(seen.map((d) => d.seq)).toEqual([0, 1, 2]);
|
|
98
|
+
expect(Math.max(...attemptsSeen)).toBe(1);
|
|
99
|
+
} finally {
|
|
100
|
+
await close();
|
|
101
|
+
}
|
|
102
|
+
}, 6e4);
|
|
103
|
+
it("collapses a re-enqueue of the same delivery instead of delivering it twice", async () => {
|
|
104
|
+
const { transport, queue, close } = await make();
|
|
105
|
+
try {
|
|
106
|
+
const seen = [];
|
|
107
|
+
await transport.bind(queue, async (d2) => void seen.push(d2), { concurrency: 1, activeTimeoutMs: 3e4 });
|
|
108
|
+
const d = delivery("job-dedupe");
|
|
109
|
+
await Promise.all([
|
|
110
|
+
transport.enqueue(queue, d, { retry: ONCE, delayMs: 800 }),
|
|
111
|
+
transport.enqueue(queue, d, { retry: ONCE, delayMs: 800 })
|
|
112
|
+
]);
|
|
113
|
+
await until(() => seen.length >= 1);
|
|
114
|
+
await sleep(1e3);
|
|
115
|
+
expect(seen.length).toBe(1);
|
|
116
|
+
} finally {
|
|
117
|
+
await close();
|
|
118
|
+
}
|
|
119
|
+
}, 6e4);
|
|
120
|
+
it("reports a delivery it does not hold as unknown, and removes one it does", async () => {
|
|
121
|
+
const { transport, queue, close } = await make();
|
|
122
|
+
try {
|
|
123
|
+
expect(await transport.getState(queue, "nothing-here")).toBe("unknown");
|
|
124
|
+
const { transportJobId } = await transport.enqueue(queue, delivery("job-remove"), { retry: ONCE, delayMs: 6e4 });
|
|
125
|
+
await transport.remove(queue, transportJobId);
|
|
126
|
+
expect(await transport.getState(queue, transportJobId)).toBe("unknown");
|
|
127
|
+
} finally {
|
|
128
|
+
await close();
|
|
129
|
+
}
|
|
130
|
+
}, 6e4);
|
|
131
|
+
it("fires a tick repeatedly", async () => {
|
|
132
|
+
const { transport, queue, close } = await make();
|
|
133
|
+
try {
|
|
134
|
+
let ticks = 0;
|
|
135
|
+
await transport.bind(queue, async () => void (ticks += 1), { concurrency: 1, activeTimeoutMs: 3e4 });
|
|
136
|
+
await transport.upsertTick({ id: "conformance-tick", queue, everyMs: 1e3 });
|
|
137
|
+
await until(() => ticks >= 2, 2e4);
|
|
138
|
+
expect(ticks).toBeGreaterThanOrEqual(2);
|
|
139
|
+
} finally {
|
|
140
|
+
await close();
|
|
141
|
+
}
|
|
142
|
+
}, 6e4);
|
|
143
|
+
it("aborts in-flight work on close, and returns within its timeout", async () => {
|
|
144
|
+
const { transport, queue, close } = await make();
|
|
145
|
+
try {
|
|
146
|
+
let aborted = false;
|
|
147
|
+
let started = false;
|
|
148
|
+
await transport.bind(
|
|
149
|
+
queue,
|
|
150
|
+
async (_d, ctx) => {
|
|
151
|
+
started = true;
|
|
152
|
+
await new Promise((resolve) => {
|
|
153
|
+
if (ctx.signal.aborted) return resolve();
|
|
154
|
+
ctx.signal.addEventListener("abort", () => {
|
|
155
|
+
aborted = true;
|
|
156
|
+
resolve();
|
|
157
|
+
});
|
|
158
|
+
setTimeout(resolve, 6e4).unref?.();
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
{ concurrency: 1, activeTimeoutMs: 12e4 }
|
|
162
|
+
);
|
|
163
|
+
await transport.enqueue(queue, delivery("job-drain"), { retry: ONCE });
|
|
164
|
+
await until(() => started);
|
|
165
|
+
const startedAt = Date.now();
|
|
166
|
+
await transport.close({ timeoutMs: 1e4 });
|
|
167
|
+
expect(Date.now() - startedAt).toBeLessThan(15e3);
|
|
168
|
+
expect(aborted).toBe(true);
|
|
169
|
+
} finally {
|
|
170
|
+
await close();
|
|
171
|
+
}
|
|
172
|
+
}, 9e4);
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
transportConformance
|
|
176
|
+
};
|
|
177
|
+
//# sourceMappingURL=conformance.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/transport/conformance.ts"],
|
|
4
|
+
"sourcesContent": ["// One suite, three adapters.\n//\n// Shipped from the package rather than written in the harness so that anyone adding a fourth\n// adapter runs exactly the checks the existing three pass. A scenario that passes on one\n// transport and not another is a bug, not a caveat \u2014 and the only way to keep that true is for\n// there to be a single definition of what passing means.\n//\n// Framework-agnostic: the caller supplies `it` and `expect`, so the same suite runs under\n// vitest here and under whatever a downstream host uses.\n\nimport type { Delivery, RetrySettings } from '../core/types'\nimport type { TransportAdapter } from './types'\n\nexport type ConformanceHooks = {\n it: (name: string, fn: () => Promise<void>, timeoutMs?: number) => void\n expect: (actual: unknown) => {\n toBe(expected: unknown): void\n toEqual(expected: unknown): void\n toBeGreaterThan(expected: number): void\n toBeGreaterThanOrEqual(expected: number): void\n toBeLessThan(expected: number): void\n }\n /** A fresh adapter and a queue name nothing else uses. */\n make: () => Promise<{ transport: TransportAdapter; queue: string; close: () => Promise<void> }>\n}\n\nconst RETRY: RetrySettings = { attempts: 3, backoff: { type: 'fixed', delayMs: 200, maxDelayMs: 200 } }\nconst ONCE: RetrySettings = { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } }\n\nconst delivery = (jobId: string, seq = 0, redrives = 0): Delivery => ({ jobId, seq, redrives })\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\n/** Waits for a condition rather than for a duration, so a slow CI runner makes the suite\n * slower rather than flaky. */\nasync function until(predicate: () => boolean, timeoutMs = 15_000): Promise<void> {\n const deadline = Date.now() + timeoutMs\n while (Date.now() < deadline) {\n if (predicate()) return\n await sleep(25)\n }\n throw new Error('condition was never met')\n}\n\nexport function transportConformance(hooks: ConformanceHooks): void {\n const { it, expect, make } = hooks\n\n it('delivers an enqueued job to a bound handler', async () => {\n const { transport, queue, close } = await make()\n try {\n const seen: Delivery[] = []\n await transport.bind(queue, async (d) => void seen.push(d), { concurrency: 1, activeTimeoutMs: 30_000 })\n await transport.enqueue(queue, delivery('job-1'), { retry: ONCE })\n await until(() => seen.length === 1)\n expect(seen[0]).toEqual(delivery('job-1'))\n } finally {\n await close()\n }\n }, 60_000)\n\n it('honours a delay before making the delivery available', async () => {\n const { transport, queue, close } = await make()\n try {\n let firstSeenAt = 0\n const start = Date.now()\n await transport.bind(queue, async () => void (firstSeenAt = Date.now()), { concurrency: 1, activeTimeoutMs: 30_000 })\n await transport.enqueue(queue, delivery('job-delay'), { retry: ONCE, delayMs: 1_500 })\n await until(() => firstSeenAt > 0)\n expect(firstSeenAt - start).toBeGreaterThanOrEqual(1_000)\n } finally {\n await close()\n }\n }, 60_000)\n\n it('retries a handler that throws, up to the attempt limit', async () => {\n const { transport, queue, close } = await make()\n try {\n let attempts = 0\n await transport.bind(\n queue,\n async () => {\n attempts += 1\n throw new Error('nope')\n },\n { concurrency: 1, activeTimeoutMs: 30_000 },\n )\n await transport.enqueue(queue, delivery('job-retry'), { retry: RETRY })\n await until(() => attempts >= 2, 20_000)\n expect(attempts).toBeGreaterThanOrEqual(2)\n } finally {\n await close()\n }\n }, 60_000)\n\n it('stops retrying when the handler signals there is nothing left to attempt', async () => {\n const { transport, queue, close } = await make()\n try {\n let attempts = 0\n await transport.bind(\n queue,\n async () => {\n attempts += 1\n const error = new Error('settled')\n error.name = 'NoFurtherAttempts'\n throw error\n },\n { concurrency: 1, activeTimeoutMs: 30_000 },\n )\n await transport.enqueue(queue, delivery('job-final'), { retry: RETRY })\n await until(() => attempts >= 1)\n await sleep(1_500) // long enough for a retry to have landed if one were coming\n expect(attempts).toBe(1)\n } finally {\n await close()\n }\n }, 60_000)\n\n it('hands work back as a new delivery without spending an attempt', async () => {\n const { transport, queue, close } = await make()\n try {\n const seen: Delivery[] = []\n const attemptsSeen: number[] = []\n await transport.bind(\n queue,\n async (d, ctx) => {\n seen.push(d)\n attemptsSeen.push(ctx.attempt)\n if (d.seq < 2) await ctx.handBack({ ...d, seq: d.seq + 1 })\n },\n { concurrency: 1, activeTimeoutMs: 30_000 },\n )\n await transport.enqueue(queue, delivery('job-yield'), { retry: RETRY })\n\n await until(() => seen.length === 3, 20_000)\n expect(seen.map((d) => d.seq)).toEqual([0, 1, 2])\n // The point of a hand-back: three slices, none of them a retry.\n expect(Math.max(...attemptsSeen)).toBe(1)\n } finally {\n await close()\n }\n }, 60_000)\n\n it('collapses a re-enqueue of the same delivery instead of delivering it twice', async () => {\n const { transport, queue, close } = await make()\n try {\n const seen: Delivery[] = []\n await transport.bind(queue, async (d) => void seen.push(d), { concurrency: 1, activeTimeoutMs: 30_000 })\n const d = delivery('job-dedupe')\n // Enqueued before binding drains it, so both land while the job is still waiting.\n await Promise.all([\n transport.enqueue(queue, d, { retry: ONCE, delayMs: 800 }),\n transport.enqueue(queue, d, { retry: ONCE, delayMs: 800 }),\n ])\n await until(() => seen.length >= 1)\n await sleep(1_000)\n expect(seen.length).toBe(1)\n } finally {\n await close()\n }\n }, 60_000)\n\n it('reports a delivery it does not hold as unknown, and removes one it does', async () => {\n const { transport, queue, close } = await make()\n try {\n expect(await transport.getState(queue, 'nothing-here')).toBe('unknown')\n const { transportJobId } = await transport.enqueue(queue, delivery('job-remove'), { retry: ONCE, delayMs: 60_000 })\n await transport.remove(queue, transportJobId)\n expect(await transport.getState(queue, transportJobId)).toBe('unknown')\n } finally {\n await close()\n }\n }, 60_000)\n\n it('fires a tick repeatedly', async () => {\n const { transport, queue, close } = await make()\n try {\n let ticks = 0\n await transport.bind(queue, async () => void (ticks += 1), { concurrency: 1, activeTimeoutMs: 30_000 })\n await transport.upsertTick({ id: 'conformance-tick', queue, everyMs: 1_000 })\n await until(() => ticks >= 2, 20_000)\n expect(ticks).toBeGreaterThanOrEqual(2)\n } finally {\n await close()\n }\n }, 60_000)\n\n it('aborts in-flight work on close, and returns within its timeout', async () => {\n const { transport, queue, close } = await make()\n try {\n let aborted = false\n let started = false\n await transport.bind(\n queue,\n async (_d, ctx) => {\n started = true\n await new Promise<void>((resolve) => {\n if (ctx.signal.aborted) return resolve()\n ctx.signal.addEventListener('abort', () => {\n aborted = true\n resolve()\n })\n // Long enough that only the abort can end it inside the close timeout.\n setTimeout(resolve, 60_000).unref?.()\n })\n },\n { concurrency: 1, activeTimeoutMs: 120_000 },\n )\n await transport.enqueue(queue, delivery('job-drain'), { retry: ONCE })\n await until(() => started)\n\n const startedAt = Date.now()\n await transport.close({ timeoutMs: 10_000 })\n expect(Date.now() - startedAt).toBeLessThan(15_000)\n expect(aborted).toBe(true)\n } finally {\n await close()\n }\n }, 90_000)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["d"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { deliveryId } from "../core/ids.js";
|
|
2
|
+
function backoffFor(retry, attempt) {
|
|
3
|
+
const { type, delayMs, maxDelayMs } = retry.backoff;
|
|
4
|
+
const raw = type === "fixed" ? delayMs : delayMs * 2 ** Math.max(0, attempt - 1);
|
|
5
|
+
return Math.min(raw, maxDelayMs);
|
|
6
|
+
}
|
|
7
|
+
class MemoryTransport {
|
|
8
|
+
constructor(faults = {}) {
|
|
9
|
+
this.faults = faults;
|
|
10
|
+
this.name = "memory";
|
|
11
|
+
this.supportsTransactionalEnqueue = false;
|
|
12
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
13
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
14
|
+
this.ticks = /* @__PURE__ */ new Map();
|
|
15
|
+
this.inFlight = /* @__PURE__ */ new Set();
|
|
16
|
+
this.abort = new AbortController();
|
|
17
|
+
this.pump = null;
|
|
18
|
+
this.closed = false;
|
|
19
|
+
}
|
|
20
|
+
async enqueue(queue, delivery, opts) {
|
|
21
|
+
const id = deliveryId(delivery);
|
|
22
|
+
if (this.faults.dropEnqueue?.(delivery)) return { transportJobId: id };
|
|
23
|
+
this.entries.set(id, {
|
|
24
|
+
id,
|
|
25
|
+
queue,
|
|
26
|
+
delivery,
|
|
27
|
+
availableAt: Date.now() + (opts.delayMs ?? 0),
|
|
28
|
+
attempt: 0,
|
|
29
|
+
retry: opts.retry,
|
|
30
|
+
state: (opts.delayMs ?? 0) > 0 ? "delayed" : "waiting"
|
|
31
|
+
});
|
|
32
|
+
this.start();
|
|
33
|
+
return { transportJobId: id };
|
|
34
|
+
}
|
|
35
|
+
async remove(_queue, transportJobId) {
|
|
36
|
+
this.entries.delete(transportJobId);
|
|
37
|
+
}
|
|
38
|
+
async getState(_queue, transportJobId) {
|
|
39
|
+
return this.entries.get(transportJobId)?.state ?? "unknown";
|
|
40
|
+
}
|
|
41
|
+
async upsertTick(opts) {
|
|
42
|
+
this.ticks.get(opts.id)?.timer.unref?.();
|
|
43
|
+
clearInterval(this.ticks.get(opts.id)?.timer);
|
|
44
|
+
const timer = setInterval(() => {
|
|
45
|
+
void this.enqueue(opts.queue, { jobId: opts.id, seq: 0, redrives: 0 }, { retry: TICK_RETRY });
|
|
46
|
+
}, opts.everyMs);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
this.ticks.set(opts.id, { queue: opts.queue, everyMs: opts.everyMs, timer });
|
|
49
|
+
}
|
|
50
|
+
async bind(queue, handler, opts) {
|
|
51
|
+
this.handlers.set(queue, { handler, opts });
|
|
52
|
+
this.start();
|
|
53
|
+
return {
|
|
54
|
+
queue,
|
|
55
|
+
close: async () => {
|
|
56
|
+
this.handlers.delete(queue);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async close(opts = {}) {
|
|
61
|
+
this.closed = true;
|
|
62
|
+
this.abort.abort();
|
|
63
|
+
for (const tick of this.ticks.values()) clearInterval(tick.timer);
|
|
64
|
+
this.ticks.clear();
|
|
65
|
+
if (this.pump) clearInterval(this.pump);
|
|
66
|
+
this.pump = null;
|
|
67
|
+
const deadline = new Promise((resolve) => setTimeout(resolve, opts.timeoutMs ?? 3e4).unref?.());
|
|
68
|
+
await Promise.race([Promise.allSettled([...this.inFlight]).then(() => void 0), deadline]);
|
|
69
|
+
}
|
|
70
|
+
/** Test hook: how many deliveries the broker still holds. */
|
|
71
|
+
size() {
|
|
72
|
+
return this.entries.size;
|
|
73
|
+
}
|
|
74
|
+
start() {
|
|
75
|
+
if (this.pump || this.closed) return;
|
|
76
|
+
this.pump = setInterval(() => void this.drain(), 5);
|
|
77
|
+
this.pump.unref?.();
|
|
78
|
+
}
|
|
79
|
+
async drain() {
|
|
80
|
+
if (this.closed) return;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
for (const entry of [...this.entries.values()]) {
|
|
83
|
+
if (entry.state === "active" || entry.availableAt > now) continue;
|
|
84
|
+
const bound = this.handlers.get(entry.queue);
|
|
85
|
+
if (!bound) continue;
|
|
86
|
+
if (this.faults.stall?.(entry.delivery)) continue;
|
|
87
|
+
const running = [...this.inFlight].length;
|
|
88
|
+
if (running >= bound.opts.concurrency) return;
|
|
89
|
+
entry.state = "active";
|
|
90
|
+
entry.attempt += 1;
|
|
91
|
+
const promise = this.run(entry, bound.handler, bound.opts).finally(() => this.inFlight.delete(promise));
|
|
92
|
+
this.inFlight.add(promise);
|
|
93
|
+
if (this.faults.duplicateDeliveries) {
|
|
94
|
+
const twin = this.run({ ...entry }, bound.handler, bound.opts).finally(() => this.inFlight.delete(twin));
|
|
95
|
+
this.inFlight.add(twin);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async run(entry, handler, opts) {
|
|
100
|
+
let handedBack = false;
|
|
101
|
+
try {
|
|
102
|
+
await handler(entry.delivery, {
|
|
103
|
+
transportJobId: entry.id,
|
|
104
|
+
attempt: entry.attempt,
|
|
105
|
+
maxAttempts: entry.retry.attempts,
|
|
106
|
+
signal: this.abort.signal,
|
|
107
|
+
handBack: async (next, handBackOpts) => {
|
|
108
|
+
handedBack = true;
|
|
109
|
+
this.entries.delete(entry.id);
|
|
110
|
+
await this.enqueue(entry.queue, next, { retry: entry.retry, delayMs: handBackOpts?.delayMs });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
if (!handedBack) {
|
|
114
|
+
entry.state = "completed";
|
|
115
|
+
this.entries.delete(entry.id);
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error?.name === "NoFurtherAttempts") {
|
|
119
|
+
entry.state = "failed";
|
|
120
|
+
this.entries.delete(entry.id);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (entry.attempt >= entry.retry.attempts) {
|
|
124
|
+
entry.state = "failed";
|
|
125
|
+
this.entries.delete(entry.id);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
entry.state = "delayed";
|
|
129
|
+
entry.availableAt = Date.now() + backoffFor(entry.retry, entry.attempt);
|
|
130
|
+
} finally {
|
|
131
|
+
void opts;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const TICK_RETRY = { attempts: 1, backoff: { type: "fixed", delayMs: 0, maxDelayMs: 0 } };
|
|
136
|
+
export {
|
|
137
|
+
MemoryTransport
|
|
138
|
+
};
|
|
139
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/transport/memory.ts"],
|
|
4
|
+
"sourcesContent": ["// An in-process transport, for unit tests and fault injection.\n//\n// Not a toy: it implements the same delivery semantics as the real adapters \u2014 attempts,\n// backoff, delayed availability, hand-back that spends no attempt, a real abort signal on\n// close \u2014 and it runs the same conformance suite. Its purpose is to make failures *reachable*\n// (duplicate a delivery, stall one, drop one) that a real broker only produces by accident.\n//\n// Explicitly not for production: nothing survives the process.\n\nimport { deliveryId } from '../core/ids'\nimport type { Delivery, RetrySettings } from '../core/types'\nimport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n TransportAdapter,\n} from './types'\n\ntype Entry = {\n id: string\n queue: string\n delivery: Delivery\n availableAt: number\n attempt: number\n retry: RetrySettings\n state: DeliveryState\n}\n\nfunction backoffFor(retry: RetrySettings, attempt: number): number {\n const { type, delayMs, maxDelayMs } = retry.backoff\n const raw = type === 'fixed' ? delayMs : delayMs * 2 ** Math.max(0, attempt - 1)\n return Math.min(raw, maxDelayMs)\n}\n\nexport type MemoryFaults = {\n /** Deliver each accepted delivery twice. The lease must refuse the second. */\n duplicateDeliveries?: boolean\n /** Swallow enqueues matching this predicate, simulating a broker that lost the message.\n * The reconciler is what must notice. */\n dropEnqueue?: (delivery: Delivery) => boolean\n /** Hold a delivery indefinitely instead of running it. */\n stall?: (delivery: Delivery) => boolean\n}\n\nexport class MemoryTransport implements TransportAdapter {\n readonly name = 'memory' as const\n readonly supportsTransactionalEnqueue = false\n\n private readonly entries = new Map<string, Entry>()\n private readonly handlers = new Map<string, { handler: DeliveryHandler; opts: BindOptions }>()\n private readonly ticks = new Map<string, { queue: string; everyMs: number; timer: NodeJS.Timeout }>()\n private readonly inFlight = new Set<Promise<void>>()\n private readonly abort = new AbortController()\n private pump: NodeJS.Timeout | null = null\n private closed = false\n\n constructor(private readonly faults: MemoryFaults = {}) {}\n\n async enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {\n const id = deliveryId(delivery)\n if (this.faults.dropEnqueue?.(delivery)) return { transportJobId: id }\n // Same id, same delivery: the broker deduplicates, which is what a caller-supplied job id\n // buys. The database refuses a duplicate anyway; this only saves the wasted claim.\n this.entries.set(id, {\n id,\n queue,\n delivery,\n availableAt: Date.now() + (opts.delayMs ?? 0),\n attempt: 0,\n retry: opts.retry,\n state: (opts.delayMs ?? 0) > 0 ? 'delayed' : 'waiting',\n })\n this.start()\n return { transportJobId: id }\n }\n\n async remove(_queue: string, transportJobId: string): Promise<void> {\n this.entries.delete(transportJobId)\n }\n\n async getState(_queue: string, transportJobId: string): Promise<DeliveryState> {\n return this.entries.get(transportJobId)?.state ?? 'unknown'\n }\n\n async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {\n this.ticks.get(opts.id)?.timer.unref?.()\n clearInterval(this.ticks.get(opts.id)?.timer)\n const timer = setInterval(() => {\n void this.enqueue(opts.queue, { jobId: opts.id, seq: 0, redrives: 0 }, { retry: TICK_RETRY })\n }, opts.everyMs)\n timer.unref?.()\n this.ticks.set(opts.id, { queue: opts.queue, everyMs: opts.everyMs, timer })\n }\n\n async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {\n this.handlers.set(queue, { handler, opts })\n this.start()\n return {\n queue,\n close: async () => {\n this.handlers.delete(queue)\n },\n }\n }\n\n async close(opts: { timeoutMs?: number } = {}): Promise<void> {\n this.closed = true\n this.abort.abort()\n for (const tick of this.ticks.values()) clearInterval(tick.timer)\n this.ticks.clear()\n if (this.pump) clearInterval(this.pump)\n this.pump = null\n // Bounded: a handler that ignores its abort signal must not be able to hang shutdown\n // forever. Dropping the wait is the honest outcome \u2014 the lease still protects the row.\n const deadline = new Promise<void>((resolve) => setTimeout(resolve, opts.timeoutMs ?? 30_000).unref?.())\n await Promise.race([Promise.allSettled([...this.inFlight]).then(() => undefined), deadline])\n }\n\n /** Test hook: how many deliveries the broker still holds. */\n size(): number {\n return this.entries.size\n }\n\n private start(): void {\n if (this.pump || this.closed) return\n this.pump = setInterval(() => void this.drain(), 5)\n this.pump.unref?.()\n }\n\n private async drain(): Promise<void> {\n if (this.closed) return\n const now = Date.now()\n for (const entry of [...this.entries.values()]) {\n if (entry.state === 'active' || entry.availableAt > now) continue\n const bound = this.handlers.get(entry.queue)\n if (!bound) continue\n if (this.faults.stall?.(entry.delivery)) continue\n const running = [...this.inFlight].length\n if (running >= bound.opts.concurrency) return\n entry.state = 'active'\n entry.attempt += 1\n const promise = this.run(entry, bound.handler, bound.opts).finally(() => this.inFlight.delete(promise))\n this.inFlight.add(promise)\n if (this.faults.duplicateDeliveries) {\n const twin = this.run({ ...entry }, bound.handler, bound.opts).finally(() => this.inFlight.delete(twin))\n this.inFlight.add(twin)\n }\n }\n }\n\n private async run(entry: Entry, handler: DeliveryHandler, opts: BindOptions): Promise<void> {\n let handedBack = false\n try {\n await handler(entry.delivery, {\n transportJobId: entry.id,\n attempt: entry.attempt,\n maxAttempts: entry.retry.attempts,\n signal: this.abort.signal,\n handBack: async (next, handBackOpts) => {\n handedBack = true\n this.entries.delete(entry.id)\n await this.enqueue(entry.queue, next, { retry: entry.retry, delayMs: handBackOpts?.delayMs })\n },\n })\n if (!handedBack) {\n entry.state = 'completed'\n this.entries.delete(entry.id)\n }\n } catch (error) {\n if ((error as { name?: string })?.name === 'NoFurtherAttempts') {\n entry.state = 'failed'\n this.entries.delete(entry.id)\n return\n }\n if (entry.attempt >= entry.retry.attempts) {\n entry.state = 'failed'\n this.entries.delete(entry.id)\n return\n }\n entry.state = 'delayed'\n entry.availableAt = Date.now() + backoffFor(entry.retry, entry.attempt)\n } finally {\n void opts\n }\n }\n}\n\n/** The tick is a heartbeat, not work: one attempt, no backoff to reason about. */\nconst TICK_RETRY: RetrySettings = { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|