diff --git a/service.go b/service.go index f611c02..e3c492d 100644 --- a/service.go +++ b/service.go @@ -36,6 +36,9 @@ func LoadConfig() (Config, error) { if err != nil { 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 return cfg, nil @@ -62,14 +65,14 @@ func NewService(cfg Config) *Service { return &Service{Store: store, Syncer: syncer, interval: cfg.SyncInterval} } -// Start loads the cached rota, runs one best-effort sync (a failure leaves the -// cached/empty data in place — offline-tolerant), then polls in the background -// until ctx is canceled. +// Start loads the cached rota, runs one best-effort sync in the background +// (a failure leaves the cached/empty data in place — offline-tolerant), then +// polls in the background until ctx is canceled. func (s *Service) Start(ctx context.Context) error { if err := s.Store.Load(); err != nil { return err } - _ = s.Syncer.Sync(ctx) + go func() { _ = s.Syncer.Sync(ctx) }() go s.Syncer.Run(ctx, s.interval) return nil } diff --git a/service_test.go b/service_test.go index 32df135..587bd7d 100644 --- a/service_test.go +++ b/service_test.go @@ -43,3 +43,11 @@ func TestLoadConfigRejectsBadInterval(t *testing.T) { 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") + } +} diff --git a/store.go b/store.go index 9083e9a..88b14bd 100644 --- a/store.go +++ b/store.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "path/filepath" "sync" @@ -44,7 +45,8 @@ func (s *Store) Load() error { var p persisted 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() @@ -95,7 +97,7 @@ func writeJSONAtomic(path string, v any) error { return fmt.Errorf("create temp cache: %w", err) } 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 { _ = tmp.Close() diff --git a/store_test.go b/store_test.go index d41669c..326b7f3 100644 --- a/store_test.go +++ b/store_test.go @@ -1,7 +1,9 @@ package dyzurbot import ( + "os" "path/filepath" + "sync" "testing" "time" ) @@ -62,3 +64,49 @@ func TestStoreSnapshotIsACopy(t *testing.T) { 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() +} diff --git a/sync.go b/sync.go index 2b31555..ae40ced 100644 --- a/sync.go +++ b/sync.go @@ -33,7 +33,7 @@ func (sy *Syncer) Sync(ctx context.Context) error { 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) { ticker := time.NewTicker(interval) defer ticker.Stop()