Sauvegarder une fois par séance plutôt qu'à chaque modification

L'interface enregistre après une seconde d'inactivité, et le serveur copiait
le planning avant chaque écriture : une après-midi d'édition produisait des
dizaines de fichiers quasi identiques.

Le problème n'est pas la place occupée mais ce que la rotation en faisait —
les cinquante emplacements se remplissaient en quelques heures et chassaient
les états anciens, les seuls qu'on cherche à retrouver. Un filet qui ne
remonte pas au-delà de la dernière demi-heure ne protège pas de la bêtise
qu'on découvre le lendemain.

La copie est désormais prise à l'ouverture, quand le planning est encore dans
l'état d'avant la séance. Deux garde-fous : au plus une par jour si le serveur
reste allumé longtemps, et rien du tout si le contenu n'a pas bougé depuis la
dernière copie. Les cinquante conservées couvrent maintenant des mois.

La copie et le remplacement partagent un état — la date de la dernière
sauvegarde — donc un verrou les sérialise, ce qui protège au passage du cas
où deux onglets enregistrent simultanément.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 17:08:26 +02:00
parent 558d42f4cb
commit faac28172e
4 changed files with 201 additions and 14 deletions

View File

@@ -168,8 +168,22 @@ Tout tient dans un fichier JSON, `data/projets.json`, décrit dans
parlants (`site-web`, `cadrage`) plutôt que des UUID : il reste éditable à la main et lisible dans un parlants (`site-web`, `cadrage`) plutôt que des UUID : il reste éditable à la main et lisible dans un
diff git. diff git.
À chaque sauvegarde, le serveur copie la version précédente dans un dossier `backups/` voisin du **À l'ouverture**, le serveur copie le planning dans un dossier `backups/` voisin, horodaté : c'est
planning, horodatée. l'état d'avant la séance, celui qu'on veut retrouver si elle tourne mal. Si le serveur reste allumé
plusieurs jours, une copie est reprise au plus une fois par jour. Rouvrir l'outil sans avoir rien
modifié ne laisse pas de trace.
Cinquante sauvegardes sont conservées, les plus anciennes étant supprimées au fil de l'eau. Comme
elles valent une par séance et non une par modification, cela représente des mois d'historique
plutôt que la dernière demi-heure.
Pour restaurer, **arrêter `frise` et fermer l'onglet d'abord** : l'application garde son planning
en mémoire et sa prochaine sauvegarde écraserait ce qu'on vient de remettre en place.
```sh
ls -lt backups/ # repérer la version voulue
cp backups/projets-20260801-170352.json projets.json
```
Le planning n'est **pas versionné** : `.gitignore` exclut `projets.json`, ce dépôt ne contenant Le planning n'est **pas versionné** : `.gitignore` exclut `projets.json`, ce dépôt ne contenant
que le code. Seul `data/exemple.json` y figure, comme jeu de démonstration. que le code. Seul `data/exemple.json` y figure, comme jeu de démonstration.

View File

