pgserve 1.2.0 → 2.0.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.
- package/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
- package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
- package/.genie/wishes/pgserve-v2/WISH.md +442 -0
- package/.genie/wishes/release-system-genie-pattern/WISH.md +9 -9
- package/.genie/wishes/release-system-genie-pattern/validation.md +43 -10
- package/.github/workflows/ci.yml +10 -6
- package/.github/workflows/release.yml +1 -1
- package/.github/workflows/version.yml +4 -4
- package/CHANGELOG.md +150 -0
- package/Makefile +12 -12
- package/README.md +216 -10
- package/bin/pgserve-wrapper.cjs +3 -3
- package/bin/{pglite-server.js → postgres-server.js} +258 -1
- package/bun.lock +0 -3
- package/ecosystem.config.cjs +3 -3
- package/eslint.config.js +2 -0
- package/knip.json +1 -1
- package/package.json +4 -5
- package/scripts/test-bun-self-heal.sh +10 -10
- package/src/admin-client.js +171 -0
- package/src/audit.js +168 -0
- package/src/control-db.js +313 -0
- package/src/daemon-control.js +408 -0
- package/src/daemon-shared.js +18 -0
- package/src/daemon-tcp.js +296 -0
- package/src/daemon.js +629 -0
- package/src/fingerprint.js +453 -0
- package/src/gc.js +351 -0
- package/src/index.js +31 -0
- package/src/protocol.js +131 -0
- package/src/router.js +8 -0
- package/src/sdk.js +137 -0
- package/src/tenancy.js +75 -0
- package/src/tokens.js +102 -0
- package/tests/audit.test.js +189 -0
- package/tests/benchmarks/runner.js +430 -754
- package/tests/control-db.test.js +285 -0
- package/tests/daemon-fingerprint-integration.test.js +111 -0
- package/tests/daemon-pr24-regression.test.js +198 -0
- package/tests/fingerprint.test.js +249 -0
- package/tests/fixtures/240-orphan-seed.sql +30 -0
- package/tests/orphan-cleanup.test.js +390 -0
- package/tests/sdk.test.js +71 -0
- package/tests/tcp-listen.test.js +368 -0
- package/tests/tenancy.test.js +403 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/fingerprint.js — kernel-rooted peer identity.
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* - getPeerCred() returns the calling process's pid/uid/gid via SO_PEERCRED
|
|
6
|
+
* - findNearestPackageJson() walks upward; deepest match wins (monorepo)
|
|
7
|
+
* - derivePackageFingerprint() is stable across cwd changes in the same project
|
|
8
|
+
* - same name + different paths → different fingerprints
|
|
9
|
+
* - same path + different uid → different fingerprints
|
|
10
|
+
* - script fallback triggers when no package.json above cwd
|
|
11
|
+
* - end-to-end: handleControlAccept() emits a connection_routed audit entry
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { test, expect, beforeAll, beforeEach, afterEach } from 'bun:test';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import os from 'os';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import net from 'net';
|
|
19
|
+
import {
|
|
20
|
+
initFingerprintFfi,
|
|
21
|
+
getPeerCred,
|
|
22
|
+
findNearestPackageJson,
|
|
23
|
+
readPackageName,
|
|
24
|
+
derivePackageFingerprint,
|
|
25
|
+
deriveScriptFingerprint,
|
|
26
|
+
fingerprintFromCred,
|
|
27
|
+
handleControlAccept,
|
|
28
|
+
_setPeerCredImpl,
|
|
29
|
+
} from '../src/fingerprint.js';
|
|
30
|
+
import { configureAudit, AUDIT_EVENTS } from '../src/audit.js';
|
|
31
|
+
|
|
32
|
+
let scratch;
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
await initFingerprintFfi();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-fp-test-'));
|
|
40
|
+
configureAudit({
|
|
41
|
+
logFile: path.join(scratch, 'audit.log'),
|
|
42
|
+
target: 'file',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
_setPeerCredImpl(null);
|
|
48
|
+
try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* noop */ }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// SO_PEERCRED smoke — proves the FFI path works end-to-end on this kernel.
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
test('getPeerCred reads kernel-attested pid/uid/gid via Unix socket pair', async () => {
|
|
56
|
+
const sockPath = path.join(scratch, 'peer.sock');
|
|
57
|
+
const expectedUid = process.getuid();
|
|
58
|
+
const expectedGid = process.getgid();
|
|
59
|
+
const expectedPid = process.pid;
|
|
60
|
+
|
|
61
|
+
const cred = await new Promise((resolve, reject) => {
|
|
62
|
+
const server = net.createServer((socket) => {
|
|
63
|
+
try {
|
|
64
|
+
const c = getPeerCred(socket);
|
|
65
|
+
socket.end();
|
|
66
|
+
server.close(() => resolve(c));
|
|
67
|
+
} catch (err) {
|
|
68
|
+
server.close(() => reject(err));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
server.on('error', reject);
|
|
72
|
+
server.listen(sockPath, () => {
|
|
73
|
+
const client = net.createConnection(sockPath);
|
|
74
|
+
client.on('error', reject);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(cred.pid).toBe(expectedPid);
|
|
79
|
+
expect(cred.uid).toBe(expectedUid);
|
|
80
|
+
expect(cred.gid).toBe(expectedGid);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Pure-function tests on derivation surface
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
test('fingerprint stable across cwd change in the same project', () => {
|
|
88
|
+
// Layout:
|
|
89
|
+
// <scratch>/proj/package.json (name=alpha)
|
|
90
|
+
// <scratch>/proj/sub/deep/
|
|
91
|
+
// Same project → same fingerprint regardless of starting cwd.
|
|
92
|
+
const proj = path.join(scratch, 'proj');
|
|
93
|
+
fs.mkdirSync(path.join(proj, 'sub', 'deep'), { recursive: true });
|
|
94
|
+
fs.writeFileSync(path.join(proj, 'package.json'), JSON.stringify({ name: 'alpha' }));
|
|
95
|
+
|
|
96
|
+
const root = findNearestPackageJson(proj);
|
|
97
|
+
const fromSub = findNearestPackageJson(path.join(proj, 'sub'));
|
|
98
|
+
const fromDeep = findNearestPackageJson(path.join(proj, 'sub', 'deep'));
|
|
99
|
+
|
|
100
|
+
expect(root).not.toBeNull();
|
|
101
|
+
expect(fromSub).toBe(root);
|
|
102
|
+
expect(fromDeep).toBe(root);
|
|
103
|
+
|
|
104
|
+
const fp1 = derivePackageFingerprint({ packageRealpath: root, name: 'alpha', uid: 1000 });
|
|
105
|
+
const fp2 = derivePackageFingerprint({ packageRealpath: fromSub, name: 'alpha', uid: 1000 });
|
|
106
|
+
const fp3 = derivePackageFingerprint({ packageRealpath: fromDeep, name: 'alpha', uid: 1000 });
|
|
107
|
+
expect(fp1).toBe(fp2);
|
|
108
|
+
expect(fp2).toBe(fp3);
|
|
109
|
+
expect(fp1).toMatch(/^[0-9a-f]{12}$/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('two projects with the same name but different paths get different fingerprints', () => {
|
|
113
|
+
const a = path.join(scratch, 'a-project');
|
|
114
|
+
const b = path.join(scratch, 'b-project');
|
|
115
|
+
fs.mkdirSync(a);
|
|
116
|
+
fs.mkdirSync(b);
|
|
117
|
+
fs.writeFileSync(path.join(a, 'package.json'), JSON.stringify({ name: 'shared' }));
|
|
118
|
+
fs.writeFileSync(path.join(b, 'package.json'), JSON.stringify({ name: 'shared' }));
|
|
119
|
+
|
|
120
|
+
const pa = findNearestPackageJson(a);
|
|
121
|
+
const pb = findNearestPackageJson(b);
|
|
122
|
+
expect(pa).not.toBe(pb);
|
|
123
|
+
|
|
124
|
+
const fpa = derivePackageFingerprint({ packageRealpath: pa, name: 'shared', uid: 1000 });
|
|
125
|
+
const fpb = derivePackageFingerprint({ packageRealpath: pb, name: 'shared', uid: 1000 });
|
|
126
|
+
expect(fpa).not.toBe(fpb);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('same path + different uid → different fingerprints', () => {
|
|
130
|
+
const proj = path.join(scratch, 'multi-user');
|
|
131
|
+
fs.mkdirSync(proj);
|
|
132
|
+
fs.writeFileSync(path.join(proj, 'package.json'), JSON.stringify({ name: 'multi' }));
|
|
133
|
+
const realpath = findNearestPackageJson(proj);
|
|
134
|
+
|
|
135
|
+
const fp1000 = derivePackageFingerprint({ packageRealpath: realpath, name: 'multi', uid: 1000 });
|
|
136
|
+
const fp1001 = derivePackageFingerprint({ packageRealpath: realpath, name: 'multi', uid: 1001 });
|
|
137
|
+
expect(fp1000).not.toBe(fp1001);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('script fallback triggered when no package.json above cwd', () => {
|
|
141
|
+
// Build an isolated path tree under scratch with no package.json anywhere.
|
|
142
|
+
// We point fingerprintFromCred at an override cwd inside scratch so the
|
|
143
|
+
// upward walk hits the filesystem root (no package.json in /tmp/.. either,
|
|
144
|
+
// because we use a deliberately ephemeral dir tree owned by the test).
|
|
145
|
+
const isolated = path.join(scratch, 'isolated', 'deep');
|
|
146
|
+
fs.mkdirSync(isolated, { recursive: true });
|
|
147
|
+
|
|
148
|
+
// Sanity: walking up from `isolated` finds no package.json (until at least
|
|
149
|
+
// /tmp/... or higher; we trust the host doesn't have one in /tmp).
|
|
150
|
+
// If the host *does* have one above /tmp, the result would still be deterministic
|
|
151
|
+
// and correct (mode='package'), but we want to test the script-fallback branch
|
|
152
|
+
// here. Mock findNearestPackageJson by passing a cwdOverride beneath a fake
|
|
153
|
+
// chroot — the easiest way is to walk to a path that we control: use an
|
|
154
|
+
// empty subtree under scratch and pretend the walk has hit the root.
|
|
155
|
+
const sentinelFile = findNearestPackageJson(isolated);
|
|
156
|
+
// If the host has no package.json anywhere up to /, sentinelFile is null.
|
|
157
|
+
// If it does, this assertion would falsely target the host's package.json.
|
|
158
|
+
// To make the test deterministic, we drive the script branch directly via
|
|
159
|
+
// deriveScriptFingerprint; the integration of "no package.json found" is
|
|
160
|
+
// covered by fingerprintFromCred's branch logic with cmdlineOverride.
|
|
161
|
+
|
|
162
|
+
const fp = deriveScriptFingerprint({
|
|
163
|
+
uid: 1000,
|
|
164
|
+
cwd: '/some/orphan/dir',
|
|
165
|
+
cmdline1: '/usr/local/bin/foo.js',
|
|
166
|
+
});
|
|
167
|
+
expect(fp).toMatch(/^[0-9a-f]{12}$/);
|
|
168
|
+
|
|
169
|
+
// Also verify fingerprintFromCred picks the script branch when cwdOverride
|
|
170
|
+
// points at a path with no ancestor package.json — we use a path under
|
|
171
|
+
// scratch since scratch itself has no package.json, and we pass cmdlineOverride.
|
|
172
|
+
const info = fingerprintFromCred(
|
|
173
|
+
{ pid: 9999, uid: 1000, gid: 1000 },
|
|
174
|
+
{
|
|
175
|
+
cwdOverride: isolated,
|
|
176
|
+
cmdlineOverride: ['/usr/local/bin/bun', '/some/orphan/dir/foo.js'],
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
// sentinelFile may be null (script mode) or non-null (if host has /package.json upstream).
|
|
180
|
+
if (sentinelFile === null) {
|
|
181
|
+
expect(info.mode).toBe('script');
|
|
182
|
+
expect(info.fingerprint).toMatch(/^[0-9a-f]{12}$/);
|
|
183
|
+
expect(info.packageRealpath).toBeNull();
|
|
184
|
+
} else {
|
|
185
|
+
// Host has an ancestor package.json. Still verify that derivation produces
|
|
186
|
+
// a 12-hex value and that the 'package' branch was chosen — the
|
|
187
|
+
// script-fallback behavior is independently exercised by deriveScriptFingerprint above.
|
|
188
|
+
expect(info.mode).toBe('package');
|
|
189
|
+
expect(info.fingerprint).toMatch(/^[0-9a-f]{12}$/);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('monorepo: nested package.json wins (deepest match)', () => {
|
|
194
|
+
// Layout:
|
|
195
|
+
// <scratch>/mono/package.json (name=workspace-root)
|
|
196
|
+
// <scratch>/mono/packages/api/package.json (name=api)
|
|
197
|
+
// <scratch>/mono/packages/api/src/
|
|
198
|
+
// Walking up from src/ must find the api package.json, not the workspace root.
|
|
199
|
+
const root = path.join(scratch, 'mono');
|
|
200
|
+
const api = path.join(root, 'packages', 'api');
|
|
201
|
+
const apiSrc = path.join(api, 'src');
|
|
202
|
+
fs.mkdirSync(apiSrc, { recursive: true });
|
|
203
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'workspace-root' }));
|
|
204
|
+
fs.writeFileSync(path.join(api, 'package.json'), JSON.stringify({ name: 'api' }));
|
|
205
|
+
|
|
206
|
+
const found = findNearestPackageJson(apiSrc);
|
|
207
|
+
expect(found).toBe(fs.realpathSync(path.join(api, 'package.json')));
|
|
208
|
+
expect(readPackageName(found)).toBe('api');
|
|
209
|
+
|
|
210
|
+
const info = fingerprintFromCred(
|
|
211
|
+
{ pid: 9999, uid: 1000, gid: 1000 },
|
|
212
|
+
{ cwdOverride: apiSrc, cmdlineOverride: ['bun', 'src/index.js'] },
|
|
213
|
+
);
|
|
214
|
+
expect(info.mode).toBe('package');
|
|
215
|
+
expect(info.name).toBe('api');
|
|
216
|
+
expect(info.packageRealpath).toBe(found);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// End-to-end: handleControlAccept emits connection_routed
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
test('handleControlAccept emits a connection_routed audit event with 12-hex fingerprint', () => {
|
|
224
|
+
const proj = path.join(scratch, 'audit-target');
|
|
225
|
+
fs.mkdirSync(proj);
|
|
226
|
+
fs.writeFileSync(path.join(proj, 'package.json'), JSON.stringify({ name: 'audit-app' }));
|
|
227
|
+
|
|
228
|
+
// Stub peer-cred impl so we don't need a real socket.
|
|
229
|
+
_setPeerCredImpl(() => ({ pid: 4242, uid: 1000, gid: 1000 }));
|
|
230
|
+
|
|
231
|
+
const info = handleControlAccept(
|
|
232
|
+
{ /* fake socket */ },
|
|
233
|
+
{ cwdOverride: proj, cmdlineOverride: ['bun', 'index.js'] },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(info.fingerprint).toMatch(/^[0-9a-f]{12}$/);
|
|
237
|
+
expect(info.mode).toBe('package');
|
|
238
|
+
expect(info.name).toBe('audit-app');
|
|
239
|
+
|
|
240
|
+
const logFile = path.join(scratch, 'audit.log');
|
|
241
|
+
const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
242
|
+
expect(lines.length).toBe(1);
|
|
243
|
+
const entry = JSON.parse(lines[0]);
|
|
244
|
+
expect(entry.event).toBe(AUDIT_EVENTS.CONNECTION_ROUTED);
|
|
245
|
+
expect(entry.fingerprint).toBe(info.fingerprint);
|
|
246
|
+
expect(entry.peer_pid).toBe(4242);
|
|
247
|
+
expect(entry.peer_uid).toBe(1000);
|
|
248
|
+
expect(entry.mode).toBe('package');
|
|
249
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- Group 5 — synthetic orphan fixture.
|
|
2
|
+
--
|
|
3
|
+
-- Seeds 240 rows in `pgserve_meta` with stale `last_connection_at`
|
|
4
|
+
-- (48 hours old, well past the 24h TTL) and a dead `liveness_pid`. Half
|
|
5
|
+
-- the rows use a guaranteed-out-of-range PID (2147483646, far above
|
|
6
|
+
-- Linux's pid_max ≤ 2^22 ≈ 4M); the other half use NULL so the sweep
|
|
7
|
+
-- exercises both audit code paths (`db_reaped_liveness` vs `db_reaped_ttl`).
|
|
8
|
+
--
|
|
9
|
+
-- The accompanying harness `tests/orphan-cleanup.test.js` runs this file
|
|
10
|
+
-- and then `CREATE DATABASE`s each row's `database_name` so the sweep
|
|
11
|
+
-- actually has something to DROP.
|
|
12
|
+
|
|
13
|
+
INSERT INTO pgserve_meta (
|
|
14
|
+
database_name,
|
|
15
|
+
fingerprint,
|
|
16
|
+
peer_uid,
|
|
17
|
+
package_realpath,
|
|
18
|
+
last_connection_at,
|
|
19
|
+
liveness_pid,
|
|
20
|
+
persist
|
|
21
|
+
)
|
|
22
|
+
SELECT
|
|
23
|
+
format('app_orphan_%s', lpad(to_hex(i), 12, '0')),
|
|
24
|
+
lpad(to_hex(i), 12, '0'),
|
|
25
|
+
1000,
|
|
26
|
+
NULL,
|
|
27
|
+
now() - interval '48 hours',
|
|
28
|
+
CASE WHEN i % 2 = 0 THEN 2147483646 ELSE NULL END,
|
|
29
|
+
false
|
|
30
|
+
FROM generate_series(1, 240) AS i;
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group 5 — orphan cleanup harness.
|
|
3
|
+
*
|
|
4
|
+
* Boots a real pgserve daemon (no GC triggers — we drive sweeps manually so
|
|
5
|
+
* we can assert exact counts and latency), applies the 240-orphan SQL
|
|
6
|
+
* fixture, creates 240 matching empty databases, runs one `gcSweep`, then
|
|
7
|
+
* asserts:
|
|
8
|
+
* - all 240 rows gone from pgserve_meta
|
|
9
|
+
* - all 240 user databases gone from pg_database
|
|
10
|
+
* - audit log emitted 240 `db_reaped_*` events
|
|
11
|
+
*
|
|
12
|
+
* Plus the auxiliary cases the wish demands:
|
|
13
|
+
* - persist=true row is exempt (audited as db_persist_honored, never reaped)
|
|
14
|
+
* - live liveness_pid + stale last_connection_at slides the window forward
|
|
15
|
+
* instead of reaping
|
|
16
|
+
* - on-connect sweep listener returns under 50ms P99 (sweep is detached;
|
|
17
|
+
* accept must not block on it)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
describe,
|
|
22
|
+
test,
|
|
23
|
+
expect,
|
|
24
|
+
beforeAll,
|
|
25
|
+
afterAll,
|
|
26
|
+
beforeEach,
|
|
27
|
+
} from 'bun:test';
|
|
28
|
+
import fs from 'fs';
|
|
29
|
+
import os from 'os';
|
|
30
|
+
import path from 'path';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
PgserveDaemon,
|
|
34
|
+
resolveControlSocketPath,
|
|
35
|
+
resolvePidLockPath,
|
|
36
|
+
resolveLibpqCompatPath,
|
|
37
|
+
} from '../src/daemon.js';
|
|
38
|
+
import { _setPeerCredImpl, initFingerprintFfi } from '../src/fingerprint.js';
|
|
39
|
+
import { configureAudit, AUDIT_EVENTS } from '../src/audit.js';
|
|
40
|
+
import { gcSweep, installSweepTriggers } from '../src/gc.js';
|
|
41
|
+
import { createLogger } from '../src/logger.js';
|
|
42
|
+
|
|
43
|
+
const FIXTURE_PATH = path.join(__dirname, 'fixtures', '240-orphan-seed.sql');
|
|
44
|
+
const ORPHAN_COUNT = 240;
|
|
45
|
+
|
|
46
|
+
let scratchDir;
|
|
47
|
+
let auditFile;
|
|
48
|
+
let savedAuditDefaults;
|
|
49
|
+
let daemon;
|
|
50
|
+
let adminClient;
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
await initFingerprintFfi();
|
|
54
|
+
_setPeerCredImpl(() => ({
|
|
55
|
+
pid: process.pid,
|
|
56
|
+
uid: process.getuid(),
|
|
57
|
+
gid: process.getgid(),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-gc-test-'));
|
|
61
|
+
const controlSocketDir = path.join(scratchDir, 'sock');
|
|
62
|
+
fs.mkdirSync(controlSocketDir, { recursive: true });
|
|
63
|
+
auditFile = path.join(scratchDir, 'audit.log');
|
|
64
|
+
|
|
65
|
+
savedAuditDefaults = {
|
|
66
|
+
logFile: path.join(os.homedir(), '.pgserve', 'audit.log'),
|
|
67
|
+
target: process.env.PGSERVE_AUDIT_TARGET || 'file',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
daemon = new PgserveDaemon({
|
|
71
|
+
controlSocketDir,
|
|
72
|
+
controlSocketPath: resolveControlSocketPath(controlSocketDir),
|
|
73
|
+
pidLockPath: resolvePidLockPath(controlSocketDir),
|
|
74
|
+
libpqCompatPath: resolveLibpqCompatPath(controlSocketDir, 5432),
|
|
75
|
+
auditLogFile: auditFile,
|
|
76
|
+
auditTarget: 'file',
|
|
77
|
+
pgPort: 16720,
|
|
78
|
+
logger: createLogger({ level: process.env.LOG_LEVEL || 'warn' }),
|
|
79
|
+
// Tests drive sweeps explicitly — disable the auto-installed boot
|
|
80
|
+
// sweep + hourly timer + on-connect listener.
|
|
81
|
+
gcEnabled: false,
|
|
82
|
+
});
|
|
83
|
+
await daemon.start();
|
|
84
|
+
adminClient = daemon._adminClient;
|
|
85
|
+
}, 90_000);
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
try {
|
|
89
|
+
if (adminClient) {
|
|
90
|
+
const r = await adminClient.query(`
|
|
91
|
+
SELECT datname FROM pg_database
|
|
92
|
+
WHERE datname LIKE 'app_%' AND datistemplate = false
|
|
93
|
+
`);
|
|
94
|
+
for (const row of r.rows) {
|
|
95
|
+
try {
|
|
96
|
+
await adminClient.query(
|
|
97
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1`,
|
|
98
|
+
[row.datname],
|
|
99
|
+
);
|
|
100
|
+
await adminClient.query(`DROP DATABASE IF EXISTS "${row.datname}"`);
|
|
101
|
+
} catch { /* swallow */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch { /* swallow */ }
|
|
105
|
+
try { await daemon?.stop(); } catch { /* swallow */ }
|
|
106
|
+
_setPeerCredImpl(null);
|
|
107
|
+
if (savedAuditDefaults) configureAudit(savedAuditDefaults);
|
|
108
|
+
try { fs.rmSync(scratchDir, { recursive: true, force: true }); } catch { /* swallow */ }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
// Reset audit log so each test sees only its own events.
|
|
113
|
+
try { fs.writeFileSync(auditFile, '', { mode: 0o600 }); } catch { /* swallow */ }
|
|
114
|
+
// Reset pgserve_meta + drop any leftover app_* DBs from prior tests.
|
|
115
|
+
await adminClient.query('TRUNCATE pgserve_meta');
|
|
116
|
+
const r = await adminClient.query(`
|
|
117
|
+
SELECT datname FROM pg_database
|
|
118
|
+
WHERE datname LIKE 'app_%' AND datistemplate = false
|
|
119
|
+
`);
|
|
120
|
+
for (const row of r.rows) {
|
|
121
|
+
try {
|
|
122
|
+
await adminClient.query(
|
|
123
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1`,
|
|
124
|
+
[row.datname],
|
|
125
|
+
);
|
|
126
|
+
await adminClient.query(`DROP DATABASE IF EXISTS "${row.datname}"`);
|
|
127
|
+
} catch { /* swallow */ }
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function readAudit() {
|
|
132
|
+
if (!fs.existsSync(auditFile)) return [];
|
|
133
|
+
return fs.readFileSync(auditFile, 'utf8')
|
|
134
|
+
.split('\n')
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.map((l) => JSON.parse(l));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function applyFixture() {
|
|
140
|
+
const sql = fs.readFileSync(FIXTURE_PATH, 'utf8');
|
|
141
|
+
await adminClient.query(sql);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function createSeededDatabases() {
|
|
145
|
+
// The fixture writes 240 deterministic database_name values. Read them
|
|
146
|
+
// back and materialise the matching empty databases. Run in batches so
|
|
147
|
+
// the embedded PG admin pool isn't swamped (default max=2).
|
|
148
|
+
const r = await adminClient.query(
|
|
149
|
+
`SELECT database_name FROM pgserve_meta ORDER BY database_name`,
|
|
150
|
+
);
|
|
151
|
+
const names = r.rows.map((row) => row.database_name);
|
|
152
|
+
const batchSize = 8;
|
|
153
|
+
for (let i = 0; i < names.length; i += batchSize) {
|
|
154
|
+
const slice = names.slice(i, i + batchSize);
|
|
155
|
+
await Promise.all(slice.map((dbName) =>
|
|
156
|
+
adminClient.query(`CREATE DATABASE "${dbName}"`).catch((err) => {
|
|
157
|
+
// 42P04 = duplicate_database — tolerated, the DB already exists
|
|
158
|
+
// from a prior test run that bailed before cleanup.
|
|
159
|
+
if (!(err?.code === '42P04' || /already exists/i.test(err?.message || ''))) {
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}),
|
|
163
|
+
));
|
|
164
|
+
}
|
|
165
|
+
return names;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function countMetaRows() {
|
|
169
|
+
const r = await adminClient.query(`SELECT count(*)::int AS n FROM pgserve_meta`);
|
|
170
|
+
return r.rows[0].n;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function countUserDatabases() {
|
|
174
|
+
const r = await adminClient.query(`
|
|
175
|
+
SELECT count(*)::int AS n FROM pg_database
|
|
176
|
+
WHERE datname LIKE 'app_orphan_%' AND datistemplate = false
|
|
177
|
+
`);
|
|
178
|
+
return r.rows[0].n;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
describe('gcSweep: 240-orphan fixture', () => {
|
|
182
|
+
test('one sweep reaps all 240 ephemeral orphans', async () => {
|
|
183
|
+
await applyFixture();
|
|
184
|
+
await createSeededDatabases();
|
|
185
|
+
|
|
186
|
+
expect(await countMetaRows()).toBe(ORPHAN_COUNT);
|
|
187
|
+
expect(await countUserDatabases()).toBe(ORPHAN_COUNT);
|
|
188
|
+
|
|
189
|
+
const result = await gcSweep({
|
|
190
|
+
adminClient,
|
|
191
|
+
pgManager: daemon.pgManager,
|
|
192
|
+
now: new Date(),
|
|
193
|
+
logger: daemon.logger,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(result.examined).toBe(ORPHAN_COUNT);
|
|
197
|
+
expect(result.reaped).toBe(ORPHAN_COUNT);
|
|
198
|
+
expect(result.kept).toBe(0);
|
|
199
|
+
|
|
200
|
+
// pgserve_meta empty.
|
|
201
|
+
expect(await countMetaRows()).toBe(0);
|
|
202
|
+
// pg_database has no app_orphan_* rows left.
|
|
203
|
+
expect(await countUserDatabases()).toBe(0);
|
|
204
|
+
|
|
205
|
+
const events = readAudit();
|
|
206
|
+
const reapEvents = events.filter(
|
|
207
|
+
(e) => e.event === AUDIT_EVENTS.DB_REAPED_TTL ||
|
|
208
|
+
e.event === AUDIT_EVENTS.DB_REAPED_LIVENESS,
|
|
209
|
+
);
|
|
210
|
+
expect(reapEvents.length).toBe(ORPHAN_COUNT);
|
|
211
|
+
|
|
212
|
+
// Fixture splits 50/50 between liveness_pid=NULL and a dead pid →
|
|
213
|
+
// both audit code paths fire.
|
|
214
|
+
const ttl = events.filter((e) => e.event === AUDIT_EVENTS.DB_REAPED_TTL);
|
|
215
|
+
const liveness = events.filter((e) => e.event === AUDIT_EVENTS.DB_REAPED_LIVENESS);
|
|
216
|
+
expect(ttl.length).toBe(120);
|
|
217
|
+
expect(liveness.length).toBe(120);
|
|
218
|
+
}, 120_000);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('gcSweep: persist + liveness exemptions', () => {
|
|
222
|
+
test('persist=true row is never reaped, even past TTL', async () => {
|
|
223
|
+
// Seed one persist=true row past TTL plus one ephemeral past TTL.
|
|
224
|
+
await adminClient.query(`
|
|
225
|
+
INSERT INTO pgserve_meta (
|
|
226
|
+
database_name, fingerprint, peer_uid, last_connection_at, liveness_pid, persist
|
|
227
|
+
) VALUES
|
|
228
|
+
('app_persist_aaaaaaaaaaaa', 'aaaaaaaaaaaa', 1000, now() - interval '48 hours', NULL, true),
|
|
229
|
+
('app_orphan_bbbbbbbbbbbb', 'bbbbbbbbbbbb', 1000, now() - interval '48 hours', NULL, false)
|
|
230
|
+
`);
|
|
231
|
+
await adminClient.query(`CREATE DATABASE "app_persist_aaaaaaaaaaaa"`);
|
|
232
|
+
await adminClient.query(`CREATE DATABASE "app_orphan_bbbbbbbbbbbb"`);
|
|
233
|
+
|
|
234
|
+
const result = await gcSweep({
|
|
235
|
+
adminClient,
|
|
236
|
+
pgManager: daemon.pgManager,
|
|
237
|
+
now: new Date(),
|
|
238
|
+
logger: daemon.logger,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// The persist=true row never appears via forEachReapable (the SQL
|
|
242
|
+
// filter excludes it), so result.examined == 1 (only the orphan).
|
|
243
|
+
expect(result.reaped).toBe(1);
|
|
244
|
+
expect(result.reapedNames).toEqual(['app_orphan_bbbbbbbbbbbb']);
|
|
245
|
+
|
|
246
|
+
const remaining = await adminClient.query(
|
|
247
|
+
`SELECT database_name, persist FROM pgserve_meta ORDER BY database_name`,
|
|
248
|
+
);
|
|
249
|
+
expect(remaining.rows).toEqual([
|
|
250
|
+
{ database_name: 'app_persist_aaaaaaaaaaaa', persist: true },
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
const persistDb = await adminClient.query(
|
|
254
|
+
`SELECT 1 FROM pg_database WHERE datname = 'app_persist_aaaaaaaaaaaa'`,
|
|
255
|
+
);
|
|
256
|
+
expect(persistDb.rows.length).toBe(1);
|
|
257
|
+
}, 60_000);
|
|
258
|
+
|
|
259
|
+
test('live liveness_pid + stale last_connection_at slides window, no reap', async () => {
|
|
260
|
+
const livePid = process.pid; // self — guaranteed alive
|
|
261
|
+
await adminClient.query(`
|
|
262
|
+
INSERT INTO pgserve_meta (
|
|
263
|
+
database_name, fingerprint, peer_uid, last_connection_at, liveness_pid, persist
|
|
264
|
+
) VALUES
|
|
265
|
+
('app_live_cccccccccccc', 'cccccccccccc', 1000, now() - interval '48 hours', $1, false)
|
|
266
|
+
`, [livePid]);
|
|
267
|
+
await adminClient.query(`CREATE DATABASE "app_live_cccccccccccc"`);
|
|
268
|
+
|
|
269
|
+
const before = await adminClient.query(
|
|
270
|
+
`SELECT last_connection_at FROM pgserve_meta WHERE database_name = $1`,
|
|
271
|
+
['app_live_cccccccccccc'],
|
|
272
|
+
);
|
|
273
|
+
const beforeMs = before.rows[0].last_connection_at.getTime();
|
|
274
|
+
|
|
275
|
+
const result = await gcSweep({
|
|
276
|
+
adminClient,
|
|
277
|
+
pgManager: daemon.pgManager,
|
|
278
|
+
now: new Date(),
|
|
279
|
+
logger: daemon.logger,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(result.reaped).toBe(0);
|
|
283
|
+
expect(result.aliveSkipped).toBe(1);
|
|
284
|
+
|
|
285
|
+
const after = await adminClient.query(
|
|
286
|
+
`SELECT last_connection_at FROM pgserve_meta WHERE database_name = $1`,
|
|
287
|
+
['app_live_cccccccccccc'],
|
|
288
|
+
);
|
|
289
|
+
expect(after.rows.length).toBe(1);
|
|
290
|
+
const afterMs = after.rows[0].last_connection_at.getTime();
|
|
291
|
+
// Slid forward: new timestamp > old by at least the staleness gap.
|
|
292
|
+
expect(afterMs).toBeGreaterThan(beforeMs + 24 * 60 * 60 * 1000);
|
|
293
|
+
}, 60_000);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('installSweepTriggers: on-connect sweep is non-blocking', () => {
|
|
297
|
+
test('emit("accept") returns under 50ms P99 even with always-sample rate', async () => {
|
|
298
|
+
// Use a stub admin client that simulates a slow GC (artificially long
|
|
299
|
+
// pgserve_meta query). If the listener weren't detached, every emit()
|
|
300
|
+
// would wait on this — the test would time out at 200ms × N samples.
|
|
301
|
+
let sweepCount = 0;
|
|
302
|
+
const slowAdmin = {
|
|
303
|
+
async query(_text, _params) {
|
|
304
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
305
|
+
sweepCount += 1;
|
|
306
|
+
return { rows: [], rowCount: 0 };
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
// Force "always sample" by passing getDbCount = 1 (so N = max(1,1/10) = 1)
|
|
310
|
+
// and dbCount low enough that the rate is 1/1 = always.
|
|
311
|
+
const handle = installSweepTriggers(daemon, {
|
|
312
|
+
adminClient: slowAdmin,
|
|
313
|
+
intervalMs: 0,
|
|
314
|
+
bootSweep: false,
|
|
315
|
+
getDbCount: () => 1,
|
|
316
|
+
});
|
|
317
|
+
try {
|
|
318
|
+
const samples = [];
|
|
319
|
+
for (let i = 0; i < 100; i++) {
|
|
320
|
+
const t0 = process.hrtime.bigint();
|
|
321
|
+
daemon.emit('accept', { fingerprint: 'aaaaaaaaaaaa', socket: {} });
|
|
322
|
+
const t1 = process.hrtime.bigint();
|
|
323
|
+
samples.push(Number(t1 - t0) / 1e6); // ns → ms
|
|
324
|
+
}
|
|
325
|
+
samples.sort((a, b) => a - b);
|
|
326
|
+
const p99 = samples[Math.floor(samples.length * 0.99) - 1];
|
|
327
|
+
expect(p99).toBeLessThan(50);
|
|
328
|
+
} finally {
|
|
329
|
+
await handle.stop();
|
|
330
|
+
}
|
|
331
|
+
// Sanity: at least the boot=false branch ran, so sweepCount may be 0
|
|
332
|
+
// if the rate decided not to sample, but the latency check is the
|
|
333
|
+
// load-bearing assertion.
|
|
334
|
+
expect(sweepCount).toBeGreaterThanOrEqual(0);
|
|
335
|
+
}, 60_000);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('installSweepTriggers: boot sweep logs summary', () => {
|
|
339
|
+
test('boot sweep runs once and reports counts via logger.info', async () => {
|
|
340
|
+
// Seed three rows: two reapable, one persist.
|
|
341
|
+
await adminClient.query(`
|
|
342
|
+
INSERT INTO pgserve_meta (
|
|
343
|
+
database_name, fingerprint, peer_uid, last_connection_at, liveness_pid, persist
|
|
344
|
+
) VALUES
|
|
345
|
+
('app_boot_aaaaaaaaaaaa', 'aaaaaaaaaaaa', 1000, now() - interval '48 hours', NULL, false),
|
|
346
|
+
('app_boot_bbbbbbbbbbbb', 'bbbbbbbbbbbb', 1000, now() - interval '48 hours', NULL, false),
|
|
347
|
+
('app_boot_cccccccccccc', 'cccccccccccc', 1000, now() - interval '48 hours', NULL, true)
|
|
348
|
+
`);
|
|
349
|
+
await adminClient.query(`CREATE DATABASE "app_boot_aaaaaaaaaaaa"`);
|
|
350
|
+
await adminClient.query(`CREATE DATABASE "app_boot_bbbbbbbbbbbb"`);
|
|
351
|
+
await adminClient.query(`CREATE DATABASE "app_boot_cccccccccccc"`);
|
|
352
|
+
|
|
353
|
+
const calls = [];
|
|
354
|
+
const captureLogger = {
|
|
355
|
+
info: (...args) => calls.push({ level: 'info', args }),
|
|
356
|
+
warn: () => {},
|
|
357
|
+
error: () => {},
|
|
358
|
+
debug: () => {},
|
|
359
|
+
};
|
|
360
|
+
const stubDaemon = Object.assign(Object.create(daemon), {
|
|
361
|
+
logger: captureLogger,
|
|
362
|
+
});
|
|
363
|
+
// Object.create copies prototype, so emitter methods are inherited.
|
|
364
|
+
|
|
365
|
+
const handle = installSweepTriggers(stubDaemon, {
|
|
366
|
+
adminClient,
|
|
367
|
+
intervalMs: 0,
|
|
368
|
+
bootSweep: true,
|
|
369
|
+
});
|
|
370
|
+
try {
|
|
371
|
+
// Wait for setImmediate-scheduled boot sweep.
|
|
372
|
+
const deadline = Date.now() + 5000;
|
|
373
|
+
while (Date.now() < deadline) {
|
|
374
|
+
const summary = calls.find((c) =>
|
|
375
|
+
typeof c.args[1] === 'string' && c.args[1].includes('boot sweep complete'),
|
|
376
|
+
);
|
|
377
|
+
if (summary) break;
|
|
378
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
379
|
+
}
|
|
380
|
+
const summary = calls.find((c) =>
|
|
381
|
+
typeof c.args[1] === 'string' && c.args[1].includes('boot sweep complete'),
|
|
382
|
+
);
|
|
383
|
+
expect(summary).toBeDefined();
|
|
384
|
+
expect(summary.args[0].reaped).toBe(2);
|
|
385
|
+
expect(summary.args[0].persist_skipped).toBe(0); // forEachReapable filter excludes persist=true rows from `examined` entirely
|
|
386
|
+
} finally {
|
|
387
|
+
await handle.stop();
|
|
388
|
+
}
|
|
389
|
+
}, 60_000);
|
|
390
|
+
});
|