pgserve 2.3.0 → 2.4.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/bin/pgserve-wrapper.cjs +5 -4
- package/bin/postgres-server.js +142 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +2 -2
- package/scripts/test-npx.sh +32 -10
- package/src/cli-install.cjs +147 -77
- package/src/commands/uninstall.js +241 -0
- package/src/index.js +11 -44
- package/src/lib/admin-json.js +202 -0
- package/src/lib/pm2-args.js +119 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/admin-client.js +0 -223
- package/src/audit.js +0 -168
- package/src/cluster.js +0 -654
- package/src/control-db.js +0 -330
- package/src/daemon-control.js +0 -468
- package/src/daemon-shared.js +0 -18
- package/src/daemon-tcp.js +0 -297
- package/src/daemon.js +0 -709
- package/src/dashboard.js +0 -217
- package/src/fingerprint.js +0 -479
- package/src/gc.js +0 -351
- package/src/pg-wire.js +0 -869
- package/src/protocol.js +0 -389
- package/src/restore.js +0 -574
- package/src/router.js +0 -546
- package/src/sdk.js +0 -137
- package/src/stats-collector.js +0 -453
- package/src/stats-dashboard.js +0 -401
- package/src/sync.js +0 -335
- package/src/tenancy.js +0 -75
- package/src/tokens.js +0 -102
package/src/cluster.js
DELETED
|
@@ -1,654 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cluster Mode for pgserve
|
|
3
|
-
*
|
|
4
|
-
* Architecture:
|
|
5
|
-
* - PRIMARY process: Runs single embedded PostgreSQL instance
|
|
6
|
-
* - WORKER processes: Only run TCP routing to PRIMARY's PostgreSQL
|
|
7
|
-
*
|
|
8
|
-
* This enables multi-core scaling (3-5x throughput on multi-core systems)
|
|
9
|
-
* while maintaining a single PostgreSQL instance.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import cluster from 'cluster';
|
|
13
|
-
import os from 'os';
|
|
14
|
-
import { SQL } from 'bun';
|
|
15
|
-
import { createLogger } from './logger.js';
|
|
16
|
-
import { PostgresManager } from './postgres.js';
|
|
17
|
-
import { extractDatabaseName } from './protocol.js';
|
|
18
|
-
import { EventEmitter } from 'events';
|
|
19
|
-
import { loadEffectiveConfig } from './settings-loader.cjs';
|
|
20
|
-
|
|
21
|
-
// PostgreSQL protocol constants
|
|
22
|
-
const PROTOCOL_VERSION_3 = 196608;
|
|
23
|
-
const SSL_REQUEST_CODE = 80877103;
|
|
24
|
-
const GSSAPI_REQUEST_CODE = 80877104;
|
|
25
|
-
const CANCEL_REQUEST_CODE = 80877102;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Attempt to write a pending buffer to a target socket.
|
|
29
|
-
* Returns remaining unwritten bytes, or null if fully flushed.
|
|
30
|
-
*/
|
|
31
|
-
function flushPending(target, pending) {
|
|
32
|
-
const written = target.write(pending);
|
|
33
|
-
if (written === pending.byteLength) return null;
|
|
34
|
-
if (written === 0) return pending;
|
|
35
|
-
return pending.subarray(written);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Stats collection constants
|
|
39
|
-
const WORKER_STATS_TIMEOUT_MS = 10000; // Worker stats older than this are considered stale
|
|
40
|
-
const WORKER_STATS_REPORT_INTERVAL_MS = 4000; // How often workers report stats to primary
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* ClusterRouter - Lightweight TCP router for worker processes
|
|
44
|
-
* Does NOT start PostgreSQL - connects to PRIMARY's PostgreSQL via Unix socket
|
|
45
|
-
*/
|
|
46
|
-
class ClusterRouter extends EventEmitter {
|
|
47
|
-
constructor(options = {}) {
|
|
48
|
-
super();
|
|
49
|
-
this.port = options.port || 8432;
|
|
50
|
-
this.host = options.host || '127.0.0.1';
|
|
51
|
-
this.pgSocketPath = options.pgSocketPath; // From PRIMARY
|
|
52
|
-
this.pgPort = options.pgPort;
|
|
53
|
-
this.pgUser = options.pgUser || 'postgres';
|
|
54
|
-
this.pgPassword = options.pgPassword || 'postgres';
|
|
55
|
-
this.autoProvision = options.autoProvision !== false;
|
|
56
|
-
this.maxConnections = options.maxConnections || 1000;
|
|
57
|
-
this.enablePgvector = options.enablePgvector || false;
|
|
58
|
-
|
|
59
|
-
this.logger = createLogger({ level: options.logLevel || 'info' });
|
|
60
|
-
this.sql = null; // Bun.sql for admin queries
|
|
61
|
-
this.server = null;
|
|
62
|
-
this.connections = new Set();
|
|
63
|
-
this.setMaxListeners(this.maxConnections + 10);
|
|
64
|
-
|
|
65
|
-
// Connection stats tracking for IPC reporting
|
|
66
|
-
this.connectionStats = {
|
|
67
|
-
totalConnected: 0,
|
|
68
|
-
totalDisconnected: 0
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Socket state storage for Bun TCP handler model
|
|
74
|
-
*/
|
|
75
|
-
socketState = new WeakMap();
|
|
76
|
-
|
|
77
|
-
async start() {
|
|
78
|
-
// Admin connection for auto-provisioning databases (Bun.sql)
|
|
79
|
-
if (this.autoProvision) {
|
|
80
|
-
// Bun.sql uses TCP connections - Unix sockets not directly supported
|
|
81
|
-
// This is fine for admin queries (low volume, local connection)
|
|
82
|
-
this.sql = new SQL({
|
|
83
|
-
hostname: '127.0.0.1',
|
|
84
|
-
port: this.pgPort,
|
|
85
|
-
database: 'postgres',
|
|
86
|
-
username: this.pgUser,
|
|
87
|
-
password: this.pgPassword,
|
|
88
|
-
max: 2, // Small pool for admin queries
|
|
89
|
-
idleTimeout: 30,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Create TCP server using Bun.listen() for 2-3x throughput
|
|
94
|
-
const router = this;
|
|
95
|
-
const isWindows = os.platform() === 'win32';
|
|
96
|
-
this.server = Bun.listen({
|
|
97
|
-
hostname: this.host,
|
|
98
|
-
port: this.port,
|
|
99
|
-
reusePort: !isWindows, // SO_REUSEPORT for multi-worker port sharing (Linux/macOS only)
|
|
100
|
-
socket: {
|
|
101
|
-
data(socket, data) {
|
|
102
|
-
router.handleSocketData(socket, data);
|
|
103
|
-
},
|
|
104
|
-
open(socket) {
|
|
105
|
-
router.handleSocketOpen(socket);
|
|
106
|
-
},
|
|
107
|
-
close(socket) {
|
|
108
|
-
router.handleSocketClose(socket);
|
|
109
|
-
},
|
|
110
|
-
error(socket, error) {
|
|
111
|
-
router.handleSocketError(socket, error);
|
|
112
|
-
},
|
|
113
|
-
drain(socket) {
|
|
114
|
-
const state = router.socketState.get(socket);
|
|
115
|
-
if (!state) return;
|
|
116
|
-
// Flush any pending PG→Client data
|
|
117
|
-
if (state.pendingToClient) {
|
|
118
|
-
state.pendingToClient = flushPending(socket, state.pendingToClient);
|
|
119
|
-
}
|
|
120
|
-
// If fully flushed, resume reading from PostgreSQL
|
|
121
|
-
if (!state.pendingToClient && state.pgSocket) {
|
|
122
|
-
state.pgSocket.resume();
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// Verify port actually bound (detect silent failures on Windows)
|
|
129
|
-
if (!this.server || !this.server.port) {
|
|
130
|
-
throw new Error(`Failed to bind to port ${this.port} - reusePort may not be supported on this platform`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
this.emit('listening');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async createDatabase(dbName) {
|
|
137
|
-
if (!this.autoProvision || !this.sql) return;
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
// Bun.sql uses tagged template literals for parameterized queries
|
|
141
|
-
const result = await this.sql`SELECT 1 FROM pg_database WHERE datname = ${dbName}`;
|
|
142
|
-
|
|
143
|
-
if (result.length === 0) {
|
|
144
|
-
// Use sql() helper for safe identifier escaping (like CREATE DATABASE)
|
|
145
|
-
await this.sql.unsafe(`CREATE DATABASE "${dbName.replace(/"/g, '""')}"`);
|
|
146
|
-
|
|
147
|
-
// Auto-enable pgvector extension if configured
|
|
148
|
-
if (this.enablePgvector) {
|
|
149
|
-
await this.enablePgvectorExtension(dbName);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
} catch (error) {
|
|
153
|
-
// Ignore "already exists" (race condition between workers)
|
|
154
|
-
if (!error.message?.includes('already exists')) {
|
|
155
|
-
this.logger.error({ database: dbName, err: error }, 'Failed to create database');
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Enable pgvector extension on a database
|
|
162
|
-
* Creates a temporary connection to the specific database to run CREATE EXTENSION
|
|
163
|
-
* @param {string} dbName - Database name to enable pgvector on
|
|
164
|
-
*/
|
|
165
|
-
async enablePgvectorExtension(dbName) {
|
|
166
|
-
let dbPool = null;
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
// Create temporary connection to the specific database
|
|
170
|
-
dbPool = new SQL({
|
|
171
|
-
hostname: '127.0.0.1',
|
|
172
|
-
port: this.pgPort,
|
|
173
|
-
database: dbName,
|
|
174
|
-
username: this.pgUser,
|
|
175
|
-
password: this.pgPassword,
|
|
176
|
-
max: 1,
|
|
177
|
-
idleTimeout: 5,
|
|
178
|
-
connectionTimeout: 5,
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// Enable pgvector extension
|
|
182
|
-
await dbPool.unsafe('CREATE EXTENSION IF NOT EXISTS vector');
|
|
183
|
-
this.logger.info({ dbName }, 'pgvector extension enabled');
|
|
184
|
-
} catch (error) {
|
|
185
|
-
// Log but don't fail database creation - pgvector might not be available
|
|
186
|
-
this.logger.warn({ dbName, err: error.message }, 'Failed to enable pgvector extension (non-fatal)');
|
|
187
|
-
} finally {
|
|
188
|
-
// Always close the temporary connection
|
|
189
|
-
if (dbPool) {
|
|
190
|
-
await dbPool.close().catch(() => {});
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Handle socket open (Bun TCP handler)
|
|
197
|
-
*/
|
|
198
|
-
handleSocketOpen(socket) {
|
|
199
|
-
this.socketState.set(socket, {
|
|
200
|
-
buffer: null,
|
|
201
|
-
pgSocket: null,
|
|
202
|
-
dbName: null,
|
|
203
|
-
handshakeComplete: false,
|
|
204
|
-
pendingToPg: null,
|
|
205
|
-
pendingToClient: null
|
|
206
|
-
});
|
|
207
|
-
this.connections.add(socket);
|
|
208
|
-
this.connectionStats.totalConnected++;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Handle socket data (Bun TCP handler)
|
|
213
|
-
*/
|
|
214
|
-
handleSocketData(socket, data) {
|
|
215
|
-
const state = this.socketState.get(socket);
|
|
216
|
-
if (!state) return;
|
|
217
|
-
|
|
218
|
-
// If handshake complete, forward to PostgreSQL
|
|
219
|
-
if (state.handshakeComplete && state.pgSocket) {
|
|
220
|
-
// If there's already pending data, append to it
|
|
221
|
-
if (state.pendingToPg) {
|
|
222
|
-
state.pendingToPg = Buffer.concat([state.pendingToPg, data]);
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
const written = state.pgSocket.write(data);
|
|
226
|
-
if (written < data.byteLength) {
|
|
227
|
-
// Partial write — buffer remainder and pause client
|
|
228
|
-
state.pendingToPg = written === 0 ? Buffer.from(data) : Buffer.from(data.subarray(written));
|
|
229
|
-
socket.pause();
|
|
230
|
-
}
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Buffer data for startup message parsing
|
|
235
|
-
if (state.buffer) {
|
|
236
|
-
state.buffer = Buffer.concat([state.buffer, data]);
|
|
237
|
-
} else {
|
|
238
|
-
state.buffer = Buffer.from(data);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
this.processStartupMessage(socket, state);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Process PostgreSQL startup message and establish proxy connection
|
|
246
|
-
*/
|
|
247
|
-
async processStartupMessage(socket, state) {
|
|
248
|
-
const buffer = state.buffer;
|
|
249
|
-
if (!buffer || buffer.length < 8) return;
|
|
250
|
-
|
|
251
|
-
const messageLength = buffer.readUInt32BE(0);
|
|
252
|
-
if (buffer.length < messageLength) return;
|
|
253
|
-
|
|
254
|
-
const code = buffer.readUInt32BE(4);
|
|
255
|
-
|
|
256
|
-
// Handle SSL/GSSAPI/Cancel requests
|
|
257
|
-
if (code === SSL_REQUEST_CODE || code === GSSAPI_REQUEST_CODE) {
|
|
258
|
-
socket.write(Buffer.from('N'));
|
|
259
|
-
state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
|
|
260
|
-
if (state.buffer) await this.processStartupMessage(socket, state);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (code === CANCEL_REQUEST_CODE) {
|
|
265
|
-
socket.end();
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (code !== PROTOCOL_VERSION_3) {
|
|
270
|
-
this.logger.warn({ code }, 'Unsupported protocol version');
|
|
271
|
-
socket.end();
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const startupMessage = buffer.subarray(0, messageLength);
|
|
276
|
-
const dbName = extractDatabaseName(startupMessage);
|
|
277
|
-
state.dbName = dbName;
|
|
278
|
-
|
|
279
|
-
try {
|
|
280
|
-
await this.createDatabase(dbName);
|
|
281
|
-
|
|
282
|
-
const router = this;
|
|
283
|
-
|
|
284
|
-
// Shared handler for pgSocket (used by both unix and TCP paths)
|
|
285
|
-
const pgHandler = {
|
|
286
|
-
data(_pgSocket, pgData) {
|
|
287
|
-
// Forward PostgreSQL response to client with backpressure
|
|
288
|
-
if (state.pendingToClient) {
|
|
289
|
-
state.pendingToClient = Buffer.concat([state.pendingToClient, pgData]);
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
const written = socket.write(pgData);
|
|
293
|
-
if (written < pgData.byteLength) {
|
|
294
|
-
state.pendingToClient = written === 0 ? Buffer.from(pgData) : Buffer.from(pgData.subarray(written));
|
|
295
|
-
_pgSocket.pause();
|
|
296
|
-
}
|
|
297
|
-
},
|
|
298
|
-
open(pgSocket) {
|
|
299
|
-
pgSocket.write(startupMessage);
|
|
300
|
-
state.handshakeComplete = true;
|
|
301
|
-
},
|
|
302
|
-
close(_pgSocket) {
|
|
303
|
-
socket.end();
|
|
304
|
-
},
|
|
305
|
-
error(_pgSocket, error) {
|
|
306
|
-
router.logger.error({ dbName, err: error }, 'PostgreSQL socket error');
|
|
307
|
-
socket.end();
|
|
308
|
-
},
|
|
309
|
-
drain(_pgSocket) {
|
|
310
|
-
// Flush any pending Client→PG data
|
|
311
|
-
if (state.pendingToPg) {
|
|
312
|
-
state.pendingToPg = flushPending(_pgSocket, state.pendingToPg);
|
|
313
|
-
}
|
|
314
|
-
// If fully flushed, resume reading from client
|
|
315
|
-
if (!state.pendingToPg) {
|
|
316
|
-
socket.resume();
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
if (this.pgSocketPath) {
|
|
322
|
-
state.pgSocket = await Bun.connect({ unix: this.pgSocketPath, socket: pgHandler });
|
|
323
|
-
} else {
|
|
324
|
-
state.pgSocket = await Bun.connect({ hostname: '127.0.0.1', port: this.pgPort, socket: pgHandler });
|
|
325
|
-
}
|
|
326
|
-
} catch (error) {
|
|
327
|
-
this.logger.error({ dbName, err: error }, 'Connection error');
|
|
328
|
-
socket.end();
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Handle socket close (Bun TCP handler)
|
|
334
|
-
*/
|
|
335
|
-
handleSocketClose(socket) {
|
|
336
|
-
const state = this.socketState.get(socket);
|
|
337
|
-
if (state) {
|
|
338
|
-
state.pendingToPg = null;
|
|
339
|
-
state.pendingToClient = null;
|
|
340
|
-
if (state.pgSocket) state.pgSocket.end();
|
|
341
|
-
}
|
|
342
|
-
this.connections.delete(socket);
|
|
343
|
-
this.socketState.delete(socket);
|
|
344
|
-
this.connectionStats.totalDisconnected++;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Get router stats for IPC reporting
|
|
349
|
-
*/
|
|
350
|
-
getStats() {
|
|
351
|
-
return {
|
|
352
|
-
connections: this.connections.size,
|
|
353
|
-
totalConnected: this.connectionStats.totalConnected,
|
|
354
|
-
totalDisconnected: this.connectionStats.totalDisconnected,
|
|
355
|
-
pid: process.pid
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Handle socket error (Bun TCP handler)
|
|
361
|
-
*/
|
|
362
|
-
handleSocketError(socket, error) {
|
|
363
|
-
const state = this.socketState.get(socket);
|
|
364
|
-
if (error.code !== 'ECONNRESET') {
|
|
365
|
-
this.logger.error({ err: error, dbName: state?.dbName }, 'Socket error');
|
|
366
|
-
}
|
|
367
|
-
if (state) {
|
|
368
|
-
state.pendingToPg = null;
|
|
369
|
-
state.pendingToClient = null;
|
|
370
|
-
if (state.pgSocket) state.pgSocket.end();
|
|
371
|
-
}
|
|
372
|
-
this.connections.delete(socket);
|
|
373
|
-
this.socketState.delete(socket);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
async stop() {
|
|
377
|
-
for (const socket of this.connections) {
|
|
378
|
-
socket.end();
|
|
379
|
-
}
|
|
380
|
-
this.connections.clear();
|
|
381
|
-
|
|
382
|
-
if (this.sql) {
|
|
383
|
-
try {
|
|
384
|
-
await this.sql.close();
|
|
385
|
-
} catch {
|
|
386
|
-
// Expected: connection may already be terminated during cleanup
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Close TCP server (Bun.listen returns a server with stop() method)
|
|
391
|
-
if (this.server) {
|
|
392
|
-
this.server.stop();
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Build a `backendExited` handler for cluster mode supervision.
|
|
400
|
-
*
|
|
401
|
-
* On unexpected exit (`expected: false`) — postgres SIGKILL'd, OOM-killed,
|
|
402
|
-
* segfaulted, etc. — the handler:
|
|
403
|
-
* 1. logs the exit code,
|
|
404
|
-
* 2. flips `shuttingDown` so the cluster.on('exit') worker-respawn path
|
|
405
|
-
* no longer forks new workers,
|
|
406
|
-
* 3. SIGTERMs every live worker (no point routing to a dead backend), and
|
|
407
|
-
* 4. calls `exitFn(1)` so the parent process supervisor restarts us.
|
|
408
|
-
*
|
|
409
|
-
* On clean exit (`expected: true`, initiated by `pgManager.stop()`) the
|
|
410
|
-
* handler is silent — the surrounding shutdown logic handles teardown.
|
|
411
|
-
*
|
|
412
|
-
* Exported so the supervision contract can be unit-tested without spawning
|
|
413
|
-
* a real cluster (the integration path is already covered for single-process
|
|
414
|
-
* mode by tests/wrapper-supervision.test.js).
|
|
415
|
-
*
|
|
416
|
-
* @param {object} args
|
|
417
|
-
* @param {Map<number, {kill: (sig: string) => void}>} args.workers - live worker registry
|
|
418
|
-
* @param {(v: boolean) => void} args.setShuttingDown - flips outer-scope `shuttingDown`
|
|
419
|
-
* @param {(code: number) => void} [args.exitFn=process.exit] - test seam
|
|
420
|
-
* @param {(...args: unknown[]) => void} [args.log=console.error] - test seam
|
|
421
|
-
* @returns {(info: {code: number, expected: boolean}) => void}
|
|
422
|
-
*/
|
|
423
|
-
export function buildClusterSupervisionHandler({
|
|
424
|
-
workers,
|
|
425
|
-
setShuttingDown,
|
|
426
|
-
exitFn = process.exit,
|
|
427
|
-
log = console.error,
|
|
428
|
-
}) {
|
|
429
|
-
return ({ code, expected }) => {
|
|
430
|
-
if (expected) return;
|
|
431
|
-
log(
|
|
432
|
-
`[pgserve] postgres backend exited unexpectedly (code=${code}) in cluster mode; ` +
|
|
433
|
-
`the primary is exiting so a process supervisor can restart it.`
|
|
434
|
-
);
|
|
435
|
-
setShuttingDown(true);
|
|
436
|
-
for (const worker of workers.values()) {
|
|
437
|
-
try { worker.kill('SIGTERM'); } catch { /* worker may already be dead */ }
|
|
438
|
-
}
|
|
439
|
-
exitFn(1);
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Start pgserve in cluster mode
|
|
445
|
-
*/
|
|
446
|
-
export async function startClusterServer(options = {}) {
|
|
447
|
-
const numWorkers = options.workers || os.cpus().length;
|
|
448
|
-
const port = options.port || 8432;
|
|
449
|
-
const host = options.host || '127.0.0.1';
|
|
450
|
-
const pgPort = options.pgPort || (port + 1000);
|
|
451
|
-
|
|
452
|
-
if (cluster.isPrimary) {
|
|
453
|
-
// Port binding happens in workers via Bun.listen with reusePort
|
|
454
|
-
// If port is in use, first worker will fail with EADDRINUSE
|
|
455
|
-
console.log(`[pgserve] Cluster mode: ${numWorkers} workers`);
|
|
456
|
-
|
|
457
|
-
// PRIMARY: Start our embedded PostgreSQL (single instance)
|
|
458
|
-
const logger = createLogger({ level: options.logLevel || 'info' });
|
|
459
|
-
const pgManager = new PostgresManager({
|
|
460
|
-
dataDir: options.baseDir,
|
|
461
|
-
port: pgPort,
|
|
462
|
-
logger: logger.child({ component: 'postgres' }),
|
|
463
|
-
useRam: options.useRam, // Use /dev/shm for true RAM storage (Linux only)
|
|
464
|
-
enablePgvector: options.enablePgvector // Auto-enable pgvector extension on new databases
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
await pgManager.start();
|
|
468
|
-
const pgSocketPath = pgManager.getSocketPath();
|
|
469
|
-
|
|
470
|
-
console.log(`[pgserve] Embedded PostgreSQL started`);
|
|
471
|
-
console.log(`[pgserve] Socket: ${pgSocketPath || `TCP port ${pgPort}`}`);
|
|
472
|
-
|
|
473
|
-
// Track shutdown state and worker registry early so the supervision
|
|
474
|
-
// handler below can tear workers down on unexpected backend death.
|
|
475
|
-
let shuttingDown = false;
|
|
476
|
-
const workers = new Map();
|
|
477
|
-
const workerStats = new Map(); // Track stats from each worker
|
|
478
|
-
|
|
479
|
-
// Supervision: when the embedded postgres backend dies unexpectedly
|
|
480
|
-
// (SIGKILL/OOM/segfault — anything other than a clean stop()), exit the
|
|
481
|
-
// primary so a process supervisor (`genie serve`, pm2, systemd) restarts
|
|
482
|
-
// the cluster cleanly with a fresh backend. Without this, the primary
|
|
483
|
-
// keeps running with a zombie pgManager (socketDir nulled) and every
|
|
484
|
-
// worker fails StartupMessages with "Connection closed" forever while
|
|
485
|
-
// pm2 reports the process as healthy. Mirrors the single-process fix
|
|
486
|
-
// in bin/postgres-server.js (PgserveDaemon.on('backendDiedUnexpectedly'))
|
|
487
|
-
// — pgserve#45 only protected the daemon path, not cluster mode (default
|
|
488
|
-
// on multi-core systems).
|
|
489
|
-
pgManager.on('backendExited', buildClusterSupervisionHandler({
|
|
490
|
-
workers,
|
|
491
|
-
setShuttingDown: (v) => { shuttingDown = v; },
|
|
492
|
-
}));
|
|
493
|
-
|
|
494
|
-
// Fork workers with PostgreSQL connection info.
|
|
495
|
-
//
|
|
496
|
-
// Pass through using AUTOPG_<X> (the primary names) so workers don't
|
|
497
|
-
// emit the legacy-PGSERVE deprecation log for our own internal IPC.
|
|
498
|
-
// PGSERVE_WORKER stays as-is — it's an internal flag, not part of the
|
|
499
|
-
// settings precedence chain.
|
|
500
|
-
const workerEnv = () => ({
|
|
501
|
-
PGSERVE_WORKER: 'true',
|
|
502
|
-
AUTOPG_PORT: String(port),
|
|
503
|
-
AUTOPG_HOST: host,
|
|
504
|
-
AUTOPG_PG_SOCKET: pgSocketPath || '',
|
|
505
|
-
AUTOPG_PG_PORT: String(pgPort),
|
|
506
|
-
AUTOPG_PG_USER: 'postgres',
|
|
507
|
-
AUTOPG_PG_PASSWORD: 'postgres',
|
|
508
|
-
AUTOPG_LOG_LEVEL: options.logLevel || 'info',
|
|
509
|
-
AUTOPG_AUTO_PROVISION: options.autoProvision !== false ? 'true' : 'false',
|
|
510
|
-
// max_connections is a postgres GUC, not a server-level env. Workers
|
|
511
|
-
// read it from settings.postgres via loadEffectiveConfig; we still
|
|
512
|
-
// ship the value here so a CLI override (e.g. `--max-connections`)
|
|
513
|
-
// reaches the worker before the daemon writes settings.json.
|
|
514
|
-
AUTOPG_MAX_CONNECTIONS: String(options.maxConnections || 1000),
|
|
515
|
-
AUTOPG_ENABLE_PGVECTOR: options.enablePgvector ? 'true' : 'false',
|
|
516
|
-
});
|
|
517
|
-
for (let i = 0; i < numWorkers; i++) {
|
|
518
|
-
const worker = cluster.fork(workerEnv());
|
|
519
|
-
workers.set(worker.id, worker);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// (shuttingDown declared above with the supervision handler.)
|
|
523
|
-
|
|
524
|
-
// Restart dead workers (unless shutting down)
|
|
525
|
-
cluster.on('exit', (worker, code, signal) => {
|
|
526
|
-
workers.delete(worker.id);
|
|
527
|
-
|
|
528
|
-
if (shuttingDown) {
|
|
529
|
-
return; // Don't restart during shutdown
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
console.log(`[pgserve] Worker ${worker.id} died (${signal || code}), restarting...`);
|
|
533
|
-
const newWorker = cluster.fork(workerEnv());
|
|
534
|
-
workers.set(newWorker.id, newWorker);
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
// Wait for workers to be ready and handle IPC messages
|
|
538
|
-
let readyCount = 0;
|
|
539
|
-
await new Promise((resolve) => {
|
|
540
|
-
cluster.on('message', (worker, message) => {
|
|
541
|
-
if (message.type === 'ready') {
|
|
542
|
-
readyCount++;
|
|
543
|
-
if (readyCount === numWorkers) resolve();
|
|
544
|
-
} else if (message.type === 'stats') {
|
|
545
|
-
// Update worker stats from IPC
|
|
546
|
-
workerStats.set(worker.id, {
|
|
547
|
-
...message.data,
|
|
548
|
-
lastUpdate: Date.now()
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
});
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
console.log(`[pgserve] All ${numWorkers} workers ready`);
|
|
555
|
-
console.log(`[pgserve] Listening on ${host}:${port}`);
|
|
556
|
-
|
|
557
|
-
return {
|
|
558
|
-
workers,
|
|
559
|
-
pgPort,
|
|
560
|
-
pgSocketPath,
|
|
561
|
-
stop: async () => {
|
|
562
|
-
console.log('[pgserve] Stopping cluster...');
|
|
563
|
-
shuttingDown = true; // Prevent worker restart during shutdown
|
|
564
|
-
for (const worker of workers.values()) {
|
|
565
|
-
worker.send({ type: 'shutdown' });
|
|
566
|
-
}
|
|
567
|
-
await new Promise((resolve) => {
|
|
568
|
-
const check = setInterval(() => {
|
|
569
|
-
if (workers.size === 0) {
|
|
570
|
-
clearInterval(check);
|
|
571
|
-
resolve();
|
|
572
|
-
}
|
|
573
|
-
}, 100);
|
|
574
|
-
});
|
|
575
|
-
await pgManager.stop();
|
|
576
|
-
console.log('[pgserve] Cluster stopped');
|
|
577
|
-
},
|
|
578
|
-
getStats: () => {
|
|
579
|
-
// Aggregate stats from all workers
|
|
580
|
-
let totalConnections = 0;
|
|
581
|
-
let totalConnected = 0;
|
|
582
|
-
let totalDisconnected = 0;
|
|
583
|
-
const activeWorkerStats = {};
|
|
584
|
-
|
|
585
|
-
for (const [id, stats] of workerStats) {
|
|
586
|
-
// Only include recent stats (within timeout window)
|
|
587
|
-
if (Date.now() - stats.lastUpdate < WORKER_STATS_TIMEOUT_MS) {
|
|
588
|
-
totalConnections += stats.connections || 0;
|
|
589
|
-
totalConnected += stats.totalConnected || 0;
|
|
590
|
-
totalDisconnected += stats.totalDisconnected || 0;
|
|
591
|
-
activeWorkerStats[id] = stats;
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
return {
|
|
596
|
-
workers: workers.size,
|
|
597
|
-
pids: Array.from(workers.values()).map(w => w.process.pid),
|
|
598
|
-
connections: {
|
|
599
|
-
active: totalConnections,
|
|
600
|
-
totalConnected,
|
|
601
|
-
totalDisconnected
|
|
602
|
-
},
|
|
603
|
-
workerStats: activeWorkerStats
|
|
604
|
-
};
|
|
605
|
-
},
|
|
606
|
-
pgManager
|
|
607
|
-
};
|
|
608
|
-
} else {
|
|
609
|
-
// WORKER: Only run TCP routing, connect to PRIMARY's PostgreSQL.
|
|
610
|
-
//
|
|
611
|
-
// Worker config comes from loadEffectiveConfig() (defaults < file < env).
|
|
612
|
-
// The primary fork() above sets PGSERVE_* env vars so existing supervised
|
|
613
|
-
// installs keep working; AUTOPG_* env vars take precedence when set, and
|
|
614
|
-
// a one-time deprecation note is emitted for legacy-only PGSERVE_* hits.
|
|
615
|
-
const { settings } = loadEffectiveConfig();
|
|
616
|
-
const router = new ClusterRouter({
|
|
617
|
-
port: settings.server.port,
|
|
618
|
-
host: settings.server.host,
|
|
619
|
-
pgSocketPath: settings.server.pgSocketPath || null,
|
|
620
|
-
pgPort: settings.server.pgPort,
|
|
621
|
-
pgUser: settings.server.pgUser,
|
|
622
|
-
pgPassword: settings.server.pgPassword,
|
|
623
|
-
logLevel: settings.runtime.logLevel,
|
|
624
|
-
autoProvision: settings.runtime.autoProvision,
|
|
625
|
-
maxConnections: settings.postgres.max_connections,
|
|
626
|
-
enablePgvector: settings.runtime.enablePgvector,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
await router.start();
|
|
630
|
-
|
|
631
|
-
// Tell PRIMARY we're ready
|
|
632
|
-
process.send({ type: 'ready' });
|
|
633
|
-
|
|
634
|
-
// Periodically send stats to PRIMARY
|
|
635
|
-
const statsInterval = setInterval(() => {
|
|
636
|
-
try {
|
|
637
|
-
process.send({ type: 'stats', data: router.getStats() });
|
|
638
|
-
} catch {
|
|
639
|
-
// Expected: IPC channel may be closed during shutdown
|
|
640
|
-
}
|
|
641
|
-
}, WORKER_STATS_REPORT_INTERVAL_MS);
|
|
642
|
-
|
|
643
|
-
// Handle shutdown
|
|
644
|
-
process.on('message', async (message) => {
|
|
645
|
-
if (message.type === 'shutdown') {
|
|
646
|
-
clearInterval(statsInterval);
|
|
647
|
-
await router.stop();
|
|
648
|
-
process.exit(0);
|
|
649
|
-
}
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
return router;
|
|
653
|
-
}
|
|
654
|
-
}
|