feat: add inspectFile and restoreFromFile to backup
This commit is contained in:
1 parent
70dc1b8283
commit
f9da2b513b
1 file changed
+161
+161
@@ -150,3 +150,164 @@ fn parse(allocator: Allocator, data: []const u8) !ParsedBackup {
|
|||||||
var entries = std.ArrayList(ParsedEntry).init(allocator);
|
var entries = std.ArrayList(ParsedEntry).init(allocator);
|
||||||
errdefer entries.deinit();
|
errdefer entries.deinit();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (pos + footerMagic.len <= data.len and std.mem.eql(u8, data[pos .. pos + 4], footerMagic)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (pos + 8 > data.len) return BackupError.Truncated;
|
||||||
|
const keyLen = encode.readU32(data, pos);
|
||||||
|
const valueLen = encode.readU32(data, pos + 4);
|
||||||
|
const entryStart = pos;
|
||||||
|
const keyStart = pos + 8;
|
||||||
|
const valueStart = keyStart + keyLen;
|
||||||
|
const valueEnd = valueStart + valueLen;
|
||||||
|
if (valueEnd + 4 > data.len) return BackupError.Truncated;
|
||||||
|
|
||||||
|
const entryBytes = data[entryStart..valueEnd];
|
||||||
|
const storedCrc = encode.readU32(data, valueEnd);
|
||||||
|
const actualCrc = std.hash.Crc32.hash(entryBytes);
|
||||||
|
if (storedCrc != actualCrc) return BackupError.Corrupt;
|
||||||
|
|
||||||
|
try entries.append(.{ .key = data[keyStart..valueStart], .value = data[valueStart..valueEnd] });
|
||||||
|
pos = valueEnd + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos + 4 + 8 + 4 > data.len) return BackupError.Truncated;
|
||||||
|
const declaredCount = encode.readU64(data, pos + 4);
|
||||||
|
if (declaredCount != entries.items.len) return BackupError.CountMismatch;
|
||||||
|
|
||||||
|
const storedFileCrc = encode.readU32(data, pos + 4 + 8);
|
||||||
|
const actualFileCrc = std.hash.Crc32.hash(data[0..pos]);
|
||||||
|
if (storedFileCrc != actualFileCrc) return BackupError.Corrupt;
|
||||||
|
|
||||||
|
return .{ .seq = seq, .entries = try entries.toOwnedSlice() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and structurally validates a backup file without applying it,
|
||||||
|
/// returning its metadata (seq, entry count). Useful to verify a backup
|
||||||
|
/// before trusting it for disaster recovery.
|
||||||
|
pub fn inspectFile(allocator: Allocator, path: []const u8) !BackupInfo {
|
||||||
|
const data = try std.fs.cwd().readFileAlloc(allocator, path, 1 << 34);
|
||||||
|
defer allocator.free(data);
|
||||||
|
const parsed = try parse(allocator, data);
|
||||||
|
defer allocator.free(parsed.entries);
|
||||||
|
return .{ .seq = parsed.seq, .entryCount = parsed.entries.len };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores every entry from the backup at `path` into `db`, in chunks of
|
||||||
|
/// `chunkSize` applied atomically via WriteBatch. The whole file is validated
|
||||||
|
/// (magics, per-entry and whole-file crc, declared count) before any chunk is
|
||||||
|
/// applied, so a corrupt backup fails without mutating the target database.
|
||||||
|
/// Existing keys in `db` outside the backup are left untouched; keys present
|
||||||
|
/// in both are overwritten with the backup's value.
|
||||||
|
pub fn restoreFromFile(allocator: Allocator, db: *Db, path: []const u8, chunkSize: usize) !BackupInfo {
|
||||||
|
const data = try std.fs.cwd().readFileAlloc(allocator, path, 1 << 34);
|
||||||
|
defer allocator.free(data);
|
||||||
|
|
||||||
|
const parsed = try parse(allocator, data);
|
||||||
|
defer allocator.free(parsed.entries);
|
||||||
|
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < parsed.entries.len) {
|
||||||
|
const end = @min(i + chunkSize, parsed.entries.len);
|
||||||
|
var batch = WriteBatch.init(allocator);
|
||||||
|
defer batch.deinit();
|
||||||
|
for (parsed.entries[i..end]) |entry| {
|
||||||
|
try batch.put(entry.key, entry.value);
|
||||||
|
}
|
||||||
|
try db.apply(&batch);
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{ .seq = parsed.seq, .entryCount = parsed.entries.len };
|
||||||
|
}
|
||||||
|
|
||||||
|
test "backup round trip: dump then restore into a fresh db reproduces every key" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const srcDir = "test-backup-src-1";
|
||||||
|
const dstDir = "test-backup-dst-1";
|
||||||
|
const backupPath = "test-backup-1.cblb";
|
||||||
|
std.fs.cwd().deleteTree(srcDir) catch {};
|
||||||
|
std.fs.cwd().deleteTree(dstDir) catch {};
|
||||||
|
defer std.fs.cwd().deleteTree(srcDir) catch {};
|
||||||
|
defer std.fs.cwd().deleteTree(dstDir) catch {};
|
||||||
|
defer std.fs.cwd().deleteFile(backupPath) catch {};
|
||||||
|
|
||||||
|
var src = try Db.open(allocator, srcDir, .{});
|
||||||
|
defer src.close();
|
||||||
|
|
||||||
|
var keyBuf: [16]u8 = undefined;
|
||||||
|
var valBuf: [16]u8 = undefined;
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < 200) : (i += 1) {
|
||||||
|
const key = try std.fmt.bufPrint(&keyBuf, "k{d:0>6}", .{i});
|
||||||
|
const value = try std.fmt.bufPrint(&valBuf, "v{d:0>6}", .{i});
|
||||||
|
try src.put(key, value);
|
||||||
|
}
|
||||||
|
// overwrite one, delete another: backup must reflect resolved state.
|
||||||
|
try src.put("k000005", "updated");
|
||||||
|
try src.delete("k000010");
|
||||||
|
|
||||||
|
const snap = try src.snapshot();
|
||||||
|
const count = try dumpToFile(allocator, snap, backupPath);
|
||||||
|
try std.testing.expectEqual(@as(u64, 199), count);
|
||||||
|
|
||||||
|
var dst = try Db.open(allocator, dstDir, .{});
|
||||||
|
defer dst.close();
|
||||||
|
const info = try restoreFromFile(allocator, &dst, backupPath, 32);
|
||||||
|
try std.testing.expectEqual(@as(u64, 199), info.entryCount);
|
||||||
|
|
||||||
|
i = 0;
|
||||||
|
while (i < 200) : (i += 1) {
|
||||||
|
const key = try std.fmt.bufPrint(&keyBuf, "k{d:0>6}", .{i});
|
||||||
|
const got = try dst.get(allocator, key);
|
||||||
|
if (i == 10) {
|
||||||
|
try std.testing.expect(got == null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
defer allocator.free(got.?);
|
||||||
|
if (i == 5) {
|
||||||
|
try std.testing.expectEqualStrings("updated", got.?);
|
||||||
|
} else {
|
||||||
|
const expected = try std.fmt.bufPrint(&valBuf, "v{d:0>6}", .{i});
|
||||||
|
try std.testing.expectEqualStrings(expected, got.?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "backup of empty db round trips to zero entries" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const srcDir = "test-backup-src-empty";
|
||||||
|
const dstDir = "test-backup-dst-empty";
|
||||||
|
const backupPath = "test-backup-empty.cblb";
|
||||||
|
std.fs.cwd().deleteTree(srcDir) catch {};
|
||||||
|
std.fs.cwd().deleteTree(dstDir) catch {};
|
||||||
|
defer std.fs.cwd().deleteTree(srcDir) catch {};
|
||||||
|
defer std.fs.cwd().deleteTree(dstDir) catch {};
|
||||||
|
defer std.fs.cwd().deleteFile(backupPath) catch {};
|
||||||
|
|
||||||
|
var src = try Db.open(allocator, srcDir, .{});
|
||||||
|
defer src.close();
|
||||||
|
const snap = try src.snapshot();
|
||||||
|
const count = try dumpToFile(allocator, snap, backupPath);
|
||||||
|
try std.testing.expectEqual(@as(u64, 0), count);
|
||||||
|
|
||||||
|
const info = try inspectFile(allocator, backupPath);
|
||||||
|
try std.testing.expectEqual(@as(u64, 0), info.entryCount);
|
||||||
|
|
||||||
|
var dst = try Db.open(allocator, dstDir, .{});
|
||||||
|
defer dst.close();
|
||||||
|
_ = try restoreFromFile(allocator, &dst, backupPath, 32);
|
||||||
|
var iter = try dst.scan(allocator, null, null);
|
||||||
|
defer iter.deinit();
|
||||||
|
try std.testing.expect((try iter.next()) == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "backup is point-in-time: writes after snapshot are excluded" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const srcDir = "test-backup-src-pit";
|
||||||
|
const backupPath = "test-backup-pit.cblb";
|
||||||
|
std.fs.cwd().deleteTree(srcDir) catch {};
|
||||||
|
defer std.fs.cwd().deleteTree(srcDir) catch {};
|
||||||
|
defer std.fs.cwd().deleteFile(backupPath) catch {};
|
||||||
|
|
||||||
Reference in new issue
Block a user