Second Brain documentation

ADR 0020 – Réindexation des corps existants : réconciliation au démarrage, événements avec `reason: "reindex"`

Accepté (2026-10-15, mode autonome)

← Toutes les décisions

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_key a été rempli en SQL par la migration V4 avec lower(), qui peut différer de WikiLinks.key (sigma final, certains caractères non latins). La résolution d’un lien dépend de cette colonne.
  • WikiLinkService.reindex et BodyTagIndex.reindex n’écrivent que les différences. Une exception : WikiLinkRepository.replaceUnresolved supprime 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 ligne node_link de type wiki, node_tag d’origine body ou node_wiki_unresolved. Un corps sans [[ ni # produit des ensembles vides (WikiLinks.extract et BodyTags.extract s’arrêtent tout de suite) : seuls les index périmés sont à vider, et ils sont couverts par les EXISTS.

  • Une transaction par page. On appelle wiki.lock(), puis on lit la page avec FOR NO KEY UPDATE (ordre par id). 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), puis wiki.reindex(id, keys, REASON), sauf si le corps dépasse WikiLinks.MAX_TARGETS ;
    • BodyTags.tags(body), puis bodyTags.reindex(id, tags, REASON), sauf si le corps dépasse BodyTags.MAX_DISTINCT.

    Un nœud au-delà d’un plafond est compté dans skipped et 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. resolvePending n’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 colonnes node.body, node.updated_at et node.search.

Événements : ceux des chemins d’écriture, avec une raison

  • Chaque ligne node_link ajoutée ou retirée produit link.added ou link.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.updated ni touch : le contenu ne change pas. C’est le même choix que le rattrapage de resolvePending (ADR 0009), qui écrit link.added sans changer updated_at.
  • Les lignes node_link et node_tag sont des données visibles (rétroliens, filtres, compteurs) et des déclencheurs des règles du jalon 3, contrairement à aspect_text ou title_key. Elles suivent donc la règle « toute modification écrit un node_event ». reason permet à l’historique et aux règles de distinguer un rattrapage d’une saisie, comme wiki.rename sur node.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 ligne node_link ou node_wiki_unresolved a changé, et reason est ajouté aux payloads s’il n’est pas nul. Les deux surcharges existantes délèguent avec null.
  • BodyTagIndex : boolean reindex(UUID nodeId, List<String> wanted, String reason), même principe. La surcharge existante délègue avec null.
  • WikiLinkRepository : Map<String, String> unresolved(UUID sourceId). replaceUnresolved ne fait rien si l’ensemble (clés et textes) est identique : le second passage n’écrit aucune ligne, et le chemin update en profite aussi.
  • NodeRepository :
    • List<TitleKeyRow> titleKeyPage(UUID after, int size) (record TitleKeyRow(UUID id, String title, String titleKey)) et void updateTitleKey(UUID id, String key) (sans updated_at) ;
    • List<BodyRow> bodyIndexPage(UUID after, int size) (record BodyRow(UUID id, String body), présélection et FOR 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ès run :
    • lien wiki A→B et clé absent non résolue ;
    • aucun lien vers soi ;
    • tag body projet/x seul ;
    • link.added et tags.updated avec reason: "reindex" ;
    • aucun node.updated ;
    • updated_at inchangé.
  • Idempotence : un second run renvoie reindexed == 0. Le nombre de node_event est inchangé, et le xmin des lignes node_link, node_tag et node_wiki_unresolved aussi : aucune écriture.
  • Index périmé : une ligne wiki et une ligne body sont présentes alors que le corps n’a ni [[ ni #. Elles sont retirées (link.removed, tags.updated avec reason), 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.removed puis link.added).
  • Ce qui n’est pas touché : tags manual et liens typés.
  • Plafond : un corps de 501 cibles est ignoré (skipped), sans bloquer les autres nœuds.
  • Clé de titre : un title_key faux, é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.update réécrit ce corps.
  • Non-régression : les payloads de create, update et propagateRename n’ont pas de clé reason. Un update qui ne change pas les clés non résolues laisse leur xmin intact. WikiLinkIT, WikiRenameIT et BodyTagIT restent 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 des node.updated vides et changerait updated_at de 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. AspectTextReconciler et propagateRename incluent déjà la corbeille.

Conséquences

  • Le premier démarrage de la version déployée écrit un link.added par lien et un tags.updated par nœud tagué dans l’historique des anciens nœuds, marqués reindex. 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.