Coverage for src/local_deep_research/vector_stores/legacy_rekey.py: 73%

184 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 07:11 +0000

1"""Login-time (phase-2) re-key of legacy RAG indices -- the per-user half of 

2the vector-store cutover. 

3 

4``migrate_legacy_docstores`` (phase 1, ``legacy_cleanup.py``) runs once at 

5server startup and turns each legacy ``<hash>.pkl`` docstore into a small, 

6text-free ``<hash>.idmap.json`` sidecar (FAISS position -> uuid), deleting the 

7plaintext ``.pkl``. It cannot go further: rebuilding the index keyed by 

8``DocumentChunk.id`` needs the per-user encrypted DB (to resolve uuid -> 

9``DocumentChunk.id``), which is only available once a user has logged in. 

10 

11This module is that second half. ``rekey_user_indexes`` is called once per 

12user at login time (from ``database/encrypted_db.py`` 

13``_open_user_database_cold``, AFTER the user's DB connection is already 

14cached -- see that function's docstring for why it must run there and not 

15earlier). For each of the user's ``RAGIndex`` rows whose on-disk ``.faiss`` 

16still has a sidecar (i.e. phase 1 ran but phase 2 hasn't), it resolves 

17``uuid -> DocumentChunk.id`` from the DB, re-keys the index in place via 

18``legacy_cleanup.rekey_index_file`` (no re-embedding), and re-records file 

19integrity. Any single index that fails is quarantined (moved aside) and its 

20collection's indexed state is reset so the normal indexing path rebuilds it 

21from scratch -- one bad index never blocks the rest of the user's indices, 

22and this whole step never blocks login (the caller wraps it in try/except). 

23 

24Gated behind a one-time per-user settings marker 

25(``research_library.rag_index_rekeyed_v2``) so this is a single 

26``get_setting`` call (fast no-op) on every login after the first successful 

27run, and for every user who has no legacy sidecars at all -- the common case 

28once the fleet has passed through the cutover once. 

29""" 

30 

31import json 

32import time 

33from pathlib import Path 

34from typing import Dict, Iterable, List, Optional, cast 

35 

36from loguru import logger 

37 

38from ..database.models.library import ( 

39 DocumentChunk, 

40 DocumentCollection, 

41 RAGIndex, 

42 RagDocumentStatus, 

43) 

44from ..database.session_context import get_user_db_session 

45from ..security.file_integrity import FAISSIndexVerifier, FileIntegrityManager 

46from ..security.log_sanitizer import redact_secrets 

47from ..utilities.db_utils import get_settings_manager 

48from .legacy_cleanup import rekey_index_file 

49 

50# Settings key gating the one-time per-user re-key. Only set after every one 

51# of the user's indices has been resolved (rekeyed, skipped, or 

52# quarantined) -- see ``rekey_user_indexes`` for what happens if the process 

53# is killed mid-run (next login just retries the remaining sidecars). 

54REKEY_MARKER_SETTING = "research_library.rag_index_rekeyed_v2" 

55 

56# SQLITE_MAX_VARIABLE_NUMBER is 999 on older SQLite builds; chunk the 

57# embedding_id IN() lookup well below it. Mirrors ``_IN_CLAUSE_CHUNK`` in 

58# research_library/notes/services/note_service.py. 

59_IN_CLAUSE_CHUNK = 500 

60 

61_SIDECAR_SUFFIX = ".idmap.json" 

62 

63 

64def _sidecar_for(faiss_path: Path) -> Path: 

65 # <hash>.faiss -> <hash>.idmap.json (same stem as legacy_cleanup uses 

66 # for <hash>.pkl -> <hash>.idmap.json, since .pkl and .faiss share a stem). 

67 return faiss_path.with_name(faiss_path.stem + _SIDECAR_SUFFIX) 

68 

69 

70def _is_idmap2(faiss_path: Path) -> Optional[bool]: 

