@checkstack/backend-api 0.18.0 → 0.20.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/CHANGELOG.md +251 -0
- package/package.json +10 -8
- package/src/advisory-lock-pool.it.test.ts +282 -0
- package/src/advisory-lock.it.test.ts +111 -0
- package/src/advisory-lock.test.ts +273 -0
- package/src/advisory-lock.ts +216 -0
- package/src/collector-strategy.ts +9 -0
- package/src/core-services.ts +7 -0
- package/src/esm-script-runner.test.ts +93 -1
- package/src/esm-script-runner.ts +53 -2
- package/src/index.ts +1 -0
- package/src/plugin-system.ts +14 -0
- package/src/schema-utils.test.ts +44 -0
- package/src/schema-utils.ts +6 -0
- package/src/zod-config.ts +33 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test (real Postgres) for the advisory-lock service.
|
|
3
|
+
*
|
|
4
|
+
* This is part of the surgical integration lane (plan §14.4 #1). It pins the
|
|
5
|
+
* one behaviour fakes cannot model faithfully: Postgres session-level advisory
|
|
6
|
+
* locks are tied to the DB *connection* that acquired them, so the holding
|
|
7
|
+
* client must be the same one that releases — and killing the holding
|
|
8
|
+
* connection must auto-release the lock.
|
|
9
|
+
*
|
|
10
|
+
* Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it. The
|
|
11
|
+
* `integration` CI job sets that flag and provides a real Postgres service
|
|
12
|
+
* container. Connection comes from `CHECKSTACK_IT_PG_URL` (defaulting to the
|
|
13
|
+
* `docker-compose-dev.yml` Postgres port).
|
|
14
|
+
*/
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
16
|
+
import { Pool } from "pg";
|
|
17
|
+
import { createAdvisoryLockService } from "./advisory-lock";
|
|
18
|
+
|
|
19
|
+
const PG_URL =
|
|
20
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
21
|
+
"postgres://postgres:postgres@localhost:5432/postgres";
|
|
22
|
+
|
|
23
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
24
|
+
"advisory-lock (real Postgres)",
|
|
25
|
+
() => {
|
|
26
|
+
let pool: Pool;
|
|
27
|
+
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
pool = new Pool({ connectionString: PG_URL });
|
|
30
|
+
// A pooled client can error asynchronously while idle (e.g. its backend
|
|
31
|
+
// is terminated by the kill test below). pg emits that on the pool; with
|
|
32
|
+
// no handler it surfaces as an unhandled "Connection terminated
|
|
33
|
+
// unexpectedly" error that fails the whole test file. Swallowing idle-
|
|
34
|
+
// client errors is the documented pg pattern - the tests still assert
|
|
35
|
+
// behaviour through fresh checkouts.
|
|
36
|
+
pool.on("error", () => {});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await pool.end();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("a second tryAcquire of the same key returns null until release", async () => {
|
|
44
|
+
const service = createAdvisoryLockService(pool);
|
|
45
|
+
const key = `it-advisory-lock:${crypto.randomUUID()}`;
|
|
46
|
+
|
|
47
|
+
const first = await service.tryAcquire(key);
|
|
48
|
+
expect(first).not.toBeNull();
|
|
49
|
+
|
|
50
|
+
// The lock is held — a concurrent acquire of the SAME key must fail.
|
|
51
|
+
const second = await service.tryAcquire(key);
|
|
52
|
+
expect(second).toBeNull();
|
|
53
|
+
|
|
54
|
+
// After release, a third acquire succeeds.
|
|
55
|
+
await first?.release();
|
|
56
|
+
const third = await service.tryAcquire(key);
|
|
57
|
+
expect(third).not.toBeNull();
|
|
58
|
+
await third?.release();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("killing the holding connection auto-releases the lock", async () => {
|
|
62
|
+
const service = createAdvisoryLockService(pool);
|
|
63
|
+
const key = `it-advisory-lock:${crypto.randomUUID()}`;
|
|
64
|
+
|
|
65
|
+
// Acquire on a dedicated client owned by the handle.
|
|
66
|
+
const held = await service.tryAcquire(key);
|
|
67
|
+
expect(held).not.toBeNull();
|
|
68
|
+
|
|
69
|
+
// While held, the key is unavailable.
|
|
70
|
+
const blocked = await service.tryAcquire(key);
|
|
71
|
+
expect(blocked).toBeNull();
|
|
72
|
+
|
|
73
|
+
// Terminate ONLY the backend holding the advisory lock - found via
|
|
74
|
+
// `pg_locks` - from a fresh connection. Dropping that session makes
|
|
75
|
+
// Postgres auto-release the lock. We deliberately do NOT kill every other
|
|
76
|
+
// backend (the old approach): that also terminated the pool's idle
|
|
77
|
+
// connections, whose async "connection terminated" errors flaked the test
|
|
78
|
+
// and left the pool unusable. The handle holds exactly one advisory lock,
|
|
79
|
+
// so this targets precisely the holder.
|
|
80
|
+
const killer = await pool.connect();
|
|
81
|
+
try {
|
|
82
|
+
await killer.query(
|
|
83
|
+
`SELECT pg_terminate_backend(pid)
|
|
84
|
+
FROM pg_locks
|
|
85
|
+
WHERE locktype = 'advisory'
|
|
86
|
+
AND pid <> pg_backend_pid()`,
|
|
87
|
+
);
|
|
88
|
+
} finally {
|
|
89
|
+
killer.release();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The lock should now be acquirable again. Retry briefly because the
|
|
93
|
+
// server takes a moment to reap the terminated backend's session locks.
|
|
94
|
+
let reacquired: Awaited<ReturnType<typeof service.tryAcquire>> = null;
|
|
95
|
+
for (let attempt = 0; attempt < 20 && reacquired === null; attempt++) {
|
|
96
|
+
reacquired = await service.tryAcquire(key);
|
|
97
|
+
if (reacquired === null) {
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
expect(reacquired).not.toBeNull();
|
|
102
|
+
await reacquired?.release();
|
|
103
|
+
|
|
104
|
+
// The `held` handle still owns its (now-terminated) client. Release it so
|
|
105
|
+
// the dead client is returned to the pool - otherwise `pool.end()` in
|
|
106
|
+
// afterAll blocks waiting for the checked-out client to drain. The unlock
|
|
107
|
+
// query runs against a dead connection and rejects; that's expected.
|
|
108
|
+
await held?.release().catch(() => {});
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
);
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createAdvisoryLockService,
|
|
4
|
+
type AdvisoryLockPool,
|
|
5
|
+
type AdvisoryLockPoolClient,
|
|
6
|
+
} from "./advisory-lock";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Faithful fake of a `pg.Pool` that models Postgres' per-connection
|
|
10
|
+
* advisory-lock semantics for BOTH lock flavours:
|
|
11
|
+
*
|
|
12
|
+
* SESSION locks (`tryAcquire`):
|
|
13
|
+
* - A key can be held by at most one connection at a time.
|
|
14
|
+
* - `pg_try_advisory_lock` succeeds only if the key is free; it then
|
|
15
|
+
* binds the key to the acquiring connection.
|
|
16
|
+
* - `pg_advisory_unlock` only frees the key if THIS connection holds it
|
|
17
|
+
* (a no-op otherwise) — exactly the bug we are guarding against: an
|
|
18
|
+
* unlock issued on a different connection does nothing.
|
|
19
|
+
*
|
|
20
|
+
* TRANSACTION locks (`withXactLock`):
|
|
21
|
+
* - `pg_advisory_xact_lock` BLOCKS until the key is free, then binds it to
|
|
22
|
+
* the acquiring connection's transaction.
|
|
23
|
+
* - `COMMIT` / `ROLLBACK` release every xact lock held by that connection
|
|
24
|
+
* and wake the next blocked waiter (FIFO) — modelling auto-release and
|
|
25
|
+
* the serialization guarantee.
|
|
26
|
+
*
|
|
27
|
+
* This lets the tests prove the service keeps acquire + release on ONE
|
|
28
|
+
* client and that concurrent `withXactLock` callers serialize.
|
|
29
|
+
*/
|
|
30
|
+
interface FakePool extends AdvisoryLockPool {
|
|
31
|
+
checkedOut: number;
|
|
32
|
+
released: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeFakePool(): FakePool {
|
|
36
|
+
// key -> owning connection id (or absent if free)
|
|
37
|
+
const heldBy = new Map<string, number>();
|
|
38
|
+
// xact key -> owning connection id; waiters queued FIFO per key.
|
|
39
|
+
const xactHeldBy = new Map<string, number>();
|
|
40
|
+
const xactWaiters = new Map<string, Array<() => void>>();
|
|
41
|
+
let nextConnId = 0;
|
|
42
|
+
const counters = { checkedOut: 0, released: 0 };
|
|
43
|
+
|
|
44
|
+
// hashtextextended($1, 0) is opaque here — we just key on the raw string,
|
|
45
|
+
// which is faithful since the SQL is deterministic per key.
|
|
46
|
+
function keyOf(values: unknown[] | undefined): string {
|
|
47
|
+
return String(values?.[0]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
get checkedOut() {
|
|
52
|
+
return counters.checkedOut;
|
|
53
|
+
},
|
|
54
|
+
get released() {
|
|
55
|
+
return counters.released;
|
|
56
|
+
},
|
|
57
|
+
async connect(): Promise<AdvisoryLockPoolClient> {
|
|
58
|
+
const connId = nextConnId++;
|
|
59
|
+
counters.checkedOut++;
|
|
60
|
+
const releaseXactLocks = () => {
|
|
61
|
+
for (const [key, owner] of [...xactHeldBy.entries()]) {
|
|
62
|
+
if (owner !== connId) continue;
|
|
63
|
+
xactHeldBy.delete(key);
|
|
64
|
+
const next = xactWaiters.get(key)?.shift();
|
|
65
|
+
if (next) next();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
async query<T>(queryText: string, values?: unknown[]) {
|
|
70
|
+
if (queryText === "BEGIN") return { rows: [] };
|
|
71
|
+
if (queryText === "COMMIT" || queryText === "ROLLBACK") {
|
|
72
|
+
releaseXactLocks();
|
|
73
|
+
return { rows: [] };
|
|
74
|
+
}
|
|
75
|
+
const key = keyOf(values);
|
|
76
|
+
if (queryText.includes("pg_try_advisory_lock")) {
|
|
77
|
+
const owner = heldBy.get(key);
|
|
78
|
+
const ok = owner === undefined;
|
|
79
|
+
if (ok) heldBy.set(key, connId);
|
|
80
|
+
return { rows: [{ ok } as unknown as T] };
|
|
81
|
+
}
|
|
82
|
+
if (queryText.includes("pg_advisory_xact_lock")) {
|
|
83
|
+
if (!xactHeldBy.has(key)) {
|
|
84
|
+
xactHeldBy.set(key, connId);
|
|
85
|
+
return { rows: [] };
|
|
86
|
+
}
|
|
87
|
+
// Blocked: enqueue and wait until a holder commits/rolls back.
|
|
88
|
+
await new Promise<void>((resolve) => {
|
|
89
|
+
const q = xactWaiters.get(key) ?? [];
|
|
90
|
+
q.push(() => {
|
|
91
|
+
xactHeldBy.set(key, connId);
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
xactWaiters.set(key, q);
|
|
95
|
+
});
|
|
96
|
+
return { rows: [] };
|
|
97
|
+
}
|
|
98
|
+
if (queryText.includes("pg_advisory_unlock")) {
|
|
99
|
+
// Only the owning connection can release — model the leak bug.
|
|
100
|
+
if (heldBy.get(key) === connId) heldBy.delete(key);
|
|
101
|
+
return { rows: [{ ok: true } as unknown as T] };
|
|
102
|
+
}
|
|
103
|
+
return { rows: [] };
|
|
104
|
+
},
|
|
105
|
+
release() {
|
|
106
|
+
counters.released++;
|
|
107
|
+
},
|
|
108
|
+
on() {
|
|
109
|
+
// The fake never emits async client errors; the real client's
|
|
110
|
+
// `on('error')` hardening is exercised by the IT against real
|
|
111
|
+
// Postgres (killing the holding connection).
|
|
112
|
+
},
|
|
113
|
+
off() {
|
|
114
|
+
// Counterpart to `on`; the service detaches its error listener on
|
|
115
|
+
// release. No-op here since the fake never attaches one.
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("createAdvisoryLockService", () => {
|
|
123
|
+
it("acquire → second acquire fails while held → release → third acquire succeeds", async () => {
|
|
124
|
+
const pool = makeFakePool();
|
|
125
|
+
const svc = createAdvisoryLockService(pool);
|
|
126
|
+
|
|
127
|
+
const first = await svc.tryAcquire("k");
|
|
128
|
+
expect(first).not.toBeNull();
|
|
129
|
+
|
|
130
|
+
// Held: a second acquire (different pooled connection) must fail.
|
|
131
|
+
const second = await svc.tryAcquire("k");
|
|
132
|
+
expect(second).toBeNull();
|
|
133
|
+
|
|
134
|
+
// Release on the SAME client that acquired (the bug is release no-op'ing
|
|
135
|
+
// because it ran on a different connection).
|
|
136
|
+
await first!.release();
|
|
137
|
+
|
|
138
|
+
const third = await svc.tryAcquire("k");
|
|
139
|
+
expect(third).not.toBeNull();
|
|
140
|
+
await third!.release();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns the client to the pool on both the failed-acquire and release paths", async () => {
|
|
144
|
+
const pool = makeFakePool();
|
|
145
|
+
const svc = createAdvisoryLockService(pool);
|
|
146
|
+
|
|
147
|
+
const h = await svc.tryAcquire("k");
|
|
148
|
+
const blocked = await svc.tryAcquire("k"); // fails → must release client
|
|
149
|
+
expect(blocked).toBeNull();
|
|
150
|
+
await h!.release();
|
|
151
|
+
|
|
152
|
+
// 2 connects (one held+released, one failed+released) => 2 releases.
|
|
153
|
+
expect(pool.checkedOut).toBe(2);
|
|
154
|
+
expect(pool.released).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("release is idempotent", async () => {
|
|
158
|
+
const pool = makeFakePool();
|
|
159
|
+
const svc = createAdvisoryLockService(pool);
|
|
160
|
+
const h = await svc.tryAcquire("k");
|
|
161
|
+
await h!.release();
|
|
162
|
+
await h!.release(); // no throw, no double client.release
|
|
163
|
+
expect(pool.released).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("different keys do not block each other", async () => {
|
|
167
|
+
const pool = makeFakePool();
|
|
168
|
+
const svc = createAdvisoryLockService(pool);
|
|
169
|
+
const a = await svc.tryAcquire("a");
|
|
170
|
+
const b = await svc.tryAcquire("b");
|
|
171
|
+
expect(a).not.toBeNull();
|
|
172
|
+
expect(b).not.toBeNull();
|
|
173
|
+
await a!.release();
|
|
174
|
+
await b!.release();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("createAdvisoryLockService.withXactLock", () => {
|
|
179
|
+
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
|
|
180
|
+
|
|
181
|
+
it("runs fn, returns its value, and releases the client", async () => {
|
|
182
|
+
const pool = makeFakePool();
|
|
183
|
+
const svc = createAdvisoryLockService(pool);
|
|
184
|
+
const result = await svc.withXactLock({ key: "k", fn: async () => 42 });
|
|
185
|
+
expect(result).toBe(42);
|
|
186
|
+
expect(pool.checkedOut).toBe(1);
|
|
187
|
+
expect(pool.released).toBe(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("serializes concurrent calls on the same key (second fn waits for first to commit)", async () => {
|
|
191
|
+
const pool = makeFakePool();
|
|
192
|
+
const svc = createAdvisoryLockService(pool);
|
|
193
|
+
const order: string[] = [];
|
|
194
|
+
|
|
195
|
+
let releaseFirst!: () => void;
|
|
196
|
+
const firstHeld = new Promise<void>((r) => (releaseFirst = r));
|
|
197
|
+
|
|
198
|
+
const p1 = svc.withXactLock({
|
|
199
|
+
key: "k",
|
|
200
|
+
fn: async () => {
|
|
201
|
+
order.push("1-start");
|
|
202
|
+
await firstHeld;
|
|
203
|
+
order.push("1-end");
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Let p1 acquire the lock before p2 attempts it.
|
|
208
|
+
await tick();
|
|
209
|
+
const p2 = svc.withXactLock({
|
|
210
|
+
key: "k",
|
|
211
|
+
fn: async () => {
|
|
212
|
+
order.push("2-start");
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// While p1 holds the lock, p2's fn must NOT have started.
|
|
217
|
+
await tick();
|
|
218
|
+
expect(order).toEqual(["1-start"]);
|
|
219
|
+
|
|
220
|
+
releaseFirst();
|
|
221
|
+
await Promise.all([p1, p2]);
|
|
222
|
+
expect(order).toEqual(["1-start", "1-end", "2-start"]);
|
|
223
|
+
expect(pool.released).toBe(2);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("rolls back and releases the client when fn throws, freeing the lock", async () => {
|
|
227
|
+
const pool = makeFakePool();
|
|
228
|
+
const svc = createAdvisoryLockService(pool);
|
|
229
|
+
|
|
230
|
+
await expect(
|
|
231
|
+
svc.withXactLock({
|
|
232
|
+
key: "k",
|
|
233
|
+
fn: async () => {
|
|
234
|
+
throw new Error("boom");
|
|
235
|
+
},
|
|
236
|
+
}),
|
|
237
|
+
).rejects.toThrow("boom");
|
|
238
|
+
|
|
239
|
+
// Lock was released on rollback: a subsequent acquire succeeds promptly.
|
|
240
|
+
const after = await svc.withXactLock({ key: "k", fn: async () => "ok" });
|
|
241
|
+
expect(after).toBe("ok");
|
|
242
|
+
expect(pool.released).toBe(2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("different keys do not serialize", async () => {
|
|
246
|
+
const pool = makeFakePool();
|
|
247
|
+
const svc = createAdvisoryLockService(pool);
|
|
248
|
+
const started: string[] = [];
|
|
249
|
+
|
|
250
|
+
let release!: () => void;
|
|
251
|
+
const held = new Promise<void>((r) => (release = r));
|
|
252
|
+
|
|
253
|
+
const pA = svc.withXactLock({
|
|
254
|
+
key: "a",
|
|
255
|
+
fn: async () => {
|
|
256
|
+
started.push("a");
|
|
257
|
+
await held;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
await tick();
|
|
261
|
+
// Key "b" must run even while "a" is still held.
|
|
262
|
+
await svc.withXactLock({
|
|
263
|
+
key: "b",
|
|
264
|
+
fn: async () => {
|
|
265
|
+
started.push("b");
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
expect(started).toContain("b");
|
|
269
|
+
|
|
270
|
+
release();
|
|
271
|
+
await pA;
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres advisory-lock helpers with correct connection affinity.
|
|
3
|
+
*
|
|
4
|
+
* Postgres session-level advisory locks (`pg_try_advisory_lock` /
|
|
5
|
+
* `pg_advisory_unlock`) are tied to the DB *session* (connection) that
|
|
6
|
+
* acquired them. The platform runs every plugin query through a
|
|
7
|
+
* schema-scoped proxy that wraps each statement in its own short
|
|
8
|
+
* transaction on a connection borrowed from the shared pool and returned
|
|
9
|
+
* immediately. Acquiring a session lock through that proxy therefore runs
|
|
10
|
+
* the lock on one pooled connection and the unlock on a *different* one —
|
|
11
|
+
* so the unlock no-ops and the lock leaks until the original connection is
|
|
12
|
+
* recycled. This module fixes that two ways:
|
|
13
|
+
*
|
|
14
|
+
* - {@link AdvisoryLockService.tryAcquire} checks out ONE dedicated
|
|
15
|
+
* client from the pool, acquires the session lock on it, and returns a
|
|
16
|
+
* handle that owns that client. `release()` runs the unlock on the SAME
|
|
17
|
+
* client and then returns it to the pool. Use this for long-held locks
|
|
18
|
+
* (e.g. an installer election held across a minutes-long `bun install`)
|
|
19
|
+
* where a long-open transaction would be unacceptable.
|
|
20
|
+
*
|
|
21
|
+
* - {@link AdvisoryLockService.withXactLock} wraps acquire + work + release
|
|
22
|
+
* in a single transaction using `pg_advisory_xact_lock`, which auto-
|
|
23
|
+
* releases at COMMIT/ROLLBACK. Use this for SHORT critical sections (e.g. a
|
|
24
|
+
* find-then-create dedup) where holding a transaction for the duration is
|
|
25
|
+
* fine and the auto-release removes any chance of a leak.
|
|
26
|
+
*
|
|
27
|
+
* BOTH run on the service's pool, which MUST be a pool dedicated to advisory
|
|
28
|
+
* locks (separate from the pool the locked work runs on). A held lock keeps its
|
|
29
|
+
* connection checked out for the lock's lifetime; if lock and work shared one
|
|
30
|
+
* pool, concurrency >= pool size would deadlock (every slot a lock-holder
|
|
31
|
+
* waiting for a work connection). The backend wires this to a dedicated
|
|
32
|
+
* `lockPool`; that pool also sets `idle_in_transaction_session_timeout` /
|
|
33
|
+
* `lock_timeout` so a stalled critical section cannot strand a lock forever.
|
|
34
|
+
*
|
|
35
|
+
* Keys are arbitrary strings hashed to Postgres' 64-bit lock space via
|
|
36
|
+
* `hashtextextended(key, 0)`. Callers SHOULD namespace keys (e.g.
|
|
37
|
+
* `"script-packages.installer"`, `"incident.dedupe:<systemId>"`) since the
|
|
38
|
+
* advisory-lock space is global to the database server, not schema-scoped.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Minimal pool surface this module needs. Modelled on `pg.Pool` /
|
|
43
|
+
* `pg.PoolClient` without importing `pg` directly so the helper stays a
|
|
44
|
+
* pure type-level contract; the backend wires in the real `adminPool`.
|
|
45
|
+
*/
|
|
46
|
+
export interface AdvisoryLockPoolClient {
|
|
47
|
+
query<T>(
|
|
48
|
+
queryText: string,
|
|
49
|
+
values?: unknown[],
|
|
50
|
+
): Promise<{ rows: T[] }>;
|
|
51
|
+
/** Return the client to the pool. */
|
|
52
|
+
release(): void;
|
|
53
|
+
/**
|
|
54
|
+
* Subscribe to async client errors. A held client (session lock, or an open
|
|
55
|
+
* xact-lock transaction) is checked out for a while; if its backend dies
|
|
56
|
+
* (admin termination, failover, network drop) `pg` emits `'error'` on the
|
|
57
|
+
* client, and an `'error'` with no listener is re-thrown by the EventEmitter
|
|
58
|
+
* and would crash the pod. We attach a listener so that loss degrades
|
|
59
|
+
* gracefully instead. Modelled on `pg.Client.on`.
|
|
60
|
+
*/
|
|
61
|
+
on(event: "error", listener: (err: Error) => void): void;
|
|
62
|
+
/**
|
|
63
|
+
* Detach a previously-attached error listener. MUST be called before
|
|
64
|
+
* returning the client to the pool: pooled clients are reused, so attaching a
|
|
65
|
+
* fresh listener on every checkout WITHOUT removing it on release leaks one
|
|
66
|
+
* listener per acquisition on each long-lived physical connection (an
|
|
67
|
+
* unbounded `MaxListenersExceeded` leak). Modelled on `pg.Client.off`.
|
|
68
|
+
*/
|
|
69
|
+
off(event: "error", listener: (err: Error) => void): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AdvisoryLockPool {
|
|
73
|
+
connect(): Promise<AdvisoryLockPoolClient>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A held session-level advisory lock that owns its dedicated client. */
|
|
77
|
+
export interface AdvisoryLockHandle {
|
|
78
|
+
/**
|
|
79
|
+
* Release the lock (`pg_advisory_unlock` on the SAME client) and return
|
|
80
|
+
* the client to the pool. Idempotent: a second call is a no-op.
|
|
81
|
+
*/
|
|
82
|
+
release(): Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AdvisoryLockService {
|
|
86
|
+
/**
|
|
87
|
+
* Try to acquire a session-level advisory lock for `key` on a dedicated
|
|
88
|
+
* pooled client. Returns a handle on success, or `null` if the lock is
|
|
89
|
+
* already held (by this or another process). The handle owns the client
|
|
90
|
+
* until `release()` is called, so callers MUST always release in a
|
|
91
|
+
* `finally`.
|
|
92
|
+
*/
|
|
93
|
+
tryAcquire(key: string): Promise<AdvisoryLockHandle | null>;
|
|
94
|
+
/**
|
|
95
|
+
* Run `fn` while holding a transaction-scoped advisory lock for `key`,
|
|
96
|
+
* acquired with `pg_advisory_xact_lock` (which BLOCKS until granted) on a
|
|
97
|
+
* dedicated client from THIS service's pool, and auto-released when that
|
|
98
|
+
* transaction commits/rolls back after `fn` settles.
|
|
99
|
+
*
|
|
100
|
+
* The lock transaction runs on this service's (dedicated lock) pool, while
|
|
101
|
+
* `fn` does its real work on whatever database it already holds (typically
|
|
102
|
+
* the shared admin pool). Because the held lock connection and the work
|
|
103
|
+
* connection come from DIFFERENT pools, the nested acquisition can never
|
|
104
|
+
* deadlock the work pool. Use this for SHORT critical sections that gate a
|
|
105
|
+
* read-then-write on another connection.
|
|
106
|
+
*/
|
|
107
|
+
withXactLock<T>(args: { key: string; fn: () => Promise<T> }): Promise<T>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Shared no-op `'error'` listener for held clients. A single module-level
|
|
112
|
+
* reference (rather than a fresh closure per acquisition) is what lets `off`
|
|
113
|
+
* detach exactly the listener `on` attached, and avoids allocating one per
|
|
114
|
+
* lock. It captures nothing, so sharing it is safe.
|
|
115
|
+
*/
|
|
116
|
+
const swallowClientError = (): void => {};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build an {@link AdvisoryLockService} backed by a pool. The backend
|
|
120
|
+
* provides the real admin pool; tests can provide a faithful fake that
|
|
121
|
+
* models per-connection session-lock semantics.
|
|
122
|
+
*/
|
|
123
|
+
export function createAdvisoryLockService(
|
|
124
|
+
pool: AdvisoryLockPool,
|
|
125
|
+
): AdvisoryLockService {
|
|
126
|
+
return {
|
|
127
|
+
async tryAcquire(key) {
|
|
128
|
+
const client = await pool.connect();
|
|
129
|
+
// A held session lock keeps this client checked out (not idle), so the
|
|
130
|
+
// pool's own error handler won't cover it. If this backend is terminated
|
|
131
|
+
// (admin kill / failover) while the lock is held, `pg` emits `'error'`
|
|
132
|
+
// here; without a listener the process crashes. Swallow it - the session
|
|
133
|
+
// lock is auto-released server-side when the backend dies, and a stale
|
|
134
|
+
// `release()` is already a no-op-safe `finally`, so the loss surfaces as
|
|
135
|
+
// the key simply becoming acquirable again. The listener is removed on
|
|
136
|
+
// release so it does not accumulate on the reused pooled connection.
|
|
137
|
+
client.on("error", swallowClientError);
|
|
138
|
+
const releaseClient = () => {
|
|
139
|
+
client.off("error", swallowClientError);
|
|
140
|
+
client.release();
|
|
141
|
+
};
|
|
142
|
+
let acquired = false;
|
|
143
|
+
try {
|
|
144
|
+
const result = await client.query<{ ok: boolean }>(
|
|
145
|
+
"SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS ok",
|
|
146
|
+
[key],
|
|
147
|
+
);
|
|
148
|
+
acquired = Boolean(result.rows[0]?.ok);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
releaseClient();
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
if (!acquired) {
|
|
154
|
+
// Did not get the lock — return the client immediately. (A failed
|
|
155
|
+
// pg_try_advisory_lock acquires nothing, so there is nothing to
|
|
156
|
+
// unlock.)
|
|
157
|
+
releaseClient();
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let released = false;
|
|
162
|
+
return {
|
|
163
|
+
async release() {
|
|
164
|
+
if (released) return;
|
|
165
|
+
released = true;
|
|
166
|
+
try {
|
|
167
|
+
await client.query(
|
|
168
|
+
"SELECT pg_advisory_unlock(hashtextextended($1, 0))",
|
|
169
|
+
[key],
|
|
170
|
+
);
|
|
171
|
+
} finally {
|
|
172
|
+
releaseClient();
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async withXactLock({ key, fn }) {
|
|
179
|
+
const client = await pool.connect();
|
|
180
|
+
// Same rationale as tryAcquire: the lock transaction keeps this client
|
|
181
|
+
// checked out (idle in transaction) while `fn` runs, so attach an error
|
|
182
|
+
// listener to survive a backend termination instead of crashing the pod.
|
|
183
|
+
// Removed in the finally so it does not accumulate on the reused client.
|
|
184
|
+
client.on("error", swallowClientError);
|
|
185
|
+
try {
|
|
186
|
+
await client.query("BEGIN");
|
|
187
|
+
try {
|
|
188
|
+
// BLOCKS on this dedicated client until the lock is granted; auto-
|
|
189
|
+
// released by the COMMIT/ROLLBACK below. `fn`'s own work runs on a
|
|
190
|
+
// DIFFERENT pool, so no same-pool nested-acquisition deadlock.
|
|
191
|
+
await client.query(
|
|
192
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
|
193
|
+
[key],
|
|
194
|
+
);
|
|
195
|
+
const result = await fn();
|
|
196
|
+
await client.query("COMMIT");
|
|
197
|
+
return result;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// Roll back so the xact lock releases and nothing partial lingers on
|
|
200
|
+
// this connection before it returns to the pool. Best-effort: if the
|
|
201
|
+
// backend already died, ROLLBACK throws but release() still frees the
|
|
202
|
+
// slot and the lock is auto-released server-side.
|
|
203
|
+
try {
|
|
204
|
+
await client.query("ROLLBACK");
|
|
205
|
+
} catch (rollbackError) {
|
|
206
|
+
void rollbackError;
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
client.off("error", swallowClientError);
|
|
212
|
+
client.release();
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -89,6 +89,15 @@ export interface CollectorStrategy<
|
|
|
89
89
|
client: TClient;
|
|
90
90
|
pluginId: string;
|
|
91
91
|
runContext?: CollectorRunContext;
|
|
92
|
+
/**
|
|
93
|
+
* Resolved secret env for THIS run (the collector's declared
|
|
94
|
+
* `secretEnv` mapped to values), delivered just-in-time. Injected into
|
|
95
|
+
* the collector's script execution env and never persisted. Empty /
|
|
96
|
+
* absent when the collector declares no secrets. The collector is
|
|
97
|
+
* responsible for masking these values out of its returned output
|
|
98
|
+
* (source-side defense in depth).
|
|
99
|
+
*/
|
|
100
|
+
secretEnv?: Record<string, string>;
|
|
92
101
|
}): Promise<CollectorResult<TResult>>;
|
|
93
102
|
|
|
94
103
|
/**
|
package/src/core-services.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { PluginArtifactStore } from "./plugin-artifact-store";
|
|
|
16
16
|
import type { EventBus } from "./event-bus-types";
|
|
17
17
|
import type { WebSocketRouteRegistry } from "./ws-registry";
|
|
18
18
|
import type { ReadinessRegistry } from "./readiness-registry";
|
|
19
|
+
import type { AdvisoryLockService } from "./advisory-lock";
|
|
19
20
|
|
|
20
21
|
export * from "./types";
|
|
21
22
|
|
|
@@ -66,4 +67,10 @@ export const coreServices = {
|
|
|
66
67
|
readinessRegistry: createServiceRef<ReadinessRegistry>(
|
|
67
68
|
"core.readinessRegistry",
|
|
68
69
|
),
|
|
70
|
+
/**
|
|
71
|
+
* Postgres advisory-lock service backed by a dedicated pooled client, so
|
|
72
|
+
* session-level locks keep connection affinity across acquire/release.
|
|
73
|
+
* See {@link AdvisoryLockService}.
|
|
74
|
+
*/
|
|
75
|
+
advisoryLock: createServiceRef<AdvisoryLockService>("core.advisoryLock"),
|
|
69
76
|
};
|