3737import stat
3838import time
3939from collections import defaultdict
40- from contextlib import closing
40+ from contextlib import closing , suppress
4141from datetime import datetime
4242import re
4343from 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
829896def _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+
840919def 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