Skip to content

Fix : Synchronisation de l'index des sources externes : bandeau figé + cycle intégral à chaque passe - #1

Merged
repod-ce merged 3 commits into
repod-ce:mainfrom
TiotBenjy:fix/ext-id-sync
Aug 10, 2026
Merged

repod-ce merged 3 commits into
repod-ce:mainfrom
TiotBenjy:fix/ext-id-sync

Conversation

@TiotBenjy

Copy link
Copy Markdown
Contributor

Trois problèmes distincts, en 3 commits séparés et relisibles indépendamment. Le premier est un bug d'affichage ; les deux autres sont des optimisations de la synchronisation elle-même, mises au jour en investiguant le premier.

1. fix(ui) : bandeau de fraîcheur figé sur « jamais synchronisé »

Une source désactivée dans les paramètres est exclue de tous les jobs de sync, et n'obtient donc jamais de last_sync. Or le front calculait la fraîcheur par un Math.max() sur toutes les sources, où hoursSinceSync(null) vaut Infinity. Une seule source désactivée suffisait à afficher l'avertissement en permanence. Constaté avec des sources synchronisées avec succès 20 minutes plus tôt, sans qu'aucune action utilisateur puisse l'éteindre.

GET /import/sync-status ne renvoyant pas l'état d'activation, le front ne pouvait pas distinguer "désactivée" de "réellement en retard". Le champ enabled est désormais exposé, issu de la même fonction que le filtre de sync, et le calcul est extrait dans computeIndexStaleness().

2. perf(sync) : sauter les sources dont l'index amont n'a pas changé

Chaque cycle re-téléchargeait et re-parsait les 120 sources intégralement, y compris celles inchangées depuis des semaines, soit ~18 min par cycle complet.

Le hash de l'index publié en amont est désormais mémorisé après chaque ingestion réussie (table source_index_state, migration 0011) et comparé au cycle suivant. Empreinte égale signifie bit pour bit le même index qu'en base : on ne se fie ni à un horodatage ni à un ETag serveur.

Côté APT, InRelease est téléchargée et authentifiée avant Packages.gz, de sorte que le hash attendu est connu avant de lancer un téléchargement important.

Deux invariants rendent le saut sûr. L'empreinte est effacée avant toute réécriture et réécrite seulement après succès complet, de sorte qu'une ingestion interrompue ne laisse jamais une empreinte prétendant que la base est complète (critique côté RPM, où le DELETE est commité dès le premier batch). Et un saut n'a lieu que si la source a encore des lignes en base, ce qui protège d'une purge ou d'une restauration partielle. Une source sautée voit tout de même son last_sync rafraîchi.

Un paramètre force (défaut false) permet de réindexer inconditionnellement. Le bouton de resync d'une source précise l'utilise ; la sync globale reste incrémentale.

Chaîne de confiance

Elle est inchangée, et l'authentification a lieu à chaque cycle, y compris quand la source est sautée :

Format Authentification de l'index Empreinte comparée
APT signature GPG d'InRelease, vérifiée à chaque passe SHA256 de Packages déclaré par cet InRelease authentifié
RPM signature de repomd.xml via repomd.xml.asc, quand le dépôt en publie une SHA-256 de primary.xml déclaré par repomd.xml
APK signature RSA de l'archive, vérifiée contre le trousseau Alpine local SHA-256 de l'archive, Alpine ne publiant pas de manifeste de hash

Un saut n'intervient que si l'empreinte issue d'une source authentifiée est identique à celle d'un contenu déjà vérifié et ingéré. Lors d'une ingestion réelle, le hash attendu reste confronté aux octets reçus ; un test dédié vérifie qu'un Packages.gz falsifié est rejeté et jamais parsé.

Un seul écart mérite d'être signalé : sur un saut APK, la signature RSA n'est pas revérifiée, puisque rien n'est ingéré et que les octets sont par construction ceux dont la signature avait déjà été validée.

