@effectstream/db 0.200.5 → 0.300.0
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/package.json +6 -6
- package/src/snapshot-handler.ts +31 -2
- package/test/snapshot-lifecycle.test.ts +118 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.300.0",
|
|
4
4
|
"license": "(MIT OR Apache-2.0)",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
"./version": "./migrations/system-version.ts"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@effectstream/coroutine": "0.
|
|
25
|
-
"@effectstream/log": "0.
|
|
26
|
-
"@effectstream/runtime": "0.
|
|
27
|
-
"@effectstream/sync": "0.
|
|
28
|
-
"@effectstream/utils": "0.
|
|
24
|
+
"@effectstream/coroutine": "0.300.0",
|
|
25
|
+
"@effectstream/log": "0.300.0",
|
|
26
|
+
"@effectstream/runtime": "0.300.0",
|
|
27
|
+
"@effectstream/sync": "0.300.0",
|
|
28
|
+
"@effectstream/utils": "0.300.0",
|
|
29
29
|
"@electric-sql/pglite": "^0.3.14",
|
|
30
30
|
"@pgtyped/runtime": "2.4.2",
|
|
31
31
|
"@pgtyped/parser": "2.4.2",
|
package/src/snapshot-handler.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
statMtime,
|
|
8
8
|
} from "@effectstream/utils/fs";
|
|
9
9
|
import { spawnOutput } from "@effectstream/utils/runtime-spawn";
|
|
10
|
-
import { sleep, until, type Operation } from "effection";
|
|
10
|
+
import { ensure, sleep, until, type Operation } from "effection";
|
|
11
11
|
|
|
12
12
|
/** `process.env` works under Node, Bun, and Deno (via node: compat). */
|
|
13
13
|
declare const process: { env: Record<string, string | undefined> };
|
|
@@ -67,10 +67,15 @@ function resolveSnapshotConfig(config?: SnapshotConfig): ResolvedSnapshotConfig
|
|
|
67
67
|
* Creates a database snapshot using `pg_dump` in custom format (`-F c`).
|
|
68
68
|
* Must be called AFTER releasing the DB mutex (pg_dump opens its own connection).
|
|
69
69
|
*
|
|
70
|
+
* @param signal - optional; aborting it kills the `pg_dump` child. Pass one
|
|
71
|
+
* whenever the caller's own lifetime is bounded (see {@link runSnapshotLoop}),
|
|
72
|
+
* otherwise a cancelled caller leaves a full dump running unsupervised.
|
|
73
|
+
*
|
|
70
74
|
* @see docs/home/1000-effectstream-engine/1003-database-snapshots.md
|
|
71
75
|
*/
|
|
72
76
|
export async function createSnapshot(
|
|
73
77
|
config?: SnapshotConfig,
|
|
78
|
+
signal?: AbortSignal,
|
|
74
79
|
): Promise<void> {
|
|
75
80
|
// pg_dump queries pg_catalog directly; PGlite's catalog may not match the
|
|
76
81
|
// locally-installed pg_dump version, so skip snapshots under PGlite.
|
|
@@ -106,6 +111,7 @@ export async function createSnapshot(
|
|
|
106
111
|
env: { ...ambientEnv, PGPASSWORD: ENV.DB_PW ?? "" },
|
|
107
112
|
stdout: "inherit",
|
|
108
113
|
stderr: "inherit",
|
|
114
|
+
signal,
|
|
109
115
|
});
|
|
110
116
|
if (!result.success) {
|
|
111
117
|
throw new Error(`[Snapshot] pg_dump failed with exit code ${result.code}`);
|
|
@@ -126,15 +132,38 @@ export function* runSnapshotLoop(
|
|
|
126
132
|
const resolved = resolveSnapshotConfig(snapshotConfig);
|
|
127
133
|
const intervalMs = resolved.intervalSeconds * 1000;
|
|
128
134
|
console.log(`[Snapshot] Loop started (interval ${intervalMs / 1000}s, path ${resolved.path}, cwd ${cwd()})`);
|
|
135
|
+
|
|
136
|
+
// A dump is a child process holding its own database connection. `until`
|
|
137
|
+
// only abandons the promise on cancellation, so without this the loop can be
|
|
138
|
+
// torn down while `pg_dump` keeps running — the runtime would report a clean
|
|
139
|
+
// shutdown with a live child still writing.
|
|
140
|
+
let abortInFlight: (() => void) | undefined;
|
|
141
|
+
let inFlightExit: Promise<void> | undefined;
|
|
142
|
+
yield* ensure(function* () {
|
|
143
|
+
abortInFlight?.();
|
|
144
|
+
if (inFlightExit) yield* until(inFlightExit);
|
|
145
|
+
});
|
|
146
|
+
|
|
129
147
|
while (true) {
|
|
130
148
|
yield* sleep(intervalMs);
|
|
131
149
|
console.log(`[Snapshot] Firing`);
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const dump = createSnapshot(resolved, controller.signal);
|
|
152
|
+
abortInFlight = () => controller.abort();
|
|
153
|
+
// Settles when the child is gone, however it ended; the rejection is
|
|
154
|
+
// observed here so abandoning `dump` cannot become an unhandled rejection.
|
|
155
|
+
inFlightExit = dump.then(() => {}, () => {});
|
|
132
156
|
try {
|
|
133
|
-
yield* until(
|
|
157
|
+
yield* until(dump);
|
|
134
158
|
console.log(`[Snapshot] Done; sleeping ${intervalMs / 1000}s`);
|
|
135
159
|
} catch (e) {
|
|
136
160
|
console.error("[Snapshot] Failed:", e);
|
|
137
161
|
}
|
|
162
|
+
// Deliberately not in a `finally`: on cancellation the generator's finally
|
|
163
|
+
// blocks run BEFORE the `ensure` above, which would clear the handle the
|
|
164
|
+
// ensure needs to kill the child.
|
|
165
|
+
abortInFlight = undefined;
|
|
166
|
+
inFlightExit = undefined;
|
|
138
167
|
}
|
|
139
168
|
}
|
|
140
169
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reproduction for the snapshot child-process boundary (spec 00031: G6).
|
|
3
|
+
*
|
|
4
|
+
* `createSnapshot` shells out to `pg_dump` through `spawnOutput`, which is
|
|
5
|
+
* awaited with effection's `until`. `until` abandons the promise on
|
|
6
|
+
* cancellation but nothing kills the process behind it, so halting the runtime
|
|
7
|
+
* leaves a full database dump running unsupervised.
|
|
8
|
+
*
|
|
9
|
+
* A stub `pg_dump` (first on PATH) stands in for the real one so the test needs
|
|
10
|
+
* no PostgreSQL installation and can observe the child's PID directly.
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
13
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { run, type Task } from "effection";
|
|
17
|
+
import { runSnapshotLoop } from "../src/snapshot-handler.ts";
|
|
18
|
+
|
|
19
|
+
const ENV_KEYS = ["PATH", "PGLITE", "SNAPSHOT_TEST_PIDFILE"] as const;
|
|
20
|
+
|
|
21
|
+
let saved: Record<string, string | undefined>;
|
|
22
|
+
let workDir: string;
|
|
23
|
+
let pidFile: string;
|
|
24
|
+
let task: Task<unknown> | undefined;
|
|
25
|
+
|
|
26
|
+
function alive(pid: number): boolean {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sleep(ms: number): Promise<void> {
|
|
36
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function waitUntil(
|
|
40
|
+
check: () => boolean,
|
|
41
|
+
ms: number,
|
|
42
|
+
label: string,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const end = Date.now() + ms;
|
|
45
|
+
while (!check()) {
|
|
46
|
+
if (Date.now() >= end) throw new Error(`${label} not met within ${ms}ms`);
|
|
47
|
+
await sleep(10);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function childPid(): number | undefined {
|
|
52
|
+
if (!existsSync(pidFile)) return undefined;
|
|
53
|
+
const raw = readFileSync(pidFile, "utf8").trim();
|
|
54
|
+
const pid = Number(raw);
|
|
55
|
+
return Number.isFinite(pid) && pid > 0 ? pid : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
saved = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
|
60
|
+
workDir = mkdtempSync(join(tmpdir(), "es-00031-snapshot-"));
|
|
61
|
+
pidFile = join(workDir, "pg_dump.pid");
|
|
62
|
+
|
|
63
|
+
// A stand-in `pg_dump` that announces its PID and then runs long enough for
|
|
64
|
+
// the test to halt the loop while it is still working.
|
|
65
|
+
const stub = join(workDir, "pg_dump");
|
|
66
|
+
writeFileSync(
|
|
67
|
+
stub,
|
|
68
|
+
`#!/bin/sh\necho $$ > "${pidFile}"\nexec sleep 30\n`,
|
|
69
|
+
"utf8",
|
|
70
|
+
);
|
|
71
|
+
chmodSync(stub, 0o755);
|
|
72
|
+
|
|
73
|
+
process.env.PATH = `${workDir}:${process.env.PATH ?? ""}`;
|
|
74
|
+
// `createSnapshot` short-circuits under PGLite (pg_dump cannot read its
|
|
75
|
+
// catalog), so the scenario under test is the server-backed one.
|
|
76
|
+
process.env.PGLITE = "false";
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
if (task) {
|
|
81
|
+
await Promise.race([task.halt().catch(() => {}), sleep(2_000)]);
|
|
82
|
+
task = undefined;
|
|
83
|
+
}
|
|
84
|
+
const pid = childPid();
|
|
85
|
+
if (pid !== undefined && alive(pid)) {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, "SIGKILL");
|
|
88
|
+
} catch { /* already gone */ }
|
|
89
|
+
}
|
|
90
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
91
|
+
for (const key of ENV_KEYS) {
|
|
92
|
+
const value = saved[key];
|
|
93
|
+
if (value === undefined) delete process.env[key];
|
|
94
|
+
else process.env[key] = value;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("G6: halting the snapshot loop kills the in-flight pg_dump child", async () => {
|
|
99
|
+
task = run(() =>
|
|
100
|
+
runSnapshotLoop({
|
|
101
|
+
intervalSeconds: 0.05,
|
|
102
|
+
path: join(workDir, "snapshots"),
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
void Promise.resolve(task).catch(() => {});
|
|
106
|
+
|
|
107
|
+
await waitUntil(() => childPid() !== undefined, 10_000, "pg_dump started");
|
|
108
|
+
const pid = childPid()!;
|
|
109
|
+
expect(alive(pid)).toBe(true);
|
|
110
|
+
|
|
111
|
+
await task.halt();
|
|
112
|
+
task = undefined;
|
|
113
|
+
await sleep(500);
|
|
114
|
+
|
|
115
|
+
// The runtime claimed it had shut down while a database dump was still
|
|
116
|
+
// running with an open connection.
|
|
117
|
+
expect(alive(pid)).toBe(false);
|
|
118
|
+
}, 30_000);
|