@volter/world-runtime 3.0.50 → 3.0.52
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/dist/src/pglite-host.mjs +74 -364
- package/package.json +4 -4
- package/src/pglite-host.mjs +74 -364
package/dist/src/pglite-host.mjs
CHANGED
|
@@ -40,43 +40,20 @@
|
|
|
40
40
|
// multiple databases require the native or container backing.
|
|
41
41
|
// - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
|
|
42
42
|
// COPY … TO STDOUT works.
|
|
43
|
-
// - ONE
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// and
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// the holder's uncommitted rows — Postgres would show it only committed ones.
|
|
58
|
-
// While a guest's batch runs, the holder waits for it (its idle clock
|
|
59
|
-
// stopped); a guest batch still open after GUEST_TURN_MS while anyone waits
|
|
60
|
-
// (a cursor that has not sent its Sync) is cancelled (57014), its savepoint
|
|
61
|
-
// undone;
|
|
62
|
-
// a batch that begins with a read and goes on to write is refused (0A000).
|
|
63
|
-
// A connection that closes while it waits
|
|
64
|
-
// for the lock gives it up unrun (rallly walk 2: pg-pool evicted an idle
|
|
65
|
-
// client — its Terminate queued behind another connection's transaction,
|
|
66
|
-
// ran once that ended, left no ReadyForQuery, and held the lock forever). All connections share the one
|
|
67
|
-
// backend session, so session state leaks between them and outlives the
|
|
68
|
-
// connection that set it: SET (search_path, timezone…), temp tables and
|
|
69
|
-
// SQL PREPAREd statements are visible to every other connection (a
|
|
70
|
-
// statement prepared over the protocol is the connection's own: a name
|
|
71
|
-
// another live connection holds is renamed for it, and its own SQL
|
|
72
|
-
// EXECUTE/DEALLOCATE of that name is renamed with it; scopeNames — except
|
|
73
|
-
// in CREATE TABLE … AS EXECUTE, a comment between the verb and the name,
|
|
74
|
-
// a Query holding a backslash, and a DEALLOCATE sent through Parse, which
|
|
75
|
-
// reach the name as the backend holds it); session
|
|
76
|
-
// advisory locks do NOT exclude (a second connection's
|
|
77
|
-
// pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
|
|
78
|
-
// to the connection that sent it, not to the one that ran LISTEN.
|
|
79
|
-
// CREATE ROLE succeeds but connections never run as that role.
|
|
43
|
+
// - ONE backend session, with concurrent wire connections and driver pools.
|
|
44
|
+
// A complete implicit statement or explicit transaction owns it until the
|
|
45
|
+
// backend reports idle; other connections' reads and writes wait their turn.
|
|
46
|
+
// No reader borrows another connection's open transaction. An idle holder
|
|
47
|
+
// is not terminated to admit a waiter. Cross-connection work awaited inside
|
|
48
|
+
// a transaction must wait for that transaction to finish.
|
|
49
|
+
// Session SET, temp tables and session advisory locks remain shared across
|
|
50
|
+
// transactions and outlive their connection; pg_try_advisory_lock on a key
|
|
51
|
+
// another connection holds sees the same session and returns true. NOTIFY
|
|
52
|
+
// answers its sender, not an independent listener, and pg_backend_pid is
|
|
53
|
+
// shared. Existing prepared-name scoping supports pooled Prisma clients;
|
|
54
|
+
// SQL spellings outside its handled forms remain shared. Select native or
|
|
55
|
+
// container PostgreSQL for independent sessions; no gateway emulation of
|
|
56
|
+
// locks, isolation or session state supplies those semantics.
|
|
80
57
|
//
|
|
81
58
|
// Database timestamps use the World clock: PGlite reads Date.now() for now(),
|
|
82
59
|
// CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
|
|
@@ -187,104 +164,34 @@ dbReady.catch((error) => {
|
|
|
187
164
|
});
|
|
188
165
|
|
|
189
166
|
// ---- transaction-affinity lock ---------------------------------------------
|
|
190
|
-
// How long the holder may sit idle inside its transaction while another client
|
|
191
|
-
// waits before it is terminated (see the limits above). Prisma's own default
|
|
192
|
-
// interactive-transaction timeout is 5s; a session idle that long with others
|
|
193
|
-
// queued is one no client is still driving.
|
|
194
|
-
const IDLE_IN_TRANSACTION_MS = 5000;
|
|
195
167
|
let holder = null;
|
|
196
168
|
const waiters = [];
|
|
197
|
-
/** Each connection's socket, whether a message of it is running, and since
|
|
198
|
-
* when it has been idle. */
|
|
199
169
|
const sessions = new Map();
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// awaits a repository read on another connection) otherwise waits for a lock
|
|
208
|
-
// the transaction never gives up. The read sees the holder's uncommitted rows —
|
|
209
|
-
// a deviation from Postgres's isolation, stated in the limits above. Writes,
|
|
210
|
-
// and a guest's own BEGIN, wait for the lock as before.
|
|
211
|
-
let guestBatch = null;
|
|
212
|
-
let guestStartedAt = 0;
|
|
213
|
-
// How long a guest batch may hold its turn while the holder or a writer waits (a cursor that never sends its Sync
|
|
214
|
-
// would otherwise hold every connection up): past it the guest's batch is refused and the waiters run.
|
|
215
|
-
const GUEST_TURN_MS = 2000;
|
|
216
|
-
function wakeAll() {
|
|
217
|
-
for (const wake of waiters.splice(0)) wake();
|
|
218
|
-
}
|
|
219
|
-
/** How `token` may run its next message: 'own' (it holds the lock, and its turn is claimed here, before any other
|
|
220
|
-
* waiter wakes to look) or 'guest'. A holder waiting for its turn is `wanting`: no new guest starts ahead of it. */
|
|
221
|
-
async function enter(token, session, data) {
|
|
222
|
-
for (;;) {
|
|
223
|
-
if (guestBatch === token) return 'guest';
|
|
224
|
-
if (guestBatch === null) {
|
|
225
|
-
if (holder === null || holder === token) {
|
|
226
|
-
holder = token;
|
|
227
|
-
session.busy = true;
|
|
228
|
-
session.wanting = false;
|
|
229
|
-
return 'own';
|
|
230
|
-
}
|
|
231
|
-
const host = sessions.get(holder);
|
|
232
|
-
if (host && !host.busy && !host.wanting && !host.midBatch && host.status === 'T'
|
|
233
|
-
&& session.status !== 'T' && session.status !== 'E' && guestMessageAllowed(session, data)) {
|
|
234
|
-
guestBatch = token;
|
|
235
|
-
guestStartedAt = machineNow();
|
|
236
|
-
return 'guest';
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
if (holder === token) session.wanting = true;
|
|
240
|
-
await new Promise((resolve) => waiters.push(resolve));
|
|
170
|
+
/** Reserve the next complete protocol cycle/transaction in arrival order. */
|
|
171
|
+
function enter(token, session) {
|
|
172
|
+
if (session.closed) return Promise.resolve(false);
|
|
173
|
+
if (holder === token) return Promise.resolve(true);
|
|
174
|
+
if (holder === null && !waiters.length) {
|
|
175
|
+
holder = token;
|
|
176
|
+
return Promise.resolve(true);
|
|
241
177
|
}
|
|
178
|
+
return new Promise(resolve => waiters.push({ token, session, resolve }));
|
|
242
179
|
}
|
|
243
180
|
function release(token) {
|
|
244
181
|
if (holder !== token) return;
|
|
245
182
|
holder = null;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// dollar quoting and backslash-escape strings (E'…', where \' does not close) are not read here: not read-only
|
|
253
|
-
// (a backslash anywhere, too: with standard_conforming_strings off, which one connection can set for the shared
|
|
254
|
-
// session, a plain '…' takes backslash escapes)
|
|
255
|
-
if (/\$[A-Za-z_0-9]*\$/.test(text) || text.includes('\\')) return null;
|
|
256
|
-
let out = '';
|
|
257
|
-
for (let i = 0; i < text.length;) {
|
|
258
|
-
const c = text[i];
|
|
259
|
-
if (c === "'" || c === '"') {
|
|
260
|
-
const end = text.indexOf(c, i + 1);
|
|
261
|
-
if (end < 0) return null;
|
|
262
|
-
let j = end;
|
|
263
|
-
while (text[j + 1] === c) { const next = text.indexOf(c, j + 2); if (next < 0) return null; j = next; }
|
|
264
|
-
out += ' ';
|
|
265
|
-
i = j + 1;
|
|
266
|
-
} else if (c === '-' && text[i + 1] === '-') {
|
|
267
|
-
const end = text.indexOf('\n', i);
|
|
268
|
-
i = end < 0 ? text.length : end;
|
|
269
|
-
out += ' ';
|
|
270
|
-
} else if (c === '/' && text[i + 1] === '*') {
|
|
271
|
-
const end = text.indexOf('*/', i + 2);
|
|
272
|
-
if (end < 0) return null;
|
|
273
|
-
i = end + 2;
|
|
274
|
-
out += ' ';
|
|
275
|
-
} else { out += c; i += 1; }
|
|
183
|
+
while (waiters.length) {
|
|
184
|
+
const next = waiters.shift();
|
|
185
|
+
if (next.session.closed) { next.resolve(false); continue; }
|
|
186
|
+
holder = next.token;
|
|
187
|
+
next.resolve(true);
|
|
188
|
+
break;
|
|
276
189
|
}
|
|
277
|
-
return out.trim();
|
|
278
190
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const sql = statementSkeleton(text);
|
|
284
|
-
if (sql === null) return false;
|
|
285
|
-
if (sql.replace(/;\s*$/, '').includes(';')) return false; // one statement only
|
|
286
|
-
if (!/^(select|with|show|values|table|explain)\b/i.test(sql)) return false;
|
|
287
|
-
return !/\b(insert|update|delete|merge|into|copy|analyze|call|do|set_config|nextval|setval|pg_(try_)?advisory\w*|pg_notify|lo_\w+|for\s+(update|share|no\s+key\s+update|key\s+share))\b/i.test(sql);
|
|
191
|
+
function cancelWait(token) {
|
|
192
|
+
const index = waiters.findIndex(waiter => waiter.token === token);
|
|
193
|
+
if (index < 0) return;
|
|
194
|
+
waiters.splice(index, 1)[0].resolve(false);
|
|
288
195
|
}
|
|
289
196
|
|
|
290
197
|
const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
|
|
@@ -504,126 +411,6 @@ async function flushClosed(db) {
|
|
|
504
411
|
}
|
|
505
412
|
}
|
|
506
413
|
|
|
507
|
-
/** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
|
|
508
|
-
* PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
|
|
509
|
-
function noteStatement(session, message) {
|
|
510
|
-
const data = asBuffer(message);
|
|
511
|
-
if (data[0] === 0x51 /* Q */ && /\b(prepare|deallocate|discard)\b/i.test(text(data, 5))) session.statements.clear();
|
|
512
|
-
else if (data[0] === 0x50 /* P */) {
|
|
513
|
-
const name = text(data, 5);
|
|
514
|
-
session.statements.set(name, readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)));
|
|
515
|
-
} else if (data[0] === 0x43 /* C */ && data[5] === 0x53 /* S */) {
|
|
516
|
-
session.statements.delete(text(data, 6));
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/** Whether a guest may run this message: a Query or Parse of read-only SQL, a Bind of a statement this connection
|
|
521
|
-
* prepared read-only (the unnamed one included), and the batch's Describe/Execute/Sync/Flush/Close. */
|
|
522
|
-
function guestMessageAllowed(session, message) {
|
|
523
|
-
const data = asBuffer(message);
|
|
524
|
-
switch (data[0]) {
|
|
525
|
-
case 0x51: return readOnlySql(text(data, 5)); // Q
|
|
526
|
-
case 0x50: { const name = text(data, 5); return readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)); } // P
|
|
527
|
-
case 0x42: { // B: portal name, then statement name; the unnamed one only as this batch parsed it
|
|
528
|
-
const portal = text(data, 5);
|
|
529
|
-
const statement = text(data, 5 + Buffer.byteLength(portal) + 1);
|
|
530
|
-
if (statement === '') return session.guestOpen && session.guestUnnamed === true;
|
|
531
|
-
return session.statements.get(statement) === true;
|
|
532
|
-
}
|
|
533
|
-
case 0x44: case 0x45: case 0x53: case 0x48: case 0x43: return session.guestOpen; // D E S H C, inside its batch
|
|
534
|
-
default: return false;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/** An ErrorResponse (and nothing else): the guest batch refused mid-way. */
|
|
539
|
-
function errorResponse(code, message) {
|
|
540
|
-
const fields = ['SERROR', 'VERROR', `C${code}`, `M${message}`];
|
|
541
|
-
const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
|
|
542
|
-
const head = Buffer.alloc(5);
|
|
543
|
-
head[0] = 0x45;
|
|
544
|
-
head.writeInt32BE(body.length + 4, 1);
|
|
545
|
-
return Buffer.concat([head, body]);
|
|
546
|
-
}
|
|
547
|
-
const READY_IDLE = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]);
|
|
548
|
-
|
|
549
|
-
/** A statement of the host's own around a guest; its failure (the holder's transaction already gone) is not fatal. */
|
|
550
|
-
async function quiet(db, sql) {
|
|
551
|
-
try { await db.query(sql); } catch (error) {
|
|
552
|
-
if (error?.code !== '3B001' && error?.code !== '25P01') throw error;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** `response` with its last ReadyForQuery reporting `status`. */
|
|
557
|
-
function withReadyStatus(response, status) {
|
|
558
|
-
const out = Buffer.from(response);
|
|
559
|
-
let offset = 0;
|
|
560
|
-
let at = -1;
|
|
561
|
-
while (offset + 5 <= out.length) {
|
|
562
|
-
const length = out.readInt32BE(offset + 1);
|
|
563
|
-
if (length < 4) break;
|
|
564
|
-
if (out[offset] === 0x5a && offset + 5 < out.length) at = offset + 5;
|
|
565
|
-
offset += 1 + length;
|
|
566
|
-
}
|
|
567
|
-
if (at >= 0) out[at] = status.charCodeAt(0);
|
|
568
|
-
return out;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/** Postgres's FATAL for a session past idle_in_transaction_session_timeout
|
|
572
|
-
* (postgres.c: ERRCODE_IDLE_IN_TRANSACTION_SESSION_TIMEOUT, 25P03). */
|
|
573
|
-
function idleInTransactionFatal() {
|
|
574
|
-
const fields = ['SFATAL', 'VFATAL', 'C25P03', 'Mterminating connection due to idle-in-transaction timeout'];
|
|
575
|
-
const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
|
|
576
|
-
const head = Buffer.alloc(5);
|
|
577
|
-
head[0] = 0x45; // 'E'
|
|
578
|
-
head.writeInt32BE(body.length + 4, 1);
|
|
579
|
-
return Buffer.concat([head, body]);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// the holder idle in its transaction past the limit while others wait: end it
|
|
583
|
-
const idleWatch = setInterval(() => {
|
|
584
|
-
// a guest holding its turn past GUEST_TURN_MS while others wait: its batch is refused, its savepoint undone
|
|
585
|
-
if (guestBatch !== null && waiters.length > 0 && machineNow() - guestStartedAt > GUEST_TURN_MS) {
|
|
586
|
-
const guest = sessions.get(guestBatch);
|
|
587
|
-
const expired = guestBatch;
|
|
588
|
-
if (guest && guest.guestRefused && !guest.guestRunning) {
|
|
589
|
-
// a refused batch that never sends its Sync: its savepoint is already undone; its turn ends
|
|
590
|
-
guest.guestRefused = false;
|
|
591
|
-
guest.guestExpired = true;
|
|
592
|
-
guest.guestErrorSent = true;
|
|
593
|
-
const host = sessions.get(holder);
|
|
594
|
-
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
595
|
-
guestBatch = null;
|
|
596
|
-
wakeAll();
|
|
597
|
-
} else if (guest && guest.guestOpen && !guest.guestRunning) {
|
|
598
|
-
guest.guestOpen = false;
|
|
599
|
-
guest.guestUnnamed = false;
|
|
600
|
-
guest.guestExpired = true;
|
|
601
|
-
void dbReady.then(async (db) => {
|
|
602
|
-
await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
|
|
603
|
-
await quiet(db, 'RELEASE SAVEPOINT volter_guest');
|
|
604
|
-
}).then(() => {
|
|
605
|
-
if (guestBatch !== expired) return;
|
|
606
|
-
const host = sessions.get(holder);
|
|
607
|
-
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
608
|
-
guestBatch = null;
|
|
609
|
-
wakeAll();
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
if (holder === null || waiters.length === 0) return;
|
|
614
|
-
const session = sessions.get(holder);
|
|
615
|
-
// armed at ReadyForQuery inside a transaction, as Postgres arms it — never mid-pipeline
|
|
616
|
-
// a holder waiting for its turn (behind a guest) is sending, not idle; a guest's turn is not the holder's idleness
|
|
617
|
-
if (!session || session.busy || session.wanting || guestBatch !== null || session.terminated || (session.status !== 'T' && session.status !== 'E')) return;
|
|
618
|
-
if (machineNow() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
|
|
619
|
-
session.terminated = true;
|
|
620
|
-
process.stderr.write('pglite-host: terminated a connection idle in its transaction while others waited\n');
|
|
621
|
-
// the socket's close handler rolls the transaction back and releases the lock
|
|
622
|
-
session.socket.end(idleInTransactionFatal());
|
|
623
|
-
setTimeout(() => session.socket.destroy(), 500).unref();
|
|
624
|
-
}, 250);
|
|
625
|
-
idleWatch.unref();
|
|
626
|
-
|
|
627
414
|
/** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
|
|
628
415
|
* protocol response, or null when the response carries none (mid-pipeline:
|
|
629
416
|
* the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
|
|
@@ -766,37 +553,31 @@ function withoutEarlyReady(message, response) {
|
|
|
766
553
|
return response;
|
|
767
554
|
}
|
|
768
555
|
|
|
769
|
-
const server = net.createServer(
|
|
556
|
+
const server = net.createServer((socket) => {
|
|
770
557
|
const token = {};
|
|
771
|
-
|
|
772
|
-
const session = { socket, busy: false, idleSince: machineNow(), terminated: false, closed: false, status: null, midBatch: false,
|
|
773
|
-
wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
|
|
774
|
-
names: new Map() };
|
|
558
|
+
const session = { closed: false, status: null, midBatch: false, names: new Map(), work: Promise.resolve() };
|
|
775
559
|
sessions.set(token, session);
|
|
776
560
|
socket.on('close', () => {
|
|
777
561
|
session.closed = true;
|
|
778
562
|
sessions.delete(token);
|
|
779
563
|
releaseStatements(session);
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
.
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
holding = false;
|
|
795
|
-
release(token);
|
|
796
|
-
});
|
|
564
|
+
cancelWait(token);
|
|
565
|
+
if (holder !== token) return;
|
|
566
|
+
// Keep ownership while any in-flight message finishes. Sync clears a
|
|
567
|
+
// disconnected extended-protocol error state, then the real backend rolls
|
|
568
|
+
// back the holder before another connection can enter its transaction.
|
|
569
|
+
void session.work.then(async () => {
|
|
570
|
+
const db = await dbReady;
|
|
571
|
+
await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4]));
|
|
572
|
+
await db.query('ROLLBACK');
|
|
573
|
+
release(token);
|
|
574
|
+
}).catch(error => {
|
|
575
|
+
process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`);
|
|
576
|
+
process.exit(1);
|
|
577
|
+
});
|
|
797
578
|
});
|
|
798
579
|
socket.on('error', () => { /* close handler owns cleanup */ });
|
|
799
|
-
|
|
580
|
+
void fromNodeSocket(socket, {
|
|
800
581
|
serverVersion: '18.3 (PGlite)',
|
|
801
582
|
auth: { method: 'password', getClearTextPassword: () => databasePassword,
|
|
802
583
|
validateCredentials: ({ username, password }) => username === databaseUser && password === databasePassword },
|
|
@@ -806,113 +587,42 @@ const server = net.createServer(async (socket) => {
|
|
|
806
587
|
},
|
|
807
588
|
async onMessage(data, { isAuthenticated, clientParams }) {
|
|
808
589
|
if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const db = await dbReady;
|
|
815
|
-
readWorldClock();
|
|
816
|
-
// a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
|
|
817
|
-
if (session.guestExpired) {
|
|
818
|
-
if (data[0] === 0x53) { session.guestExpired = false; session.guestErrorSent = false; session.status = 'I'; return READY_IDLE; }
|
|
819
|
-
if (session.guestErrorSent) return new Uint8Array(0);
|
|
820
|
-
session.guestErrorSent = true;
|
|
821
|
-
return errorResponse('57014', 'the containerless Postgres (one session) cancelled this read: it ran beside another connection\'s open transaction longer than its turn allows');
|
|
822
|
-
}
|
|
823
|
-
const role = await enter(token, session, data);
|
|
824
|
-
if (role === 'guest') {
|
|
590
|
+
// Gateway owns Terminate; it must neither wait for a turn nor terminate
|
|
591
|
+
// the shared PostgreSQL backend. The close handler owns rollback.
|
|
592
|
+
if (data[0] === 0x58) return undefined;
|
|
593
|
+
if (!await enter(token, session)) return undefined;
|
|
594
|
+
const work = (async () => {
|
|
825
595
|
if (session.closed) return undefined;
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
guestBatch = null;
|
|
833
|
-
session.guestUnnamed = false;
|
|
834
|
-
// the holder's idle clock stops while a guest runs, and goes on from where it was
|
|
835
|
-
const host = sessions.get(holder);
|
|
836
|
-
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
837
|
-
wakeAll();
|
|
838
|
-
};
|
|
839
|
-
// a batch refused mid-way: its remaining messages are dropped until its Sync, as Postgres skips after an error
|
|
840
|
-
if (session.guestRefused) {
|
|
841
|
-
if (sync) { endGuest(); return READY_IDLE; }
|
|
842
|
-
return new Uint8Array(0);
|
|
843
|
-
}
|
|
844
|
-
if (session.guestOpen && !guestMessageAllowed(session, data)) {
|
|
845
|
-
session.guestRunning = true; // the expiry waits until this refusal is done
|
|
846
|
-
await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
|
|
847
|
-
await quiet(db, 'RELEASE SAVEPOINT volter_guest');
|
|
848
|
-
session.guestOpen = false;
|
|
849
|
-
session.guestRefused = true;
|
|
850
|
-
session.guestRunning = false;
|
|
851
|
-
return errorResponse('0A000', 'the containerless Postgres (one session) runs only reads beside another connection\'s open transaction; this batch began with a read and went on to write');
|
|
852
|
-
}
|
|
853
|
-
if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
|
|
854
|
-
data = scopeNames(session, client, true);
|
|
855
|
-
noteStatement(session, data);
|
|
856
|
-
if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
|
|
596
|
+
const db = await dbReady;
|
|
597
|
+
readWorldClock();
|
|
598
|
+
if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(db);
|
|
599
|
+
if (session.closed) return undefined;
|
|
600
|
+
const closes = closing(data);
|
|
601
|
+
data = scopeNames(session, data, true);
|
|
857
602
|
let raw;
|
|
858
|
-
session.guestRunning = true;
|
|
859
603
|
try {
|
|
860
|
-
raw = await db.execProtocolRaw(refuseStatements(data,
|
|
604
|
+
raw = await db.execProtocolRaw(refuseStatements(data, clientParams?.database || clientParams?.user));
|
|
861
605
|
} catch (error) {
|
|
606
|
+
// SQL errors arrive as protocol bytes. A throw is a backend failure.
|
|
862
607
|
process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
|
|
863
608
|
process.exit(1);
|
|
864
609
|
}
|
|
865
610
|
const response = withoutEarlyReady(data, raw);
|
|
866
611
|
settleClose(session, closes, response);
|
|
867
612
|
const status = lastReadyStatus(response);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
// message — a Terminate run now would leave no ReadyForQuery to release it
|
|
878
|
-
if (session.closed || session.terminated) {
|
|
879
|
-
session.busy = false;
|
|
880
|
-
if (!holding) release(token);
|
|
881
|
-
return undefined;
|
|
882
|
-
}
|
|
883
|
-
holding = true;
|
|
884
|
-
session.busy = true;
|
|
885
|
-
// a turn that begins outside any transaction first closes what closed connections left
|
|
886
|
-
if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(await dbReady);
|
|
887
|
-
data = scopeNames(session, client, true);
|
|
888
|
-
noteStatement(session, data);
|
|
889
|
-
// libpq's default: no database parameter means the user's name
|
|
890
|
-
const own = clientParams?.database || clientParams?.user;
|
|
891
|
-
let raw;
|
|
892
|
-
try {
|
|
893
|
-
raw = await db.execProtocolRaw(refuseStatements(data, own));
|
|
894
|
-
} catch (error) {
|
|
895
|
-
// SQL errors arrive as ErrorResponse bytes; a throw means the WASM
|
|
896
|
-
// backend itself aborted and every later call would hang. Exit so the
|
|
897
|
-
// World sees the service down (status fails) instead of a wedged port.
|
|
898
|
-
process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
|
|
899
|
-
process.exit(1);
|
|
900
|
-
}
|
|
901
|
-
session.busy = false;
|
|
902
|
-
session.idleSince = machineNow();
|
|
903
|
-
const response = withoutEarlyReady(data, raw);
|
|
904
|
-
settleClose(session, closes, response);
|
|
905
|
-
const status = lastReadyStatus(response);
|
|
906
|
-
session.midBatch = status === null;
|
|
907
|
-
if (status !== null) session.status = status;
|
|
908
|
-
if (status === 'I') {
|
|
909
|
-
holding = false;
|
|
910
|
-
release(token);
|
|
911
|
-
} else if (status === 'T') {
|
|
912
|
-
wakeAll(); // idle in its transaction: a waiting reader may now run as a guest
|
|
913
|
-
}
|
|
914
|
-
return response;
|
|
613
|
+
session.midBatch = status === null;
|
|
614
|
+
if (status !== null) session.status = status;
|
|
615
|
+
// A closed holder retains admission until its close handler confirms
|
|
616
|
+
// rollback, even if its in-flight message reported idle.
|
|
617
|
+
if (status === 'I' && !session.closed) release(token);
|
|
618
|
+
return response;
|
|
619
|
+
})();
|
|
620
|
+
session.work = work;
|
|
621
|
+
return work;
|
|
915
622
|
},
|
|
623
|
+
}).catch(error => {
|
|
624
|
+
process.stderr.write(`pglite-host: wire connection failed: ${errorMessage(error)}\n`);
|
|
625
|
+
socket.destroy();
|
|
916
626
|
});
|
|
917
627
|
});
|
|
918
628
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.52",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -69,14 +69,14 @@
|
|
|
69
69
|
"node": ">=22.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@volter/world-core": "3.0.
|
|
73
|
-
"@volter/world-console": "3.0.
|
|
72
|
+
"@volter/world-core": "3.0.52",
|
|
73
|
+
"@volter/world-console": "3.0.47"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@electric-sql/pglite": "0.5.8",
|
|
77
77
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
78
78
|
"@volter/world-access": "3.0.1",
|
|
79
|
-
"@volter/world-core": "3.0.
|
|
79
|
+
"@volter/world-core": "3.0.52",
|
|
80
80
|
"mongodb": "~6.20.0",
|
|
81
81
|
"pg-gateway": "0.3.0-beta.4",
|
|
82
82
|
"smol-toml": "^1.8.0",
|
package/src/pglite-host.mjs
CHANGED
|
@@ -40,43 +40,20 @@
|
|
|
40
40
|
// multiple databases require the native or container backing.
|
|
41
41
|
// - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
|
|
42
42
|
// COPY … TO STDOUT works.
|
|
43
|
-
// - ONE
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// and
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// the holder's uncommitted rows — Postgres would show it only committed ones.
|
|
58
|
-
// While a guest's batch runs, the holder waits for it (its idle clock
|
|
59
|
-
// stopped); a guest batch still open after GUEST_TURN_MS while anyone waits
|
|
60
|
-
// (a cursor that has not sent its Sync) is cancelled (57014), its savepoint
|
|
61
|
-
// undone;
|
|
62
|
-
// a batch that begins with a read and goes on to write is refused (0A000).
|
|
63
|
-
// A connection that closes while it waits
|
|
64
|
-
// for the lock gives it up unrun (rallly walk 2: pg-pool evicted an idle
|
|
65
|
-
// client — its Terminate queued behind another connection's transaction,
|
|
66
|
-
// ran once that ended, left no ReadyForQuery, and held the lock forever). All connections share the one
|
|
67
|
-
// backend session, so session state leaks between them and outlives the
|
|
68
|
-
// connection that set it: SET (search_path, timezone…), temp tables and
|
|
69
|
-
// SQL PREPAREd statements are visible to every other connection (a
|
|
70
|
-
// statement prepared over the protocol is the connection's own: a name
|
|
71
|
-
// another live connection holds is renamed for it, and its own SQL
|
|
72
|
-
// EXECUTE/DEALLOCATE of that name is renamed with it; scopeNames — except
|
|
73
|
-
// in CREATE TABLE … AS EXECUTE, a comment between the verb and the name,
|
|
74
|
-
// a Query holding a backslash, and a DEALLOCATE sent through Parse, which
|
|
75
|
-
// reach the name as the backend holds it); session
|
|
76
|
-
// advisory locks do NOT exclude (a second connection's
|
|
77
|
-
// pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
|
|
78
|
-
// to the connection that sent it, not to the one that ran LISTEN.
|
|
79
|
-
// CREATE ROLE succeeds but connections never run as that role.
|
|
43
|
+
// - ONE backend session, with concurrent wire connections and driver pools.
|
|
44
|
+
// A complete implicit statement or explicit transaction owns it until the
|
|
45
|
+
// backend reports idle; other connections' reads and writes wait their turn.
|
|
46
|
+
// No reader borrows another connection's open transaction. An idle holder
|
|
47
|
+
// is not terminated to admit a waiter. Cross-connection work awaited inside
|
|
48
|
+
// a transaction must wait for that transaction to finish.
|
|
49
|
+
// Session SET, temp tables and session advisory locks remain shared across
|
|
50
|
+
// transactions and outlive their connection; pg_try_advisory_lock on a key
|
|
51
|
+
// another connection holds sees the same session and returns true. NOTIFY
|
|
52
|
+
// answers its sender, not an independent listener, and pg_backend_pid is
|
|
53
|
+
// shared. Existing prepared-name scoping supports pooled Prisma clients;
|
|
54
|
+
// SQL spellings outside its handled forms remain shared. Select native or
|
|
55
|
+
// container PostgreSQL for independent sessions; no gateway emulation of
|
|
56
|
+
// locks, isolation or session state supplies those semantics.
|
|
80
57
|
//
|
|
81
58
|
// Database timestamps use the World clock: PGlite reads Date.now() for now(),
|
|
82
59
|
// CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
|
|
@@ -187,104 +164,34 @@ dbReady.catch((error) => {
|
|
|
187
164
|
});
|
|
188
165
|
|
|
189
166
|
// ---- transaction-affinity lock ---------------------------------------------
|
|
190
|
-
// How long the holder may sit idle inside its transaction while another client
|
|
191
|
-
// waits before it is terminated (see the limits above). Prisma's own default
|
|
192
|
-
// interactive-transaction timeout is 5s; a session idle that long with others
|
|
193
|
-
// queued is one no client is still driving.
|
|
194
|
-
const IDLE_IN_TRANSACTION_MS = 5000;
|
|
195
167
|
let holder = null;
|
|
196
168
|
const waiters = [];
|
|
197
|
-
/** Each connection's socket, whether a message of it is running, and since
|
|
198
|
-
* when it has been idle. */
|
|
199
169
|
const sessions = new Map();
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// awaits a repository read on another connection) otherwise waits for a lock
|
|
208
|
-
// the transaction never gives up. The read sees the holder's uncommitted rows —
|
|
209
|
-
// a deviation from Postgres's isolation, stated in the limits above. Writes,
|
|
210
|
-
// and a guest's own BEGIN, wait for the lock as before.
|
|
211
|
-
let guestBatch = null;
|
|
212
|
-
let guestStartedAt = 0;
|
|
213
|
-
// How long a guest batch may hold its turn while the holder or a writer waits (a cursor that never sends its Sync
|
|
214
|
-
// would otherwise hold every connection up): past it the guest's batch is refused and the waiters run.
|
|
215
|
-
const GUEST_TURN_MS = 2000;
|
|
216
|
-
function wakeAll() {
|
|
217
|
-
for (const wake of waiters.splice(0)) wake();
|
|
218
|
-
}
|
|
219
|
-
/** How `token` may run its next message: 'own' (it holds the lock, and its turn is claimed here, before any other
|
|
220
|
-
* waiter wakes to look) or 'guest'. A holder waiting for its turn is `wanting`: no new guest starts ahead of it. */
|
|
221
|
-
async function enter(token, session, data) {
|
|
222
|
-
for (;;) {
|
|
223
|
-
if (guestBatch === token) return 'guest';
|
|
224
|
-
if (guestBatch === null) {
|
|
225
|
-
if (holder === null || holder === token) {
|
|
226
|
-
holder = token;
|
|
227
|
-
session.busy = true;
|
|
228
|
-
session.wanting = false;
|
|
229
|
-
return 'own';
|
|
230
|
-
}
|
|
231
|
-
const host = sessions.get(holder);
|
|
232
|
-
if (host && !host.busy && !host.wanting && !host.midBatch && host.status === 'T'
|
|
233
|
-
&& session.status !== 'T' && session.status !== 'E' && guestMessageAllowed(session, data)) {
|
|
234
|
-
guestBatch = token;
|
|
235
|
-
guestStartedAt = machineNow();
|
|
236
|
-
return 'guest';
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
if (holder === token) session.wanting = true;
|
|
240
|
-
await new Promise((resolve) => waiters.push(resolve));
|
|
170
|
+
/** Reserve the next complete protocol cycle/transaction in arrival order. */
|
|
171
|
+
function enter(token, session) {
|
|
172
|
+
if (session.closed) return Promise.resolve(false);
|
|
173
|
+
if (holder === token) return Promise.resolve(true);
|
|
174
|
+
if (holder === null && !waiters.length) {
|
|
175
|
+
holder = token;
|
|
176
|
+
return Promise.resolve(true);
|
|
241
177
|
}
|
|
178
|
+
return new Promise(resolve => waiters.push({ token, session, resolve }));
|
|
242
179
|
}
|
|
243
180
|
function release(token) {
|
|
244
181
|
if (holder !== token) return;
|
|
245
182
|
holder = null;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// dollar quoting and backslash-escape strings (E'…', where \' does not close) are not read here: not read-only
|
|
253
|
-
// (a backslash anywhere, too: with standard_conforming_strings off, which one connection can set for the shared
|
|
254
|
-
// session, a plain '…' takes backslash escapes)
|
|
255
|
-
if (/\$[A-Za-z_0-9]*\$/.test(text) || text.includes('\\')) return null;
|
|
256
|
-
let out = '';
|
|
257
|
-
for (let i = 0; i < text.length;) {
|
|
258
|
-
const c = text[i];
|
|
259
|
-
if (c === "'" || c === '"') {
|
|
260
|
-
const end = text.indexOf(c, i + 1);
|
|
261
|
-
if (end < 0) return null;
|
|
262
|
-
let j = end;
|
|
263
|
-
while (text[j + 1] === c) { const next = text.indexOf(c, j + 2); if (next < 0) return null; j = next; }
|
|
264
|
-
out += ' ';
|
|
265
|
-
i = j + 1;
|
|
266
|
-
} else if (c === '-' && text[i + 1] === '-') {
|
|
267
|
-
const end = text.indexOf('\n', i);
|
|
268
|
-
i = end < 0 ? text.length : end;
|
|
269
|
-
out += ' ';
|
|
270
|
-
} else if (c === '/' && text[i + 1] === '*') {
|
|
271
|
-
const end = text.indexOf('*/', i + 2);
|
|
272
|
-
if (end < 0) return null;
|
|
273
|
-
i = end + 2;
|
|
274
|
-
out += ' ';
|
|
275
|
-
} else { out += c; i += 1; }
|
|
183
|
+
while (waiters.length) {
|
|
184
|
+
const next = waiters.shift();
|
|
185
|
+
if (next.session.closed) { next.resolve(false); continue; }
|
|
186
|
+
holder = next.token;
|
|
187
|
+
next.resolve(true);
|
|
188
|
+
break;
|
|
276
189
|
}
|
|
277
|
-
return out.trim();
|
|
278
190
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const sql = statementSkeleton(text);
|
|
284
|
-
if (sql === null) return false;
|
|
285
|
-
if (sql.replace(/;\s*$/, '').includes(';')) return false; // one statement only
|
|
286
|
-
if (!/^(select|with|show|values|table|explain)\b/i.test(sql)) return false;
|
|
287
|
-
return !/\b(insert|update|delete|merge|into|copy|analyze|call|do|set_config|nextval|setval|pg_(try_)?advisory\w*|pg_notify|lo_\w+|for\s+(update|share|no\s+key\s+update|key\s+share))\b/i.test(sql);
|
|
191
|
+
function cancelWait(token) {
|
|
192
|
+
const index = waiters.findIndex(waiter => waiter.token === token);
|
|
193
|
+
if (index < 0) return;
|
|
194
|
+
waiters.splice(index, 1)[0].resolve(false);
|
|
288
195
|
}
|
|
289
196
|
|
|
290
197
|
const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
|
|
@@ -504,126 +411,6 @@ async function flushClosed(db) {
|
|
|
504
411
|
}
|
|
505
412
|
}
|
|
506
413
|
|
|
507
|
-
/** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
|
|
508
|
-
* PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
|
|
509
|
-
function noteStatement(session, message) {
|
|
510
|
-
const data = asBuffer(message);
|
|
511
|
-
if (data[0] === 0x51 /* Q */ && /\b(prepare|deallocate|discard)\b/i.test(text(data, 5))) session.statements.clear();
|
|
512
|
-
else if (data[0] === 0x50 /* P */) {
|
|
513
|
-
const name = text(data, 5);
|
|
514
|
-
session.statements.set(name, readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)));
|
|
515
|
-
} else if (data[0] === 0x43 /* C */ && data[5] === 0x53 /* S */) {
|
|
516
|
-
session.statements.delete(text(data, 6));
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/** Whether a guest may run this message: a Query or Parse of read-only SQL, a Bind of a statement this connection
|
|
521
|
-
* prepared read-only (the unnamed one included), and the batch's Describe/Execute/Sync/Flush/Close. */
|
|
522
|
-
function guestMessageAllowed(session, message) {
|
|
523
|
-
const data = asBuffer(message);
|
|
524
|
-
switch (data[0]) {
|
|
525
|
-
case 0x51: return readOnlySql(text(data, 5)); // Q
|
|
526
|
-
case 0x50: { const name = text(data, 5); return readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)); } // P
|
|
527
|
-
case 0x42: { // B: portal name, then statement name; the unnamed one only as this batch parsed it
|
|
528
|
-
const portal = text(data, 5);
|
|
529
|
-
const statement = text(data, 5 + Buffer.byteLength(portal) + 1);
|
|
530
|
-
if (statement === '') return session.guestOpen && session.guestUnnamed === true;
|
|
531
|
-
return session.statements.get(statement) === true;
|
|
532
|
-
}
|
|
533
|
-
case 0x44: case 0x45: case 0x53: case 0x48: case 0x43: return session.guestOpen; // D E S H C, inside its batch
|
|
534
|
-
default: return false;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/** An ErrorResponse (and nothing else): the guest batch refused mid-way. */
|
|
539
|
-
function errorResponse(code, message) {
|
|
540
|
-
const fields = ['SERROR', 'VERROR', `C${code}`, `M${message}`];
|
|
541
|
-
const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
|
|
542
|
-
const head = Buffer.alloc(5);
|
|
543
|
-
head[0] = 0x45;
|
|
544
|
-
head.writeInt32BE(body.length + 4, 1);
|
|
545
|
-
return Buffer.concat([head, body]);
|
|
546
|
-
}
|
|
547
|
-
const READY_IDLE = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]);
|
|
548
|
-
|
|
549
|
-
/** A statement of the host's own around a guest; its failure (the holder's transaction already gone) is not fatal. */
|
|
550
|
-
async function quiet(db, sql) {
|
|
551
|
-
try { await db.query(sql); } catch (error) {
|
|
552
|
-
if (error?.code !== '3B001' && error?.code !== '25P01') throw error;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** `response` with its last ReadyForQuery reporting `status`. */
|
|
557
|
-
function withReadyStatus(response, status) {
|
|
558
|
-
const out = Buffer.from(response);
|
|
559
|
-
let offset = 0;
|
|
560
|
-
let at = -1;
|
|
561
|
-
while (offset + 5 <= out.length) {
|
|
562
|
-
const length = out.readInt32BE(offset + 1);
|
|
563
|
-
if (length < 4) break;
|
|
564
|
-
if (out[offset] === 0x5a && offset + 5 < out.length) at = offset + 5;
|
|
565
|
-
offset += 1 + length;
|
|
566
|
-
}
|
|
567
|
-
if (at >= 0) out[at] = status.charCodeAt(0);
|
|
568
|
-
return out;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/** Postgres's FATAL for a session past idle_in_transaction_session_timeout
|
|
572
|
-
* (postgres.c: ERRCODE_IDLE_IN_TRANSACTION_SESSION_TIMEOUT, 25P03). */
|
|
573
|
-
function idleInTransactionFatal() {
|
|
574
|
-
const fields = ['SFATAL', 'VFATAL', 'C25P03', 'Mterminating connection due to idle-in-transaction timeout'];
|
|
575
|
-
const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
|
|
576
|
-
const head = Buffer.alloc(5);
|
|
577
|
-
head[0] = 0x45; // 'E'
|
|
578
|
-
head.writeInt32BE(body.length + 4, 1);
|
|
579
|
-
return Buffer.concat([head, body]);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// the holder idle in its transaction past the limit while others wait: end it
|
|
583
|
-
const idleWatch = setInterval(() => {
|
|
584
|
-
// a guest holding its turn past GUEST_TURN_MS while others wait: its batch is refused, its savepoint undone
|
|
585
|
-
if (guestBatch !== null && waiters.length > 0 && machineNow() - guestStartedAt > GUEST_TURN_MS) {
|
|
586
|
-
const guest = sessions.get(guestBatch);
|
|
587
|
-
const expired = guestBatch;
|
|
588
|
-
if (guest && guest.guestRefused && !guest.guestRunning) {
|
|
589
|
-
// a refused batch that never sends its Sync: its savepoint is already undone; its turn ends
|
|
590
|
-
guest.guestRefused = false;
|
|
591
|
-
guest.guestExpired = true;
|
|
592
|
-
guest.guestErrorSent = true;
|
|
593
|
-
const host = sessions.get(holder);
|
|
594
|
-
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
595
|
-
guestBatch = null;
|
|
596
|
-
wakeAll();
|
|
597
|
-
} else if (guest && guest.guestOpen && !guest.guestRunning) {
|
|
598
|
-
guest.guestOpen = false;
|
|
599
|
-
guest.guestUnnamed = false;
|
|
600
|
-
guest.guestExpired = true;
|
|
601
|
-
void dbReady.then(async (db) => {
|
|
602
|
-
await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
|
|
603
|
-
await quiet(db, 'RELEASE SAVEPOINT volter_guest');
|
|
604
|
-
}).then(() => {
|
|
605
|
-
if (guestBatch !== expired) return;
|
|
606
|
-
const host = sessions.get(holder);
|
|
607
|
-
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
608
|
-
guestBatch = null;
|
|
609
|
-
wakeAll();
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
if (holder === null || waiters.length === 0) return;
|
|
614
|
-
const session = sessions.get(holder);
|
|
615
|
-
// armed at ReadyForQuery inside a transaction, as Postgres arms it — never mid-pipeline
|
|
616
|
-
// a holder waiting for its turn (behind a guest) is sending, not idle; a guest's turn is not the holder's idleness
|
|
617
|
-
if (!session || session.busy || session.wanting || guestBatch !== null || session.terminated || (session.status !== 'T' && session.status !== 'E')) return;
|
|
618
|
-
if (machineNow() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
|
|
619
|
-
session.terminated = true;
|
|
620
|
-
process.stderr.write('pglite-host: terminated a connection idle in its transaction while others waited\n');
|
|
621
|
-
// the socket's close handler rolls the transaction back and releases the lock
|
|
622
|
-
session.socket.end(idleInTransactionFatal());
|
|
623
|
-
setTimeout(() => session.socket.destroy(), 500).unref();
|
|
624
|
-
}, 250);
|
|
625
|
-
idleWatch.unref();
|
|
626
|
-
|
|
627
414
|
/** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
|
|
628
415
|
* protocol response, or null when the response carries none (mid-pipeline:
|
|
629
416
|
* the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
|
|
@@ -766,37 +553,31 @@ function withoutEarlyReady(message, response) {
|
|
|
766
553
|
return response;
|
|
767
554
|
}
|
|
768
555
|
|
|
769
|
-
const server = net.createServer(
|
|
556
|
+
const server = net.createServer((socket) => {
|
|
770
557
|
const token = {};
|
|
771
|
-
|
|
772
|
-
const session = { socket, busy: false, idleSince: machineNow(), terminated: false, closed: false, status: null, midBatch: false,
|
|
773
|
-
wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
|
|
774
|
-
names: new Map() };
|
|
558
|
+
const session = { closed: false, status: null, midBatch: false, names: new Map(), work: Promise.resolve() };
|
|
775
559
|
sessions.set(token, session);
|
|
776
560
|
socket.on('close', () => {
|
|
777
561
|
session.closed = true;
|
|
778
562
|
sessions.delete(token);
|
|
779
563
|
releaseStatements(session);
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
.
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
holding = false;
|
|
795
|
-
release(token);
|
|
796
|
-
});
|
|
564
|
+
cancelWait(token);
|
|
565
|
+
if (holder !== token) return;
|
|
566
|
+
// Keep ownership while any in-flight message finishes. Sync clears a
|
|
567
|
+
// disconnected extended-protocol error state, then the real backend rolls
|
|
568
|
+
// back the holder before another connection can enter its transaction.
|
|
569
|
+
void session.work.then(async () => {
|
|
570
|
+
const db = await dbReady;
|
|
571
|
+
await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4]));
|
|
572
|
+
await db.query('ROLLBACK');
|
|
573
|
+
release(token);
|
|
574
|
+
}).catch(error => {
|
|
575
|
+
process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`);
|
|
576
|
+
process.exit(1);
|
|
577
|
+
});
|
|
797
578
|
});
|
|
798
579
|
socket.on('error', () => { /* close handler owns cleanup */ });
|
|
799
|
-
|
|
580
|
+
void fromNodeSocket(socket, {
|
|
800
581
|
serverVersion: '18.3 (PGlite)',
|
|
801
582
|
auth: { method: 'password', getClearTextPassword: () => databasePassword,
|
|
802
583
|
validateCredentials: ({ username, password }) => username === databaseUser && password === databasePassword },
|
|
@@ -806,113 +587,42 @@ const server = net.createServer(async (socket) => {
|
|
|
806
587
|
},
|
|
807
588
|
async onMessage(data, { isAuthenticated, clientParams }) {
|
|
808
589
|
if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const db = await dbReady;
|
|
815
|
-
readWorldClock();
|
|
816
|
-
// a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
|
|
817
|
-
if (session.guestExpired) {
|
|
818
|
-
if (data[0] === 0x53) { session.guestExpired = false; session.guestErrorSent = false; session.status = 'I'; return READY_IDLE; }
|
|
819
|
-
if (session.guestErrorSent) return new Uint8Array(0);
|
|
820
|
-
session.guestErrorSent = true;
|
|
821
|
-
return errorResponse('57014', 'the containerless Postgres (one session) cancelled this read: it ran beside another connection\'s open transaction longer than its turn allows');
|
|
822
|
-
}
|
|
823
|
-
const role = await enter(token, session, data);
|
|
824
|
-
if (role === 'guest') {
|
|
590
|
+
// Gateway owns Terminate; it must neither wait for a turn nor terminate
|
|
591
|
+
// the shared PostgreSQL backend. The close handler owns rollback.
|
|
592
|
+
if (data[0] === 0x58) return undefined;
|
|
593
|
+
if (!await enter(token, session)) return undefined;
|
|
594
|
+
const work = (async () => {
|
|
825
595
|
if (session.closed) return undefined;
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
guestBatch = null;
|
|
833
|
-
session.guestUnnamed = false;
|
|
834
|
-
// the holder's idle clock stops while a guest runs, and goes on from where it was
|
|
835
|
-
const host = sessions.get(holder);
|
|
836
|
-
if (host) host.idleSince += machineNow() - guestStartedAt;
|
|
837
|
-
wakeAll();
|
|
838
|
-
};
|
|
839
|
-
// a batch refused mid-way: its remaining messages are dropped until its Sync, as Postgres skips after an error
|
|
840
|
-
if (session.guestRefused) {
|
|
841
|
-
if (sync) { endGuest(); return READY_IDLE; }
|
|
842
|
-
return new Uint8Array(0);
|
|
843
|
-
}
|
|
844
|
-
if (session.guestOpen && !guestMessageAllowed(session, data)) {
|
|
845
|
-
session.guestRunning = true; // the expiry waits until this refusal is done
|
|
846
|
-
await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
|
|
847
|
-
await quiet(db, 'RELEASE SAVEPOINT volter_guest');
|
|
848
|
-
session.guestOpen = false;
|
|
849
|
-
session.guestRefused = true;
|
|
850
|
-
session.guestRunning = false;
|
|
851
|
-
return errorResponse('0A000', 'the containerless Postgres (one session) runs only reads beside another connection\'s open transaction; this batch began with a read and went on to write');
|
|
852
|
-
}
|
|
853
|
-
if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
|
|
854
|
-
data = scopeNames(session, client, true);
|
|
855
|
-
noteStatement(session, data);
|
|
856
|
-
if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
|
|
596
|
+
const db = await dbReady;
|
|
597
|
+
readWorldClock();
|
|
598
|
+
if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(db);
|
|
599
|
+
if (session.closed) return undefined;
|
|
600
|
+
const closes = closing(data);
|
|
601
|
+
data = scopeNames(session, data, true);
|
|
857
602
|
let raw;
|
|
858
|
-
session.guestRunning = true;
|
|
859
603
|
try {
|
|
860
|
-
raw = await db.execProtocolRaw(refuseStatements(data,
|
|
604
|
+
raw = await db.execProtocolRaw(refuseStatements(data, clientParams?.database || clientParams?.user));
|
|
861
605
|
} catch (error) {
|
|
606
|
+
// SQL errors arrive as protocol bytes. A throw is a backend failure.
|
|
862
607
|
process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
|
|
863
608
|
process.exit(1);
|
|
864
609
|
}
|
|
865
610
|
const response = withoutEarlyReady(data, raw);
|
|
866
611
|
settleClose(session, closes, response);
|
|
867
612
|
const status = lastReadyStatus(response);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
// message — a Terminate run now would leave no ReadyForQuery to release it
|
|
878
|
-
if (session.closed || session.terminated) {
|
|
879
|
-
session.busy = false;
|
|
880
|
-
if (!holding) release(token);
|
|
881
|
-
return undefined;
|
|
882
|
-
}
|
|
883
|
-
holding = true;
|
|
884
|
-
session.busy = true;
|
|
885
|
-
// a turn that begins outside any transaction first closes what closed connections left
|
|
886
|
-
if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(await dbReady);
|
|
887
|
-
data = scopeNames(session, client, true);
|
|
888
|
-
noteStatement(session, data);
|
|
889
|
-
// libpq's default: no database parameter means the user's name
|
|
890
|
-
const own = clientParams?.database || clientParams?.user;
|
|
891
|
-
let raw;
|
|
892
|
-
try {
|
|
893
|
-
raw = await db.execProtocolRaw(refuseStatements(data, own));
|
|
894
|
-
} catch (error) {
|
|
895
|
-
// SQL errors arrive as ErrorResponse bytes; a throw means the WASM
|
|
896
|
-
// backend itself aborted and every later call would hang. Exit so the
|
|
897
|
-
// World sees the service down (status fails) instead of a wedged port.
|
|
898
|
-
process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
|
|
899
|
-
process.exit(1);
|
|
900
|
-
}
|
|
901
|
-
session.busy = false;
|
|
902
|
-
session.idleSince = machineNow();
|
|
903
|
-
const response = withoutEarlyReady(data, raw);
|
|
904
|
-
settleClose(session, closes, response);
|
|
905
|
-
const status = lastReadyStatus(response);
|
|
906
|
-
session.midBatch = status === null;
|
|
907
|
-
if (status !== null) session.status = status;
|
|
908
|
-
if (status === 'I') {
|
|
909
|
-
holding = false;
|
|
910
|
-
release(token);
|
|
911
|
-
} else if (status === 'T') {
|
|
912
|
-
wakeAll(); // idle in its transaction: a waiting reader may now run as a guest
|
|
913
|
-
}
|
|
914
|
-
return response;
|
|
613
|
+
session.midBatch = status === null;
|
|
614
|
+
if (status !== null) session.status = status;
|
|
615
|
+
// A closed holder retains admission until its close handler confirms
|
|
616
|
+
// rollback, even if its in-flight message reported idle.
|
|
617
|
+
if (status === 'I' && !session.closed) release(token);
|
|
618
|
+
return response;
|
|
619
|
+
})();
|
|
620
|
+
session.work = work;
|
|
621
|
+
return work;
|
|
915
622
|
},
|
|
623
|
+
}).catch(error => {
|
|
624
|
+
process.stderr.write(`pglite-host: wire connection failed: ${errorMessage(error)}\n`);
|
|
625
|
+
socket.destroy();
|
|
916
626
|
});
|
|
917
627
|
});
|
|
918
628
|
|