pg-boss 12.32.0 → 12.33.1

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.
Files changed (48) hide show
  1. package/dist/adapters/pglite.d.ts +1 -0
  2. package/dist/adapters/pglite.d.ts.map +1 -1
  3. package/dist/adapters/pglite.js +106 -2
  4. package/dist/attorney.d.ts.map +1 -1
  5. package/dist/attorney.js +12 -0
  6. package/dist/bam.d.ts.map +1 -1
  7. package/dist/bam.js +10 -5
  8. package/dist/boss.d.ts.map +1 -1
  9. package/dist/boss.js +24 -7
  10. package/dist/claimTimer.d.ts +44 -0
  11. package/dist/claimTimer.d.ts.map +1 -0
  12. package/dist/claimTimer.js +95 -0
  13. package/dist/cli.js +56 -2
  14. package/dist/clock.d.ts +31 -0
  15. package/dist/clock.d.ts.map +1 -0
  16. package/dist/clock.js +156 -0
  17. package/dist/contractor.d.ts +5 -1
  18. package/dist/contractor.d.ts.map +1 -1
  19. package/dist/contractor.js +24 -2
  20. package/dist/db.d.ts +6 -1
  21. package/dist/db.d.ts.map +1 -1
  22. package/dist/db.js +50 -6
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +59 -27
  26. package/dist/manager.d.ts +2 -2
  27. package/dist/manager.d.ts.map +1 -1
  28. package/dist/manager.js +17 -17
  29. package/dist/migrationStore.d.ts.map +1 -1
  30. package/dist/migrationStore.js +210 -2
  31. package/dist/navigator.d.ts.map +1 -1
  32. package/dist/navigator.js +10 -5
  33. package/dist/plans.d.ts +17 -2
  34. package/dist/plans.d.ts.map +1 -1
  35. package/dist/plans.js +203 -83
  36. package/dist/schema.json +11 -3
  37. package/dist/timekeeper.d.ts +12 -6
  38. package/dist/timekeeper.d.ts.map +1 -1
  39. package/dist/timekeeper.js +64 -20
  40. package/dist/tools.d.ts +9 -3
  41. package/dist/tools.d.ts.map +1 -1
  42. package/dist/tools.js +35 -8
  43. package/dist/types.d.ts +50 -0
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/worker.d.ts +3 -1
  46. package/dist/worker.d.ts.map +1 -1
  47. package/dist/worker.js +12 -9
  48. package/package.json +6 -3
