fix: lint, interval validation, corrupt-cache tolerance, background init sync, concurrent test

This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-06-17 20:45:29 +02:00
parent 7d799c74f1
commit 6c177c9514
5 changed files with 68 additions and 7 deletions

View File

@ -36,6 +36,9 @@ func LoadConfig() (Config, error) {
if err != nil { if err != nil {
return Config{}, fmt.Errorf("invalid SYNC_INTERVAL %q: %w", raw, err) return Config{}, fmt.Errorf("invalid SYNC_INTERVAL %q: %w", raw, err)
} }
if interval <= 0 {
return Config{}, fmt.Errorf("SYNC_INTERVAL must be positive, got %v", interval)
}
cfg.SyncInterval = interval cfg.SyncInterval = interval
return cfg, nil return cfg, nil
@ -62,14 +65,14 @@ func NewService(cfg Config) *Service {
return &Service{Store: store, Syncer: syncer, interval: cfg.SyncInterval} return &Service{Store: store, Syncer: syncer, interval: cfg.SyncInterval}
} }
// Start loads the cached rota, runs one best-effort sync (a failure leaves the // Start loads the cached rota, runs one best-effort sync in the background
// cached/empty data in place — offline-tolerant), then polls in the background // (a failure leaves the cached/empty data in place — offline-tolerant), then
// until ctx is canceled. // polls in the background until ctx is canceled.
func (s *Service) Start(ctx context.Context) error { func (s *Service) Start(ctx context.Context) error {
if err := s.Store.Load(); err != nil { if err := s.Store.Load(); err != nil {
return err return err
} }
_ = s.Syncer.Sync(ctx) go func() { _ = s.Syncer.Sync(ctx) }()
go s.Syncer.Run(ctx, s.interval) go s.Syncer.Run(ctx, s.interval)
return nil return nil
} }

View File

@ -43,3 +43,11 @@ func TestLoadConfigRejectsBadInterval(t *testing.T) {
t.Fatal("expected error for unparseable SYNC_INTERVAL") t.Fatal("expected error for unparseable SYNC_INTERVAL")
} }
} }
func TestLoadConfigRejectsNonPositiveInterval(t *testing.T) {
t.Setenv("SPREADSHEET_ID", "sheet-123")
t.Setenv("SYNC_INTERVAL", "0s")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected error for non-positive SYNC_INTERVAL")
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@ -44,7 +45,8 @@ func (s *Store) Load() error {
var p persisted var p persisted
if err := json.Unmarshal(data, &p); err != nil { if err := json.Unmarshal(data, &p); err != nil {
return fmt.Errorf("decode cache %q: %w", s.path, err) log.Printf("load cache %q: corrupt, ignoring: %v", s.path, err)
return nil
} }
s.mu.Lock() s.mu.Lock()
@ -95,7 +97,7 @@ func writeJSONAtomic(path string, v any) error {
return fmt.Errorf("create temp cache: %w", err) return fmt.Errorf("create temp cache: %w", err)
} }
tmpName := tmp.Name() tmpName := tmp.Name()
defer os.Remove(tmpName) // harmless no-op after a successful rename defer func() { _ = os.Remove(tmpName) }() // harmless no-op after a successful rename
if _, err := tmp.Write(data); err != nil { if _, err := tmp.Write(data); err != nil {
_ = tmp.Close() _ = tmp.Close()

View File

@ -1,7 +1,9 @@
package dyzurbot package dyzurbot
import ( import (
"os"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
"time" "time"
) )
@ -62,3 +64,49 @@ func TestStoreSnapshotIsACopy(t *testing.T) {
t.Errorf("snapshot mutation leaked into store: %q", again.Weeks[0].Person1) t.Errorf("snapshot mutation leaked into store: %q", again.Weeks[0].Person1)
} }
} }
func TestStoreLoadCorruptFileIsNotError(t *testing.T) {
path := filepath.Join(t.TempDir(), "corrupt.json")
if err := os.WriteFile(path, []byte("{not valid json"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s := NewStore(path)
if err := s.Load(); err != nil {
t.Fatalf("Load of corrupt file should be nil, got %v", err)
}
rota, ts := s.Snapshot()
if len(rota.Weeks) != 0 || len(rota.People) != 0 || !ts.IsZero() {
t.Errorf("empty store expected, got weeks=%d people=%d ts=%v", len(rota.Weeks), len(rota.People), ts)
}
}
func TestStoreConcurrentSnapshotAndReplace(t *testing.T) {
path := filepath.Join(t.TempDir(), "rota.json")
s := NewStore(path)
var wg sync.WaitGroup
// Launch 2 goroutines repeatedly calling Replace
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
_ = s.Replace(sampleRota(), time.Now())
}
}()
}
// Launch 2 goroutines repeatedly calling Snapshot
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
_, _ = s.Snapshot()
}
}()
}
wg.Wait()
}

View File

@ -33,7 +33,7 @@ func (sy *Syncer) Sync(ctx context.Context) error {
return sy.store.Replace(rota, sy.now()) return sy.store.Replace(rota, sy.now())
} }
// Run polls Sync on the given interval until ctx is cancelled. // Run polls Sync on the given interval until ctx is canceled.
func (sy *Syncer) Run(ctx context.Context, interval time.Duration) { func (sy *Syncer) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()