fix: lint, interval validation, corrupt-cache tolerance, background init sync, concurrent test
This commit is contained in:
parent
7d799c74f1
commit
6c177c9514
11
service.go
11
service.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
6
store.go
6
store.go
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
2
sync.go
2
sync.go
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue