fix: close the remaining review findings (cross-process lock, partials)

Second Codex (gpt-6.1-sol, xhigh) pass over 60634d3, each item verified
before fixing:

- new: the sync recheck hashed an existing empty database before schema
  setup, so the first sync falsely reported an external change. Hash
  after setup; a published snapshot already has the schema and keeps
  its bytes.
- sync, import, and entity edits now hold a cross-process lock
  (<db>.lock, std File::lock), so a sync cannot replace the database
  under an import running in another process. Import validates and
  downloads before taking the lock, keeping its error order. MSRV
  1.85 -> 1.89 for File::lock.
- the row-count rebuild branch also never downgrades history.
- cache reads no longer delete corrupt entries (a concurrent writer may
  have just replaced them); the next put overwrites atomically.
- idx-mcp validates the changes limit before querying and runs each
  requested section or analysis once.
- XLSX parsing bounds total cell text, so one large shared string
  referenced from many cells cannot exhaust memory.
- PDF/archive percentages must be 0-100%; HHI uses saturating math.

CLI goldens unchanged. Live A/B vs 0.2.4: 16 passed, discover hits the
IDX 403 on both builds (exit 4 vs 1, intended).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 22:20:57 +00:00
commit 1e551385d0
10 changed files with 117 additions and 34 deletions

View file

@ -233,7 +233,8 @@ impl Cache {
runtime::warn(format!(
"corrupted cache entry for {data_type}/{symbol}, treating as miss: {e}"
));
let _ = fs::remove_file(&path);
// Not deleted: a concurrent writer may have just replaced it
// with a valid entry. The next put overwrites it atomically.
return Ok(None);
}
};
@ -244,7 +245,6 @@ impl Cache {
entry.schema_version,
CURRENT_SCHEMA_VERSION
));
let _ = fs::remove_file(&path);
return Ok(None);
}
Ok(Some(entry))
@ -352,7 +352,7 @@ mod tests {
}
#[test]
fn corrupted_cache_entry_returns_none_and_deletes_file() {
fn corrupted_cache_entry_is_a_miss_until_replaced() {
let root = tmp();
let cache = Cache::at(root.clone());
@ -371,12 +371,16 @@ mod tests {
.expect("get should not error");
assert_eq!(result, None, "corrupted entry should be treated as miss");
// The corrupted file should be deleted
assert!(!path.exists(), "corrupted file should be deleted");
// Left for the next put to replace (deleting could race a writer).
cache
.put("quote", "CORRUPT.JK", &T { v: 7 }, 3600)
.expect("put");
let result: Option<T> = cache.get("quote", "CORRUPT.JK").expect("get");
assert_eq!(result, Some(T { v: 7 }));
}
#[test]
fn corrupted_cache_entry_get_stale_returns_none_and_deletes_file() {
fn corrupted_cache_entry_get_stale_is_a_miss() {
let root = tmp();
let cache = Cache::at(root.clone());
@ -401,8 +405,7 @@ mod tests {
"corrupted entry should be treated as miss in get_stale"
);
// The corrupted file should be deleted
assert!(!path.exists(), "corrupted file should be deleted");
assert!(path.exists(), "left for the next put to replace");
}
#[test]