@volter/world-runtime 3.0.162 → 3.0.164
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/ancestry.js +2 -2
- package/dist/src/app-url.d.ts +5 -1
- package/dist/src/app-url.js +14 -1
- package/dist/src/branch.js +28 -8
- package/dist/src/browser-files.d.ts +5 -0
- package/dist/src/browser-files.js +14 -0
- package/dist/src/browsers.d.ts +2 -3
- package/dist/src/browsers.js +4 -4
- package/dist/src/catalog.d.ts +2 -1
- package/dist/src/catalog.js +3 -2
- package/dist/src/changeset.js +1 -1
- package/dist/src/cli-commands.js +71 -18
- package/dist/src/cli-load-clock.d.ts +1 -0
- package/dist/src/cli-load-clock.js +2 -0
- package/dist/src/cli.js +5 -1
- package/dist/src/clickhouse-backing.d.ts +2 -0
- package/dist/src/clickhouse-backing.js +145 -0
- package/dist/src/command-lifetime.d.mts +1 -1
- package/dist/src/command-lifetime.mjs +5 -2
- package/dist/src/configs.js +1 -1
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +4 -2
- package/dist/src/covers.js +8 -4
- package/dist/src/go-mysql-binding.d.ts +19 -0
- package/dist/src/go-mysql-binding.js +51 -0
- package/dist/src/guest-processes.d.mts +1 -1
- package/dist/src/guest-processes.mjs +22 -14
- package/dist/src/host-cli.js +8 -1
- package/dist/src/host-ready.d.ts +16 -2
- package/dist/src/host-ready.js +66 -39
- package/dist/src/host-worker.js +7 -1
- package/dist/src/host.d.ts +26 -0
- package/dist/src/host.js +93 -3
- package/dist/src/infra-definition.d.ts +35 -0
- package/dist/src/infra-definition.js +146 -0
- package/dist/src/infra.d.ts +3 -0
- package/dist/src/infra.js +375 -105
- package/dist/src/init.d.ts +46 -0
- package/dist/src/init.js +445 -18
- package/dist/src/lifecycle-record.d.ts +5 -1
- package/dist/src/lifecycle-record.js +27 -4
- package/dist/src/local-branches.js +1 -1
- package/dist/src/mariadb-bootstrap.d.ts +32 -0
- package/dist/src/mariadb-bootstrap.js +90 -0
- package/dist/src/mariadb-supplier.d.ts +23 -0
- package/dist/src/mariadb-supplier.js +215 -0
- package/dist/src/mongodb-backing.d.ts +4 -0
- package/dist/src/mongodb-backing.js +71 -1
- package/dist/src/native-mariadb-backing.d.ts +13 -0
- package/dist/src/native-mariadb-backing.js +512 -0
- package/dist/src/native-postgres-backing.d.ts +4 -10
- package/dist/src/native-postgres-backing.js +428 -43
- package/dist/src/native-server-inputs.d.ts +16 -0
- package/dist/src/native-server-inputs.js +204 -0
- package/dist/src/origin.js +3 -3
- package/dist/src/original-inputs.d.ts +14 -0
- package/dist/src/original-inputs.js +84 -0
- package/dist/src/pack-facts.d.ts +9 -1
- package/dist/src/pglite-backing.d.ts +41 -17
- package/dist/src/pglite-backing.js +213 -79
- package/dist/src/pglite-host.mjs +482 -10
- package/dist/src/process-groups.d.mts +6 -1
- package/dist/src/process-groups.d.ts +2 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/process-groups.mjs +82 -15
- package/dist/src/process-signal.d.mts +2 -2
- package/dist/src/process-signal.mjs +29 -9
- package/dist/src/project-inspect.d.ts +12 -0
- package/dist/src/project-inspect.js +41 -4
- package/dist/src/proxy-env.d.ts +34 -0
- package/dist/src/proxy-env.js +114 -0
- package/dist/src/redirect-proxy.d.ts +1 -31
- package/dist/src/redirect-proxy.js +3 -112
- package/dist/src/redis-backing.d.ts +6 -4
- package/dist/src/redis-backing.js +20 -168
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +503 -218
- package/dist/src/schema.d.ts +1 -0
- package/dist/src/schema.js +5 -3
- package/dist/src/served-world.js +15 -10
- package/dist/src/service-exit.d.ts +3 -2
- package/dist/src/service-exit.js +7 -5
- package/dist/src/twin-backing.d.ts +47 -0
- package/dist/src/twin-backing.js +343 -0
- package/dist/src/up-cli.js +56 -3
- package/package.json +4 -4
- package/src/ancestry.ts +2 -2
- package/src/app-url.ts +9 -2
- package/src/branch.ts +26 -8
- package/src/browser-files.ts +15 -0
- package/src/browsers.ts +4 -4
- package/src/catalog.ts +4 -3
- package/src/changeset.ts +1 -1
- package/src/cli-commands.ts +85 -18
- package/src/cli-load-clock.ts +2 -0
- package/src/cli.ts +5 -1
- package/src/clickhouse-backing.ts +70 -0
- package/src/command-lifetime.d.mts +1 -1
- package/src/command-lifetime.mjs +5 -2
- package/src/configs.ts +1 -1
- package/src/consumers.ts +1 -1
- package/src/covers.ts +9 -4
- package/src/go-mysql-binding.ts +49 -0
- package/src/guest-processes.d.mts +1 -1
- package/src/guest-processes.mjs +22 -14
- package/src/host-cli.ts +7 -1
- package/src/host-ready.ts +62 -31
- package/src/host-worker.ts +6 -2
- package/src/host.ts +88 -6
- package/src/infra-definition.ts +135 -0
- package/src/infra.ts +250 -45
- package/src/init.ts +416 -17
- package/src/lifecycle-record.ts +18 -4
- package/src/local-branches.ts +1 -1
- package/src/mariadb-bootstrap.ts +107 -0
- package/src/mariadb-supplier.ts +199 -0
- package/src/mongodb-backing.ts +59 -1
- package/src/native-mariadb-backing.ts +418 -0
- package/src/native-postgres-backing.ts +353 -41
- package/src/native-server-inputs.ts +169 -0
- package/src/origin.ts +3 -3
- package/src/original-inputs.ts +72 -0
- package/src/pack-facts.ts +1 -1
- package/src/pglite-backing.ts +192 -80
- package/src/pglite-host.mjs +482 -10
- package/src/process-groups.d.mts +6 -1
- package/src/process-groups.mjs +82 -15
- package/src/process-groups.ts +2 -1
- package/src/process-signal.d.mts +2 -2
- package/src/process-signal.mjs +29 -9
- package/src/project-inspect.ts +37 -4
- package/src/proxy-env.ts +128 -0
- package/src/redirect-proxy.ts +3 -125
- package/src/redis-backing.ts +19 -136
- package/src/runtime.ts +342 -107
- package/src/schema.ts +5 -4
- package/src/served-world.ts +14 -10
- package/src/service-exit.ts +5 -3
- package/src/twin-backing.ts +300 -0
- package/src/up-cli.ts +52 -3
package/src/pglite-host.mjs
CHANGED
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
// "extension … is not available" error. Loading the set costs boot time:
|
|
30
30
|
// PGlite.create() in-memory, node 22 on darwin arm64 (this repo's dev box,
|
|
31
31
|
// 2026-09-27, nothing else running in the process) took 1.66s bare and
|
|
32
|
-
// 2.47s with the set; RSS was within 50 MB either way.
|
|
32
|
+
// 2.47s with the set; RSS was within 50 MB either way. One open loads less:
|
|
33
|
+
// a migrated prepared cluster this boot placed (`--declared-extensions`)
|
|
34
|
+
// loads its installed extensions and startup preloads, including the retained
|
|
35
|
+
// configuration's preloads (DECLARED_EXTENSIONS below); uncertain evidence
|
|
36
|
+
// and every other open load the whole set.
|
|
33
37
|
//
|
|
34
38
|
// Limits, stated plainly (measured on 0.5.8 through this gateway with `pg`
|
|
35
39
|
// clients; pglite-backing.test.ts holds the extension and database checks):
|
|
@@ -63,7 +67,12 @@
|
|
|
63
67
|
// Randomness remains the host's own; no database schema objects are installed.
|
|
64
68
|
import { errorMessage } from './error-message.mjs';
|
|
65
69
|
import net from 'node:net';
|
|
66
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
70
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
71
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
72
|
+
import { fileURLToPath } from 'node:url';
|
|
73
|
+
import { gunzipSync } from 'node:zlib';
|
|
74
|
+
import * as nodeModule from 'node:module';
|
|
75
|
+
import { dirname, join } from 'node:path';
|
|
67
76
|
import clockForm from '@volter/world-core/world-clock';
|
|
68
77
|
import { fromNodeSocket } from 'pg-gateway/node';
|
|
69
78
|
import { BackendError } from 'pg-gateway';
|
|
@@ -97,6 +106,109 @@ const CONTRIB = [
|
|
|
97
106
|
'pg_trgm', 'pg_visibility', 'pg_walinspect', 'pgcrypto', 'seg', 'tablefunc',
|
|
98
107
|
'tcn', 'tsm_system_rows', 'tsm_system_time', 'unaccent', 'uuid_ossp',
|
|
99
108
|
];
|
|
109
|
+
// THE OPEN'S SPLIT (S2, Rallly's World): a tab's World waited 0.50 s from this host's spawn to its serving, against
|
|
110
|
+
// ~0.12 s for the same reopen of a prepared cluster natively, and nothing said where. Said once, on stdout beside the
|
|
111
|
+
// serving line: ms from this process's start to the imports done, to create() called and done, the wasm compile and
|
|
112
|
+
// instantiate inside it (WebAssembly's own calls, timed where create makes them), and to serving.
|
|
113
|
+
//
|
|
114
|
+
// THE EXTENSION PHASE inside create() (S2 R66): a prepared open loads every extension bundle it is given (34 here) before
|
|
115
|
+
// the backend starts. For each, PGlite 0.5.8 reads the .tar.gz through zlib's createGunzip, untars it into the
|
|
116
|
+
// Emscripten file system, and preloads each .so, a side module: its preload plugin calls loadWebAssemblyModule with
|
|
117
|
+
// loadAsync, which for bytes is WebAssembly.instantiate(bytes) (dist/chunk-*.js; the synchronous `new
|
|
118
|
+
// WebAssembly.Module` and `new WebAssembly.Instance` are its path for a dlopen of a library not preloaded, and for the
|
|
119
|
+
// few-byte modules it makes to wrap a JS function in a table). Said beside the rest: the bundles' gunzips (count, ms
|
|
120
|
+
// summed and the wall they spanned), the side modules (count, bytes, compile+instantiate ms summed and their wall), the
|
|
121
|
+
// main module's own wasm, the wrapper stubs, and the time between, inside the extension window, that none of them
|
|
122
|
+
// covers (the untar and the file writes, and anything else there). All four WebAssembly entries are timed, the
|
|
123
|
+
// constructors too, so a side module made either way is a side module, never "between". Not seen: work that is
|
|
124
|
+
// none of these (an extension's own JS), which lands in "between". The whole line also rides the listener receipt, so
|
|
125
|
+
// the World's serving line (pglite-backing.ts) carries it where a run's console sees it.
|
|
126
|
+
const openSplit = { importsDoneMs: 0, createStartMs: 0, createDoneMs: 0, wasm: { compileMs: 0, instantiateMs: 0, calls: 0, bytes: 0 }, sessionDoneMs: 0 };
|
|
127
|
+
const split = (value) => Math.round(value * 10) / 10;
|
|
128
|
+
/** [start, end, kind, bytes] of every timed piece; kind 'gunzip', 'stub' (under 1 KiB), 'side' (under 1 MiB) or 'main'. */
|
|
129
|
+
const pieces = [];
|
|
130
|
+
// Keep only the opening observation: process-wide wrappers may still see later serving work.
|
|
131
|
+
let collectingOpenPieces = true;
|
|
132
|
+
const STUB_MODULE_BYTES = 1024, SIDE_MODULE_BYTES = 1024 * 1024;
|
|
133
|
+
{
|
|
134
|
+
const wasm = openSplit.wasm, compile = WebAssembly.compile, instantiate = WebAssembly.instantiate;
|
|
135
|
+
// A compiled module's bytes, so its instantiation is counted as the module it is (a side module compiled, then
|
|
136
|
+
// instantiated from its Module, is a side module twice, not main wasm).
|
|
137
|
+
const moduleBytes = new WeakMap();
|
|
138
|
+
const sizeOf = (source) => (source instanceof WebAssembly.Module ? moduleBytes.get(source) ?? 0 : source?.byteLength ?? 0);
|
|
139
|
+
const kindOf = (bytes) => (bytes === 0 || bytes >= SIDE_MODULE_BYTES ? 'main' : bytes < STUB_MODULE_BYTES ? 'stub' : 'side');
|
|
140
|
+
const counted = (began, bytes, field) => {
|
|
141
|
+
if (!collectingOpenPieces) return;
|
|
142
|
+
const ended = performance.now(); wasm[field] += ended - began; pieces.push([began, ended, kindOf(bytes), bytes]);
|
|
143
|
+
};
|
|
144
|
+
WebAssembly.compile = async function (...args) {
|
|
145
|
+
const began = performance.now(), bytes = sizeOf(args[0]); wasm.calls += 1; wasm.bytes += bytes;
|
|
146
|
+
try { const module = await compile.apply(this, args); moduleBytes.set(module, bytes); return module; } finally { counted(began, bytes, 'compileMs'); }
|
|
147
|
+
};
|
|
148
|
+
WebAssembly.instantiate = async function (...args) {
|
|
149
|
+
const began = performance.now(), bytes = sizeOf(args[0]); wasm.calls += 1; if (!(args[0] instanceof WebAssembly.Module)) wasm.bytes += bytes;
|
|
150
|
+
try { return await instantiate.apply(this, args); } finally { counted(began, bytes, 'instantiateMs'); }
|
|
151
|
+
};
|
|
152
|
+
// The synchronous constructors, as proxies whose construct trap times them: `instanceof` still answers through the
|
|
153
|
+
// target's prototype, and what they make is the target's own.
|
|
154
|
+
WebAssembly.Module = new Proxy(WebAssembly.Module, {
|
|
155
|
+
construct(target, args) {
|
|
156
|
+
const began = performance.now(), bytes = sizeOf(args[0]); wasm.calls += 1; wasm.bytes += bytes;
|
|
157
|
+
try { const module = Reflect.construct(target, args); moduleBytes.set(module, bytes); return module; } finally { counted(began, bytes, 'compileMs'); }
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
WebAssembly.Instance = new Proxy(WebAssembly.Instance, {
|
|
161
|
+
construct(target, args) {
|
|
162
|
+
const began = performance.now(), bytes = sizeOf(args[0]); wasm.calls += 1;
|
|
163
|
+
try { return Reflect.construct(target, args); } finally { counted(began, bytes, 'instantiateMs'); }
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
// Node's CommonJS createGunzip is configurable but not writable (lib/zlib.js createProperty): assignment throws.
|
|
167
|
+
// Redefine only that configurable data property, preserving its flags, then sync its ESM binding for PGlite.
|
|
168
|
+
// Timing is optional: a runtime that cannot redefine or sync the builtin leaves gunzip timing unobserved.
|
|
169
|
+
const { createRequire, syncBuiltinESMExports } = nodeModule;
|
|
170
|
+
let zlib, property, wrapped;
|
|
171
|
+
try {
|
|
172
|
+
zlib = typeof createRequire === 'function' ? createRequire(import.meta.url)('node:zlib') : undefined;
|
|
173
|
+
property = zlib ? Object.getOwnPropertyDescriptor(zlib, 'createGunzip') : undefined;
|
|
174
|
+
if (property?.configurable && typeof property.value === 'function' && typeof syncBuiltinESMExports === 'function') {
|
|
175
|
+
const createGunzip = property.value;
|
|
176
|
+
wrapped = function (...args) {
|
|
177
|
+
const began = performance.now(), stream = createGunzip.apply(this, args);
|
|
178
|
+
stream.once('end', () => { if (collectingOpenPieces) pieces.push([began, performance.now(), 'gunzip', 0]); });
|
|
179
|
+
return stream;
|
|
180
|
+
};
|
|
181
|
+
Object.defineProperty(zlib, 'createGunzip', { ...property, value: wrapped });
|
|
182
|
+
syncBuiltinESMExports();
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// A missing observation cannot prevent the database opening. Restore an installed wrapper if sync refused it.
|
|
186
|
+
if (zlib && property && Object.getOwnPropertyDescriptor(zlib, 'createGunzip')?.value === wrapped) {
|
|
187
|
+
try { Object.defineProperty(zlib, 'createGunzip', property); syncBuiltinESMExports?.(); } catch { /* timing only */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** The extension phase, from the timed pieces: see THE EXTENSION PHASE above. */
|
|
192
|
+
function extensionPhase() {
|
|
193
|
+
const of = (kind) => pieces.filter(piece => piece[2] === kind);
|
|
194
|
+
const sum = (list) => list.reduce((total, [start, end]) => total + (end - start), 0);
|
|
195
|
+
const wall = (list) => (list.length ? Math.max(...list.map(piece => piece[1])) - Math.min(...list.map(piece => piece[0])) : 0);
|
|
196
|
+
const gunzips = of('gunzip'), sides = of('side'), main = of('main'), stubs = of('stub'), phase = [...gunzips, ...sides, ...stubs];
|
|
197
|
+
let between = 0;
|
|
198
|
+
if (phase.length) {
|
|
199
|
+
// The window's time no gunzip or side module covers: the union of their intervals, taken from the window.
|
|
200
|
+
const sorted = [...phase].sort((left, right) => left[0] - right[0]);
|
|
201
|
+
let covered = 0, [from, to] = sorted[0];
|
|
202
|
+
for (const [start, end] of sorted.slice(1)) {
|
|
203
|
+
if (start > to) { covered += to - from; [from, to] = [start, end]; } else to = Math.max(to, end);
|
|
204
|
+
}
|
|
205
|
+
covered += to - from;
|
|
206
|
+
between = wall(phase) - covered;
|
|
207
|
+
}
|
|
208
|
+
return `extensions ${split(wall(phase))} ms wall: ${gunzips.length} gunzips ${split(sum(gunzips))} ms (wall ${split(wall(gunzips))}), `
|
|
209
|
+
+ `${sides.length} side modules ${sides.reduce((total, piece) => total + piece[3], 0)} bytes ${split(sum(sides))} ms (wall ${split(wall(sides))}), `
|
|
210
|
+
+ `${stubs.length} stubs ${split(sum(stubs))} ms, between ${split(between)} ms; main wasm ${main.length} calls ${split(sum(main))} ms`;
|
|
211
|
+
}
|
|
100
212
|
const { PGlite } = await import('@electric-sql/pglite');
|
|
101
213
|
const { vector } = await import('@electric-sql/pglite-pgvector');
|
|
102
214
|
const extensions = { vector };
|
|
@@ -107,6 +219,7 @@ for (const [name, module] of await Promise.all(CONTRIB.map(async name => [name,
|
|
|
107
219
|
if (!extension) throw new Error(`pglite-host: @electric-sql/pglite/contrib/${name} exports no ${name}`);
|
|
108
220
|
extensions[name] = extension;
|
|
109
221
|
}
|
|
222
|
+
openSplit.importsDoneMs = performance.now();
|
|
110
223
|
|
|
111
224
|
function arg(name) {
|
|
112
225
|
const index = process.argv.indexOf(name);
|
|
@@ -128,9 +241,18 @@ const preparing = process.argv.includes('--prepare');
|
|
|
128
241
|
const optional = (name) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; };
|
|
129
242
|
const listeningFile = optional('--listening-file');
|
|
130
243
|
const bootToken = optional('--boot');
|
|
131
|
-
// --prepared:
|
|
132
|
-
//
|
|
244
|
+
// --prepared: pglite-backing.ts proved this declared identity from a placed preparation stamp or a kept cluster's
|
|
245
|
+
// serving receipt, so the declared database and role exist and the maintenance open is skipped.
|
|
133
246
|
const prepared = process.argv.includes('--prepared');
|
|
247
|
+
// --record-extensions: this host serves the World's declared database step (pglite-backing.ts migratePreparedCluster),
|
|
248
|
+
// so the extensions the cluster has when it closes are the declared migrations' own: they are written beside the
|
|
249
|
+
// cluster (DECLARED_EXTENSIONS) for a prepared open to load those alone (S2 R66b).
|
|
250
|
+
const recordingExtensions = process.argv.includes('--record-extensions');
|
|
251
|
+
// --declared-extensions: pglite-backing.ts placed this cluster from the World's preparation this boot and the live
|
|
252
|
+
// database carries the image's migration (liveDatabaseMigration), so its record is the declared migrations' set and
|
|
253
|
+
// nothing has created an extension in it since. Every other open loads the whole set and removes the record, which a
|
|
254
|
+
// later extension (kept data migrated by a newer image, or created through a whole-set open) would make stale.
|
|
255
|
+
const declaredOnly = process.argv.includes('--declared-extensions');
|
|
134
256
|
const port = preparing ? 0 : Number(arg('--port'));
|
|
135
257
|
const dataDir = arg('--data');
|
|
136
258
|
const databaseUser = process.env.VOLTER_WORLD_PGLITE_USER;
|
|
@@ -153,15 +275,361 @@ process.stderr.on('error', () => {});
|
|
|
153
275
|
// listening starts AFTER readiness so "port answers" means "serves queries"
|
|
154
276
|
const initKeepAlive = setInterval(() => {}, 500);
|
|
155
277
|
const quoteIdentifier = value => `"${value.replaceAll('"', '""')}"`;
|
|
278
|
+
/** PGlite's default printErr suppresses backend startup errors when debug is off. Its public extension setup seam
|
|
279
|
+
* keeps backend stderr in the child log without enabling debug. PostgreSQL may include a failed statement's SQL. */
|
|
280
|
+
function withBackendStderr(bundles) {
|
|
281
|
+
return { ...bundles, world_backend_stderr: {
|
|
282
|
+
name: 'world_backend_stderr',
|
|
283
|
+
setup: async (_db, emscriptenOpts) => ({ emscriptenOpts: { ...emscriptenOpts, printErr: line => {
|
|
284
|
+
try { process.stderr.write(`pglite-host backend: ${String(line)}\n`); } catch { /* a closed log cannot fail startup */ }
|
|
285
|
+
} } }),
|
|
286
|
+
} };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* EXTENSIONS AS POSTGRES LOADS THEM: THEIR FILES ONCE, THEIR LIBRARIES WHEN USED (S2 T12). PGlite 0.5.8 loads each
|
|
290
|
+
* extension it is given at every open: it gunzips the bundle (.tar.gz), untars it into the Emscripten file system and
|
|
291
|
+
* preloads every .so, a side module compiled and instantiated into the instance (THE EXTENSION PHASE above: Rallly's
|
|
292
|
+
* prepared open, 34 bundles, ~166-183 ms of extension wall, of which gunzips ~76-90 ms, side modules ~57-62 ms and the
|
|
293
|
+
* untar and file writes ~76 ms). Native Postgres has the files on disk once, loads its shared_preload_libraries once at
|
|
294
|
+
* the postmaster's start, and loads any other extension's library at its first use (dfmgr's load_external_function:
|
|
295
|
+
* dlopen). This host does the same:
|
|
296
|
+
* - a bundle is gunzipped and untarred once per cache, its files kept unpacked beside the cluster's data directory
|
|
297
|
+
* (`.volter-pglite-extensions/<name>-<sha256 of the bundle, 16 hex>/`, with `.files.json` naming the digest and the
|
|
298
|
+
* files); every later open whose bundle has that digest reads the kept files and writes them into the instance's
|
|
299
|
+
* file system before it starts (a preRun), with no gunzip and no untar. The cache is the data directory's parent's:
|
|
300
|
+
* the image build's --prepare host keeps its own, so a World's first `up` unpacks once more;
|
|
301
|
+
* - no .so is preloaded: Postgres's own dlopen loads a library when it is first used, and the shared preload
|
|
302
|
+
* libraries (pg_stat_statements, which PGlite's preload list and the retained configuration name) at the backend's
|
|
303
|
+
* start, through Emscripten's dlopen, which reads the file and compiles and instantiates it synchronously (the path
|
|
304
|
+
* PGlite itself leaves for a library not preloaded).
|
|
305
|
+
* A bundle that cannot be read or unpacked is said and given no files, as PGlite logs a failed bundle and goes on; a
|
|
306
|
+
* cache that cannot be kept (no space, no permission, a read-only directory) is counted as keepFailed with its error,
|
|
307
|
+
* and that open's files are the ones it unpacked.
|
|
308
|
+
* THE CEILING, from source: an instantiated side module cannot be kept across opens. Each open is its own Emscripten
|
|
309
|
+
* instance with its own memory and table, and a side module is linked into those: libdylink's loadWebAssemblyModule
|
|
310
|
+
* places its data at a memoryBase it allocates in that instance's heap and its functions in that instance's table, then
|
|
311
|
+
* instantiates with that instance's memory and table as imports. So the library's instantiation is per open, as a
|
|
312
|
+
* process's dlopen is per process natively; only the files are kept.
|
|
313
|
+
* The gain is unmeasured: the reading is the next Rallly lap's serving line, its extension phase (no gunzips, no side
|
|
314
|
+
* module preloads) and its `extension files` account (kept, unpacked, keepFailed, ms).
|
|
315
|
+
*/
|
|
316
|
+
const EXTENSION_FILES = join(dirname(dataDir), '.volter-pglite-extensions');
|
|
317
|
+
/** A tar member's name is a relative path inside the bundle: no absolute path, no `..` or empty segment. */
|
|
318
|
+
function safeMemberName(name) {
|
|
319
|
+
return typeof name === 'string' && name.length > 0 && !name.startsWith('/') && !name.includes('\0')
|
|
320
|
+
&& name.split('/').every((segment, at, all) => segment !== '..' && segment !== '.' && (segment !== '' || at === all.length - 1));
|
|
321
|
+
}
|
|
322
|
+
/** A bundle's tar members: ustar (its magic and checksum checked), a pax header's path or a GNU long name where one is given. */
|
|
323
|
+
function untarMembers(tar) {
|
|
324
|
+
const members = [];
|
|
325
|
+
let longName;
|
|
326
|
+
const text = (at, length) => { const end = tar.indexOf(0, at); return tar.toString('utf8', at, end >= 0 && end < at + length ? end : at + length); };
|
|
327
|
+
const octal = (at, length, what) => {
|
|
328
|
+
const field = text(at, length).trim();
|
|
329
|
+
if (!/^[0-7]*$/.test(field)) throw new Error(`tar: ${what} is not octal`);
|
|
330
|
+
return field ? parseInt(field, 8) : 0;
|
|
331
|
+
};
|
|
332
|
+
for (let at = 0; at + 512 <= tar.length;) {
|
|
333
|
+
if (tar.subarray(at, at + 512).every(byte => byte === 0)) break;
|
|
334
|
+
if (!text(at + 257, 6).startsWith('ustar')) throw new Error('tar: a header without the ustar magic');
|
|
335
|
+
let sum = 0;
|
|
336
|
+
for (let index = 0; index < 512; index += 1) sum += index >= 148 && index < 156 ? 0x20 : tar[at + index];
|
|
337
|
+
if (sum !== octal(at + 148, 8, 'a header checksum')) throw new Error('tar: a header checksum does not match');
|
|
338
|
+
const size = octal(at + 124, 12, 'a member size');
|
|
339
|
+
const type = String.fromCharCode(tar[at + 156] || 0x30);
|
|
340
|
+
const prefix = text(at + 345, 155);
|
|
341
|
+
const name = longName ?? (prefix ? `${prefix}/${text(at, 100)}` : text(at, 100));
|
|
342
|
+
if (at + 512 + size > tar.length) throw new Error('tar: a member runs past the archive');
|
|
343
|
+
const data = tar.subarray(at + 512, at + 512 + size);
|
|
344
|
+
at += 512 + Math.ceil(size / 512) * 512;
|
|
345
|
+
if (type === 'x') {
|
|
346
|
+
const record = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(data.toString('utf8'));
|
|
347
|
+
longName = record ? record[1] : undefined;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (type === 'L') { longName = data.toString('utf8').replace(/\0+$/, ''); continue; }
|
|
351
|
+
longName = undefined;
|
|
352
|
+
if (type === 'g') continue;
|
|
353
|
+
if (type !== '0' && type !== '\0') continue;
|
|
354
|
+
if (!safeMemberName(name)) throw new Error(`tar: a member named outside the bundle (${JSON.stringify(name).slice(0, 80)})`);
|
|
355
|
+
members.push({ name, data });
|
|
356
|
+
}
|
|
357
|
+
return members;
|
|
358
|
+
}
|
|
359
|
+
/** An open's account of its bundles: kept and unpacked bundles, their files, failures to keep, kept lists unreadable. */
|
|
360
|
+
const accountFor = (create) => ({ create, bundles: 0, kept: 0, unpacked: 0, tarRefused: 0, keepFailed: 0, keepErrors: new Set(), keptUnread: 0, files: 0, ms: 0 });
|
|
361
|
+
// Every create's account, in order. A host that is not prepared opens twice (template1, then the declared database),
|
|
362
|
+
// and the extension phase's timed pieces span both creates, so the account is said for both (review of T12b, D): the
|
|
363
|
+
// timed gunzips are every create's unpacked plus its tarRefused (a bundle gunzipped whose tar the reader refused).
|
|
364
|
+
const creates = [];
|
|
365
|
+
const KEPT_LIST = '.files.json';
|
|
366
|
+
// The kept list's form: changed with the tar reader, its member filter, this list's fields or how a keep is made
|
|
367
|
+
// durable, so a cache another form wrote is unpacked again rather than read. 2: each file with its size, checked on
|
|
368
|
+
// read. 3: each file synced before the keep's rename, so a v2 keep (unsynced, possibly a zero-filled file at its full
|
|
369
|
+
// size after a crash) is never served (review of T12c, B1).
|
|
370
|
+
const KEPT_VERSION = 3;
|
|
371
|
+
/** A kept list that cannot be read is said and counted once per bundle, however many times the bundle looks at it. */
|
|
372
|
+
function keptUnreadable(bundle, what, error) {
|
|
373
|
+
if (bundle.unread) return;
|
|
374
|
+
bundle.unread = true;
|
|
375
|
+
bundle.account.keptUnread += 1;
|
|
376
|
+
process.stderr.write(`pglite-host: extension ${bundle.name}: ${what} cannot be read (${errorMessage(error)}); unpacked again\n`);
|
|
377
|
+
}
|
|
378
|
+
/** The files `kept` lists, where its list has this form and this digest; else undefined. */
|
|
379
|
+
function keptList(bundle, kept, digest) {
|
|
380
|
+
let listed;
|
|
381
|
+
try { listed = JSON.parse(readFileSync(join(kept, KEPT_LIST), 'utf8')); }
|
|
382
|
+
catch (error) {
|
|
383
|
+
if (error?.code !== 'ENOENT') keptUnreadable(bundle, 'its kept files', error);
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
if (listed?.version !== KEPT_VERSION || listed.digest !== digest || !Array.isArray(listed.files)
|
|
387
|
+
|| !listed.files.every(file => safeMemberName(file?.name) && Number.isSafeInteger(file?.size) && file.size >= 0)) return undefined;
|
|
388
|
+
return listed.files;
|
|
389
|
+
}
|
|
390
|
+
/** The kept files of `kept`, read, where its list stands and every file is present at its size; else undefined. */
|
|
391
|
+
function keptFiles(bundle, kept, digest) {
|
|
392
|
+
const listed = keptList(bundle, kept, digest);
|
|
393
|
+
if (!listed) return undefined;
|
|
394
|
+
try {
|
|
395
|
+
const files = listed.map(file => [file.name, readFileSync(join(kept, file.name))]);
|
|
396
|
+
// A file cut short after a power loss (a zero-length .so) is not served: every size must be the one kept.
|
|
397
|
+
if (files.some(([_file, data], at) => data.length !== listed[at].size)) return undefined;
|
|
398
|
+
return files;
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error?.code !== 'ENOENT') keptUnreadable(bundle, 'a kept file', error);
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** Whether `kept` stands whole, by its list and each file's size as stat says it: no file read back (review of T12b, B). */
|
|
405
|
+
function keptStands(bundle, kept, digest) {
|
|
406
|
+
const listed = keptList(bundle, kept, digest);
|
|
407
|
+
if (!listed) return false;
|
|
408
|
+
try { return listed.every(file => { const stat = statSync(join(kept, file.name)); return stat.isFile() && stat.size === file.size; }); }
|
|
409
|
+
catch (error) {
|
|
410
|
+
if (error?.code !== 'ENOENT') keptUnreadable(bundle, 'a kept file', error);
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* `data` written to `path` and synced before the keep's rename, so a crash cannot leave a kept file at its listed size
|
|
416
|
+
* with blocks never written (a zero-filled .so the size check would pass; review of T12b, C). The directories are not
|
|
417
|
+
* synced: a rename a crash loses leaves the cache cold, never wrong, and the next open unpacks again. Where fsync fails
|
|
418
|
+
* though writes work (some FUSE, 9p or network file systems), nothing is kept: every open there unpacks, as before T12.
|
|
419
|
+
* A failure to close is thrown only where the write and the sync succeeded, so it never hides theirs.
|
|
420
|
+
*/
|
|
421
|
+
function writeSynced(path, data) {
|
|
422
|
+
const fd = openSync(path, 'w');
|
|
423
|
+
let failure;
|
|
424
|
+
try { writeFileSync(fd, data); fsyncSync(fd); } catch (error) { failure = error; }
|
|
425
|
+
try { closeSync(fd); } catch (error) { failure ??= error; }
|
|
426
|
+
if (failure) throw failure;
|
|
427
|
+
}
|
|
428
|
+
/** A bundle's files (relative path, bytes) as PGlite would write them, unpacked once and kept beside the cluster. */
|
|
429
|
+
function bundleFiles(name, bundlePath, account) {
|
|
430
|
+
let packed;
|
|
431
|
+
try { packed = readFileSync(bundlePath instanceof URL ? fileURLToPath(bundlePath) : String(bundlePath)); }
|
|
432
|
+
catch (error) {
|
|
433
|
+
process.stderr.write(`pglite-host: extension ${name}: its bundle cannot be read (${errorMessage(error)}); it is not loaded\n`);
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
const bundle = { name, account, unread: false };
|
|
437
|
+
const digest = createHash('sha256').update(packed).digest('hex');
|
|
438
|
+
const keptName = `${name}-${digest.slice(0, 16)}`;
|
|
439
|
+
const kept = join(EXTENSION_FILES, keptName);
|
|
440
|
+
const standing = keptFiles(bundle, kept, digest);
|
|
441
|
+
if (standing) { account.kept += 1; return standing; }
|
|
442
|
+
let files;
|
|
443
|
+
let gunzipped = false;
|
|
444
|
+
try {
|
|
445
|
+
// As PGlite's own untar (dist/index.js Et): members whose names start with "." are not written. A bundle the
|
|
446
|
+
// reader refuses (a bad checksum or magic, a member outside the bundle) is logged and given no files, where
|
|
447
|
+
// PGlite's own untar would throw out of the open.
|
|
448
|
+
const began = performance.now();
|
|
449
|
+
const tar = gunzipSync(packed);
|
|
450
|
+
if (collectingOpenPieces) pieces.push([began, performance.now(), 'gunzip', 0]);
|
|
451
|
+
gunzipped = true;
|
|
452
|
+
files = untarMembers(tar).filter(member => !member.name.startsWith('.') && !member.name.endsWith('/'))
|
|
453
|
+
.map(member => [member.name, member.data]);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (gunzipped) account.tarRefused += 1;
|
|
456
|
+
process.stderr.write(`pglite-host: extension ${name}: its bundle cannot be unpacked (${errorMessage(error)}); it is not loaded\n`);
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
account.unpacked += 1;
|
|
460
|
+
// Kept by a rename of a whole directory, so a reader never sees half of one; a host that lost the race keeps nothing.
|
|
461
|
+
const writing = `${kept}.${process.pid}.${randomUUID()}`;
|
|
462
|
+
let keepError;
|
|
463
|
+
try {
|
|
464
|
+
mkdirSync(EXTENSION_FILES, { recursive: true });
|
|
465
|
+
for (const [file, data] of files) { mkdirSync(dirname(join(writing, file)), { recursive: true }); writeSynced(join(writing, file), data); }
|
|
466
|
+
writeSynced(join(writing, KEPT_LIST), JSON.stringify({ version: KEPT_VERSION, digest, files: files.map(([file, data]) => ({ name: file, size: data.length })) }));
|
|
467
|
+
// A directory left by an earlier keep that lacks a file is replaced; one another host kept meanwhile stands.
|
|
468
|
+
try { renameSync(writing, kept); }
|
|
469
|
+
catch (error) {
|
|
470
|
+
if (error?.code !== 'ENOTEMPTY' && error?.code !== 'EEXIST') throw error;
|
|
471
|
+
if (!keptStands(bundle, kept, digest)) { rmSync(kept, { recursive: true, force: true }); renameSync(writing, kept); }
|
|
472
|
+
}
|
|
473
|
+
} catch (error) { keepError = error; }
|
|
474
|
+
// Whatever is left of the staging, and an earlier digest's directory or an ended host's staging of this name, goes.
|
|
475
|
+
// Cleaning is best effort and neither said nor counted: a staging that cannot be removed, or whose pid answers as live
|
|
476
|
+
// (its host, or another process now holding a reused pid), stays until a later keep of this name finds it removable.
|
|
477
|
+
try { rmSync(writing, { recursive: true, force: true }); } catch { /* left for a later keep */ }
|
|
478
|
+
try {
|
|
479
|
+
for (const entry of readdirSync(EXTENSION_FILES)) {
|
|
480
|
+
if (!entry.startsWith(`${name}-`) || entry === keptName) continue;
|
|
481
|
+
const staging = /\.(\d+)\.[0-9a-f-]{36}$/.exec(entry);
|
|
482
|
+
if (staging) { try { process.kill(Number(staging[1]), 0); continue; } catch (error) { if (error?.code === 'EPERM') continue; } }
|
|
483
|
+
rmSync(join(EXTENSION_FILES, entry), { recursive: true, force: true });
|
|
484
|
+
}
|
|
485
|
+
} catch { /* left for a later keep */ }
|
|
486
|
+
// Kept or not is what stands at the end: another host's directory, or ours, holding every file at its size.
|
|
487
|
+
if (!keptStands(bundle, kept, digest)) {
|
|
488
|
+
account.keepFailed += 1;
|
|
489
|
+
account.keepErrors.add(keepError?.code ?? (keepError ? errorMessage(keepError).slice(0, 60) : 'not kept'));
|
|
490
|
+
}
|
|
491
|
+
return files;
|
|
492
|
+
}
|
|
493
|
+
/** One create's extension-files account. */
|
|
494
|
+
function accountSaid(account) {
|
|
495
|
+
return `${account.bundles} bundles (${account.kept} kept, ${account.unpacked} unpacked${account.tarRefused ? `, ${account.tarRefused} tars refused` : ''}, ${account.keepFailed} not kept`
|
|
496
|
+
+ `${account.keepErrors.size ? `: ${[...account.keepErrors].join(', ')}` : ''}${account.keptUnread ? `, ${account.keptUnread} kept unreadable` : ''}), `
|
|
497
|
+
+ `${account.files} files in ${split(account.ms)} ms`;
|
|
498
|
+
}
|
|
499
|
+
/** The open's extension-files account, every create's, as the serving line (and a --prepare host's own line) says it. */
|
|
500
|
+
function extensionFilesSaid() {
|
|
501
|
+
const said = creates.length === 1 ? accountSaid(creates[0]) : creates.map(account => `${account.create}: ${accountSaid(account)}`).join('; ');
|
|
502
|
+
return `extension files: ${said || 'no create'}, no library preloaded (S2 T12)`;
|
|
503
|
+
}
|
|
504
|
+
/** Each bundle extension as one whose files are written into the instance before it starts, its libraries not preloaded. */
|
|
505
|
+
function unpackedOnce(bundles, create) {
|
|
506
|
+
const account = accountFor(create);
|
|
507
|
+
creates.push(account);
|
|
508
|
+
const wrapped = {};
|
|
509
|
+
for (const [name, extension] of Object.entries(bundles)) {
|
|
510
|
+
wrapped[name] = { name: extension.name ?? name, setup: async (db, emscriptenOpts, clientOnly) => {
|
|
511
|
+
const given = await extension.setup(db, emscriptenOpts, clientOnly);
|
|
512
|
+
if (!given?.bundlePath) return given;
|
|
513
|
+
const { bundlePath, ...rest } = given;
|
|
514
|
+
const began = performance.now();
|
|
515
|
+
const files = bundleFiles(name, bundlePath, account);
|
|
516
|
+
account.bundles += 1; account.files += files.length; account.ms += performance.now() - began;
|
|
517
|
+
const base = rest.emscriptenOpts ?? emscriptenOpts;
|
|
518
|
+
return { ...rest, emscriptenOpts: { ...base, preRun: [...(base.preRun ?? []), (module) => {
|
|
519
|
+
for (const [file, data] of files) {
|
|
520
|
+
const target = `${module.WASM_PREFIX}/${file}`;
|
|
521
|
+
const parent = target.slice(0, target.lastIndexOf('/'));
|
|
522
|
+
if (!module.FS.analyzePath(parent).exists) module.FS.mkdirTree(parent);
|
|
523
|
+
module.FS.writeFile(target, data);
|
|
524
|
+
}
|
|
525
|
+
}] } };
|
|
526
|
+
} };
|
|
527
|
+
}
|
|
528
|
+
return wrapped;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* THE DECLARED MIGRATIONS' EXTENSIONS (S2 R66b). A prepared open loaded every extension bundle the host offers (34) at
|
|
533
|
+
* create(), each a gunzip, an untar into the Emscripten file system and a side module's instantiate, whatever the
|
|
534
|
+
* cluster uses: Rallly's World waited 0.50 s from spawn to serving against ~0.12 s natively. Where the cluster was
|
|
535
|
+
* prepared with the World's declared database step, its installed extensions and preload libraries are recorded
|
|
536
|
+
* beside it (`.volter-declared-extensions.json`, under the data directory, which Postgres leaves alone). Only an open
|
|
537
|
+
* given `--declared-extensions` (the boot that placed the cluster, see `declaredOnly` above) loads those and the retained
|
|
538
|
+
* configuration's preload libraries: pg_extension alone omits a required preload without CREATE EXTENSION. An
|
|
539
|
+
* extension the migrations created is loaded; one they did not is absent, as it is from the freshly placed cluster, and
|
|
540
|
+
* a `CREATE EXTENSION` of it refuses "is not available". Such an open of a cluster with no record (prepared with no
|
|
541
|
+
* declared step, or before the record existed), or of a record naming an extension this host does not offer, loads every
|
|
542
|
+
* bundle, as every other open does.
|
|
543
|
+
*/
|
|
544
|
+
const DECLARED_EXTENSIONS = join(dataDir, '.volter-declared-extensions.json');
|
|
545
|
+
/** The bundles a prepared open loads: the record's, where every name in it is one this host offers; else all. */
|
|
546
|
+
function declaredExtensions() {
|
|
547
|
+
let named;
|
|
548
|
+
try { named = JSON.parse(readFileSync(DECLARED_EXTENSIONS, 'utf8')); } catch { return extensions; }
|
|
549
|
+
// A v1 record says nothing about startup preloads: keep the full set, including for existing prepared images.
|
|
550
|
+
if (named?.version !== 2 || !Array.isArray(named.extensions) || !Array.isArray(named.preloadLibraries)) return extensions;
|
|
551
|
+
const configured = configuredPreloads();
|
|
552
|
+
if (configured === undefined) return extensions;
|
|
553
|
+
const chosen = {};
|
|
554
|
+
for (const name of [...named.extensions, ...named.preloadLibraries, ...configured]) {
|
|
555
|
+
if (typeof name !== 'string') return extensions;
|
|
556
|
+
// plpgsql is the server's own; uuid-ossp's module is uuid_ossp (the module name; see CONTRIB).
|
|
557
|
+
if (name === 'plpgsql') continue;
|
|
558
|
+
const module = name === 'uuid-ossp' ? 'uuid_ossp' : name;
|
|
559
|
+
if (!Object.hasOwn(extensions, module)) return extensions;
|
|
560
|
+
chosen[module] = extensions[module];
|
|
561
|
+
}
|
|
562
|
+
return chosen;
|
|
563
|
+
}
|
|
564
|
+
/** Only simple library names this host supplies can narrow the bundle set; uncertain syntax keeps every bundle. */
|
|
565
|
+
function preloadNames(value) {
|
|
566
|
+
if (typeof value !== 'string') return undefined;
|
|
567
|
+
if (!value.trim()) return [];
|
|
568
|
+
const names = value.split(',').map(name => name.trim());
|
|
569
|
+
return names.every(name => /^[a-z][a-z0-9_-]*$/.test(name) && Object.hasOwn(extensions, name)) ? names : undefined;
|
|
570
|
+
}
|
|
571
|
+
/** The files PostgreSQL will read are the truth at boot. Union both files' preloads, even a superseded assignment;
|
|
572
|
+
* includes and unsupported value syntax fall back to all bundles rather than guessing their dependency closure. */
|
|
573
|
+
function configuredPreloads() {
|
|
574
|
+
const names = new Set();
|
|
575
|
+
for (const file of ['postgresql.conf', 'postgresql.auto.conf']) {
|
|
576
|
+
let text;
|
|
577
|
+
try { text = readFileSync(join(dataDir, file), 'utf8'); }
|
|
578
|
+
catch (error) { if (file === 'postgresql.auto.conf' && error?.code === 'ENOENT') continue; return undefined; }
|
|
579
|
+
for (const source of text.split(/\r?\n/)) {
|
|
580
|
+
const line = source.trim();
|
|
581
|
+
if (!line || line.startsWith('#')) continue;
|
|
582
|
+
if (/^include(?:_dir|_if_exists)?\b/i.test(line)) return undefined;
|
|
583
|
+
if (!/^shared_preload_libraries\b/i.test(line)) continue;
|
|
584
|
+
const setting = /^shared_preload_libraries\s*=\s*(?:'([^'\\]*)'|([a-z0-9_, \t-]*?))\s*(?:#.*)?$/i.exec(line);
|
|
585
|
+
if (!setting) return undefined;
|
|
586
|
+
const libraries = preloadNames(setting[1] ?? setting[2]);
|
|
587
|
+
if (libraries === undefined) return undefined;
|
|
588
|
+
for (const name of libraries) names.add(name);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return [...names];
|
|
592
|
+
}
|
|
593
|
+
/** The cluster's extensions now, written beside it for the next prepared open (only where this host records them). */
|
|
594
|
+
async function recordExtensions(db) {
|
|
595
|
+
if (!recordingExtensions) return;
|
|
596
|
+
try {
|
|
597
|
+
const rows = (await db.query('SELECT extname FROM pg_extension ORDER BY extname')).rows;
|
|
598
|
+
const startup = (await db.query("SELECT current_setting('session_preload_libraries') AS session, current_setting('local_preload_libraries') AS local")).rows[0];
|
|
599
|
+
// Database/role defaults can supply these outside the conf files; no narrowed record is safe while either is set.
|
|
600
|
+
if (typeof startup?.session !== 'string' || typeof startup?.local !== 'string' || startup.session.trim() || startup.local.trim()) {
|
|
601
|
+
throw new Error('Session or local preload libraries prevent a narrowed extension record.');
|
|
602
|
+
}
|
|
603
|
+
const setting = (await db.query("SELECT current_setting('shared_preload_libraries') AS libraries")).rows[0]?.libraries;
|
|
604
|
+
const preloadLibraries = preloadNames(setting);
|
|
605
|
+
if (preloadLibraries === undefined) throw new Error('The cluster preload libraries cannot be recorded as known bundles.');
|
|
606
|
+
const temporary = `${DECLARED_EXTENSIONS}.${process.pid}.tmp`;
|
|
607
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 2, extensions: rows.map(row => row.extname), preloadLibraries })}\n`);
|
|
608
|
+
renameSync(temporary, DECLARED_EXTENSIONS);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
// No record is the full set at the next open: never a narrower one than the cluster needs.
|
|
611
|
+
rmSync(DECLARED_EXTENSIONS, { force: true });
|
|
612
|
+
process.stderr.write(`pglite-host: the cluster's extensions were not recorded: ${errorMessage(error)}\n`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
156
615
|
async function openDeclaredDatabase() {
|
|
616
|
+
// A record a narrowed open did not ask for is gone before anything can create an extension it lacks (review of R66b).
|
|
617
|
+
if (!declaredOnly && !recordingExtensions) rmSync(DECLARED_EXTENSIONS, { force: true });
|
|
157
618
|
if (prepared) {
|
|
158
|
-
|
|
619
|
+
process.stdout.write('pglite-host opening declared database directly\n');
|
|
620
|
+
openSplit.createStartMs = performance.now();
|
|
621
|
+
const loading = declaredOnly && !recordingExtensions ? declaredExtensions() : extensions;
|
|
622
|
+
if (loading !== extensions) process.stdout.write(`pglite-host loading the declared migrations' extensions: ${Object.keys(loading).join(', ') || 'none'}\n`);
|
|
623
|
+
const db = await PGlite.create({ dataDir, extensions: withBackendStderr(unpackedOnce(loading, databaseName)), database: databaseName });
|
|
624
|
+
openSplit.createDoneMs = performance.now();
|
|
159
625
|
await db.exec(`SET SESSION AUTHORIZATION ${quoteIdentifier(databaseUser)}`);
|
|
626
|
+
openSplit.sessionDoneMs = performance.now();
|
|
160
627
|
return db;
|
|
161
628
|
}
|
|
162
629
|
// PostgreSQL cannot rename its current database. Use its maintenance database
|
|
163
630
|
// before opening the declared one; old Worlds' rows in postgres stay intact.
|
|
164
|
-
|
|
631
|
+
process.stdout.write('pglite-host opening template1 for database identity setup\n');
|
|
632
|
+
const maintenance = await PGlite.create({ dataDir, extensions: withBackendStderr(unpackedOnce(extensions, 'template1')), database: 'template1' });
|
|
165
633
|
try {
|
|
166
634
|
const existing = await maintenance.query('SELECT datname FROM pg_database WHERE datname = $1', [databaseName]);
|
|
167
635
|
if (!existing.rows.length) {
|
|
@@ -173,7 +641,7 @@ async function openDeclaredDatabase() {
|
|
|
173
641
|
const role = await maintenance.query('SELECT rolname FROM pg_roles WHERE rolname = $1', [databaseUser]);
|
|
174
642
|
if (!role.rows.length) await maintenance.exec(`CREATE ROLE ${quoteIdentifier(databaseUser)} LOGIN SUPERUSER`);
|
|
175
643
|
} finally { await maintenance.close(); }
|
|
176
|
-
const db = await PGlite.create({ dataDir, extensions, database: databaseName });
|
|
644
|
+
const db = await PGlite.create({ dataDir, extensions: withBackendStderr(unpackedOnce(extensions, databaseName)), database: databaseName });
|
|
177
645
|
await db.exec(`SET SESSION AUTHORIZATION ${quoteIdentifier(databaseUser)}`);
|
|
178
646
|
return db;
|
|
179
647
|
}
|
|
@@ -649,17 +1117,21 @@ const server = net.createServer((socket) => {
|
|
|
649
1117
|
if (preparing) void dbReady.then(async (db) => {
|
|
650
1118
|
await db.close();
|
|
651
1119
|
clearInterval(initKeepAlive);
|
|
652
|
-
process.stdout.write(`pglite-host prepared ${dataDir}\n`);
|
|
1120
|
+
process.stdout.write(`pglite-host prepared ${dataDir} (${extensionFilesSaid()})\n`);
|
|
653
1121
|
process.exit(0);
|
|
654
1122
|
});
|
|
655
1123
|
else void dbReady.then(() => {
|
|
656
1124
|
clearInterval(initKeepAlive);
|
|
657
1125
|
server.listen(port, '127.0.0.1', () => {
|
|
1126
|
+
const wasm = openSplit.wasm;
|
|
1127
|
+
const said = `imports ${split(openSplit.importsDoneMs)} ms, create ${split(openSplit.createStartMs)} -> ${split(openSplit.createDoneMs)} ms (wasm ${wasm.calls} calls, ${wasm.bytes} bytes: compile ${split(wasm.compileMs)} ms, instantiate ${split(wasm.instantiateMs)} ms; ${extensionPhase()}; ${extensionFilesSaid()}), session ${split(openSplit.sessionDoneMs)} ms, serving ${split(performance.now())} ms from this process's start`;
|
|
1128
|
+
collectingOpenPieces = false;
|
|
658
1129
|
if (listeningFile) {
|
|
659
1130
|
const temporary = `${listeningFile}.${process.pid}.tmp`;
|
|
660
|
-
writeFileSync(temporary, `${JSON.stringify({ version: 1, pid: process.pid, port, boot: bootToken ?? null })}\n`, { mode: 0o600 });
|
|
1131
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, pid: process.pid, port, boot: bootToken ?? null, openSplit: said })}\n`, { mode: 0o600 });
|
|
661
1132
|
renameSync(temporary, listeningFile);
|
|
662
1133
|
}
|
|
1134
|
+
process.stdout.write(`pglite-host open split: ${said}\n`);
|
|
663
1135
|
process.stdout.write(`pglite-host serving postgres wire protocol on 127.0.0.1:${port}\n`);
|
|
664
1136
|
});
|
|
665
1137
|
});
|
|
@@ -667,7 +1139,7 @@ else void dbReady.then(() => {
|
|
|
667
1139
|
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
668
1140
|
process.on(signal, () => {
|
|
669
1141
|
server.close(() => {
|
|
670
|
-
void dbReady.then((db) => db.close()).finally(() => process.exit(0));
|
|
1142
|
+
void dbReady.then(async (db) => { await recordExtensions(db); await db.close(); }).finally(() => process.exit(0));
|
|
671
1143
|
});
|
|
672
1144
|
// a hung close must not make `down` hang the world
|
|
673
1145
|
setTimeout(() => process.exit(0), 3000).unref();
|
package/src/process-groups.d.mts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
/** One reading of the process table, shared by the questions of one decision: made empty by the caller, filled by the first question that needs it. */
|
|
2
|
+
export type ProcessListing = { table?: unknown; proc?: unknown };
|
|
3
|
+
export function survivingOwnedGroups(pids: number[], listing?: ProcessListing): number[];
|
|
4
|
+
export function setWorldEventsFile(path: string | undefined): void;
|
|
5
|
+
export function worldEvent(record: Record<string, unknown>): void;
|
|
6
|
+
export function sayProbe(command: string, startedAt: number, outcome: string): void;
|
|
2
7
|
export function processStarts(pids: number[]): Map<number, string>;
|