pgserve 0.1.5 → 1.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/src/router.js CHANGED
@@ -1,19 +1,20 @@
1
1
  /**
2
- * Multi-Tenant Router (Performance Optimized)
2
+ * Multi-Tenant Router (TCP Proxy to Embedded PostgreSQL)
3
3
  *
4
- * Single TCP server that routes connections to different PGlite instances
5
- * based on database name from PostgreSQL connection string
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
- * Performance Optimizations:
8
- * - Pino logger (5x faster than console.log)
9
- * - TCP socket optimizations (nodelay, keepalive)
10
- * - Minimal event emitter overhead
11
- * - Optimized connection tracking
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 { PGLiteSocketHandler } from '@electric-sql/pglite-socket';
16
- import { InstancePool } from './pool.js';
16
+ import { PostgresManager } from './postgres.js';
17
+ import { SyncManager } from './sync.js';
17
18
  import { extractDatabaseNameFromSocket } from './protocol.js';
18
19
  import { EventEmitter } from 'events';
19
20
  import pino from 'pino';
@@ -24,13 +25,15 @@ import pino from 'pino';
24
25
  export class MultiTenantRouter extends EventEmitter {
25
26
  constructor(options = {}) {
26
27
  super();
27
- this.port = options.port || 8432;
28
+ this.port = options.port || 5432;
28
29
  this.host = options.host || '127.0.0.1';
29
- this.baseDir = options.baseDir || './data';
30
- this.memoryMode = options.memoryMode || false;
31
- this.maxInstances = options.maxInstances || 100;
30
+ this.baseDir = options.baseDir || null; // null = memory mode
31
+ this.memoryMode = !options.baseDir;
32
+ this.maxConnections = options.maxConnections || 1000;
32
33
  this.autoProvision = options.autoProvision !== false;
33
- this.inspect = options.inspect || false;
34
+
35
+ // Internal PostgreSQL port (different from router port)
36
+ this.pgPort = options.pgPort || (this.port + 1000);
34
37
 
35
38
  // Pino logger (ultra-fast structured logging)
36
39
  const logLevel = options.logLevel || 'info';
@@ -42,13 +45,19 @@ export class MultiTenantRouter extends EventEmitter {
42
45
  } : undefined
43
46
  });
44
47
 
45
- // Instance pool
46
- this.pool = new InstancePool({
47
- baseDir: this.baseDir,
48
- memoryMode: this.memoryMode,
49
- maxInstances: this.maxInstances,
50
- autoProvision: this.autoProvision,
51
- logger: this.logger.child({ component: 'pool' })
48
+ // Sync options (async replication to real PostgreSQL)
49
+ this.syncTo = options.syncTo || null;
50
+ this.syncDatabases = options.syncDatabases
51
+ ? options.syncDatabases.split(',').map(s => s.trim())
52
+ : [];
53
+ this.syncManager = null;
54
+
55
+ // PostgreSQL manager (with sync flag if needed)
56
+ this.pgManager = new PostgresManager({
57
+ dataDir: this.baseDir,
58
+ port: this.pgPort,
59
+ logger: this.logger.child({ component: 'postgres' }),
60
+ syncEnabled: !!this.syncTo // Enable logical replication if sync is configured
52
61
  });
53
62
 
54
63
  // TCP server
@@ -56,21 +65,7 @@ export class MultiTenantRouter extends EventEmitter {
56
65
  this.connections = new Set();
57
66
 
58
67
  // Performance: Reduce event listener overhead
59
- this.setMaxListeners(this.maxInstances + 10);
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
- });
68
+ this.setMaxListeners(this.maxConnections + 10);
74
69
  }
75
70
 
76
71
  /**
@@ -84,16 +79,6 @@ export class MultiTenantRouter extends EventEmitter {
84
79
  // Enable TCP keepalive (detect dead connections)
85
80
  socket.setKeepAlive(true, 60000); // 60s initial delay
86
81
 
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
82
  // Prevent socket timeout during long-running queries
98
83
  socket.setTimeout(0);
99
84
  }
@@ -102,19 +87,39 @@ export class MultiTenantRouter extends EventEmitter {
102
87
  * Start multi-tenant router
103
88
  */
