From faac28172ef5b541c06bce8e6a21463bceeda542 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Sat, 1 Aug 2026 17:08:26 +0200 Subject: [PATCH] =?UTF-8?q?Sauvegarder=20une=20fois=20par=20s=C3=A9ance=20?= =?UTF-8?q?plut=C3=B4t=20qu'=C3=A0=20chaque=20modification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 18 ++++++++-- docs/decisions.md | 35 ++++++++++++++++++-- main.go | 84 ++++++++++++++++++++++++++++++++++++++++++----- main_test.go | 78 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 201 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index fd26def..4e36838 100644 --- a/README.md +++ b/README.md @@ -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 diff git. -À chaque sauvegarde, le serveur copie la version précédente dans un dossier `backups/` voisin du -planning, horodatée. +**À l'ouverture**, le serveur copie le planning dans un dossier `backups/` voisin, horodaté : c'est +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 que le code. Seul `data/exemple.json` y figure, comme jeu de démonstration. diff --git a/docs/decisions.md b/docs/decisions.md index 9c8dbc4..dd8f00c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 partout, sans sélecteur à réautoriser. -Bénéfice supplémentaire : le serveur peut écrire une sauvegarde horodatée à chaque écriture, ce que -la File System Access API ne permettait pas simplement. +Bénéfice supplémentaire : le serveur peut écrire une sauvegarde horodatée, ce que la File System +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 @@ -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 format du fichier et les routes sont identiques, et son historique reste dans git si le besoin de 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. diff --git a/main.go b/main.go index ee47891..114d55f 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ import ( "runtime" "sort" "strings" + "sync" "syscall" "time" ) @@ -47,6 +48,16 @@ const tailleMax = 5 << 20 // sont supprimées pour que backups/ ne grossisse pas indéfiniment. 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 // un double-clic, l'utilisateur n'a aucun moyen de passer --port. const portInitial = 8000 @@ -57,6 +68,12 @@ var donneesInitiales = []byte(`{"version": 3, "projects": []}`) type serveur struct { fichierDonnees string 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() { @@ -88,6 +105,14 @@ func main() { url := fmt.Sprintf("http://localhost:%d", ecouteur.Addr().(*net.TCPAddr).Port) fmt.Printf("Frise multi-projets -> %s\n", url) 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() @@ -287,11 +312,14 @@ func (s *serveur) ecrireDonnees(w http.ResponseWriter, r *http.Request) { } indente.WriteByte('\n') - if err := s.sauvegarderVersionPrecedente(); err != nil { - erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) - return + s.mu.Lock() + err = s.sauvegarderVersionPrecedente(false) + 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)) return } @@ -308,9 +336,18 @@ func (s *serveur) dossierBackups() string { return filepath.Join(filepath.Dir(s.fichierDonnees), "backups") } -// sauvegarderVersionPrecedente copie le fichier actuel dans backups/ avant de -// l'écraser. -func (s *serveur) sauvegarderVersionPrecedente() error { +// sauvegarderVersionPrecedente copie le fichier actuel dans backups/, si la +// copie apporte quelque chose. +// +// 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) if errors.Is(err, fs.ErrNotExist) { return nil @@ -319,12 +356,19 @@ func (s *serveur) sauvegarderVersionPrecedente() error { 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() if err := os.MkdirAll(dossier, 0o755); err != nil { return err } - base := strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees)) + base := s.baseNom() marqueur := time.Now().Format("20060102-150405") 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 { return err } + s.dernierBackup = time.Now() 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 // qu'une coupure en cours d'écriture ne laisse jamais un planning tronqué. func (s *serveur) ecrireAtomiquement(contenu []byte) error { diff --git a/main_test.go b/main_test.go index 0f474d9..3540c00 100644 --- a/main_test.go +++ b/main_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" "testing/fstest" + "time" ) // 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) 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", fmt.Sprintf(`{"version":3,"projects":[],"n":%d}`, i)) 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")) if len(sauvegardes) != 3 { t.Errorf("sauvegardes = %d, attendu 3 — des copies se sont écrasées", len(sauvegardes))