@ultimat3/jobs 1.2.0 → 2.0.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/src/errors.ts CHANGED
@@ -10,15 +10,27 @@ export const JOB_OWNED_ERROR_CODES = [
10
10
  'X_JOB_MAX_ATTEMPTS',
11
11
  'X_DRIVER_UNAVAILABLE',
12
12
  'X_IDEMPOTENCY_REQUIRED',
13
+ 'X_JOB_TENANT_REQUIRED',
14
+ 'X_JOB_CONCURRENCY_UNENFORCEABLE',
15
+ 'X_JOB_LEASE_LOST',
16
+ 'X_JOB_SLOT_LOST',
17
+ 'X_JOB_NOT_CANCELLABLE',
13
18
  'X_OUTBOX_NO_TX',
19
+ 'X_BACKFILL_PENDING',
20
+ 'X_BACKFILL_APPLIED',
21
+ 'X_BACKFILL_ENVIRONMENT',
22
+ 'X_BACKFILL_MIGRATION_PENDING',
23
+ 'X_BACKFILL_RUNNING',
24
+ 'X_BACKFILL_STALLED',
25
+ 'X_BACKFILL_UNKNOWN',
14
26
  ] as const;
15
27
 
16
28
  /**
17
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `JobsNotImplementedError` below throws it; jobs keeps
18
- * no title for it, because the copy this file used to hold was a second title that nothing would
19
- * have failed on once core's changed.
29
+ * `X_NOT_IMPLEMENTED` and `X_ABORTED` are `@ultimat3/core`'s. `JobsNotImplementedError` and
30
+ * `JobAbortedError` below throw them; jobs keeps no title for either, because the copy this file
31
+ * used to hold was a second title that nothing would have failed on once core's changed.
20
32
  */
21
- export const JOB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
33
+ export const JOB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ABORTED'] as const;
22
34
 
23
35
  /** Every code jobs can throw: the ones it owns plus the one it borrows. */
24
36
  export const JOB_ERROR_CODES = [...JOB_OWNED_ERROR_CODES, ...JOB_BORROWED_ERROR_CODES] as const;
@@ -33,7 +45,19 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
33
45
  X_JOB_MAX_ATTEMPTS: 'the job exhausted its retries',
34
46
  X_DRIVER_UNAVAILABLE: 'the queue driver is unreachable',
35
47
  X_IDEMPOTENCY_REQUIRED: 'the job has no idempotencyKey',
48
+ X_JOB_TENANT_REQUIRED: 'the job declares no tenant',
49
+ X_JOB_CONCURRENCY_UNENFORCEABLE: 'job.concurrency is declared and cannot be enforced',
50
+ X_JOB_LEASE_LOST: 'the queue took this job back mid-run',
51
+ X_JOB_SLOT_LOST: 'the fleet concurrency slot was taken by another worker',
52
+ X_JOB_NOT_CANCELLABLE: 'the job cannot be cancelled',
36
53
  X_OUTBOX_NO_TX: 'enqueue outside a transaction',
54
+ X_BACKFILL_PENDING: 'a declared backfill has never completed',
55
+ X_BACKFILL_APPLIED: 'the ledger already holds a completed pass',
56
+ X_BACKFILL_ENVIRONMENT: 'the backfill is not declared for this environment',
57
+ X_BACKFILL_MIGRATION_PENDING: 'the migration this backfill requires is not applied',
58
+ X_BACKFILL_RUNNING: 'a pass under this name is already live',
59
+ X_BACKFILL_STALLED: 'the sweep ended with rows its own count still matches',
60
+ X_BACKFILL_UNKNOWN: 'no declaration carries this backfill name',
37
61
  };
38
62
 
39
63
  // One unconditional call, so a second package claiming one of jobs' codes throws
@@ -103,6 +127,30 @@ export class JobTimeoutError extends UltimateError {
103
127
  }
104
128
  }
105
129
 
