ADR 0020 – Réindexation des corps existants : réconciliation au démarrage, événements avec `reason: "reindex"`
Accepté (2026-10-15, mode autonome)
ADR 0020 – Réindexation des corps existants : réconciliation au démarrage, événements avec reason: "reindex"
Statut : Accepté (2026-10-15, mode autonome)
Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles) : réconciliation Java au démarrage, avant l’ouverture du port, sur le modèle d’AspectTextReconciler (ADR 0013), sans endpoint d’administration ; clés de titre recalculées d’abord ; corbeille comprise ; présélection SQL des nœuds concernés ; aucune écriture si l’index est juste ; updated_at inchangé ; link.added, link.removed et tags.updated avec "reason": "reindex", aucun node.updated ; aucune réécriture de corps ; corps au-delà des plafonds ignoré pour l’index concerné ; ni migration, ni changement d’API ou de front.
Contexte
Les ADR 0009 et 0011 indexent les [[…]] (lignes node_link de type wiki, table node_wiki_unresolved) et les #tags (lignes node_tag d’origine body) dans la transaction de chaque écriture du corps. Les corps enregistrés avant ces tranches ne sont pas indexés : pas de rétroliens, pas de tags du corps, et le renommage d’une cible ne réécrit pas ces corps, puisque propagateRename ne parcourt que les sources déjà liées. Il faut rattraper cela avant le déploiement.
Autres constats :
node.title_keya été rempli en SQL par la migration V4 aveclower(), qui peut différer deWikiLinks.key(sigma final, certains caractères non latins). La résolution d’un lien dépend de cette colonne.WikiLinkService.reindexetBodyTagIndex.reindexn’écrivent que les différences. Une exception :WikiLinkRepository.replaceUnresolvedsupprime puis fait un upsert à chaque appel, même quand rien n’a changé.- Au jalon 1, aucun traitement planifié n’écrit en base dans l’application. Avant l’ouverture du port HTTP, il n’y a donc aucun autre écrivain.
Décision
Quand : au démarrage, à chaque démarrage, idempotent
/** Réindexe liens wiki et #tags de tous les corps au démarrage, avant l'ouverture du port HTTP (ADR 0020). */
@Component
class BodyIndexReconciler implements SmartInitializingSingleton {
static final int PAGE_SIZE = 100; // corps jusqu'à 200 000 caractères : pages plus petites qu'ADR 0013
static final String REASON = "reindex";
BodyIndexReconciler(NodeRepository nodes, WikiLinkService wiki, BodyTagIndex bodyTags,
PlatformTransactionManager transactionManager);
@Override public void afterSingletonsInstantiated(); // run(PAGE_SIZE), puis une ligne de journal
/** @param titleKeys clés de titre corrigées ; @param reindexed nœuds dont un index a changé ;
* @param seen nœuds examinés ; @param skipped nœuds ignorés (plafond, erreur d'analyse) */
record Result(int titleKeys, int reindexed, int seen, int skipped) { }
Result run(int pageSize);
}
- C’est le même mécanisme que l’ADR 0013 : il est automatique au premier démarrage de la version déployée, puis ne fait rien (lecture seule) aux démarrages suivants. Il corrige aussi l’index après la restauration d’une ancienne sauvegarde, une correction SQL manuelle ou une évolution de la grammaire dans une version future.
- Il ne dépend pas de
config/: aucune garde sur les définitions en erreur. - Ordre par rapport à
AspectTextReconciler: indifférent (colonnes et tables distinctes). - Une exception SQL se propage et fait échouer le démarrage, comme pour l’ADR 0013. Une erreur d’analyse d’un corps est journalisée avec l’identifiant seul, et le nœud est ignoré.
- Journal :
Index du contenu (liens wiki, #tags) : {} clé(s) de titre corrigée(s), {} nœud(s) réindexé(s) sur {} examiné(s), {} ignoré(s), en {} ms.
Phase 1 : clés de titre
Les nœuds sont parcourus par pages (pagination par id, corbeille comprise, une transaction par page). Quand WikiLinks.key(title) diffère de title_key, on exécute UPDATE node SET title_key = ?. Il n’y a ni événement ni updated_at : c’est un index dérivé, comme aspect_text. Cette phase précède la phase 2, puisque la résolution lit title_key.
Phase 2 : liens wiki et #tags du corps
-
Présélection SQL (pagination par
id, corbeille comprise) :strpos(body, '[[') > 0 OR strpos(body, '#') > 0, ou bien le nœud a déjà une lignenode_linkde typewiki,node_tagd’originebodyounode_wiki_unresolved. Un corps sans[[ni#produit des ensembles vides (WikiLinks.extractetBodyTags.extracts’arrêtent tout de suite) : seuls les index périmés sont à vider, et ils sont couverts par lesEXISTS. -
Une transaction par page. On appelle
wiki.lock(), puis on lit la page avecFOR NO KEY UPDATE(ordre parid). L’ordre des verrous est donc celui de l’ADR 0009 (index wiki, puis lignes). C’est sans effet avant l’ouverture du port, mais le composant reste sûr si un endpoint l’appelle un jour. -
Pour chaque nœud :
WikiLinks.keys(body), puiswiki.reindex(id, keys, REASON), sauf si le corps dépasseWikiLinks.MAX_TARGETS;BodyTags.tags(body), puisbodyTags.reindex(id, tags, REASON), sauf si le corps dépasseBodyTags.MAX_DISTINCT.
Un nœud au-delà d’un plafond est compté dans
skippedet journalisé (identifiant seul). Son index reste tel quel : l’ADR 0009 et l’ADR 0011 garantissent qu’un index ne dépasse jamais les plafonds. -
Les règles des ADR 0009 et 0011 s’appliquent telles quelles :
- une cible résolue vers le nœud vivant le plus ancien ;
- une cible en corbeille qui bascule vers un nœud vivant de même clé ;
- un lien vers soi non indexé ;
- les clés non résolues dans
node_wiki_unresolved.
Le résultat ne dépend pas de l’ordre des sources : la résolution ne lit que les titres.
resolvePendingn’est donc pas appelé. -
Aucune réécriture de corps. Un
[[Ancien]]dont la cible a été renommée avant cette tranche reste du texte et passe en clé non résolue. Le réconciliateur ne devine pas la cible. -
Ce qui n’est pas touché : les liens typés, les tags
manual, les colonnesnode.body,node.updated_atetnode.search.
Événements : ceux des chemins d’écriture, avec une raison
- Chaque ligne
node_linkajoutée ou retirée produitlink.addedoulink.removed, avec le payload habituel (linkId,type: "wiki",targetId,targetTitle) plus"reason": "reindex". - Un changement des tags du corps produit un seul
tags.updated{origin: "body", before, after, reason: "reindex"}. - Il n’y a ni
node.updatednitouch: le contenu ne change pas. C’est le même choix que le rattrapage deresolvePending(ADR 0009), qui écritlink.addedsans changerupdated_at. - Les lignes
node_linketnode_tagsont des données visibles (rétroliens, filtres, compteurs) et des déclencheurs des règles du jalon 3, contrairement àaspect_textoutitle_key. Elles suivent donc la règle « toute modification écrit unnode_event».reasonpermet à l’historique et aux règles de distinguer un rattrapage d’une saisie, commewiki.renamesurnode.updated. - Les écritures normales (
create,update,propagateRename) gardent exactement leurs payloads, sans cléreason.
Changements de code (une seule tranche, backend seul)
WikiLinkService:boolean reindex(UUID sourceId, Map<String, String> wanted, String reason). Il renvoie vrai si une lignenode_linkounode_wiki_unresolveda changé, etreasonest ajouté aux payloads s’il n’est pas nul. Les deux surcharges existantes délèguent avecnull.BodyTagIndex:boolean reindex(UUID nodeId, List<String> wanted, String reason), même principe. La surcharge existante délègue avecnull.WikiLinkRepository:Map<String, String> unresolved(UUID sourceId).replaceUnresolvedne fait rien si l’ensemble (clés et textes) est identique : le second passage n’écrit aucune ligne, et le cheminupdateen profite aussi.NodeRepository:List<TitleKeyRow> titleKeyPage(UUID after, int size)(record TitleKeyRow(UUID id, String title, String titleKey)) etvoid updateTitleKey(UUID id, String key)(sansupdated_at) ;List<BodyRow> bodyIndexPage(UUID after, int size)(record BodyRow(UUID id, String body), présélection etFOR NO KEY UPDATE).
- Ni migration, ni YAML, ni méta-schéma, ni endpoint, ni changement du client généré ou du front. L’historique affiche ces événements comme les autres.
Tests
BodyIndexReconcilerIT (Testcontainers) :
- Rattrapage : des nœuds sont insérés en SQL (corps avec
[[B]],[[Absent]],[[Soi-même]],#projet/x,`#code`), sans index. Aprèsrun:- lien
wikiA→B et cléabsentnon résolue ; - aucun lien vers soi ;
- tag
bodyprojet/xseul ; link.addedettags.updatedavecreason: "reindex";- aucun
node.updated; updated_atinchangé.
- lien
- Idempotence : un second
runrenvoiereindexed == 0. Le nombre denode_eventest inchangé, et lexmindes lignesnode_link,node_tagetnode_wiki_unresolvedaussi : aucune écriture. - Index périmé : une ligne
wikiet une lignebodysont présentes alors que le corps n’a ni[[ni#. Elles sont retirées (link.removed,tags.updatedavecreason), ce qui valide la présélection. - Corbeille : une source en corbeille est indexée. Une cible en corbeille qui a un doublon vivant bascule vers lui (
link.removedpuislink.added). - Ce qui n’est pas touché : tags
manualet liens typés. - Plafond : un corps de 501 cibles est ignoré (
skipped), sans bloquer les autres nœuds. - Clé de titre : un
title_keyfaux, écrit en SQL, est corrigé sans événement, et la source qui vise ce titre est liée dans le même passage. - Pagination :
run(2)sur 5 nœuds. - Renommage après réconciliation : un corps inséré en SQL est réindexé, puis le renommage de la cible par
NodeService.updateréécrit ce corps. - Non-régression : les payloads de
create,updateetpropagateRenamen’ont pas de cléreason. Unupdatequi ne change pas les clés non résolues laisse leurxminintact.WikiLinkIT,WikiRenameITetBodyTagITrestent verts.
Alternatives écartées
- Endpoint d’administration (seul ou en plus) : il faudrait des verrous en concurrence avec les écritures, de la sécurité et une page ou un appel manuel. Il faudrait aussi y penser au déploiement. Au jalon 1, rien ne change la grammaire sans redémarrage. Le composant prend déjà les verrous de l’ADR 0009 : l’exposer plus tard est additif.
- Commande unique avec marqueur en base (« déjà fait ») : un état de plus à gérer, et elle ne corrige ni une restauration ancienne ni une évolution de la grammaire.
- Aucun événement (index dérivé, comme
aspect_text) : les liens wiki et les tags du corps sont des données lues par les rétroliens, les filtres et les futures règles. Leur apparition sans trace contredirait la règle du projet et l’ADR 0011 (qui écarte « pas d’événement pour les tags du corps »). - Événements sans
reason: une règle du jalon 3 « lien créé » se déclencherait en masse au premier démarrage, sans moyen de filtrer. - Passer par
NodeService.update: cela produirait desnode.updatedvides et changeraitupdated_atde tous les nœuds, ce qui fausserait les tris par date. - Réécrire les
[[Ancien]]orphelins : la cible ne peut pas être devinée de façon sûre. - Indexer au-delà des plafonds : les autres chemins d’écriture ne produisent jamais un tel index, et le prochain enregistrement du nœud serait refusé de toute façon.
- Exclure la corbeille : une restauration (jalon 3) retrouverait un nœud sans index.
AspectTextReconcileretpropagateRenameincluent déjà la corbeille.
Conséquences
- Le premier démarrage de la version déployée écrit un
link.addedpar lien et untags.updatedpar nœud tagué dans l’historique des anciens nœuds, marquésreindex. Ensuite, chaque démarrage relit les nœuds présélectionnés : de l’ordre de quelques requêtes par nœud qui contient[[ou#, soit quelques secondes pour quelques milliers de nœuds. - Les
[[…]]dont la cible a été renommée avant cette tranche restent non résolus. L’utilisateur les corrige à la main, et la création d’un nœud de ce titre les rattrape. - Une évolution future de la grammaire (transclusions V2, par exemple) sera appliquée aux corps existants au redémarrage, avec des événements
reindex. - Les lignes de l’ADR 0009 (« Outil de réindexation complète ») et de l’ADR 0011 (« Réindexation des corps existants ») sont soldées pour le jalon 1.
Reporté
- Endpoint ou commande d’administration (import Obsidian V2, ou si le démarrage devient trop long).
- Empreinte de version d’index par nœud, pour ne plus relire les corps à chaque démarrage.
- Libellé « (réindexation) » dans l’écran d’historique.