@@ -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
@@ -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,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,CAyB9F;AAiGD,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,
@@ -409,6 +410,7 @@ function getConfig(value) {
409
410
  config.useListenNotify = ('useListenNotify' in config) ? config.useListenNotify : false;
410
411
  config.reindex = ('reindex' in config) ? config.reindex : true;
411
412
  resolveBackend(config);
413
+ applyClockConfig(config);
412
414
  applySchemaConfig(config);
413
415
  applyOpsConfig(config);
414
416
  applyScheduleConfig(config);
@@ -417,6 +419,16 @@ function getConfig(value) {
417
419
  validateWarningConfig(config);
418
420
  return config;
419
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
+ }
420
432
  function applySchemaConfig(config) {
421
433
  if (config.schema) {
422
434
  assertPostgresObjectName(config.schema);
package/dist/bam.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"bam.d.ts","sourceRoot":"","sources":["../src/bam.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAGtC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAOnC,cAAM,GAAI,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAOzD,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAU1C,IAAI,OAAO,IAAK,OAAO,CAEtB;IAEK,KAAK;IAWL,IAAI;CA8HX;AAED,eAAe,GAAG,CAAA"}
1
+ {"version":3,"file":"bam.d.ts","sourceRoot":"","sources":["../src/bam.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAOnC,cAAM,GAAI,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAOzD,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAU1C,IAAI,OAAO,IAAK,OAAO,CAEtB;IAEK,KAAK;IAcL,IAAI;CAkIX;AAED,eAAe,GAAG,CAAA"}
package/dist/bam.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import EventEmitter from 'node:events';
2
+ import { ClaimTimer } from "./claimTimer.js";
2
3
  import * as plans from "./plans.js";
3
4
  import { delay } from "./tools.js";
4
5
  import * as types from "./types.js";
@@ -9,7 +10,7 @@ const events = {
9
10
  class Bam extends EventEmitter {
10
11
  #stopped;
11
12
  #working;
12
- #pollInterval;
13
+ #pollTimer;
13
14
  #db;
14
15
  #config;
15
16
  events = events;
@@ -27,16 +28,17 @@ class Bam extends EventEmitter {
27
28
  if (!this.#stopped)
28
29
  return;
29
30
  this.#stopped = false;
31
+ this.#pollTimer = new ClaimTimer(this.#config.clock, this.#config.bamIntervalSeconds, () => this.#onPoll());
30
32
  setImmediate(() => this.#onPoll());
31
- this.#pollInterval = setInterval(() => this.#onPoll(), this.#config.bamIntervalSeconds * 1000);
33
+ this.#pollTimer.start();
32
34
  }
33
35
  async stop() {
34
36
  if (this.#stopped)
35
37
  return;
36
38
  this.#stopped = true;
37
- if (this.#pollInterval) {
38
- clearInterval(this.#pollInterval);
39
- this.#pollInterval = undefined;
39
+ if (this.#pollTimer) {
40
+ this.#pollTimer.stop();
41
+ this.#pollTimer = undefined;
40
42
  }
41
43
  while (this.#working) {
42
44
  await delay(10);
@@ -55,6 +57,9 @@ class Bam extends EventEmitter {
55
57
  }
56
58
  const sql = plans.trySetBamTime(this.#config.schema, this.#config.bamIntervalSeconds);
57
59
  const { rows } = await this.#db.executeSql(sql);
60
+ // The row is stamped; the next attempt is measured from here rather than from the tick that
61
+ // started this one. See ClaimTimer.
62
+ this.#pollTimer?.anchor();
58
63
  if (rows.length === 1) {
59
64
  await this.#processCommands();
60
65
  }
@@ -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;AAEtC,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;IAaL,IAAI;IAmGJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,KAAK,CAAC,gBAAgB;IAorBvF;;;;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
@@ -1,4 +1,5 @@
1
1
  import EventEmitter from 'node:events';
2
+ import { ClaimTimer } from "./claimTimer.js";
2
3
  import * as plans from "./plans.js";
3
4
  import { delay, unwrapSQLResult } from "./tools.js";
4
5
  import * as types from "./types.js";
@@ -75,7 +76,7 @@ class Boss extends EventEmitter {
75
76
  #stopped;
76
77
  #stopping;
77
78
  #maintaining;
78
- #superviseInterval;
79
+ #superviseTimer;
79
80
  #db;
80
81
  #config;
81
82
  #manager;
@@ -125,15 +126,16 @@ class Boss extends EventEmitter {
125
126
  async start() {
126
127
  if (this.#stopped) {
127
128
  this.#stopping = false;
128
- this.#superviseInterval = setInterval(() => this.#onSupervise(), this.#config.superviseIntervalSeconds * 1000);
129
+ this.#superviseTimer = new ClaimTimer(this.#config.clock, this.#config.superviseIntervalSeconds, () => this.#onSupervise());
130
+ this.#superviseTimer.start();
129
131
  this.#stopped = false;
130
132
  }
131
133
  }
132
134
  async stop() {
133
135
  if (!this.#stopped) {
134
136
  this.#stopping = true;
135
- if (this.#superviseInterval)
136
- clearInterval(this.#superviseInterval);
137
+ if (this.#superviseTimer)
138
+ this.#superviseTimer.stop();
137
139
  this.#stopped = true;
138
140
  while (this.#maintaining) {
139
141
  await delay(10);
@@ -154,6 +156,7 @@ class Boss extends EventEmitter {
154
156
  if (typeof (query) === 'string') {
155
157
  query = { text: query, values: [] };
156
158
  }
159
+ // Real time: a stopwatch around I/O reads zero on a frozen clock.
157
160
  const started = Date.now();
158
161
  const result = unwrapSQLResult(await this.#db.executeSql(query.text, query.values));
159
162
  const elapsed = (Date.now() - started) / 1000;
@@ -177,7 +180,11 @@ class Boss extends EventEmitter {
177
180
  await delay(this.#config.__test__delay_maint_ms);
178
181
  }
179
182
  const queues = await this.#manager.getQueues();
180
- !this.#stopped && (await this.supervise(queues));
183
+ // The timer's own pass is the only one that re-anchors it. A supervise() call from the
184
+ // application stamps the same claims, but it may be scoped to a single queue, and anchoring
185
+ // on it would push the background pass - the one that covers every other queue - out by a
186
+ // full interval each time. An application polling one hot queue would starve the rest.
187
+ !this.#stopped && (await this.#supervisePass(queues, undefined, () => this.#superviseTimer?.anchor()));
181
188
  }
182
189
  catch (err) {
183
190
  this.emit(events.error, err);
@@ -207,6 +214,11 @@ class Boss extends EventEmitter {
207
214
  await this.#executeQuery(sql);
208
215
  }
209
216
  async supervise(value, options) {
217
+ await this.#supervisePass(value, options);
218
+ }
219
+ // onClaimsSettled fires once every monitor and maintain claim in the pass has been stamped, which
220
+ // is where the background timer measures its next attempt from. Only #onSupervise passes one.
221
+ async #supervisePass(value, options, onClaimsSettled) {
210
222
  let queues;
211
223
  if (Array.isArray(value)) {
212
224
  queues = value;
@@ -241,6 +253,11 @@ class Boss extends EventEmitter {
241
253
  await this.#maintain(table, chunk);
242
254
  }
243
255
  }
256
+ // Every monitor and maintain claim in this pass has now been stamped, so the next pass is
257
+ // measured from here rather than from the tick that started this one. See ClaimTimer. Before
258
+ // the tail below and not at the end of the pass: the tail ends in a rebuild that is DDL and can
259
+ // run for seconds, and the claims must not be measured from the far side of it.
260
+ onClaimsSettled?.();
244
261
  if (this.#stopping)
245
262
  return;
246
263
  // Immediately after the aggregates it measures, not at the head of the next pass. Deferring the
@@ -701,9 +718,9 @@ class Boss extends EventEmitter {
701
718
  return;
702
719
  }
703
720
  else {
704
- if (Date.now() < this.#detectOnly)
721
+ if (this.#config.clock.now() < this.#detectOnly)
705
722
  return;
706
- this.#detectOnly = Date.now() + this.#config.reindexIntervalSeconds * 1000;
723
+ this.#detectOnly = this.#config.clock.now() + this.#config.reindexIntervalSeconds * 1000;
707
724
  }
708
725
  }
709
726
  if (this.#stopping)
@@ -0,0 +1,44 @@
1
+ import type { Clock } from './types.ts';
2
+ /**
3
+ * The timer behind an interval claim, anchored to the claim instead of to a fixed grid.
4
+ *
5
+ * An interval claim is a conditional UPDATE that only goes through when the row it stamps is at
6
+ * least `seconds` old by the server's clock, so exactly one instance in a deployment runs the pass
7
+ * per interval (see plans.trySetTimestamp). Every instance tries on a timer of its own.
8
+ *
9
+ * setInterval schedules the next tick from a grid fixed before the callback runs, while the row is
10
+ * stamped when the UPDATE reaches the server - a moment later by however long the pool wait, the
11
+ * round trip and the plan took. The two do not move together. When a tick's statement lands faster
12
+ * than the previous tick's did, the two stamps come out closer together than the period, the claim
13
+ * is refused, and the pass after it is a whole interval late. Measured on an idle Linux box that is
14
+ * 4 of 19 ticks at a 45-second period, all of them between 44.990 and 44.998 seconds old, and a
15
+ * quarter of the queue monitor's passes at its defaults. For the cron pass a lost claim is a
16
+ * 90-second gap against a 60-second due window, and the occurrences inside it are never sent.
17
+ *
18
+ * Anchoring removes the mismatch rather than tolerating it: the next attempt is scheduled once the
19
+ * claim has been stamped, so the gap between two stamps is the period plus one round trip rather
20
+ * than the period minus the difference between two of them. It can only ever come out long, never
21
+ * short, which is the direction that costs nothing - a pass never runs before its interval is up.
22
+ *
23
+ * Note what does not appear here: a clock. The claim compares two timestamps that the server wrote,
24
+ * so the client's clock is already absent from it and skew between the two cancels exactly. There
25
+ * is nothing for a skew correction to correct, which is why the fix is in when the attempt is made
26
+ * rather than in what it is measured against.
27
+ *
28
+ * `anchor()` is what a pass calls once its claim has been stamped. A pass that returns without ever
29
+ * reaching its claim - stopped, already working, an error on the way in - is re-armed from the end
30
+ * of the callback instead, so the chain cannot die on a path that never anchored it.
31
+ */
32
+ export declare class ClaimTimer {
33
+ #private;
34
+ constructor(clock: Clock, seconds: number, fn: () => Promise<void>);
35
+ start(): void;
36
+ stop(): void;
37
+ /**
38
+ * Re-anchors the next attempt to now, because the claim this timer drives has just been stamped.
39
+ * Called whether the claim was won or lost: the winner needs its next attempt to fall after its
40
+ * own stamp, and a loser's phase is its own business either way.
41
+ */
42
+ anchor(): void;
43
+ }
44
+ //# sourceMappingURL=claimTimer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claimTimer.d.ts","sourceRoot":"","sources":["../src/claimTimer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAc,MAAM,YAAY,CAAA;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,qBAAa,UAAU;;gBASR,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC;IAMnE,KAAK,IAAK,IAAI;IAOd,IAAI,IAAK,IAAI;IAKb;;;;OAIG;IACH,MAAM,IAAK,IAAI;CAwChB"}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The timer behind an interval claim, anchored to the claim instead of to a fixed grid.
3
+ *
4
+ * An interval claim is a conditional UPDATE that only goes through when the row it stamps is at
5
+ * least `seconds` old by the server's clock, so exactly one instance in a deployment runs the pass
6
+ * per interval (see plans.trySetTimestamp). Every instance tries on a timer of its own.
7
+ *
8
+ * setInterval schedules the next tick from a grid fixed before the callback runs, while the row is
9
+ * stamped when the UPDATE reaches the server - a moment later by however long the pool wait, the
10
+ * round trip and the plan took. The two do not move together. When a tick's statement lands faster
11
+ * than the previous tick's did, the two stamps come out closer together than the period, the claim
12
+ * is refused, and the pass after it is a whole interval late. Measured on an idle Linux box that is
13
+ * 4 of 19 ticks at a 45-second period, all of them between 44.990 and 44.998 seconds old, and a
14
+ * quarter of the queue monitor's passes at its defaults. For the cron pass a lost claim is a
15
+ * 90-second gap against a 60-second due window, and the occurrences inside it are never sent.
16
+ *
17
+ * Anchoring removes the mismatch rather than tolerating it: the next attempt is scheduled once the
18
+ * claim has been stamped, so the gap between two stamps is the period plus one round trip rather
19
+ * than the period minus the difference between two of them. It can only ever come out long, never
20
+ * short, which is the direction that costs nothing - a pass never runs before its interval is up.
21
+ *
22
+ * Note what does not appear here: a clock. The claim compares two timestamps that the server wrote,
23
+ * so the client's clock is already absent from it and skew between the two cancels exactly. There
24
+ * is nothing for a skew correction to correct, which is why the fix is in when the attempt is made
25
+ * rather than in what it is measured against.
26
+ *
27
+ * `anchor()` is what a pass calls once its claim has been stamped. A pass that returns without ever
28
+ * reaching its claim - stopped, already working, an error on the way in - is re-armed from the end
29
+ * of the callback instead, so the chain cannot die on a path that never anchored it.
30
+ */
31
+ export class ClaimTimer {
32
+ #clock;
33
+ #ms;
34
+ #fn;
35
+ #handle;
36
+ #stopped = true;
37
+ #anchored = false;
38
+ constructor(clock, seconds, fn) {
39
+ this.#clock = clock;
40
+ this.#ms = seconds * 1000;
41
+ this.#fn = fn;
42
+ }
43
+ start() {
44
+ if (!this.#stopped)
45
+ return;
46
+ this.#stopped = false;
47
+ this.#arm();
48
+ }
49
+ stop() {
50
+ this.#stopped = true;
51
+ this.#disarm();
52
+ }
53
+ /**
54
+ * Re-anchors the next attempt to now, because the claim this timer drives has just been stamped.
55
+ * Called whether the claim was won or lost: the winner needs its next attempt to fall after its
56
+ * own stamp, and a loser's phase is its own business either way.
57
+ */
58
+ anchor() {
59
+ if (this.#stopped)
60
+ return;
61
+ this.#anchored = true;
62
+ this.#arm();
63
+ }
64
+ #arm() {
65
+ this.#disarm();
66
+ if (this.#stopped)
67
+ return;
68
+ this.#handle = this.#clock.setTimeout(() => { this.#run(); }, this.#ms);
69
+ }
70
+ #disarm() {
71
+ if (this.#handle !== undefined) {
72
+ this.#clock.clearTimeout(this.#handle);
73
+ this.#handle = undefined;
74
+ }
75
+ }
76
+ async #run() {
77
+ this.#handle = undefined;
78
+ this.#anchored = false;
79
+ // Deliberately not caught. Every pass this drives ends its own catch in an error event, so the
80
+ // only rejection that reaches here is one that could not be reported - an error event with
81
+ // nothing listening, which EventEmitter throws on. That belongs to the process, the way it did
82
+ // when these ran on setInterval; a timer that turned it into anything else would be inventing a
83
+ // failure mode for the host to handle. What the timer does owe is the chain, and finally pays
84
+ // that whether the pass resolved or not.
85
+ try {
86
+ await this.#fn();
87
+ }
88
+ finally {
89
+ // A pass that reached its claim already armed the next attempt through anchor(); this is the
90
+ // path for one that did not, including one that threw.
91
+ if (!this.#anchored)
92
+ this.#arm();
93
+ }
94
+ }
95
+ }
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"}