From 4afe123a76671b054507b894c577f38e0ba8bcd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20=27Kama=C5=9B=27=20Bruchal?= Date: Wed, 17 Jun 2026 20:29:04 +0200 Subject: [PATCH] feat: add in-memory rota Store with atomic JSON persistence --- store.go | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++ store_test.go | 64 +++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 store.go create mode 100644 store_test.go diff --git a/store.go b/store.go new file mode 100644 index 0000000..9083e9a --- /dev/null +++ b/store.go @@ -0,0 +1,111 @@ +package dyzurbot + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// persisted is the on-disk JSON shape of the cache. +type persisted struct { + LastSynced time.Time `json:"last_synced"` + Rota Rota `json:"rota"` +} + +// Store holds the rota in memory and mirrors it to a JSON file on disk. +// All access is guarded so readers (queries, reminders) and the syncer +// can run concurrently. +type Store struct { + mu sync.RWMutex + path string + rota Rota + lastSynced time.Time +} + +// NewStore returns a Store backed by the JSON file at path. +func NewStore(path string) *Store { + return &Store{path: path} +} + +// Load reads the JSON snapshot into memory. A missing file is treated as an +// empty cache (cold start), not an error. +func (s *Store) Load() error { + data, err := os.ReadFile(s.path) // #nosec G304 -- path is operator-controlled config + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read cache %q: %w", s.path, err) + } + + var p persisted + if err := json.Unmarshal(data, &p); err != nil { + return fmt.Errorf("decode cache %q: %w", s.path, err) + } + + s.mu.Lock() + defer s.mu.Unlock() + s.rota = p.Rota + s.lastSynced = p.LastSynced + return nil +} + +// Snapshot returns a copy of the cached rota plus the last successful sync +// time. The copy lets callers read without holding the lock and shields the +// cache from caller-side mutation. +func (s *Store) Snapshot() (Rota, time.Time) { + s.mu.RLock() + defer s.mu.RUnlock() + + weeks := make([]DutyWeek, len(s.rota.Weeks)) + copy(weeks, s.rota.Weeks) + people := make([]Person, len(s.rota.People)) + copy(people, s.rota.People) + return Rota{Weeks: weeks, People: people}, s.lastSynced +} + +// Replace swaps the in-memory state and persists it to disk via a +// temp-file-and-rename so a crash mid-write cannot corrupt the cache. +func (s *Store) Replace(rota Rota, syncedAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + if err := writeJSONAtomic(s.path, persisted{LastSynced: syncedAt, Rota: rota}); err != nil { + return err + } + s.rota = rota + s.lastSynced = syncedAt + return nil +} + +// writeJSONAtomic marshals v and writes it to path atomically. +func writeJSONAtomic(path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("encode cache: %w", err) + } + + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".rota-*.tmp") // #nosec G304 -- dir derived from operator config + if err != nil { + return fmt.Errorf("create temp cache: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // harmless no-op after a successful rename + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp cache: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp cache: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename temp cache: %w", err) + } + return nil +} diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..d41669c --- /dev/null +++ b/store_test.go @@ -0,0 +1,64 @@ +package dyzurbot + +import ( + "path/filepath" + "testing" + "time" +) + +func sampleRota() Rota { + return Rota{ + Weeks: []DutyWeek{{WeekStart: time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC), Person1: "Jan Kowalski", Person2: "Anna Nowak"}}, + People: []Person{{Name: "Piotr Wiśniewski", DutyCount: 1}}, + } +} + +func TestStoreReplaceThenLoadRoundTrips(t *testing.T) { + path := filepath.Join(t.TempDir(), "rota.json") + synced := time.Date(2026, 6, 17, 20, 0, 0, 0, time.UTC) + + s1 := NewStore(path) + if err := s1.Replace(sampleRota(), synced); err != nil { + t.Fatalf("Replace: %v", err) + } + + s2 := NewStore(path) + if err := s2.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + rota, ts := s2.Snapshot() + if !ts.Equal(synced) { + t.Errorf("lastSynced: got %v, want %v", ts, synced) + } + if len(rota.Weeks) != 1 || rota.Weeks[0].Person1 != "Jan Kowalski" { + t.Errorf("weeks not restored: %+v", rota.Weeks) + } + if len(rota.People) != 1 || rota.People[0].DutyCount != 1 { + t.Errorf("people not restored: %+v", rota.People) + } +} + +func TestStoreLoadMissingFileIsNotError(t *testing.T) { + s := NewStore(filepath.Join(t.TempDir(), "does-not-exist.json")) + if err := s.Load(); err != nil { + t.Fatalf("Load of missing 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 TestStoreSnapshotIsACopy(t *testing.T) { + s := NewStore(filepath.Join(t.TempDir(), "rota.json")) + if err := s.Replace(sampleRota(), time.Now()); err != nil { + t.Fatalf("Replace: %v", err) + } + rota, _ := s.Snapshot() + rota.Weeks[0].Person1 = "MUTATED" + + again, _ := s.Snapshot() + if again.Weeks[0].Person1 != "Jan Kowalski" { + t.Errorf("snapshot mutation leaked into store: %q", again.Weeks[0].Person1) + } +}