Skip to content

Commit fe8e748

Browse files
authored
Merge pull request #2279 from mvalentsev/fix/fts5-heal-corroborate-content
fix(repair): check the FTS5 content table before rebuilding from it (#2278)
2 parents 34e6bd7 + 18cf07f commit fe8e748

4 files changed

Lines changed: 583 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1616
- **`sweep` books a failed `stat` as a failure again.** The non-regular-file gate added in 3.7.1 reads `stat.S_ISREG(f.stat().st_mode)` inside a `try`, and its `except OSError` printed `SKIP` and moved on. A dangling symlink, a symlink loop and a file unlinked between `rglob` and the gate all raise there, and before the gate existed every one of them reached `sweep()` and was booked in `failures` — so `sweep` went from reporting a transcript it could not read to reporting success. A failed probe is now an error, not a benign file type: it is logged, printed as `WARNING`, and appended to `failures`, while a probe that succeeds and says "not regular" still skips silently. (#2221)
1717
- **`mempalace init` no longer tracebacks on a directory it cannot enter.** `_parse_gradle`'s `is_file()` gate sat in front of the `try` that the parser's own `except OSError` provides, so a manifest under a directory with `r` but no `x` raised `PermissionError` out of a call that used to answer "no manifest name". The gate moved inside that `try`, and `_collect_manifest_names` stats through `os.path.isfile`, which reports rather than raises. (#2221)
1818
- **`split` no longer blocks on a FIFO at its own output name, nor writes through a broken link.** The type gate in `main` covers the files the glob listed; `split_file` builds its output names itself, so a pre-existing named pipe at one of them wedged `write_text` in the kernel waiting for a reader. The check asks about the link itself rather than its target, because a dangling symlink at an output name reads as "nothing there" and `write_text` would create the target — landing a chunk outside the output directory. Output names that are anything but a regular file are now skipped with a `SKIP` line. (#2221)
19+
- **The FTS5 auto-heal checks the content table before it rebuilds from it.** `PRAGMA quick_check`'s isolated `malformed inverted index for FTS5 table` says the inverted index and `embedding_fulltext_search_content` disagree, not which of them is wrong, and damaging the content table produces that same wording on SQLite 3.45.1, 3.47.1 and 3.51.2 alike. The heal rebuilt from that table regardless and reported "rebuilt from intact content", so a damaged content table cost the palace its lexical reach — 12 of 30 drawers stopped answering `lexical_search` for a word `embedding_metadata` still held — permanently on the `mine` path, where nothing re-files afterwards. Chroma writes every document twice, into `embedding_metadata` under `chroma:document` and into the FTS5 table at `rowid = embeddings.id`, so the shadow copy has an authority: the heal now checks it against that table, restores the rows that disagree and rebuilds, all in one transaction under the mine lock. Rows the authority cannot speak for keep their content and are named in the output, and a check that cannot conclude declines the rebuild instead of guessing. (#2278)
1920

2021
---
2122

mempalace/palace.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1125,7 +1125,9 @@ def _validate_palace_fts5_after_mine(palace_path: str) -> None:
11251125
`maybe_autoheal_fts5_index` rebuild `cmd_repair` already runs as its own
11261126
preflight step — so a normal `mine` self-heals the recoverable case
11271127
instead of forcing the operator to run `mempalace repair` by hand for a
1128-
derived index that regenerates from intact content.
1128+
derived index. Nothing re-files afterwards on this path, so the heal is the
1129+
last word here: it checks the content table against `embedding_metadata`
1130+
before rebuilding from it, and declines when it cannot.
11291131
"""
11301132
if resolve_backend_name(palace_path) != "chroma":
11311133
return

mempalace/repair.py

Lines changed: 192 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
import stat
3838
import time
3939
from collections import defaultdict
40-
from contextlib import closing
40+
from contextlib import closing, suppress
4141
from datetime import datetime
4242
import re
4343
from typing import Callable, Iterator, Optional
@@ -813,9 +813,8 @@ def print_sqlite_integrity_abort(palace_path: str, errors: list[str]) -> None:
813813
# "fts5: corruption found reading blob N from table \"embedding_fulltext_search\""
814814
# (SQLite >= ~3.5x, confirmed on 3.53.2 / Python 3.13.7 — the exact
815815
# message this repo's own test fixture produces on that build)
816-
# Either failure is recoverable in place: the index is derived from the
817-
# intact ``embedding_fulltext_search_content`` shadow table, so rebuilding it
818-
# restores full-text search without touching any drawer rows. Concurrent
816+
# Either failure says the index and the ``embedding_fulltext_search_content``
817+
# shadow table disagree; neither names the side that is damaged. Concurrent
819818
# killed-mid-write mines are the usual cause (#1596). A regex matching only
820819
# the older phrasing would silently decline to auto-heal on newer SQLite —
821820
# the exact failure this repo's own test suite caught (test_repair.py's two
@@ -825,32 +824,123 @@ def print_sqlite_integrity_abort(palace_path: str, errors: list[str]) -> None:
825824
re.IGNORECASE,
826825
)
827826

827+
# ``embedding_fulltext_search`` is derived data twice over: chroma writes each
828+
# document into ``embedding_metadata`` under ``chroma:document`` and into the
829+
# FTS5 table at ``rowid = embeddings.id``, and every read path returns the
830+
# metadata copy (checked against chromadb 1.5.7). So the content shadow table
831+
# has an authority to be checked against, and a rebuild that reads it is only
832+
# as good as that check.
833+
#
834+
# Both queries scan the metadata table: ``(id, key)`` is its primary key, so a
835+
# lookup by ``key`` alone cannot use it. An index for the heal alone would be
836+
# paid on every write instead, which is the worse trade.
837+
#
838+
# ``typeof(m.id) = 'integer'`` is not decoration. ``id`` is nullable — a NULL
839+
# is storable in that composite key — and feeding NULL into the content table's
840+
# ``INTEGER PRIMARY KEY`` makes SQLite assign a fresh rowid rather than conflict,
841+
# so such a row would never reconcile and the heal would decline on every run.
842+
_FTS5_CONTENT_TO_RESTORE_SQL = """
843+
SELECT count(*)
844+
FROM embedding_metadata AS m
845+
LEFT JOIN embedding_fulltext_search_content AS c ON c.id = m.id
846+
WHERE m.key = 'chroma:document'
847+
AND typeof(m.id) = 'integer'
848+
AND m.string_value IS NOT NULL
849+
AND c.c0 IS NOT m.string_value
850+
"""
851+
852+
# How much of the content table the authority can speak for. A row it cannot —
853+
# chroma's own update path stores ``{'chroma:document': None}`` by deleting the
854+
# metadata row while still writing the FTS row — keeps its content untouched,
855+
# because for those rows the shadow table may be the only copy. If the authority
856+
# can speak for none of them there is nothing to rebuild on, and a row at an id
857+
# no ``embeddings`` row uses says the table is not keyed the way this code reads
858+
# it: chroma's ``00003-full-text-tokenize`` migration populated the FTS table
859+
# from ``embedding_metadata.rowid`` over every string value, not from
860+
# ``embeddings.id`` over documents.
861+
_FTS5_CONTENT_CENSUS_SQL = """
862+
SELECT coalesce(sum(CASE WHEN m.id IS NULL THEN 0 ELSE 1 END), 0),
863+
coalesce(sum(CASE WHEN m.id IS NULL THEN 1 ELSE 0 END), 0),
864+
coalesce(sum(CASE WHEN e.id IS NULL THEN 1 ELSE 0 END), 0)
865+
FROM embedding_fulltext_search_content AS c
866+
LEFT JOIN embedding_metadata AS m
867+
ON m.id = c.id
868+
AND m.key = 'chroma:document'
869+
AND m.string_value IS NOT NULL
870+
LEFT JOIN embeddings AS e ON e.id = c.id
871+
"""
872+
873+
# Written to the shadow table directly, not through the virtual table: an
874+
# INSERT or DELETE on ``embedding_fulltext_search`` goes through the inverted
875+
# index, which is the structure quick_check has just called malformed. Writing
876+
# the content rows and then rebuilding is the one order that does not depend on
877+
# the damaged side. ``c0`` is FTS5's own column-naming convention for the first
878+
# indexed column, and the SELECT needs its WHERE clause for ``ON CONFLICT`` to
879+
# parse at all — dropping that predicate turns this into a syntax error.
880+
_FTS5_CONTENT_RESTORE_SQL = """
881+
INSERT INTO embedding_fulltext_search_content(id, c0)
882+
SELECT m.id, m.string_value
883+
FROM embedding_metadata AS m
884+
WHERE m.key = 'chroma:document'
885+
AND typeof(m.id) = 'integer'
886+
AND m.string_value IS NOT NULL
887+
ON CONFLICT(id) DO UPDATE SET c0 = excluded.c0
888+
WHERE embedding_fulltext_search_content.c0 IS NOT excluded.c0
889+
"""
890+
891+
_FTS5_REBUILD_SQL = (
892+
"INSERT INTO embedding_fulltext_search(embedding_fulltext_search) VALUES('rebuild')" # noqa: E501
893+
)
894+
828895

829896
def _errors_are_isolated_fts5(errors: list[str]) -> bool:
830897
"""True when every quick_check error is a malformed FTS5 inverted index.
831898
832-
Only an isolated FTS5 failure is safe to auto-heal: the inverted index is
833-
derived data that ``rebuild`` regenerates from the content shadow table. If
834-
quick_check also reports page/row corruption, the data itself may be damaged
835-
and rebuilding the index over it would mask real loss — that still aborts.
899+
Isolation is necessary but not sufficient for an auto-heal: it rules out
900+
page/row corruption elsewhere in the file, and nothing more. Which of the
901+
two FTS5 tables is damaged is still open — see
902+
:func:`maybe_autoheal_fts5_index`, which settles that before writing.
836903
"""
837904
return bool(errors) and all(_FTS5_MALFORMED_RE.search(e) for e in errors)
838905

839906

907+
def _fts5_content_rows_to_restore(conn: sqlite3.Connection) -> int:
908+
"""Count documents whose shadow copy is missing or says something else."""
909+
return int(conn.execute(_FTS5_CONTENT_TO_RESTORE_SQL).fetchone()[0])
910+
911+
912+
def _fts5_content_census(conn: sqlite3.Connection) -> tuple[int, int, int]:
913+
"""Content rows a ``chroma:document`` can speak for, rows it cannot, rows
914+
sitting at an id no ``embeddings`` row uses."""
915+
checked, unverifiable, unkeyed = conn.execute(_FTS5_CONTENT_CENSUS_SQL).fetchone()
916+
return int(checked), int(unverifiable), int(unkeyed)
917+
918+
840919
def maybe_autoheal_fts5_index(palace_path: str, errors: list[str], *, progress=print) -> list[str]:
841920
"""Rebuild a malformed FTS5 inverted index in place; return remaining errors.
842921
843922
The repair preflight aborts when ``PRAGMA quick_check`` reports SQLite-layer
844923
corruption. After concurrent killed-mid-write mines (#1596) the common
845-
failure is an isolated ``malformed inverted index for FTS5 table``, which is
846-
fully recoverable: the index rebuilds from the intact
847-
``embedding_fulltext_search_content`` table without touching drawer rows.
848-
849-
When the errors are isolated to FTS5, rebuild the index under the palace
850-
write lock (so a live mine cannot race the rebuild) and re-run quick_check.
851-
Returns the remaining quick_check errors — empty when the heal succeeded.
852-
Broader corruption, a lock held by another writer, or a rebuild failure
853-
leaves ``errors`` unchanged so the caller still aborts with the banner.
924+
failure is an isolated ``malformed inverted index for FTS5 table``, and
925+
``rebuild`` recovers it by regenerating the index from
926+
``embedding_fulltext_search_content``.
927+
928+
That error says the index and the content table disagree; it does not say
929+
which of them is wrong. So the content table is checked against
930+
``embedding_metadata`` first, and any row that disagrees is restored from it
931+
before the rebuild reads it — otherwise a rebuild over a damaged content
932+
table would overwrite an index that still held the drawer's own words and
933+
leave quick_check clean, reporting success for a palace that lost full-text
934+
reach. Both writes are derived from ``embedding_metadata``; rows that
935+
table cannot speak for are left untouched.
936+
937+
Everything happens under the palace write lock (so a live mine cannot race
938+
it) and in one transaction, which is why a restored row cannot outlive a
939+
rebuild that then fails. Returns the remaining quick_check errors — empty
940+
when the heal succeeded. Broader corruption, a lock held by another writer,
941+
a content table that cannot be checked or cannot be brought into agreement,
942+
a rebuild failure, or a quick_check still dirty afterwards leaves ``errors``
943+
unchanged so the caller still aborts with the banner.
854944
"""
855945
if not _errors_are_isolated_fts5(errors):
856946
return errors
@@ -864,32 +954,97 @@ def maybe_autoheal_fts5_index(palace_path: str, errors: list[str], *, progress=p
864954
from .palace import MineAlreadyRunning, mine_palace_lock
865955

866956
progress(
867-
"\n Isolated FTS5 inverted-index corruption detected; attempting an\n"
868-
" in-place rebuild from the intact content table before aborting."
957+
"\n Isolated FTS5 inverted-index corruption detected; checking the content\n"
958+
" table against embedding_metadata before rebuilding the index from it."
869959
)
960+
declined = False
961+
to_restore = checked = unverifiable = unkeyed = 0
870962
try:
871963
with mine_palace_lock(palace_path):
872964
with closing(sqlite3.connect(sqlite_path, isolation_level=None)) as conn:
873-
conn.execute(
874-
"INSERT INTO embedding_fulltext_search"
875-
"(embedding_fulltext_search) VALUES('rebuild')"
876-
)
877-
conn.commit()
965+
conn.execute("BEGIN IMMEDIATE")
966+
try:
967+
to_restore = _fts5_content_rows_to_restore(conn)
968+
checked, unverifiable, unkeyed = _fts5_content_census(conn)
969+
except sqlite3.Error as exc:
970+
# Suppressed: a failing ROLLBACK would replace the message
971+
# that says why the heal declined. Closing the connection
972+
# rolls the transaction back either way.
973+
with suppress(sqlite3.Error):
974+
conn.execute("ROLLBACK")
975+
declined = True
976+
progress(
977+
" Skipped FTS5 rebuild: the content table cannot be checked against "
978+
f"embedding_metadata ({exc}). The index still holds the terms it was "
979+
"built from."
980+
)
981+
if not declined and unverifiable and not checked and not to_restore:
982+
with suppress(sqlite3.Error):
983+
conn.execute("ROLLBACK")
984+
declined = True
985+
progress(
986+
" Skipped FTS5 rebuild: no content row has an embedding_metadata "
987+
"document to check it against. The index still holds the terms it "
988+
"was built from."
989+
)
990+
if not declined and to_restore:
991+
# Present tense on purpose: this prints before COMMIT, so it
992+
# is also what an operator sees on a run that then rolls back.
993+
progress(
994+
f" Restoring {to_restore} content row(s) from embedding_metadata "
995+
"before rebuilding."
996+
)
997+
conn.execute(_FTS5_CONTENT_RESTORE_SQL)
998+
if _fts5_content_rows_to_restore(conn):
999+
with suppress(sqlite3.Error):
1000+
conn.execute("ROLLBACK")
1001+
declined = True
1002+
progress(
1003+
" Skipped FTS5 rebuild: the content table still disagrees with "
1004+
"embedding_metadata after restoring it. Nothing was written."
1005+
)
1006+
else:
1007+
# Re-taken: a restore can add content rows the authority
1008+
# has and the shadow table had lost, so the census from
1009+
# before it would under-report what the rebuild reads.
1010+
checked, unverifiable, unkeyed = _fts5_content_census(conn)
1011+
if not declined:
1012+
if unverifiable:
1013+
progress(
1014+
f" {unverifiable} content row(s) have no embedding_metadata document "
1015+
"to check against; the rebuild indexes them as they stand."
1016+
)
1017+
if unkeyed:
1018+
progress(
1019+
f" {unkeyed} content row(s) sit at an id no embeddings row uses; "
1020+
"this table was not written by the current chromadb schema."
1021+
)
1022+
conn.execute(_FTS5_REBUILD_SQL)
1023+
conn.execute("COMMIT")
8781024
except MineAlreadyRunning as exc:
8791025
progress(
8801026
f" Skipped FTS5 rebuild: palace is being written by another process ({exc}). "
8811027
"Stop it and re-run."
8821028
)
8831029
return errors
8841030
except Exception as exc:
885-
progress(f" FTS5 rebuild failed (leaving palace untouched): {exc}")
1031+
# Deliberately broad and deliberately not naming the rebuild: this now
1032+
# covers the lock, the transaction, both counts and the restore, and a
1033+
# heal that raises must never be what fails a mine.
1034+
progress(f" FTS5 heal failed (leaving palace untouched): {exc}")
1035+
return errors
1036+
1037+
if declined:
8861038
return errors
8871039

8881040
remaining = sqlite_integrity_errors(palace_path)
8891041
if remaining:
8901042
progress(" FTS5 rebuild did not clear quick_check; aborting for safety.")
8911043
else:
892-
progress(" FTS5 index rebuilt from intact content; quick_check is clean.")
1044+
progress(
1045+
f" FTS5 index rebuilt from content checked against embedding_metadata "
1046+
f"({checked} row(s)); quick_check is clean."
1047+
)
8931048
return remaining
8941049

8951050

@@ -1898,10 +2053,12 @@ def resolve_repair_preflight_errors(
18982053
18992054
The prediction is deliberately the optimistic branch, and it is stated as
19002055
an attempt rather than a promise: the real heal still returns the errors
1901-
unchanged when another process holds the mine lock, when the rebuild
1902-
raises, or when ``quick_check`` is still dirty afterwards. A dry run cannot
1903-
tell those apart without taking the lock and writing, which is exactly what
1904-
it must not do, so the wording names them instead.
2056+
unchanged when another process holds the mine lock, when the content table
2057+
cannot be checked against ``embedding_metadata`` or cannot be brought into
2058+
agreement with it, when the rebuild raises, or when ``quick_check`` is still
2059+
dirty afterwards. A dry run cannot tell those apart without taking the lock
2060+
and writing, which is exactly what it must not do, so the wording names them
2061+
instead.
19052062
"""
19062063
if not errors:
19072064
return errors
@@ -1910,10 +2067,11 @@ def resolve_repair_preflight_errors(
19102067
if _errors_are_isolated_fts5(errors):
19112068
progress(
19122069
"\n DRY RUN — quick_check reports an isolated FTS5 inverted-index error.\n"
1913-
" A real run would attempt an in-place rebuild of that index from the\n"
1914-
" intact content table and continue if it succeeds; it aborts instead if\n"
1915-
" another process holds the mine lock or the rebuild leaves quick_check\n"
1916-
" dirty. This preview leaves the index untouched."
2070+
" A real run would check the content table against embedding_metadata,\n"
2071+
" restore any row that disagrees, rebuild the index from it and continue\n"
2072+
" if that succeeds; it aborts instead if another process holds the mine\n"
2073+
" lock, if the content table cannot be checked or restored, or if the\n"
2074+
" rebuild leaves quick_check dirty. This preview leaves the index untouched."
19172075
)
19182076
return []
19192077
return errors

0 commit comments

Comments
 (0)