pgserve 0.1.5 → 1.0.2
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/.claude/settings.local.json +11 -0
- package/.husky/pre-commit +2 -0
- package/README.md +281 -256
- package/bin/pglite-server.js +214 -396
- package/eslint.config.js +54 -0
- package/knip.json +8 -0
- package/package.json +32 -11
- package/src/cluster.js +320 -0
- package/src/dashboard.js +211 -0
- package/src/index.js +10 -171
- package/src/postgres.js +478 -0
- package/src/protocol.js +31 -7
- package/src/restore.js +587 -0
- package/src/router.js +172 -115
- package/src/sync.js +342 -0
- package/tests/benchmarks/runner.js +300 -155
- package/tests/sync-perf-test.js +150 -0
- package/src/detector.js +0 -105
- package/src/pool.js +0 -320
- package/src/ports.js +0 -114
- package/src/registry.js +0 -134
- package/src/server.js +0 -265
package/src/router.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Multi-Tenant Router (
|
|
2
|
+
* Multi-Tenant Router (TCP Proxy to Embedded PostgreSQL)
|
|
3
3
|
*
|
|
4
|
-
* Single TCP server that routes connections to
|
|
5
|
-
*
|
|
4
|
+
* Single TCP server that routes connections to an embedded PostgreSQL instance.
|
|
5
|
+
* Extracts database name from PostgreSQL startup message, auto-creates database,
|
|
6
|
+
* then proxies the connection to real PostgreSQL.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
8
|
+
* Features:
|
|
9
|
+
* - TRUE concurrent connections (native PostgreSQL)
|
|
10
|
+
* - Auto-provision databases on first connection
|
|
11
|
+
* - Zero configuration required
|
|
12
|
+
* - Memory mode (default) or persistent storage
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import net from 'net';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { PostgresManager } from './postgres.js';
|
|
17
|
+
import { SyncManager } from './sync.js';
|
|
18
|
+
import { RestoreManager } from './restore.js';
|
|
19
|
+
import { Dashboard } from './dashboard.js';
|
|
17
20
|
import { extractDatabaseNameFromSocket } from './protocol.js';
|
|
18
21
|
import { EventEmitter } from 'events';
|
|
19
22
|
import pino from 'pino';
|
|
@@ -24,13 +27,15 @@ import pino from 'pino';
|
|
|
24
27
|
export class MultiTenantRouter extends EventEmitter {
|
|
25
28
|
constructor(options = {}) {
|
|
26
29
|
super();
|
|
27
|
-
this.port = options.port ||
|
|
30
|
+
this.port = options.port || 5432;
|
|
28
31
|
this.host = options.host || '127.0.0.1';
|
|
29
|
-
this.baseDir = options.baseDir ||
|
|
30
|
-
this.memoryMode = options.
|
|
31
|
-
this.
|
|
32
|
+
this.baseDir = options.baseDir || null; // null = memory mode
|
|
33
|
+
this.memoryMode = !options.baseDir;
|
|
34
|
+
this.maxConnections = options.maxConnections || 1000;
|
|
32
35
|
this.autoProvision = options.autoProvision !== false;
|
|
33
|
-
|
|
36
|
+
|
|
37
|
+
// Internal PostgreSQL port (different from router port)
|
|
38
|
+
this.pgPort = options.pgPort || (this.port + 1000);
|
|
34
39
|
|
|
35
40
|
// Pino logger (ultra-fast structured logging)
|
|
36
41
|
const logLevel = options.logLevel || 'info';
|
|
@@ -42,13 +47,19 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
42
47
|
} : undefined
|
|
43
48
|
});
|
|
44
49
|
|
|
45
|
-
//
|
|
46
|
-
this.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// Sync options (async replication to real PostgreSQL)
|
|
51
|
+
this.syncTo = options.syncTo || null;
|
|
52
|
+
this.syncDatabases = options.syncDatabases
|
|
53
|
+
? options.syncDatabases.split(',').map(s => s.trim())
|
|
54
|
+
: [];
|
|
55
|
+
this.syncManager = null;
|
|
56
|
+
|
|
57
|
+
// PostgreSQL manager (with sync flag if needed)
|
|
58
|
+
this.pgManager = new PostgresManager({
|
|
59
|
+
dataDir: this.baseDir,
|
|
60
|
+
port: this.pgPort,
|
|
61
|
+
logger: this.logger.child({ component: 'postgres' }),
|
|
62
|
+
syncEnabled: !!this.syncTo // Enable logical replication if sync is configured
|
|
52
63
|
});
|
|
53
64
|
|
|
54
65
|
// TCP server
|
|
@@ -56,21 +67,7 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
56
67
|
this.connections = new Set();
|
|
57
68
|
|
|
58
69
|
// Performance: Reduce event listener overhead
|
|
59
|
-
this.setMaxListeners(this.
|
|
60
|
-
|
|
61
|
-
// Forward pool events (optimized logging)
|
|
62
|
-
this.pool.on('instance-created', (dbName) => {
|
|
63
|
-
this.logger.info({ dbName }, 'Database created');
|
|
64
|
-
this.emit('database-created', dbName);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
this.pool.on('instance-locked', (dbName) => {
|
|
68
|
-
this.logger.debug({ dbName }, 'Database locked');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
this.pool.on('instance-unlocked', (dbName) => {
|
|
72
|
-
this.logger.debug({ dbName }, 'Database unlocked');
|
|
73
|
-
});
|
|
70
|
+
this.setMaxListeners(this.maxConnections + 10);
|
|
74
71
|
}
|
|
75
72
|
|
|
76
73
|
/**
|
|
@@ -84,16 +81,6 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
84
81
|
// Enable TCP keepalive (detect dead connections)
|
|
85
82
|
socket.setKeepAlive(true, 60000); // 60s initial delay
|
|
86
83
|
|
|
87
|
-
// Increase socket buffer sizes for better throughput
|
|
88
|
-
// Note: These are hints to OS, actual values may differ
|
|
89
|
-
try {
|
|
90
|
-
socket.setRecvBufferSize && socket.setRecvBufferSize(128 * 1024); // 128KB
|
|
91
|
-
socket.setSendBufferSize && socket.setSendBufferSize(128 * 1024); // 128KB
|
|
92
|
-
} catch (err) {
|
|
93
|
-
// Ignore if not supported
|
|
94
|
-
this.logger.debug({ err }, 'Could not set socket buffer sizes');
|
|
95
|
-
}
|
|
96
|
-
|
|
97
84
|
// Prevent socket timeout during long-running queries
|
|
98
85
|
socket.setTimeout(0);
|
|
99
86
|
}
|
|
@@ -102,19 +89,87 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
102
89
|
* Start multi-tenant router
|
|
103
90
|
*/
|
|
104
91
|
async start() {
|
|
105
|
-
|
|
106
|
-
|
|
92
|
+
// Initialize dashboard for informative CLI output
|
|
93
|
+
const dashboard = new Dashboard();
|
|
94
|
+
dashboard.showHeader({
|
|
95
|
+
port: this.port,
|
|
96
|
+
host: this.host,
|
|
97
|
+
memoryMode: this.memoryMode,
|
|
98
|
+
syncTo: this.syncTo
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Start PostgreSQL first
|
|
102
|
+
dashboard.stage('PostgreSQL binaries resolved');
|
|
103
|
+
await this.pgManager.start();
|
|
104
|
+
dashboard.stage('PostgreSQL started');
|
|
105
|
+
|
|
106
|
+
// Automatic restore from external PostgreSQL (if sync configured)
|
|
107
|
+
// This runs BEFORE SyncManager to restore data before enabling outbound sync
|
|
108
|
+
if (this.syncTo) {
|
|
109
|
+
const restoreManager = new RestoreManager({
|
|
110
|
+
sourceUrl: this.syncTo,
|
|
111
|
+
patterns: this.syncDatabases,
|
|
112
|
+
targetPort: this.pgPort,
|
|
113
|
+
targetSocketPath: this.pgManager.getSocketPath(),
|
|
114
|
+
logger: this.logger.child({ component: 'restore' }),
|
|
115
|
+
onProgress: (metrics) => dashboard.updateRestore(metrics)
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Start restore progress display
|
|
119
|
+
dashboard.startRestore(restoreManager.totalDatabases || 1);
|
|
120
|
+
|
|
121
|
+
const restoreResult = await restoreManager.restore(this.pgManager);
|
|
122
|
+
|
|
123
|
+
if (restoreResult.success) {
|
|
124
|
+
dashboard.completeRestore(restoreResult.metrics);
|
|
125
|
+
this.logger.info({
|
|
126
|
+
databases: restoreResult.metrics.databasesRestored,
|
|
127
|
+
tables: restoreResult.metrics.tablesRestored,
|
|
128
|
+
rows: restoreResult.metrics.rowsRestored,
|
|
129
|
+
bytes: restoreResult.metrics.bytesTransferred,
|
|
130
|
+
durationMs: restoreResult.metrics.endTime - restoreResult.metrics.startTime
|
|
131
|
+
}, 'Restored from external PostgreSQL');
|
|
132
|
+
} else if (restoreResult.skipped) {
|
|
133
|
+
// No progress to complete - was skipped
|
|
134
|
+
} else {
|
|
135
|
+
dashboard.cleanup();
|
|
136
|
+
this.logger.warn({ error: restoreResult.error }, 'Restore failed (continuing without restored data)');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Initialize SyncManager if configured (async replication)
|
|
141
|
+
if (this.syncTo) {
|
|
142
|
+
this.syncManager = new SyncManager({
|
|
143
|
+
targetUrl: this.syncTo,
|
|
144
|
+
databases: this.syncDatabases,
|
|
145
|
+
sourcePort: this.pgPort,
|
|
146
|
+
sourceSocketPath: this.pgManager.getSocketPath(),
|
|
147
|
+
logLevel: this.logger.level
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Wire SyncManager to PostgresManager for database creation hooks
|
|
151
|
+
this.pgManager.setSyncManager(this.syncManager);
|
|
152
|
+
|
|
153
|
+
// Initialize sync connections (non-blocking, runs in background)
|
|
154
|
+
this.syncManager.initialize(this.pgManager)
|
|
155
|
+
.then(() => {
|
|
156
|
+
dashboard.stage('Sync manager initialized');
|
|
157
|
+
this.logger.info('Sync manager initialized');
|
|
158
|
+
})
|
|
159
|
+
.catch(err => this.logger.warn({ err: err.message }, 'Sync manager initialization failed (non-fatal)'));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return new Promise((resolve, _reject) => {
|
|
163
|
+
// Create TCP server
|
|
107
164
|
this.server = net.createServer({
|
|
108
|
-
// Performance: Allow half-open sockets (faster cleanup)
|
|
109
165
|
allowHalfOpen: false,
|
|
110
|
-
// Performance: Pause on connect (manual resume after setup)
|
|
111
166
|
pauseOnConnect: true
|
|
112
167
|
}, async (socket) => {
|
|
113
168
|
await this.handleConnection(socket);
|
|
114
169
|
});
|
|
115
170
|
|
|
116
|
-
// Set max connections
|
|
117
|
-
this.server.maxConnections = this.
|
|
171
|
+
// Set max connections
|
|
172
|
+
this.server.maxConnections = this.maxConnections;
|
|
118
173
|
|
|
119
174
|
// Error handling
|
|
120
175
|
this.server.on('error', (error) => {
|
|
@@ -124,13 +179,20 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
124
179
|
|
|
125
180
|
// Start listening
|
|
126
181
|
this.server.listen(this.port, this.host, () => {
|
|
182
|
+
const socketPath = this.pgManager.getSocketPath();
|
|
183
|
+
|
|
184
|
+
dashboard.stage('TCP server listening');
|
|
185
|
+
dashboard.showReady({ port: this.port, host: this.host });
|
|
186
|
+
|
|
127
187
|
this.logger.info({
|
|
128
188
|
host: this.host,
|
|
129
189
|
port: this.port,
|
|
190
|
+
pgPort: this.pgPort,
|
|
191
|
+
pgSocketPath: socketPath || '(TCP)',
|
|
130
192
|
baseDir: this.memoryMode ? '(in-memory)' : this.baseDir,
|
|
131
193
|
memoryMode: this.memoryMode,
|
|
132
194
|
autoProvision: this.autoProvision,
|
|
133
|
-
|
|
195
|
+
maxConnections: this.maxConnections
|
|
134
196
|
}, 'Multi-tenant router started');
|
|
135
197
|
|
|
136
198
|
this.emit('listening');
|
|
@@ -140,91 +202,81 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
140
202
|
}
|
|
141
203
|
|
|
142
204
|
/**
|
|
143
|
-
* Handle incoming connection (
|
|
205
|
+
* Handle incoming connection (TCP Proxy)
|
|
206
|
+
* OPTIMIZED: Removed hot path logging for performance
|
|
144
207
|
*/
|
|
145
208
|
async handleConnection(socket) {
|
|
146
|
-
const connId = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
147
|
-
const startTime = Date.now();
|
|
148
|
-
|
|
149
|
-
// Optimize socket BEFORE any I/O
|
|
150
|
-
this.optimizeSocket(socket);
|
|
151
|
-
|
|
152
209
|
// Track connection
|
|
153
210
|
this.connections.add(socket);
|
|
154
211
|
|
|
155
|
-
//
|
|
156
|
-
|
|
212
|
+
// Optimize socket BEFORE any I/O
|
|
213
|
+
this.optimizeSocket(socket);
|
|
157
214
|
|
|
158
215
|
let dbName = null;
|
|
159
|
-
let
|
|
216
|
+
let pgSocket = null;
|
|
160
217
|
|
|
161
218
|
try {
|
|
162
219
|
// Extract database name from PostgreSQL handshake
|
|
163
|
-
this.logger.debug({ connId }, 'Reading startup message');
|
|
164
220
|
const { dbName: extractedDbName, buffered } = await extractDatabaseNameFromSocket(socket);
|
|
165
221
|
dbName = extractedDbName;
|
|
166
222
|
|
|
167
|
-
|
|
223
|
+
// Auto-provision database if needed
|
|
224
|
+
if (this.autoProvision) {
|
|
225
|
+
await this.pgManager.createDatabase(dbName);
|
|
226
|
+
}
|
|
168
227
|
|
|
169
|
-
//
|
|
170
|
-
const
|
|
228
|
+
// Connect to real PostgreSQL (prefer Unix socket for speed)
|
|
229
|
+
const socketPath = this.pgManager.getSocketPath();
|
|
230
|
+
if (socketPath) {
|
|
231
|
+
// Unix socket connection (Linux/macOS) - ~30% faster than TCP
|
|
232
|
+
pgSocket = net.connect({ path: socketPath });
|
|
233
|
+
} else {
|
|
234
|
+
// TCP fallback (Windows)
|
|
235
|
+
pgSocket = net.connect({ host: '127.0.0.1', port: this.pgPort });
|
|
236
|
+
}
|
|
171
237
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
routingTimeMs: routingTime
|
|
178
|
-
}, 'Routed to database');
|
|
238
|
+
// Wait for PostgreSQL connection
|
|
239
|
+
await new Promise((resolve, reject) => {
|
|
240
|
+
pgSocket.once('connect', resolve);
|
|
241
|
+
pgSocket.once('error', reject);
|
|
242
|
+
});
|
|
179
243
|
|
|
180
|
-
|
|
181
|
-
socket.unshift(buffered);
|
|
244
|
+
this.optimizeSocket(pgSocket);
|
|
182
245
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
db: instance.db,
|
|
186
|
-
closeOnDetach: true,
|
|
187
|
-
inspect: this.inspect
|
|
188
|
-
});
|
|
246
|
+
// Send the buffered startup message to PostgreSQL
|
|
247
|
+
pgSocket.write(buffered);
|
|
189
248
|
|
|
190
|
-
//
|
|
191
|
-
|
|
249
|
+
// Resume client socket (was paused on connect)
|
|
250
|
+
socket.resume();
|
|
192
251
|
|
|
193
|
-
|
|
252
|
+
// Bidirectional pipe (TRUE proxy)
|
|
253
|
+
socket.pipe(pgSocket);
|
|
254
|
+
pgSocket.pipe(socket);
|
|
194
255
|
|
|
195
|
-
// Handle
|
|
256
|
+
// Handle cleanup - optimized: single handler, no logging in hot path
|
|
196
257
|
const cleanup = () => {
|
|
197
|
-
this.logger.debug({ dbName, connId }, 'Connection closed');
|
|
198
|
-
if (handler) {
|
|
199
|
-
handler.detach();
|
|
200
|
-
}
|
|
201
258
|
this.connections.delete(socket);
|
|
202
|
-
|
|
203
|
-
|
|
259
|
+
if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
|
|
260
|
+
if (socket && !socket.destroyed) socket.destroy();
|
|
204
261
|
};
|
|
205
262
|
|
|
206
263
|
socket.once('close', cleanup);
|
|
207
|
-
socket.once('error',
|
|
208
|
-
|
|
209
|
-
|
|
264
|
+
socket.once('error', cleanup);
|
|
265
|
+
pgSocket.once('close', () => {
|
|
266
|
+
if (socket && !socket.destroyed) socket.destroy();
|
|
210
267
|
});
|
|
268
|
+
pgSocket.once('error', cleanup);
|
|
211
269
|
|
|
212
|
-
this.emit('connection', { dbName, socket
|
|
270
|
+
this.emit('connection', { dbName, socket });
|
|
213
271
|
} catch (error) {
|
|
214
|
-
|
|
272
|
+
// Only log actual errors
|
|
273
|
+
this.logger.error({ dbName, err: error }, 'Connection error');
|
|
215
274
|
|
|
216
275
|
// Cleanup
|
|
217
|
-
if (
|
|
218
|
-
|
|
219
|
-
handler.detach();
|
|
220
|
-
} catch (detachErr) {
|
|
221
|
-
this.logger.debug({ err: detachErr }, 'Error detaching handler');
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
socket.destroy(); // Force close on error
|
|
276
|
+
if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
|
|
277
|
+
socket.destroy();
|
|
226
278
|
this.connections.delete(socket);
|
|
227
|
-
this.emit('connection-error', { error, dbName
|
|
279
|
+
this.emit('connection-error', { error, dbName });
|
|
228
280
|
}
|
|
229
281
|
}
|
|
230
282
|
|
|
@@ -237,7 +289,7 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
237
289
|
// Close all connections gracefully
|
|
238
290
|
const activeConns = this.connections.size;
|
|
239
291
|
for (const socket of this.connections) {
|
|
240
|
-
socket.end();
|
|
292
|
+
socket.end();
|
|
241
293
|
}
|
|
242
294
|
this.connections.clear();
|
|
243
295
|
|
|
@@ -248,12 +300,16 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
248
300
|
});
|
|
249
301
|
}
|
|
250
302
|
|
|
251
|
-
//
|
|
252
|
-
|
|
303
|
+
// Stop SyncManager first (before PostgreSQL)
|
|
304
|
+
if (this.syncManager) {
|
|
305
|
+
await this.syncManager.stop();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Stop PostgreSQL
|
|
309
|
+
await this.pgManager.stop();
|
|
253
310
|
|
|
254
311
|
this.logger.info({
|
|
255
|
-
activeConnections: activeConns
|
|
256
|
-
closedInstances: this.pool.instances.size
|
|
312
|
+
activeConnections: activeConns
|
|
257
313
|
}, 'Router stopped');
|
|
258
314
|
|
|
259
315
|
this.emit('stopped');
|
|
@@ -266,8 +322,9 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
266
322
|
return {
|
|
267
323
|
port: this.port,
|
|
268
324
|
host: this.host,
|
|
325
|
+
pgPort: this.pgPort,
|
|
269
326
|
activeConnections: this.connections.size,
|
|
270
|
-
|
|
327
|
+
postgres: this.pgManager.getStats()
|
|
271
328
|
};
|
|
272
329
|
}
|
|
273
330
|
|
|
@@ -275,7 +332,7 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
275
332
|
* List all databases
|
|
276
333
|
*/
|
|
277
334
|
listDatabases() {
|
|
278
|
-
return this.
|
|
335
|
+
return this.pgManager.getStats().databases;
|
|
279
336
|
}
|
|
280
337
|
}
|
|
281
338
|
|