File size: 8,246 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | // src/backup.ts
//
// MiniDb's online backup as injected-dependency free functions: the write-gate
// fence + drain, then the atomic copy (temp dir β per-file fsync β manifest β
// rename swap). Everything the flow needs from MiniDb (lifecycle gates,
// compaction state, the write-op tracker, the backup serializer, the WAL) is
// injected through BackupDeps, so this module never imports the MiniDb class
// itself.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fsyncDir } from './compaction.js';
import { isPersistentFile } from './generation.js';
/** Unique suffixes for backup's temp/aside dirs (see copyBackupAtomic). */
let backupTmpSeq = 0;
/** The owner-injected surface a backup run needs (see the header). */
export interface BackupDeps {
dir: () => string;
/** The owner's stats object (fsyncDir's dirFsyncUnsupported sink). */
stats: { dirFsyncUnsupported: boolean };
ensureOpen: () => void;
compacting: () => boolean;
/** The in-flight compaction's completion (null when none). */
compactDone: () => Promise<void> | null;
readOnly: () => boolean;
compact: () => Promise<void>;
/** Pause the write gate; resolves at the drain (the quiescence point). */
pauseWrites: () => Promise<void>;
resumeWrites: () => void;
serializeBackups: <T>(fn: () => Promise<T>) => Promise<T>;
walRecoveryChain: () => Promise<void>;
flushWal: () => Promise<void>;
}
/** The rejection a write op gets while a backup holds the write gate: the
* fence is short (file copies) and retryable, so callers can simply
* re-issue the write afterwards. */
export function backupInProgressError(): Error {
return Object.assign(new Error('MiniDb backup is in progress: writes are fenced until it completes'), {
code: 'BACKUP_IN_PROGRESS',
});
}
/** Write a consistent online backup of this database directory.
*
* Semantics (plan 12): backup pauses the write gate β new writes reject
* with BACKUP_IN_PROGRESS β and waits for every in-flight write to settle.
* That drain completion IS the linearization point: every write
* acknowledged before it is included in the backup, every write submitted
* after it is not. The copy itself is an atomic commit: persistent files
* go to a sibling temp dir, every copied file is fsync'd, the manifest is
* written LAST (the commit marker β a manifest on disk implies every file
* it lists is fully copied and durable), then the temp dir is renamed over
* the destination (an existing previous backup is swapped aside first and
* restored if the rename fails). A failure anywhere before the rename
* leaves the destination untouched and the temp dir removed β never a half
* backup. Concurrent backups serialize on serializeBackups. */
export async function backup(deps: BackupDeps, destDir: string, opts: { compact?: boolean } = {}): Promise<void> {
deps.ensureOpen();
if (!destDir) throw new TypeError('backup: destDir is required');
if (deps.compacting()) await deps.compactDone();
if (opts.compact !== false && !deps.readOnly()) await deps.compact();
if (deps.compacting()) await deps.compactDone();
// The gate closes SYNCHRONOUSLY here (pause's first statement): a write
// submitted from the same synchronous segment as this backup() call
// already sees the fence. pause is reference-counted, so a second backup
// queued on the serializer keeps the gate closed until the last one
// resumes.
const drain = deps.pauseWrites();
try {
await deps.serializeBackups(async () => {
// Fence + drain. After the drain resolves, no write op is running and
// no new one can start, so the on-disk files are quiescent (this
// instance is the only writer β a read-only instance backing up a
// LIVE writer's dir can only fence itself and stays best-effort, as
// before).
await drain;
// A compaction kicked by the just-drained writes must finish before
// the copy; no new one can start with the gate closed (kicks come from
// write commit bodies only).
if (deps.compacting()) await deps.compactDone();
// Wait out any in-flight WAL recovery inside the fence: a WAL failure
// racing the backup leaves un-acked bytes in db.wal that the recovery
// is about to truncate away, and the copy must land on the recovered
// (possibly truncated) file rather than copying bytes that are about
// to disappear. With the gate closed no new recovery can be kicked,
// and a persistent failure keeps the WAL poisoned, so the flush below
// rejects the backup.
await deps.walRecoveryChain();
await deps.flushWal();
await copyBackupAtomic(deps, destDir);
});
} finally {
deps.resumeWrites();
}
}
/** The atomic-copy core of backup(): temp dir β per-file fsync β manifest
* (commit marker) β dir fsync β rename swap β parent fsync. Runs with the
* write gate paused. */
async function copyBackupAtomic(deps: BackupDeps, destDir: string): Promise<void> {
const parent = path.dirname(destDir);
const base = path.basename(destDir);
const tmp = path.join(parent, `.${base}.backup-tmp-${process.pid}-${++backupTmpSeq}`);
const aside = path.join(parent, `.${base}.backup-old-${process.pid}-${++backupTmpSeq}`);
await fs.mkdir(parent, { recursive: true });
// Sweep orphans from a crashed previous backup of this destination.
for (const name of await fs.readdir(parent)) {
if (name.startsWith(`.${base}.backup-tmp-`) || name.startsWith(`.${base}.backup-old-`)) {
await fs.rm(path.join(parent, name), { recursive: true, force: true });
}
}
await fs.mkdir(tmp);
try {
const files = await persistentFiles(deps.dir());
const copied: string[] = [];
for (const name of files) if (await copyIfExists(deps.dir(), name, tmp)) copied.push(name);
// Fsync every copied file BEFORE the manifest: the manifest is the
// commit marker, so a durable manifest must imply durable payloads.
for (const name of copied) {
const h = await fs.open(path.join(tmp, name), 'r');
try {
await h.sync();
} finally {
await h.close();
}
}
const manifest = path.join(tmp, 'backup.manifest.json');
await fs.writeFile(manifest, JSON.stringify({ version: 1, createdAt: Date.now(), files: copied }, null, 2), 'utf8');
const mh = await fs.open(manifest, 'r');
try {
await mh.sync();
} finally {
await mh.close();
}
await fsyncDir(tmp, { strict: true, stats: deps.stats });
// Swap into place: move an existing previous backup aside, rename the
// temp dir over the destination, restore the aside on failure.
let asideUsed = false;
try {
try {
await fs.rename(destDir, aside);
asideUsed = true;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
}
await fs.rename(tmp, destDir);
} catch (err) {
if (asideUsed) await fs.rename(aside, destDir).catch(() => {});
throw err;
}
await fs.rm(aside, { recursive: true, force: true });
await fsyncDir(parent, { strict: true, stats: deps.stats });
} finally {
// A successful rename already moved the temp dir away (this rm is a
// no-op); a failed copy must not strand it (review #22: no half backup).
await fs.rm(tmp, { recursive: true, force: true }).catch(() => {});
}
}
async function persistentFiles(dir: string): Promise<string[]> {
const names = await fs.readdir(dir);
return names.filter(isPersistentFile);
}
async function copyIfExists(dir: string, name: string, destDir: string): Promise<boolean> {
try {
const src = path.join(dir, name);
const st = await fs.stat(src);
// The generations/ tree is a directory: copy it recursively (backup
// includes published generations; a restore's inode change safely
// invalidates their WAL anchors, so they fall back to a rebuild).
if (st.isDirectory()) await fs.cp(src, path.join(destDir, name), { recursive: true });
else await fs.copyFile(src, path.join(destDir, name));
return true;
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw e;
}
}
|