@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// belong to the infrastructure service; a recipe never operates postgres itself.
|
|
3
3
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
-
import { accessSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { accessSync, chmodSync, chownSync, copyFileSync, lchownSync, lstatSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { connect } from 'node:net';
|
|
7
7
|
import { runtimeManifest } from './package-manifest.ts';
|
|
8
8
|
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
@@ -10,8 +10,81 @@ import { commandLifetime, COMMAND_GRACE_MS, CommandRetirementError } from './com
|
|
|
10
10
|
import { survivingOwnedGroups } from './process-groups.ts';
|
|
11
11
|
import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess, type SpawnIdentity } from './process-receipt.ts';
|
|
12
12
|
import { signalChild, childIdentity } from './process-signal.mjs';
|
|
13
|
+
import { worldEvent } from './process-groups.mjs';
|
|
13
14
|
import { errorMessage } from './error-message.ts';
|
|
14
|
-
import { PREPARED_DIRECTORY, databasePrepareOf, expectedMigration, forgetStaleMigration, keepFailureEvidence, runDatabasePrepare, takePreparedCluster, type HostLaunch, type InfraService, type PreparedMigration } from './pglite-backing.ts';
|
|
15
|
+
import { PREPARED_DIRECTORY, PREPARE_STATE_DIRECTORY, packageScriptPath, databasePrepareOf, environmentFile, expectedMigration, forgetStaleMigration, keepFailureEvidence, migrationInputDigests, preparedStore, preparedStoreStamp, preparedWorldServices, runDatabasePrepare, takePreparedCluster, type HostLaunch, type InfraService, type PreparedMigration } from './pglite-backing.ts';
|
|
16
|
+
import type { WorldDatabasePrepare } from './schema.ts';
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/** OS identity, distinct from PostgreSQL's configured SQL role. No in-process privilege mutation. */
|
|
20
|
+
function postgresRunAs(): { uid: number; gid: number } {
|
|
21
|
+
const uid = process.getuid?.(); const gid = process.getgid?.();
|
|
22
|
+
if (uid === undefined || gid === undefined) throw new Error('native PostgreSQL requires a numeric OS process identity');
|
|
23
|
+
if (uid !== 0) return { uid, gid };
|
|
24
|
+
const accounts = readFileSync('/etc/passwd', 'utf8').split('\n').map(line => line.split(':'));
|
|
25
|
+
for (const name of ['postgres', 'nobody']) {
|
|
26
|
+
const account = accounts.find(fields => fields[0] === name && /^\d+$/.test(fields[2] ?? '') && /^\d+$/.test(fields[3] ?? '')
|
|
27
|
+
&& Number(fields[2]) > 0 && Number(fields[2]) < 0xffffffff && Number(fields[3]) > 0 && Number(fields[3]) < 0xffffffff);
|
|
28
|
+
if (account) return { uid: Number(account[2]), gid: Number(account[3]) };
|
|
29
|
+
}
|
|
30
|
+
throw new Error('native PostgreSQL started by root requires an installed non-root postgres or nobody account');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The prepared cluster copied as the server's own (S2 R15): each directory, file and link made, its mode taken from
|
|
35
|
+
* the source as cpSync takes it, and given to the server's uid and gid as it is made. A cpSync and then
|
|
36
|
+
* ownPostgresCluster's walk asked the tree for every one of the cluster's files twice more (Cal in a tab: the
|
|
37
|
+
* placing step 170 ms, each ask a kernel request from up's process).
|
|
38
|
+
*/
|
|
39
|
+
function copyClusterOwned(runAs: { uid: number; gid: number }): (from: string, to: string) => void {
|
|
40
|
+
const copy = (from: string, to: string): void => {
|
|
41
|
+
const stat = lstatSync(from);
|
|
42
|
+
if (stat.isSymbolicLink()) { symlinkSync(readlinkSync(from), to); lchownSync(to, runAs.uid, runAs.gid); return; }
|
|
43
|
+
if (stat.isDirectory()) {
|
|
44
|
+
mkdirSync(to);
|
|
45
|
+
for (const name of readdirSync(from)) copy(join(from, name), join(to, name));
|
|
46
|
+
} else if (stat.isFile()) copyFileSync(from, to);
|
|
47
|
+
else throw new Error(`native PostgreSQL's prepared cluster holds a ${stat.isFIFO() ? 'FIFO' : 'special file'}, which is not copied: ${from}`);
|
|
48
|
+
chmodSync(to, stat.mode & 0o7777);
|
|
49
|
+
chownSync(to, runAs.uid, runAs.gid);
|
|
50
|
+
};
|
|
51
|
+
return copy;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Assign only the stopped World's cluster, including a cluster placed from preparation. Never follow links. */
|
|
55
|
+
function ownPostgresCluster(data: string, runAs: { uid: number; gid: number }): { walked: number; changed: number } {
|
|
56
|
+
// What the walk did, said by the start's step (S2 R71): every entry is looked at, and only another owner's changed.
|
|
57
|
+
const counts = { walked: 0, changed: 0 };
|
|
58
|
+
const visit = (path: string): void => {
|
|
59
|
+
const stat = lstatSync(path);
|
|
60
|
+
counts.walked += 1;
|
|
61
|
+
if (stat.uid !== runAs.uid || stat.gid !== runAs.gid) {
|
|
62
|
+
counts.changed += 1;
|
|
63
|
+
if (stat.isSymbolicLink()) lchownSync(path, runAs.uid, runAs.gid);
|
|
64
|
+
else chownSync(path, runAs.uid, runAs.gid);
|
|
65
|
+
}
|
|
66
|
+
if (stat.isDirectory()) for (const name of readdirSync(path)) visit(join(path, name));
|
|
67
|
+
};
|
|
68
|
+
visit(data);
|
|
69
|
+
return counts;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The service may traverse its own World bookkeeping directories, without reading their private receipts. */
|
|
73
|
+
function postgresDirectorySearch(root: string, env: NodeJS.ProcessEnv, runAs: { uid: number; gid: number }): void {
|
|
74
|
+
const directories = [root];
|
|
75
|
+
// The live lifecycle creates instance/data as 0700. Only its recorded instance is granted search;
|
|
76
|
+
// preparation has a different root and never changes the application's directory above it.
|
|
77
|
+
if (env.VOLTER_WORLD_INSTANCE && env.VOLTER_WORLD_DATA
|
|
78
|
+
&& resolve(env.VOLTER_WORLD_DATA) === resolve(root)
|
|
79
|
+
&& dirname(resolve(env.VOLTER_WORLD_INSTANCE)) === dirname(resolve(root))) directories.push(dirname(resolve(root)));
|
|
80
|
+
for (const directory of directories) {
|
|
81
|
+
const stat = lstatSync(directory);
|
|
82
|
+
if (!stat.isDirectory()) throw new Error(`native PostgreSQL directory is not a directory: ${directory}`);
|
|
83
|
+
if ((stat.uid === runAs.uid && (stat.mode & 0o100)) || (stat.gid === runAs.gid && (stat.mode & 0o010)) || (stat.mode & 0o001)) continue;
|
|
84
|
+
chownSync(directory, stat.uid, runAs.gid);
|
|
85
|
+
chmodSync(directory, (stat.mode & 0o7777) | 0o010);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
15
88
|
|
|
16
89
|
interface Ownership { pid: number; data: string; port: number; binary: string }
|
|
17
90
|
interface Started { child: ChildProcess; owned: Ownership; closed: boolean; identity?: SpawnIdentity; birth?: Promise<SpawnIdentity | undefined>; error?: Error }
|
|
@@ -46,11 +119,58 @@ function ownership(root: string) {
|
|
|
46
119
|
}
|
|
47
120
|
return { present: records.some(row => row.present) || spawn.present, held: values.find(row => row.pid === spawn.value?.pid) ?? values[0], identity: spawn.value, values, errors };
|
|
48
121
|
}
|
|
49
|
-
|
|
50
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Whether postmaster.pid names the postmaster this lifecycle holds. The postmaster CREATES that file and then writes
|
|
124
|
+
* it, and removes it as it exits: a reader meets it empty, part written, or gone. Those are not a cluster owned by
|
|
125
|
+
* another; they are a file not written yet (false: ask again). A mismatch is four COMPLETE lines of which the pid,
|
|
126
|
+
* the data directory or the port differs, and it is refused with what was read beside what was held.
|
|
127
|
+
*/
|
|
128
|
+
function verifyOwner(held: Ownership): boolean {
|
|
129
|
+
let text: string;
|
|
130
|
+
try { text = readFileSync(join(held.data, 'postmaster.pid'), 'utf8'); } catch { return false; }
|
|
131
|
+
const lines = text.split('\n');
|
|
132
|
+
// Lines 1-4 are pid, data directory, start time, port; each is complete when a line follows it.
|
|
133
|
+
if (lines.length < 5 || lines.slice(0, 4).some((line) => line.trim() === '')) return false;
|
|
51
134
|
if (Number(lines[0]) !== held.pid || resolve(lines[1]!) !== held.data || Number(lines[3]) !== held.port) {
|
|
52
|
-
|
|
135
|
+
// A FILE LEFT BY A POSTMASTER THAT IS GONE IS STALE, NOT ANOTHER OWNER. After a kill or a crash the file stays,
|
|
136
|
+
// four complete lines naming a pid with no process; the postmaster just started reads it, finds that, and
|
|
137
|
+
// writes its own. Read in between, it names neither this postmaster nor a live one: not written yet. Only a
|
|
138
|
+
// file naming a LIVE process that is not this one, or this one with another directory or port, is a mismatch.
|
|
139
|
+
const named = Number(lines[0]);
|
|
140
|
+
if (named !== held.pid && Number.isInteger(named) && named > 0) {
|
|
141
|
+
let gone = false;
|
|
142
|
+
try { process.kill(named, 0); } catch (error) { gone = (error as NodeJS.ErrnoException).code === 'ESRCH'; }
|
|
143
|
+
if (gone) return false;
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`native postgres ownership does not match the cluster; teardown refused (postmaster.pid says pid ${lines[0]}, data ${lines[1]}, port ${lines[3]}; this lifecycle holds pid ${held.pid}, data ${held.data}, port ${held.port})`);
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* THE POSTMASTER'S STOP. PostgreSQL's contract for a stop is one signal to the postmaster (SIGINT, its fast
|
|
151
|
+
* shutdown; what pg_ctl sends): it ends its backends itself, writes the shutdown checkpoint and removes
|
|
152
|
+
* postmaster.pid. A signal to its GROUP reaches the checkpointer and the writers too, which are not the ones to
|
|
153
|
+
* tell. Between the signal and its exit the postmaster is writing the cluster down, and how long that takes is the
|
|
154
|
+
* size of what is dirty, so it is awaited on the lifecycle's own budget (READY_TIMEOUT_MS), never a command's
|
|
155
|
+
* grace: a SIGKILL in the middle of the checkpoint leaves a cluster with its pid file, which nothing takes. Only
|
|
156
|
+
* past that budget is the group killed, and that is said as a kill. The stop, the exit, and the time between are
|
|
157
|
+
* events in the World's log.
|
|
158
|
+
*/
|
|
159
|
+
async function stopPostmaster(pid: number, deliver: (signal: NodeJS.Signals, group: boolean) => Promise<boolean> | boolean, by: string, data?: string): Promise<void> {
|
|
160
|
+
if (!liveGroup(pid)) return;
|
|
161
|
+
const asked = Date.now();
|
|
162
|
+
worldEvent({ event: 'world.service.stop', service: 'postgres', by, signal: 'SIGINT', to: 'pid', pid });
|
|
163
|
+
await deliver('SIGINT', false);
|
|
164
|
+
const deadline = asked + READY_TIMEOUT_MS;
|
|
165
|
+
while (liveGroup(pid) && Date.now() < deadline) await wait(100);
|
|
166
|
+
if (!liveGroup(pid)) {
|
|
167
|
+
worldEvent({ event: 'world.service.exited', service: 'postgres', by, pid, shutdownMs: Date.now() - asked, ...(data ? { pidFileLeft: existsSync(join(data, 'postmaster.pid')) } : {}) });
|
|
168
|
+
return;
|
|
53
169
|
}
|
|
170
|
+
worldEvent({ event: 'world.service.killed', service: 'postgres', by, pid, signal: 'SIGKILL', to: 'group', afterMs: Date.now() - asked, doing: 'its shutdown had not ended within the lifecycle budget; the cluster is left with its pid file and is not taken' });
|
|
171
|
+
await deliver('SIGKILL', true);
|
|
172
|
+
const confirm = Date.now() + COMMAND_GRACE_MS;
|
|
173
|
+
while (liveGroup(pid) && Date.now() < confirm) await wait(20);
|
|
54
174
|
}
|
|
55
175
|
const liveGroup = (pid: number) => pid > 0 && survivingOwnedGroups([pid]).length > 0;
|
|
56
176
|
function removeReceipts(root: string): void {
|
|
@@ -65,6 +185,11 @@ function removeReceipts(root: string): void {
|
|
|
65
185
|
}
|
|
66
186
|
if (errors.length) throw new AggregateError(errors, 'native postgres retired but receipt cleanup failed');
|
|
67
187
|
}
|
|
188
|
+
async function retirePostmaster(identity: SpawnIdentity, by: string, data?: string): Promise<void> {
|
|
189
|
+
await stopPostmaster(identity.pid, (signal, group) => signalOwnedProcess(identity, signal, group, by), by, data);
|
|
190
|
+
if (liveGroup(identity.pid)) throw new Error(`native postgres group ${identity.pid} retirement unconfirmed; ownership retained`);
|
|
191
|
+
}
|
|
192
|
+
/** A helper this backing started (psql, createdb, initdb): it holds no state, and keeps a command's grace. */
|
|
68
193
|
async function retireIdentity(identity: SpawnIdentity): Promise<void> {
|
|
69
194
|
const { pid } = identity;
|
|
70
195
|
for (const signal of ['SIGINT', 'SIGKILL'] as const) {
|
|
@@ -76,19 +201,17 @@ async function retireIdentity(identity: SpawnIdentity): Promise<void> {
|
|
|
76
201
|
if (liveGroup(pid)) throw new Error(`native postgres group ${pid} retirement unconfirmed; ownership retained`);
|
|
77
202
|
}
|
|
78
203
|
/** A retained child is ownership even before postmaster.pid or the ready receipt exists. */
|
|
79
|
-
async function retireStarted(started: Started): Promise<void> {
|
|
204
|
+
async function retireStarted(started: Started, by = 'down (this process started it)'): Promise<void> {
|
|
80
205
|
const { child, owned } = started;
|
|
81
206
|
child.ref();
|
|
82
|
-
|
|
207
|
+
if (owned.pid > 0) {
|
|
208
|
+
await stopPostmaster(owned.pid, async (signal, group) => signalChild(child, signal, group, by)
|
|
209
|
+
|| (liveGroup(owned.pid) ? signalOwnedProcess(started.identity ?? await started.birth, signal, group, by) : false), by, owned.data);
|
|
210
|
+
}
|
|
211
|
+
const deadline = Date.now() + COMMAND_GRACE_MS;
|
|
212
|
+
while (Date.now() < deadline) {
|
|
83
213
|
if (started.closed && !liveGroup(owned.pid)) return;
|
|
84
|
-
|
|
85
|
-
await signalOwnedProcess(started.identity ?? await started.birth, signal, true);
|
|
86
|
-
}
|
|
87
|
-
const deadline = Date.now() + COMMAND_GRACE_MS;
|
|
88
|
-
while (Date.now() < deadline) {
|
|
89
|
-
if (started.closed && !liveGroup(owned.pid)) return;
|
|
90
|
-
await wait(20);
|
|
91
|
-
}
|
|
214
|
+
await wait(20);
|
|
92
215
|
}
|
|
93
216
|
throw new Error(`native postgres child ${owned.pid} retirement unconfirmed; ownership retained`);
|
|
94
217
|
}
|
|
@@ -120,11 +243,11 @@ function logTail(log: string): string {
|
|
|
120
243
|
try { return streamTail(`log ${log}`, readFileSync(log, 'utf8')); } catch { return ''; }
|
|
121
244
|
}
|
|
122
245
|
/** Every helper joins exit and its private group, including spawn errors and abort. */
|
|
123
|
-
async function tool(command: string, args: string[], env: NodeJS.ProcessEnv, deadline: number, root: string, signal?: AbortSignal): Promise<string> {
|
|
246
|
+
async function tool(command: string, args: string[], env: NodeJS.ProcessEnv, deadline: number, root: string, runAs: { uid: number; gid: number }, signal?: AbortSignal): Promise<string> {
|
|
124
247
|
signal?.throwIfAborted();
|
|
125
248
|
const remaining = deadline - Date.now();
|
|
126
249
|
if (remaining <= 0) throw new Error(`native postgres readiness deadline elapsed before ${command}`);
|
|
127
|
-
const child = spawn(command, args, { env, detached: true, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
250
|
+
const child = spawn(command, args, { env, ...runAs, detached: true, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
128
251
|
const lifetime = commandLifetime(child, signal);
|
|
129
252
|
let output = ''; let errorOutput = ''; let spawnError: Error | undefined; let timedOut = false; let failure: unknown;
|
|
130
253
|
child.stdout?.on('data', chunk => { output += chunk; });
|
|
@@ -213,6 +336,18 @@ export function nativePostgresTools(env: NodeJS.ProcessEnv): string | undefined
|
|
|
213
336
|
return undefined;
|
|
214
337
|
}
|
|
215
338
|
|
|
339
|
+
/**
|
|
340
|
+
* How a cluster left running is made durable before its recovery: one syncfs of its file system, where PostgreSQL's
|
|
341
|
+
* default opens and fsyncs every file under the data directory. A World's postmaster is not stopped when its tab
|
|
342
|
+
* closes, so a returning visit recovers: the Umami f7b17202 pair's second visit spent 652 ms in the startup process
|
|
343
|
+
* (3,078 opens, 1,496 fsyncs, 244 directory reads), where the first's took 77 ms, and was ready at check 8, not 5
|
|
344
|
+
* (S2 L1). syncfs is Linux's (PostgreSQL builds it where the C library has it: glibc, and the musl the tab's postgres
|
|
345
|
+
* is built with); macOS has none, and an option its build lacks is refused at start, so only Linux asks for it.
|
|
346
|
+
*/
|
|
347
|
+
function recoverySync(): string[] {
|
|
348
|
+
return process.platform === 'linux' ? ['-c', 'recovery_init_sync_method=syncfs'] : [];
|
|
349
|
+
}
|
|
350
|
+
|
|
216
351
|
/** initdb's arguments after `-D`, the same at up and at prepare, and so in the stamp. */
|
|
217
352
|
function initdbArguments(user: string): string[] {
|
|
218
353
|
return [`--username=${user}`, '--auth=trust', '--no-locale', '--encoding=UTF8'];
|
|
@@ -294,6 +429,131 @@ function nativeStampMatches(text: string, expected: NativeStamp): boolean {
|
|
|
294
429
|
* step runs against that postmaster before its stop, and a step that fails leaves no cluster. The image build runs it with the programs the tab
|
|
295
430
|
* runs, under the tab's kernel, so the cluster is the one a first boot there would write.
|
|
296
431
|
*/
|
|
432
|
+
/** Every log file under a state directory, as one text with each file's path before its lines; empty when none. */
|
|
433
|
+
function worldLogs(state: string): string {
|
|
434
|
+
const out: string[] = [];
|
|
435
|
+
const walk = (directory: string): void => {
|
|
436
|
+
let names: string[];
|
|
437
|
+
try { names = readdirSync(directory).sort(); } catch { return; }
|
|
438
|
+
for (const name of names) {
|
|
439
|
+
const path = join(directory, name);
|
|
440
|
+
let stats;
|
|
441
|
+
try { stats = statSync(path); } catch { continue; }
|
|
442
|
+
if (stats.isDirectory()) { if (name !== 'native-postgres-data' && name !== 'node_modules') walk(path); }
|
|
443
|
+
else if (stats.isFile() && /\.log$/.test(name) && stats.size <= 8 * 1024 * 1024) {
|
|
444
|
+
try { out.push(`==> ${path} <==\n${readFileSync(path, 'utf8')}`); } catch { /* a log that cannot be read is left out */ }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
walk(state);
|
|
449
|
+
return out.join('\n');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A managed store, other than the cluster, that keeps its bytes under the World's data and is staged with it: the
|
|
454
|
+
* tree of a service a twin serves, which twin-backing.ts names `<kind>-twin` (redis-twin).
|
|
455
|
+
*/
|
|
456
|
+
const stagedStore = (name: string): boolean => /^[a-z0-9]+-twin$/.test(name);
|
|
457
|
+
|
|
458
|
+
/** The state directory a preparation with the World up boots its disposable instance in, beside the World's own. */
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A cluster migrated by a step that needs the World (`runtime.database.prepare.with: "world"`): a migration that
|
|
462
|
+
* talks to a service the World supplies (a cache it flushes, a queue it registers with, a vendor it reads) cannot
|
|
463
|
+
* run against a bare postmaster. The World is brought up whole, as `run` brings it up, in a state directory of its
|
|
464
|
+
* own beside the World's (`.volter-prepare`), with no prepared cluster to take, so its managed PostgreSQL is made
|
|
465
|
+
* new by initdb; the step is the World's foreground command, in the app's tree, with the image's environment under
|
|
466
|
+
* the World's own (the names its twins issued and its infrastructure discovered); the World then goes down, which
|
|
467
|
+
* stops the postmaster cleanly. The stopped cluster is moved to the World's prepared place and stamped with the
|
|
468
|
+
* step, its inputs' digests and the twins that were up. Each other managed store that keeps its bytes under the
|
|
469
|
+
* World's data (the tree of each service a twin serves, `<kind>-twin`: the Redis twin's root) is staged beside the cluster with the same migration in a
|
|
470
|
+
* stamp of its own, since what the step wrote there is the World's prepared state as much as its tables are.
|
|
471
|
+
* Everything else that instance wrote (a twin's own state, its logs) is named in the note and removed with the
|
|
472
|
+
* state directory. A step that fails leaves no cluster, keeps the step's whole output where a failed preparation
|
|
473
|
+
* keeps its evidence, and says so.
|
|
474
|
+
*/
|
|
475
|
+
async function prepareWithWorld(step: WorldDatabasePrepare, definition: string, worldConfig: string, tools: string, env: NodeJS.ProcessEnv, launch: HostLaunch): Promise<string> {
|
|
476
|
+
const appRoot = env.VOLTER_WORLD_APP_ROOT;
|
|
477
|
+
if (!appRoot) throw new Error('the World declares runtime.database.prepare: VOLTER_WORLD_APP_ROOT must name the app image\'s root');
|
|
478
|
+
const root = dirname(worldConfig);
|
|
479
|
+
const { directory, stamp } = nativePreparedCluster(worldConfig);
|
|
480
|
+
const state = join(root, PREPARE_STATE_DIRECTORY);
|
|
481
|
+
rmSync(directory, { recursive: true, force: true });
|
|
482
|
+
rmSync(stamp, { force: true });
|
|
483
|
+
rmSync(state, { recursive: true, force: true });
|
|
484
|
+
// The other stores a step run with the World up may write, staged beside the cluster (architecture, "A step may
|
|
485
|
+
// need the World"): each by the name of its directory under the World's data.
|
|
486
|
+
const previouslyStaged = (): string[] => { try { return readdirSync(dirname(directory)).filter(stagedStore); } catch { return []; } };
|
|
487
|
+
const unstage = (): void => { for (const name of previouslyStaged()) { const store = preparedStore(worldConfig, name); rmSync(store.directory, { recursive: true, force: true }); rmSync(store.stamp, { force: true }); } };
|
|
488
|
+
unstage();
|
|
489
|
+
const cwd = join(appRoot, step.cwd);
|
|
490
|
+
const { user, database } = settings(definition);
|
|
491
|
+
// The World's run reads this process's environment: the preparation's own (its PostgreSQL programs, the app's
|
|
492
|
+
// root), the image's values over it as for a step run alone, and the state directory. This process is the
|
|
493
|
+
// preparation and nothing else, so its environment is the preparation's to set.
|
|
494
|
+
const { PATH: _imagePath, ...imageValues } = env.VOLTER_WORLD_APP_ENV ? environmentFile(env.VOLTER_WORLD_APP_ENV) : {};
|
|
495
|
+
const before = { ...process.env };
|
|
496
|
+
for (const [name, value] of Object.entries({ ...env, ...imageValues, VOLTER_STATE_DIR: PREPARE_STATE_DIRECTORY })) if (value !== undefined) process.env[name] = value;
|
|
497
|
+
// The step is a package script's command here too, and is found where the application's own start finds it
|
|
498
|
+
// (pglite-backing.ts runDatabasePrepare): each node_modules/.bin from its working directory upward, before PATH.
|
|
499
|
+
process.env.PATH = [...packageScriptPath(cwd, appRoot), process.env.PATH ?? ''].filter(Boolean).join(delimiter);
|
|
500
|
+
delete process.env.VOLTER_WORLD_CONFIG;
|
|
501
|
+
const names = join(state, 'prepare.env');
|
|
502
|
+
let moved = false;
|
|
503
|
+
try {
|
|
504
|
+
const { runWorldOwned } = await import('./runtime.ts');
|
|
505
|
+
let ran: Awaited<ReturnType<typeof runWorldOwned>>;
|
|
506
|
+
try { ran = await runWorldOwned(worldConfig, step.command, { root, cwd, owner: 'database-prepare', envFile: names, forceEnvOutput: true }, launch.signal); }
|
|
507
|
+
catch (error) { throw new Error(`the World's database prepare step (${step.command.join(' ')}), run with the World up, did not complete: ${errorMessage(error)}`, { cause: error }); }
|
|
508
|
+
const log = ran.outcome.log, output = log && existsSync(log) ? readFileSync(log, 'utf8') : '';
|
|
509
|
+
if (ran.exitCode !== 0) {
|
|
510
|
+
throw new Error(`the World's database prepare step (${step.command.join(' ')}), run with the World up, exited ${ran.exitCode}\n${output.trim().split('\n').slice(-40).join('\n')}${keepFailureEvidence('database-prepare-step-output', output, env)}`);
|
|
511
|
+
}
|
|
512
|
+
const data = join(ran.instance.dirs.data, 'native-postgres-data');
|
|
513
|
+
if (!existsSync(join(data, 'PG_VERSION'))) throw new Error(`the World came up without a native PostgreSQL cluster at ${data}: a step declared with: "world" is prepared on the native backing only`);
|
|
514
|
+
if (existsSync(join(data, 'postmaster.pid'))) throw new Error('the World went down and left its postmaster\'s pid file: the cluster was not stopped cleanly and is not taken');
|
|
515
|
+
const given = existsSync(names) ? readFileSync(names, 'utf8').split('\n').map((line) => /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/.exec(line)?.[1]).filter((name): name is string => name !== undefined) : [];
|
|
516
|
+
const world = preparedWorldServices(worldConfig);
|
|
517
|
+
mkdirSync(dirname(directory), { recursive: true });
|
|
518
|
+
renameSync(data, directory);
|
|
519
|
+
moved = true;
|
|
520
|
+
const migration: PreparedMigration = { command: [...step.command], inputs: migrationInputDigests(cwd, step.inputs), world };
|
|
521
|
+
const staged: string[] = [];
|
|
522
|
+
for (const name of readdirSync(ran.instance.dirs.data).filter(stagedStore).sort()) {
|
|
523
|
+
const from = join(ran.instance.dirs.data, name), store = preparedStore(worldConfig, name);
|
|
524
|
+
if (!statSync(from).isDirectory() || !readdirSync(from).length) continue;
|
|
525
|
+
// The twin that served this root is gone when its pid file is: the World's down waits for it and removes the
|
|
526
|
+
// file (twin-backing.ts). One left behind is a twin that may still be writing, as a postmaster's is above.
|
|
527
|
+
const left = readdirSync(ran.instance.dirs.data).filter((entry) => entry.startsWith(`${name}-`) && entry.endsWith('.pid'));
|
|
528
|
+
if (left.length) throw new Error(`the World went down and left ${left.join(', ')}: the ${name} was not stopped cleanly and its root is not staged`);
|
|
529
|
+
renameSync(from, store.directory);
|
|
530
|
+
writeFileSync(store.stamp, preparedStoreStamp(migration));
|
|
531
|
+
staged.push(name);
|
|
532
|
+
}
|
|
533
|
+
// Everything else the instance left under its data, by name: not staged, and gone with the state directory.
|
|
534
|
+
const notStaged = readdirSync(ran.instance.dirs.data).filter((name) => !/\.(?:log|pid|json)$/.test(name) && !/^(?:logs?|run)$/.test(name)).sort();
|
|
535
|
+
// The cluster's stamp is written LAST: a preparation killed before here leaves a cluster nothing takes, never a
|
|
536
|
+
// stamped cluster whose stores did not arrive.
|
|
537
|
+
writeFileSync(stamp, `${JSON.stringify(nativeStamp(tools, user, database, true, migration))}\n`);
|
|
538
|
+
launch.note?.(`prepared stores beside the cluster: ${staged.join(', ') || 'none'}${notStaged.length ? `; left under the instance's data and not staged: ${notStaged.join(', ')}` : ''}`);
|
|
539
|
+
launch.note?.(`database prepare step, with the World up (${Object.entries(world).map(([id, version]) => `${id}@${version}`).join(', ') || 'no twins'}; ${given.length} names given: ${given.join(', ')}): ${step.command.join(' ')}\n${output.trim()}`);
|
|
540
|
+
return directory;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (moved) { rmSync(directory, { recursive: true, force: true }); rmSync(stamp, { force: true }); }
|
|
543
|
+
unstage();
|
|
544
|
+
// The instance's own logs go with the state directory below, and they are where a World that did not come up
|
|
545
|
+
// says why (the co-located host's output, a managed service's log, the lifecycle log). They are kept first,
|
|
546
|
+
// whole, where a failed preparation keeps its evidence, and the error names the file.
|
|
547
|
+
const logs = worldLogs(state);
|
|
548
|
+
if (logs && error instanceof Error) error.message += keepFailureEvidence('database-prepare-world-logs', logs, env);
|
|
549
|
+
throw error;
|
|
550
|
+
} finally {
|
|
551
|
+
rmSync(state, { recursive: true, force: true });
|
|
552
|
+
for (const name of Object.keys(process.env)) if (!(name in before)) delete process.env[name];
|
|
553
|
+
Object.assign(process.env, before);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
297
557
|
export async function nativePrepare(service: InfraService, definition: string, worldConfig: string, launch: HostLaunch = {}): Promise<string> {
|
|
298
558
|
if (service.kind !== 'postgres') throw new Error('only a declared PostgreSQL is prepared');
|
|
299
559
|
const env: NodeJS.ProcessEnv = { ...(launch.env ?? process.env) };
|
|
@@ -301,6 +561,8 @@ export async function nativePrepare(service: InfraService, definition: string, w
|
|
|
301
561
|
delete env.VOLTER_WORLD_CONFIG;
|
|
302
562
|
const tools = nativePostgresTools(env);
|
|
303
563
|
if (!tools) throw new Error('native prepare requires the PostgreSQL programs: a postgres server on PATH, or VOLTER_WORLD_POSTGRES_BIN');
|
|
564
|
+
const withWorld = databasePrepareOf(worldConfig);
|
|
565
|
+
if (withWorld?.with === 'world') return prepareWithWorld(withWorld, definition, worldConfig, tools, env, launch);
|
|
304
566
|
const { directory, stamp } = nativePreparedCluster(worldConfig);
|
|
305
567
|
const root = dirname(directory);
|
|
306
568
|
mkdirSync(root, { recursive: true });
|
|
@@ -331,13 +593,7 @@ export async function nativePrepare(service: InfraService, definition: string, w
|
|
|
331
593
|
}
|
|
332
594
|
// The clean stop: SIGINT is the postmaster's fast shutdown, which writes its checkpoint and removes postmaster.pid.
|
|
333
595
|
// Its wait is the lifecycle's own readiness budget, not a new limit; down then clears the receipts.
|
|
334
|
-
|
|
335
|
-
if (identity) {
|
|
336
|
-
await signalOwnedProcess(identity, 'SIGINT', true);
|
|
337
|
-
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
338
|
-
while (liveGroup(identity.pid) && Date.now() < deadline) await wait(100);
|
|
339
|
-
}
|
|
340
|
-
await nativePostgresDown(root);
|
|
596
|
+
await nativePostgresDown(root, 'prepare (the cluster is made; its clean stop)');
|
|
341
597
|
if (stepFailure) {
|
|
342
598
|
rmSync(directory, { recursive: true, force: true });
|
|
343
599
|
throw failed(stepFailure.error);
|
|
@@ -351,9 +607,21 @@ export async function nativePrepare(service: InfraService, definition: string, w
|
|
|
351
607
|
return directory;
|
|
352
608
|
}
|
|
353
609
|
|
|
610
|
+
/**
|
|
611
|
+
* How often the start looks for the postmaster's readiness (postmaster.pid's status line, then its port). Each look
|
|
612
|
+
* is two file reads; at 100 ms a start waited up to 100 ms past a ready server (Cal in a tab: ready at check 8, 1,211
|
|
613
|
+
* ms). The postmaster says it no other way a parent can wait on: its log is a file this start does not hold open.
|
|
614
|
+
*/
|
|
615
|
+
const READY_CHECK_MS = 20;
|
|
616
|
+
|
|
354
617
|
export async function nativePostgresUp(services: InfraService[], root: string, definition: string, launch: HostLaunch): Promise<void> {
|
|
355
618
|
if (services.length !== 1 || services[0]!.kind !== 'postgres') throw new Error('native postgres requires one declared postgres service');
|
|
356
619
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
620
|
+
// EACH STEP OF THIS START SAYS ITS OWN TIME AND WHAT IT WAITED ON, as a note the World's up prints on its own
|
|
621
|
+
// clock. In a tab the whole start took ten seconds of a World's twelve (Twenty: backing chosen at +1.29 s, ready at
|
|
622
|
+
// +11.72 s) and one line, the cluster's placing, said where any of it went.
|
|
623
|
+
let stepBegan = performance.now();
|
|
624
|
+
const step = (what: string): void => { const now = performance.now(); launch.note?.(`native PostgreSQL: ${what}, ${Math.round(now - stepBegan)} ms`); stepBegan = now; };
|
|
357
625
|
const env: NodeJS.ProcessEnv = { ...(launch.env ?? process.env), LC_ALL: launch.env?.LC_ALL || 'C' };
|
|
358
626
|
const binaries = nativePostgresTools(env);
|
|
359
627
|
if (!binaries) throw new Error('native postgres requires an installed PostgreSQL: a postgres server on PATH, or VOLTER_WORLD_POSTGRES_BIN naming its tool directory');
|
|
@@ -373,12 +641,19 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
|
|
|
373
641
|
await retireTools(root);
|
|
374
642
|
const service = services[0]!;
|
|
375
643
|
if (await listening(service.hostPort, deadline, launch.signal)) throw new Error(`native postgres port ${service.hostPort} is already occupied`);
|
|
644
|
+
step(`an earlier start's programs retired and port ${service.hostPort} found free (one connection refused)`);
|
|
376
645
|
const data = resolve(root, 'native-postgres-data');
|
|
377
646
|
const { user, database } = settings(definition);
|
|
378
647
|
// An empty data directory takes the World's prepared cluster when its stamp matches (ADR 0018's placement; the
|
|
379
648
|
// cluster was made by these same programs at image build). initdb then finds PG_VERSION, and the database exists.
|
|
380
649
|
forgetStaleMigration(data);
|
|
381
650
|
const prepared = env.VOLTER_WORLD_CONFIG ? nativePreparedCluster(env.VOLTER_WORLD_CONFIG) : undefined;
|
|
651
|
+
// Placed this start under a stamp that names its database: the cluster holds it (S2 R15).
|
|
652
|
+
let placedDatabase = false;
|
|
653
|
+
// Copied as the server's own, every file already its: the walk below would find nothing to change.
|
|
654
|
+
let ownedAsCopied = false;
|
|
655
|
+
// Moved in whole this start, as the image laid it (S2 B2): owned by the owner the image names, not to be walked.
|
|
656
|
+
let movedAsLaid = false;
|
|
382
657
|
// The check reads two files' metadata and the stamp; it runs only when a prepared cluster is there to take.
|
|
383
658
|
if (prepared && existsSync(join(prepared.directory, 'PG_VERSION')) && !existsSync(join(data, 'PG_VERSION'))) {
|
|
384
659
|
// A migrated cluster is the app's only for the step inputs it was migrated from (pglite-backing.ts, expectedMigration).
|
|
@@ -387,13 +662,33 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
|
|
|
387
662
|
else {
|
|
388
663
|
const expected = nativeStamp(binaries, user, database, false, wanted.migration);
|
|
389
664
|
const placed = takePreparedCluster(prepared, text => nativeStampMatches(text, expected), data,
|
|
390
|
-
'other PostgreSQL programs, initdb arguments, identity or migration', wanted.migration);
|
|
665
|
+
'other PostgreSQL programs, initdb arguments, identity or migration', wanted.migration, copyClusterOwned(postgresRunAs()));
|
|
666
|
+
ownedAsCopied = placed.copied === true;
|
|
667
|
+
movedAsLaid = placed.placed && placed.copied !== true;
|
|
391
668
|
if (placed.note) launch.note?.(placed.note);
|
|
669
|
+
placedDatabase = placed.placed;
|
|
392
670
|
}
|
|
393
671
|
}
|
|
672
|
+
// The placement's own parts, each a step (S2 R71: Cal's 195-206 ms was one line): the stamp's check and the move,
|
|
673
|
+
// then the server's directories and ownership with what the walk looked at and changed.
|
|
674
|
+
step(placedDatabase ? 'the prepared cluster\'s stamp read and matched, and the cluster moved into place' : 'the prepared cluster looked for (none placed)');
|
|
394
675
|
mkdirSync(data, { recursive: true, mode: 0o700 });
|
|
676
|
+
const runAs = postgresRunAs();
|
|
677
|
+
postgresDirectorySearch(root, env, runAs);
|
|
678
|
+
// A cluster this start moved in whole from the prepared tree is owned as the image laid it. Where the page says the
|
|
679
|
+
// image laid every entry of that cluster with one owner, and that owner is the server's (VOLTER_WORLD_PREPARED_CLUSTER_
|
|
680
|
+
// OWNER, given only where its open placed the World's tree with the prepared project), the walk could change nothing
|
|
681
|
+
// and is not run (S2 B2: 134-206 ms of lstats on Cal and Twenty). Any other cluster, or no such word, is walked.
|
|
682
|
+
const laid = /^(\d+):(\d+)$/u.exec(env.VOLTER_WORLD_PREPARED_CLUSTER_OWNER ?? '');
|
|
683
|
+
const ownedAsLaid = movedAsLaid && laid !== null && Number(laid[1]) === runAs.uid && Number(laid[2]) === runAs.gid;
|
|
684
|
+
const owned = ownedAsCopied || ownedAsLaid ? undefined : ownPostgresCluster(data, runAs);
|
|
685
|
+
launch.note?.(`native PostgreSQL: child uid ${runAs.uid}, gid ${runAs.gid}; cluster ${data}`);
|
|
686
|
+
step(ownedAsCopied ? 'the prepared cluster looked for and copied as the server\'s own'
|
|
687
|
+
: ownedAsLaid ? `the prepared cluster looked for and placed where one matches, laid as the server's own (${runAs.uid}:${runAs.gid}): not walked`
|
|
688
|
+
: `the prepared cluster looked for and placed where one matches, its files made the server's (${owned!.walked} entries walked, ${owned!.changed} changed)`);
|
|
395
689
|
if (!existsSync(join(data, 'PG_VERSION'))) {
|
|
396
|
-
await tool(join(binaries, 'initdb'), ['-D', data, ...initdbArguments(user)], env, deadline, root, launch.signal);
|
|
690
|
+
await tool(join(binaries, 'initdb'), ['-D', data, ...initdbArguments(user)], env, deadline, root, runAs, launch.signal);
|
|
691
|
+
step('initdb run to its end (no prepared cluster was placed)');
|
|
397
692
|
}
|
|
398
693
|
launch.signal?.throwIfAborted();
|
|
399
694
|
const binary = join(binaries, 'postgres');
|
|
@@ -407,7 +702,7 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
|
|
|
407
702
|
const fd = openSync(log, 'a');
|
|
408
703
|
let child: ChildProcess;
|
|
409
704
|
try {
|
|
410
|
-
child = spawn(binary, ['-D', data, '-h', '127.0.0.1', '-p', String(service.hostPort), '-k', ''], { env, detached: true, windowsHide: true, stdio: ['ignore', fd, fd] });
|
|
705
|
+
child = spawn(binary, ['-D', data, '-h', '127.0.0.1', '-p', String(service.hostPort), '-k', '', ...recoverySync()], { env, ...runAs, detached: true, windowsHide: true, stdio: ['ignore', fd, fd] });
|
|
411
706
|
current.pid = child.pid ?? 0;
|
|
412
707
|
started = { child, owned: current, closed: false };
|
|
413
708
|
const observed = started;
|
|
@@ -424,22 +719,36 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
|
|
|
424
719
|
if (started.error) throw started.error;
|
|
425
720
|
if (!child.pid) throw new Error('native postgres spawn failed');
|
|
426
721
|
publishProcessReceipt(recordPath(root), JSON.stringify(current) + '\n');
|
|
722
|
+
step('the postmaster spawned, its identity captured and its receipts written');
|
|
723
|
+
// The cluster is chosen and placed (takePreparedCluster's move or copy, its migration record written; or initdb's)
|
|
724
|
+
// and its server spawned: what a twin's staged store is placed beside is there now (S2 R79), before readiness.
|
|
725
|
+
launch.onPlaced?.();
|
|
726
|
+
let checks = 0;
|
|
427
727
|
for (;;) {
|
|
728
|
+
checks += 1;
|
|
428
729
|
launch.signal?.throwIfAborted();
|
|
429
730
|
if (started.error) throw started.error;
|
|
430
731
|
if (child.exitCode !== null || child.signalCode !== null || started.closed) throw new Error(`native postgres exited before readiness; see ${log}${logTail(log)}`);
|
|
431
732
|
if (Date.now() >= deadline) throw new Error(`native postgres did not become ready; see ${log}${logTail(log)}`);
|
|
432
|
-
if (existsSync(join(data, 'postmaster.pid'))) {
|
|
433
|
-
|
|
434
|
-
|
|
733
|
+
if (existsSync(join(data, 'postmaster.pid')) && verifyOwner(current)) {
|
|
734
|
+
// Read again for its status line; gone or shorter between the two reads is not ready yet.
|
|
735
|
+
let lines: string[] = [];
|
|
736
|
+
try { lines = readFileSync(join(data, 'postmaster.pid'), 'utf8').split('\n'); } catch { /* removed since the check above */ }
|
|
435
737
|
if (lines[7]?.trim() === 'ready' && await listening(service.hostPort, deadline, launch.signal)) break;
|
|
436
738
|
}
|
|
437
|
-
await wait(
|
|
739
|
+
await wait(READY_CHECK_MS);
|
|
438
740
|
}
|
|
439
|
-
|
|
741
|
+
step(`the postmaster ready: postmaster.pid says so and its port answers, at check ${checks} (a check every ${READY_CHECK_MS} ms)`);
|
|
742
|
+
// A cluster placed this start was matched on a stamp that names this database (nativeStampMatches), and made with
|
|
743
|
+
// it by these same programs at image build: asking the server again was a psql process of its own (Cal in a tab:
|
|
744
|
+
// 327 ms of a 2.3 s World up). A cluster from an earlier start, or initdb's, is still asked, and given the database
|
|
745
|
+
// where it lacks it.
|
|
746
|
+
if (placedDatabase && database !== 'postgres') step(`database ${database} is the placed cluster's, by its stamp: not asked`);
|
|
747
|
+
else if (database !== 'postgres') {
|
|
440
748
|
const sql = `SELECT 1 FROM pg_database WHERE datname = '${database.replaceAll("'", "''")}'`;
|
|
441
|
-
const output = await tool(join(binaries, 'psql'), ['-h', '127.0.0.1', '-p', String(service.hostPort), '-U', user, '-d', 'postgres', '-tAc', sql], env, deadline, root, launch.signal);
|
|
442
|
-
|
|
749
|
+
const output = await tool(join(binaries, 'psql'), ['-h', '127.0.0.1', '-p', String(service.hostPort), '-U', user, '-d', 'postgres', '-tAc', sql], env, deadline, root, runAs, launch.signal);
|
|
750
|
+
step(`psql run to its end, asking whether database ${database} is there`);
|
|
751
|
+
if (!output.trim()) { await tool(join(binaries, 'createdb'), ['-h', '127.0.0.1', '-p', String(service.hostPort), '-U', user, '--', database], env, deadline, root, runAs, launch.signal); step(`createdb run to its end`); }
|
|
443
752
|
}
|
|
444
753
|
launch.signal?.throwIfAborted();
|
|
445
754
|
if (child.exitCode !== null || child.signalCode !== null) throw new Error(`native postgres exited during readiness; see ${log}${logTail(log)}`);
|
|
@@ -449,7 +758,7 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
|
|
|
449
758
|
// The original publication/readiness failure survives every cleanup failure.
|
|
450
759
|
const failures: unknown[] = [error];
|
|
451
760
|
try {
|
|
452
|
-
if (started) await retireStarted(started);
|
|
761
|
+
if (started) await retireStarted(started, 'up (startup failed)');
|
|
453
762
|
removeReceipts(root);
|
|
454
763
|
starts.delete(root);
|
|
455
764
|
} catch (cleanup) { failures.push(cleanup); }
|
|
@@ -457,13 +766,15 @@ export async function nativePostgresUp(services: InfraService[], root: string, d
|
|
|
457
766
|
throw error;
|
|
458
767
|
}
|
|
459
768
|
}
|
|
460
|
-
|
|
769
|
+
/** The native postmaster's status proof: its ownership known, its held group live, its spawn identity and owner
|
|
770
|
+
* verified, and its port answering. `signal` ends the port's wait with the boot that asks (an up's hand-over). */
|
|
771
|
+
export async function nativePostgresStatus(root: string, signal?: AbortSignal): Promise<boolean> {
|
|
461
772
|
const state = ownership(root);
|
|
462
773
|
if (state.errors.length) throw new AggregateError(state.errors, 'native postgres ownership uncertain; retry down');
|
|
463
774
|
if (!state.held || !liveGroup(state.held.pid)) return false;
|
|
464
775
|
if (state.identity) await verifySpawnIdentity(state.identity);
|
|
465
|
-
verifyOwner(state.held);
|
|
466
|
-
return listening(state.held.port, Date.now() + READY_TIMEOUT_MS);
|
|
776
|
+
if (!verifyOwner(state.held)) return false;
|
|
777
|
+
return listening(state.held.port, Date.now() + READY_TIMEOUT_MS, signal);
|
|
467
778
|
}
|
|
468
779
|
|
|
469
780
|
async function retireTools(root: string): Promise<void> {
|
|
@@ -488,16 +799,17 @@ async function retireTools(root: string): Promise<void> {
|
|
|
488
799
|
rmSync(receipt, { force: true }); rmSync(spawnReceiptPath(receipt), { force: true });
|
|
489
800
|
}
|
|
490
801
|
}
|
|
491
|
-
export async function nativePostgresDown(root: string): Promise<void> {
|
|
802
|
+
export async function nativePostgresDown(root: string, by = 'down'): Promise<void> {
|
|
492
803
|
const started = starts.get(root);
|
|
493
804
|
const state = ownership(root);
|
|
805
|
+
const data = resolve(root, 'native-postgres-data');
|
|
494
806
|
if (state.errors.length) process.stderr.write(`${errorMessage(new AggregateError(state.errors, 'native postgres ownership uncertain; retrying retirement through established identity'))}\n`);
|
|
495
807
|
if (started) {
|
|
496
|
-
await retireStarted(started);
|
|
808
|
+
await retireStarted(started, by);
|
|
497
809
|
} else if (state.identity) {
|
|
498
810
|
if (liveGroup(state.identity.pid)) {
|
|
499
811
|
await verifySpawnIdentity(state.identity);
|
|
500
|
-
await
|
|
812
|
+
await retirePostmaster(state.identity, by, data);
|
|
501
813
|
}
|
|
502
814
|
} else if (state.held && state.held.pid > 0) {
|
|
503
815
|
if (liveGroup(state.held.pid)) {
|