Skip to content

Commit 87f2154

Browse files
authored
Merge pull request #2145 from mvalentsev/fix/repair-legacy-dry-run
fix(repair): honor --dry-run in the default mode (#2144)
2 parents b757aa7 + 495a12b commit 87f2154

6 files changed

Lines changed: 592 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
2727
- **Chroma HNSW write defaults match chromadb** (`batch_size=100` / `sync_threshold=1000`) instead of the old 2/2 bloat guard that rewrote segments thousands of times on large mines. (#2107, #2106)
2828
- **Repair and recovery are safer under contention.** `repair --mode from-sqlite` takes the mine-lock before archiving; rebuilds preserve a verified temp collection when the live swap fails; sparse drawers with zero `embedding_metadata` rows are no longer dropped; truncated ID pagination fails loud instead of pretending success. (#2109, #2086, #2087)
2929
- **`repair --mode from-sqlite --dry-run` is a true preview.** It no longer archives or re-embeds; it prints per-collection would-be counts from SQLite ground truth and exits without touching the palace. Unreadable counts fail closed instead of inventing zeros. (#2133, #2095, #1654)
30+
- **`repair --dry-run` is a true preview in the default (legacy) mode too.** That path ignored the flag entirely and ran the real rebuild — deleting any existing `<palace>.backup`, copying the palace over it, and re-filing the drawers collection. It now prints a read-only plan and exits without opening a chromadb client, which is itself a write to `chroma.sqlite3`. The plan names the live-collection delete the rebuild performs, warns when an existing backup would be destroyed, and reports the truncation guard as disabled when `--confirm-truncation-ok` is set. An isolated FTS5 inverted-index error is reported as auto-healable instead of raising the manual-recovery abort a real run never reaches, unreadable counts fail closed with a non-zero exit, and the `--dry-run` help no longer claims to be `--mode max-seq-id` only. (#2144)
3031
- **HNSW divergence is preflighted before remaining `col.count()` crash sites** across mine, dedup, migrate, repair, and palace helpers. (#2093)
3132
- **Re-mine and conversation ingest no longer lose or duplicate drawers.** Content-hash dedup prevents duplicate LLM conversation drawers; sweeper drawers are excluded from convo extract-mode purge scope and failed purges abort; search returns round-trippable `drawer_id` values for `get_drawer`. (#2050, #2125, #2089, #2090, #2044, #2080)
3233
- **MCP and daemon lifecycle harden multi-agent use.** Read-only mode refuses config and checkpoint-ack tools that rewrite host state; stdio MCP exits on stdin EOF/broken pipe so orphaned sessions release locks; daemon jobs refused the palace lock are deferred instead of failed permanently. (#2126, #2103, #2101, #2072, #2029, #2014)

mempalace/cli.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,13 +1140,14 @@ def cmd_repair(args):
11401140
_close_chroma_handles,
11411141
_extract_drawers,
11421142
_post_rebuild_cleanup,
1143+
_preview_legacy_repair,
11431144
_promote_temp_collection,
11441145
_rebuild_collection_via_temp,
11451146
check_extraction_safety,
11461147
index_read_recovery_guidance,
1147-
maybe_autoheal_fts5_index,
11481148
maybe_repair_poisoned_max_seq_id_before_rebuild,
11491149
print_sqlite_integrity_abort,
1150+
resolve_repair_preflight_errors,
11501151
sqlite_integrity_errors,
11511152
)
11521153

@@ -1250,17 +1251,21 @@ def cmd_repair(args):
12501251
# stack trace instead of the friendly abort message. Run quick_check
12511252
# here so we can surface the clear recovery instructions and exit
12521253
# cleanly before chromadb's compactor touches the disk.
1253-
sqlite_errors = sqlite_integrity_errors(palace_path)
1254-
if sqlite_errors:
1255-
sqlite_errors = maybe_autoheal_fts5_index(palace_path, sqlite_errors)
1254+
dry_run = getattr(args, "dry_run", False)
1255+
# The FTS5 autoheal inside this call is a write, so a --dry-run predicts
1256+
# its outcome instead of performing it (#1596 is auto-healable and must
1257+
# not surface as an abort in a preview).
1258+
sqlite_errors = resolve_repair_preflight_errors(
1259+
palace_path, sqlite_integrity_errors(palace_path), dry_run=dry_run
1260+
)
12561261
if sqlite_errors:
12571262
print_sqlite_integrity_abort(palace_path, sqlite_errors)
12581263
sys.exit(1)
12591264

12601265
preflight = maybe_repair_poisoned_max_seq_id_before_rebuild(
12611266
palace_path,
12621267
backup=getattr(args, "backup", True),
1263-
dry_run=getattr(args, "dry_run", False),
1268+
dry_run=dry_run,
12641269
assume_yes=getattr(args, "yes", False),
12651270
)
12661271
if preflight is not None:
@@ -1271,6 +1276,24 @@ def cmd_repair(args):
12711276
print(f"{'=' * 55}\n")
12721277
print(f" Palace: {palace_path}")
12731278

1279+
if dry_run:
1280+
# Return before the backend is used at all: the chromadb client this
1281+
# path opens is itself a write to chroma.sqlite3 (measured — the file
1282+
# hash changes on get_collection alone, before count()), so a preview
1283+
# that reached it could not be inert. Staying off the chromadb layer
1284+
# also keeps a dry run clear of the layer repair is separately reported
1285+
# to segfault in on a large palace (#2113). Exit non-zero on an
1286+
# unreadable count for parity with the from-sqlite preview above, so
1287+
# `--dry-run && repair --yes` cannot walk into the destructive run
1288+
# after a failed preview (#2095, #2133).
1289+
if not _preview_legacy_repair(
1290+
palace_path=palace_path,
1291+
collection_name=collection_name,
1292+
confirm_truncation_ok=getattr(args, "confirm_truncation_ok", False),
1293+
):
1294+
sys.exit(1)
1295+
return
1296+
12741297
backend = ChromaBackend()
12751298

12761299
# Try to read existing drawers
@@ -2135,7 +2158,7 @@ def main():
21352158
p_repair.add_argument(
21362159
"--dry-run",
21372160
action="store_true",
2138-
help="Print detected poisoned rows and exit without mutation (--mode max-seq-id only)",
2161+
help="Print what the repair would do and exit without modifying the palace",
21392162
)
21402163

21412164
# repair-status — read-only HNSW capacity health check (#1222)

mempalace/repair.py

Lines changed: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,6 +1715,21 @@ def rebuild_from_sqlite(
17151715
)
17161716

17171717

1718+
def _print_unreadable_count_refusal(*, collection_name: str, palace_path: str) -> None:
1719+
"""Refuse to preview a collection whose SQLite row count cannot be read.
1720+
1721+
Fail closed: inventing 0 would hide an unreadable source and make the
1722+
operator believe a real run would upsert nothing (review note on #1654 /
1723+
#2095). Shared by both previews so the wording cannot drift apart.
1724+
"""
1725+
print(
1726+
f"\n Cannot preview [{collection_name}]: SQLite row count is unreadable "
1727+
f"at {os.path.join(palace_path, 'chroma.sqlite3')}.\n"
1728+
" Fix source readability (schema, lock, permissions) and re-run "
1729+
"--dry-run; refusing to invent zero counts."
1730+
)
1731+
1732+
17181733
def _preview_rebuild_from_sqlite(
17191734
*,
17201735
source_palace: str,
@@ -1739,15 +1754,7 @@ def _preview_rebuild_from_sqlite(
17391754
for cname in _recoverable_collections():
17401755
n = sqlite_drawer_count(source_palace, cname)
17411756
if n is None:
1742-
# Fail closed: inventing 0 would hide an unreadable source and
1743-
# make the operator believe a real rebuild would upsert nothing
1744-
# (review note on #1654 / #2095).
1745-
print(
1746-
f"\n Cannot preview [{cname}]: SQLite row count is unreadable "
1747-
f"at {os.path.join(source_palace, 'chroma.sqlite3')}.\n"
1748-
" Fix source readability (schema, lock, permissions) and re-run "
1749-
"--dry-run; refusing to invent zero counts."
1750-
)
1757+
_print_unreadable_count_refusal(collection_name=cname, palace_path=source_palace)
17511758
return {}
17521759
counts[cname] = n
17531760
print(f" [{cname}] would re-embed and upsert {n} rows")
@@ -1758,6 +1765,116 @@ def _preview_rebuild_from_sqlite(
17581765
return counts
17591766

17601767

1768+
def _preview_legacy_repair(
1769+
*,
1770+
palace_path: str,
1771+
collection_name: str,
1772+
confirm_truncation_ok: bool = False,
1773+
) -> dict[str, int]:
1774+
"""Read-only preview for the default (legacy) ``repair`` path (``dry_run=True``).
1775+
1776+
Never opens a chromadb client, takes a lock, or writes. Opening a client is
1777+
itself a write to ``chroma.sqlite3``, so the row count comes from the
1778+
read-only SQLite ground truth :func:`check_extraction_safety` already
1779+
trusts. That is a different source than the real run rebuilds from (it
1780+
re-files what the chromadb collection layer returns), so the plan below
1781+
states the ``#1208`` contingency rather than promising the number.
1782+
1783+
``confirm_truncation_ok`` mirrors the real run's flag: it switches that
1784+
contingency off, so the preview has to say the guard is disabled rather
1785+
than promise an abort that would not happen.
1786+
1787+
Returns ``{}`` when the count is unreadable so a broken preview cannot look
1788+
like a valid plan (#1654, #2095, #2133).
1789+
"""
1790+
print("\n DRY RUN — no changes will be made.")
1791+
n = sqlite_drawer_count(palace_path, collection_name)
1792+
if n is None:
1793+
_print_unreadable_count_refusal(collection_name=collection_name, palace_path=palace_path)
1794+
print(f"{'=' * 55}\n")
1795+
return {}
1796+
1797+
if n == 0:
1798+
# The real run stops at ``total == 0`` with "Nothing to repair.", or —
1799+
# when the collection is absent altogether — at the index-read error
1800+
# that points to --mode from-sqlite. Neither backs up nor rebuilds, so
1801+
# promising a backup and a VACUUM here would describe a run that does
1802+
# not happen.
1803+
print(
1804+
f" [{collection_name}] chroma.sqlite3 holds no rows. A real run would report\n"
1805+
" nothing to repair, or an index read error, and change nothing."
1806+
)
1807+
print(f"{'=' * 55}\n")
1808+
return {collection_name: 0}
1809+
1810+
backup_path = os.path.normpath(palace_path) + ".backup"
1811+
if confirm_truncation_ok:
1812+
print(
1813+
f" [{collection_name}] chroma.sqlite3 holds {n} rows, and --confirm-truncation-ok\n"
1814+
" is set, so the #1208 truncation guard is DISABLED. A real run would re-file\n"
1815+
f" whatever the chromadb collection layer returns, even if that is fewer than {n}\n"
1816+
" rows, and the difference would be destroyed. It would, in order:"
1817+
)
1818+
else:
1819+
print(
1820+
f" [{collection_name}] chroma.sqlite3 holds {n} rows. A real run would extract them\n"
1821+
" through the chromadb collection layer first and abort without changes if that\n"
1822+
f" returns fewer than {n} (#1208 truncation guard). It would then, in order:"
1823+
)
1824+
if os.path.exists(backup_path):
1825+
print(f" 1. DELETE the existing backup at {backup_path} — or refuse outright")
1826+
print(" if it is not a palace — and copy the live palace in its place")
1827+
else:
1828+
print(f" 1. copy the palace directory to {backup_path}")
1829+
print(f" 2. DELETE the live '{collection_name}' collection and re-file the extracted rows")
1830+
print(" into a fresh one, staged and verified in a temp collection first")
1831+
print(" 3. rebuild the FTS5 index and VACUUM chroma.sqlite3")
1832+
print("\n Without --yes it would ask for confirmation before step 1.")
1833+
print(" Re-run without --dry-run to execute.")
1834+
print(f"{'=' * 55}\n")
1835+
return {collection_name: n}
1836+
1837+
1838+
def resolve_repair_preflight_errors(
1839+
palace_path: str,
1840+
errors: list[str],
1841+
*,
1842+
dry_run: bool,
1843+
progress=print,
1844+
) -> list[str]:
1845+
"""Return the quick_check errors that still block a repair.
1846+
1847+
A real run heals an isolated malformed FTS5 inverted index in place and
1848+
carries on (#1596). ``--dry-run`` must not perform that write, so it
1849+
classifies the errors with the same :func:`_errors_are_isolated_fts5`
1850+
predicate the real path gates on: an isolated FTS5 error is reported and
1851+
cleared, anything broader still aborts. Without this a preview would print
1852+
the ABORT banner — offline ``sqlite3 .recover``, recreate the FTS5 table —
1853+
for a palace the tool repairs by itself.
1854+
1855+
The prediction is deliberately the optimistic branch, and it is stated as
1856+
an attempt rather than a promise: the real heal still returns the errors
1857+
unchanged when another process holds the mine lock, when the rebuild
1858+
raises, or when ``quick_check`` is still dirty afterwards. A dry run cannot
1859+
tell those apart without taking the lock and writing, which is exactly what
1860+
it must not do, so the wording names them instead.
1861+
"""
1862+
if not errors:
1863+
return errors
1864+
if not dry_run:
1865+
return maybe_autoheal_fts5_index(palace_path, errors, progress=progress)
1866+
if _errors_are_isolated_fts5(errors):
1867+
progress(
1868+
"\n DRY RUN — quick_check reports an isolated FTS5 inverted-index error.\n"
1869+
" A real run would attempt an in-place rebuild of that index from the\n"
1870+
" intact content table and continue if it succeeds; it aborts instead if\n"
1871+
" another process holds the mine lock or the rebuild leaves quick_check\n"
1872+
" dirty. This preview leaves the index untouched."
1873+
)
1874+
return []
1875+
return errors
1876+
1877+
17611878
def _rebuild_from_sqlite_locked(
17621879
*,
17631880
source_palace: str,

0 commit comments

Comments
 (0)