@@ -64,8 +64,9 @@ serveur de la bibliothèque standard, `GET` et `PUT` sur `/api/data`, tient en u
— soit **moins** de code que l'API navigateur plus son repli plus son miroir. Et il fonctionne — soit **moins** de code que l'API navigateur plus son repli plus son miroir. Et il fonctionne
partout, sans sélecteur à réautoriser. partout, sans sélecteur à réautoriser.
Bénéfice supplémentaire : le serveur peut écrire une sauvegarde horodatée à chaque écriture, ce que Bénéfice supplémentaire : le serveur peut écrire une sauvegarde horodatée, ce que la File System
la File System Access API ne permettait pas simplement. Access API ne permettait pas simplement. Elle l'était alors à chaque écriture ; son rythme a depuis
changé, voir décision 23.
## 4. DOM et CSS plutôt que SVG ## 4. DOM et CSS plutôt que SVG
@@ -500,3 +501,33 @@ sans recompiler.
`serve.py` a été supprimé une fois le binaire validé en usage réel. Il n'y avait rien à migrer : le `serve.py` a été supprimé une fois le binaire validé en usage réel. Il n'y avait rien à migrer : le
format du fichier et les routes sont identiques, et son historique reste dans git si le besoin de format du fichier et les routes sont identiques, et son historique reste dans git si le besoin de
le relire se présentait. le relire se présentait.
## 23. Une sauvegarde par séance, pas une par modification
Le serveur copiait le planning dans `backups/` avant **chaque** écriture. Comme l'interface
enregistre après une seconde d'inactivité, une séance d'édition un peu soutenue produisait des
dizaines de fichiers quasi identiques.
Le défaut n'est pas l'espace occupé — cinquante copies d'un planning macro pèsent 250 Ko — mais ce
que la rotation en fait : les cinquante emplacements se remplissaient en une après-midi, et
chassaient précisément les états anciens qu'on cherche à retrouver. Un filet de sécurité qui ne
remonte pas plus loin que la dernière demi-heure protège de la faute de frappe, pas de la bêtise
qu'on découvre le lendemain.
La copie est donc prise **à l'ouverture**, et une seule fois. C'est le bon moment : l'état d'avant
la séance est exactement celui vers lequel on veut revenir, et il est figé avant qu'on y touche.
Deux garde-fous complètent la règle :
- si le serveur reste allumé longtemps — un serveur local qu'on ne ferme jamais est un cas
réel —, une copie est reprise au plus une fois par jour, faute de quoi une session de trois
semaines ne laisserait qu'un seul point de reprise ;
- si le planning est identique à la dernière sauvegarde, rien n'est écrit. Rouvrir l'outil sans
avoir rien modifié n'a pas à consommer un emplacement.
À raison d'une copie par séance, les cinquante conservées couvrent des mois. Pour un historique
plus long ou plus fin, la vraie réponse n'est pas d'en garder davantage mais de versionner le
planning dans git, ce que son format texte indenté rend parfaitement lisible (voir décision 5).
Ce changement a rendu concurrentes deux étapes qui ne l'étaient pas — la copie et le remplacement
dépendent maintenant d'un état partagé, la date de la dernière sauvegarde. Un verrou les sérialise,
ce qui protège au passage du cas où deux onglets enregistrent en même temps.

84
main.go
View File

