lib/sql/src/lifecycle.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const builtin = @import("builtin");
3 const branch = @import("branch.zig");
4 const catalog_mod = @import("catalog.zig");
5 const connection_mod = @import("connection.zig");
6 const file_mod = @import("file.zig");
7 const history_mod = @import("history/root.zig");
8 const pager = @import("pager.zig");
9 const session_mod = @import("session/root.zig");
10 const version = @import("version.zig");
11 const wal = @import("wal.zig");
12
13 const Allocator = std.mem.Allocator;
14 const default_capacity: pager.Capacity = .{ .wal_frames = 8192 };
15
16 /// Owns one database and the storage that database runs on, created by a tool
17 /// at startup to open its database through it. The workspace holds the file
18 /// workspace and, once opened, the database itself, and hands callers a pointer
19 /// to that database. Because callers hold that pointer, the workspace has to
20 /// stay at one address from the open until the opened database is released, so
21 /// callers place it on the heap. The workspace takes its storage either from
22 /// the caller with `init` or from an allocator with `allocate`, and gives it
23 /// back with the matching `deinit` or `deallocate`. The workspace opens one
24 /// database at a time, reports `WorkspaceBusy` for a second open while the
25 /// first is live, and refuses teardown by assertion while a database is still
26 /// open.
27 pub const Workspace = struct {
28 pub const Limits = file_mod.Database.Workspace.Limits;
29 pub const Capacity = file_mod.Database.Workspace.Capacity;
30 pub const Storage = file_mod.Database.Workspace.Storage;
31 pub const InitError = file_mod.Database.Workspace.InitError;
32 pub const AllocateError = file_mod.Database.Workspace.AllocateError;
33 pub const AcquireError = file_mod.Database.Workspace.AcquireError;
34
35 file_workspace: file_mod.Database.Workspace,
36 database: file_mod.Database = undefined,
37 database_live: bool = false,
38
39 pub fn init(
40 storage: Storage,
41 limits: Limits,
42 ) InitError!Workspace {
43 return .{
44 .file_workspace = try file_mod.Database.Workspace.init(storage, limits),
45 };
46 }
47
48 pub fn allocate(allocator: Allocator, limits: Limits) AllocateError!Workspace {
49 return .{
50 .file_workspace = try file_mod.Database.Workspace.allocate(allocator, limits),
51 };
52 }
53
54 pub fn activate(self: *Workspace) void {
55 std.debug.assert(!self.database_live);
56 self.file_workspace.activate();
57 }
58
59 pub fn deinit(self: *Workspace) Storage {
60 std.debug.assert(!self.database_live);
61 const storage = self.file_workspace.deinit();
62 self.* = undefined;
63 return storage;
64 }
65
66 pub fn deallocate(self: *Workspace, allocator: Allocator) void {
67 std.debug.assert(!self.database_live);
68 self.file_workspace.deallocate(allocator);
69 self.* = undefined;
70 }
71
72 pub fn retainedBytes(self: *const Workspace) usize {
73 return self.file_workspace.retainedBytes();
74 }
75
76 fn acquireWritable(
77 self: *Workspace,
78 allocator: Allocator,
79 dir: std.Io.Dir,
80 options: file_mod.OpenOptions,
81 ) file_mod.Error!*file_mod.Database {
82 if (self.database_live) return error.WorkspaceBusy;
83 const writable = try file_mod.Database.open(
84 allocator,
85 &self.file_workspace,
86 dir,
87 options,
88 );
89 self.database = writable;
90 self.database_live = true;
91 return &self.database;
92 }
93
94 fn acquireReadOnly(
95 self: *Workspace,
96 allocator: Allocator,
97 dir: std.Io.Dir,
98 options: file_mod.ReadOpenOptions,
99 ) file_mod.Error!*file_mod.Database {
100 if (self.database_live) return error.WorkspaceBusy;
101 self.database = try file_mod.Database.openReadOnly(
102 allocator,
103 &self.file_workspace,
104 dir,
105 options,
106 );
107 self.database_live = true;
108 return &self.database;
109 }
110
111 fn releaseDatabase(self: *Workspace, database: *file_mod.Database) void {
112 std.debug.assert(self.database_live);
113 std.debug.assert(database == &self.database);
114 database.deinit();
115 self.database_live = false;
116 }
117
118 fn ownerOf(writable: *file_mod.Database) *Workspace {
119 const owner: *Workspace = @alignCast(@fieldParentPtr(
120 "file_workspace",
121 writable.workspace,
122 ));
123 std.debug.assert(owner.database_live);
124 std.debug.assert(writable == &owner.database);
125 return owner;
126 }
127 };
128
129 pub const Error = file_mod.Error || history_mod.Error || history_mod.refs.ReadError || connection_mod.Error || std.Io.Dir.DeleteFileError;
130
131 pub const Options = struct {
132 io: std.Io = std.Options.debug_io,
133 paths: file_mod.Paths = .{},
134 history_path: []const u8,
135 branch: []const u8 = "main",
136 header: wal.Header,
137 max_wal_bytes: usize = file_mod.default_max_wal_bytes,
138 wal_capacity_bytes: ?usize = null,
139 capacity: pager.Capacity = default_capacity,
140 control: wal.Control = .{},
141 };
142
143 pub const ReadOptions = struct {
144 io: std.Io = std.Options.debug_io,
145 paths: file_mod.Paths = .{},
146 history_path: []const u8,
147 branch: []const u8 = "main",
148 header: wal.Header,
149 max_wal_bytes: usize = file_mod.default_max_wal_bytes,
150 read_cache_capacity: usize = file_mod.default_read_cache_capacity,
151 advance_refs: bool = false,
152 };
153
154 pub const Recovery = enum {
155 clean,
156 created,
157 truncated_history,
158 rebuilt_invalid_history,
159 rebuilt_missing_branch,
160 };
161
162 pub const LazyHistory = struct {
163 allocator: Allocator,
164 io: std.Io,
165 dir: std.Io.Dir,
166 path: []u8,
167 state: State,
168 retired: ?history_mod.refs.Snapshot = null,
169 read_only: bool = false,
170
171 const State = union(enum) {
172 deferred: history_mod.refs.Snapshot,
173 open: history_mod.History,
174 };
175
176 pub fn full(self: *LazyHistory) Error!*history_mod.History {
177 switch (self.state) {
178 .open => {},
179 .deferred => |snapshot| {
180 std.debug.assert(self.retired == null);
181 const opened = try history_mod.History.open(self.allocator, self.dir, .{
182 .io = self.io,
183 .path = self.path,
184 .recovery = .reject,
185 .create = !self.read_only,
186 .read_only = self.read_only,
187 });
188 self.retired = snapshot;
189 self.state = .{ .open = opened };
190 },
191 }
192 return &self.state.open;
193 }
194
195 pub fn replayed(self: *const LazyHistory) bool {
196 return self.state == .open;
197 }
198
199 pub fn checkoutBranch(self: *const LazyHistory, name: []const u8) Error!branch.Checkout {
200 return switch (self.state) {
201 .open => |*history| try history.checkoutBranch(name),
202 .deferred => |*snapshot| checkout: {
203 const entry = snapshot.find(name) orelse return error.RefNotFound;
204 break :checkout branch.Checkout.init(
205 .{ .name = entry.name, .target = entry.head },
206 entry.root,
207 );
208 },
209 };
210 }
211
212 pub fn deinit(self: *LazyHistory) void {
213 switch (self.state) {
214 .open => |*history| history.deinit(),
215 .deferred => |*snapshot| snapshot.deinit(),
216 }
217 if (self.retired) |*snapshot| snapshot.deinit();
218 self.dir.close(self.io);
219 self.allocator.free(self.path);
220 self.* = undefined;
221 }
222 };
223
224 pub const Opened = struct {
225 allocator: Allocator,
226 workspace: *Workspace,
227 workspace_owned: bool = false,
228 file: *file_mod.Database,
229 history: LazyHistory,
230 connection: connection_mod.Connection,
231 recovery: Recovery,
232
233 pub fn deinit(self: *Opened) void {
234 const allocator = self.allocator;
235 const workspace = self.workspace;
236 const workspace_owned = self.workspace_owned;
237 close(self.file, &self.history, &self.connection);
238 if (workspace_owned) {
239 workspace.deallocate(allocator);
240 allocator.destroy(workspace);
241 }
242 self.* = undefined;
243 }
244 };
245
246 pub const ReadRepairReason = union(enum) {
247 file: file_mod.ReadRepairReason,
248 missing_refs,
249 invalid_refs,
250 stale_refs,
251 missing_branch,
252 root_mismatch,
253 };
254
255 pub const ReadOpened = struct {
256 allocator: Allocator,
257 workspace: *Workspace,
258 workspace_owned: bool = false,
259 file: *file_mod.ReadOnlyDatabase,
260 catalog: catalog_mod.Reader,
261 head: version.Hash,
262 root: version.Hash,
263
264 pub fn deinit(self: *ReadOpened) void {
265 const allocator = self.allocator;
266 const workspace = self.workspace;
267 const workspace_owned = self.workspace_owned;
268 self.file.deinit();
269 self.allocator.destroy(self.file);
270 if (workspace_owned) {
271 workspace.deallocate(allocator);
272 allocator.destroy(workspace);
273 }
274 self.* = undefined;
275 }
276 };
277
278 pub const ReadOpenResult = union(enum) {
279 ready: ReadOpened,
280 repair_required: ReadRepairReason,
281 };
282
283 pub const ConnectionReadOpenResult = union(enum) {
284 ready: Opened,
285 repair_required: ReadRepairReason,
286 };
287
288 pub const ReadRefsRefreshResult = union(enum) {
289 current,
290 refreshed,
291 moved,
292 repair_required: ReadRepairReason,
293 };
294
295 pub const ReadRefsPrepareResult = union(enum) {
296 current,
297 candidate: history_mod.refs.Snapshot,
298 moved,
299 repair_required: ReadRepairReason,
300 };
301
302 pub const SparseReadOptions = struct {
303 read: ReadOptions,
304 control: wal.Control = .{},
305 };
306
307 const SparseBranch = struct {
308 covered_length: u64,
309 head: version.Hash,
310 root: version.Hash,
311 conflicts: version.Hash,
312 };
313
314 pub const SparseReadAssessment = struct {
315 catalog: catalog_mod.Reader,
316 head: version.Hash,
317 root: version.Hash,
318 branch: SparseBranch,
319 live_root: version.Hash,
320 };
321
322 pub const SparseReadBeginResult = union(enum) {
323 ready: SparseReadAssessment,
324 moved,
325 repair_required: ReadRepairReason,
326 };
327
328 pub const SparseReadFinishResult = union(enum) {
329 stable,
330 moved,
331 repair_required: ReadRepairReason,
332 };
333
334 const SparseRefreshResult = union(enum) {
335 ready: std.meta.Tag(file_mod.SparseRefreshResult),
336 repair_required: ReadRepairReason,
337 };
338
339 pub const SparseReadSession = struct {
340 workspace: wal.Scanner.Workspace,
341 file: file_mod.SparseReadOnlyDatabase = undefined,
342 file_live: bool = false,
343
344 pub fn init(self: *SparseReadSession, workspace: wal.Scanner.Workspace) void {
345 self.* = .{ .workspace = workspace };
346 }
347
348 pub fn deinit(self: *SparseReadSession) void {
349 self.resetFile();
350 self.* = undefined;
351 }
352
353 pub fn begin(
354 self: *SparseReadSession,
355 allocator: Allocator,
356 dir: std.Io.Dir,
357 options: SparseReadOptions,
358 ) Error!SparseReadBeginResult {
359 try options.control.check();
360 const branch_state = switch (try loadSparseBranch(allocator, dir, options.read, options.control)) {
361 .ready => |ready| ready,
362 .repair_required => |reason| return .{ .repair_required = reason },
363 };
364 switch (try self.refreshFile(dir, options)) {
365 .ready => {},
366 .repair_required => |reason| return .{ .repair_required = reason },
367 }
368 try options.control.check();
369 const catalog = catalog_mod.Reader.open(self.file.snapshot(), .{}) catch |err| {
370 if (err == error.RecoveryRequired) {
371 self.resetFile();
372 return .moved;
373 }
374 return err;
375 };
376 var live_root = version.readDatabaseRootMaintained(
377 allocator,
378 &catalog,
379 branch_state.conflicts,
380 ) catch |err| {
381 if (err == error.RecoveryRequired) {
382 self.resetFile();
383 return .moved;
384 }
385 return err;
386 };
387 defer live_root.deinit();
388 try options.control.check();
389 return .{ .ready = .{
390 .catalog = catalog,
391 .head = branch_state.head,
392 .root = branch_state.root,
393 .branch = branch_state,
394 .live_root = live_root.hash,
395 } };
396 }
397
398 pub fn finish(
399 self: *SparseReadSession,
400 allocator: Allocator,
401 dir: std.Io.Dir,
402 assessment: SparseReadAssessment,
403 options: SparseReadOptions,
404 ) Error!SparseReadFinishResult {
405 try options.control.check();
406 const file_moved = switch (try self.refreshFile(dir, options)) {
407 .ready => |state| state == .refreshed,
408 .repair_required => |reason| return .{ .repair_required = reason },
409 };
410 const branch_state = switch (try loadSparseBranch(allocator, dir, options.read, options.control)) {
411 .ready => |ready| ready,
412 .repair_required => |reason| return .{ .repair_required = reason },
413 };
414 const catalog = catalog_mod.Reader.open(self.file.snapshot(), .{}) catch |err| {
415 if (err == error.RecoveryRequired) {
416 self.resetFile();
417 return .moved;
418 }
419 return err;
420 };
421 var live_root = version.readDatabaseRootMaintained(
422 allocator,
423 &catalog,
424 branch_state.conflicts,
425 ) catch |err| {
426 if (err == error.RecoveryRequired) {
427 self.resetFile();
428 return .moved;
429 }
430 return err;
431 };
432 defer live_root.deinit();
433 try options.control.check();
434 if (file_moved or
435 !std.meta.eql(branch_state, assessment.branch) or
436 !version.same(live_root.hash, assessment.live_root))
437 {
438 return .moved;
439 }
440 if (!version.same(live_root.hash, branch_state.root)) {
441 return .{ .repair_required = .root_mismatch };
442 }
443 return .stable;
444 }
445
446 fn refreshFile(
447 self: *SparseReadSession,
448 dir: std.Io.Dir,
449 options: SparseReadOptions,
450 ) Error!SparseRefreshResult {
451 if (!self.file_live) {
452 const opened = file_mod.SparseReadOnlyDatabase.openExisting(dir, .{
453 .io = options.read.io,
454 .paths = options.read.paths,
455 .workspace = self.workspace,
456 .control = options.control,
457 }) catch |err| {
458 self.resetFile();
459 return err;
460 };
461 switch (opened) {
462 .ready => |ready| {
463 self.file = ready;
464 self.file_live = true;
465 return .{ .ready = .refreshed };
466 },
467 .repair_required => |reason| return .{
468 .repair_required = .{ .file = reason },
469 },
470 }
471 }
472 const refreshed = self.file.refresh(dir, .{
473 .paths = options.read.paths,
474 .control = options.control,
475 }) catch |err| {
476 self.resetFile();
477 return err;
478 };
479 return switch (refreshed) {
480 .unchanged => .{ .ready = .unchanged },
481 .refreshed => .{ .ready = .refreshed },
482 .repair_required => |reason| repair: {
483 self.resetFile();
484 break :repair .{ .repair_required = .{ .file = reason } };
485 },
486 };
487 }
488
489 fn resetFile(self: *SparseReadSession) void {
490 if (self.file_live) self.file.deinit();
491 self.file_live = false;
492 }
493 };
494
495 pub fn open(
496 allocator: Allocator,
497 workspace: *Workspace,
498 dir: std.Io.Dir,
499 options: Options,
500 ) Error!Opened {
501 var recovery: Recovery = if (fileExists(options.io, dir, options.history_path)) .clean else .created;
502 const file_ptr = try workspace.acquireWritable(allocator, dir, .{
503 .io = options.io,
504 .paths = options.paths,
505 .header = options.header,
506 .max_wal_bytes = options.max_wal_bytes,
507 .wal_capacity_bytes = options.wal_capacity_bytes,
508 .write_capacity = options.capacity,
509 .control = options.control,
510 });
511 errdefer workspace.releaseDatabase(file_ptr);
512
513 var owned_dir = try dir.openDir(options.io, ".", .{});
514 errdefer owned_dir.close(options.io);
515 const owned_path = try allocator.dupe(u8, options.history_path);
516 errdefer allocator.free(owned_path);
517
518 if (recovery == .clean) {
519 if (try deferredCheckout(allocator, dir, options)) |deferred| {
520 var snapshot = deferred.snapshot;
521 errdefer snapshot.deinit();
522 var connection = try liveConnection(
523 allocator,
524 file_ptr,
525 deferred.checkout,
526 deferred.conflicts,
527 );
528 if (version.same((try connection.workingRoot()), deferred.checkout.working.working)) {
529 return .{
530 .allocator = allocator,
531 .workspace = workspace,
532 .file = file_ptr,
533 .history = .{
534 .allocator = allocator,
535 .io = options.io,
536 .dir = owned_dir,
537 .path = owned_path,
538 .state = .{ .deferred = snapshot },
539 },
540 .connection = connection,
541 .recovery = .clean,
542 };
543 }
544 connection.deinit();
545 snapshot.deinit();
546 }
547 }
548
549 var history = history_mod.History.open(allocator, dir, .{
550 .io = options.io,
551 .path = options.history_path,
552 .recovery = .truncate,
553 .control = options.control,
554 }) catch |err| switch (err) {
555 error.InvalidHistory => blk: {
556 recovery = .rebuilt_invalid_history;
557 try dir.deleteFile(options.io, options.history_path);
558 break :blk try history_mod.History.open(allocator, dir, .{
559 .io = options.io,
560 .path = options.history_path,
561 .recovery = .truncate,
562 .control = options.control,
563 });
564 },
565 else => return err,
566 };
567 errdefer history.deinit();
568 if (history.recovery == .truncated) recovery = .truncated_history;
569
570 var connection = switch (recovery) {
571 .created, .rebuilt_invalid_history => try connection_mod.Connection.create(
572 allocator,
573 file_ptr,
574 &history,
575 .{ .branch = options.branch },
576 ),
577 .clean, .truncated_history, .rebuilt_missing_branch => replayedConnection(
578 allocator,
579 file_ptr,
580 &history,
581 options.branch,
582 ) catch |err| switch (err) {
583 error.RefNotFound => blk: {
584 recovery = .rebuilt_missing_branch;
585 break :blk try connection_mod.Connection.create(allocator, file_ptr, &history, .{ .branch = options.branch });
586 },
587 else => return err,
588 },
589 };
590 errdefer connection.deinit();
591
592 return .{
593 .allocator = allocator,
594 .workspace = workspace,
595 .file = file_ptr,
596 .history = .{
597 .allocator = allocator,
598 .io = options.io,
599 .dir = owned_dir,
600 .path = owned_path,
601 .state = .{ .open = history },
602 },
603 .connection = connection,
604 .recovery = recovery,
605 };
606 }
607
608 pub fn openForTesting(allocator: Allocator, dir: std.Io.Dir, options: Options) Error!Opened {
609 if (!builtin.is_test) @compileError("openForTesting is available only in tests");
610 const workspace = try allocator.create(Workspace);
611 errdefer allocator.destroy(workspace);
612 workspace.* = try Workspace.allocate(allocator, .{
613 .header = options.header,
614 .max_wal_bytes = options.wal_capacity_bytes orelse options.max_wal_bytes,
615 .path_storage = file_mod.PathStorage.Limits.forDirect(options.paths),
616 });
617 errdefer workspace.deallocate(allocator);
618 var opened = try open(allocator, workspace, dir, options);
619 opened.workspace_owned = true;
620 return opened;
621 }
622
623 pub fn openReadOnly(
624 allocator: Allocator,
625 workspace: *Workspace,
626 dir: std.Io.Dir,
627 options: ReadOptions,
628 ) Error!ReadOpenResult {
629 var refs_snapshot = switch (try loadReadRefs(allocator, dir, options, .{})) {
630 .ready => |ready| ready,
631 .repair_required => |reason| return .{ .repair_required = reason },
632 };
633 defer refs_snapshot.deinit();
634 return try openReadOnlyFromSnapshot(
635 allocator,
636 workspace,
637 dir,
638 options,
639 &refs_snapshot,
640 .{},
641 );
642 }
643
644 pub fn openConnectionReadOnly(
645 allocator: Allocator,
646 workspace: *Workspace,
647 dir: std.Io.Dir,
648 options: ReadOptions,
649 ) Error!ConnectionReadOpenResult {
650 var snapshot = switch (try loadReadRefs(allocator, dir, options, .{})) {
651 .ready => |ready| ready,
652 .repair_required => |reason| return .{ .repair_required = reason },
653 };
654 var snapshot_live = true;
655 defer if (snapshot_live) snapshot.deinit();
656 const entry = snapshot.find(options.branch) orelse
657 return .{ .repair_required = .missing_branch };
658 const file_ptr = try workspace.acquireReadOnly(allocator, dir, .{
659 .io = options.io,
660 .paths = options.paths,
661 .header = options.header,
662 .max_wal_bytes = options.max_wal_bytes,
663 .read_cache_capacity = options.read_cache_capacity,
664 });
665 var file_live = true;
666 defer if (file_live) workspace.releaseDatabase(file_ptr);
667 var connection = try liveConnection(
668 allocator,
669 file_ptr,
670 branch.Checkout.init(.{ .name = entry.name, .target = entry.head }, entry.root),
671 entry.conflicts,
672 );
673 var connection_live = true;
674 defer if (connection_live) connection.deinit();
675 if (!version.same(try connection.workingRoot(), entry.root)) {
676 return .{ .repair_required = .root_mismatch };
677 }
678 var owned_dir = try dir.openDir(options.io, ".", .{});
679 errdefer owned_dir.close(options.io);
680 const owned_path = try allocator.dupe(u8, options.history_path);
681 snapshot_live = false;
682 file_live = false;
683 connection_live = false;
684 return .{ .ready = .{
685 .allocator = allocator,
686 .workspace = workspace,
687 .file = file_ptr,
688 .history = .{
689 .allocator = allocator,
690 .io = options.io,
691 .dir = owned_dir,
692 .path = owned_path,
693 .state = .{ .deferred = snapshot },
694 .read_only = true,
695 },
696 .connection = connection,
697 .recovery = .clean,
698 } };
699 }
700
701 fn openReadOnlyFromSnapshot(
702 allocator: Allocator,
703 workspace: *Workspace,
704 dir: std.Io.Dir,
705 options: ReadOptions,
706 refs_snapshot: *const history_mod.refs.Snapshot,
707 control: wal.Control,
708 ) Error!ReadOpenResult {
709 try control.check();
710 const entry = refs_snapshot.find(options.branch) orelse
711 return .{ .repair_required = .missing_branch };
712
713 var database_value = switch (try file_mod.ReadOnlyDatabase.openExisting(
714 allocator,
715 &workspace.file_workspace,
716 dir,
717 .{
718 .io = options.io,
719 .paths = options.paths,
720 .header = options.header,
721 .max_wal_bytes = options.max_wal_bytes,
722 .read_cache_capacity = options.read_cache_capacity,
723 .control = control,
724 },
725 )) {
726 .ready => |ready| ready,
727 .repair_required => |reason| return .{ .repair_required = .{ .file = reason } },
728 };
729 const database = allocator.create(file_mod.ReadOnlyDatabase) catch |err| {
730 database_value.deinit();
731 return err;
732 };
733 database.* = database_value;
734 var database_live = true;
735 defer if (database_live) {
736 database.deinit();
737 allocator.destroy(database);
738 };
739
740 try control.check();
741 const catalog = try catalog_mod.Reader.open(database.snapshot(), .{});
742 var live_root = try version.readDatabaseRootMaintained(allocator, &catalog, entry.conflicts);
743 defer live_root.deinit();
744 try control.check();
745 if (!version.same(live_root.hash, entry.root)) {
746 return .{ .repair_required = .root_mismatch };
747 }
748
749 database_live = false;
750 return .{ .ready = .{
751 .allocator = allocator,
752 .workspace = workspace,
753 .file = database,
754 .catalog = catalog,
755 .head = entry.head,
756 .root = entry.root,
757 } };
758 }
759
760 pub fn prepareReadRefs(
761 allocator: Allocator,
762 workspace: *Workspace,
763 dir: std.Io.Dir,
764 options: ReadOptions,
765 control: wal.Control,
766 ) Error!ReadRefsPrepareResult {
767 try control.check();
768 var path_buffer: [history_mod.refs.path_bytes_max]u8 = undefined;
769 if (history_mod.refs.pathFor(&path_buffer, options.history_path) == null) {
770 return .{ .repair_required = .invalid_refs };
771 }
772 var snapshot = (history_mod.refs.loadExisting(
773 allocator,
774 options.io,
775 dir,
776 options.history_path,
777 ) catch |err| switch (err) {
778 error.FileNotFound => return .{ .repair_required = .missing_refs },
779 else => return err,
780 }) orelse return .{ .repair_required = .invalid_refs };
781 defer snapshot.deinit();
782 const history_stat = dir.statFile(
783 options.io,
784 options.history_path,
785 .{},
786 ) catch |err| switch (err) {
787 error.FileNotFound => return .{ .repair_required = .stale_refs },
788 else => return err,
789 };
790 if (snapshot.covered_length == history_stat.size) {
791 const reason = try validateReadRefs(
792 allocator,
793 workspace,
794 dir,
795 options,
796 &snapshot,
797 control,
798 );
799 if (reason) |value| return .{ .repair_required = value };
800 try control.check();
801 const final_stat = dir.statFile(options.io, options.history_path, .{}) catch
802 return .moved;
803 return if (final_stat.size == history_stat.size) .current else .moved;
804 }
805 if (snapshot.covered_length > history_stat.size) {
806 return .{ .repair_required = .stale_refs };
807 }
808 return prepareStaleReadRefs(
809 allocator,
810 workspace,
811 dir,
812 options,
813 &snapshot,
814 history_stat.size,
815 control,
816 );
817 }
818
819 fn prepareStaleReadRefs(
820 allocator: Allocator,
821 workspace: *Workspace,
822 dir: std.Io.Dir,
823 options: ReadOptions,
824 snapshot: *const history_mod.refs.Snapshot,
825 history_length: u64,
826 control: wal.Control,
827 ) Error!ReadRefsPrepareResult {
828 var candidate = history_mod.refs.advance(
829 allocator,
830 options.io,
831 dir,
832 options.history_path,
833 snapshot,
834 history_length,
835 control,
836 ) catch |err| switch (err) {
837 error.InvalidHistory,
838 error.TruncatedHistory,
839 error.RecoveryRequired,
840 error.StreamTooLong,
841 => return .{ .repair_required = .stale_refs },
842 else => return err,
843 };
844 var candidate_live = true;
845 defer if (candidate_live) candidate.deinit();
846 if (try validateReadRefs(
847 allocator,
848 workspace,
849 dir,
850 options,
851 &candidate,
852 control,
853 )) |reason| return .{ .repair_required = reason };
854 const final_stat = dir.statFile(options.io, options.history_path, .{}) catch
855 return .moved;
856 if (final_stat.size != history_length) return .moved;
857 try control.check();
858 candidate_live = false;
859 return .{ .candidate = candidate };
860 }
861
862 fn refreshReadRefs(
863 allocator: Allocator,
864 workspace: *Workspace,
865 dir: std.Io.Dir,
866 options: ReadOptions,
867 control: wal.Control,
868 ) Error!ReadRefsRefreshResult {
869 if (!builtin.is_test) @compileError("read refs publication is tracker-owned");
870 return switch (try prepareReadRefs(
871 allocator,
872 workspace,
873 dir,
874 options,
875 control,
876 )) {
877 .current => .current,
878 .candidate => |candidate_value| publish: {
879 var candidate = candidate_value;
880 defer candidate.deinit();
881 try history_mod.refs.store(
882 allocator,
883 options.io,
884 dir,
885 options.history_path,
886 candidate.covered_length,
887 candidate.entries,
888 );
889 break :publish .refreshed;
890 },
891 .moved => .moved,
892 .repair_required => |reason| .{ .repair_required = reason },
893 };
894 }
895
896 fn validateReadRefs(
897 allocator: Allocator,
898 workspace: *Workspace,
899 dir: std.Io.Dir,
900 options: ReadOptions,
901 snapshot: *const history_mod.refs.Snapshot,
902 control: wal.Control,
903 ) Error!?ReadRepairReason {
904 return switch (try openReadOnlyFromSnapshot(
905 allocator,
906 workspace,
907 dir,
908 options,
909 snapshot,
910 control,
911 )) {
912 .ready => |ready_value| ready: {
913 var ready = ready_value;
914 ready.deinit();
915 break :ready null;
916 },
917 .repair_required => |reason| reason,
918 };
919 }
920
921 pub fn openReadOnlyForTesting(
922 allocator: Allocator,
923 dir: std.Io.Dir,
924 options: ReadOptions,
925 ) Error!ReadOpenResult {
926 if (!builtin.is_test) @compileError("openReadOnlyForTesting is available only in tests");
927 const workspace = try allocator.create(Workspace);
928 errdefer allocator.destroy(workspace);
929 workspace.* = try Workspace.allocate(allocator, .{
930 .header = options.header,
931 .max_wal_bytes = options.max_wal_bytes,
932 .path_storage = .{
933 .database_bytes = 0,
934 .wal_bytes = 0,
935 },
936 .read_cache_pages = options.read_cache_capacity,
937 });
938 errdefer workspace.deallocate(allocator);
939 const result = try openReadOnly(allocator, workspace, dir, options);
940 return switch (result) {
941 .ready => |ready| opened: {
942 var value = ready;
943 value.workspace_owned = true;
944 break :opened .{ .ready = value };
945 },
946 .repair_required => |reason| repair: {
947 workspace.deallocate(allocator);
948 allocator.destroy(workspace);
949 break :repair .{ .repair_required = reason };
950 },
951 };
952 }
953
954 pub fn close(
955 file_ptr: *file_mod.Database,
956 history: *LazyHistory,
957 connection: *connection_mod.Connection,
958 ) void {
959 connection.deinit();
960 history.deinit();
961 Workspace.ownerOf(file_ptr).releaseDatabase(file_ptr);
962 }
963
964 pub fn commit(
965 connection: *connection_mod.Connection,
966 history: *LazyHistory,
967 file_ptr: *file_mod.Database,
968 ) Error!version.Hash {
969 var healed = false;
970 const replay = history.full() catch |err| switch (err) {
971 error.InvalidHistory, error.TruncatedHistory => blk: {
972 healed = true;
973 break :blk try rebuiltHistory(connection, history);
974 },
975 else => return err,
976 };
977 try connection.stage();
978 const head = connection.commit(replay) catch |err| switch (err) {
979 error.NoStagedRoot => head: {
980 if (!healed) return err;
981 break :head (try connection.checkout()).head;
982 },
983 else => return err,
984 };
985 try file_ptr.syncWal();
986 return head;
987 }
988
989 fn rebuiltHistory(
990 connection: *connection_mod.Connection,
991 history: *LazyHistory,
992 ) Error!*history_mod.History {
993 std.debug.assert(!history.replayed());
994 history.dir.deleteFile(history.io, history.path) catch |err| switch (err) {
995 error.FileNotFound => {},
996 else => return err,
997 };
998 const branch_name = (try connection.checkout()).name;
999 const fresh = try history_mod.History.open(history.allocator, history.dir, .{
1000 .io = history.io,
1001 .path = history.path,
1002 .recovery = .reject,
1003 });
1004 const snapshot = history.state.deferred;
1005 history.state = .{ .open = fresh };
1006 std.debug.assert(history.retired == null);
1007 history.retired = snapshot;
1008 const opened = &history.state.open;
1009 try connection.adoptRebuiltHistory(opened, branch_name);
1010 return opened;
1011 }
1012
1013 pub fn mergeCommit(
1014 connection: *connection_mod.Connection,
1015 history: *LazyHistory,
1016 file_ptr: *file_mod.Database,
1017 theirs: version.Hash,
1018 ) Error!version.Hash {
1019 const replay = try history.full();
1020 const head = try connection.mergeCommit(replay, theirs);
1021 try file_ptr.syncWal();
1022 return head;
1023 }
1024
1025 pub fn alignToBranchHead(
1026 allocator: Allocator,
1027 connection: *connection_mod.Connection,
1028 history: *LazyHistory,
1029 file_ptr: *file_mod.Database,
1030 branch_name: []const u8,
1031 ) Error!void {
1032 try connection.checkoutBranch(allocator, try history.full(), branch_name);
1033 try file_ptr.syncWal();
1034 }
1035
1036 pub fn fastForwardBranch(
1037 allocator: Allocator,
1038 connection: *connection_mod.Connection,
1039 history: *LazyHistory,
1040 target: version.Hash,
1041 ) Error!void {
1042 try connection.fastForwardBranch(allocator, try history.full(), target);
1043 }
1044
1045 pub fn walSize(file_ptr: *const file_mod.Database) usize {
1046 return file_ptr.pager.walBytes().len;
1047 }
1048
1049 pub fn checkpoint(file_ptr: *file_mod.Database, header: wal.Header) Error!void {
1050 _ = try file_ptr.checkpoint(.{ .restart_header = header });
1051 }
1052
1053 pub fn checkpointIfWalLarge(file_ptr: *file_mod.Database, header: wal.Header, threshold: usize) Error!void {
1054 if (walSize(file_ptr) >= threshold) try checkpoint(file_ptr, header);
1055 }
1056
1057 const DeferredCheckout = struct {
1058 snapshot: history_mod.refs.Snapshot,
1059 checkout: branch.Checkout,
1060 conflicts: version.Hash,
1061 };
1062
1063 const ReadRefsResult = union(enum) {
1064 ready: history_mod.refs.Snapshot,
1065 repair_required: ReadRepairReason,
1066 };
1067
1068 const SparseBranchResult = union(enum) {
1069 ready: SparseBranch,
1070 repair_required: ReadRepairReason,
1071 };
1072
1073 fn loadSparseBranch(
1074 allocator: Allocator,
1075 dir: std.Io.Dir,
1076 options: ReadOptions,
1077 control: wal.Control,
1078 ) Error!SparseBranchResult {
1079 var snapshot = switch (try loadReadRefs(allocator, dir, options, control)) {
1080 .ready => |ready| ready,
1081 .repair_required => |reason| return .{ .repair_required = reason },
1082 };
1083 defer snapshot.deinit();
1084 const entry = snapshot.find(options.branch) orelse
1085 return .{ .repair_required = .missing_branch };
1086 return .{ .ready = .{
1087 .covered_length = snapshot.covered_length,
1088 .head = entry.head,
1089 .root = entry.root,
1090 .conflicts = entry.conflicts,
1091 } };
1092 }
1093
1094 fn loadReadRefs(
1095 allocator: Allocator,
1096 dir: std.Io.Dir,
1097 options: ReadOptions,
1098 control: wal.Control,
1099 ) Error!ReadRefsResult {
1100 try control.check();
1101 var path_buffer: [history_mod.refs.path_bytes_max]u8 = undefined;
1102 if (history_mod.refs.pathFor(&path_buffer, options.history_path) == null) {
1103 return .{ .repair_required = .invalid_refs };
1104 }
1105 var snapshot = (history_mod.refs.loadExisting(
1106 allocator,
1107 options.io,
1108 dir,
1109 options.history_path,
1110 ) catch |err| switch (err) {
1111 error.FileNotFound => return .{ .repair_required = .missing_refs },
1112 else => return err,
1113 }) orelse return .{ .repair_required = .invalid_refs };
1114 var snapshot_live = true;
1115 defer if (snapshot_live) snapshot.deinit();
1116 const history_stat = dir.statFile(
1117 options.io,
1118 options.history_path,
1119 .{},
1120 ) catch |err| switch (err) {
1121 error.FileNotFound => return .{ .repair_required = .stale_refs },
1122 else => return err,
1123 };
1124 if (snapshot.covered_length != history_stat.size) {
1125 if (!options.advance_refs or snapshot.covered_length > history_stat.size) {
1126 return .{ .repair_required = .stale_refs };
1127 }
1128 const advanced = history_mod.refs.advance(
1129 allocator,
1130 options.io,
1131 dir,
1132 options.history_path,
1133 &snapshot,
1134 history_stat.size,
1135 control,
1136 ) catch |err| switch (err) {
1137 error.InvalidHistory,
1138 error.TruncatedHistory,
1139 error.RecoveryRequired,
1140 error.StreamTooLong,
1141 => return .{ .repair_required = .stale_refs },
1142 else => return err,
1143 };
1144 snapshot.deinit();
1145 snapshot = advanced;
1146 try control.check();
1147 const final_stat = try dir.statFile(options.io, options.history_path, .{});
1148 if (final_stat.size != history_stat.size) {
1149 return .{ .repair_required = .stale_refs };
1150 }
1151 }
1152 snapshot_live = false;
1153 return .{ .ready = snapshot };
1154 }
1155
1156 fn deferredCheckout(allocator: Allocator, dir: std.Io.Dir, options: Options) Error!?DeferredCheckout {
1157 var snapshot = (try history_mod.refs.load(allocator, options.io, dir, options.history_path)) orelse return null;
1158 var snapshot_live = true;
1159 defer if (snapshot_live) snapshot.deinit();
1160 const stat = dir.statFile(options.io, options.history_path, .{}) catch return null;
1161 if (snapshot.covered_length != stat.size) return null;
1162 const entry = snapshot.find(options.branch) orelse return null;
1163 snapshot_live = false;
1164 return .{
1165 .snapshot = snapshot,
1166 .checkout = branch.Checkout.init(.{ .name = entry.name, .target = entry.head }, entry.root),
1167 .conflicts = entry.conflicts,
1168 };
1169 }
1170
1171 fn liveConnection(
1172 allocator: Allocator,
1173 file_ptr: *file_mod.Database,
1174 checkout: branch.Checkout,
1175 conflicts: version.Hash,
1176 ) Error!connection_mod.Connection {
1177 const catalog = try catalog_mod.Catalog.open(file_ptr, .{});
1178 var root = try version.databaseRootMaintained(allocator, &catalog, conflicts);
1179 errdefer root.deinit();
1180 const live_checkout = checkout.withWorking(root.hash);
1181 const session = session_mod.DatabaseSession.initWithRoot(allocator, live_checkout, &root);
1182 return connection_mod.Connection.init(catalog, session);
1183 }
1184
1185 fn replayedConnection(
1186 allocator: Allocator,
1187 file_ptr: *file_mod.Database,
1188 history: *history_mod.History,
1189 branch_name: []const u8,
1190 ) Error!connection_mod.Connection {
1191 return try connection_mod.Connection.open(allocator, file_ptr, history, .{
1192 .branch = branch_name,
1193 });
1194 }
1195
1196 fn fileExists(io: std.Io, dir: std.Io.Dir, path: []const u8) bool {
1197 _ = dir.statFile(io, path, .{}) catch return false;
1198 return true;
1199 }
1200
1201 const testing_io = std.Options.debug_io;
1202
1203 fn testingHeader() wal.Header {
1204 return .{
1205 .sequence = 91,
1206 .salt = .{ .first = 0x0101_2323, .second = 0x4545_6767 },
1207 };
1208 }
1209
1210 fn testingOptions() Options {
1211 return .{
1212 .io = testing_io,
1213 .paths = .{ .database = "live.db", .wal = "live.wal" },
1214 .history_path = "live.history",
1215 .header = testingHeader(),
1216 };
1217 }
1218
1219 fn testingReadOptions() ReadOptions {
1220 return .{
1221 .io = testing_io,
1222 .paths = .{ .database = "live.db", .wal = "live.wal" },
1223 .history_path = "live.history",
1224 .header = testingHeader(),
1225 };
1226 }
1227
1228 fn commitTestingRow(opened: *Opened, allocator: Allocator, statement: []const u8) !version.Hash {
1229 var result = try opened.connection.execute(allocator, statement, .{ .durability = .buffered });
1230 result.deinit(allocator);
1231 return try commit(&opened.connection, &opened.history, opened.file);
1232 }
1233
1234 const TestingReadState = struct {
1235 head: version.Hash,
1236 root: version.Hash,
1237 };
1238
1239 const TestingControl = struct {
1240 calls: usize = 0,
1241 interrupt_at: ?usize = null,
1242
1243 fn control(self: *TestingControl) wal.Control {
1244 return .{ .context = self, .interrupted_fn = interrupted };
1245 }
1246
1247 fn interrupted(context: ?*anyopaque) bool {
1248 const self: *TestingControl = @ptrCast(@alignCast(context.?));
1249 const call = self.calls;
1250 self.calls += 1;
1251 return if (self.interrupt_at) |interrupt_at| call == interrupt_at else false;
1252 }
1253 };
1254
1255 const TestingHistoryMove = struct {
1256 history: *history_mod.History,
1257 root: version.DatabaseRoot,
1258 move_at: usize,
1259 calls: usize = 0,
1260 moved: bool = false,
1261 failed: bool = false,
1262
1263 fn control(self: *TestingHistoryMove) wal.Control {
1264 return .{ .context = self, .interrupted_fn = interrupted };
1265 }
1266
1267 fn interrupted(context: ?*anyopaque) bool {
1268 const self: *TestingHistoryMove = @ptrCast(@alignCast(context.?));
1269 const call = self.calls;
1270 self.calls += 1;
1271 if (call == self.move_at) {
1272 self.history.putDatabaseRoot(self.root) catch {
1273 self.failed = true;
1274 return false;
1275 };
1276 self.moved = true;
1277 }
1278 return false;
1279 }
1280 };
1281
1282 fn overwriteTestingRefs(dir: std.Io.Dir, bytes: []const u8) !void {
1283 var file = try dir.createFile(
1284 testing_io,
1285 "live.history.refs",
1286 .{ .read = true, .truncate = true },
1287 );
1288 defer file.close(testing_io);
1289 try file.writePositionalAll(testing_io, bytes, 0);
1290 }
1291
1292 fn createTestingReadState(allocator: Allocator, dir: std.Io.Dir) !TestingReadState {
1293 var opened = try openForTesting(allocator, dir, testingOptions());
1294 defer opened.deinit();
1295 const head = try commitTestingRow(&opened, allocator, "CREATE TABLE items (name)");
1296 return .{ .head = head, .root = try opened.connection.workingRoot() };
1297 }
1298
1299 fn readRepairReason(dir: std.Io.Dir, options: ReadOptions) !ReadRepairReason {
1300 return switch (try openReadOnlyForTesting(std.testing.allocator, dir, options)) {
1301 .repair_required => |reason| reason,
1302 .ready => |ready_value| {
1303 var ready = ready_value;
1304 ready.deinit();
1305 return error.ExpectedRepairRequired;
1306 },
1307 };
1308 }
1309
1310 fn expectReadRepairTag(
1311 dir: std.Io.Dir,
1312 options: ReadOptions,
1313 expected: std.meta.Tag(ReadRepairReason),
1314 ) !void {
1315 try std.testing.expectEqual(expected, std.meta.activeTag(try readRepairReason(dir, options)));
1316 }
1317
1318 const TestingConflictHistory = struct {
1319 commit: version.Hash,
1320 database: version.Hash,
1321 conflicts: version.Hash,
1322 artifact: version.Hash,
1323 };
1324
1325 fn createTestingConflictHistory(allocator: Allocator, dir: std.Io.Dir) !TestingConflictHistory {
1326 var opened = try openForTesting(allocator, dir, testingOptions());
1327 defer opened.deinit();
1328 _ = try commitTestingRow(&opened, allocator, "CREATE TABLE items (name)");
1329 _ = try commitTestingRow(
1330 &opened,
1331 allocator,
1332 "INSERT INTO items (rowid, name) VALUES (1, 'base')",
1333 );
1334 _ = try opened.connection.createBranch(try opened.history.full(), "side");
1335 _ = try commitTestingRow(
1336 &opened,
1337 allocator,
1338 "UPDATE items SET name = 'ours' WHERE rowid = 1",
1339 );
1340 try alignToBranchHead(allocator, &opened.connection, &opened.history, opened.file, "side");
1341 const side = try commitTestingRow(
1342 &opened,
1343 allocator,
1344 "UPDATE items SET name = 'theirs' WHERE rowid = 1",
1345 );
1346 try alignToBranchHead(allocator, &opened.connection, &opened.history, opened.file, "main");
1347 var merged = try opened.connection.mergeBranch(
1348 allocator,
1349 try opened.history.full(),
1350 "side",
1351 .{},
1352 );
1353 defer merged.deinit();
1354 return .{
1355 .commit = try mergeCommit(&opened.connection, &opened.history, opened.file, side),
1356 .database = try opened.connection.workingRoot(),
1357 .conflicts = merged.conflict_root.hash,
1358 .artifact = merged.artifacts[0].hash,
1359 };
1360 }
1361
1362 test "lifecycle workspace reuses one pinned writable slot" {
1363 const allocator = std.testing.allocator;
1364 var tmp = std.testing.tmpDir(.{});
1365 defer tmp.cleanup();
1366 const options = testingOptions();
1367 var workspace = try Workspace.allocate(allocator, .{
1368 .header = options.header,
1369 .max_wal_bytes = options.wal_capacity_bytes orelse options.max_wal_bytes,
1370 .path_storage = file_mod.PathStorage.Limits.forDirect(options.paths),
1371 });
1372 defer workspace.deallocate(allocator);
1373
1374 var first = try open(allocator, &workspace, tmp.dir, options);
1375 const slot = first.file;
1376 try std.testing.expectEqual(&workspace.database, slot);
1377 try std.testing.expectError(
1378 error.WorkspaceBusy,
1379 open(allocator, &workspace, tmp.dir, options),
1380 );
1381 first.deinit();
1382
1383 var second = try open(allocator, &workspace, tmp.dir, options);
1384 defer second.deinit();
1385 try std.testing.expectEqual(slot, second.file);
1386 }
1387
1388 test "lifecycle read-only open owns a stable maintained snapshot" {
1389 const allocator = std.testing.allocator;
1390 var tmp = std.testing.tmpDir(.{});
1391 defer tmp.cleanup();
1392 const expected = try createTestingReadState(allocator, tmp.dir);
1393
1394 var opened = switch (try openReadOnlyForTesting(allocator, tmp.dir, testingReadOptions())) {
1395 .ready => |ready| ready,
1396 .repair_required => return error.UnexpectedRepairRequired,
1397 };
1398 defer opened.deinit();
1399 try std.testing.expect(version.same(expected.head, opened.head));
1400 try std.testing.expect(version.same(expected.root, opened.root));
1401 var names = try opened.catalog.relationNames(allocator);
1402 defer names.deinit();
1403 try std.testing.expectEqual(@as(usize, 1), names.names.len);
1404 try std.testing.expectEqualSlices(u8, "items", names.names[0]);
1405 }
1406
1407 test "lifecycle read connection derives stale refs and rejects durable writes" {
1408 const allocator = std.testing.allocator;
1409 var tmp = std.testing.tmpDir(.{});
1410 defer tmp.cleanup();
1411 _ = try createTestingReadState(allocator, tmp.dir);
1412 const stale = try tmp.dir.readFileAlloc(
1413 testing_io,
1414 "live.history.refs",
1415 allocator,
1416 .limited(1 << 20),
1417 );
1418 defer allocator.free(stale);
1419 var expected: version.Hash = undefined;
1420 {
1421 var writer = try openForTesting(allocator, tmp.dir, testingOptions());
1422 defer writer.deinit();
1423 expected = try commitTestingRow(
1424 &writer,
1425 allocator,
1426 "INSERT INTO items (rowid, name) VALUES (2, 'later')",
1427 );
1428 }
1429 try overwriteTestingRefs(tmp.dir, stale);
1430 const before = try readArtifactMetadata(tmp.dir);
1431 var options = testingReadOptions();
1432 options.advance_refs = true;
1433 var workspace = try Workspace.allocate(allocator, .{
1434 .header = options.header,
1435 .max_wal_bytes = options.max_wal_bytes,
1436 .path_storage = file_mod.PathStorage.Limits.forDirect(options.paths),
1437 });
1438 defer workspace.deallocate(allocator);
1439 {
1440 var opened = switch (try openConnectionReadOnly(allocator, &workspace, tmp.dir, options)) {
1441 .ready => |ready| ready,
1442 .repair_required => return error.UnexpectedRepairRequired,
1443 };
1444 defer opened.deinit();
1445 try std.testing.expect(version.same(expected, opened.connection.session.checkout.head));
1446 const history = try opened.history.full();
1447 try std.testing.expect(version.same(expected, (try history.checkoutBranch("main")).head));
1448 try std.testing.expectError(error.ReadOnly, history.createBranch("forbidden", expected));
1449 try std.testing.expectError(error.ReadOnlyDatabase, opened.connection.execute(
1450 allocator,
1451 "INSERT INTO items (rowid, name) VALUES (3, 'forbidden')",
1452 .{},
1453 ));
1454 }
1455 try std.testing.expectEqualDeep(before, try readArtifactMetadata(tmp.dir));
1456 const after = try tmp.dir.readFileAlloc(
1457 testing_io,
1458 "live.history.refs",
1459 allocator,
1460 .limited(1 << 20),
1461 );
1462 defer allocator.free(after);
1463 try std.testing.expectEqualSlices(u8, stale, after);
1464 }
1465
1466 const ReadArtifactMetadata = struct {
1467 size: u64,
1468 mtime: std.Io.Timestamp,
1469 ctime: std.Io.Timestamp,
1470 };
1471
1472 fn readArtifactMetadata(dir: std.Io.Dir) ![4]ReadArtifactMetadata {
1473 const paths = [_][]const u8{
1474 "live.db", "live.wal", "live.history", "live.history.refs",
1475 };
1476 var values: [paths.len]ReadArtifactMetadata = undefined;
1477 for (paths, &values) |path, *value| {
1478 const stat = try dir.statFile(testing_io, path, .{});
1479 value.* = .{ .size = stat.size, .mtime = stat.mtime, .ctime = stat.ctime };
1480 }
1481 return values;
1482 }
1483
1484 test "lifecycle read-only forwards read cache capacity" {
1485 const allocator = std.testing.allocator;
1486 var tmp = std.testing.tmpDir(.{});
1487 defer tmp.cleanup();
1488 _ = try createTestingReadState(allocator, tmp.dir);
1489
1490 var options = testingReadOptions();
1491 options.read_cache_capacity = 3;
1492 var opened = switch (try openReadOnlyForTesting(allocator, tmp.dir, options)) {
1493 .ready => |ready| ready,
1494 .repair_required => return error.UnexpectedRepairRequired,
1495 };
1496 defer opened.deinit();
1497 try std.testing.expectEqual(@as(usize, 3), opened.file.readCacheCapacity());
1498 }
1499
1500 test "lifecycle late read refs cancellation preserves the stale sidecar" {
1501 const allocator = std.testing.allocator;
1502 var tmp = std.testing.tmpDir(.{});
1503 defer tmp.cleanup();
1504 const options = testingOptions();
1505 {
1506 var opened = try openForTesting(allocator, tmp.dir, options);
1507 defer opened.deinit();
1508 _ = try commitTestingRow(&opened, allocator, "CREATE TABLE items (name)");
1509 }
1510 const stale = try tmp.dir.readFileAlloc(
1511 testing_io,
1512 "live.history.refs",
1513 allocator,
1514 .unlimited,
1515 );
1516 defer allocator.free(stale);
1517 {
1518 var opened = try openForTesting(allocator, tmp.dir, options);
1519 defer opened.deinit();
1520 _ = try commitTestingRow(
1521 &opened,
1522 allocator,
1523 "INSERT INTO items (rowid, name) VALUES (1, 'later')",
1524 );
1525 }
1526 var workspace = try Workspace.allocate(allocator, .{
1527 .header = options.header,
1528 .max_wal_bytes = options.max_wal_bytes,
1529 .path_storage = .{ .database_bytes = 0, .wal_bytes = 0 },
1530 .read_cache_pages = file_mod.default_read_cache_capacity,
1531 });
1532 defer workspace.deallocate(allocator);
1533
1534 try overwriteTestingRefs(tmp.dir, stale);
1535 var successful = TestingControl{};
1536 try std.testing.expectEqual(
1537 std.meta.Tag(ReadRefsRefreshResult).refreshed,
1538 std.meta.activeTag(try refreshReadRefs(
1539 allocator,
1540 &workspace,
1541 tmp.dir,
1542 testingReadOptions(),
1543 successful.control(),
1544 )),
1545 );
1546 try std.testing.expect(successful.calls > 0);
1547
1548 try overwriteTestingRefs(tmp.dir, stale);
1549 var interrupted = TestingControl{ .interrupt_at = successful.calls - 1 };
1550 try std.testing.expectError(
1551 error.Interrupted,
1552 refreshReadRefs(
1553 allocator,
1554 &workspace,
1555 tmp.dir,
1556 testingReadOptions(),
1557 interrupted.control(),
1558 ),
1559 );
1560 const after = try tmp.dir.readFileAlloc(
1561 testing_io,
1562 "live.history.refs",
1563 allocator,
1564 .unlimited,
1565 );
1566 defer allocator.free(after);
1567 try std.testing.expectEqualSlices(u8, stale, after);
1568 }
1569
1570 test "lifecycle moved history preserves stale refs before retry publication" {
1571 const allocator = std.testing.allocator;
1572 var tmp = std.testing.tmpDir(.{});
1573 defer tmp.cleanup();
1574 const options = testingOptions();
1575 {
1576 var opened = try openForTesting(allocator, tmp.dir, options);
1577 defer opened.deinit();
1578 _ = try commitTestingRow(&opened, allocator, "CREATE TABLE items (name)");
1579 }
1580 const stale = try tmp.dir.readFileAlloc(
1581 testing_io,
1582 "live.history.refs",
1583 allocator,
1584 .unlimited,
1585 );
1586 defer allocator.free(stale);
1587 {
1588 var opened = try openForTesting(allocator, tmp.dir, options);
1589 defer opened.deinit();
1590 _ = try commitTestingRow(
1591 &opened,
1592 allocator,
1593 "INSERT INTO items (rowid, name) VALUES (1, 'later')",
1594 );
1595 }
1596
1597 var history = try history_mod.History.open(allocator, tmp.dir, .{
1598 .io = testing_io,
1599 .path = options.history_path,
1600 .recovery = .reject,
1601 });
1602 defer history.deinit();
1603 try overwriteTestingRefs(tmp.dir, stale);
1604 const history_before = try tmp.dir.statFile(testing_io, options.history_path, .{});
1605 var workspace = try Workspace.allocate(allocator, .{
1606 .header = options.header,
1607 .max_wal_bytes = options.max_wal_bytes,
1608 .path_storage = .{ .database_bytes = 0, .wal_bytes = 0 },
1609 .read_cache_pages = file_mod.default_read_cache_capacity,
1610 });
1611 defer workspace.deallocate(allocator);
1612 const moved_root = version.DatabaseRoot.init(&.{.{
1613 .name = "unreferenced",
1614 .hash = version.emptyHash("lifecycle.moved-history"),
1615 }}, version.ConflictRoot.empty());
1616 var move = TestingHistoryMove{
1617 .history = &history,
1618 .root = moved_root,
1619 .move_at = 1,
1620 };
1621
1622 try std.testing.expectEqual(
1623 std.meta.Tag(ReadRefsRefreshResult).moved,
1624 std.meta.activeTag(try refreshReadRefs(
1625 allocator,
1626 &workspace,
1627 tmp.dir,
1628 testingReadOptions(),
1629 move.control(),
1630 )),
1631 );
1632 try std.testing.expect(move.moved);
1633 try std.testing.expect(!move.failed);
1634 const history_after = try tmp.dir.statFile(testing_io, options.history_path, .{});
1635 try std.testing.expect(history_after.size > history_before.size);
1636 const after_move = try tmp.dir.readFileAlloc(
1637 testing_io,
1638 "live.history.refs",
1639 allocator,
1640 .unlimited,
1641 );
1642 defer allocator.free(after_move);
1643 try std.testing.expectEqualSlices(u8, stale, after_move);
1644
1645 try std.testing.expectEqual(
1646 std.meta.Tag(ReadRefsRefreshResult).refreshed,
1647 std.meta.activeTag(try refreshReadRefs(
1648 allocator,
1649 &workspace,
1650 tmp.dir,
1651 testingReadOptions(),
1652 .{},
1653 )),
1654 );
1655 var repaired = (try history_mod.refs.loadExisting(
1656 allocator,
1657 testing_io,
1658 tmp.dir,
1659 options.history_path,
1660 )).?;
1661 defer repaired.deinit();
1662 try std.testing.expectEqual(history_after.size, repaired.covered_length);
1663 }
1664
1665 test "lifecycle read-only classifies branch root and freshness repairs" {
1666 const allocator = std.testing.allocator;
1667 var tmp = std.testing.tmpDir(.{});
1668 defer tmp.cleanup();
1669 _ = try createTestingReadState(allocator, tmp.dir);
1670
1671 var missing_branch = testingReadOptions();
1672 missing_branch.branch = "missing";
1673 try expectReadRepairTag(tmp.dir, missing_branch, .missing_branch);
1674
1675 var snapshot = (try history_mod.refs.load(
1676 allocator,
1677 testing_io,
1678 tmp.dir,
1679 "live.history",
1680 )) orelse return error.SnapshotMissing;
1681 defer snapshot.deinit();
1682 var changed = false;
1683 for (snapshot.entries) |*entry| {
1684 if (!std.mem.eql(u8, entry.name, "main")) continue;
1685 entry.root = version.emptyHash("read-only root mismatch");
1686 changed = true;
1687 break;
1688 }
1689 try std.testing.expect(changed);
1690 try history_mod.refs.store(
1691 allocator,
1692 testing_io,
1693 tmp.dir,
1694 "live.history",
1695 snapshot.covered_length,
1696 snapshot.entries,
1697 );
1698 try expectReadRepairTag(tmp.dir, testingReadOptions(), .root_mismatch);
1699
1700 try history_mod.refs.store(
1701 allocator,
1702 testing_io,
1703 tmp.dir,
1704 "live.history",
1705 snapshot.covered_length + 1,
1706 snapshot.entries,
1707 );
1708 try expectReadRepairTag(tmp.dir, testingReadOptions(), .stale_refs);
1709 }
1710
1711 test "lifecycle read-only classifies missing and invalid refs" {
1712 const allocator = std.testing.allocator;
1713 var tmp = std.testing.tmpDir(.{});
1714 defer tmp.cleanup();
1715 _ = try createTestingReadState(allocator, tmp.dir);
1716 const refs_path = "live.history" ++ history_mod.refs.suffix;
1717
1718 try tmp.dir.deleteFile(testing_io, refs_path);
1719 try expectReadRepairTag(tmp.dir, testingReadOptions(), .missing_refs);
1720 {
1721 var refs_file = try tmp.dir.createFile(testing_io, refs_path, .{
1722 .read = true,
1723 .truncate = true,
1724 });
1725 defer refs_file.close(testing_io);
1726 try refs_file.writePositionalAll(testing_io, "invalid refs", 0);
1727 }
1728 try expectReadRepairTag(tmp.dir, testingReadOptions(), .invalid_refs);
1729 }
1730
1731 test "lifecycle read-only propagates refs allocation failure" {
1732 var tmp = std.testing.tmpDir(.{});
1733 defer tmp.cleanup();
1734 _ = try createTestingReadState(std.testing.allocator, tmp.dir);
1735 var failing = std.testing.FailingAllocator.init(
1736 std.testing.allocator,
1737 .{ .fail_index = 0 },
1738 );
1739 try std.testing.expectError(
1740 error.OutOfMemory,
1741 openReadOnlyForTesting(failing.allocator(), tmp.dir, testingReadOptions()),
1742 );
1743 }
1744
1745 test "lifecycle read-only maps file repair reasons" {
1746 const allocator = std.testing.allocator;
1747 var tmp = std.testing.tmpDir(.{});
1748 defer tmp.cleanup();
1749 _ = try createTestingReadState(allocator, tmp.dir);
1750
1751 try tmp.dir.deleteFile(testing_io, "live.db");
1752 try tmp.dir.deleteFile(testing_io, "live.wal");
1753 const reason = try readRepairReason(tmp.dir, testingReadOptions());
1754 switch (reason) {
1755 .file => |file_reason| try std.testing.expectEqual(
1756 file_mod.ReadRepairReason.missing_snapshot,
1757 file_reason,
1758 ),
1759 else => return error.ExpectedFileRepairRequired,
1760 }
1761 }
1762
1763 test "lifecycle first open creates and later opens defer history replay" {
1764 const allocator = std.testing.allocator;
1765 var tmp = std.testing.tmpDir(.{});
1766 defer tmp.cleanup();
1767
1768 {
1769 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1770 defer opened.deinit();
1771 try std.testing.expectEqual(Recovery.created, opened.recovery);
1772 try std.testing.expect(opened.history.replayed());
1773 var created = try opened.connection.execute(allocator, "CREATE TABLE items (name)", .{ .durability = .buffered });
1774 created.deinit(allocator);
1775 _ = try commit(&opened.connection, &opened.history, opened.file);
1776 }
1777
1778 var reopened = try openForTesting(allocator, tmp.dir, testingOptions());
1779 defer reopened.deinit();
1780 try std.testing.expectEqual(Recovery.clean, reopened.recovery);
1781 try std.testing.expect(!reopened.history.replayed());
1782 const checkout = try reopened.history.checkoutBranch("main");
1783 try std.testing.expectEqualStrings("main", checkout.name);
1784 try std.testing.expect(!(try reopened.connection.checkout()).working.dirty());
1785 }
1786
1787 test "lifecycle deferred history upgrades once for a commit and refreshes the sidecar" {
1788 const allocator = std.testing.allocator;
1789 var tmp = std.testing.tmpDir(.{});
1790 defer tmp.cleanup();
1791
1792 {
1793 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1794 defer opened.deinit();
1795 var created = try opened.connection.execute(allocator, "CREATE TABLE items (name)", .{ .durability = .buffered });
1796 created.deinit(allocator);
1797 _ = try commit(&opened.connection, &opened.history, opened.file);
1798 }
1799
1800 var first_head: version.Hash = undefined;
1801 {
1802 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1803 defer opened.deinit();
1804 try std.testing.expect(!opened.history.replayed());
1805 first_head = try commitTestingRow(&opened, allocator, "INSERT INTO items (rowid, name) VALUES (1, 'alpha')");
1806 try std.testing.expect(opened.history.replayed());
1807 }
1808
1809 var reopened = try openForTesting(allocator, tmp.dir, testingOptions());
1810 defer reopened.deinit();
1811 try std.testing.expect(!reopened.history.replayed());
1812 const checkout = try reopened.history.checkoutBranch("main");
1813 try std.testing.expect(version.same(checkout.head, first_head));
1814 var rows = try reopened.connection.execute(allocator, "SELECT name FROM items", .{});
1815 defer rows.deinit(allocator);
1816 try std.testing.expectEqual(@as(usize, 1), rows.rowCount());
1817 }
1818
1819 test "lifecycle truncated fast forward decision repairs the accepted prepare" {
1820 const allocator = std.testing.allocator;
1821 var tmp = std.testing.tmpDir(.{});
1822 defer tmp.cleanup();
1823
1824 var baseline: version.Hash = undefined;
1825 var target: version.Hash = undefined;
1826 var coordinator_length: usize = 0;
1827 {
1828 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1829 defer opened.deinit();
1830 _ = try commitTestingRow(&opened, allocator, "CREATE TABLE items (name)");
1831 baseline = try commitTestingRow(
1832 &opened,
1833 allocator,
1834 "INSERT INTO items (rowid, name) VALUES (1, 'baseline')",
1835 );
1836 _ = try opened.connection.createBranch(try opened.history.full(), "target");
1837 try alignToBranchHead(
1838 allocator,
1839 &opened.connection,
1840 &opened.history,
1841 opened.file,
1842 "target",
1843 );
1844 target = try commitTestingRow(
1845 &opened,
1846 allocator,
1847 "INSERT INTO items (rowid, name) VALUES (2, 'target')",
1848 );
1849 try alignToBranchHead(
1850 allocator,
1851 &opened.connection,
1852 &opened.history,
1853 opened.file,
1854 "main",
1855 );
1856 const history = try opened.history.full();
1857 var update = try history.beginFastForward("main", baseline, target);
1858 try update.commit();
1859 coordinator_length = history.bytes_written;
1860 }
1861
1862 var primary = try tmp.dir.openFile(testing_io, "live.history", .{ .mode = .read_write });
1863 try primary.setLength(testing_io, coordinator_length - 1);
1864 try primary.sync(testing_io);
1865 primary.close(testing_io);
1866
1867 var repaired = try openForTesting(allocator, tmp.dir, testingOptions());
1868 defer repaired.deinit();
1869 try std.testing.expectEqual(Recovery.truncated_history, repaired.recovery);
1870 try std.testing.expect(repaired.history.replayed());
1871 try std.testing.expect((try repaired.history.full()).fastForwardRecovery() == null);
1872 try std.testing.expect(version.same(baseline, (try repaired.connection.checkout()).head));
1873 try std.testing.expect(version.same(
1874 baseline,
1875 (try (try repaired.history.full()).ref("main")).?.target,
1876 ));
1877 var rows = try repaired.connection.execute(allocator, "SELECT name FROM items", .{});
1878 defer rows.deinit(allocator);
1879 try std.testing.expectEqual(@as(usize, 1), rows.rowCount());
1880 }
1881
1882 test "lifecycle reopen restores a committed conflict root" {
1883 const allocator = std.testing.allocator;
1884 var tmp = std.testing.tmpDir(.{});
1885 defer tmp.cleanup();
1886 const expected = try createTestingConflictHistory(allocator, tmp.dir);
1887 var reopened = try openForTesting(allocator, tmp.dir, testingOptions());
1888 defer reopened.deinit();
1889 try std.testing.expectEqual(Recovery.clean, reopened.recovery);
1890 try std.testing.expect(!reopened.history.replayed());
1891 try std.testing.expect(version.same(expected.commit, (try reopened.connection.checkout()).head));
1892 try std.testing.expect(version.same(expected.database, (try reopened.connection.workingRoot())));
1893 try std.testing.expect(!(try reopened.connection.checkout()).working.dirty());
1894 try std.testing.expect(version.same(expected.conflicts, reopened.connection.session.workingRoot().conflicts));
1895 var artifacts = try reopened.connection.conflictArtifacts(
1896 allocator,
1897 try reopened.history.full(),
1898 );
1899 defer artifacts.deinit();
1900 try std.testing.expectEqual(@as(usize, 1), artifacts.artifacts.len);
1901 try std.testing.expect(version.same(expected.artifact, artifacts.artifacts[0].hash));
1902 }
1903
1904 test "lifecycle stale deferred conflict identity falls back and heals" {
1905 const allocator = std.testing.allocator;
1906 var tmp = std.testing.tmpDir(.{});
1907 defer tmp.cleanup();
1908 _ = try createTestingConflictHistory(allocator, tmp.dir);
1909
1910 var snapshot = (try history_mod.refs.load(
1911 allocator,
1912 testing_io,
1913 tmp.dir,
1914 "live.history",
1915 )) orelse return error.SnapshotMissing;
1916 defer snapshot.deinit();
1917 var changed = false;
1918 for (snapshot.entries) |*entry| {
1919 if (!std.mem.eql(u8, entry.name, "main")) continue;
1920 entry.conflicts = version.emptyHash("stale deferred conflicts");
1921 changed = true;
1922 break;
1923 }
1924 try std.testing.expect(changed);
1925 try history_mod.refs.store(
1926 allocator,
1927 testing_io,
1928 tmp.dir,
1929 "live.history",
1930 snapshot.covered_length,
1931 snapshot.entries,
1932 );
1933
1934 {
1935 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1936 defer opened.deinit();
1937 try std.testing.expect(opened.history.replayed());
1938 }
1939 var healed = try openForTesting(allocator, tmp.dir, testingOptions());
1940 defer healed.deinit();
1941 try std.testing.expect(!healed.history.replayed());
1942 }
1943
1944 test "lifecycle missing or stale sidecar falls back to full replay and heals" {
1945 const allocator = std.testing.allocator;
1946 var tmp = std.testing.tmpDir(.{});
1947 defer tmp.cleanup();
1948
1949 {
1950 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1951 defer opened.deinit();
1952 var created = try opened.connection.execute(allocator, "CREATE TABLE items (name)", .{ .durability = .buffered });
1953 created.deinit(allocator);
1954 _ = try commit(&opened.connection, &opened.history, opened.file);
1955 }
1956
1957 try tmp.dir.deleteFile(testing_io, "live.history" ++ history_mod.refs.suffix);
1958 {
1959 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1960 defer opened.deinit();
1961 try std.testing.expectEqual(Recovery.clean, opened.recovery);
1962 try std.testing.expect(opened.history.replayed());
1963 }
1964
1965 var healed = try openForTesting(allocator, tmp.dir, testingOptions());
1966 defer healed.deinit();
1967 try std.testing.expect(!healed.history.replayed());
1968 }
1969
1970 test "lifecycle deferred open reads through the working database without history bytes" {
1971 const allocator = std.testing.allocator;
1972 var tmp = std.testing.tmpDir(.{});
1973 defer tmp.cleanup();
1974
1975 {
1976 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1977 defer opened.deinit();
1978 var created = try opened.connection.execute(allocator, "CREATE TABLE items (name)", .{ .durability = .buffered });
1979 created.deinit(allocator);
1980 var inserted = try opened.connection.execute(allocator, "INSERT INTO items (rowid, name) VALUES (1, 'alpha')", .{ .durability = .buffered });
1981 inserted.deinit(allocator);
1982 _ = try commit(&opened.connection, &opened.history, opened.file);
1983 }
1984
1985 {
1986 var scribbled = try tmp.dir.openFile(testing_io, "live.history", .{ .mode = .read_write });
1987 defer scribbled.close(testing_io);
1988 const length = try scribbled.length(testing_io);
1989 try std.testing.expect(length > 8);
1990 try scribbled.writePositionalAll(testing_io, "XXXXXXXX", length / 2);
1991 }
1992
1993 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
1994 defer opened.deinit();
1995 try std.testing.expect(!opened.history.replayed());
1996 var rows = try opened.connection.execute(allocator, "SELECT name FROM items", .{});
1997 defer rows.deinit(allocator);
1998 try std.testing.expectEqual(@as(usize, 1), rows.rowCount());
1999 }
2000
2001 test "lifecycle commit heals a corrupted history from the working database" {
2002 const allocator = std.testing.allocator;
2003 var tmp = std.testing.tmpDir(.{});
2004 defer tmp.cleanup();
2005
2006 {
2007 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
2008 defer opened.deinit();
2009 var created = try opened.connection.execute(allocator, "CREATE TABLE items (name)", .{ .durability = .buffered });
2010 created.deinit(allocator);
2011 var inserted = try opened.connection.execute(allocator, "INSERT INTO items (rowid, name) VALUES (1, 'alpha')", .{ .durability = .buffered });
2012 inserted.deinit(allocator);
2013 _ = try commit(&opened.connection, &opened.history, opened.file);
2014 }
2015
2016 {
2017 var scribbled = try tmp.dir.openFile(testing_io, "live.history", .{ .mode = .read_write });
2018 defer scribbled.close(testing_io);
2019 const length = try scribbled.length(testing_io);
2020 try scribbled.writePositionalAll(testing_io, "XXXXXXXX", length / 2);
2021 }
2022
2023 {
2024 var opened = try openForTesting(allocator, tmp.dir, testingOptions());
2025 defer opened.deinit();
2026 try std.testing.expect(!opened.history.replayed());
2027 var inserted = try opened.connection.execute(allocator, "INSERT INTO items (rowid, name) VALUES (2, 'beta')", .{ .durability = .buffered });
2028 inserted.deinit(allocator);
2029 _ = try commit(&opened.connection, &opened.history, opened.file);
2030 try std.testing.expect(opened.history.replayed());
2031 }
2032
2033 var reopened = try openForTesting(allocator, tmp.dir, testingOptions());
2034 defer reopened.deinit();
2035 try std.testing.expectEqual(Recovery.clean, reopened.recovery);
2036 var rows = try reopened.connection.execute(allocator, "SELECT name FROM items", .{});
2037 defer rows.deinit(allocator);
2038 try std.testing.expectEqual(@as(usize, 2), rows.rowCount());
2039 }