130
+ /**
131
+ * This attempt was cancelled — its deadline passed, or the caller went away — and something in it
132
+ * tried to keep going. The run belongs to whoever claims it next, so a step write from here would
133
+ * land on their history.
134
+ *
135
+ * Core's `X_ABORTED` rather than a code of jobs' own: the framework already means exactly one
136
+ * thing by "the signal fired, stop work", and a second name for it would make an agent ask which
137
+ * one it is looking at. `X_JOB_TIMEOUT` stays the code the ATTEMPT fails with; this is the code
138
+ * the work inside it stops with.
139
+ */
140
+ export class JobAbortedError extends UltimateError {
141
+ constructor(input: { job: string; step?: string }) {
142
+ super({
143
+ code: 'X_ABORTED',
144
+ cause:
145
+ input.step === undefined
146
+ ? `job "${input.job}" was cancelled — this attempt no longer owns the run`
147
+ : `job "${input.job}" was cancelled before step "${input.step}" could be recorded`,
148
+ fix: 'add throwIfAborted(ctx) before expensive work, or pass fetch(url, { signal: ctx.signal }) — the queue re-runs the job, so stop at the deadline instead of running past it',
149
+ docs: docsFor('X_ABORTED'),
150
+ });
151
+ }
152
+ }
153
+
106
154
  /** Retries exhausted. The job is in the dead-letter queue, not lost. */