@@ -32,6 +32,7 @@ import (
"runtime" "runtime"
"sort" "sort"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
) )
@@ -47,6 +48,16 @@ const tailleMax = 5 << 20
// sont supprimées pour que backups/ ne grossisse pas indéfiniment. // sont supprimées pour que backups/ ne grossisse pas indéfiniment.
const backupsConserves = 50 const backupsConserves = 50
// Intervalle minimal entre deux sauvegardes tant que le serveur tourne.
//
// L'interface enregistre après une seconde d'inactivité : copier le planning à
// chaque écriture produisait des dizaines de fichiers quasi identiques par
// séance, qui chassaient les états anciens et ne remontaient pas plus loin que
// la dernière demi-heure. Une sauvegarde est prise à l'ouverture — l'état
// d'avant la séance, celui qu'on veut retrouver — puis au plus une par jour si
// le serveur reste allumé longtemps. Voir docs/decisions.md, section 23.
const intervalleBackup = 24 * time.Hour
// Premier port tenté. En cas d'occupation on essaie les suivants : lancé par // Premier port tenté. En cas d'occupation on essaie les suivants : lancé par
// un double-clic, l'utilisateur n'a aucun moyen de passer --port. // un double-clic, l'utilisateur n'a aucun moyen de passer --port.
const portInitial = 8000 const portInitial = 8000
@@ -57,6 +68,12 @@ var donneesInitiales = []byte(`{"version": 3, "projects": []}`)
type serveur struct { type serveur struct {
fichierDonnees string fichierDonnees string
interfaceWeb fs.FS interfaceWeb fs.FS
// Sérialise les écritures : la copie de sauvegarde et le remplacement du
// fichier forment un tout, que deux onglets ouverts ne doivent pas
// entrelacer.
mu sync.Mutex
dernierBackup time.Time
} }
func main() { func main() {
@@ -88,6 +105,14 @@ func main() {
url := fmt.Sprintf("http://localhost:%d", ecouteur.Addr().(*net.TCPAddr).Port) url := fmt.Sprintf("http://localhost:%d", ecouteur.Addr().(*net.TCPAddr).Port)
fmt.Printf("Frise multi-projets -> %s\n", url) fmt.Printf("Frise multi-projets -> %s\n", url)
fmt.Printf("Données : %s\n", fichier) fmt.Printf("Données : %s\n", fichier)
// L'état d'avant la séance : c'est celui qu'on voudra retrouver si la
// séance tourne mal. Un échec ici n'empêche pas de travailler, mais il doit
// se voir — l'utilisateur croirait sinon avoir un filet qu'il n'a pas.
if err := srv.sauvegarderVersionPrecedente(true); err != nil {
fmt.Fprintf(os.Stderr, "Attention : sauvegarde d'ouverture impossible (%v)\n", err)
}
fmt.Println("Ctrl+C pour arrêter.") fmt.Println("Ctrl+C pour arrêter.")
fmt.Println() fmt.Println()
@@ -287,11 +312,14 @@ func (s *serveur) ecrireDonnees(w http.ResponseWriter, r *http.Request) {
} }
indente.WriteByte('\n') indente.WriteByte('\n')
if err := s.sauvegarderVersionPrecedente(); err != nil { s.mu.Lock()
erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) err = s.sauvegarderVersionPrecedente(false)
return if err == nil {
err = s.ecrireAtomiquement(indente.Bytes())
} }
if err := s.ecrireAtomiquement(indente.Bytes()); err != nil { s.mu.Unlock()
if err != nil {
erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err))
return return
} }
@@ -308,9 +336,18 @@ func (s *serveur) dossierBackups() string {
return filepath.Join(filepath.Dir(s.fichierDonnees), "backups") return filepath.Join(filepath.Dir(s.fichierDonnees), "backups")
} }
// sauvegarderVersionPrecedente copie le fichier actuel dans backups/ avant de // sauvegarderVersionPrecedente copie le fichier actuel dans backups/, si la
// l'écraser. // copie apporte quelque chose.
func (s *serveur) sauvegarderVersionPrecedente() error { //
// Deux garde-fous évitent d'inonder le dossier : l'intervalle minimal, que
// « forcer » outrepasse à l'ouverture, et la comparaison au dernier état
// sauvegardé — rouvrir l'outil sans avoir rien modifié n'a pas à laisser de
// trace.
func (s *serveur) sauvegarderVersionPrecedente(forcer bool) error {
if !forcer && time.Since(s.dernierBackup) < intervalleBackup {
return nil
}
contenu, err := os.ReadFile(s.fichierDonnees) contenu, err := os.ReadFile(s.fichierDonnees)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return nil return nil
@@ -319,12 +356,19 @@ func (s *serveur) sauvegarderVersionPrecedente() error {
return err return err
} }
if precedent, trouve := s.dernierEtatSauvegarde(); trouve && bytes.Equal(precedent, contenu) {
// Rien n'a bougé depuis la dernière copie. On note tout de même le
// passage, pour ne pas relire le dossier à chaque écriture.
s.dernierBackup = time.Now()
return nil
}
dossier := s.dossierBackups() dossier := s.dossierBackups()
if err := os.MkdirAll(dossier, 0o755); err != nil { if err := os.MkdirAll(dossier, 0o755); err != nil {
return err return err
} }
base := strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees)) base := s.baseNom()
marqueur := time.Now().Format("20060102-150405") marqueur := time.Now().Format("20060102-150405")
cible := filepath.Join(dossier, fmt.Sprintf("%s-%s.json", base, marqueur)) cible := filepath.Join(dossier, fmt.Sprintf("%s-%s.json", base, marqueur))
@@ -337,9 +381,33 @@ func (s *serveur) sauvegarderVersionPrecedente() error {
if err := os.WriteFile(cible, contenu, 0o644); err != nil { if err := os.WriteFile(cible, contenu, 0o644); err != nil {
return err return err
} }
s.dernierBackup = time.Now()
return elaguerBackups(dossier, base) return elaguerBackups(dossier, base)
} }
// baseNom est le nom du planning sans son extension, préfixe commun à ses
// sauvegardes.
func (s *serveur) baseNom() string {
return strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees))
}
// dernierEtatSauvegarde renvoie le contenu de la sauvegarde la plus récente.
// L'horodatage est en tête et de longueur fixe : l'ordre lexicographique est
// l'ordre chronologique.
func (s *serveur) dernierEtatSauvegarde() ([]byte, bool) {
entrees, err := filepath.Glob(filepath.Join(s.dossierBackups(), s.baseNom()+"-*.json"))
if err != nil || len(entrees) == 0 {
return nil, false
}
sort.Strings(entrees)
contenu, err := os.ReadFile(entrees[len(entrees)-1])
if err != nil {
return nil, false
}
return contenu, true
}
// ecrireAtomiquement écrit dans un fichier temporaire puis remplace, pour // ecrireAtomiquement écrit dans un fichier temporaire puis remplace, pour
// qu'une coupure en cours d'écriture ne laisse jamais un planning tronqué. // qu'une coupure en cours d'écriture ne laisse jamais un planning tronqué.
func (s *serveur) ecrireAtomiquement(contenu []byte) error { func (s *serveur) ecrireAtomiquement(contenu []byte) error {

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"testing" "testing"
"testing/fstest" "testing/fstest"
"time"
) )
// serveurDeTest monte un serveur sur un dossier temporaire. Le fichier de // serveurDeTest monte un serveur sur un dossier temporaire. Le fichier de
@@ -253,11 +254,14 @@ func TestElaguerBackupsNeGardeQueLesPlusRecents(t *testing.T) {
} }
} }
func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) { // Le point de la décision 23 : une séance d'édition ne doit pas remplir le
// dossier de copies quasi identiques, sous peine d'en chasser les états
// anciens — les seuls qu'on cherche vraiment à retrouver.
func TestEcrituresRapprocheesNeProduisentQuUneSauvegarde(t *testing.T) {
s, routes := serveurDeTest(t) s, routes := serveurDeTest(t)
ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`) ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`)
for i := 0; i < 3; i++ { for i := 0; i < 10; i++ {
rec := appeler(t, routes, http.MethodPut, "/api/data", rec := appeler(t, routes, http.MethodPut, "/api/data",
fmt.Sprintf(`{"version":3,"projects":[],"n":%d}`, i)) fmt.Sprintf(`{"version":3,"projects":[],"n":%d}`, i))
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -265,6 +269,76 @@ func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) {
} }
} }
sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
if len(sauvegardes) != 1 {
t.Errorf("sauvegardes = %d, attendu 1 pour dix écritures rapprochées", len(sauvegardes))
}
// Et c'est bien l'état d'avant la séance qui est conservé.
if lireFichier(t, sauvegardes[0]) != `{"projects":[]}` {
t.Errorf("la sauvegarde ne contient pas l'état initial : %s", lireFichier(t, sauvegardes[0]))
}
}
func TestSauvegardeDOuvertureEstToujoursPrise(t *testing.T) {
s, _ := serveurDeTest(t)
ecrireFichier(t, s.fichierDonnees, `{"projects":["avant"]}`)
// Une écriture vient d'avoir lieu : l'intervalle n'est pas écoulé.
s.dernierBackup = time.Now()
if err := s.sauvegarderVersionPrecedente(true); err != nil {
t.Fatalf("sauvegarde d'ouverture : %v", err)
}
sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
if len(sauvegardes) != 1 {
t.Fatalf("sauvegardes = %d, attendu 1 — l'ouverture doit passer outre l'intervalle",
len(sauvegardes))
}
}
func TestOuvrirSansRienModifierNeDupliquePas(t *testing.T) {
s, _ := serveurDeTest(t)
ecrireFichier(t, s.fichierDonnees, `{"projects":["inchangé"]}`)
// Trois ouvertures successives sur un planning qu'on n'a pas touché.
for i := 0; i < 3; i++ {
if err := s.sauvegarderVersionPrecedente(true); err != nil {
t.Fatalf("ouverture %d : %v", i, err)
}
}
sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
if len(sauvegardes) != 1 {
t.Errorf("sauvegardes = %d, attendu 1 — le contenu est identique", len(sauvegardes))
}
}
func TestSansFichierAucuneSauvegarde(t *testing.T) {
s, _ := serveurDeTest(t)
if err := s.sauvegarderVersionPrecedente(true); err != nil {
t.Fatalf("sauvegarde : %v", err)
}
if existe(s.dossierBackups()) {
t.Error("un dossier de sauvegardes a été créé alors qu'il n'y a pas de planning")
}
}
// Deux copies dans la même seconde ne doivent pas s'écraser. Le cas est devenu
// rare depuis l'espacement des sauvegardes, mais il reste atteignable — deux
// exemplaires lancés en même temps sur le même planning.
func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) {
s, _ := serveurDeTest(t)
for i := 0; i < 3; i++ {
ecrireFichier(t, s.fichierDonnees, fmt.Sprintf(`{"projects":[],"n":%d}`, i))
if err := s.sauvegarderVersionPrecedente(true); err != nil {
t.Fatalf("sauvegarde %d : %v", i, err)
}
}
sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json"))
if len(sauvegardes) != 3 { if len(sauvegardes) != 3 {
t.Errorf("sauvegardes = %d, attendu 3 — des copies se sont écrasées", len(sauvegardes)) t.Errorf("sauvegardes = %d, attendu 3 — des copies se sont écrasées", len(sauvegardes))