104
89
  async start() {
90
+ // Start PostgreSQL first
91
+ await this.pgManager.start();
92
+
93
+ // Initialize SyncManager if configured (async replication)
94
+ if (this.syncTo) {
95
+ this.syncManager = new SyncManager({
96
+ targetUrl: this.syncTo,
97
+ databases: this.syncDatabases,
98
+ sourcePort: this.pgPort,
99
+ sourceSocketPath: this.pgManager.getSocketPath(),
100
+ logLevel: this.logger.level
101
+ });
102
+
103
+ // Wire SyncManager to PostgresManager for database creation hooks
104
+ this.pgManager.setSyncManager(this.syncManager);
105
+
106
+ // Initialize sync connections (non-blocking, runs in background)
107
+ this.syncManager.initialize(this.pgManager)
108
+ .then(() => this.logger.info('Sync manager initialized'))
109
+ .catch(err => this.logger.warn({ err: err.message }, 'Sync manager initialization failed (non-fatal)'));
110
+ }
111
+
105
112
  return new Promise((resolve, reject) => {
106
- // Create TCP server with optimizations
113
+ // Create TCP server
107
114
  this.server = net.createServer({
108
- // Performance: Allow half-open sockets (faster cleanup)
109
115
  allowHalfOpen: false,
110
- // Performance: Pause on connect (manual resume after setup)
111
116
  pauseOnConnect: true
112
117
  }, async (socket) => {
113
118
  await this.handleConnection(socket);
114
119
  });
115
120
 
116
- // Set max connections (system limit)
117
- this.server.maxConnections = this.maxInstances * 2;
121
+ // Set max connections
122
+ this.server.maxConnections = this.maxConnections;
118
123
 
119
124
  // Error handling
120
125
  this.server.on('error', (error) => {
@@ -124,13 +129,16 @@ export class MultiTenantRouter extends EventEmitter {
124
129
 
125
130
  // Start listening
126
131
  this.server.listen(this.port, this.host, () => {
132
+ const socketPath = this.pgManager.getSocketPath();
127
133
  this.logger.info({
128
134
  host: this.host,
129
135
  port: this.port,
136
+ pgPort: this.pgPort,
137
+ pgSocketPath: socketPath || '(TCP)',
130
138
  baseDir: this.memoryMode ? '(in-memory)' : this.baseDir,
131
139
  memoryMode: this.memoryMode,
132
140
  autoProvision: this.autoProvision,
133
- maxInstances: this.maxInstances
141
+ maxConnections: this.maxConnections
134
142
  }, 'Multi-tenant router started');
135
143
 
136
144
  this.emit('listening');
@@ -140,91 +148,81 @@ export class MultiTenantRouter extends EventEmitter {
140
148
  }
141
149
 
142
150
  /**
143
- * Handle incoming connection (Performance Optimized)
151
+ * Handle incoming connection (TCP Proxy)
152
+ * OPTIMIZED: Removed hot path logging for performance
144
153
  */
145
154
  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
155
  // Track connection
153
156
  this.connections.add(socket);
154
157
 
155
- // NOTE: Don't resume here - let readStartupMessage() resume after setting up listeners
156
- // This prevents race condition where data arrives before listener is attached
158
+ // Optimize socket BEFORE any I/O
159
+ this.optimizeSocket(socket);
157
160
 
158
161
  let dbName = null;
159
- let handler = null;
162
+ let pgSocket = null;
160
163
 
161
164
  try {
162
165
  // Extract database name from PostgreSQL handshake
163
- this.logger.debug({ connId }, 'Reading startup message');
164
166
  const { dbName: extractedDbName, buffered } = await extractDatabaseNameFromSocket(socket);
165
167
  dbName = extractedDbName;
166
168
 
167
- this.logger.info({ dbName, connId }, 'Connection request');
169
+ // Auto-provision database if needed
170
+ if (this.autoProvision) {
171
+ await this.pgManager.createDatabase(dbName);
172
+ }
168
173
 
169
- // Get or create PGlite instance (with locking)
170
- const instance = await this.pool.acquire(dbName, socket);
174
+ // Connect to real PostgreSQL (prefer Unix socket for speed)
175
+ const socketPath = this.pgManager.getSocketPath();
176
+ if (socketPath) {
177
+ // Unix socket connection (Linux/macOS) - ~30% faster than TCP
178
+ pgSocket = net.connect({ path: socketPath });
179
+ } else {
180
+ // TCP fallback (Windows)
181
+ pgSocket = net.connect({ host: '127.0.0.1', port: this.pgPort });
182
+ }
171
183
 
172
- const routingTime = Date.now() - startTime;
173
- this.logger.info({
174
- dbName,
175
- connId,
176
- dataDir: instance.dataDir,
177
- routingTimeMs: routingTime
178
- }, 'Routed to database');
184
+ // Wait for PostgreSQL connection
185
+ await new Promise((resolve, reject) => {
186
+ pgSocket.once('connect', resolve);
187
+ pgSocket.once('error', reject);
188
+ });
179
189
 
180
- // Push buffered data back to socket for handler to read
181
- socket.unshift(buffered);
190
+ this.optimizeSocket(pgSocket);
182
191
 
183
- // Create handler for this connection
184
- handler = new PGLiteSocketHandler({
185
- db: instance.db,
186
- closeOnDetach: true,
187
- inspect: this.inspect
188
- });
192
+ // Send the buffered startup message to PostgreSQL
193
+ pgSocket.write(buffered);
189
194
 
190
- // Attach socket to handler
191
- await handler.attach(socket);
195
+ // Resume client socket (was paused on connect)
196
+ socket.resume();
192
197
 
193
- this.logger.debug({ dbName, connId }, 'Socket attached');
198
+ // Bidirectional pipe (TRUE proxy)
199
+ socket.pipe(pgSocket);
200
+ pgSocket.pipe(socket);
194
201
 
195
- // Handle socket close (cleanup)
202
+ // Handle cleanup - optimized: single handler, no logging in hot path
196
203
  const cleanup = () => {
197
- this.logger.debug({ dbName, connId }, 'Connection closed');
198
- if (handler) {
199
- handler.detach();
200
- }
201
204
  this.connections.delete(socket);
202
- // Note: Don't call socket.removeAllListeners() here as it removes
203
- // the pool's unlock handlers before they can fire, causing stuck locks
205
+ if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
206
+ if (socket && !socket.destroyed) socket.destroy();
204
207
  };
205
208
 
206
209
  socket.once('close', cleanup);
207
- socket.once('error', (error) => {
208
- this.logger.warn({ dbName, connId, err: error }, 'Socket error');
209
- cleanup();
210
+ socket.once('error', cleanup);
211
+ pgSocket.once('close', () => {
212
+ if (socket && !socket.destroyed) socket.destroy();
210
213
  });
214
+ pgSocket.once('error', cleanup);
211
215
 
212
- this.emit('connection', { dbName, socket, connId });
216
+ this.emit('connection', { dbName, socket });
213
217
  } catch (error) {
214
- this.logger.error({ dbName, connId, err: error }, 'Connection error');
218
+ // Only log actual errors
219
+ this.logger.error({ dbName, err: error }, 'Connection error');
215
220
 
216
221
  // Cleanup
217
- if (handler) {
218
- try {
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
222
+ if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
223
+ socket.destroy();
226
224
  this.connections.delete(socket);
227
- this.emit('connection-error', { error, dbName, connId });
225
+ this.emit('connection-error', { error, dbName });
228
226
  }
229
227
  }
230
228
 
@@ -237,7 +235,7 @@ export class MultiTenantRouter extends EventEmitter {
237
235
  // Close all connections gracefully
238
236
  const activeConns = this.connections.size;
239
237
  for (const socket of this.connections) {
240
- socket.end(); // Graceful close (vs destroy())
238
+ socket.end();
241
239
  }
242
240
  this.connections.clear();
243
241
 
@@ -248,12 +246,16 @@ export class MultiTenantRouter extends EventEmitter {
248
246
  });
249
247
  }
250
248
 
251
- // Close all PGlite instances
252
- await this.pool.closeAll();
249
+ // Stop SyncManager first (before PostgreSQL)
250
+ if (this.syncManager) {
251
+ await this.syncManager.stop();
252
+ }
253
+
254
+ // Stop PostgreSQL
255
+ await this.pgManager.stop();
253
256
 
254
257
  this.logger.info({
255
- activeConnections: activeConns,
256
- closedInstances: this.pool.instances.size
258
+ activeConnections: activeConns
257
259
  }, 'Router stopped');
258
260
 
259
261
  this.emit('stopped');
@@ -266,8 +268,9 @@ export class MultiTenantRouter extends EventEmitter {
266
268
  return {
267
269
  port: this.port,
268
270
  host: this.host,
271
+ pgPort: this.pgPort,
269
272
  activeConnections: this.connections.size,
270
- pool: this.pool.getStats()
273
+ postgres: this.pgManager.getStats()
271
274
  };
272
275
  }
273
276
 
@@ -275,7 +278,7 @@ export class MultiTenantRouter extends EventEmitter {
275
278
  * List all databases
276
279
  */
277
280
  listDatabases() {
278
- return this.pool.list();
281
+ return this.pgManager.getStats().databases;
279
282
  }
280
283
  }
281
284