71 """``True`` iff the ``.faiss`` loads as the new ``IndexIDMap2`` format; 

72 ``False`` if it loads as a DIFFERENT (old) format; ``None`` if it could NOT 

73 be read at all. 

74 

75 Used to tell an already-migrated index (IndexIDMap2, no sidecar) apart from 

76 a phase-1 reindex-fallback leftover (an OLD-format base index whose ``.pkl`` 

77 was deleted without writing a sidecar). The caller quarantines ONLY on a 

78 definite ``False``. A ``None`` (transient IO/permission error) must NOT 

79 destructively quarantine + full-re-embed a possibly-healthy index — the 

80 caller withholds the marker and retries next login instead. A genuinely 

81 corrupt file that keeps failing is still caught by the normal index/search 

82 load-failure path, which quarantines there. 

83 """ 

84 from faiss import IndexIDMap2, read_index 

85 

86 try: 

87 index = read_index(str(faiss_path)) 

88 except Exception: 

89 return None 

90 return isinstance(index, IndexIDMap2) 

91 

92 

93def _load_uuid_to_id( 

94 username: str, db_password: str, uuids: Iterable[str] 

95) -> Dict[str, int]: 

96 """Resolve ``DocumentChunk.embedding_id`` (uuid) -> ``DocumentChunk.id`` 

97 (int) for the given uuids, chunking the ``IN()`` list. uuids with no 

98 matching row (deleted chunks) are simply absent from the result -- 

99 ``rekey_index_file`` drops those positions as orphans.""" 

100 uuid_list = list(uuids) 

101 uuid_to_id: Dict[str, int] = {} 

102 with get_user_db_session(username, db_password) as session: 

103 for i in range(0, len(uuid_list), _IN_CLAUSE_CHUNK): 

104 batch = uuid_list[i : i + _IN_CLAUSE_CHUNK] 

105 rows = ( 

106 session.query(DocumentChunk.embedding_id, DocumentChunk.id) 

107 .filter(DocumentChunk.embedding_id.in_(batch)) 

108 .all() 

109 ) 

110 uuid_to_id.update(dict(rows)) 

111 return uuid_to_id 

112 

113 

114def _resolve_index_params(rag_index: RAGIndex, settings) -> Dict: 

115 """RAGIndex row values win; fall back to the current ``local_search_*`` 

116 defaults for legacy rows that predate these columns -- mirrors the 

117 default resolution in ``rag_service_factory.get_rag_service``.""" 

118 return { 

119 "dimension": rag_index.embedding_dimension, 

120 "index_type": rag_index.index_type 

121 or (settings.get_setting("local_search_index_type") or "flat"), 

122 "metric": rag_index.distance_metric 

123 or (settings.get_setting("local_search_distance_metric") or "cosine"), 

124 "normalize": ( 

125 rag_index.normalize_vectors 

126 if rag_index.normalize_vectors is not None 

127 else settings.get_bool_setting("local_search_normalize_vectors") 

128 ), 

129 } 

130 

131 

132def _persist_rekeyed_config( 

133 username: str, db_password: str, rag_index_id: int, params: Dict 

134) -> None: 

135 """Pin the config the physical file was ACTUALLY built with onto the 

136 RAGIndex row. 

137 

138 Legacy rows predate the index_type/distance_metric/normalize_vectors 

139 columns, so ``_resolve_index_params`` fills them from the live 

140 ``local_search_*`` settings and ``rekey_index_file`` bakes that choice into 

141 the .faiss file. If the row is left NULL, every later access re-resolves the 

142 SAME settings-fallback chain — so changing a global default afterward makes 

143 the runtime reinterpret the file under a different index_type/metric/ 

144 normalize than it was built with. Write the resolved values back (only where 

145 the row is still NULL — never override an explicit stored value).""" 

146 with get_user_db_session(username, db_password) as session: 

147 row = session.query(RAGIndex).filter_by(id=rag_index_id).first() 

148 if row is None: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 return 

150 changed = False 

151 if row.index_type is None: 

152 row.index_type = params["index_type"] 

153 changed = True 

154 if row.distance_metric is None: 

155 row.distance_metric = params["metric"] 

156 changed = True 

157 if row.normalize_vectors is None: 

158 row.normalize_vectors = params["normalize"] 

159 changed = True 

160 if changed: 

161 session.commit() 

162 

163 

164def _purge_redundant_pkl(faiss_path: Path, db_password: str) -> bool: 

