feat: add BackupInfo and RollingCrc

This commit is contained in:
dualfroz committed 2026-09-01 20:06:17 +02:00
1 parent c3e6d8e04a
commit 70dc1b8283
1 file changed
+152
+152
View File
@@ -0,0 +1,152 @@
const std = @import("std");
const db_mod = @import("db.zig");
const encode = @import("encode.zig");
const Allocator = std.mem.Allocator;
const Db = db_mod.Db;
const Snapshot = db_mod.Snapshot;
const WriteBatch = db_mod.WriteBatch;
// Portable logical backup format for cobalt. Dumps a consistent, point-in-time
// view of the live keyspace (as returned by Snapshot.scan - tombstones already
// resolved) into a self-describing file that can be replayed into any Db.
//
// Layout:
// header: magic("CBLB" 4B) | version(u32) | seq(u64)
// entry*: keyLen(u32) | valueLen(u32) | key | value | entryCrc(u32)
// entryCrc = crc32(keyLen le bytes || valueLen le bytes || key || value)
// footer: magic("CBLE" 4B) | entryCount(u64) | fileCrc(u32)
// fileCrc = crc32 of every byte written before the footer magic
// (header + all entries), rolling-accumulated while writing.
//
// Restore validates the whole file (magics, per-entry crc, trailing count,
// whole-file crc) before touching the target Db, so a corrupt or truncated
// backup never applies a partial write.
const headerMagic = "CBLB";
const footerMagic = "CBLE";
const formatVersion: u32 = 1;
pub const BackupError = error{
BadMagic,
UnsupportedVersion,
Truncated,
Corrupt,
CountMismatch,
};
pub const BackupInfo = struct {
seq: u64,
entryCount: u64,
};
const RollingCrc = struct {
hasher: std.hash.Crc32,
fn init() RollingCrc {
return .{ .hasher = std.hash.Crc32.init() };
}
fn feed(self: *RollingCrc, bytes: []const u8) void {
self.hasher.update(bytes);
}
fn value(self: RollingCrc) u32 {
var copy = self.hasher;
return copy.final();
}
};
fn writeAllCrc(writer: anytype, roll: *RollingCrc, bytes: []const u8) !void {
try writer.writeAll(bytes);
roll.feed(bytes);
}
fn putU32Crc(writer: anytype, roll: *RollingCrc, v: u32) !void {
var b: [4]u8 = undefined;
std.mem.writeInt(u32, &b, v, encode.endian);
try writeAllCrc(writer, roll, &b);
}
fn putU64Crc(writer: anytype, roll: *RollingCrc, v: u64) !void {
var b: [8]u8 = undefined;
std.mem.writeInt(u64, &b, v, encode.endian);
try writeAllCrc(writer, roll, &b);
}
/// Writes a full, consistent backup of `snapshot` to `path` (created/truncated).
/// Returns the number of entries written.
pub fn dumpToFile(allocator: Allocator, snapshot: Snapshot, path: []const u8) !u64 {
const file = try std.fs.cwd().createFile(path, .{ .truncate = true });
defer file.close();
var bufferedWriter = std.io.bufferedWriter(file.writer());
const writer = bufferedWriter.writer();
var roll = RollingCrc.init();
try writeAllCrc(writer, &roll, headerMagic);
try putU32Crc(writer, &roll, formatVersion);
try putU64Crc(writer, &roll, snapshot.seq);
var iter = try snapshot.scan(allocator, null, null);
defer iter.deinit();
var count: u64 = 0;
var entryScratch = std.ArrayList(u8).init(allocator);
defer entryScratch.deinit();
while (try iter.next()) |entry| {
entryScratch.clearRetainingCapacity();
var lenBuf: [8]u8 = undefined;
std.mem.writeInt(u32, lenBuf[0..4], @intCast(entry.key.len), encode.endian);
std.mem.writeInt(u32, lenBuf[4..8], @intCast(entry.value.len), encode.endian);
try entryScratch.appendSlice(&lenBuf);
try entryScratch.appendSlice(entry.key);
try entryScratch.appendSlice(entry.value);
const entryCrc = std.hash.Crc32.hash(entryScratch.items);
try writeAllCrc(writer, &roll, entryScratch.items);
try putU32Crc(writer, &roll, entryCrc);
count += 1;
}
try writer.writeAll(footerMagic);
var countBuf: [8]u8 = undefined;
std.mem.writeInt(u64, &countBuf, count, encode.endian);
try writer.writeAll(&countBuf);
const fileCrc = roll.value();
var crcBuf: [4]u8 = undefined;
std.mem.writeInt(u32, &crcBuf, fileCrc, encode.endian);
try writer.writeAll(&crcBuf);
try bufferedWriter.flush();
return count;
}
const ParsedEntry = struct {
key: []const u8,
value: []const u8,
};
/// One pass over a fully-loaded backup buffer: validates structure end to end
/// (magics, per-entry crc, declared count, whole-file crc) without allocating
/// per-entry copies. Returns the entries slice (positions into `data`) plus
/// backup metadata, or a BackupError describing the first problem found.
const ParsedBackup = struct {
seq: u64,
entries: []ParsedEntry,
};
fn parse(allocator: Allocator, data: []const u8) !ParsedBackup {
if (data.len < headerMagic.len + 4 + 8) return BackupError.Truncated;
if (!std.mem.eql(u8, data[0..4], headerMagic)) return BackupError.BadMagic;
const version = encode.readU32(data, 4);
if (version != formatVersion) return BackupError.UnsupportedVersion;
const seq = encode.readU64(data, 8);
var pos: usize = 16;
var entries = std.ArrayList(ParsedEntry).init(allocator);
errdefer entries.deinit();