diff --git a/docs/ceilings.md b/docs/ceilings.md index c72efb8b..51c21a83 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -875,6 +875,13 @@ work when nobody knows it and that the call site never shows. The mechanism and version-1 step is the one step every database that already exists has long since run, so a column added there reaches none of them and every read of that table fails. 0.53.0 shipped a column that way and loaded no projects at all. +- **The workspace database is three files while lich runs** (`internal/store/store.go`, `open`): WAL mode + appends commits to `lich.db-wal` beside `lich.db`, with `lich.db-shm` as its index, and folds them back into + `lich.db` at checkpoints and on a clean close. Copying `lich.db` alone off a running lich, as a backup or a + move, leaves the commits since the last checkpoint behind; copy all three, or close lich first. With + `synchronous(NORMAL)` a power loss can drop the last commits, never corrupt the file. The rollback journal it + replaced created, synced and deleted a file on every commit, tens of milliseconds each on Windows, which + was most of the backend suite's time there. - **The history's branch is read live, so a row whose checkout is gone has none** (`internal/project.BranchesOf`): the branch a row shows is not the one it stores — a worktree keeps the name it was created with while an agent moves the branch inside it, so the stored snapshot dates the close and only git can say what the diff --git a/internal/store/store.go b/internal/store/store.go index 8d8e7caf..d3986dda 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -458,13 +458,16 @@ func New() (*Service, error) { // open opens the database at path, creating parent directories and applying the // schema. foreign_keys is enabled per connection via the DSN so ON DELETE // CASCADE fires; a single open connection serializes writes and sidesteps SQLite -// lock contention in this low-concurrency desktop app. +// lock contention in this low-concurrency desktop app. WAL with synchronous +// NORMAL appends each commit to lich.db-wal instead of creating, syncing and +// deleting a rollback journal per write, which on Windows costs tens of +// milliseconds a commit; a power loss can drop the last commits, never corrupt. func open(path string) (*Service, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, fmt.Errorf("create data directory: %w", err) } - dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(%d)", path, busyTimeoutMS) + dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(%d)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path, busyTimeoutMS) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6cde1413..02a20396 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -23,6 +23,27 @@ func newTestStore(t testing.TB) *Service { return svc } +// TestOpenCommitsWithoutAJournalFilePerWrite pins the journal setup: the +// default rollback journal creates, syncs and deletes a file on every commit, +// which costs tens of milliseconds each on Windows. +func TestOpenCommitsWithoutAJournalFilePerWrite(t *testing.T) { + svc := newTestStore(t) + var mode string + var synchronous int + if err := svc.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil { + t.Fatal(err) + } + if err := svc.db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil { + t.Fatal(err) + } + if mode != "wal" { + t.Errorf("journal_mode = %q, want wal", mode) + } + if synchronous != 1 { + t.Errorf("synchronous = %d, want 1 (NORMAL)", synchronous) + } +} + // TestSessionExistsSpansParkedRows: the answer is what internal/drop asks // before ageing a dropped-file copy out, and a parked session is one a resume // walks straight back into, so its copies are still worth keeping and parking