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
8 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.411+3.0.42

view file

src/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.root

view file

src/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 a

view file

src/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,

view file

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 }

view file

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+}

view file

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 }

view file

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 }

view file