165 """Delete a legacy plaintext ``.pkl`` that has become REDUNDANT. 

166 

167 Once phase-1 has extracted the sidecar (or the index is already an 

168 IndexIDMap2), the ``.pkl``'s chunk text lives only in the encrypted DB and 

169 the id-map lives in the sidecar, so the ``.pkl`` is pure leftover plaintext. 

170 Phase-1 deletes it, but that one-time unlink can fail (a directory the app 

171 can't write, a read-only mount, an AV/EDR lock) AFTER the sidecar was 

172 written — leaving plaintext on disk that no later step revisits once the 

173 per-user completion marker is set. Callers invoke this at every phase-2 

174 finalization point (re-key success, already-migrated skip) as a backstop. 

175 

176 Returns True if the ``.pkl`` is absent or was deleted, False if deletion 

177 failed (the caller then withholds the completion marker so phase-2 retries 

178 instead of gating the user out with plaintext still on disk). Deliberately 

179 NOT called where a ``.pkl`` is still NEEDED (no sidecar + old-format index, 

180 i.e. phase-1 hasn't converted it yet) — that case is handled separately. 

181 """ 

182 pkl = faiss_path.with_suffix(".pkl") 

183 if not pkl.exists(): 

184 return True 

185 try: 

186 pkl.unlink() 

187 logger.info(f"Removed surviving legacy plaintext .pkl {pkl}") 

188 return True 

189 except OSError as exc: 

190 safe_msg = redact_secrets(str(exc), db_password) 

191 logger.warning( 

192 f"Could not remove surviving legacy plaintext .pkl {pkl}; " 

193 "withholding re-key completion so it is retried next login: " 

194 f"{safe_msg}" 

195 ) 

196 return False 

197 

198 

199def _quarantine_and_reset( 

200 username: str, 

201 db_password: str, 

202 rag_index: RAGIndex, 

203 faiss_path: Path, 

204 sidecar_path: Path, 

205 lock, 

206) -> None: 

207 """Move the broken index pair aside and reset the collection's indexed 

208 state so the normal indexing path rebuilds it from scratch. 

209 

210 Mirrors ``LibraryRAGService._quarantine_corrupt_index`` + 

211 ``_reset_index_state_for_rebuild`` in spirit (rename-aside, never 

212 delete; clear ``RagDocumentStatus`` + ``DocumentCollection.indexed`` so 

213 the next index run does real work instead of skipping everything). 

214 Kept as a free function here (rather than reusing those bound methods 

215 directly) so this module doesn't have to construct a full 

216 ``LibraryRAGService`` -- which would pull in an embedding provider -- 

217 just to quarantine a broken re-key. 

218 

219 Raises on rename failure so the caller can log it distinctly and, in 

220 turn, withhold the per-user completion marker (see 

221 ``rekey_user_indexes``) so this index is retried on the next login 

222 instead of being silently abandoned behind a "done" marker. 

223 """ 

224 # Reset DB state FIRST, then move the files aside. These two steps are not 

225 # atomic, so ordering decides what a crash in between leaves behind: 

226 # - DB-first (here): the collection is durably flagged "needs reindex" 

227 # before its file is touched. A crash after the commit leaves a stray 

228 # (still-original or renamed) file that the next index run simply 

229 # rebuilds over — no data lost, no wrong state. 

230 # - file-first (the reverse) would strand the collection as "indexed" with 

231 # its backing index renamed away and no recoverable data until an admin 

232 # intervenes — the exact permanent-loss failure this ordering avoids. 

233 collection_id = rag_index.collection_name.removeprefix("collection_") 

234 with get_user_db_session(username, db_password) as session: 

235 idx = session.query(RAGIndex).filter_by(id=rag_index.id).first() 

236 if idx: 236 ↛ 239line 236 didn't jump to line 239 because the condition on line 236 was always true

237 idx.chunk_count = 0 

238 idx.total_documents = 0 

239 session.query(RagDocumentStatus).filter_by( 

240 rag_index_id=rag_index.id 

241 ).delete() 

242 # Only the CURRENT index backs the collection's live searches, so only 

243 # its failure should force the whole collection to re-embed. Quarantining 

244 # a STALE (is_current=False) row must NOT flip the collection's live 

245 # DocumentCollection.indexed state -- the current index is still healthy 

246 # and serving searches; clearing this row's RAGIndex + RagDocumentStatus 

247 # above is enough. (rekey_user_indexes now re-keys stale rows too, so 

248 # this guard is what keeps a stale-row failure from needlessly 

