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;
  }
}