feat: garde-fous d'écriture des classeurs Excel #153 (P0)
L'édition d'un .xlsx pouvait détruire une partie du classeur, le concurrencer en silence, ou diffuser une injection de formule. - BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd (valeurs calculées en cache, slicers, contrôles, connexions, custom XML, signature, commentaires enrichis, macros) → xlsx_lossy_features exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans `force` (confirmation explicite puis reprise). Périmètre réel revalidé : graphiques, images et TCD survivent au round-trip. - BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage ne peut plus tronquer le classeur, le backup reste intact. - BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s, 409 conflict) ; endpoint xlsx/save devenu synchrone pour que l'attente s'exécute dans le threadpool. - BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in `allow_formula` ou le bouton f(x) de la visionneuse. Le handler ServiceError expose désormais code + details, que api() propage. - BUG-084 : la suppression d'une vault purge enfin l'index inversé (documents fantômes qui continuaient de matcher) et is_stale() devient is_ready(), le nom étant trompeur (la staleness n'existe plus). Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI), 3 E2E Playwright, suite E2E complète verte, ruff/mypy 0. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
# Plan: Incremental InvertedIndex for 40k+ files
|
||||
# Incremental InvertedIndex for 40k+ files — livré
|
||||
|
||||
> **Statut : LIVRÉ (BUG-033, v2.3.0).** Ce fichier a servi de plan
|
||||
> d'exécution ; il est conservé comme **trace de conception**. Le code réel
|
||||
> a divergé sur plusieurs points (voir [État réel](#état-réel-corrigé-au-2026-09-27))
|
||||
> — ne pas lire les extraits de code ci-dessous comme du code actuel.
|
||||
|
||||
## Problem Summary
|
||||
|
||||
@@ -17,6 +22,11 @@ Then hook these into `_add_file_to_structures` and `_remove_file_from_structures
|
||||
|
||||
Remove the `is_stale()` / `rebuild()` / cooldown mechanism entirely. The inverted index is always current.
|
||||
|
||||
> ⚠️ **Nuance retenue à l'implémentation** : un unique `rebuild()` reste nécessaire au
|
||||
> démarrage (le hook est inerte tant que l'index n'est pas prêt) et au reindex manuel
|
||||
> d'une vault. Ce qui disparaît, c'est la *staleness* : plus de compteur de génération,
|
||||
> plus de cooldown, plus de rebuild paresseux.
|
||||
|
||||
## Dependency Architecture
|
||||
|
||||
**Current import chain:**
|
||||
@@ -346,3 +356,61 @@ This hack was only needed to reduce the number of inverted index rebuilds. With
|
||||
4. **Sorted tokens performance:** `bisect.insort` and `list.pop(idx)` are O(V) worst case for large V. For 40k files, the vocabulary size V is typically 50k-200k tokens. O(V) for a single insertion is ~0.001ms, acceptable. The rebuild() call at startup handles the initial bulk.
|
||||
|
||||
5. **tag_norm_map / tag_prefix_index growth:** These grow monotonically (never shrink on incremental remove). With 40k files and thousands of tags, this is a few thousand entries — negligible. A manual "Réindexer" button triggers a full `rebuild()` to clean up.
|
||||
|
||||
---
|
||||
|
||||
## État réel (corrigé le 2026-09-27)
|
||||
|
||||
Le plan ci-dessus a servi de brouillon : **le code livré en est différent sur
|
||||
quatre points**. Relevé fait sur `backend/search.py`, `backend/indexer.py` et
|
||||
`backend/main.py`, pas de mémoire.
|
||||
|
||||
| Point prévu | État réel |
|
||||
|---|---|
|
||||
| Étapes 1-2 : hook + `add_document()` / `remove_document()` | ✅ livré tel que prévu |
|
||||
| Étapes 4-5 : `rebuild()` initial via `init_inverted_index()` appelé depuis la lifespan | ✅ livré (`backend/main.py:297`, dans l'exécuteur de recherche) |
|
||||
| Étape 6 : retirer `is_stale()` + `_last_rebuild` / `_rebuild_cooldown` / `_source_generation` | ✅ **déjà fait** avant cette relecture — aucun de ces symboles ne subsiste |
|
||||
| Étape 7 : retirer le hack de coalescence `_index_generation` dans `_on_vault_change` | ✅ **déjà fait** — `_on_vault_change` n'existe plus |
|
||||
| `get_inverted_index()` simplifié | ✅ mais **sans le fallback `_needs_rebuild`** prévu par le plan |
|
||||
|
||||
### Écarts assumés
|
||||
|
||||
1. **`is_stale()` a survécu sous un autre nom.** L'étape 6 est faite, mais la
|
||||
méthode a été conservée car elle répond à une autre question : *l'index
|
||||
initial est-il construit ?* Elle ne mesure plus aucune staleness (le compteur
|
||||
de génération et le cooldown ont disparu) et le nom était trompeur. Elle est
|
||||
donc renommée `is_ready()` — cohérent avec le `is_ready()` déjà exposé par
|
||||
`SemanticIndex` (`backend/semantic_search.py`). L'alias `is_stale()` de
|
||||
`SemanticIndex`, sans aucun appelant, est supprimé.
|
||||
|
||||
Impact : le champ de `/api/diagnostics` passe de `is_stale` à `is_ready`
|
||||
(libellé « Index prêt » côté `frontend/js/config.js`).
|
||||
|
||||
2. **Pas de repli `_needs_rebuild`.** Le plan prévoyait qu'un échec
|
||||
d'incrémentation marque l'index pour reconstruction. L'implémentation
|
||||
retenue se contente de logger un warning et de continuer à servir l'index.
|
||||
Choix assumé : un échec d'incrémentation est exceptionnel, et reconstruire
|
||||
silencieusement serait plus coûteux que l'état dégradé. **Si ce compromis
|
||||
devient critiquique, c'est le point à rouvrir.**
|
||||
|
||||
3. **`_ready` remplace `doc_count == 0`.** Le plan prévoyait de sauter le hook
|
||||
« index vide » ; le drapeau explicite `_ready` est plus sûr (un vault
|
||||
réellement vide serait sinon pris pour un index non construit).
|
||||
|
||||
4. **`rebuild()` reste nécessaire** au démarrage et au reindex manuel d'une
|
||||
vault. Le plan parlait de le supprimer de `get_inverted_index()`, ce qui est
|
||||
fait, mais la méthode elle-même est conservée.
|
||||
|
||||
### Bug trouvé pendant cette relecture (corrigé ici)
|
||||
|
||||
`remove_vault_from_index()` (`backend/indexer.py`) ne notifiait pas le hook.
|
||||
Conséquence mesurée : après suppression d'une vault, ses 8 documents test
|
||||
restaient dans l'index inversé — `postings`, `doc_info`, `doc_vault`,
|
||||
`vault_docs` — et continuaient de correspondre aux recherches pour une vault
|
||||
inexistante. Seul un reindex manuel les effaçait.
|
||||
|
||||
Le correctif déclenche `_on_index_change('remove', …)` pour chaque fichier de
|
||||
la vault, et `_remove_doc_internals()` supprime désormais la clé `vault_docs`
|
||||
quand son set devient vide (c'est un `defaultdict` : une simple lecture la
|
||||
ré créait). Test de non-régression :
|
||||
`TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le patch).
|
||||
|
||||
Reference in New Issue
Block a user