Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: automatic db backup

+410
+25
cmd/server/main.go
··· 16 16 "time" 17 17 18 18 "arabica/internal/atproto" 19 + "arabica/internal/backup" 19 20 "arabica/internal/logging" 20 21 "arabica/internal/database/boltstore" 21 22 "arabica/internal/database/sqlitestore" ··· 393 394 } 394 395 } 395 396 }() 397 + 398 + // Initialize automated backups 399 + backupDir := os.Getenv("ARABICA_BACKUP_DIR") 400 + if backupDir == "" { 401 + dataDir := os.Getenv("XDG_DATA_HOME") 402 + if dataDir == "" { 403 + home, _ := os.UserHomeDir() 404 + dataDir = filepath.Join(home, ".local", "share") 405 + } 406 + backupDir = filepath.Join(dataDir, "arabica", "backups") 407 + } 408 + backupDest, err := backup.NewLocalDestination(backupDir) 409 + if err != nil { 410 + log.Warn().Err(err).Msg("Failed to create backup destination, backups disabled") 411 + } else { 412 + backupSvc := backup.NewService(backup.Config{ 413 + ScheduleHour: 11, // 11:00 UTC = 3:00 AM PST 414 + Retain: 2, 415 + Dest: backupDest, 416 + }) 417 + backupSvc.AddSource(backup.NewSQLiteSource("feed-index", feedIndex.DB())) 418 + backupSvc.Start(ctx) 419 + log.Info().Str("dir", backupDir).Msg("Automated backups enabled") 420 + } 396 421 397 422 // Initialize join request handling 398 423 smtpPort := 587
+230
internal/backup/backup.go
··· 1 + package backup 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "sort" 10 + "strings" 11 + "time" 12 + 13 + "github.com/rs/zerolog/log" 14 + ) 15 + 16 + // Source represents a database that can be backed up. 17 + type Source interface { 18 + // Name returns a short identifier used in backup filenames (e.g. "feed-index"). 19 + Name() string 20 + // Backup creates a consistent snapshot at the given file path. 21 + Backup(ctx context.Context, destPath string) error 22 + } 23 + 24 + // Destination controls where backups are stored and how old ones are pruned. 25 + type Destination interface { 26 + // Write stores the backup from srcPath. The key is the relative filename. 27 + Write(ctx context.Context, key string, srcPath string) error 28 + // List returns keys matching the given prefix, newest first. 29 + List(ctx context.Context, prefix string) ([]string, error) 30 + // Delete removes a backup by key. 31 + Delete(ctx context.Context, key string) error 32 + } 33 + 34 + // Config holds backup service configuration. 35 + type Config struct { 36 + // ScheduleHour is the hour (0-23) in UTC to run the daily backup. 37 + // Default: 11 (11:00 UTC = 3:00 AM PST). 38 + ScheduleHour int 39 + // Retain is the number of backups to keep per source (default: 2). 40 + Retain int 41 + // Destination for storing backups. 42 + Dest Destination 43 + } 44 + 45 + // Service manages periodic backups of registered sources. 46 + type Service struct { 47 + config Config 48 + sources []Source 49 + } 50 + 51 + // NewService creates a backup service with the given config. 52 + func NewService(cfg Config) *Service { 53 + if cfg.Retain == 0 { 54 + cfg.Retain = 2 55 + } 56 + return &Service{ 57 + config: cfg, 58 + } 59 + } 60 + 61 + // AddSource registers a database source for backup. 62 + func (s *Service) AddSource(src Source) { 63 + s.sources = append(s.sources, src) 64 + } 65 + 66 + // Start runs an initial backup after a short delay, then schedules daily 67 + // backups at the configured hour (UTC). 68 + func (s *Service) Start(ctx context.Context) { 69 + go func() { 70 + // Short delay on startup to let the app stabilize, then run immediately. 71 + select { 72 + case <-time.After(1 * time.Minute): 73 + case <-ctx.Done(): 74 + return 75 + } 76 + 77 + s.runAll(ctx) 78 + 79 + // Sleep until the next scheduled hour, then repeat daily. 80 + for { 81 + next := nextOccurrence(time.Now().UTC(), s.config.ScheduleHour) 82 + delay := time.Until(next) 83 + log.Debug().Time("next_backup", next).Str("delay", delay.String()).Msg("Scheduled next backup") 84 + 85 + select { 86 + case <-time.After(delay): 87 + s.runAll(ctx) 88 + case <-ctx.Done(): 89 + return 90 + } 91 + } 92 + }() 93 + } 94 + 95 + // nextOccurrence returns the next time.Time at the given hour (UTC). 96 + // If the hour hasn't passed today, it returns today at that hour; 97 + // otherwise it returns tomorrow at that hour. 98 + func nextOccurrence(now time.Time, hour int) time.Time { 99 + today := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC) 100 + if today.After(now) { 101 + return today 102 + } 103 + return today.Add(24 * time.Hour) 104 + } 105 + 106 + func (s *Service) runAll(ctx context.Context) { 107 + for _, src := range s.sources { 108 + if err := s.backupSource(ctx, src); err != nil { 109 + log.Error().Err(err).Str("source", src.Name()).Msg("Backup failed") 110 + } else { 111 + log.Info().Str("source", src.Name()).Msg("Backup completed") 112 + } 113 + } 114 + } 115 + 116 + func (s *Service) backupSource(ctx context.Context, src Source) error { 117 + timestamp := time.Now().UTC().Format("20060102-150405") 118 + filename := fmt.Sprintf("%s-%s.bak", src.Name(), timestamp) 119 + 120 + // Write to a temp file first, then hand off to the destination. 121 + tmpDir := os.TempDir() 122 + tmpPath := filepath.Join(tmpDir, filename) 123 + defer os.Remove(tmpPath) 124 + 125 + if err := src.Backup(ctx, tmpPath); err != nil { 126 + return fmt.Errorf("creating backup: %w", err) 127 + } 128 + 129 + if err := s.config.Dest.Write(ctx, filename, tmpPath); err != nil { 130 + return fmt.Errorf("writing backup: %w", err) 131 + } 132 + 133 + // Prune old backups. 134 + prefix := src.Name() + "-" 135 + keys, err := s.config.Dest.List(ctx, prefix) 136 + if err != nil { 137 + log.Warn().Err(err).Str("source", src.Name()).Msg("Failed to list backups for pruning") 138 + return nil // backup itself succeeded 139 + } 140 + if len(keys) > s.config.Retain { 141 + for _, key := range keys[s.config.Retain:] { 142 + if err := s.config.Dest.Delete(ctx, key); err != nil { 143 + log.Warn().Err(err).Str("key", key).Msg("Failed to delete old backup") 144 + } else { 145 + log.Info().Str("key", key).Msg("Pruned old backup") 146 + } 147 + } 148 + } 149 + 150 + return nil 151 + } 152 + 153 + // SQLiteSource backs up a SQLite database using VACUUM INTO. 154 + type SQLiteSource struct { 155 + name string 156 + db *sql.DB 157 + } 158 + 159 + // NewSQLiteSource creates a backup source for a SQLite database. 160 + func NewSQLiteSource(name string, db *sql.DB) *SQLiteSource { 161 + return &SQLiteSource{name: name, db: db} 162 + } 163 + 164 + func (s *SQLiteSource) Name() string { return s.name } 165 + 166 + func (s *SQLiteSource) Backup(ctx context.Context, destPath string) error { 167 + _, err := s.db.ExecContext(ctx, "VACUUM INTO ?", destPath) 168 + if err != nil { 169 + return fmt.Errorf("VACUUM INTO: %w", err) 170 + } 171 + return nil 172 + } 173 + 174 + // LocalDestination stores backups in a local directory. 175 + type LocalDestination struct { 176 + dir string 177 + } 178 + 179 + // NewLocalDestination creates a destination that writes to a local directory. 180 + func NewLocalDestination(dir string) (*LocalDestination, error) { 181 + if err := os.MkdirAll(dir, 0755); err != nil { 182 + return nil, fmt.Errorf("creating backup directory: %w", err) 183 + } 184 + return &LocalDestination{dir: dir}, nil 185 + } 186 + 187 + func (d *LocalDestination) Write(_ context.Context, key string, srcPath string) error { 188 + destPath := filepath.Join(d.dir, key) 189 + 190 + src, err := os.Open(srcPath) 191 + if err != nil { 192 + return err 193 + } 194 + defer src.Close() 195 + 196 + dst, err := os.Create(destPath) 197 + if err != nil { 198 + return err 199 + } 200 + defer dst.Close() 201 + 202 + // Use io.Copy via ReadFrom for efficiency. 203 + if _, err := dst.ReadFrom(src); err != nil { 204 + os.Remove(destPath) 205 + return err 206 + } 207 + return dst.Close() 208 + } 209 + 210 + func (d *LocalDestination) List(_ context.Context, prefix string) ([]string, error) { 211 + entries, err := os.ReadDir(d.dir) 212 + if err != nil { 213 + return nil, err 214 + } 215 + 216 + var keys []string 217 + for _, e := range entries { 218 + if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) { 219 + keys = append(keys, e.Name()) 220 + } 221 + } 222 + 223 + // Sort descending (newest first) — timestamp in filename makes this work. 224 + sort.Sort(sort.Reverse(sort.StringSlice(keys))) 225 + return keys, nil 226 + } 227 + 228 + func (d *LocalDestination) Delete(_ context.Context, key string) error { 229 + return os.Remove(filepath.Join(d.dir, key)) 230 + }
+155
internal/backup/backup_test.go
··· 1 + package backup 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "os" 7 + "path/filepath" 8 + "testing" 9 + "time" 10 + 11 + _ "modernc.org/sqlite" 12 + "github.com/stretchr/testify/assert" 13 + "github.com/stretchr/testify/require" 14 + ) 15 + 16 + func TestSQLiteBackup(t *testing.T) { 17 + // Create a temp SQLite DB with some data. 18 + dbPath := filepath.Join(t.TempDir(), "test.db") 19 + db, err := sql.Open("sqlite", dbPath) 20 + require.NoError(t, err) 21 + defer db.Close() 22 + 23 + _, err = db.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)") 24 + require.NoError(t, err) 25 + _, err = db.Exec("INSERT INTO t (val) VALUES ('hello'), ('world')") 26 + require.NoError(t, err) 27 + 28 + // Backup via VACUUM INTO. 29 + src := NewSQLiteSource("test-db", db) 30 + assert.Equal(t, "test-db", src.Name()) 31 + 32 + backupPath := filepath.Join(t.TempDir(), "backup.db") 33 + err = src.Backup(context.Background(), backupPath) 34 + require.NoError(t, err) 35 + 36 + // Verify the backup is a valid SQLite DB with the data. 37 + backupDB, err := sql.Open("sqlite", backupPath) 38 + require.NoError(t, err) 39 + defer backupDB.Close() 40 + 41 + var count int 42 + err = backupDB.QueryRow("SELECT count(*) FROM t").Scan(&count) 43 + require.NoError(t, err) 44 + assert.Equal(t, 2, count) 45 + } 46 + 47 + func TestLocalDestinationRetention(t *testing.T) { 48 + dir := t.TempDir() 49 + dest, err := NewLocalDestination(dir) 50 + require.NoError(t, err) 51 + 52 + ctx := context.Background() 53 + 54 + // Create 4 fake backup files. 55 + for _, name := range []string{ 56 + "db-20260101-000000.bak", 57 + "db-20260102-000000.bak", 58 + "db-20260103-000000.bak", 59 + "db-20260104-000000.bak", 60 + } { 61 + tmpFile := filepath.Join(t.TempDir(), name) 62 + require.NoError(t, os.WriteFile(tmpFile, []byte("data"), 0644)) 63 + require.NoError(t, dest.Write(ctx, name, tmpFile)) 64 + } 65 + 66 + // List should return newest first. 67 + keys, err := dest.List(ctx, "db-") 68 + require.NoError(t, err) 69 + assert.Equal(t, []string{ 70 + "db-20260104-000000.bak", 71 + "db-20260103-000000.bak", 72 + "db-20260102-000000.bak", 73 + "db-20260101-000000.bak", 74 + }, keys) 75 + 76 + // Delete oldest two. 77 + require.NoError(t, dest.Delete(ctx, "db-20260101-000000.bak")) 78 + require.NoError(t, dest.Delete(ctx, "db-20260102-000000.bak")) 79 + 80 + keys, err = dest.List(ctx, "db-") 81 + require.NoError(t, err) 82 + assert.Len(t, keys, 2) 83 + } 84 + 85 + func TestServicePrunesOldBackups(t *testing.T) { 86 + // Set up a real SQLite DB. 87 + dbPath := filepath.Join(t.TempDir(), "test.db") 88 + db, err := sql.Open("sqlite", dbPath) 89 + require.NoError(t, err) 90 + defer db.Close() 91 + _, err = db.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)") 92 + require.NoError(t, err) 93 + 94 + backupDir := t.TempDir() 95 + dest, err := NewLocalDestination(backupDir) 96 + require.NoError(t, err) 97 + 98 + ctx := context.Background() 99 + 100 + // Pre-seed 3 old backups. 101 + for _, name := range []string{ 102 + "test-20260101-000000.bak", 103 + "test-20260102-000000.bak", 104 + "test-20260103-000000.bak", 105 + } { 106 + require.NoError(t, os.WriteFile(filepath.Join(backupDir, name), []byte("old"), 0644)) 107 + } 108 + 109 + svc := NewService(Config{ 110 + Retain: 2, 111 + Dest: dest, 112 + }) 113 + svc.AddSource(NewSQLiteSource("test", db)) 114 + 115 + // One new backup run — should create 1 new + prune old to keep only 2 total. 116 + svc.runAll(ctx) 117 + 118 + keys, err := dest.List(ctx, "test-") 119 + require.NoError(t, err) 120 + assert.Equal(t, 2, len(keys)) 121 + } 122 + 123 + func TestNextOccurrence(t *testing.T) { 124 + tests := []struct { 125 + name string 126 + now time.Time 127 + hour int 128 + expected time.Time 129 + }{ 130 + { 131 + name: "hour hasn't passed yet today", 132 + now: time.Date(2026, 3, 31, 8, 0, 0, 0, time.UTC), 133 + hour: 11, 134 + expected: time.Date(2026, 3, 31, 11, 0, 0, 0, time.UTC), 135 + }, 136 + { 137 + name: "hour already passed today", 138 + now: time.Date(2026, 3, 31, 14, 0, 0, 0, time.UTC), 139 + hour: 11, 140 + expected: time.Date(2026, 4, 1, 11, 0, 0, 0, time.UTC), 141 + }, 142 + { 143 + name: "exactly at the scheduled hour", 144 + now: time.Date(2026, 3, 31, 11, 0, 0, 0, time.UTC), 145 + hour: 11, 146 + expected: time.Date(2026, 4, 1, 11, 0, 0, 0, time.UTC), 147 + }, 148 + } 149 + for _, tt := range tests { 150 + t.Run(tt.name, func(t *testing.T) { 151 + result := nextOccurrence(tt.now, tt.hour) 152 + assert.Equal(t, tt.expected, result) 153 + }) 154 + } 155 + }