249 # re-embedding the whole collection.) 

250 if idx is not None and idx.is_current: 

251 session.query(DocumentCollection).filter_by( 

252 collection_id=collection_id, indexed=True 

253 ).update({"indexed": False, "chunk_count": 0}) 

254 session.commit() 

255 

256 # Hold the per-(user, index_path) write lock for the renames so they don't 

257 # race a concurrent search/index reading the same .faiss (which would see 

258 # the file vanish mid-operation). 

259 ns = time.time_ns() 

260 with lock: 

261 if faiss_path.exists(): 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 faiss_path.rename(Path(f"{faiss_path}.corrupt-{ns}")) 

263 if sidecar_path.exists(): 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 sidecar_path.rename(Path(f"{sidecar_path}.corrupt-{ns}")) 

265 # Also DELETE (not rename) any lingering legacy plaintext .pkl companion. 

266 # Phase-1 normally removes it at startup, but if it couldn't then 

267 # (unreadable/symlinked at that moment) or it reappeared, quarantining 

268 # the .faiss must not leave the plaintext docstore on disk. Renaming 

269 # aside like the .faiss would preserve the plaintext, so unlink it. Let 

270 # an unlink failure PROPAGATE (like the renames above): swallowing it 

271 # counted the quarantine as clean success and set the completion marker 

272 # while plaintext survived on disk. The caller instead logs it and 

273 # withholds the marker so phase-2 retries. 

274 pkl = faiss_path.with_suffix(".pkl") 

275 if pkl.exists(): 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 pkl.unlink() 

277 logger.warning( 

278 f"Quarantined un-rekeyable RAG index {faiss_path} for collection " 

279 f"{collection_id}; reset its indexed state so it rebuilds on next use." 

280 ) 

281 

282 

283def rekey_user_indexes(username: str, db_password: str) -> Dict[str, int]: 

284 """Re-key this user's legacy RAG indices (phase 2 of the cutover), once. 

285 

286 MUST be called only after ``username``'s DB connection is already in 

287 ``db_manager.connections``. This function goes through 

288 ``get_user_db_session`` / ``FileIntegrityManager``, both of which 

289 re-enter ``db_manager.open_user_database`` on a thread-local 

290 session-cache miss. Calling this from inside ``_open_user_database_cold`` 

291 BEFORE the connection is cached would deadlock: ``open_user_database`` 

292 serializes the cold-open behind a per-user ``threading.Lock`` (not 

293 reentrant), which this same thread is already holding at that point. 

294 

295 Returns a stats dict (``checked``/``rekeyed``/``quarantined``/ 

296 ``skipped_no_sidecar``/``skipped_no_faiss``). Does not raise -- every 

297 per-index failure is caught and quarantined; the caller wraps this call 

298 anyway as defense in depth. 

299 """ 

300 stats = { 

301 "checked": 0, 

302 "rekeyed": 0, 

303 "quarantined": 0, 

304 "skipped_no_sidecar": 0, 

305 "skipped_no_faiss": 0, 

306 } 

307 

308 with get_user_db_session(username, db_password) as session: 

309 settings = get_settings_manager(db_session=session, username=username) 

310 if settings.get_bool_setting(REKEY_MARKER_SETTING, False): 

311 return stats # already done for this user -- fast no-op 

312 # Re-key EVERY RAGIndex row phase-1 converted, not just the current one. 

313 # A collection can have stale historical rows (is_current=False) from an 

314 # earlier embedding-model switch, and those are NOT unreachable: reverting 

315 # the embedding-model setting re-resolves a search to the stale row by 

316 # index_hash (library_rag_service._get_or_create_rag_index looks the row 

317 # up by hash, NOT is_current), and a still-old-format .faiss then fails to 

318 # load and hard-fails search. Phase-1 already writes text-free sidecars 

319 # for ALL of them, so phase-2 must re-key ALL of them. To keep a stale 

320 # row's re-key FAILURE from collaterally wiping the healthy current 

321 # index's indexed state, _quarantine_and_reset resets the collection-wide 

322 # DocumentCollection.indexed flag ONLY for the current row (see there). 

323 indexes: List[RAGIndex] = session.query(RAGIndex).all() 

324 

325 if not indexes: 

326 settings.set_setting(REKEY_MARKER_SETTING, True) 

