pg-boss 12.32.0 → 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.
- package/dist/adapters/pglite.d.ts +1 -0
- package/dist/adapters/pglite.d.ts.map +1 -1
- package/dist/adapters/pglite.js +106 -2
- package/dist/attorney.d.ts.map +1 -1
- package/dist/attorney.js +12 -0
- package/dist/bam.js +2 -2
- package/dist/boss.d.ts.map +1 -1
- package/dist/boss.js +5 -4
- package/dist/cli.js +56 -2
- package/dist/clock.d.ts +31 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +156 -0
- package/dist/contractor.d.ts +5 -1
- package/dist/contractor.d.ts.map +1 -1
- package/dist/contractor.js +24 -2
- package/dist/db.d.ts +6 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +50 -6
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +59 -27
- package/dist/manager.d.ts +2 -2
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +17 -17
- package/dist/migrationStore.d.ts.map +1 -1
- package/dist/migrationStore.js +210 -2
- package/dist/navigator.js +2 -2
- package/dist/plans.d.ts +17 -2
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +189 -83
- package/dist/schema.json +11 -3
- package/dist/timekeeper.d.ts.map +1 -1
- package/dist/timekeeper.js +10 -10
- package/dist/tools.d.ts +9 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +35 -8
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts +3 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +12 -9
- 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;
|
|
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"}
|
package/dist/adapters/pglite.js
CHANGED
|
@@ -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
|
|
124
|
+
return await raceLeaderChange(text, values);
|
|
30
125
|
}
|
|
31
126
|
catch (err) {
|
|
32
|
-
|
|
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
|
package/dist/attorney.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attorney.d.ts","sourceRoot":"","sources":["../src/attorney.ts"],"names":[],"mappings":"
|
|
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.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) {
|
package/dist/boss.d.ts.map
CHANGED
|
@@ -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;
|
|
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 (
|
|
705
|
+
if (this.#config.clock.now() < this.#detectOnly)
|
|
705
706
|
return;
|
|
706
|
-
this.#detectOnly =
|
|
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
|
-
|
|
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}):`);
|
package/dist/clock.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/contractor.d.ts
CHANGED
|
@@ -11,7 +11,11 @@ declare class Contractor {
|
|
|
11
11
|
isInstalled(): Promise<boolean>;
|
|
12
12
|
start(): Promise<void>;
|
|
13
13
|
private assertNoSchemaCaseVariant;
|
|
14
|
-
detectDrift(
|
|
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>;
|
package/dist/contractor.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/contractor.js
CHANGED
|
@@ -113,7 +113,7 @@ class Contractor {
|
|
|
113
113
|
// Presence-level schema drift scan: compares the managed indexes the code expects against the live
|
|
114
114
|
// catalog. Partitioned vs. non-partitioned is read from the database (job_common presence), and the
|
|
115
115
|
// per-queue policy indexes are computed from the queue table, so conditional indexes are handled.
|
|
116
|
-
async detectDrift() {
|
|
116
|
+
async detectDrift(options = {}) {
|
|
117
117
|
const schema = this.config.schema;
|
|
118
118
|
const probe = await this.db.executeSql(plans.jobCommonExists(schema));
|
|
119
119
|
const partitioned = !!probe.rows[0].name;
|
|
@@ -206,12 +206,34 @@ class Contractor {
|
|
|
206
206
|
return drifter.computeSchemaDrift({
|
|
207
207
|
indexes: { expected: expectedIndexes, live, building },
|
|
208
208
|
tables: { expected: plans.expectedManagedTables(schema, partitioned, partitions), live: liveTables ?? [...new Set(liveColumns.map(c => c.table))] },
|
|
209
|
-
functions: functionsSupported ? { expected: plans.expectedManagedFunctions(schema, partitioned), live: liveFunctions } : undefined,
|
|
209
|
+
functions: functionsSupported ? { expected: plans.expectedManagedFunctions(schema, partitioned, options), live: liveFunctions } : undefined,
|
|
210
210
|
columns: { expected: expectedColumns, live: liveColumns },
|
|
211
211
|
constraints: canonicalPg ? { expected: plans.expectedManagedConstraints(schema, partitioned), live: liveConstraints } : undefined,
|
|
212
212
|
enum: { name: 'job_state', expected: plans.EXPECTED_JOB_STATES, actual: enumLabels }
|
|
213
213
|
});
|
|
214
214
|
}
|
|
215
|
+
// A TestClock swaps the body of job_now() and only puts it back when its handle is disposed, so a
|
|
216
|
+
// run killed mid-test (Ctrl-C, a crashed suite, a bailed runner) leaves the override installed.
|
|
217
|
+
// Correctness survives - the body falls through to pg_catalog.now() for any session that has not
|
|
218
|
+
// opted in - but the override's subquery defeats inlining, so every statement that reads the clock
|
|
219
|
+
// pays a per-row function call instead of a constant-folded timestamp, permanently and silently.
|
|
220
|
+
// The leftover travels with the database, which matters most for an embedded one whose data
|
|
221
|
+
// directory gets committed or shipped.
|
|
222
|
+
//
|
|
223
|
+
// Best-effort in both directions: a backend or a role that cannot read pg_proc is not evidence of
|
|
224
|
+
// a problem, and must never block a start that would otherwise succeed.
|
|
225
|
+
async detectClockOverride() {
|
|
226
|
+
try {
|
|
227
|
+
const result = await this.db.executeSql(plans.getClockFunctionSource(this.config.schema));
|
|
228
|
+
return plans.clockFunctionIsOverridden(result.rows[0]?.source);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async restoreClockFunction() {
|
|
235
|
+
await this.db.executeSql(plans.restoreClockFunction(this.config.schema));
|
|
236
|
+
}
|
|
215
237
|
async check() {
|
|
216
238
|
const installed = await this.isInstalled();
|
|
217
239
|
if (!installed) {
|
package/dist/db.d.ts
CHANGED
|
@@ -4,13 +4,18 @@ export declare const TRANSACTION_ROLLBACK_TIMEOUT_MS = 5000;
|
|
|
4
4
|
declare class Db extends EventEmitter implements types.IDatabase, types.EventsMixin {
|
|
5
5
|
private pool;
|
|
6
6
|
private config;
|
|
7
|
+
private clock;
|
|
8
|
+
private sessionStatements;
|
|
7
9
|
opened: boolean;
|
|
8
|
-
constructor(config: types.DatabaseOptions
|
|
10
|
+
constructor(config: types.DatabaseOptions & {
|
|
11
|
+
clock?: types.Clock;
|
|
12
|
+
});
|
|
9
13
|
events: {
|
|
10
14
|
error: string;
|
|
11
15
|
};
|
|
12
16
|
open(): Promise<void>;
|
|
13
17
|
close(): Promise<void>;
|
|
18
|
+
setSessionStatements(statements: string[]): Promise<void>;
|
|
14
19
|
executeSql(text: string, values?: unknown[]): Promise<import("pg").QueryResult<any>>;
|
|
15
20
|
listen(channel: string, onNotification: (payload: string) => void, onReconnect: () => void): Promise<types.ListenHandle>;
|
|
16
21
|
beginTransaction(): Promise<types.TransactionHandle>;
|
package/dist/db.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAItC,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAaxC,eAAO,MAAM,+BAA+B,OAAO,CAAA;AAEnD,cAAM,EAAG,SAAQ,YAAa,YAAW,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,WAAW;IACzE,OAAO,CAAC,IAAI,CAAU;IACtB,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,KAAK,CAAa;IAG1B,OAAO,CAAC,iBAAiB,CAAe;IAGxC,MAAM,EAAE,OAAO,CAAA;gBAEF,MAAM,EAAE,KAAK,CAAC,eAAe,GAAG;QAAE,KAAK,CAAC,EAAE,KAAK,CAAC,KAAK,CAAA;KAAE;IAcpE,MAAM;;MAEL;IAEK,IAAI;IAgBJ,KAAK;IAOL,oBAAoB,CAAE,UAAU,EAAE,MAAM,EAAE;IAoC1C,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE;IAqB5C,MAAM,CACV,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,EACzC,WAAW,EAAE,MAAM,IAAI,GACtB,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC;IA2JxB,gBAAgB,IAAK,OAAO,CAAC,KAAK,CAAC,iBAAiB,CAAC;IAsFrD,eAAe,CAAC,CAAC,EAAG,EAAE,EAAE,CAAC,EAAE,EAAE,KAAK,CAAC,SAAS,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CAY/E;AAED,eAAe,EAAE,CAAA"}
|