Add scheduled rotating local backups

This commit is contained in:
Mikhail Chusavitin
2026-02-11 19:48:40 +03:00
parent 7fbf813952
commit a1edca3be9
8 changed files with 559 additions and 22 deletions

View File

@@ -232,6 +232,10 @@ func main() {
syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
go syncWorker.Start(workerCtx)
backupCtx, backupCancel := context.WithCancel(context.Background())
defer backupCancel()
go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath)
srv := &http.Server{
Addr: cfg.Address(),
Handler: router,
@@ -274,6 +278,7 @@ func main() {
// Stop background sync worker first
syncWorker.Stop()
workerCancel()
backupCancel()
// Then shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -324,6 +329,9 @@ func setConfigDefaults(cfg *config.Config) {
if cfg.Pricing.MinQuotesForMedian == 0 {
cfg.Pricing.MinQuotesForMedian = 3
}
if cfg.Backup.Time == "" {
cfg.Backup.Time = "00:00"
}
}
func ensureDefaultConfigFile(configPath string) error {
@@ -347,6 +355,9 @@ func ensureDefaultConfigFile(configPath string) error {
read_timeout: 30s
write_timeout: 30s
backup:
time: "00:00"
logging:
level: "info"
format: "json"
@@ -373,9 +384,14 @@ type runtimeLoggingConfig struct {
Output string `yaml:"output"`
}
type runtimeBackupConfig struct {
Time string `yaml:"time"`
}
type runtimeConfigFile struct {
Server runtimeServerConfig `yaml:"server"`
Logging runtimeLoggingConfig `yaml:"logging"`
Backup runtimeBackupConfig `yaml:"backup"`
}
// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
@@ -398,6 +414,9 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
Format: cfg.Logging.Format,
Output: cfg.Logging.Output,
},
Backup: runtimeBackupConfig{
Time: cfg.Backup.Time,
},
}
rendered, err := yaml.Marshal(&runtimeCfg)
@@ -416,6 +435,69 @@ func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) erro
return nil
}
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
if cfg == nil {
return
}
hour, minute, err := parseBackupTime(cfg.Backup.Time)
if err != nil {
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
hour = 0
minute = 0
}
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
slog.Error("local backup failed", "error", backupErr)
} else if len(created) > 0 {
for _, path := range created {
slog.Info("local backup completed", "archive", path)
}
}
for {
next := nextBackupTime(time.Now(), hour, minute)
timer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
start := time.Now()
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
duration := time.Since(start)
if backupErr != nil {
slog.Error("local backup failed", "error", backupErr, "duration", duration)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path, "duration", duration)
}
}
}
}
}
func parseBackupTime(value string) (int, int, error) {
if strings.TrimSpace(value) == "" {
return 0, 0, fmt.Errorf("empty backup time")
}
parsed, err := time.Parse("15:04", value)
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func nextBackupTime(now time.Time, hour, minute int) time.Time {
location := now.Location()
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
if !now.Before(target) {
target = target.Add(24 * time.Hour)
}
return target
}
// runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) {
restartSig := make(chan struct{}, 1)
@@ -1336,31 +1418,30 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
})
})
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return simplified list of all projects (UUID + Name only)
type ProjectSimple struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
// Return simplified list of all projects (UUID + Name only)
type ProjectSimple struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
simplified := make([]ProjectSimple, 0, len(allProjects))
for _, p := range allProjects {
simplified = append(simplified, ProjectSimple{
UUID: p.UUID,
Name: p.Name,
})
}
simplified := make([]ProjectSimple, 0, len(allProjects))
for _, p := range allProjects {
simplified = append(simplified, ProjectSimple{
UUID: p.UUID,
Name: p.Name,
})
}
c.JSON(http.StatusOK, simplified)
})
c.JSON(http.StatusOK, simplified)
})
projects.POST("", func(c *gin.Context) {
var req services.CreateProjectRequest