4d41f88e04ed
let status run while another koh command writes, and say so plainly when a write has to wait too long (3.0.42, codex review pending)
- time
- 2026-09-24 04:51 UTC · 2 weeks ago
- machine
- koh2
- agent
- claude-opus-5-5
- session
- koh 3 reliability campaign
- parent
- 1a8e4215f494
↓ koh-4d41f88e.tar.gzatlas at this save
files changed8 files
VERSION+1 −1src/snapshot/snapshot_test.v+25 −0src/snapshot/tree.v+20 −20src/storage/cache.v+22 −1src/storage/sqlite.v+8 −0src/storage/sqlite_test.v+28 −0src/storage/storage.v+3 −0v.mod+1 −1src/snapshot/snapshot_test.v+25 −0
@@ -159,6 +159,31 @@159159 assert store.load_cache()!.len == 0160160 }161161 162+// `status` must work while another agent's save holds the write lock: the163+// cache only saves time, so a busy repo hashes without caching, at once.164+fn test_a_walk_does_not_wait_for_another_writer() {165+ mut store := storage.create(temp_root('busy_walk')!)!166+ root := store.root167+ defer {168+ store.close()169+ os.rmdir_all(root) or {}170+ }171+ put(root, 'a.txt', 'a\n')!172+ // Only a file whose last change is older than the racy window is cached,173+ // and ctime cannot be set back, so the test waits it out.174+ time.sleep(racy_window_ns * time.nanosecond + 100 * time.millisecond)175+ writer := storage.open(root)!176+ defer {177+ writer.close()178+ }179+ writer.begin()!180+ started := time.now()181+ files := walk(store, root, Ignore{})!182+ assert files.len == 1183+ assert time.since(started) < time.second184+ writer.rollback()185+}186+162187 fn test_restore_writes_removes_and_leaves_ignored_files_alone() {163188 mut store := storage.create(temp_root('restore')!)!164189 root := store.rootsrc/snapshot/tree.v+20 −20
@@ -23,7 +23,11 @@2323 pub fn walk(store &storage.Store, root string, ig Ignore) ![]FileEntry {2424 cache := store.load_cache()!2525 mut entries := []FileEntry{}26- walk_dir(store, root, '', ig, cache, mut entries)!26+ mut fresh := []storage.CacheRow{}27+ walk_dir(root, '', ig, cache, mut entries, mut fresh)!28+ // Skipped while another command writes, or inside a caller's transaction:29+ // the next walk hashes those files again, which is all a missing row costs.30+ store.save_cache_rows(fresh) or {}2731 entries.sort(a.path < b.path)2832 return entries2933 }@@ -33,12 +37,13 @@3337 // database to check it with.3438 pub fn walk_raw(root string, ig Ignore) ![]FileEntry {3539 mut entries := []FileEntry{}36- walk_dir(none, root, '', ig, map[string]storage.CacheRow{}, mut entries)!40+ mut unused := []storage.CacheRow{}41+ walk_dir(root, '', ig, map[string]storage.CacheRow{}, mut entries, mut unused)!3742 entries.sort(a.path < b.path)3843 return entries3944 }4045 41-fn walk_dir(store ?&storage.Store, root string, rel string, ig Ignore, cache map[string]storage.CacheRow, mut out []FileEntry) ! {46+fn walk_dir(root string, rel string, ig Ignore, cache map[string]storage.CacheRow, mut out []FileEntry, mut fresh []storage.CacheRow) ! {4247 dir := if rel == '' { root } else { os.join_path(root, rel) }4348 for name in os.ls(dir)! {4449 child := if rel == '' { name } else { '${rel}/${name}' }@@ -50,13 +55,13 @@5055 }5156 match st.kind {5257 .dir {53- walk_dir(store, root, child, ig, cache, mut out)!58+ walk_dir(root, child, ig, cache, mut out, mut fresh)!5459 }5560 .file {5661 if st.size > storage.max_file_size {5762 return error('${child} is ${st.size} bytes; koh stores code and text, not files this large')5863 }59- hash := hash_file(store, root, child, st, cache)!64+ hash := hash_file(root, child, st, cache, mut fresh)!6065 out << FileEntry{6166 path: child6267 hash: hash@@ -69,28 +74,23 @@6974 }7075 }7176 72-fn hash_file(store ?&storage.Store, root string, rel string, st Stat, cache map[string]storage.CacheRow) !string {73- if s := store {74- if row := cache[rel] {75- if row.size == st.size && row.mtime_ns == st.mtime_ns && row.ctime_ns == st.ctime_ns {76- return row.hash77- }77+fn hash_file(root string, rel string, st Stat, cache map[string]storage.CacheRow, mut fresh []storage.CacheRow) !string {78+ if row := cache[rel] {79+ if row.size == st.size && row.mtime_ns == st.mtime_ns && row.ctime_ns == st.ctime_ns {80+ return row.hash7881 }79- data := os.read_bytes(os.join_path(root, rel))!80- hash := storage.hash_hex(data)81- if !cacheable(st, time.now().unix_nano()) {82- return hash83- }84- s.save_cache(storage.CacheRow{82+ }83+ hash := storage.hash_hex(os.read_bytes(os.join_path(root, rel))!)84+ if cacheable(st, time.now().unix_nano()) {85+ fresh << storage.CacheRow{8586 path: rel8687 size: st.size8788 mtime_ns: st.mtime_ns8889 ctime_ns: st.ctime_ns8990 hash: hash90- })!91- return hash91+ }9292 }93- return storage.hash_hex(os.read_bytes(os.join_path(root, rel))!)93+ return hash9494 }9595 9696 // cacheable reports whether a hash may be cached. Linux stamps files from asrc/storage/cache.v+22 −1
@@ -32,7 +32,28 @@3232 return rows3333 }3434 35-pub fn (s &Store) save_cache(row CacheRow) ! {35+// save_cache_rows writes a walk's rows in one transaction, without waiting for36+// another writer: the cache only saves time, so while another command holds37+// the lock the rows are simply not written.38+pub fn (s &Store) save_cache_rows(rows []CacheRow) ! {39+ if rows.len == 0 {40+ return41+ }42+ s.db.exec('PRAGMA busy_timeout = 0')!43+ defer {44+ s.db.exec('PRAGMA busy_timeout = ${busy_timeout_ms}') or {}45+ }46+ s.db.exec('BEGIN IMMEDIATE')!47+ for row in rows {48+ s.save_cache(row) or {49+ s.rollback()50+ return err51+ }52+ }53+ s.commit()!54+}55+56+fn (s &Store) save_cache(row CacheRow) ! {3657 mut stmt := s.db.prepare('INSERT INTO file_cache (path, size, mtime_ns, ctime_ns, hash)3758 VALUES (?, ?, ?, ?, ?)3859 ON CONFLICT(path) DO UPDATE SET size = excluded.size, mtime_ns = excluded.mtime_ns,src/storage/sqlite.v+8 −0
@@ -59,6 +59,7 @@5959 fn C.sqlite3_changes(db &C.sqlite3) i326060 6161 const sqlite_ok = 062+const sqlite_busy = 56263 const sqlite_row = 1006364 const sqlite_done = 1016465 const sqlite_open_readwrite = 0x00000002@@ -130,6 +131,12 @@130131 }131132 mut errmsg := &char(unsafe { nil })132133 code := C.sqlite3_exec(db.conn, query_c, unsafe { nil }, unsafe { nil }, &errmsg)134+ if code == sqlite_busy {135+ if errmsg != unsafe { nil } {136+ C.sqlite3_free(errmsg)137+ }138+ return error(busy)139+ }133140 if code != sqlite_ok {134141 msg := if errmsg != unsafe { nil } {135142 unsafe { cstring_to_vstring(errmsg) }@@ -204,6 +211,7 @@204211 match code {205212 sqlite_row { return true }206213 sqlite_done { return false }214+ sqlite_busy { return error(busy) }207215 else { return error(stmt.db.last_error()) }208216 }209217 }src/storage/sqlite_test.v+28 −0
@@ -60,3 +60,31 @@6060 assert err.msg().contains('FOREIGN KEY')6161 }6262 }63+64+// Two agents share one repo. A second writer must hear what is going on and65+// what to do, not SQLite's bare "database is locked".66+fn test_a_busy_repo_says_another_command_is_writing() {67+ dir := os.join_path(os.temp_dir(), 'koh3_test_${os.getpid()}_busy')68+ os.rmdir_all(dir) or {}69+ os.mkdir_all(dir)!70+ defer {71+ os.rmdir_all(dir) or {}72+ }73+ first := create(dir)!74+ defer {75+ first.close()76+ }77+ second := open(dir)!78+ defer {79+ second.close()80+ }81+ first.begin()!82+ // No waiting in a test: the second writer finds the lock taken at once.83+ second.db.exec('PRAGMA busy_timeout = 0')!84+ if _ := second.begin() {85+ assert false, 'a second writer must not get the lock'86+ } else {87+ assert err.msg().contains('another koh command is writing')88+ }89+ first.rollback()90+}src/storage/storage.v+3 −0
@@ -162,6 +162,9 @@162162 }163163 }164164 165+// What any write says after waiting busy_timeout_ms for another writer.166+pub const busy = 'another koh command is writing to this repo — wait for it to finish, then run this again'167+165168 pub fn (s &Store) begin() ! {166169 s.db.exec('BEGIN IMMEDIATE')!167170 }v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.41'3+ version: '3.0.42'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }