pgserve 2.0.3 → 2.0.5
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 +28 -0
- package/bin/postgres-server.js +14 -0
- package/package.json +1 -1
- package/src/daemon.js +12 -0
- package/src/postgres.js +55 -2
- package/tests/stale-postmaster-pid.test.js +85 -0
- package/tests/wrapper-supervision.test.js +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ All notable changes to `pgserve` are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
|
|
5
5
|
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 2.0.5
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `PostgresManager` now extends `EventEmitter` and emits `backendExited`
|
|
12
|
+
with `{ code, expected }` when the postgres child exits. `expected=true`
|
|
13
|
+
is reserved for shutdowns initiated by `stop()`; everything else is
|
|
14
|
+
treated as a fault. `PgserveDaemon` re-emits unexpected exits as
|
|
15
|
+
`backendDiedUnexpectedly`, and the daemon CLI wrapper subscribes and
|
|
16
|
+
exits non-zero so a process supervisor (`genie serve`, pm2, systemd)
|
|
17
|
+
can restart the daemon cleanly. Previously, an external SIGKILL of
|
|
18
|
+
the postgres backend left the wrapper alive in `epoll_wait` while the
|
|
19
|
+
control socket accepted connections forever — pgserve#45.
|
|
20
|
+
|
|
21
|
+
## 2.0.4
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- `_startPostgres()` now removes a stale `postmaster.pid` from the data
|
|
26
|
+
directory before spawning postgres. Previously, an unclean shutdown
|
|
27
|
+
(SIGKILL, machine reboot, OOM) left a `postmaster.pid` whose recorded
|
|
28
|
+
PID was no longer alive, and postgres refused to start with
|
|
29
|
+
`FATAL: lock file "postmaster.pid" already exists` on the next boot.
|
|
30
|
+
Operators had to `rm postmaster.pid` manually to recover. A live PID
|
|
31
|
+
is never touched, so a real concurrent postmaster still surfaces the
|
|
32
|
+
normal lock conflict. ([#46](https://github.com/namastexlabs/pgserve/pull/46),
|
|
33
|
+
fixes [#45](https://github.com/namastexlabs/pgserve/issues/45))
|
|
34
|
+
|
|
7
35
|
## 2.0.0 — Unreleased
|
|
8
36
|
|
|
9
37
|
> The release date will replace "Unreleased" when the v2.0.0 release workflow
|
package/bin/postgres-server.js
CHANGED
|
@@ -83,6 +83,20 @@ async function runDaemonSubcommand(daemonArgs) {
|
|
|
83
83
|
// `pgserve daemon` (long-running)
|
|
84
84
|
const opts = parseDaemonArgs(daemonArgs);
|
|
85
85
|
const daemon = new PgserveDaemon(opts);
|
|
86
|
+
|
|
87
|
+
// When the postgres backend dies on us (SIGKILL, OOM, segfault, anything
|
|
88
|
+
// other than a clean stop()), exit non-zero so a process supervisor can
|
|
89
|
+
// restart the daemon cleanly. Without this, the wrapper sat alive in
|
|
90
|
+
// epoll_wait while postgres was dead, and clients got "control.sock
|
|
91
|
+
// accepts but never replies" — pgserve#45.
|
|
92
|
+
daemon.on('backendDiedUnexpectedly', ({ code }) => {
|
|
93
|
+
console.error(
|
|
94
|
+
`pgserve daemon: postgres backend exited unexpectedly (code=${code}); ` +
|
|
95
|
+
`the wrapper is exiting so a process supervisor can restart it.`
|
|
96
|
+
);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
86
100
|
try {
|
|
87
101
|
await daemon.start();
|
|
88
102
|
} catch (err) {
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -257,6 +257,18 @@ export class PgserveDaemon extends EventEmitter {
|
|
|
257
257
|
enablePgvector: options.enablePgvector || false,
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
+
// Forward unexpected backend deaths to wrapper-level supervisors. A clean
|
|
261
|
+
// stop() sets PostgresManager._stopping=true so the event arrives with
|
|
262
|
+
// expected=true and we leave the daemon alone; an external SIGKILL / OOM
|
|
263
|
+
// / segfault arrives with expected=false and we re-emit so the wrapper
|
|
264
|
+
// can exit non-zero and let a process supervisor (genie serve, pm2,
|
|
265
|
+
// systemd) restart us cleanly. See pgserve#45.
|
|
266
|
+
this.pgManager.on('backendExited', (info) => {
|
|
267
|
+
if (!info.expected) {
|
|
268
|
+
this.emit('backendDiedUnexpectedly', info);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
260
272
|
this.server = null;
|
|
261
273
|
this.tcpServers = [];
|
|
262
274
|
this.connections = new Set();
|
package/src/postgres.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/* global fetch, Bun */
|
|
17
|
+
import { EventEmitter } from 'events';
|
|
17
18
|
import os from 'os';
|
|
18
19
|
import path from 'path';
|
|
19
20
|
import fs from 'fs';
|
|
@@ -419,8 +420,9 @@ function findAvailableTcpPort() {
|
|
|
419
420
|
return port;
|
|
420
421
|
}
|
|
421
422
|
|
|
422
|
-
export class PostgresManager {
|
|
423
|
+
export class PostgresManager extends EventEmitter {
|
|
423
424
|
constructor(options = {}) {
|
|
425
|
+
super();
|
|
424
426
|
this.dataDir = options.dataDir || null; // null = memory mode (temp dir)
|
|
425
427
|
this.port = options.port ?? 5433; // Internal PG port (router listens on different port)
|
|
426
428
|
this.user = options.user || 'postgres';
|
|
@@ -709,11 +711,57 @@ export class PostgresManager {
|
|
|
709
711
|
}
|
|
710
712
|
}
|
|
711
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Detect and remove a stale postmaster.pid that postgres would otherwise
|
|
716
|
+
* refuse to start against. Stale = the PID written into the file is not
|
|
717
|
+
* alive on this host. Called at the top of _startPostgres so that crash
|
|
718
|
+
* / SIGKILL / unclean reboot recovery is automatic.
|
|
719
|
+
*
|
|
720
|
+
* Real running backends are NEVER touched — if the PID is alive we leave
|
|
721
|
+
* the file alone and let postgres surface its normal "lock file already
|
|
722
|
+
* exists" error so the operator sees the conflict.
|
|
723
|
+
*/
|
|
724
|
+
async _ensureNoStalePostmasterLock() {
|
|
725
|
+
const pidFile = path.join(this.databaseDir, 'postmaster.pid');
|
|
726
|
+
let raw;
|
|
727
|
+
try {
|
|
728
|
+
raw = await fs.promises.readFile(pidFile, 'utf-8');
|
|
729
|
+
} catch (err) {
|
|
730
|
+
if (err.code === 'ENOENT') return;
|
|
731
|
+
throw err;
|
|
732
|
+
}
|
|
733
|
+
const firstLine = (raw.split('\n')[0] ?? '').trim();
|
|
734
|
+
const pid = Number.parseInt(firstLine, 10);
|
|
735
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
736
|
+
this.logger.warn(
|
|
737
|
+
{ pidFile, firstLine },
|
|
738
|
+
'postmaster.pid is unparseable; removing as stale'
|
|
739
|
+
);
|
|
740
|
+
await fs.promises.unlink(pidFile).catch(() => {});
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
let alive = false;
|
|
744
|
+
try {
|
|
745
|
+
process.kill(pid, 0);
|
|
746
|
+
alive = true;
|
|
747
|
+
} catch (err) {
|
|
748
|
+
// EPERM = process exists but we can't signal it — still alive.
|
|
749
|
+
alive = err.code === 'EPERM';
|
|
750
|
+
}
|
|
751
|
+
if (alive) return;
|
|
752
|
+
this.logger.info(
|
|
753
|
+
{ pidFile, stalePid: pid },
|
|
754
|
+
'Removing stale postmaster.pid (PID not running) before postgres start'
|
|
755
|
+
);
|
|
756
|
+
await fs.promises.unlink(pidFile).catch(() => {});
|
|
757
|
+
}
|
|
758
|
+
|
|
712
759
|
/**
|
|
713
760
|
* Start the PostgreSQL server process
|
|
714
761
|
* Uses Bun.spawn() for ~40% faster process startup
|
|
715
762
|
*/
|
|
716
763
|
async _startPostgres() {
|
|
764
|
+
await this._ensureNoStalePostmasterLock();
|
|
717
765
|
return new Promise((resolve, reject) => {
|
|
718
766
|
// Build PostgreSQL arguments
|
|
719
767
|
const pgArgs = [
|
|
@@ -817,6 +865,7 @@ export class PostgresManager {
|
|
|
817
865
|
// the exit is unexpected (external kill, crash, OOM).
|
|
818
866
|
this.process.exited.then((code) => {
|
|
819
867
|
processExited = true;
|
|
868
|
+
const expected = !!this._stopping;
|
|
820
869
|
if (!started) {
|
|
821
870
|
reject(new Error(`PostgreSQL exited with code ${code} before starting: ${startupOutput}`));
|
|
822
871
|
}
|
|
@@ -824,7 +873,7 @@ export class PostgresManager {
|
|
|
824
873
|
// On unexpected exit (not via stop()), reset cached paths so that
|
|
825
874
|
// getSocketPath() returns null and callers can fall back to TCP
|
|
826
875
|
// or force a fresh start().
|
|
827
|
-
if (!
|
|
876
|
+
if (!expected) {
|
|
828
877
|
this.socketDir = null;
|
|
829
878
|
this.databaseDir = null;
|
|
830
879
|
this.logger?.warn(
|
|
@@ -832,6 +881,10 @@ export class PostgresManager {
|
|
|
832
881
|
'PostgreSQL subprocess exited unexpectedly — socketDir/databaseDir reset'
|
|
833
882
|
);
|
|
834
883
|
}
|
|
884
|
+
// Notify supervisors. `expected=true` means stop() initiated the exit
|
|
885
|
+
// (clean shutdown); `expected=false` means the backend died on its
|
|
886
|
+
// own — supervisors should treat the latter as a fault signal.
|
|
887
|
+
this.emit('backendExited', { code, expected });
|
|
835
888
|
});
|
|
836
889
|
|
|
837
890
|
// Method 1: TCP connection polling (preferred, works on Linux/macOS)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale postmaster.pid cleanup
|
|
3
|
+
*
|
|
4
|
+
* Verifies that PostgresManager._ensureNoStalePostmasterLock removes
|
|
5
|
+
* a postmaster.pid file whose recorded PID is no longer alive, and
|
|
6
|
+
* leaves alone a postmaster.pid whose recorded PID is alive.
|
|
7
|
+
*
|
|
8
|
+
* Regression coverage: postgres refuses to start when postmaster.pid
|
|
9
|
+
* exists, even if the writer crashed. After unclean shutdowns this
|
|
10
|
+
* required manual `rm` to recover.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { PostgresManager } from '../src/postgres.js';
|
|
14
|
+
import { test, expect } from 'bun:test';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
|
|
19
|
+
function makeMgr(dataDir) {
|
|
20
|
+
const mgr = new PostgresManager({ dataDir });
|
|
21
|
+
mgr.databaseDir = dataDir;
|
|
22
|
+
mgr.logger = {
|
|
23
|
+
info: () => {},
|
|
24
|
+
warn: () => {},
|
|
25
|
+
error: () => {},
|
|
26
|
+
debug: () => {},
|
|
27
|
+
};
|
|
28
|
+
return mgr;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makePidFile(dir, contents) {
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
const pidFile = path.join(dir, 'postmaster.pid');
|
|
34
|
+
fs.writeFileSync(pidFile, contents, 'utf-8');
|
|
35
|
+
return pidFile;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('removes postmaster.pid when recorded PID is dead', async () => {
|
|
39
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-stale-'));
|
|
40
|
+
try {
|
|
41
|
+
// PID 999999999 will not exist on any sane system
|
|
42
|
+
const pidFile = makePidFile(dir, '999999999\n/some/data\n123\n');
|
|
43
|
+
const mgr = makeMgr(dir);
|
|
44
|
+
await mgr._ensureNoStalePostmasterLock();
|
|
45
|
+
expect(fs.existsSync(pidFile)).toBe(false);
|
|
46
|
+
} finally {
|
|
47
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('keeps postmaster.pid when recorded PID is the current (alive) process', async () => {
|
|
52
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-alive-'));
|
|
53
|
+
try {
|
|
54
|
+
const pidFile = makePidFile(dir, `${process.pid}\n/some/data\n123\n`);
|
|
55
|
+
const mgr = makeMgr(dir);
|
|
56
|
+
await mgr._ensureNoStalePostmasterLock();
|
|
57
|
+
expect(fs.existsSync(pidFile)).toBe(true);
|
|
58
|
+
} finally {
|
|
59
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('removes postmaster.pid when first line is unparseable', async () => {
|
|
64
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-garbage-'));
|
|
65
|
+
try {
|
|
66
|
+
const pidFile = makePidFile(dir, 'garbage\nnot-a-pid\n');
|
|
67
|
+
const mgr = makeMgr(dir);
|
|
68
|
+
await mgr._ensureNoStalePostmasterLock();
|
|
69
|
+
expect(fs.existsSync(pidFile)).toBe(false);
|
|
70
|
+
} finally {
|
|
71
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('no-ops when postmaster.pid does not exist', async () => {
|
|
76
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-missing-'));
|
|
77
|
+
try {
|
|
78
|
+
const mgr = makeMgr(dir);
|
|
79
|
+
// Should resolve without throwing
|
|
80
|
+
await mgr._ensureNoStalePostmasterLock();
|
|
81
|
+
expect(fs.existsSync(path.join(dir, 'postmaster.pid'))).toBe(false);
|
|
82
|
+
} finally {
|
|
83
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper supervision: postgres backend death surfaces to the wrapper
|
|
3
|
+
*
|
|
4
|
+
* Verifies that:
|
|
5
|
+
* 1. PostgresManager extends EventEmitter and emits `backendExited` when
|
|
6
|
+
* the postgres child exits.
|
|
7
|
+
* 2. `expected: true` is reported when the exit was initiated by stop().
|
|
8
|
+
* 3. `expected: false` is reported when the child was killed externally
|
|
9
|
+
* (the case the wrapper needs to react to per pgserve#45).
|
|
10
|
+
* 4. PgserveDaemon re-emits `backendDiedUnexpectedly` only for unexpected
|
|
11
|
+
* exits, not for clean stop().
|
|
12
|
+
*
|
|
13
|
+
* Tests use the real Bun.spawn'd postgres binary via PostgresManager because
|
|
14
|
+
* the supervision contract is end-to-end — a unit test with a mocked process
|
|
15
|
+
* would prove only that the JS plumbing fires.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { PostgresManager } from '../src/postgres.js';
|
|
19
|
+
import { PgserveDaemon } from '../src/daemon.js';
|
|
20
|
+
import { EventEmitter } from 'events';
|
|
21
|
+
import { test, expect } from 'bun:test';
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import os from 'os';
|
|
25
|
+
|
|
26
|
+
function quietLogger() {
|
|
27
|
+
return {
|
|
28
|
+
info: () => {}, warn: () => {}, error: () => {}, debug: () => {},
|
|
29
|
+
child: () => quietLogger(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('PostgresManager extends EventEmitter', () => {
|
|
34
|
+
const mgr = new PostgresManager({});
|
|
35
|
+
expect(mgr).toBeInstanceOf(EventEmitter);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('PostgresManager emits backendExited with expected=true after stop()', async () => {
|
|
39
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-supv-stop-'));
|
|
40
|
+
const mgr = new PostgresManager({ dataDir: dir, logger: quietLogger() });
|
|
41
|
+
let event = null;
|
|
42
|
+
mgr.on('backendExited', (info) => { event = info; });
|
|
43
|
+
await mgr.start();
|
|
44
|
+
await mgr.stop();
|
|
45
|
+
// Give event loop a tick to flush exited.then handler if not already drained
|
|
46
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
47
|
+
expect(event).not.toBeNull();
|
|
48
|
+
expect(event.expected).toBe(true);
|
|
49
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
}, 60000);
|
|
51
|
+
|
|
52
|
+
// External-SIGKILL integration coverage runs on Linux only. On macOS,
|
|
53
|
+
// Bun.spawn'd postgres reliably refuses to surface its `exited` promise
|
|
54
|
+
// within the test deadline when killed by SIGKILL — Bun's posix_spawn
|
|
55
|
+
// path on darwin holds parent reaping until grandchildren reap, which
|
|
56
|
+
// postgres never does fast enough for a deterministic test. The
|
|
57
|
+
// `expected=false` branch is still covered cross-platform by the
|
|
58
|
+
// daemon-level re-emit test below, which feeds a synthetic
|
|
59
|
+
// `backendExited` payload through a fake EventEmitter and bypasses the
|
|
60
|
+
// OS signal-handling variability entirely.
|
|
61
|
+
const linuxOnly = process.platform === 'linux' ? test : test.skip;
|
|
62
|
+
linuxOnly('PostgresManager emits backendExited with expected=false on external SIGKILL (linux)', async () => {
|
|
63
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-supv-kill-'));
|
|
64
|
+
const mgr = new PostgresManager({ dataDir: dir, logger: quietLogger() });
|
|
65
|
+
let event = null;
|
|
66
|
+
mgr.on('backendExited', (info) => { event = info; });
|
|
67
|
+
await mgr.start();
|
|
68
|
+
const childPid = mgr.process?.pid;
|
|
69
|
+
expect(childPid).toBeGreaterThan(0);
|
|
70
|
+
// External kill — _stopping stays false, so the handler must mark unexpected
|
|
71
|
+
process.kill(childPid, 'SIGKILL');
|
|
72
|
+
// Wait for the exit handler to fire (max 3s)
|
|
73
|
+
for (let i = 0; i < 60 && event === null; i++) {
|
|
74
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
75
|
+
}
|
|
76
|
+
expect(event).not.toBeNull();
|
|
77
|
+
expect(event.expected).toBe(false);
|
|
78
|
+
// Cleanup: paths were nulled by the unexpected-exit branch, so stop() is a no-op
|
|
79
|
+
await mgr.stop().catch(() => {});
|
|
80
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}, 60000);
|
|
82
|
+
|
|
83
|
+
test('PgserveDaemon re-emits backendDiedUnexpectedly only on unexpected exit', () => {
|
|
84
|
+
// Pure plumbing test — synthesize PgserveDaemon and a fake pgManager that
|
|
85
|
+
// is just an EventEmitter; verify the wiring.
|
|
86
|
+
const fakePgManager = new EventEmitter();
|
|
87
|
+
// PgserveDaemon constructor needs a baseDir; passing a tmp dir avoids
|
|
88
|
+
// touching real config.
|
|
89
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-supv-daemon-'));
|
|
90
|
+
const daemon = new PgserveDaemon({
|
|
91
|
+
baseDir: dir,
|
|
92
|
+
logger: quietLogger(),
|
|
93
|
+
pgManager: fakePgManager,
|
|
94
|
+
enforcementDisabled: true,
|
|
95
|
+
});
|
|
96
|
+
const events = [];
|
|
97
|
+
daemon.on('backendDiedUnexpectedly', (info) => events.push(info));
|
|
98
|
+
|
|
99
|
+
fakePgManager.emit('backendExited', { code: 0, expected: true });
|
|
100
|
+
expect(events).toHaveLength(0); // clean stop — no re-emit
|
|
101
|
+
|
|
102
|
+
fakePgManager.emit('backendExited', { code: 137, expected: false });
|
|
103
|
+
expect(events).toHaveLength(1);
|
|
104
|
+
expect(events[0]).toEqual({ code: 137, expected: false });
|
|
105
|
+
|
|
106
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
});
|