107
155
  export class JobMaxAttemptsError extends UltimateError {
108
156
  constructor(input: { job: string; jobId: string; attempts: number; lastError: string }) {
@@ -141,6 +189,116 @@ export class IdempotencyRequiredError extends UltimateError {
141
189
  }
142
190
  }
143
191
 
192
+ /**
193
+ * A job declared no `tenant`. The type already requires it; this is the runtime backstop for
194
+ * generated code and JS callers, so the guarantee holds at both ends — the shape
195
+ * `IdempotencyRequiredError` above already has.
196
+ *
197
+ * It is refused rather than defaulted because both defaults are wrong. `'none'` silently reopens
198
+ * the hole this field closes: the body would run with no org, and before this field existed that
199
+ * meant `@ultimat3/entity`'s guard read no actor at all and accepted a caller-named tenant
200
+ * unchecked. Inheriting the worker's org would make one identity serve every job, which is the
201
+ * cross-tenant read the declaration exists to prevent.
202
+ */
203
+ export class JobTenantRequiredError extends UltimateError {
204
+ constructor(input: { job: string }) {
205
+ super({
206
+ code: 'X_JOB_TENANT_REQUIRED',
207
+ cause: `job "${input.job}" declares no tenant — a job body runs with no request behind it, so every tenant-scoped read inside it would be unscoped`,
208
+ // `'none'` reads differently either side of `backfill()` and the fix has to say so: for a
209
+ // plain job it means "touches no tenant-scoped table", because the org is STRIPPED and any
210
+ // scoped read fails closed — but a backfill declaring it is how a sweep says it spans every
211
+ // tenant, and the pass opens the cross-tenant scope for exactly that declaration. Half the
212
+ // callers of this code arrive through `backfill()`, which forwards its `tenant` to `job()`.
213
+ fix: `add tenant: (input) => input.orgId to job("${input.job}") — or tenant: 'none', which declares NO org: right for a job that touches no tenant-scoped table, and the spelling a backfill() uses to sweep every tenant`,
214
+ docs: docsFor('X_JOB_TENANT_REQUIRED'),
215
+ });
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The queue took this job back while it was still running: `x jobs cancel` wrote a terminal state,
221
+ * or this worker's lease lapsed and another one re-claimed the row. Its own code and not
222
+ * `X_ABORTED`, because the response is different — `X_ABORTED` is "your deadline passed, make the
223
+ * work smaller", this is "somebody else owns this run now, stop writing to it".
224
+ */
225
+ export class LeaseLostError extends UltimateError {
226
+ constructor(input: { job: string; jobId: string }) {
227
+ super({
228
+ code: 'X_JOB_LEASE_LOST',
229
+ cause: `job "${input.job}" (${input.jobId}) is no longer claimed by this worker — it was cancelled, or its visibility lease lapsed and the queue re-delivered it`,
230
+ fix: `x jobs show ${input.jobId} --json`,
231
+ docs: docsFor('X_JOB_LEASE_LOST'),
232
+ });
233
+ }
234
+ }
235
+
236
+ /**
237
+ * The fleet slot this run holds under `job.concurrency` is somebody else's now: renewal answered
238
+ * "not yours", which is the one thing `LeaseStore.renew` can say that a retry cannot fix. Its own
239
+ * code and not `X_JOB_LEASE_LOST`, because they are different rows on different clocks — the
240
+ * queue may still consider this worker the owner of the JOB while another worker is already
241
+ * running one under the same cap, which is precisely the guarantee `concurrency` sells.
242
+ */
243
+ export class JobSlotLostError extends UltimateError {
244
+ constructor(input: { job: string; jobId: string; slot: number }) {
245
+ super({
246
+ code: 'X_JOB_SLOT_LOST',
247
+ cause: `job "${input.job}" (${input.jobId}) no longer holds fleet concurrency slot ${input.slot} — its lease expired and another worker took it`,
248
+ fix: `x jobs show ${input.jobId} --json`,
249
+ docs: docsFor('X_JOB_SLOT_LOST'),
250
+ });
251
+ }
252
+ }
253
+
254
+ /**
255
+ * `x jobs cancel` reached a job that already finished. Not a failure of the command — the work is
256
+ * done — but never a silent success either: an operator cancelling a runaway pass has to know
257
+ * whether they stopped it or missed it.
258
+ */
259
+ export class JobNotCancellableError extends UltimateError {
260
+ constructor(input: { jobId: string; state: string }) {
261
+ super({
262
+ code: 'X_JOB_NOT_CANCELLABLE',
263
+ cause:
264
+ input.state === 'missing'
265
+ ? `no job ${input.jobId} exists in this queue`
266
+ : `job ${input.jobId} is "${input.state}" and only a job that has not finished can be cancelled`,
267
+ fix: `x jobs ls --state running --json`,
268
+ docs: docsFor('X_JOB_NOT_CANCELLABLE'),
269
+ });
270
+ }
271
+ }
272
+
273
+ /** The driver has no `introspect.cancel`. The redis/nats stubs, and any hand-rolled driver. */
274
+ export class CancelUnsupportedError extends UltimateError {
275
+ constructor(input: { driver: string }) {
276
+ super({
277
+ code: 'X_JOB_NOT_CANCELLABLE',
278
+ cause: `the "${input.driver}" jobs driver cannot cancel a single job`,
279
+ fix: "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs cancel <id> --json",
280
+ docs: docsFor('X_JOB_NOT_CANCELLABLE'),
281
+ });
282
+ }
283
+ }
284
+
285
+ /**
286
+ * `job.concurrency` is declared and this driver has no `leases`, so the cap is per PROCESS and the
287
+ * fleet runs `concurrency x replicas`. Thrown at worker start rather than logged, because a
288
+ * documented guarantee that silently does nothing is exactly what axiom 3 exists to refuse — the
289
+ * worker refuses to start instead of running with the wrong number.
290
+ */
291
+ export class ConcurrencyUnenforceableError extends UltimateError {
292
+ constructor(input: { driver: string; jobs: readonly string[] }) {
293
+ super({
294
+ code: 'X_JOB_CONCURRENCY_UNENFORCEABLE',
295
+ cause: `${input.jobs.join(', ')} declare concurrency and the "${input.driver}" jobs driver has no lease store, so the cap would hold per process and the fleet would run concurrency x replicas`,
296
+ fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or set jobs: { driver: 'postgres' } in app.config.ts`,
297
+ docs: docsFor('X_JOB_CONCURRENCY_UNENFORCEABLE'),
298
+ });
299
+ }
300
+ }
301
+
144
302
  /** An outbox enqueue happened with no ambient transaction to join. */
145
303
  export class OutboxNoTxError extends UltimateError {
146
304
  constructor(input: { job: string }) {
@@ -153,6 +311,134 @@ export class OutboxNoTxError extends UltimateError {
153
311
  }
154
312
  }
155
313
 
314
+ /**
315
+ * The seven backfill codes below all answer one question — "why is this sweep not running?" — and
316
+ * each is here because it sends the reader somewhere different: run it, force it, change
317
+ * environment, migrate first, wait, fix the predicates, or fix the name. A code that shared a fix
318
+ * line with another one would be code inflation, which is why `X_BACKFILL_WRITE_UNCONFIRMED` was
319
+ * considered and rejected: a dry run that wrote nothing did exactly what it was asked to.
320
+ *
321
+ * Every `fix` below is ONE line something can execute — a shell command, or the edit to make.
322
+ * Never a command with prose appended: a `fix:` is copied and run verbatim, so a trailing clause
323
+ * turns a working command into a syntax error at the one moment the reader is following it
324
+ * literally. Explanations belong in `cause`, which is read and never run.
325
+ */
326
+
327
+ /**
328
+ * Declared and never completed. The alarm the framework did not have: an author could
329
+ * `x g backfill`, merge and deploy, and nothing anywhere said the pass had not run.
330
+ */
331
+ export class BackfillPendingError extends UltimateError {
332
+ constructor(input: { backfill: string; environment: string }) {
333
+ super({
334
+ code: 'X_BACKFILL_PENDING',
335
+ cause: `backfill "${input.backfill}" is declared and x_backfills holds no completed pass for it in ${input.environment}`,
336
+ fix: `x db backfill ${input.backfill} --write --json`,
337
+ docs: docsFor('X_BACKFILL_PENDING'),
338
+ });
339
+ }
340
+ }
341
+
342
+ /** Already swept. A rerun is legitimate, so this names the flag rather than refusing outright. */
343
+ export class BackfillAppliedError extends UltimateError {
344
+ constructor(input: { backfill: string; runId: string; completedAt: string }) {
345
+ super({
346
+ code: 'X_BACKFILL_APPLIED',
347
+ cause: `backfill "${input.backfill}" completed as run ${input.runId} at ${input.completedAt}; a forced rerun writes a NEW ledger row and never edits that one`,
348
+ fix: `x db backfill ${input.backfill} --write --force --json`,
349
+ docs: docsFor('X_BACKFILL_APPLIED'),
350
+ });
351
+ }
352
+ }
353
+
354
+ /**
355
+ * The declaration names the environments it belongs to and this is not one. Declared DATA, never a
356
+ * hardcoded "cleanups are production": a staging rehearsal is correct practice, so which
357
+ * environments a sweep belongs to is the app's convention and this is only the mechanism carrying
358
+ * it (axiom 8).
359
+ */
360
+ export class BackfillEnvironmentError extends UltimateError {
361
+ constructor(input: { backfill: string; environment: string; declared: readonly string[] }) {
362
+ // The first declared environment, because the fix has to be ONE runnable line and the list is
363
+ // ordered by the author. The empty case cannot arise from `checkBackfillEnvironment`, which
364
+ // treats an empty list as "every environment" — but this constructor is public, so it answers
365
+ // with the command that lists what IS declared rather than an `ULTIMATE_ENV=undefined`.
366
+ const target = input.declared[0];
367
+ super({
368
+ code: 'X_BACKFILL_ENVIRONMENT',
369
+ cause: `backfill "${input.backfill}" declares environments: ${input.declared.join(', ')} and this process resolved ${input.environment} — add "${input.environment}" to that list if this deploy should sweep too`,
370
+ fix:
371
+ target === undefined
372
+ ? 'x db backfill --pending --json'
373
+ : `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
374
+ docs: docsFor('X_BACKFILL_ENVIRONMENT'),
375
+ });
376
+ }
377
+ }
378
+
379
+ /**
380
+ * `requires` names a migration the ledger has not applied. Checked where `x_migrations` is
381
+ * readable — this package holds no `@ultimat3/db` dependency and growing one to read a ledger
382
+ * would put the migration engine on the tier-3 queue's import graph.
383
+ */
384
+ export class BackfillMigrationPendingError extends UltimateError {
385
+ constructor(input: { backfill: string; migration: string }) {
386
+ super({
387
+ code: 'X_BACKFILL_MIGRATION_PENDING',
388
+ cause: `backfill "${input.backfill}" requires migration ${input.migration}, which x_migrations does not record as applied`,
389
+ fix: 'x db migrate --json',
390
+ docs: docsFor('X_BACKFILL_MIGRATION_PENDING'),
391
+ });
392
+ }
393
+ }
394
+
395
+ /**
396
+ * The enqueue deduped: one live pass per name, so this run is the one already going. Distinct from
397
+ * `X_BACKFILL_APPLIED`, which is a pass that finished — the response there is `--force`, and the
398
+ * response here is to look at the run that is holding the key.
399
+ */
400
+ export class BackfillRunningError extends UltimateError {
401
+ constructor(input: { backfill: string; jobId: string }) {
402
+ super({
403
+ code: 'X_BACKFILL_RUNNING',
404
+ cause: `backfill "${input.backfill}" already has a live pass queued as ${input.jobId}, and one name holds one live pass; its step trace names the batch it is on, and a pass that is not advancing is a worker that lost its lease`,
405
+ fix: `x jobs show ${input.jobId} --json`,
406
+ docs: docsFor('X_BACKFILL_RUNNING'),
407
+ });
408
+ }
409
+ }
410
+
411
+ /**
412
+ * The source ran out of rows and the declaration's own `count()` still matches some. Two
413
+ * predicates that disagree is an authoring bug in any business — the sweep reported success over
414
+ * rows it never visited — so the pass fails rather than writing a completed row nobody can trust.
415
+ */
416
+ export class BackfillStalledError extends UltimateError {
417
+ constructor(input: { backfill: string; remaining: number; swept: number }) {
418
+ super({
419
+ code: 'X_BACKFILL_STALLED',
420
+ cause: `backfill "${input.backfill}" swept ${input.swept} rows, exhausted its source, and count() still matches ${input.remaining} — a WHERE the sweep narrows and the count does not is what leaves rows behind`,
421
+ fix: `make count() select on exactly what source() selects on in backfill("${input.backfill}")`,
422
+ docs: docsFor('X_BACKFILL_STALLED'),
423
+ });
424
+ }
425
+ }
426
+
427
+ /** A name no declaration in this app carries — a typo, or a backfill whose module was deleted. */
428
+ export class BackfillUnknownError extends UltimateError {
429
+ constructor(input: { backfill: string; known: readonly string[] }) {
430
+ super({
431
+ code: 'X_BACKFILL_UNKNOWN',
432
+ cause:
433
+ input.known.length === 0
434
+ ? `no backfill named "${input.backfill}" is declared, and this app declares none at all`
435
+ : `no backfill named "${input.backfill}" is declared (declared: ${input.known.join(', ')})`,
436
+ fix: 'x db backfill --pending --json',
437
+ docs: docsFor('X_BACKFILL_UNKNOWN'),
438
+ });
439
+ }
440
+ }
441
+
156
442
  export class JobsNotImplementedError extends UltimateError {
157
443
  constructor(input: { feature: string; fix: string }) {
158
444
  super({
@@ -0,0 +1,121 @@
1
+ // `x_job_events`: the event bus `step.waitForEvent` needs in a real deployment. The memory bus is
2
+ // one process's heap, and in a deployment the publisher and the resumer are never the same pod —
3
+ // a Stripe webhook lands on web-3 and the worker that resumes the run is worker-7, so `find`
4
+ // answers `undefined` until the 24h timeout and the run dead-letters with nothing logged first.
5
+ //
6
+ // Stored and not broadcast, exactly as the memory bus is: a step that suspends at 12:00 and
7
+ // resumes at 12:00:30 must still see an event published at 12:00:10.
8
+
9
+ import type { Clock } from '@ultimat3/core';
10
+ import { logger, systemClock, uuid } from '@ultimat3/core';
11
+ import type { DurationInput } from './clock';
12
+ import { nowMs, toMs } from './clock';
13
+ import type { PgExecutor } from './driver-pg';
14
+ import {
15
+ SQL_EVENT_FIND,
16
+ SQL_EVENT_LIST,
17
+ SQL_EVENT_PUBLISH,
18
+ SQL_EVENT_PURGE,
19
+ } from './driver-pg-sql';
20
+ import type { EventBus, JobEvent } from './events';
21
+
22
+ interface EventRow {
23
+ readonly id: string;
24
+ readonly name: string;
25
+ readonly payload: unknown;
26
+ readonly correlation_key: string | null;
27
+ readonly published_at: number | string;
28
+ readonly expires_at: number | string;
29
+ }
30
+
31
+ export interface PgEventBusOptions {
32
+ readonly executor: PgExecutor;
33
+ readonly clock?: Clock;
34
+ /** How long an event stays matchable. Default 7d — longer than any sane wait. */
35
+ readonly defaultTtl?: DurationInput;
36
+ /** Rows returned by `list()`. Diagnostics only; `find()` is what a step uses. */
37
+ readonly listLimit?: number;
38
+ }
39
+
40
+ /**
41
+ * `purgeExpired()` is SYNCHRONOUS in `EventBus` because the memory bus can be — it walks a Map.
42
+ * Over SQL the delete is a round trip, so this fires it and answers 0: the count is a diagnostic
43
+ * the memory bus offers and this one cannot, and blocking a step's resume on a housekeeping
44
+ * DELETE would be a far worse trade than an unanswered number. The index on `(name, published_at)`
45
+ * plus `expires_at > now()` in `find` means an unpurged row costs a filter, never a wrong answer.
46
+ */
47
+ export function createPgEventBus(options: PgEventBusOptions): EventBus {
48
+ const clock = options.clock ?? systemClock;
49
+ const defaultTtl = options.defaultTtl ?? 604_800_000;
50
+ const listLimit = options.listLimit ?? 1_000;
51
+ const exec = options.executor;
52
+
53
+ const purgeExpired = (): number => {
54
+ void exec.query(SQL_EVENT_PURGE, []).catch((error: unknown) => {
55
+ // Housekeeping never costs a publish: an unpurged row is filtered out of every read.
56
+ logger.warn('jobs.event.purge-failed', {
57
+ error: error instanceof Error ? error.message : String(error),
58
+ });
59
+ });
60
+ return 0;
61
+ };
62
+
63
+ return {
64
+ async publish(name, payload, publishOptions = {}) {
65
+ const at = nowMs(clock);
66
+ const event: JobEvent = {
67
+ id: uuid(),
68
+ name,
69
+ payload,
70
+ publishedAt: at,
71
+ expiresAt: at + toMs(publishOptions.ttl ?? defaultTtl),
72
+ ...(publishOptions.correlationKey === undefined
73
+ ? {}
74
+ : { correlationKey: publishOptions.correlationKey }),
75
+ };
76
+ await exec.query(SQL_EVENT_PUBLISH, [
77
+ event.id,
78
+ event.name,
79
+ JSON.stringify(event.payload ?? null),
80
+ event.correlationKey ?? null,
81
+ event.publishedAt,
82
+ event.expiresAt,
83
+ ]);
84
+ logger.debug('jobs.event.published', {
85
+ event: name,
86
+ correlationKey: publishOptions.correlationKey ?? null,
87
+ });
88
+ return event;
89
+ },
90
+
91
+ /** Earliest match at or after `afterMs`, so a resumed step consumes events in order. */
92
+ async find(name, correlationKey, afterMs) {
93
+ const rows = await exec.query<{ payload: unknown; published_at: number | string }>(
94
+ SQL_EVENT_FIND,
95
+ [name, correlationKey ?? null, afterMs],
96
+ );
97
+ const row = rows[0];
98
+ return row === undefined
99
+ ? undefined
100
+ : { payload: row.payload, publishedAt: Number(row.published_at) };
101
+ },
102
+
103
+ async list(name) {
104
+ const rows = await exec.query<EventRow>(SQL_EVENT_LIST, [name ?? null, listLimit]);
105
+ return rows.map((row) => ({
106
+ id: row.id,
107
+ name: row.name,
108
+ payload: row.payload,
109
+ publishedAt: Number(row.published_at),
110
+ expiresAt: Number(row.expires_at),
111
+ ...(row.correlation_key === null ? {} : { correlationKey: row.correlation_key }),
112
+ }));
113
+ },
114
+
115
+ purgeExpired,
116
+ // The memory bus's `size()` is its Map's; over SQL a `count(*)` is a round trip and every
117
+ // caller of this is a test asserting on a bound the memory bus has. `-1` is the honest
118
+ // "not a number this bus keeps" — never `0`, which reads as an empty bus.
119
+ size: () => -1,
120
+ };
121
+ }
package/src/events.ts CHANGED
@@ -115,7 +115,13 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
115
115
 
116
116
  let ambientBus: EventBus = createMemoryEventBus();
117
117
 
118
- /** Swapped at boot for the NATS/Redis-streams bus in a multi-node deployment. */
118
+ /**
119
+ * **Install `createPgEventBus({ executor })` here in any deployment with more than one process.**
120
+ * The default above is one process's heap: the pod that publishes and the pod that resumes are
121
+ * never the same one, so a webhook landing on web-3 strands a run on worker-7 until its 24h
122
+ * timeout dead-letters it, with nothing logged before then. This line used to promise a
123
+ * "NATS/Redis-streams bus swapped at boot"; no such bus existed and boot installed the memory one.
124
+ */
119
125
  export function setEventBus(bus: EventBus): void {
120
126
  ambientBus = bus;
121
127
  }