[ADD] cache qemu : ranger à nouveau le magasin sous la clé courante
La règle de clé d'un fichier portable a changé : seule la fin du chemin compte désormais. Les objets écrits sous l'ancienne règle restent sur le disque mais deviennent introuvables — le service les redemande à l'amont, et la place qu'ils tiennent ne sert plus personne. Sur un magasin de 12 764 objets, 5 419 étaient dans ce cas, soit 9,11 Gio. La passe les range sous la clé courante sans rien retélécharger, et fond au passage les copies qu'un miroir portait sous plusieurs chemins — 1 422 groupes, environ 3,37 Gio rendus. Un statut seul n'est pas touché : sa clé porte l'hôte. Vérifié : cinq tests, trois mutations attrapées. --- EN --- The key rule for a portable file changed: only the end of the path now counts. Objects written under the old rule stay on disk but become unreachable — the service asks upstream again, and the space they hold serves no one. On a store of 12 764 objects, 5 419 were in that case, that is 9.11 GiB. The pass stores them again under the current key without downloading anything, and merges on the way the copies a mirror carried under several paths — 1 422 groups, about 3.37 GiB returned. A status-only entry is left alone: its key carries the host. Checked: five tests, three mutations caught. Assisted-by: Claude Opus 5
This commit is contained in:
parent
755e2b49bd
commit
ab57a49d22
4 changed files with 448 additions and 11 deletions
|
|
@ -135,6 +135,8 @@ var anglais = map[string]string{
|
|||
"tunnel impossible pour %q : destination inconnue (%v)": "tunnel impossible for %q: unknown destination (%v)",
|
||||
"tunnel refusé pour %q : connexion non détournée, sa destination %s est cette écoute même": "tunnel refused for %q: connection not redirected, its destination %s is this very listener",
|
||||
"tunnel vers %s : %v ; %s est déchiffré à la place": "tunnel to %s: %v; %s is decrypted instead",
|
||||
"ranger à nouveau les objets du magasin sous la clé courante, et fondre les copies qu'un miroir portait sous plusieurs chemins ; service arrêté, et --dry-run pour ne que compter": "store the objects again under the current key, and merge the copies a mirror carried under several paths; service stopped, and --dry-run to only count",
|
||||
"re-clé : %v\n": "re-key: %v\n",
|
||||
"tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité": "tunnel to %s closed after %s: %s upstream, %s to the guest",
|
||||
"tunnel vers %s : %v": "tunnel to %s: %v",
|
||||
"ce n'est pas une poignée de main TLS (type %d)": "not a TLS handshake (type %d)",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
const version = "0.2.14"
|
||||
const version = "0.2.15"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
|
|
@ -95,6 +95,10 @@ func main() {
|
|||
" seul, sans corps), absent ou non-cachable. Lecture seule :"+
|
||||
" --cache-dir suffit, sans privilège, et l'âge des objets"+
|
||||
" n'est pas touché"))
|
||||
recle = flag.Bool("recle", false,
|
||||
T("ranger à nouveau les objets du magasin sous la clé courante,"+
|
||||
" et fondre les copies qu'un miroir portait sous plusieurs"+
|
||||
" chemins ; service arrêté, et --dry-run pour ne que compter"))
|
||||
oublie = flag.Bool("oublie", false,
|
||||
T("lire des lignes « MÉTHODE URL » sur l'entrée standard et"+
|
||||
" EFFACER du magasin ce que chacune désigne : le corps ET le"+
|
||||
|
|
@ -137,6 +141,21 @@ func main() {
|
|||
}
|
||||
return
|
||||
}
|
||||
// Même endroit, même raison : la passe ne touche que le magasin, et le
|
||||
// service ne doit pas tourner pendant qu'elle renomme ce qu'il sert.
|
||||
if *recle {
|
||||
store := &Store{Dir: *cacheDir}
|
||||
bilan, err := store.Recler(*dryRun)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, T("re-clé : %v\n"), err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *dryRun {
|
||||
fmt.Printf("%s", T("[à blanc] "))
|
||||
}
|
||||
fmt.Println(bilan.Ligne())
|
||||
return
|
||||
}
|
||||
|
||||
bypass := BypassFile{Path: *bypassFile}
|
||||
// Les exceptions entrent dans les règles dès leur RENDU : le service les
|
||||
|
|
|
|||
185
script/qemu_cache/recle.go
Normal file
185
script/qemu_cache/recle.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// « --recle » range à nouveau les objets sous la clé que le service calcule
|
||||
// AUJOURD'HUI.
|
||||
//
|
||||
// LE MANQUE QU'IL COMBLE. La clé d'un fichier portable a changé : seule la fin
|
||||
// du chemin compte désormais (voir SegmentsDeCle), là où le chemin entier
|
||||
// comptait. Les objets écrits sous l'ancienne règle restent sur le disque mais
|
||||
// deviennent introuvables : le service les redemande à l'amont, et l'espace
|
||||
// qu'ils occupent ne sert plus personne. La passe les range sous la clé
|
||||
// courante, sans rien retélécharger.
|
||||
//
|
||||
// ELLE FOND AUSSI LES COPIES. Un même fichier vivait sous plusieurs chemins de
|
||||
// miroir, donc sous plusieurs clés ; la clé courante les réunit, et deux
|
||||
// objets tombent alors au même endroit. Le plus récemment rangé est gardé —
|
||||
// les octets sont les mêmes, seule la date de service les sépare — et l'autre
|
||||
// rend sa place.
|
||||
//
|
||||
// CE QU'ELLE NE TOUCHE PAS. Un statut seul est rangé sous CleStatut, qui hache
|
||||
// l'URL ENTIÈRE : la règle du chemin ne l'atteint pas, et recalculer sa clé
|
||||
// avec CleDe la déplacerait là où personne ne la lit.
|
||||
//
|
||||
// QUAND LA LANCER. Le service doit être arrêté : la passe renomme des fichiers
|
||||
// qu'il sert, et un objet déplacé entre la lecture du méta et celle du corps
|
||||
// ressort en défaut de cache. Rien n'est perdu dans ce cas, mais la mesure
|
||||
// d'une campagne qui tournerait en même temps ne voudrait rien dire.
|
||||
|
||||
// Reclassement compte ce qu'une passe a fait.
|
||||
type Reclassement struct {
|
||||
Lus int
|
||||
Deplaces int
|
||||
Fondus int
|
||||
Inchanges int
|
||||
Illisibles int
|
||||
Refus int
|
||||
// OctetsRendus : ce que les copies fondues rendent au disque.
|
||||
OctetsRendus int64
|
||||
}
|
||||
|
||||
// Ligne rend le compte en une ligne lisible.
|
||||
func (r Reclassement) Ligne() string {
|
||||
return fmt.Sprintf(
|
||||
"lus %d, déplacés %d, fondus %d (%s rendus), inchangés %d,"+
|
||||
" illisibles %d, refus %d",
|
||||
r.Lus, r.Deplaces, r.Fondus, HumanBytes(r.OctetsRendus),
|
||||
r.Inchanges, r.Illisibles, r.Refus)
|
||||
}
|
||||
|
||||
// Recler range chaque objet sous la clé courante. À blanc, rien n'est écrit et
|
||||
// le compte dit ce qui bougerait.
|
||||
func (s *Store) Recler(aBlanc bool) (Reclassement, error) {
|
||||
var r Reclassement
|
||||
// La liste est faite AVANT de renommer : déplacer un fichier sous un
|
||||
// répertoire que le parcours n'a pas encore visité le lui ferait
|
||||
// rencontrer deux fois.
|
||||
var metas []string
|
||||
err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info == nil {
|
||||
return nil
|
||||
}
|
||||
if horsCasier(s.Dir, p, info) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !info.IsDir() && filepath.Ext(p) == ".meta" {
|
||||
metas = append(metas, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return r, err
|
||||
}
|
||||
|
||||
for _, chemin := range metas {
|
||||
r.Lus++
|
||||
ancienne := strings.TrimSuffix(filepath.Base(chemin), ".meta")
|
||||
m, err := s.LireMeta(ancienne)
|
||||
if err != nil {
|
||||
r.Illisibles++
|
||||
continue
|
||||
}
|
||||
// Un statut seul vit sous une clé qui porte l'hôte : la règle du
|
||||
// chemin ne le concerne pas.
|
||||
if m.StatutSeul() {
|
||||
r.Inchanges++
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(m.URL)
|
||||
if err != nil || !u.IsAbs() || u.Host == "" {
|
||||
r.Illisibles++
|
||||
continue
|
||||
}
|
||||
nouvelle := CleDe(m.Method, u)
|
||||
if nouvelle == ancienne {
|
||||
r.Inchanges++
|
||||
continue
|
||||
}
|
||||
s.deplacer(&r, ancienne, nouvelle, m, aBlanc)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// deplacer range un objet sous sa nouvelle clé, ou le fond dans la copie qui
|
||||
// s'y trouve déjà.
|
||||
//
|
||||
// Le CORPS part en premier et le méta ensuite : c'est l'ordre qui publie un
|
||||
// objet, et l'interrompre laisse au pire un corps sans méta — que le magasin
|
||||
// ignore — plutôt qu'un méta qui promet un corps absent.
|
||||
func (s *Store) deplacer(
|
||||
r *Reclassement, ancienne, nouvelle string, m *Meta, aBlanc bool,
|
||||
) {
|
||||
ancienMeta, ancienCorps := s.paths(ancienne)
|
||||
nouveauMeta, nouveauCorps := s.paths(nouvelle)
|
||||
|
||||
if autre, err := s.LireMeta(nouvelle); err == nil {
|
||||
// Deux chemins de miroir pour le même fichier. Le plus récemment
|
||||
// rangé reste : les octets sont les mêmes, seule leur date diffère.
|
||||
garde, rendus := autre.StoredAt, m.Size
|
||||
if m.StoredAt.After(garde) {
|
||||
rendus = autre.Size
|
||||
if !aBlanc {
|
||||
if err := s.retirerPaire(nouveauMeta, nouveauCorps); err != nil {
|
||||
r.Refus++
|
||||
return
|
||||
}
|
||||
if err := s.renommerPaire(
|
||||
ancienMeta, ancienCorps, nouveauMeta, nouveauCorps,
|
||||
); err != nil {
|
||||
r.Refus++
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Deplaces++
|
||||
} else if !aBlanc {
|
||||
if err := s.retirerPaire(ancienMeta, ancienCorps); err != nil {
|
||||
r.Refus++
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Fondus++
|
||||
r.OctetsRendus += rendus
|
||||
return
|
||||
}
|
||||
|
||||
if !aBlanc {
|
||||
if err := os.MkdirAll(filepath.Dir(nouveauMeta), 0o755); err != nil {
|
||||
r.Refus++
|
||||
return
|
||||
}
|
||||
if err := s.renommerPaire(
|
||||
ancienMeta, ancienCorps, nouveauMeta, nouveauCorps,
|
||||
); err != nil {
|
||||
r.Refus++
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Deplaces++
|
||||
}
|
||||
|
||||
func (s *Store) renommerPaire(ancienMeta, ancienCorps, nouveauMeta, nouveauCorps string) error {
|
||||
if err := os.Rename(ancienCorps, nouveauCorps); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(ancienMeta, nouveauMeta)
|
||||
}
|
||||
|
||||
func (s *Store) retirerPaire(meta, corps string) error {
|
||||
if err := os.Remove(corps); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(meta); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
231
script/qemu_cache/recle_test.go
Normal file
231
script/qemu_cache/recle_test.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ancienneCle reproduit la règle d'AVANT : le chemin entier, tel que le
|
||||
// magasin le hachait jusqu'à la normalisation. Les objets déjà rangés sur un
|
||||
// disque portent cette clé-là, et c'est d'eux que la passe s'occupe.
|
||||
func ancienneCle(methode string, u *url.URL) string {
|
||||
sum := sha256.Sum256([]byte(methode + " path " + u.Path))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// planter écrit un objet sous la clé donnée, comme le magasin l'aurait fait.
|
||||
func planter(t *testing.T, s *Store, cle, brut, corps string) {
|
||||
t.Helper()
|
||||
w, err := s.NewWriter(cle, Meta{
|
||||
URL: brut, Method: "GET", Status: 200, Class: "immutable",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("écriture de %s : %v", brut, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(corps)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Commit(int64(len(corps))); err != nil {
|
||||
t.Fatalf("publication de %s : %v", brut, err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
rockyA = "https://a.example/rocky/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm"
|
||||
rockyB = "https://b.example/mirror/rocky-linux/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm"
|
||||
)
|
||||
|
||||
// Un objet rangé sous l'ancienne clé est introuvable : le service le
|
||||
// redemande à l'amont alors qu'il est sur le disque. La passe le range sous la
|
||||
// clé courante, sans rien retélécharger.
|
||||
func TestReclerRendTrouvableUnObjetAncien(t *testing.T) {
|
||||
s := &Store{Dir: t.TempDir()}
|
||||
u, _ := url.Parse(rockyA)
|
||||
planter(t, s, ancienneCle("GET", u), rockyA, "le paquet")
|
||||
|
||||
if s.Detient(CleDe("GET", u)) {
|
||||
t.Fatal("l'objet est déjà trouvable : le test ne mesure rien")
|
||||
}
|
||||
bilan, err := s.Recler(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bilan.Deplaces != 1 || bilan.Fondus != 0 {
|
||||
t.Errorf("bilan %+v, attendu un seul déplacement", bilan)
|
||||
}
|
||||
if !s.Detient(CleDe("GET", u)) {
|
||||
t.Error("l'objet reste introuvable après la passe")
|
||||
}
|
||||
m, f, err := s.Get(CleDe("GET", u))
|
||||
if err != nil {
|
||||
t.Fatalf("lecture après passe : %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if m.URL != rockyA {
|
||||
t.Errorf("méta perdu en route : %q", m.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// Deux chemins de miroir pour le même fichier : la clé courante les réunit, et
|
||||
// la passe n'en garde qu'un — le plus récemment rangé.
|
||||
//
|
||||
// Les deux DIRECTIONS sont éprouvées, et l'ordre est fixé en plantant d'abord
|
||||
// l'objet de destination : laisser le parcours décider laquelle des deux
|
||||
// copies arrive la première ne couvrait qu'une branche sur deux, au gré du
|
||||
// hachage, et la branche non couverte est passée au travers d'une mutation.
|
||||
func TestReclerFondLesCopiesDeMiroir(t *testing.T) {
|
||||
ua, _ := url.Parse(rockyA)
|
||||
|
||||
t.Run("entrante plus ancienne", func(t *testing.T) {
|
||||
s := &Store{Dir: t.TempDir()}
|
||||
planter(t, s, ancienneCle("GET", ua), rockyA, "copie ancienne")
|
||||
planter(t, s, CleDe("GET", ua), rockyB, "copie en place, plus recente")
|
||||
|
||||
bilan, err := s.Recler(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bilan.Fondus != 1 || bilan.OctetsRendus == 0 {
|
||||
t.Errorf("bilan %+v, attendu une fusion qui rend des octets", bilan)
|
||||
}
|
||||
if corps := corpsDuMagasin(t, s); corps != 1 {
|
||||
t.Errorf("%d corps sur le disque, attendu 1", corps)
|
||||
}
|
||||
if lu := lireCorps(t, s, CleDe("GET", ua)); lu != "copie en place, plus recente" {
|
||||
t.Errorf("la copie gardée est %q : la plus récente devait rester", lu)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entrante plus récente", func(t *testing.T) {
|
||||
s := &Store{Dir: t.TempDir()}
|
||||
planter(t, s, CleDe("GET", ua), rockyB, "copie en place, ancienne")
|
||||
planter(t, s, ancienneCle("GET", ua), rockyA, "copie entrante, recente")
|
||||
|
||||
bilan, err := s.Recler(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bilan.Fondus != 1 || bilan.Deplaces != 1 {
|
||||
t.Errorf("bilan %+v, attendu une fusion et un déplacement", bilan)
|
||||
}
|
||||
if corps := corpsDuMagasin(t, s); corps != 1 {
|
||||
t.Errorf("%d corps sur le disque, attendu 1", corps)
|
||||
}
|
||||
if lu := lireCorps(t, s, CleDe("GET", ua)); lu != "copie entrante, recente" {
|
||||
t.Errorf("la copie gardée est %q : la plus récente devait rester", lu)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// corpsDuMagasin compte les corps réellement sur le disque : une copie qui
|
||||
// n'est plus référencée mais reste écrite occupe la place qu'on croyait rendue.
|
||||
func corpsDuMagasin(t *testing.T, s *Store) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err == nil && info != nil && !info.IsDir() &&
|
||||
strings.HasSuffix(p, ".body") {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func lireCorps(t *testing.T, s *Store, cle string) string {
|
||||
t.Helper()
|
||||
_, f, err := s.Get(cle)
|
||||
if err != nil {
|
||||
t.Fatalf("lecture de %s : %v", cle, err)
|
||||
}
|
||||
defer f.Close()
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Un chemin court tient tout entier dans la clé : rien ne doit bouger.
|
||||
func TestReclerNeTouchePasUnCheminCourt(t *testing.T) {
|
||||
s := &Store{Dir: t.TempDir()}
|
||||
brut := "https://a.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst"
|
||||
u, _ := url.Parse(brut)
|
||||
planter(t, s, CleDe("GET", u), brut, "paquet arch")
|
||||
|
||||
bilan, err := s.Recler(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bilan.Deplaces != 0 || bilan.Inchanges != 1 {
|
||||
t.Errorf("bilan %+v, attendu un objet inchangé", bilan)
|
||||
}
|
||||
if !s.Detient(CleDe("GET", u)) {
|
||||
t.Error("un objet qui ne devait pas bouger a disparu")
|
||||
}
|
||||
}
|
||||
|
||||
// Un statut seul est rangé sous une clé qui porte l'HÔTE : la règle du chemin
|
||||
// ne le concerne pas, et le déplacer l'enverrait là où personne ne le lit.
|
||||
func TestReclerLaisseLesStatutsSeuls(t *testing.T) {
|
||||
s := &Store{Dir: t.TempDir()}
|
||||
brut := "https://a.example/depot/releases/latest/download/outil.tar.gz"
|
||||
u, _ := url.Parse(brut)
|
||||
cle := CleStatut("GET", u)
|
||||
w, err := s.NewWriter(cle, Meta{
|
||||
URL: brut, Method: "GET", Status: 302, Class: "volatile",
|
||||
StatusOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Commit(0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bilan, err := s.Recler(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bilan.Deplaces != 0 {
|
||||
t.Errorf("bilan %+v : un statut seul a été déplacé", bilan)
|
||||
}
|
||||
if _, err := s.LireMeta(cle); err != nil {
|
||||
t.Error("le statut seul n'est plus sous sa clé")
|
||||
}
|
||||
}
|
||||
|
||||
// À blanc, la passe compte sans rien écrire : c'est ce qui permet de la lancer
|
||||
// avant de décider.
|
||||
func TestReclerABlancNeDeplaceRien(t *testing.T) {
|
||||
s := &Store{Dir: t.TempDir()}
|
||||
u, _ := url.Parse(rockyA)
|
||||
ancienne := ancienneCle("GET", u)
|
||||
planter(t, s, ancienne, rockyA, "le paquet")
|
||||
|
||||
bilan, err := s.Recler(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bilan.Deplaces != 1 {
|
||||
t.Errorf("bilan %+v, attendu un déplacement annoncé", bilan)
|
||||
}
|
||||
if _, err := s.LireMeta(ancienne); err != nil {
|
||||
t.Error("l'objet a bougé alors que la passe était à blanc")
|
||||
}
|
||||
if s.Detient(CleDe("GET", u)) {
|
||||
t.Error("un objet est apparu sous la clé courante, à blanc")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue