Fix : Synchronisation de l'index des sources externes : bandeau figé + cycle intégral à chaque passe - #1
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 unMath.max()sur toutes les sources, oùhoursSinceSync(null)vautInfinity. 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-statusne renvoyant pas l'état d'activation, le front ne pouvait pas distinguer "désactivée" de "réellement en retard". Le champenabledest désormais exposé, issu de la même fonction que le filtre de sync, et le calcul est extrait danscomputeIndexStaleness().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, migration0011) 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,
InReleaseest téléchargée et authentifiée avantPackages.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
DELETEest 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 sonlast_syncrafraîchi.Un paramètre
force(défautfalse) 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 :
InRelease, vérifiée à chaque passePackagesdéclaré par cetInReleaseauthentifiérepomd.xmlviarepomd.xml.asc, quand le dépôt en publie uneprimary.xmldéclaré parrepomd.xmlUn 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.gzfalsifié 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"]valait1, par crainte de la taille deprimary.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) etindexStaleness.test.js(6 cas sur le calcul du bandeau). Les mocks detest_package_index_apt_sync.pysont adaptés au découpage de la vérification APT, avec un cas supplémentaire sur le rejet d'unPackages.gzfalsifié.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_statecrée une table, sans toucher aux données existantes. Validée sur PostgreSQL 16 enupgrade,downgradeet re-upgrade. Appliquée automatiquement parentrypoint.shau démarrage.Les ajouts d'API sont additifs (champ
enableddans/import/sync-status, paramètreforceoptionnel) 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)