File size: 7,433 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 185 186 187 188 189 190 | // src/snapshot.ts
//
// Write a point-in-time snapshot of the live Store to a temp file as a sequence
// of SET frames (tombstones dropped — only live keys are emitted). The caller is
// responsible for the atomic rename + WAL rotation.
//
// We yield to the event loop every `yieldEvery` entries so a large snapshot does
// not starve other work.
//
// Stage 6 (disk valueMode): values are no longer materialized one synchronous
// positioned read per record. The live refs are grouped by their source file
// and sorted by offset, then read with bounded concurrency through an async
// positioned reader — sequential-ish I/O instead of random readSync calls on
// the event loop. Each work slice is bounded by a value-byte budget, so both
// the per-slice CPU run and the in-flight memory stay flat.
import fs from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import { encodeFrame, HEADER_SIZE, TYPE_SET } from './codec.js';
import type { Store, ValueLoc } from './store.js';
const yieldToLoop = (): Promise<void> => new Promise((r) => setImmediate(r));
const FLUSH_BYTES = 1 << 20; // coalesce into ~1 MiB writev batches
/** Stage 6 defaults: at most this many positioned reads in flight, and at
* most this many value bytes collected per work slice. */
const DEFAULT_READ_CONCURRENCY = 8;
const DEFAULT_SLICE_BYTES = 8 << 20;
export interface SnapshotResult {
count: number;
bytes: number;
locs: Map<string, ValueLoc>;
}
export interface SnapshotOptions {
yieldEvery?: number;
/** Stage 6: async positioned reader for disk-backed value refs. When
* absent, disk refs fall back to the Store's synchronous materialization
* (the pre-stage-6 behavior). */
readValueAsync?: (loc: ValueLoc) => Promise<Buffer>;
/** Max concurrent async value reads (default 8). */
readConcurrency?: number;
/** Max value bytes collected per work slice (default 8 MiB). */
sliceBytes?: number;
}
interface PendingRecord {
key: Buffer;
value: Buffer | null;
loc: ValueLoc | null;
expireAt: number;
dt: Record<string, number> | null;
}
export async function writeSnapshot(
store: Store,
tmpPath: string,
opts: SnapshotOptions = {},
): Promise<SnapshotResult> {
const yieldEvery = opts.yieldEvery ?? 2000;
const readConcurrency = Math.max(1, opts.readConcurrency ?? DEFAULT_READ_CONCURRENCY);
const sliceBytes = Math.max(1, opts.sliceBytes ?? DEFAULT_SLICE_BYTES);
const fh: FileHandle = await fs.open(tmpPath, 'w');
let count = 0;
let bytes = 0;
let batch: Buffer[] = [];
let batchBytes = 0;
const locs = new Map<string, ValueLoc>();
const flushBatch = async (): Promise<void> => {
if (batch.length === 0) return;
// writev(2) may short-write (signal interruption, RLIMIT_FSIZE, …). Loop
// until every byte is on the kernel side; otherwise the snapshot would be
// silently truncated and later renamed over the good one.
let bufs = batch;
let off = 0; // byte offset within bufs[0]
while (bufs.length > 0) {
const toWrite = off > 0 ? [bufs[0]!.subarray(off), ...bufs.slice(1)] : bufs;
const { bytesWritten } = await fh.writev(toWrite);
if (bytesWritten === 0) throw new Error('snapshot writev made no progress (short write)');
bytes += bytesWritten;
let rem = bytesWritten;
while (rem > 0 && bufs.length > 0) {
const left = bufs[0]!.length - off;
if (rem < left) {
off += rem;
rem = 0;
} else {
rem -= left;
bufs.shift();
off = 0;
}
}
}
batch = [];
batchBytes = 0;
};
const writeRecord = async (key: Buffer, value: Buffer, expireAt: number, dt: Record<string, number> | null): Promise<void> => {
const meta = dt ? Buffer.from(JSON.stringify({ dt })) : null;
const frame = encodeFrame({ type: TYPE_SET, key, value, expireAt, meta });
const frameOff = bytes + batchBytes;
locs.set(key.toString('binary'), {
file: 'snapshot',
off: frameOff + HEADER_SIZE + key.length,
len: value.length,
});
batch.push(frame);
batchBytes += frame.length;
count++;
if (batchBytes >= FLUSH_BYTES) await flushBatch();
if (count % yieldEvery === 0) await yieldToLoop();
};
try {
// Collect the live refs WITHOUT materializing values, then emit two
// passes: memory-resident values first (pure CPU), then disk-backed
// values grouped by source file and sorted by offset (see the header).
// Frame order inside a snapshot carries no semantics — every key appears
// exactly once and recovery applies frames last-writer-wins per key.
const memRecs: PendingRecord[] = [];
const diskRecs: PendingRecord[] = [];
for (const r of store.rawRefRecords()) {
const key = Buffer.from(r.kstr, 'binary');
if (r.ref.kind === 'memory') {
memRecs.push({ key, value: r.ref.value, loc: null, expireAt: r.expireAt, dt: r.dt });
} else {
diskRecs.push({ key, value: null, loc: r.ref.loc, expireAt: r.expireAt, dt: r.dt });
}
}
for (const rec of memRecs) await writeRecord(rec.key, rec.value!, rec.expireAt, rec.dt);
if (diskRecs.length > 0) {
// Group by source file, sort by offset: the async reads below then hit
// each file in ascending-offset order (sequential I/O), never random.
diskRecs.sort((a, b) => (a.loc!.file < b.loc!.file ? -1 : a.loc!.file > b.loc!.file ? 1 : a.loc!.off - b.loc!.off));
if (!opts.readValueAsync) {
// Legacy fallback: no async reader wired — synchronous materialize
// per record (the pre-stage-6 behavior), one record at a time.
for (const rec of diskRecs) {
const value = store.get(rec.key);
if (value === undefined) continue; // expired between collect and read
await writeRecord(rec.key, value, rec.expireAt, rec.dt);
}
} else {
const readValue = opts.readValueAsync;
// Work slices bounded by accumulated value bytes: each slice reads
// its records with bounded concurrency, then writes them in slice
// order, keeping both the event-loop pause and in-flight memory flat.
let i = 0;
while (i < diskRecs.length) {
let sliceLen = 0;
let j = i;
while (j < diskRecs.length && (j === i || sliceLen < sliceBytes)) {
sliceLen += diskRecs[j]!.loc!.len;
j++;
}
const slice = diskRecs.slice(i, j);
const values: (Buffer | undefined)[] = Array.from({ length: slice.length });
let cursor = 0;
const workers = Array.from({ length: Math.min(readConcurrency, slice.length) }, async () => {
for (;;) {
const idx = cursor++;
if (idx >= slice.length) return;
const rec = slice[idx]!;
values[idx] = await readValue(rec.loc!);
}
});
await Promise.all(workers);
for (let k = 0; k < slice.length; k++) {
const value = values[k];
if (value === undefined) continue;
const rec = slice[k]!;
await writeRecord(rec.key, value, rec.expireAt, rec.dt);
}
i = j;
await yieldToLoop();
}
}
}
await flushBatch();
await fh.sync();
} finally {
await fh.close();
}
return { count, bytes, locs };
}
|