327 return stats 

328 

329 # Lazy import: avoids a module-load cycle (this is a leaf import used 

330 # only inside this function -- see rekey_index_file's docstring for the 

331 # same pattern in legacy_cleanup.py). 

332 from ..research_library.services.library_rag_service import ( 

333 _get_faiss_write_lock, 

334 ) 

335 

336 integrity_manager = None # built lazily -- only if a sidecar is found 

337 # Only set the completion marker if every index was actually resolved 

338 # (rekeyed / skipped / quarantined). If quarantining itself fails (e.g. 

339 # disk full mid-rename), that index is left untouched and the marker is 

340 # withheld so the NEXT login retries it, instead of silently abandoning 

341 # it forever behind a "done" marker. 

342 all_resolved = True 

343 

344 for rag_index in indexes: 

345 stats["checked"] += 1 

346 faiss_path = Path(rag_index.index_path) 

347 sidecar_path = _sidecar_for(faiss_path) 

348 # Per-(user, index_path) write lock, held for every file mutation 

349 # (re-key persist, quarantine renames) so none race a concurrent 

350 # search/index on the same .faiss. 

351 lock = _get_faiss_write_lock(username, str(faiss_path)) 

352 

353 if not sidecar_path.exists(): 

354 # No sidecar means EITHER already fully migrated (the .faiss is an 

355 # IndexIDMap2) OR phase-1 hit its reindex-fallback: a corrupt/ 

356 # malformed legacy .pkl was deleted WITHOUT writing a sidecar, 

357 # leaving the OLD-format .faiss stranded. The latter would otherwise 

358 # be skipped forever while the DB still claims the collection is 

359 # indexed, so its search silently breaks. Distinguish by format and 

360 # quarantine + reset the un-migratable one so it rebuilds. (This 

361 # runs once — the whole function is per-user marker-gated.) 

362 fmt = _is_idmap2(faiss_path) if faiss_path.exists() else True 

363 if fmt is None: 

364 # Could NOT read the .faiss to determine its format (transient 

365 # IO/permission). Do NOT quarantine a possibly-healthy index — 

366 # withhold the marker and retry next login. 

367 logger.warning( 

368 f"Could not read {faiss_path} to check its format; will " 

369 "retry next login (NOT quarantining a possibly-healthy " 

370 "index)." 

371 ) 

372 all_resolved = False 

373 continue 

374 if faiss_path.exists() and fmt is False: 

375 # A no-sidecar old-format .faiss has TWO causes, and only one is 

376 # the "phase-1 gave up" (reindex-fallback) case that warrants a 

377 # destructive quarantine. If the legacy plaintext .pkl companion 

378 # STILL EXISTS, phase-1 simply hasn't converted this file yet 

379 # (it ran before this collection existed / crashed early / a 

380 # sibling process's phase-1 hasn't reached it) — its sidecar is 

381 # still to come. Quarantining now would force a needless full 

382 # re-embed instead of the cheap rekey phase-1 will enable, and it 

383 # would delete an intact docstore out from under phase-1. Withhold 

384 # the completion marker and retry next login, by which point 

385 # phase-1 (which runs every startup) will have written the 

386 # sidecar and removed the .pkl. 

387 if faiss_path.with_suffix(".pkl").exists(): 387 ↛ 395line 387 didn't jump to line 395 because the condition on line 387 was always true

388 logger.warning( 

389 f"Old-format index {faiss_path} still has its legacy " 

390 ".pkl and no sidecar — phase-1 has not converted it yet; " 

391 "will retry next login (NOT quarantining)." 

392 ) 

393 all_resolved = False 

394 continue 

395 logger.warning( 

396 f"Un-migratable old-format index {faiss_path} has no " 

397 "sidecar (phase-1 reindex-fallback); quarantining so its " 

398 "collection rebuilds." 

399 ) 

400 try: 

401 _quarantine_and_reset( 

402 username, 

403 db_password, 

404 rag_index, 

405 faiss_path, 

406 sidecar_path, 

407 lock, 

408 ) 

409 stats["quarantined"] += 1 

410 except Exception as exc: 

411 # db_password is a live parameter of this function; a 

412 # rendered traceback (loguru diagnose=True) would dump it. 

413 # Drop the traceback and redact str(exc). See encrypted_db. 

