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:
84
main.go
84
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 {
|
||||
|
||||
Reference in New Issue
Block a user