fix(db-backup): name the archive to pg_restore --list instead of streaming it

pg_restore reads the table of contents and exits; the read stream piped into
it stayed open on the dump. The long-lived worker checks one dump a night, so
every dump retention deleted stayed allocated until the worker restarted and
the backups volume filled. runTool no longer takes a stdin stream at all.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 16:47:26 +01:00
1 parent 104bb35919
commit 4887ed8071
2 files changed
+54 -19

No files matched your search

@@ -10,9 +10,14 @@
* tests that keep its plain-JS reading of both from drifting.
*/
import {
mkdtempSync,
chmodSync,
createReadStream,
fstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
@@ -46,6 +51,7 @@ import {
resolveBackupSchedule,
resolveDailyKeep,
selectForDeletion,
verifyArchive,
type DirectoryProbe,
type DumpSet,
type Env,
@@ -126,6 +132,44 @@ describe("dump names", () => {
});
});
/** How many of this process's descriptors are open on `path`'s file. */
function openHandlesOn(path: string): number {
const { dev, ino } = statSync(path);
return readdirSync("/dev/fd").filter((fd) => {
try {
const s = fstatSync(Number(fd));
return s.dev === dev && s.ino === ino;
} catch {
return false;
}
}).length;
}
describe("reading an archive back", () => {
// The worker lives for weeks and checks a dump every night. A handle it
// keeps on a checked dump keeps that dump's blocks allocated after
// retention deletes it, so the volume fills with "deleted" dumps.
it("leaves no handle open on the dump once the listing is read", async () => {
const dir = tempDir();
const bin = join(dir, "bin");
mkdirSync(bin);
// A pg_restore that does what the real one does with a custom-format
// archive: read the table of contents and exit without reading the rest.
writeFileSync(
join(bin, "pg_restore"),
`#!/bin/sh\nprintf '; header\\n1; 2615 2200 TABLE public a\\n2; 2615 2201 TABLE public b\\n'\n`,
);
chmodSync(join(bin, "pg_restore"), 0o755);
vi.stubEnv("PATH", `${bin}:${process.env.PATH ?? ""}`);
const dump = join(dir, "big.dump");
writeFileSync(dump, Buffer.alloc(16 * 1024 * 1024, 7));
expect(await verifyArchive(dump)).toBe(2);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(openHandlesOn(dump)).toBe(0);
});
});
describe("configuration", () => {
it("keeps at least one daily dump and ignores what is not a number", () => {
expect(resolveDailyKeep({})).toBe(7);
+9 -18
View File
@@ -739,16 +739,19 @@ export async function serverVersion(client) {
/**
* Run one Postgres client tool. Standard output goes to `stdout` when given
* (a file stream) and is collected otherwise; standard input comes from
* `stdin` when given. Rejects with the tool's own last lines on a non-zero
* exit, and kills it at `deadline` (epoch ms).
* (a file stream) and is collected otherwise. Rejects with the tool's own
* last lines on a non-zero exit, and kills it at `deadline` (epoch ms).
*
* A file the tool reads is named in `args`, never streamed to its standard
* input: `pg_restore --list` reads an archive's table of contents and exits,
* and a stream piped into it would stay open on the file. In the long-lived
* worker that kept every checked dump allocated after retention deleted it.
*
* @param {string} tool
* @param {string[]} args
* @param {{
* env?: Record<string, string>;
* stdout?: NodeJS.WritableStream;
* stdin?: NodeJS.ReadableStream;
* deadline?: number;
* }} [options]
* @returns {Promise<string>}
@@ -757,7 +760,7 @@ export function runTool(tool, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(tool, args, {
env: { ...process.env, ...(options.env ?? {}) },
stdio: [options.stdin ? "pipe" : "ignore", "pipe", "pipe"],
stdio: ["ignore", "pipe", "pipe"],
});
/** @type {Buffer[]} */
const out = [];
@@ -787,16 +790,6 @@ export function runTool(tool, args, options = {}) {
child.stderr.on("data", (c) => {
err = (err + c.toString("utf8")).slice(-4000);
});
if (options.stdin && child.stdin) {
options.stdin.on("error", (e) => {
child.kill("SIGTERM");
reject(e);
});
child.stdin.on("error", () => {
// The tool closed its input early; its exit code says why.
});
options.stdin.pipe(child.stdin);
}
child.on("error", (e) => {
if (timer) clearTimeout(timer);
reject(
@@ -841,9 +834,7 @@ export async function pgDumpVersion() {
* @param {string} path
*/
export async function verifyArchive(path) {
const listing = await runTool("pg_restore", ["--list"], {
stdin: createReadStream(path),
});
const listing = await runTool("pg_restore", ["--list", path]);
const entries = listing
.split("\n")
.filter((line) => line.trim() && !line.startsWith(";")).length;