414 safe_msg = redact_secrets(str(exc), db_password) 

415 logger.warning( 

416 f"Failed to quarantine {faiss_path}; will retry next " 

417 f"login: {safe_msg}" 

418 ) 

419 all_resolved = False 

420 continue 

421 # Already migrated (IndexIDMap2, no sidecar). Backstop: purge any 

422 # legacy plaintext .pkl that survived phase-1 here too — this is the 

423 # retry landing spot after a re-key whose Phase-B .pkl unlink failed 

424 # (sidecar already gone), so without it the marker would be set with 

425 # plaintext still on disk. 

426 if not _purge_redundant_pkl(faiss_path, db_password): 426 ↛ 427line 426 didn't jump to line 427 because the condition on line 426 was never true

427 all_resolved = False 

428 stats["skipped_no_sidecar"] += 1 

429 continue 

430 if not faiss_path.exists(): 430 ↛ 441line 430 didn't jump to line 441 because the condition on line 430 was never true

431 # Orphaned sidecar (index file itself is already gone) -- there 

432 # is nothing to re-key. Purge any legacy plaintext .pkl that 

433 # survived next to the now-gone .faiss (a phase-1 .pkl-unlink 

434 # failure, or a partial _quarantine_and_reset that renamed the 

435 # .faiss aside before reaching its own pkl.unlink, can leave one 

436 # here) BEFORE dropping the sidecar. Every other finalization branch 

437 # backstops the plaintext .pkl this way; without it this branch is 

438 # the one path that can set the per-user completion marker while an 

439 # encryption-defeating plaintext .pkl still sits on disk. Withhold 

440 # the marker if EITHER cleanup fails so phase-2 retries next login. 

441 stats["skipped_no_faiss"] += 1 

442 if not _purge_redundant_pkl(faiss_path, db_password): 

443 all_resolved = False 

444 try: 

445 sidecar_path.unlink(missing_ok=True) 

446 except OSError: 

447 logger.warning( 

448 f"Could not remove orphaned sidecar {sidecar_path}; " 

449 "will retry next login" 

450 ) 

451 all_resolved = False 

452 continue 

453 

454 # Resolve + PIN the index config onto the row BEFORE building the file, 

455 # so the row records what the file is built with even across a 

456 # crash/retry. Pinning AFTER a successful build would break the retry 

457 # path: if settings change between a crashed attempt and the retry, the 

458 # retry re-resolves DIFFERENT params, hits rekey_index_file's 

459 # already-migrated short-circuit (which only checks dim + ids), and pins 

460 # a config that disagrees with the physical file. Pinning FIRST makes the 

461 # row the source of truth that _resolve_index_params reads on retry (row 

462 # wins over settings), so the resolved params match the file at every 

463 # crash point. A transient failure here must NOT quarantine a healthy 

464 # legacy file — withhold the marker and retry next login. 

465 try: 

466 params = _resolve_index_params(rag_index, settings) 

467 # SQLAlchemy's legacy Column() declarative style types instance 

468 # attribute access as Column[int] rather than int; cast() is a 

469 # no-op at runtime. 

470 _persist_rekeyed_config( 

471 username, db_password, cast(int, rag_index.id), params 

472 ) 

473 except Exception as exc: 

474 safe_msg = redact_secrets(str(exc), db_password) 

475 logger.warning( 

476 f"Failed to pin index config for RAG index {rag_index.id} " 

477 f"({faiss_path}); will retry next login: {safe_msg}" 

478 ) 

479 all_resolved = False 

480 continue 

481 

482 # Resolve the sidecar map + uuid->DocumentChunk.id (a DB read). A failure 

483 # HERE is transient and unrelated to the index file's health — a 

484 # 'database is locked' timeout under concurrent writes, or a momentary 

485 # OSError reading the sidecar. It must NOT quarantine a perfectly healthy 

486 # legacy index (which would force a needless full re-embed); withhold the 

487 # marker and retry next login instead. 

488 try: 

489 pos_to_uuid = json.loads(sidecar_path.read_text(encoding="utf-8")) 

490 uuid_to_id = _load_uuid_to_id( 

491 username, db_password, set(pos_to_uuid.values()) 

492 ) 

493 except Exception as exc: 

494 safe_msg = redact_secrets(str(exc), db_password) 

