Claude m’a proposé un exercice Go de niveau intermédiaire : un cache clé/valeur générique, sûr en concurrence,
dont les entrées expirent après un TTL.
L’implémentation tient dans une map et un sync.RWMutex. Une
demi-heure, montre en main. Le reste de la soirée est parti dans les tests, et c’est là que ça devient
intéressant.
💡 Tout le code est sur github.
🕰️ L’horloge s’injecte
Tester l’expiration avec time.Sleep(time.Second) fonctionne. Ça rend aussi la suite lente et sensible à
l’ordonnancement, donc bancale le jour où le CI est chargé.
La sortie tient dans un champ de struct : le cache ne lit pas l’heure, il appelle une fonction qu’on lui a donnée.
type clockFunc func() time.Time
En production time.Now, en test un fakeClock avec son Advance(d).
Le test d’expiration devient instantané : on pose la valeur, on avance de deux TTL, Get doit rendre false.
J’ai gardé l’option withClock non exportée, c’est un levier de test et ça n’a rien à faire dans l’API publique.
⚠️ Le test -race ne doit affirmer que le vrai
Dix goroutines, chacune sur sa clé, plus une clé partagée par tout le monde. Sur celle-là, ma première version vérifiait la valeur.
Erreur : elle dépend de qui a écrit en dernier.
Le seul énoncé honnête, c’est « la clé existe ».
go test -race dit s’il y a une data race ; le test, lui, ne doit pas prétendre garantir ce que le code ne garantit pas.
🎯 L’éviction reste optionnelle
L’expiration paresseuse suffit à la correction, Get ne rend jamais une entrée périmée.
Le nettoyage périodique ne sert qu’à récupérer la mémoire.
En faire une option WithEviction(period) a eu un effet auquel je ne m’attendais pas sur la suite de tests :
par défaut aucun cache ne lance de goroutine, et goleak.VerifyTestMain reste vert sans qu’on y pense.
Seuls les deux tests qui parlent vraiment d’éviction paient le démarrage et le Close().
🧑💻 Le nettoyage d’un côté, son déclencheur de l’autre
evictExpired() parcourt la map et supprime ce qui est périmé.
Synchrone, testable en table : on fabrique un store à la main via withStore, on appelle, on vérifie.
startEviction() se contente de la brancher sur un
time.Ticker dans une goroutine annulable, et Close() annule le contexte puis attend le WaitGroup.
Le test qui compte n’attend rien.
✈️ synctest pour le single-flight
GetOrLoad(key, load): dix goroutines demandent la même clé absente,loadne doit partir qu’une fois.
Comment savoir qu’elles sont toutes arrivées avant de débloquer le chargement ?
Avant, on posait un time.Sleep(50 * time.Millisecond) et on croisait les doigts.
synctest.Test(t, func(t *testing.T) {
...
synctest.Wait() // toutes les goroutines de la bulle sont bloquées
close(release)
wg.Wait()
if counter.Load() != 1 { ... }
})
Désormais synctest.Wait() rend la main quand toutes les goroutines de la bulle sont durablement bloquées.
La question ne se pose plus.
💥 Là où j’ai buté : le verrouillage du GetOrLoad
Trois façons de se tromper, et j’en ai fait deux.
Garder le verrou du store pendant load(). Le chargement dure une seconde, et pendant ce temps tous les
Get, y compris sur des clés qui n’ont rien à voir, attendent. Le cache devient le goulot qu’il devait
supprimer car un seul mutex pour tout n’est pas suffisant.
Il en faut un second, dédié à la map des chargements en vol, relâché avant d’appeler
load ou d’attendre le résultat. Sinon deux clés indépendantes se sérialisent.
📝 Relire le résultat du vol sous verrou est inutile, mais je l’ai fait quand même, par réflexe.
Le close(done) suffit car le porteur écrit value et err avant de fermer le channel.
Les attendants les lisent après réception, le modèle mémoire de Go garantit l’ordre.
👉 Le race detector confirme.
Reste une fenêtre que j’assume 👇
Entre le
Getinitial et l’enregistrement du vol, une écriture peut se glisser, et le chargement partira quand même.
Le corriger demanderait de re-vérifier le store sous le verrou des vols, ce qui déplace le problème plus que ça ne le règle.
🗂️ Conclusion
Réaliser un cache minimaliste n’est pas très compliqué.
Ce sont les tests qui font l’exercice :
- une horloge qu’on peut avancer,
- des assertions qui ne mentent pas,
- une goroutine de fond qu’on peut ne pas lancer,
- et un verrouillage assez fin pour que la concurrence reste un gain plutôt qu’un décor.
