pg-boss 12.31.1 → 12.33.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.
@@ -7,6 +7,7 @@ export interface PGliteLike {
7
7
  rows: any[];
8
8
  }>>;
9
9
  listen?(channel: string, callback: (payload: string) => void): Promise<() => Promise<void>>;
10
+ onLeaderChange?(callback: () => void): () => void;
10
11
  }
11
12
  export declare function fromPglite(pglite: PGliteLike): IDatabase;
12
13
  //# sourceMappingURL=pglite.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pglite.d.ts","sourceRoot":"","sources":["../../src/adapters/pglite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAM5C,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,CAAC,GAAG,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAA;KAAE,CAAC,CAAA;IACzE,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,GAAG,EAAE,CAAA;KAAE,CAAC,CAAC,CAAA;IACpD,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;CAC5F;AAUD,wBAAgB,UAAU,CAAE,MAAM,EAAE,UAAU,GAAG,SAAS,CA6CzD"}
1
+ {"version":3,"file":"pglite.d.ts","sourceRoot":"","sources":["../../src/adapters/pglite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAM5C,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,CAAC,GAAG,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAA;KAAE,CAAC,CAAA;IACzE,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,GAAG,EAAE,CAAA;KAAE,CAAC,CAAC,CAAA;IACpD,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;IAC3F,cAAc,CAAC,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;CAClD;AAmBD,wBAAgB,UAAU,CAAE,MAAM,EAAE,UAAU,GAAG,SAAS,CAwJzD"}
@@ -1,3 +1,10 @@
1
+ // PGliteWorker's error when leadership moves while a call is in flight. Matched on the message
2
+ // rather than the constructor name: the class is anonymous after bundling, so `name` is just
3
+ // 'Error', while the message is fixed in the worker source.
4
+ const LEADER_CHANGED_MESSAGE = 'Leader changed, pending operation in indeterminate state';
5
+ function isLeaderChange(err) {
6
+ return err instanceof Error && err.message === LEADER_CHANGED_MESSAGE;
7
+ }
1
8
  // Adapts a PGlite instance (embedded single-connection WASM PostgreSQL) to pg-boss's IDatabase.
2
9
  // PGlite is full PostgreSQL, so it needs none of the distributed compatibility flags — pair it
3
10
  // with `backend: 'pglite'`. The user owns the PGlite instance lifecycle (construction and close).
@@ -23,17 +30,114 @@ export function fromPglite(pglite) {
23
30
  const results = await pglite.exec(text);
24
31
  return { rows: results.flatMap(r => r.rows ?? []) };
25
32
  };
33
+ // The statements every session must carry, kept so they can be reapplied. A plain PGlite has one
34
+ // session for the life of the instance and never needs that; PGliteWorker does — see below.
35
+ let sessionStatements = [];
36
+ let unsubscribeLeaderChange = null;
37
+ // While a reapply is in flight, every statement waits for it. Without this a query issued between
38
+ // the leader change and the reapply lands on a session that has not been set up yet.
39
+ let reapplying = null;
40
+ // Statements currently in flight, so a leader change can fail them itself — see executeSql.
41
+ const inFlight = new Set();
42
+ // Note what this cannot fix: because one leader instance serves every tab, session state is shared
43
+ // by all of them. A SET issued through one tab's adapter applies to every other tab's queries
44
+ // against that database, and there is no way to scope it to the instance that asked for it.
45
+ //
46
+ // Only the PGliteWorker leader holds an actual PGlite instance; the other tabs proxy into it. When
47
+ // the leader tab goes away, the next tab's worker constructs a *new* PGlite over the same data
48
+ // directory — a new backend, and so a new session. Anything set with SET is gone, and nothing in
49
+ // the worker replays it, so without this the session would silently revert to defaults: for the
50
+ // clock override specifically, back to real time with no error anywhere.
51
+ const watchLeaderChange = () => {
52
+ if (unsubscribeLeaderChange || typeof pglite.onLeaderChange !== 'function') {
53
+ return;
54
+ }
55
+ unsubscribeLeaderChange = pglite.onLeaderChange(() => {
56
+ // Fail the in-flight statements first: they were issued against a backend that no longer
57
+ // exists, and one of them may be holding a reapply behind it.
58
+ for (const fail of [...inFlight])
59
+ fail(new Error(LEADER_CHANGED_MESSAGE));
60
+ if (!sessionStatements.length)
61
+ return;
62
+ // Cleared only by the chain that set it: a second leader change during a reapply assigns a
63
+ // new one, and the first must not open the gate on a session the new chain has not set up.
64
+ const chain = applySessionStatements().catch(() => { }).then(() => {
65
+ if (reapplying === chain)
66
+ reapplying = null;
67
+ });
68
+ reapplying = chain;
69
+ });
70
+ };
71
+ // PGliteWorker settles a statement that was in flight across a leader change only when it was
72
+ // still queued on the transaction lock: that rejection is raised before _runExclusiveTransaction
73
+ // enters its try/finally, so it reaches us. A statement that had already *taken* the lock never
74
+ // settles at all — its own rpc rejects, but the `finally` then posts _releaseTransactionLock to a
75
+ // tab channel the new leader has not attached to yet, and nothing will ever reply or reject it.
76
+ // That await swallows the original rejection and hangs forever, which for pg-boss means a
77
+ // locked() block stalling a maintenance cycle silently and permanently.
78
+ //
79
+ // So the leader change fails its own in-flight statements, giving a lock holder the same
80
+ // indeterminate-state error a queued statement already gets. The abandoned promise is left to
81
+ // settle or not on its own; only its late rejection has to be swallowed.
82
+ const raceLeaderChange = async (text, values) => {
83
+ if (typeof pglite.onLeaderChange !== 'function') {
84
+ return await run(text, values);
85
+ }
86
+ let fail;
87
+ const lost = new Promise((_resolve, reject) => { fail = reject; });
88
+ inFlight.add(fail);
89
+ const statement = run(text, values);
90
+ statement.catch(() => { });
91
+ try {
92
+ return await Promise.race([statement, lost]);
93
+ }
94
+ finally {
95
+ inFlight.delete(fail);
96
+ }
97
+ };
98
+ // Through raceLeaderChange, not run(): a reapply orphaned by a *second* leader change is the
99
+ // same upstream hang as any other statement, and worse here, because executeSql waits on
100
+ // `reapplying` - a reapply that never settles stalls the adapter permanently and silently.
101
+ // Failing it instead leaves the next leader change to reapply from scratch.
102
+ const applySessionStatements = async () => {
103
+ for (const statement of sessionStatements) {
104
+ await raceLeaderChange(statement);
105
+ }
106
+ };
26
107
  const db = {
108
+ // A plain PGlite is one session for the life of the instance, so these are applied once and
109
+ // every later statement sees them. A pooled driver re-runs them per connection; here the only
110
+ // thing that can take the session away is a PGliteWorker leader change.
111
+ async setSessionStatements(statements) {
112
+ sessionStatements = statements;
113
+ await applySessionStatements();
114
+ },
27
115
  async executeSql(text, values) {
116
+ // Re-checked, not awaited once: a second leader change during a reapply fails the first
117
+ // chain's in-flight statement, so that chain settles early and hands the gate back while the
118
+ // chain that replaced it has not reissued anything yet. Waiting again on whatever is there
119
+ // now is what keeps a query from reaching a session the current chain has not set up.
120
+ for (let pending = reapplying; pending; pending = reapplying) {
121
+ await pending;
122
+ }
28
123
  try {
29
- return await run(text, values);
124
+ return await raceLeaderChange(text, values);
30
125
  }
31
126
  catch (err) {
32
- await pglite.query('ROLLBACK').catch(() => { });
127
+ // A leader change leaves nothing to roll back on this side: the transaction died with the
128
+ // old leader's instance, and a ROLLBACK now would go to a different session that was never
129
+ // in it. The statement's own outcome is genuinely unknown, which is what the error says.
130
+ if (!isLeaderChange(err)) {
131
+ await pglite.query('ROLLBACK').catch(() => { });
132
+ }
33
133
  throw err;
34
134
  }
35
135
  }
36
136
  };
137
+ // Taken out once and kept for the adapter's life. It used to be tied to having session statements
138
+ // to reapply; it now also fails in-flight statements, which every adapter needs whether or not a
139
+ // TestClock is involved. A plain PGlite has no onLeaderChange and subscribes to nothing.
140
+ watchLeaderChange();
37
141
  // PGlite is embedded single-connection PostgreSQL, so LISTEN/NOTIFY works entirely in-process:
38
142
  // the same instance both NOTIFYs (via pg-boss's inlined pg_notify) and delivers to listeners.
39
143
  // Only expose `listen` when the instance actually supports it (older builds/mocks may not), so
@@ -4,7 +4,7 @@ declare const POLICY: {
4
4
  MIN_POLLING_INTERVAL_MS: number;
5
5
  MAX_RETENTION_DAYS: number;
6
6
  };
7
- declare const COMPATIBILITY_FLAGS: readonly ["noSkipLocked", "noMultiMutationCte", "noTablePartitioning", "noDeferrableConstraints", "noAdvisoryLocks", "noCoveringIndexes", "noAddColumnBackfill", "noListenNotify", "noIndexProgressView", "noReindex", "noMonitorVacuum"];
7
+ declare const COMPATIBILITY_FLAGS: readonly ["noSkipLocked", "noMultiMutationCte", "noTablePartitioning", "noDeferrableConstraints", "noAdvisoryLocks", "noCoveringIndexes", "noAddColumnBackfill", "noListenNotify", "noIndexProgressView", "noReindex", "noMonitorVacuum", "noTransactionalHeartbeat"];
8
8
  declare function validateQueueArgs(config?: any): void;
9
9
  declare function pinZonelessDateTime(value: string): string;
10
10
  declare function checkSendArgs(args: any): types.Request;
@@ -1 +1 @@
1
- {"version":3,"file":"attorney.d.ts","sourceRoot":"","sources":["../src/attorney.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAExC,QAAA,MAAM,MAAM;;;;CAIX,CAAA;AAKD,QAAA,MAAM,mBAAmB,2OAYf,CAAA;AAsEV,iBAAS,iBAAiB,CAAE,MAAM,GAAE,GAAQ,QAc3C;AAgBD,iBAAS,mBAAmB,CAAE,KAAK,EAAE,MAAM,UAE1C;AAED,iBAAS,aAAa,CAAE,IAAI,EAAE,GAAG,GAAG,KAAK,CAAC,OAAO,CAgDhD;AAOD,iBAAS,eAAe,CAAE,IAAI,EAAE,GAAG,EAAE,EAAE,MAAc,EAAE;;CAAK,GAAG,KAAK,CAAC,OAAO,CA4D3E;AAED,iBAAS,mBAAmB,CAAE,MAAM,EAAE,GAAG,QAOxC;AAED,iBAAS,gBAAgB,CAAE,IAAI,EAAE,KAAK,CAAC,OAAO,EAAE,QA2D/C;AA2GD,iBAAS,aAAa,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG;IAClD,OAAO,EAAE,KAAK,CAAC,mBAAmB,CAAA;IAClC,QAAQ,EAAE,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;CACjC,CA8BA;AAED,iBAAS,cAAc,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,QAQlD;AAED,iBAAS,SAAS,CAAE,KAAK,EAAE,MAAM,GAAG,KAAK,CAAC,kBAAkB,GAAG,KAAK,CAAC,0BAA0B,CAyB9F;AA4FD,iBAAS,wBAAwB,CAAE,IAAI,EAAE,MAAM,QAiC9C;AAED,iBAAS,eAAe,CAAE,IAAI,EAAE,MAAM,QAIrC;AAED,iBAAS,SAAS,CAAE,GAAG,EAAE,MAAM,QAI9B;AA+LD,OAAO,EACL,SAAS,EACT,mBAAmB,EACnB,wBAAwB,EACxB,eAAe,EACf,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,SAAS,EACT,mBAAmB,EACnB,MAAM,EACN,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EAClB,CAAA"}
1
+ {"version":3,"file":"attorney.d.ts","sourceRoot":"","sources":["../src/attorney.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAExC,QAAA,MAAM,MAAM;;;;CAIX,CAAA;AAKD,QAAA,MAAM,mBAAmB,uQAaf,CAAA;AA2EV,iBAAS,iBAAiB,CAAE,MAAM,GAAE,GAAQ,QAc3C;AAgBD,iBAAS,mBAAmB,CAAE,KAAK,EAAE,MAAM,UAE1C;AAED,iBAAS,aAAa,CAAE,IAAI,EAAE,GAAG,GAAG,KAAK,CAAC,OAAO,CAgDhD;AAOD,iBAAS,eAAe,CAAE,IAAI,EAAE,GAAG,EAAE,EAAE,MAAc,EAAE;;CAAK,GAAG,KAAK,CAAC,OAAO,CA4D3E;AAED,iBAAS,mBAAmB,CAAE,MAAM,EAAE,GAAG,QAOxC;AAED,iBAAS,gBAAgB,CAAE,IAAI,EAAE,KAAK,CAAC,OAAO,EAAE,QA2D/C;AA2GD,iBAAS,aAAa,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG;IAClD,OAAO,EAAE,KAAK,CAAC,mBAAmB,CAAA;IAClC,QAAQ,EAAE,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;CACjC,CAgCA;AAqBD,iBAAS,cAAc,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,QAQlD;AAED,iBAAS,SAAS,CAAE,KAAK,EAAE,MAAM,GAAG,KAAK,CAAC,kBAAkB,GAAG,KAAK,CAAC,0BAA0B,CA0B9F;AA8GD,iBAAS,wBAAwB,CAAE,IAAI,EAAE,MAAM,QAiC9C;AAED,iBAAS,eAAe,CAAE,IAAI,EAAE,MAAM,QAIrC;AAED,iBAAS,SAAS,CAAE,GAAG,EAAE,MAAM,QAI9B;AA+LD,OAAO,EACL,SAAS,EACT,mBAAmB,EACnB,wBAAwB,EACxB,eAAe,EACf,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,SAAS,EACT,mBAAmB,EACnB,MAAM,EACN,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EAClB,CAAA"}
package/dist/attorney.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import assert from 'node:assert';
2
+ import { systemClock } from "./clock.js";
2
3
  import { DEFAULT_SCHEMA } from "./plans.js";
3
4
  const POLICY = {
4
5
  MAX_EXPIRATION_HOURS: 24,
@@ -19,7 +20,8 @@ const COMPATIBILITY_FLAGS = [
19
20
  'noListenNotify',
20
21
  'noIndexProgressView',
21
22
  'noReindex',
22
- 'noMonitorVacuum'
23
+ 'noMonitorVacuum',
24
+ 'noTransactionalHeartbeat'
23
25
  ];
24
26
  // The single source of truth for backend presets, mirrored by test/testHelper.ts.
25
27
  const BACKEND_PROFILES = {
@@ -46,7 +48,12 @@ const BACKEND_PROFILES = {
46
48
  // there is no pg_relation_size(), and reltuples / relpages is an "unsupported binary
47
49
  // operator: <float4> / <int4>".
48
50
  noReindex: true,
49
- noMonitorVacuum: true
51
+ noMonitorVacuum: true,
52
+ // The heartbeat writes heartbeat_on on the claimed row from a pooled connection, which under
53
+ // serializable lands at a timestamp above the handler transaction's. The completion pg-boss
54
+ // runs inside that transaction then cannot write the same row and the batch dies with a
55
+ // WriteTooOldError. YugabyteDB's snapshot isolation waits instead, so it does NOT set this.
56
+ noTransactionalHeartbeat: true
50
57
  }
51
58
  },
52
59
  yugabytedb: {
@@ -361,11 +368,28 @@ function checkWorkArgs(name, args) {
361
368
  assert(!('priority' in options) || typeof options.priority === 'boolean', 'priority must be a boolean');
362
369
  assert(!('localConcurrency' in options) || (Number.isInteger(options.localConcurrency) && options.localConcurrency >= 1), 'localConcurrency must be an integer >= 1');
363
370
  assert(!('perJobResults' in options) || typeof options.perJobResults === 'boolean', 'perJobResults must be a boolean');
371
+ assert(!('transactional' in options) || typeof options.transactional === 'boolean', 'transactional must be a boolean');
364
372
  validatePriorityRangeConfig(options);
365
373
  validateGroupConcurrencyConfig(options);
366
374
  validateHeartbeatRefreshConfig(options);
375
+ validateTransactionalConfig(options);
367
376
  return { options, callback };
368
377
  }
378
+ // Rejects the one combination a transactional worker cannot honour, rather than quietly degrading
379
+ // one of the two features. Everything else about a worker (fetching, batching, concurrency, group
380
+ // limits, heartbeats) is untouched by the option, because the transaction covers only the handler
381
+ // and the completion.
382
+ function validateTransactionalConfig(options) {
383
+ assert(!('transactionTimeoutSeconds' in options) || (Number.isInteger(options.transactionTimeoutSeconds) && options.transactionTimeoutSeconds >= 0), 'transactionTimeoutSeconds must be an integer >= 0');
384
+ if (!options.transactional) {
385
+ // Rejected rather than ignored: the bound only exists for a transaction pg-boss opened.
386
+ assert(!('transactionTimeoutSeconds' in options), 'transactionTimeoutSeconds requires transactional');
387
+ return;
388
+ }
389
+ // Per-job settlement partitions a batch into separate outcomes; one transaction can only commit
390
+ // or roll back as a whole, so the two contradict each other.
391
+ assert(!options.perJobResults, 'transactional cannot be combined with perJobResults');
392
+ }
369
393
  function checkFetchArgs(name, options) {
370
394
  assert(name, 'missing queue name');
371
395
  assert(!('batchSize' in options) || (Number.isInteger(options.batchSize) && options.batchSize >= 1), 'batchSize must be an integer > 0');
@@ -386,6 +410,7 @@ function getConfig(value) {
386
410
  config.useListenNotify = ('useListenNotify' in config) ? config.useListenNotify : false;
387
411
  config.reindex = ('reindex' in config) ? config.reindex : true;
388
412
  resolveBackend(config);
413
+ applyClockConfig(config);
389
414
  applySchemaConfig(config);
390
415
  applyOpsConfig(config);
391
416
  applyScheduleConfig(config);
@@ -394,6 +419,16 @@ function getConfig(value) {
394
419
  validateWarningConfig(config);
395
420
  return config;
396
421
  }
422
+ const CLOCK_METHODS = ['now', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'];
423
+ function applyClockConfig(config) {
424
+ if (config.clock) {
425
+ const clock = config.clock;
426
+ for (const method of CLOCK_METHODS) {
427
+ assert(typeof clock[method] === 'function', `configuration assert: clock must implement ${method}()`);
428
+ }
429
+ }
430
+ config.clock = config.clock || systemClock;
431
+ }
397
432
  function applySchemaConfig(config) {
398
433
  if (config.schema) {
399
434
  assertPostgresObjectName(config.schema);
@@ -433,6 +468,10 @@ function resolveBackend(config) {
433
468
  if (config.__test__noIndexProgressView) {
434
469
  config.noIndexProgressView = true;
435
470
  }
471
+ // Test hook: exercise the transactional-heartbeat rejection (CockroachDB) on plain Postgres.
472
+ if (config.__test__noTransactionalHeartbeat) {
473
+ config.noTransactionalHeartbeat = true;
474
+ }
436
475
  // Test hook: exercise the detection-only reindex path (bloat is reported, never rebuilt) used by
437
476
  // CockroachDB/YugabyteDB, on a plain Postgres instance.
438
477
  if (config.__test__noReindex) {
package/dist/bam.js CHANGED
@@ -28,14 +28,14 @@ class Bam extends EventEmitter {
28
28
  return;
29
29
  this.#stopped = false;
30
30
  setImmediate(() => this.#onPoll());
31
- this.#pollInterval = setInterval(() => this.#onPoll(), this.#config.bamIntervalSeconds * 1000);
31
+ this.#pollInterval = this.#config.clock.setInterval(() => this.#onPoll(), this.#config.bamIntervalSeconds * 1000);
32
32
  }
33
33
  async stop() {
34
34
  if (this.#stopped)
35
35
  return;
36
36
  this.#stopped = true;
37
37
  if (this.#pollInterval) {
38
- clearInterval(this.#pollInterval);
38
+ this.#config.clock.clearInterval(this.#pollInterval);
39
39
  this.#pollInterval = undefined;
40
40
  }
41
41
  while (this.#working) {
@@ -1 +1 @@
1
- {"version":3,"file":"boss.d.ts","sourceRoot":"","sources":["../src/boss.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AACtC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAGvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAgHnC,cAAM,IAAK,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAsC1D,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAa1C,IAAI,WAAW,IAAK,OAAO,CAE1B;IAEK,KAAK;IAWL,IAAI;IA8FJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,KAAK,CAAC,gBAAgB;IAwqBvF;;;;OAIG;IACG,kBAAkB,CAAE,OAAO,CAAC,EAAE,KAAK,CAAC,cAAc,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;CAwB7E;AAED,eAAe,IAAI,CAAA"}
1
+ {"version":3,"file":"boss.d.ts","sourceRoot":"","sources":["../src/boss.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AACtC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAGvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAgHnC,cAAM,IAAK,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAsC1D,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAa1C,IAAI,WAAW,IAAK,OAAO,CAE1B;IAEK,KAAK;IAWL,IAAI;IA+FJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,KAAK,CAAC,gBAAgB;IAwqBvF;;;;OAIG;IACG,kBAAkB,CAAE,OAAO,CAAC,EAAE,KAAK,CAAC,cAAc,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;CAwB7E;AAED,eAAe,IAAI,CAAA"}
package/dist/boss.js CHANGED
@@ -125,7 +125,7 @@ class Boss extends EventEmitter {
125
125
  async start() {
126
126
  if (this.#stopped) {
127
127
  this.#stopping = false;
128
- this.#superviseInterval = setInterval(() => this.#onSupervise(), this.#config.superviseIntervalSeconds * 1000);
128
+ this.#superviseInterval = this.#config.clock.setInterval(() => this.#onSupervise(), this.#config.superviseIntervalSeconds * 1000);
129
129
  this.#stopped = false;
130
130
  }
131
131
  }
@@ -133,7 +133,7 @@ class Boss extends EventEmitter {
133
133
  if (!this.#stopped) {
134
134
  this.#stopping = true;
135
135
  if (this.#superviseInterval)
136
- clearInterval(this.#superviseInterval);
136
+ this.#config.clock.clearInterval(this.#superviseInterval);
137
137
  this.#stopped = true;
138
138
  while (this.#maintaining) {
139
139
  await delay(10);
@@ -154,6 +154,7 @@ class Boss extends EventEmitter {
154
154
  if (typeof (query) === 'string') {
155
155
  query = { text: query, values: [] };
156
156
  }
157
+ // Real time: a stopwatch around I/O reads zero on a frozen clock.
157
158
  const started = Date.now();
158
159
  const result = unwrapSQLResult(await this.#db.executeSql(query.text, query.values));
159
160
  const elapsed = (Date.now() - started) / 1000;
@@ -701,9 +702,9 @@ class Boss extends EventEmitter {
701
702
  return;
702
703
  }
703
704
  else {
704
- if (Date.now() < this.#detectOnly)
705
+ if (this.#config.clock.now() < this.#detectOnly)
705
706
  return;
706
- this.#detectOnly = Date.now() + this.#config.reindexIntervalSeconds * 1000;
707
+ this.#detectOnly = this.#config.clock.now() + this.#config.reindexIntervalSeconds * 1000;
707
708
  }
708
709
  }
709
710
  if (this.#stopping)
package/dist/cli.js CHANGED
@@ -39,6 +39,8 @@ Options:
39
39
  Non-postgres backends need this to emit schema they accept.
40
40
  --dry-run Output SQL without executing (for plans and reindex commands)
41
41
  --force Rebuild every job index, not just the bloated ones (reindex)
42
+ --fix Restore a job_now() left overridden by a killed TestClock run
43
+ (doctor). Only run this when no instance holds a live TestClock.
42
44
 
43
45
  Environment Variables:
44
46
  PGBOSS_DATABASE_URL Full connection string
@@ -161,7 +163,8 @@ function parseCliArgs() {
161
163
  ssl: { type: 'boolean' },
162
164
  backend: { type: 'string' },
163
165
  'dry-run': { type: 'boolean' },
164
- force: { type: 'boolean' }
166
+ force: { type: 'boolean' },
167
+ fix: { type: 'boolean' }
165
168
  },
166
169
  allowPositionals: true
167
170
  });
@@ -179,6 +182,7 @@ function parseCliArgs() {
179
182
  backend: values.backend,
180
183
  dryRun: values['dry-run'],
181
184
  force: values.force,
185
+ fix: values.fix,
182
186
  command: positionals[0],
183
187
  subCommand: positionals[1]
184
188
  };
@@ -385,7 +389,32 @@ async function cmdDoctor(args) {
385
389
  // carried divergent best-effort/backend-gating bugs). Contractor.detectDrift handles the
386
390
  // partitioned probe, best-effort catalog fallbacks, and backend-specific gating in one place.
387
391
  const contractor = new Contractor(db, { ...config, schema });
388
- const report = await contractor.detectDrift();
392
+ let report = await contractor.detectDrift();
393
+ // --fix repairs exactly one cause of drift, and only when asked. A TestClock restores job_now()
394
+ // when its handle is disposed, so a run killed first leaves the override installed; but a
395
+ // leftover override is indistinguishable from one a peer instance is holding right now, which is
396
+ // why this cannot be done automatically at startup. An operator knows which it is. Everything
397
+ // else doctor finds is reported and never repaired.
398
+ if (args.fix) {
399
+ // The drift report is the first source, but not the only one: mismatchedFunctions comes from
400
+ // pg_get_functiondef, and the whole function check is skipped when that query is unsupported
401
+ // (CockroachDB). detectClockOverride() reads prosrc instead, so a leftover override is still
402
+ // found - and repaired - on a backend whose functions never reach the report at all.
403
+ const override = report.mismatchedFunctions.some(f => f.name === 'job_now' && plans.clockFunctionIsOverridden(f.actualDefinition)) ||
404
+ await contractor.detectClockOverride();
405
+ if (override) {
406
+ console.log('\njob_now() carries a TestClock override, left behind by a test run that was');
407
+ console.log('killed before releasing its clock. Restoring it — make sure no instance is');
408
+ console.log('holding a live TestClock against this schema.');
409
+ await contractor.restoreClockFunction();
410
+ console.log(' restored.');
411
+ // Report on the repaired schema, so the summary and the exit code describe what is there now.
412
+ report = await contractor.detectDrift();
413
+ }
414
+ else {
415
+ console.log('\nNothing for --fix to repair: job_now() carries no TestClock override.');
416
+ }
417
+ }
389
418
  if (report.building.length) {
390
419
  console.log(`\nBuilding (async index build in progress — not yet drift) (${report.building.length}):`);
391
420
  for (const i of report.building)
@@ -461,6 +490,14 @@ async function cmdDoctor(args) {
461
490
  console.log(` expected: ${f.definition}`);
462
491
  }
463
492
  }
493
+ let clockOverrideNamed = false;
494
+ const printClockOverrideHint = (marker) => {
495
+ console.log(`\n${marker}job_now() carries a TestClock override, left behind by a test run that was`);
496
+ console.log(' killed before releasing its clock. Time is still correct, but the function no');
497
+ console.log(' longer inlines, so every statement that reads the clock is slower. Restore it');
498
+ console.log(' with "pg-boss doctor --fix", or by hand:');
499
+ console.log(plans.restoreClockFunction(schema).split('\n').filter(l => l.trim()).map(l => ` ${l.trim()}`).join('\n'));
500
+ };
464
501
  if (report.mismatchedFunctions.length) {
465
502
  console.log(`\nMISMATCHED FUNCTIONS (body differs) (${report.mismatchedFunctions.length}):`);
466
503
  for (const f of report.mismatchedFunctions) {
@@ -468,6 +505,23 @@ async function cmdDoctor(args) {
468
505
  console.log(` expected: ${f.definition}`);
469
506
  console.log(` actual: ${f.actualDefinition}`);
470
507
  }
508
+ // One cause of a job_now() mismatch is common and self-inflicted: a TestClock only restores
509
+ // the function when its handle is disposed, so a killed run leaves the override installed.
510
+ // The body still returns real time for a session that never opted in, which is why nothing
511
+ // surfaces it at runtime - but it no longer inlines, so every statement that reads the clock
512
+ // pays a per-row call. Name it rather than leave an operator to read two bodies and guess.
513
+ const clockOverride = report.mismatchedFunctions.find(f => f.name === 'job_now' && plans.clockFunctionIsOverridden(f.actualDefinition));
514
+ if (clockOverride) {
515
+ clockOverrideNamed = true;
516
+ printClockOverrideHint(' ');
517
+ }
518
+ }
519
+ // Same hint for a backend that never produced function rows to mismatch: the check above is
520
+ // gated on pg_get_functiondef, which CockroachDB does not support, so the override is invisible
521
+ // to drift there. This probe reads prosrc and finds it anyway. It is not drift the report can
522
+ // show a body for, so it names the condition and stops short of printing an "actual:".
523
+ if (!clockOverrideNamed && await contractor.detectClockOverride()) {
524
+ printClockOverrideHint('⚠ ');
471
525
  }
472
526
  if (report.columnDrift.length) {
473
527
  console.log(`\nCOLUMN DRIFT (missing/unexpected columns, or default/type/nullability drift) (${report.columnDrift.length}):`);
@@ -0,0 +1,31 @@
1
+ import type { AttachableClock, Clock, ClockTimer, IDatabase } from './types.ts';
2
+ export declare const systemClock: Clock;
3
+ export declare function isAttachable(clock: Clock): clock is AttachableClock;
4
+ interface Target {
5
+ db: IDatabase;
6
+ schema: string;
7
+ }
8
+ /**
9
+ * A clock a test drives by hand. Time only moves through setTime() and tick(); timers only fire
10
+ * from tick(). When attached to a schema, every pg-boss statement there reads the same time
11
+ * through ${schema}.job_now(), so JavaScript and Postgres agree on what "now" is.
12
+ */
13
+ export declare class TestClock implements AttachableClock {
14
+ #private;
15
+ constructor(start?: Date | number | string);
16
+ now(): number;
17
+ setTimeout(fn: () => void, ms: number): ClockTimer;
18
+ setInterval(fn: () => void, ms: number): ClockTimer;
19
+ clearTimeout(handle: ClockTimer): void;
20
+ clearInterval(handle: ClockTimer): void;
21
+ /** Jumps to a time, forwards or backwards, firing nothing. */
22
+ setTime(t: Date | number | string): Promise<void>;
23
+ /**
24
+ * Advances by ms, firing each due timer in order at its own due time. Does not wait for I/O the
25
+ * callbacks start; observe effects with spies or by querying.
26
+ */
27
+ tick(ms: number): Promise<void>;
28
+ attach(target: Target): Promise<AsyncDisposable>;
29
+ }
30
+ export {};
31
+ //# sourceMappingURL=clock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAE/E,eAAO,MAAM,WAAW,EAAE,KAMzB,CAAA;AAED,wBAAgB,YAAY,CAAE,KAAK,EAAE,KAAK,GAAG,KAAK,IAAI,eAAe,CAEpE;AASD,UAAU,MAAM;IACd,EAAE,EAAE,SAAS,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAUD;;;;GAIG;AACH,qBAAa,SAAU,YAAW,eAAe;;gBAWlC,KAAK,GAAE,IAAI,GAAG,MAAM,GAAG,MAAmB;IAIvD,GAAG,IAAK,MAAM;IAId,UAAU,CAAE,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,UAAU;IAInD,WAAW,CAAE,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,UAAU;IAIpD,YAAY,CAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IAKvC,aAAa,CAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IAIxC,8DAA8D;IACxD,OAAO,CAAE,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKxD;;;OAGG;IACG,IAAI,CAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsChC,MAAM,CAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;CA4ExD"}
package/dist/clock.js ADDED
@@ -0,0 +1,156 @@
1
+ import { setImmediate } from 'node:timers/promises';
2
+ import * as plans from "./plans.js";
3
+ export const systemClock = {
4
+ now: () => Date.now(),
5
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
6
+ clearTimeout: (handle) => clearTimeout(handle),
7
+ setInterval: (fn, ms) => setInterval(fn, ms),
8
+ clearInterval: (handle) => clearInterval(handle)
9
+ };
10
+ export function isAttachable(clock) {
11
+ return typeof clock.attach === 'function';
12
+ }
13
+ function toMillis(t) {
14
+ const ms = t instanceof Date ? t.getTime() : typeof t === 'number' ? t : new Date(t).getTime();
15
+ if (!Number.isFinite(ms)) {
16
+ throw new Error(`TestClock: invalid time ${String(t)}`);
17
+ }
18
+ return ms;
19
+ }
20
+ /**
21
+ * A clock a test drives by hand. Time only moves through setTime() and tick(); timers only fire
22
+ * from tick(). When attached to a schema, every pg-boss statement there reads the same time
23
+ * through ${schema}.job_now(), so JavaScript and Postgres agree on what "now" is.
24
+ */
25
+ export class TestClock {
26
+ #now;
27
+ #timers = [];
28
+ #seq = 0;
29
+ #ticking = false;
30
+ // Every (db, schema) this clock currently pushes time to. More than one because a multi-instance
31
+ // test shares one clock across several PgBoss instances, each of which attaches on start(). The
32
+ // schema is shared, so the last handle to be disposed is the one that restores job_now() and
33
+ // drops the clock table; the session opt-in is separate, and each instance drops its own.
34
+ #targets = [];
35
+ constructor(start = Date.now()) {
36
+ this.#now = toMillis(start);
37
+ }
38
+ now() {
39
+ return this.#now;
40
+ }
41
+ setTimeout(fn, ms) {
42
+ return this.#schedule(fn, ms, null);
43
+ }
44
+ setInterval(fn, ms) {
45
+ return this.#schedule(fn, ms, Math.max(ms, 1));
46
+ }
47
+ clearTimeout(handle) {
48
+ const i = this.#timers.indexOf(handle);
49
+ if (i !== -1)
50
+ this.#timers.splice(i, 1);
51
+ }
52
+ clearInterval(handle) {
53
+ this.clearTimeout(handle);
54
+ }
55
+ /** Jumps to a time, forwards or backwards, firing nothing. */
56
+ async setTime(t) {
57
+ this.#now = toMillis(t);
58
+ await this.#push();
59
+ }
60
+ /**
61
+ * Advances by ms, firing each due timer in order at its own due time. Does not wait for I/O the
62
+ * callbacks start; observe effects with spies or by querying.
63
+ */
64
+ async tick(ms) {
65
+ if (this.#ticking) {
66
+ throw new Error('TestClock: tick() called while a tick is in progress');
67
+ }
68
+ this.#ticking = true;
69
+ try {
70
+ const target = this.#now + ms;
71
+ while (this.#timers.length && this.#timers[0].due <= target) {
72
+ const timer = this.#timers.shift();
73
+ if (timer.due > this.#now) {
74
+ this.#now = timer.due;
75
+ await this.#push();
76
+ }
77
+ // Reinsert the same object so the handle handed out by setInterval still clears it.
78
+ if (timer.interval !== null) {
79
+ timer.due += timer.interval;
80
+ timer.seq = this.#seq++;
81
+ this.#insert(timer);
82
+ }
83
+ timer.fn();
84
+ await setImmediate();
85
+ }
86
+ this.#now = target;
87
+ await this.#push();
88
+ await setImmediate();
89
+ }
90
+ finally {
91
+ this.#ticking = false;
92
+ }
93
+ }
94
+ async attach(target) {
95
+ const { db, schema } = target;
96
+ // One batch, so the override function is never visible over an empty table: the body reads the
97
+ // single row and COALESCEs to pg_catalog.now() when it finds none, so a seed in a second round
98
+ // trip would serve real time to anything calling job_now() in between. The timestamp is a
99
+ // literal because a parameterised statement cannot carry more than one command; it is derived
100
+ // from this.#now, a number, so there is nothing here to inject.
101
+ //
102
+ // Dropped and recreated rather than IF NOT EXISTS: adopting a table that is already there would
103
+ // mean wiping rows this clock did not write, and dropping it on release. The name is one nobody
104
+ // else would pick, so a table under it is always a leftover from a killed run and safe to take.
105
+ await db.executeSql(`
106
+ DROP TABLE IF EXISTS ${plans.clockTable(schema)};
107
+ CREATE TABLE ${plans.clockTable(schema)} (now timestamp with time zone NOT NULL);
108
+ ${plans.createClockFunction(schema, { replace: true, body: plans.clockOverrideBody(schema) })}
109
+ INSERT INTO ${plans.clockTable(schema)} (now) VALUES (to_timestamp(${this.#now / 1000}));
110
+ `);
111
+ // The session opt-in is not issued here. attach() runs after the contractor has already opened
112
+ // connections and migrated, so a SET on this one session would miss every other one. PgBoss
113
+ // declares it through db.setSessionStatements() before anything opens; see #doStart.
114
+ const entry = { db, schema };
115
+ this.#targets.push(entry);
116
+ let disposed = false;
117
+ return {
118
+ [Symbol.asyncDispose]: async () => {
119
+ if (disposed)
120
+ return;
121
+ disposed = true;
122
+ this.#targets.splice(this.#targets.indexOf(entry), 1);
123
+ if (!this.#targets.some(t => t.schema === schema)) {
124
+ await db.executeSql(`
125
+ ${plans.createClockFunction(schema, { replace: true })}
126
+ DROP TABLE IF EXISTS ${plans.clockTable(schema)};
127
+ ${plans.disableClockOverride()};
128
+ `);
129
+ }
130
+ }
131
+ };
132
+ }
133
+ #schedule(fn, ms, interval) {
134
+ const timer = { fn, due: this.#now + Math.max(ms, 0), seq: this.#seq++, interval };
135
+ this.#insert(timer);
136
+ return timer;
137
+ }
138
+ // Keeps #timers sorted by due time, then by scheduling order for equal due times, so tick() can
139
+ // always take the next timer from the front.
140
+ #insert(timer) {
141
+ let i = this.#timers.length;
142
+ while (i > 0) {
143
+ const previous = this.#timers[i - 1];
144
+ const laterDue = previous.due > timer.due;
145
+ const sameDueScheduledLater = previous.due === timer.due && previous.seq > timer.seq;
146
+ if (!laterDue && !sameDueScheduledLater)
147
+ break;
148
+ i--;
149
+ }
150
+ this.#timers.splice(i, 0, timer);
151
+ }
152
+ async #push() {
153
+ const seconds = this.#now / 1000;
154
+ await Promise.all(this.#targets.map(({ db, schema }) => db.executeSql(`UPDATE ${plans.clockTable(schema)} SET now = to_timestamp($1)`, [seconds])));
155
+ }
156
+ }
@@ -11,7 +11,11 @@ declare class Contractor {
11
11
  isInstalled(): Promise<boolean>;
12
12
  start(): Promise<void>;
13
13
  private assertNoSchemaCaseVariant;
14
- detectDrift(): Promise<types.SchemaDriftReport>;
14
+ detectDrift(options?: {
15
+ clockOverride?: boolean;
16
+ }): Promise<types.SchemaDriftReport>;
17
+ detectClockOverride(): Promise<boolean>;
18
+ restoreClockFunction(): Promise<void>;
15
19
  check(): Promise<void>;
16
20
  create(): Promise<void>;
17
21
  migrate(version: number): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"contractor.d.ts","sourceRoot":"","sources":["../src/contractor.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAkBxC,cAAM,UAAU;IACd,MAAM,CAAC,iBAAiB,CAAE,MAAM,SAAuB,EAAE,OAAO,GAAE,KAAK,CAAC,uBAA4B;IAapG,MAAM,CAAC,cAAc,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAoB,EAAE,OAAO,GAAE,KAAK,CAAC,oBAAyB;IAS3H,MAAM,CAAC,aAAa,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAgB,EAAE,OAAO,GAAE,KAAK,CAAC,WAAgB;IAM7G,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,EAAE,CAAiB;IAC3B,OAAO,CAAC,UAAU,CAAmB;gBAExB,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAgBpE,aAAa;IAKb,WAAW;IAKX,KAAK;YAsBG,yBAAyB;IAkCjC,WAAW,IAAK,OAAO,CAAC,KAAK,CAAC,iBAAiB,CAAC;IA4GhD,KAAK;IAcL,MAAM;IASN,OAAO,CAAE,OAAO,EAAE,MAAM;IASxB,IAAI,CAAE,OAAO,EAAE,MAAM;IAKrB,QAAQ,CAAE,OAAO,EAAE,MAAM;CAIhC;AAED,eAAe,UAAU,CAAA"}
1
+ {"version":3,"file":"contractor.d.ts","sourceRoot":"","sources":["../src/contractor.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAkBxC,cAAM,UAAU;IACd,MAAM,CAAC,iBAAiB,CAAE,MAAM,SAAuB,EAAE,OAAO,GAAE,KAAK,CAAC,uBAA4B;IAapG,MAAM,CAAC,cAAc,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAoB,EAAE,OAAO,GAAE,KAAK,CAAC,oBAAyB;IAS3H,MAAM,CAAC,aAAa,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAgB,EAAE,OAAO,GAAE,KAAK,CAAC,WAAgB;IAM7G,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,EAAE,CAAiB;IAC3B,OAAO,CAAC,UAAU,CAAmB;gBAExB,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAgBpE,aAAa;IAKb,WAAW;IAKX,KAAK;YAsBG,yBAAyB;IAkCjC,WAAW,CAAE,OAAO,GAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,KAAK,CAAC,iBAAiB,CAAC;IAsHzF,mBAAmB,IAAK,OAAO,CAAC,OAAO,CAAC;IASxC,oBAAoB,IAAK,OAAO,CAAC,IAAI,CAAC;IAItC,KAAK;IAcL,MAAM;IASN,OAAO,CAAE,OAAO,EAAE,MAAM;IASxB,IAAI,CAAE,OAAO,EAAE,MAAM;IAKrB,QAAQ,CAAE,OAAO,EAAE,MAAM;CAIhC;AAED,eAAe,UAAU,CAAA"}