erplibre/script/qemu_cache/variante_test.go
Mathieu Benoit 90ef1d24e7 [ADD] cache qemu : revalider les index, ranger chaque représentation
Sur une installation complète, les index pip, les métadonnées npm et le
bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque
fois. Un index déjà gardé part désormais avec son ETag : l'amont juge chaque
requête, et un « 304 » sert le corps depuis le disque. Sous « Vary: Accept »,
chaque représentation est rangée à part, faute de quoi deux clients qui
alternent les retéléchargent tour à tour. Les métadonnées PEP 658 de pip,
nommées « .whl.metadata », sont figées comme l'archive qu'elles décrivent.

--- EN ---

On a full install the pip indexes, npm metadata and repo bundle weighed about
110 MB per VM, taken whole every time. An index already kept now leaves with
its ETag: upstream judges every request, and a « 304 » serves the body from
disk. Under « Vary: Accept » each representation is stored apart, without
which two clients alternating between them re-download each in turn. The
PEP 658 metadata of pip, named « .whl.metadata », are as immutable as the
archive they describe.

Assisted-by: Claude Opus 5
2026-09-16 05:36:13 -04:00

200 lines
6.3 KiB
Go

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
)
// Les deux en-têtes Accept d'un client de registre : la fiche abrégée, et la
// fiche complète.
const (
acceptAbrege = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"
acceptComplet = "application/json"
)
// amontAVariantes sert deux représentations d'une même URL selon Accept, chacune
// sous son ETag, avec « Vary: Accept » ; « 304 » à qui présente l'ETag de la
// représentation qu'il demande. Il compte les corps envoyés par représentation.
type amontAVariantes struct {
srv *httptest.Server
mu sync.Mutex
vary bool
corps map[string]int
}
func nouvelAmontAVariantes(t *testing.T, vary bool) *amontAVariantes {
t.Helper()
a := &amontAVariantes{vary: vary, corps: map[string]int{}}
a.srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
rep, etag, corps := "complète", `"complet"`, "fiche complète, très longue"
if strings.Contains(r.Header.Get("Accept"), "install-v1") {
rep, etag, corps = "abrégée", `"abrege"`, "fiche abrégée"
}
if a.vary {
w.Header().Set("Vary", "accept-encoding, accept")
}
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
a.corps[rep]++
io.WriteString(w, corps)
}))
t.Cleanup(a.srv.Close)
return a
}
func (a *amontAVariantes) envoyes(rep string) int {
a.mu.Lock()
defer a.mu.Unlock()
return a.corps[rep]
}
func (a *amontAVariantes) hote() string {
u, _ := url.Parse(a.srv.URL)
return u.Host
}
const ficheNpm = "/npm"
func demandeAccept(t *testing.T, p *Proxy, hote, accept string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", ficheNpm, nil)
r.Host = hote
if accept != "" {
r.Header.Set("Accept", accept)
}
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
// Deux représentations demandées tour à tour ne repartent qu'une fois chacune :
// rangée à part, l'une ne remplace plus l'autre.
func TestDeuxRepresentationsQuiAlternentNeRepartentQuUneFois(t *testing.T) {
a := nouvelAmontAVariantes(t, true)
u := &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm}
if Classify(u) != ClassVolatile || PortableParChemin(u) {
t.Fatalf("%s n'est plus un volatil rangé avec son hôte", ficheNpm)
}
p := proxyDeTest(t)
ordre := []string{acceptAbrege, acceptComplet, acceptAbrege, acceptComplet, acceptAbrege}
for i, accept := range ordre {
w := demandeAccept(t, p, a.hote(), accept)
veut := "fiche complète, très longue"
if accept == acceptAbrege {
veut = "fiche abrégée"
}
if w.Body.String() != veut {
t.Fatalf("demande %d : corps %q, attendu %q", i+1, w.Body.String(), veut)
}
}
if n := a.envoyes("abrégée"); n != 1 {
t.Errorf("fiche abrégée envoyée %d fois, attendu 1", n)
}
if n := a.envoyes("complète"); n != 1 {
t.Errorf("fiche complète envoyée %d fois, attendu 1", n)
}
}
// La clé de base reste tenue : --detient, le pré-vol et le rattrapage hors
// ligne ne connaissent pas l'en-tête Accept.
func TestLaCleDeBaseResteTenuePourDetient(t *testing.T) {
a := nouvelAmontAVariantes(t, true)
p := proxyDeTest(t)
demandeAccept(t, p, a.hote(), acceptAbrege)
d := p.Store.Detenir("GET", "http://"+a.hote()+ficheNpm)
if d.Verdict != VerdictGarde {
t.Errorf("--detient dit %q pour une fiche gardée", d.Verdict)
}
}
// Sans « Vary: Accept », rien n'est rangé à part : la réponse ne varie pas, et
// la dupliquer ne ferait que doubler le disque.
func TestSansVaryAcceptAucuneVariante(t *testing.T) {
a := nouvelAmontAVariantes(t, false)
p := proxyDeTest(t)
demandeAccept(t, p, a.hote(), acceptAbrege)
base := CleDe("GET", &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm})
if p.Store.Detient(CleVariante(base, acceptAbrege)) {
t.Error("une variante a été rangée pour une réponse sans Vary: Accept")
}
if !p.Store.Detient(base) {
t.Error("la clé de base n'est plus écrite")
}
}
// Amont muet : le client reçoit SA représentation, pas la dernière rangée sous
// la base, qui peut être l'autre.
func TestHorsLigneLaRepresentationDuClientSort(t *testing.T) {
a := nouvelAmontAVariantes(t, true)
p := proxyDeTest(t)
hote := a.hote()
demandeAccept(t, p, hote, acceptAbrege)
demandeAccept(t, p, hote, acceptComplet) // la base porte désormais la complète
a.srv.Close()
w := demandeAccept(t, p, hote, acceptAbrege)
if w.Code != http.StatusOK || w.Body.String() != "fiche abrégée" {
t.Errorf("hors ligne : %d %q, attendu la fiche abrégée", w.Code, w.Body.String())
}
}
// Une requête sans Accept ne choisit aucune représentation : elle suit la clé
// de base, comme avant.
func TestSansAcceptLaBaseSeule(t *testing.T) {
if CleVariante("cle", "") != "" || CleVariante("cle", " ") != "" {
t.Error("un Accept vide produit une clé de variante")
}
if CleVariante("cle", "Application/JSON") != CleVariante("cle", "application/json") {
t.Error("la casse d'Accept change la variante")
}
if !varieSurAccept(http.Header{"Vary": {"accept-encoding, Accept"}}) ||
varieSurAccept(http.Header{"Vary": {"accept-encoding"}}) {
t.Error("lecture de Vary erronée")
}
}
// Servir une variante sert aussi l'objet de base : sa date d'usage suit, sans
// quoi un nettoyage par âge retirerait la base qu'on sert encore, et --detient
// la dirait absente.
func TestServirUneVarianteRajeunitLaBase(t *testing.T) {
a := nouvelAmontAVariantes(t, true)
p := proxyDeTest(t)
demandeAccept(t, p, a.hote(), acceptAbrege)
base := CleDe("GET", &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm})
_, corpsBase := p.Store.paths(base)
vieux := time.Now().Add(-90 * 24 * time.Hour)
if err := os.Chtimes(corpsBase, vieux, vieux); err != nil {
t.Fatal(err)
}
w := demandeAccept(t, p, a.hote(), acceptAbrege)
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeRevalidated {
t.Fatalf("seconde demande servie « %s », attendu « %s »", got, OutcomeRevalidated)
}
fi, err := os.Stat(corpsBase)
if err != nil {
t.Fatal(err)
}
if time.Since(fi.ModTime()) > time.Hour {
t.Errorf("la base date encore du %s : son usage n'a pas été noté", fi.ModTime())
}
}