495 logger.warning( 

496 f"Could not resolve sidecar/DB ids for RAG index " 

497 f"{rag_index.id} ({faiss_path}); will retry next login " 

498 f"(NOT quarantining — the index file is untouched): {safe_msg}" 

499 ) 

500 all_resolved = False 

501 continue 

502 

503 # Phase A -- re-key. A failure HERE is a genuinely un-rekeyable index 

504 # (corrupt/foreign format, dimension mismatch, duplicate-id sidecar): 

505 # quarantine + reset so the collection rebuilds from the DB. 

506 try: 

507 with lock: 

508 rekey_index_file(faiss_path, sidecar_path, uuid_to_id, **params) 

509 except Exception as exc: 

510 safe_msg = redact_secrets(str(exc), db_password) 

511 logger.warning( 

512 f"Re-key failed for RAG index {rag_index.id} " 

513 f"({faiss_path}); quarantining -- collection will reindex: " 

514 f"{safe_msg}" 

515 ) 

516 try: 

517 _quarantine_and_reset( 

518 username, 

519 db_password, 

520 rag_index, 

521 faiss_path, 

522 sidecar_path, 

523 lock, 

524 ) 

525 stats["quarantined"] += 1 

526 except Exception as exc: 

527 safe_msg = redact_secrets(str(exc), db_password) 

528 logger.warning( 

529 f"Failed to quarantine {faiss_path} after re-key " 

530 f"failure -- leaving files as-is; will retry next login: " 

531 f"{safe_msg}" 

532 ) 

533 all_resolved = False 

534 continue 

535 

536 # Phase B -- the re-key SUCCEEDED (byte-verified new IndexIDMap2). 

537 # Record the new file's integrity, THEN delete the sidecar (the 

538 # completion signal). A failure here must NOT quarantine a perfectly 

539 # good index (the bug this split fixes): leave the sidecar so the next 

540 # login retries -- rekey_index_file's IndexIDMap2 guard makes that a 

541 # clean no-op re-key -- and withhold the completion marker. 

542 try: 

543 with lock: 

544 if integrity_manager is None: 

545 integrity_manager = FileIntegrityManager( 

546 username, db_password 

547 ) 

548 integrity_manager.register_verifier(FAISSIndexVerifier()) 

549 integrity_manager.record_file( 

550 faiss_path, 

551 related_entity_type="rag_index", 

552 # cast(): rag_index.id is Column[int] under the legacy 

553 # Column() declarative style; a no-op at runtime. 

554 related_entity_id=cast(int, rag_index.id), 

555 ) 

556 # Config was already pinned before the build (see above), so 

557 # Phase B only records integrity and drops the sidecar signal. 

558 sidecar_path.unlink(missing_ok=True) 

559 # Backstop: if phase-1 wrote the sidecar but its .pkl unlink 

560 # failed, the plaintext .pkl is still on disk. The re-key just 

561 # succeeded from the sidecar and the text is in the DB, so purge 

562 # the now-redundant .pkl; withhold the marker if it can't be 

563 # removed so this index is retried rather than gated out with 

564 # plaintext surviving. 

565 if not _purge_redundant_pkl(faiss_path, db_password): 565 ↛ 566line 565 didn't jump to line 566 because the condition on line 565 was never true

566 all_resolved = False 

567 stats["rekeyed"] += 1 

568 except Exception as exc: 

569 safe_msg = redact_secrets(str(exc), db_password) 

570 logger.warning( 

571 f"Re-key of {faiss_path} succeeded but finalization " 

572 "(integrity record / sidecar removal) failed; leaving sidecar " 

573 f"to retry next login: {safe_msg}" 

574 ) 

575 all_resolved = False 

576 

577 if all_resolved: 

578 settings.set_setting(REKEY_MARKER_SETTING, True) 

579 logger.info( 

580 f"RAG index re-key for user {username}: " 

581 f"checked={stats['checked']} rekeyed={stats['rekeyed']} " 

582 f"quarantined={stats['quarantined']} " 

583 f"skipped_no_sidecar={stats['skipped_no_sidecar']} " 

584 f"skipped_no_faiss={stats['skipped_no_faiss']}" 

585 + ("" if all_resolved else " (incomplete -- will retry next login)") 

586 ) 

587 return stats