fix(repair): add --mode from-sqlite to recover palaces with corrupt HNSW (#1308) - #1310
Conversation
| # upsert pass — drawers carry the bulk of the data, closets are the AAAK | ||
| # index layer and reference drawer IDs by string in their documents (no | ||
| # foreign-key validation, so ordering is informational, not load-bearing). | ||
| RECOVERABLE_COLLECTIONS = ("mempalace_drawers", "mempalace_closets") |
There was a problem hiding this comment.
Small compatibility thought: this hardcodes mempalace_drawers, which may need to line up with the configured collection-name work in #1312.
Would it be safer for the drawer collection to come from MempalaceConfig().collection_name, with mempalace_closets appended if closets are
intentionally fixed? A regression test with collection_name = "custom_drawers" would make this pretty clear.
There was a problem hiding this comment.
Good call. Pulled drawers through MempalaceConfig().collection_name via a small _drawers_collection_name() helper. Closets stays hardcoded since the AAAK layer references drawer IDs by string and isn't per-deployment configurable. Tried to mirror #1312's pattern so it should rebase cleanly when that lands. Added a regression test that monkeypatches the resolver and seeds custom_drawers_xyz end-to-end.
| caller can stop the loop and print recovery instructions instead of | ||
| silently shipping a partial palace. | ||
| """ | ||
| col = backend.create_collection(dest_palace, collection_name) |
There was a problem hiding this comment.
This looks worth tightening: create_collection() happens before the try, so failures here won’t be wrapped as RebuildPartialError.
That matters for the in-place flow because the PR description calls out a possible Collection already exists failure if Chroma’s process cache
still sees the pre-archive collection. If that happens after the original palace was moved aside, the CLI would not get the structured
archive_path / partial-count recovery message.
Would it make sense to move create_collection() inside the try so collection-create failures are reported the same way as upsert failures?
There was a problem hiding this comment.
Fair point, moved it. Now a create_collection failure (including "Collection already exists" from a stale System cache) surfaces as RebuildPartialError with archive_path instead of a bare exception.
|
|
||
| os.makedirs(dest_palace, exist_ok=True) | ||
|
|
||
| backend = ChromaBackend() |
There was a problem hiding this comment.
Should this backend be closed in a finally?
This path creates a ChromaBackend() and can open a PersistentClient for the destination palace. Since #1285 had to be careful about active Chroma
handles during repair rollback/restore, it seems safer to make the lifecycle explicit here too:
backend = ChromaBackend()
try:
...
finally:
backend.close()There was a problem hiding this comment.
Done — wrapped the backend in try/finally backend.close() covering all exit paths (success, RebuildPartialError, unexpected). Went with try/finally over with since ChromaBackend doesn't implement the context manager protocol. The from-sqlite path post-dates #1285's lifecycle hardening so it needed its own cleanup here.
| "the existing palace aside, or pass a different source_palace= " | ||
| "(CLI: --source)." | ||
| ) | ||
| return {} |
There was a problem hiding this comment.
One CLI behavior question: rebuild_from_sqlite() returns {} for validation/refusal cases like missing source DB or existing destination, and this
command then returns normally with exit code 0.
For an explicitly requested repair command, would those cases be better as non-zero exits? That would make scripts/CI distinguish “nothing was
rebuilt because inputs were invalid” from a successful recovery.
This looks like it applies to the other validation returns just below this one too.
There was a problem hiding this comment.
Agreed. Library still returns {} for validation refusal vs. {name: 0, ...} for a legitimately empty rebuild — that contract is documented now — and the CLI converts {} to sys.exit(1). Two new tests pin both behaviors so the distinction can't silently regress.
| """ | ||
| SELECT s.id FROM segments s | ||
| JOIN collections c ON s.collection = c.id | ||
| WHERE c.name = ? AND s.scope = 'METADATA' |
There was a problem hiding this comment.
Small test-clarity thought: since this recovery path depends on Chroma’s internal SQLite segment layout, could one of the real-Chroma tests assert
which segment scope the recovered embeddings rows are attached to?
I initially wondered whether this should use the VECTOR segment because the HNSW repair/status code finds the vector segment to inspect the on-disk
HNSW files. Looking more closely, though, the document/metadata rows may correctly live under Chroma’s METADATA segment while the HNSW files live
under VECTOR.
A concrete assertion in test_extract_via_sqlite_returns_all_rows_with_metadata would make that assumption durable, e.g. query segments.scope for
the extracted row IDs and assert it is the expected scope. That would help future repair work avoid accidentally changing this to the wrong segment.
There was a problem hiding this comment.
Done. Added a JOIN against segments/collections for the extracted row IDs that asserts scope == {'METADATA'}. You read the layout exactly right — METADATA holds doc/metadata rows, VECTOR holds the HNSW files. If anyone ever points the extraction JOIN at VECTOR, this fails loudly instead of silently regressing recovery.
|
This is a really thoughtful recovery path, and I like the shape of it a lot. Bypassing Chroma entirely when the HNSW side is the thing that’s broken feels like the right answer for this class of failure, especially since the user’s verbatim data is still sitting intact in I’m not a maintainer here, just some person reading along, so please take these as questions rather than gatekeeping. I left a few inline thoughts, but they’re mostly small “can we make this even harder to misuse or regress?” questions around the edges: configured collection names, early collection-creation failures, backend handle cleanup, validation exit behavior, and making the Chroma segment-layout assumption explicit in tests. Overall, this seems like a strong direction. |
Five small hardening fixes for the from-sqlite rebuild path, all from mjc's review on MemPalace#1310: - repair.py: drawers collection name now resolves from MempalaceConfig().collection_name via _drawers_collection_name() (closets stays fixed by design — AAAK index references drawer IDs by string). Lines up with the broader configured-collection work in MemPalace#1312 so that PR can rebase cleanly on top. - repair.py: create_collection() moved inside the try block in _rebuild_one_collection so a Chroma "Collection already exists" failure surfaces as RebuildPartialError with archive_path, not an unstructured exception that strands the user without recovery instructions. - repair.py: rebuild_from_sqlite wraps backend lifetime in try/finally with backend.close() so PersistentClient handles to dest_palace are released on every exit path. The from-sqlite path post-dates MemPalace#1285's lifecycle hardening of the legacy rebuild, so this needed its own cleanup. - cli.py: cmd_repair (from-sqlite mode) now exits non-zero when rebuild_from_sqlite returns {} (validation refusal sentinel), so unattended scripts/CI distinguish "invalid inputs" from a successful rebuild that legitimately found zero rows. - tests/test_repair.py: test_extract_via_sqlite_returns_all_rows_with_metadata now asserts every backing segment is scope='METADATA', locking in the segment-layout assumption against future regressions that point the JOIN at the VECTOR segment. New test coverage: - test_rebuild_from_sqlite_honors_configured_drawer_collection_name - test_cmd_repair_from_sqlite_validation_refusal_exits_nonzero - test_cmd_repair_from_sqlite_success_does_not_exit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Hey @mjc please check the failing tests on Windows |
|
The failing tests are hitting a handle-lifecycle gap in the in-place The relevant fix is already in #1285, specifically commit There is also a separate write-boundary issue here: repair/rebuild paths should take the palace write lock around the destructive replace / restore portion so Chroma and SQLite state cannot change across the swap boundary. I’m working on that separately; it is related to repair safety, but distinct from this Windows handle-release fix. |
…NSW (MemPalace#1308) Both `--mode legacy` and the inline `cli.cmd_repair` rebuild path call `Collection.count()` as their first read — the same call that raises `chromadb.errors.InternalError: Failed to apply logs to the hnsw segment writer` on the corruption class reported in MemPalace#1308. Repair would print "Cannot recover — palace may need to be re-mined from source files" even though the underlying SQLite tables were fully intact. The new `--mode from-sqlite` reads `(id, document, metadata)` rows directly from `chroma.sqlite3` via `segments` → `embeddings` → `embedding_metadata` joins, never opens a chromadb client against the corrupt palace, and re-upserts everything into a fresh palace. - `--source PATH` extracts from a corrupt palace already moved aside - `--archive-existing` handles the in-place case by renaming the existing palace to `<palace>.pre-rebuild-<timestamp>` first - Partial-rebuild failures raise `RebuildPartialError` with the archive path so users can recover; CLI exits non-zero - In-place mode calls `SharedSystemClient.clear_system_cache()` to drop chromadb's process-wide System registry (cross-palace use does not, to limit blast radius for library callers) - Source validation runs before any destructive moves Verified end-to-end recovering a 52,300-row real-world corrupt palace. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five small hardening fixes for the from-sqlite rebuild path, all from mjc's review on MemPalace#1310: - repair.py: drawers collection name now resolves from MempalaceConfig().collection_name via _drawers_collection_name() (closets stays fixed by design — AAAK index references drawer IDs by string). Lines up with the broader configured-collection work in MemPalace#1312 so that PR can rebase cleanly on top. - repair.py: create_collection() moved inside the try block in _rebuild_one_collection so a Chroma "Collection already exists" failure surfaces as RebuildPartialError with archive_path, not an unstructured exception that strands the user without recovery instructions. - repair.py: rebuild_from_sqlite wraps backend lifetime in try/finally with backend.close() so PersistentClient handles to dest_palace are released on every exit path. The from-sqlite path post-dates MemPalace#1285's lifecycle hardening of the legacy rebuild, so this needed its own cleanup. - cli.py: cmd_repair (from-sqlite mode) now exits non-zero when rebuild_from_sqlite returns {} (validation refusal sentinel), so unattended scripts/CI distinguish "invalid inputs" from a successful rebuild that legitimately found zero rows. - tests/test_repair.py: test_extract_via_sqlite_returns_all_rows_with_metadata now asserts every backing segment is scope='METADATA', locking in the segment-layout assumption against future regressions that point the JOIN at the VECTOR segment. New test coverage: - test_rebuild_from_sqlite_honors_configured_drawer_collection_name - test_cmd_repair_from_sqlite_validation_refusal_exits_nonzero - test_cmd_repair_from_sqlite_success_does_not_exit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
f46bbb6 to
d92c741
Compare
|
Rebased onto develop
Verified locally:
The Windows test failure on the prior CI run should also clear: today's #1105 (Chroma backend now releases the SQLite file lock on |
…file locks The helper opened a chromadb PersistentClient via ChromaBackend and never closed it, leaving rust-side SQLite/HNSW file locks alive after the helper returned. On Windows that blocks the in-place archive rename inside rebuild_from_sqlite with WinError 32 on data_level0.bin, causing test_rebuild_from_sqlite_in_place_archives_when_opted_in and test_rebuild_from_sqlite_raises_on_upsert_failure to fail in the test-windows CI job. No test consumes the returned collection, so closing the backend in a try/finally is safe and drops the return.
|
Maintainer-edit pushed to unblock for v3.3.5 (due tomorrow):
Root cause: the Fix wraps the seed in Linux full suite: 1568 passed, ruff lint/format clean against the 0.4.x CI pin. Watching CI for the Windows job. |
Three conflicts, all from develop landing MemPalace#1285/MemPalace#1310/MemPalace#1312 after this branch was authored: - mempalace/cli.py: keep both import sets — this branch's maybe_repair_poisoned_max_seq_id_before_rebuild plus develop's RebuildCollectionError / _close_chroma_handles / _extract_drawers / _rebuild_collection_via_temp added in MemPalace#1285. - mempalace/repair.py: keep this branch's maybe_repair_poisoned_max_seq_id_before_rebuild definition; use develop's rebuild_index signature with the collection_name parameter added in MemPalace#1312. Normalized print indent to 2 spaces matching the rest of the file. - tests/test_repair.py: keep both this branch's max_seq_id preflight tests and develop's rebuild_from_sqlite + configured-collection-name tests; they exercise distinct code paths and don't overlap. Local: 1617 tests pass, ruff lint+format clean against 0.4.x CI pin.
What does this PR do?
Closes #1308.
Both
mempalace repair --mode legacyand the inlinecli.cmd_repairrebuild path callCollection.count()as their first read — exactly the call that raiseschromadb.errors.InternalError: Failed to apply logs to the hnsw segment writeron the corruption class reported in #1308. Repair would print "Cannot recover — palace may need to be re-mined from source files" even though the underlying SQLite tables were fully intact (the corruption lives in the on-disk HNSW index files, not the data layer).This PR adds
--mode from-sqlitethat reads(id, document, metadata)rows directly fromchroma.sqlite3via asegments→embeddings→embedding_metadatajoin, never opens a chromadb client against the corrupt palace, and re-upserts everything into a fresh palace under the user's configured embedding function.CLI surface
--archive-existingrenames the existing palace to<palace>.pre-rebuild-<timestamp>before reading from it. The original is never deleted.Safety scaffolding
A reviewer scrutiny pass surfaced several issues that all needed handling before this could land safely:
confirm_destructive_action(matching the legacy mode's existing protection). Pure cross-palace extraction into a non-existent dir is the only non-destructive path and skips the prompt.--sourcepointed at a non-palace dir. Validation now runs first.RebuildPartialErrorcarrying the failed collection name, partial counts, dest path, and archive path. CLI catches it and exits non-zero with the recovery instructions printed inline. No silent partial palace; no exit-zero on data loss.SharedSystemClient.clear_system_cache()blast radius: chromadb's process-wide System registry holds onto pre-archive state, so in-place mode would otherwise fail oncreate_collectionwith "Collection already exists". The call is now scoped to in-place mode only — cross-palace rebuilds never touch it. Docstring carries an explicit warning against callingrebuild_from_sqlitefrom inside a long-running mempalace process holding livePersistentClientreferences for other palaces.data_level0.binand cannot be recovered). The model is deterministic so search results remain semantically equivalent. Round-trip test asserts both drawer and closet document text + metadata come back intact.How to test
Recovery was verified end-to-end on a 52,300-row real-world corrupt palace (48,199 drawers + 4,101 closets) before this PR was opened. After rebuild,
mempalace statusreturned the same wing/room breakdown, andmempalace search "supabase migration"returned cosine-scored hits.Automated:
pytest tests/test_repair.py -v ruff check .11 new tests in
tests/test_repair.py, all using realtmp_pathchromadb fixtures rather than mocks (the bug class is "extraction sees different rows than chromadb stored" — only an end-to-end fixture proves that). Coverage:extract_via_sqlite_returns_all_rows_with_metadatachroma:documentkeyextract_via_sqlite_preserves_typed_metadataextract_via_sqlite_unknown_collection_yields_nothingWHERE c.name=?filter (seeds two collections, queries a third)extract_via_sqlite_missing_palace_yields_nothingrebuild_from_sqlite_roundtrips_via_real_chromadbrebuild_from_sqlite_refuses_existing_destrebuild_from_sqlite_in_place_archives_when_opted_inrebuild_from_sqlite_in_place_refuses_without_archive_flagrebuild_from_sqlite_source_missing_chroma_dbrebuild_from_sqlite_in_place_validates_source_before_archivingrebuild_from_sqlite_raises_on_upsert_failuremonkeypatchto inject a chromadb upsert failureFull suite:
1481 passed, 1 skippedonpython -m pytest tests/ --ignore=tests/benchmarks.Checklist
python -m pytest tests/ -v)ruff check .)[3.3.5] — unreleased(v3.3.4 was already tagged fromrelease/3.3.4)🤖 Generated with Claude Code