3. perf(sync) : lever la sérialisation des sources RPM (1 vers 4)

_CONCURRENCY["rpm"] valait 1, par crainte de la taille de primary.xml (jusqu'à 600 Mo décompressés). _stream_download_and_parse() parse pourtant en flux et vide l'arbre à chaque paquet : l'empreinte mémoire est bornée par le batch de 500 lignes, pas par la taille du fichier.

Avec 58 sources RPM en série et un débit mesuré à ~1 Mo/s côté miroirs, ce sémaphore constituait à lui seul le chemin critique d'un cycle, soit ~17 min sur ~18, tandis qu'APT et APK finissaient en 4 min en parallèle. Le profilage d'une source donne ~15 s de téléchargement, ~10 s de parsing, ~4 s d'insertion : la phase dominante étant réseau, le parallélisme recouvre directement les attentes.
La valeur retenue est 4 et non davantage, le parsing XML restant borné par le GIL.

Mesures

Synchronisation de fedora42-aarch64 (67 343 paquets), index amont inchangé entre les deux passes : 30,8 s puis 0,8 s.

Bandeau, rejoué sur données réelles : 120 sources, 1 désactivée, 119 suivies, aucune sans last_sync. Bandeau masqué.

Tests

Suite complète backend : 1480 passed, 3 skipped. Frontend : 50/50.

Ajouts : test_index_state_skip.py (16 cas sur les invariants du saut) et indexStaleness.test.js (6 cas sur le calcul du bandeau). Les mocks de test_package_index_apt_sync.py sont adaptés au découpage de la vérification APT, avec un cas supplémentaire sur le rejet d'un Packages.gz falsifié.

Les suites ont été exécutées sur l'état cumulé des trois commits, pas sur chacun isolément.

Migration et compatibilité

0011_source_index_state crée une table, sans toucher aux données existantes. Validée sur PostgreSQL 16 en upgrade, downgrade et re-upgrade. Appliquée automatiquement par entrypoint.sh au démarrage.

Les ajouts d'API sont additifs (champ enabled dans /import/sync-status, paramètre force optionnel) et les appelants existants sont inchangés.

source_index_state étant vide au départ, le premier cycle après mise à jour reste une synchronisation intégrale ; le gain intervient à partir du second.


J'ai lu et j'accepte le CLA de repod v1.0 (CLA.md)

…ais synchronisé »

Une source désactivée dans les paramètres est exclue de tous les jobs de sync
  (sync_manager.start_job => is_source_enabled) : elle n'est ni synchronisée, ni
  mise en erreur, et n'obtient donc jamais de ligne dans sync_status.

Or le front calculait la "fraîcheur" de l'index par un Math.max() sur TOUTES les
  sources, et hoursSinceSync(null) vaut Infinity. Une seule source désactivée
  suffisait donc à afficher « Index des sources externes non synchronisé jamais
  synchronisé » en permanence. Observé avec 119 sources sur 120 synchronisées
  avec succès 20 minutes plus tôt. Aucune action utilisateur ne pouvait éteindre
  l'avertissement : il fallait réactiver la source.

GET /import/sync-status ne renvoyait pas l'état d'activation, le front ne
  pouvait donc pas distinguer « désactivée, donc normalement jamais
  synchronisée » de « activée mais réellement en retard ». Le champ `enabled` est
  désormais exposé, issu de la même fonction que le filtre de sync, pas de
  logique dupliquée entre les deux côtés.

Le calcul est extrait dans computeIndexStaleness(), fonction pure testable, qui
  ignore les sources désactivées. Le test `enabled !== false` plutôt que
  `=== true` est délibéré : face à un backend antérieur (champ absent), la source
  est comptée, mieux vaut un avertissement de trop qu'un vrai retard d'index
  masqué silencieusement.

  Tests : indexStaleness.test.js, 6 cas : le cas du bug, un vrai retard sur source
  activée, une source activée jamais synchronisée, la compat backend ancien, le
  cas « tout désactivé », et la liste vide (statut indisponible).
Chaque cycle de synchronisation re-téléchargeait et re-parsait les 120 sources intégralement, y compris celles inchangées depuis des semaines.

Mesure faite sur une instance réelle : ~18 min par cycle complet.

Les sources publient pourtant déjà le hash de leur index, et la chaîne de confiance l'authentifie :
    - SHA256 de Packages déclaré dans InRelease signée GPG (APT) ;
    - SHA-256 de primary.xml déclaré dans repomd.xml (RPM).

Ce hash est désormais stocké après chaque import réussi (table source_index_state, migration 0011) et comparé au cycle suivant.

Côté APT, InRelease est téléchargée et authentifiée AVANT Packages.gz : le hash attendu est ainsi connu avant de lancer un téléchargement important.

Alpine ne publiant aucun manifeste équivalent, l'empreinte APK est le SHA-256 de l'archive téléchargée :
le téléchargement a lieu dans tous les cas (< 2 Mo) mais la vérification RSA, l'extraction tar, le parsing et la réécriture sont évités.

Deux garde-fous rendent le saut sûr :
    - l'empreinte est effacée AVANT toute réécriture et réécrite seulement après succès complet,
      donc une importation interrompue ne laisse jamais une empreinte prétendant que la base contient un catalogue complet ;
    - un saut n'a lieu que si la source a encore des lignes en base, ce qui protège d'une purge manuelle ou d'une restauration partielle.

Une source sautée voit quand même son last_sync rafraîchi, sans quoi le bandeau de "fraîcheur" voit une date périmée et réclame une sync que le backend va refuser de traiter.

Un paramètre `force` est ajouté aux endpoints de sync pour réindexer inconditionnellement :
    - Le bouton de resync d'une source précise l'utilise (un clic ciblé est une demande explicite de réindexation, pas un « vérifie si ça a bougé »).
    - La sync globale reste incrémentale.

Mesuré sur fedora42-aarch64 (67 343 paquets) : 30,8 s → 0,8 s, soit 41x.

Tests : test_index_state_skip.py, 16 cas couvrant les garde-fous ci-dessus.

Les mocks de test_package_index_apt_sync.py sont adaptés au nouveau découpage, avec un cas supplémentaire vérifiant qu'un Packages.gz falsifié
reste rejeté et jamais parsé ; le saut d'index ne doit pas devenir une porte dérobée sur la chaîne de confiance.

Migration 0011 testée et validée sur PostgreSQL 16 (upgrade, downgrade, re-upgrade).
_CONCURRENCY["rpm"] valait 1, par crainte de la taille de primary.xml (jusqu'à 600 Mo décompressés). Cette crainte ne correspond à rien :  _stream_download_and_parse() parse en flux et vide l'arbre à chaque paquet, l'empreinte mémoire est bornée par le batch de 500 lignes, pas par la taille du fichier.

Avec 58 sources RPM traitées strictement en série et un débit mesuré à ~1 Mo/s côté miroirs upstream, ce sémaphore était à lui seul le chemin critique d'un cycle complet : ~17 min sur ~18. Les groupes APT et APK, eux, tournaient déjà en parallèle et finissaient en 4 min.

  Le profilage d'une source (fedora42-aarch64) donne ~15 s de téléchargement, ~10 s de parsing, ~4 s d'insertion : la phase dominante est réseau, donc le parallélisme recouvre directement les attentes. Le gain reste borné par le GIL sur le parsing XML, d'où 4 plutôt qu'une valeur plus agressive.

Le raisonnement derrière chaque valeur de concurrence est documenté dans le docstring du module, pour que la prochaine personne n'ait pas à le réinférer.
@repod-ce
repod-ce merged commit d9d5515 into repod-ce:main Aug 10, 2026
6 of 8 checks passed
@TiotBenjy
TiotBenjy deleted the fix/ext-id-sync branch August 10, 2026 06:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants