From 2723cafd4d4bcf2d1366ec621859cf8f848bd5c5 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:42:56 -0400 Subject: [PATCH 1/3] feat(loa): rework LOA cache into history store (#158) Multi-window per forum username, deduped by ThreadID, replacing the single-overwrite entry model. Retention replaces prune-on-expiry: ended windows survive until EndDate predates a shared 1-year horizon (loaRetentionYears), so warm and cold-started caches converge on the identical window set. Add GetEntries(username) returning all retained windows. GetEntry, IsOnLOA, IsHealthy keep their contracts via mostRelevant selection, so /awol and /loa render identically. Prune tests updated to retention semantics; dedup/multi-window/retention covered via fakeLOAFetcher. Co-Authored-By: Claude Opus 4.8 (1M context) --- utils/loa.go | 161 ++++++++++++++++++++++++++---- utils/loa_test.go | 243 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 323 insertions(+), 81 deletions(-) diff --git a/utils/loa.go b/utils/loa.go index 4e105b1..01739f8 100644 --- a/utils/loa.go +++ b/utils/loa.go @@ -43,23 +43,95 @@ type LOAEntry struct { ThreadID int64 } +// loaRetentionYears is the single shared horizon for both the cold-start backfill +// lookback and retention pruning. The cold cache backfills posts from this far +// back, and a window is retained until its EndDate is older than this same +// horizon — so a warm cache and a freshly cold-started cache converge on the +// identical retained window set (ADR 0008). +const loaRetentionYears = 1 + +// historyHorizon is the cutoff instant `loaRetentionYears` before `now`: the +// cold-start backfill lower bound and the retention prune cutoff. A window whose +// EndDate is before this instant is dropped; the cold backfill ignores posts +// older than it. Both callers go through this one helper so the two can't drift. +func historyHorizon(now time.Time) time.Time { + return now.AddDate(-loaRetentionYears, 0, 0) +} + type LOACache struct { mu sync.RWMutex - entries map[string]LOAEntry + entries map[string][]LOAEntry lastSyncedPostDate int64 lastSuccessfulRefresh time.Time // zero == never } var GlobalLOACache = &LOACache{ - entries: make(map[string]LOAEntry), + entries: make(map[string][]LOAEntry), } -// GetEntry returns the LOA entry for a username if one exists. +// mostRelevant picks the single window to surface for the legacy GetEntry +// contract: an active window (latest-ending if several) wins; otherwise the +// soonest upcoming window; otherwise the most recently ended. This preserves the +// pre-history-store behavior of the [[LOA]] link in /awol and the active/upcoming +// split in /loa now that a username can hold multiple windows. +func mostRelevant(windows []LOAEntry, now time.Time) (LOAEntry, bool) { + var ( + best LOAEntry + found bool + state int // 0 none, 1 ended, 2 upcoming, 3 active + ) + for _, w := range windows { + var ( + s int + tie time.Time + ) + switch { + case w.isActiveAt(now): + s, tie = 3, w.EndDate // latest-ending active wins + case now.Before(w.StartDate): + s, tie = 2, w.StartDate // soonest upcoming wins (earliest start) + default: + s, tie = 1, w.EndDate // most recently ended wins (latest end) + } + if !found || s > state { + best, state, found = w, s, true + continue + } + if s != state { + continue + } + switch s { + case 2: // upcoming: prefer earliest start + if tie.Before(best.StartDate) { + best = w + } + default: // active or ended: prefer latest end + if tie.After(best.EndDate) { + best = w + } + } + } + return best, found +} + +// GetEntry returns the most-relevant retained LOA window for a username if one +// exists (see mostRelevant). Preserved unchanged for /awol's [[LOA]] link and +// /loa's active/upcoming rendering; GetEntries exposes the full history. func (c *LOACache) GetEntry(username string) (LOAEntry, bool) { c.mu.RLock() defer c.mu.RUnlock() - e, ok := c.entries[strings.ToLower(username)] - return e, ok + return mostRelevant(c.entries[strings.ToLower(username)], time.Now()) +} + +// GetEntries returns all retained LOA windows for a username, or an empty slice +// when none. The returned slice is a copy — callers may not mutate cache state. +func (c *LOACache) GetEntries(username string) []LOAEntry { + c.mu.RLock() + defer c.mu.RUnlock() + windows := c.entries[strings.ToLower(username)] + out := make([]LOAEntry, len(windows)) + copy(out, windows) + return out } // hasEnded reports whether the entry's EndDate is strictly before `now`. It is @@ -83,22 +155,35 @@ func (e LOAEntry) isActiveAt(now time.Time) bool { } // isExpiredAt reports whether the entry's LOA window has ended strictly before -// `now` — the prune cutoff. It is the strict complement of isActiveAt's inclusive -// upper bound (both via hasEnded): an entry on its EndDate (End==now) is NOT yet -// expired and survives a prune cycle; one whose EndDate is already past is pruned. +// `now`. It is the strict complement of isActiveAt's inclusive upper bound (both +// via hasEnded): an entry on its EndDate (End==now) is NOT yet expired; one whose +// EndDate is already past has expired. Ended windows are no longer pruned on +// expiry — they are retained until isRetired (see historyHorizon). func (e LOAEntry) isExpiredAt(now time.Time) bool { return e.hasEnded(now) } -// IsOnLOA returns true if the username has a currently active LOA. +// isRetired reports whether the window's EndDate is older than the retention +// horizon (loaRetentionYears before now) and so should be dropped from the +// history store. This is the retention cutoff that replaces prune-on-expiry: a +// recently-ended window is kept, one whose EndDate predates the horizon is gone. +// Shares historyHorizon with the cold-start backfill so warm and cold caches +// converge on the identical window set. +func (e LOAEntry) isRetired(now time.Time) bool { + return e.EndDate.Before(historyHorizon(now)) +} + +// IsOnLOA returns true if the username has any currently active LOA window. func (c *LOACache) IsOnLOA(username string) bool { c.mu.RLock() defer c.mu.RUnlock() - entry, ok := c.entries[strings.ToLower(username)] - if !ok { - return false + now := time.Now() + for _, w := range c.entries[strings.ToLower(username)] { + if w.isActiveAt(now) { + return true + } } - return entry.isActiveAt(time.Now()) + return false } // IsHealthy reports whether the cache has been successfully refreshed within @@ -185,18 +270,29 @@ func (c *LOACache) refresh(fetcher loaPostFetcher, nodeIDs []int) { since := c.lastSyncedPostDate c.mu.RUnlock() + now := time.Now() if since == 0 { - since = time.Now().AddDate(-1, 0, 0).Unix() + // Cold start: backfill from the shared retention horizon so the cold cache + // holds the same window set a warm one would have retained. + since = historyHorizon(now).Unix() } c.mu.Lock() defer c.mu.Unlock() - // Prune entries whose LOA has ended. - now := time.Now() - for k, e := range c.entries { - if e.isExpiredAt(now) { + // Retention prune: drop only windows whose EndDate predates the horizon. Ended + // (but recent) windows are kept as history for the accountable-day calc (#159). + for k, windows := range c.entries { + kept := windows[:0] + for _, w := range windows { + if !w.isRetired(now) { + kept = append(kept, w) + } + } + if len(kept) == 0 { delete(c.entries, k) + } else { + c.entries[k] = kept } } @@ -219,7 +315,7 @@ func (c *LOACache) refresh(fetcher loaPostFetcher, nodeIDs []int) { continue } entry.ThreadID = p.threadID - c.entries[strings.ToLower(entry.Username)] = entry + c.upsertWindow(entry) if p.postDate > maxPostDate { maxPostDate = p.postDate } @@ -233,16 +329,39 @@ func (c *LOACache) refresh(fetcher loaPostFetcher, nodeIDs []int) { c.lastSyncedPostDate = maxPostDate if successfulNodes > 0 { - c.lastSuccessfulRefresh = time.Now() + c.lastSuccessfulRefresh = now + } + + totalWindows := 0 + for _, w := range c.entries { + totalWindows += len(w) } Info("LOA cache refreshed", "new_parsed", totalParsed, - "total_active", len(c.entries), + "total_windows", totalWindows, + "total_users", len(c.entries), "successful_nodes", successfulNodes, "total_nodes", len(nodeIDs), ) } +// upsertWindow adds a parsed window to the per-username history, deduping by +// ThreadID: one forum thread is exactly one LOA, so re-scanning the same thread +// (cold-restart backfill or overlapping incremental fetch) updates the existing +// window in place rather than appending a duplicate. Caller holds c.mu. +func (c *LOACache) upsertWindow(entry LOAEntry) { + key := strings.ToLower(entry.Username) + windows := c.entries[key] + for i := range windows { + if windows[i].ThreadID == entry.ThreadID { + windows[i] = entry + c.entries[key] = windows + return + } + } + c.entries[key] = append(windows, entry) +} + func parseLOAPost(msg string) (LOAEntry, bool) { // Strip formatting-only BBCode first so label/value matching is agnostic to the // post's bold/color/size wrapping (the source of historical silent failures). diff --git a/utils/loa_test.go b/utils/loa_test.go index 72d0c06..ac25453 100644 --- a/utils/loa_test.go +++ b/utils/loa_test.go @@ -235,7 +235,7 @@ func (f *fakeLOAFetcher) fetchLOAPosts(nodeID int, since int64) ([]loaPostRow, e } func TestRefresh_IncrementalAdvance(t *testing.T) { - c := &LOACache{entries: map[string]LOAEntry{}} + c := &LOACache{entries: map[string][]LOAEntry{}} // First refresh: cold cache → since should be ~one year ago (non-zero), and // lastSyncedPostDate should advance to the max post_date observed. @@ -272,27 +272,146 @@ func TestRefresh_IncrementalAdvance(t *testing.T) { } } -func TestRefresh_PrunesExpiredEntries(t *testing.T) { - c := &LOACache{entries: map[string]LOAEntry{}} - // Seed: one expired (EndDate in the past) and one still-active entry. - c.entries["expired"] = LOAEntry{Username: "expired", StartDate: time.Now().Add(-72 * time.Hour), EndDate: time.Now().Add(-24 * time.Hour)} - c.entries["active"] = LOAEntry{Username: "active", StartDate: time.Now().Add(-24 * time.Hour), EndDate: time.Now().Add(24 * time.Hour)} +// TestRefresh_RetainsEndedWindows replaces the old prune-on-expiry test: an ended +// (recently) window is now RETAINED as history, and only a window past the +// retention horizon is dropped. A still-active window survives regardless. +func TestRefresh_RetainsEndedWindows(t *testing.T) { + c := &LOACache{entries: map[string][]LOAEntry{}} + // recentlyEnded: EndDate in the past but well within the retention horizon. + c.entries["recentlyended"] = []LOAEntry{{Username: "recentlyEnded", StartDate: time.Now().Add(-72 * time.Hour), EndDate: time.Now().Add(-24 * time.Hour), ThreadID: 1}} + // retired: EndDate older than the retention horizon (~1y). + c.entries["retired"] = []LOAEntry{{Username: "retired", StartDate: time.Now().AddDate(-1, 0, -10), EndDate: time.Now().AddDate(-1, 0, -5), ThreadID: 2}} + // active: currently within its window. + c.entries["active"] = []LOAEntry{{Username: "active", StartDate: time.Now().Add(-24 * time.Hour), EndDate: time.Now().Add(24 * time.Hour), ThreadID: 3}} c.lastSyncedPostDate = 100 // warm cache so since is deterministic f := &fakeLOAFetcher{byNode: map[int][]loaPostRow{180: nil}} c.refresh(f, []int{180}) - if _, ok := c.GetEntry("expired"); ok { - t.Errorf("expired entry should have been pruned") + if got := c.GetEntries("recentlyended"); len(got) != 1 { + t.Errorf("recently-ended window must be RETAINED as history, got %d windows", len(got)) } - if _, ok := c.GetEntry("active"); !ok { - t.Errorf("active entry must survive prune") + if got := c.GetEntries("retired"); len(got) != 0 { + t.Errorf("window past the retention horizon must be dropped, got %d windows", len(got)) + } + if got := c.GetEntries("active"); len(got) != 1 { + t.Errorf("active window must survive, got %d windows", len(got)) + } +} + +// TestRefresh_MultiWindowPerUser proves a username can hold multiple simultaneous +// windows and that refresh appends rather than overwrites. +func TestRefresh_MultiWindowPerUser(t *testing.T) { + c := &LOACache{entries: map[string][]LOAEntry{}} + + f1 := &fakeLOAFetcher{byNode: map[int][]loaPostRow{ + 180: {{message: loaPost("echo", "Jan 1, 2099", "Jan 31, 2099"), postDate: time.Now().Add(-48 * time.Hour).Unix(), threadID: 100}}, + }} + c.refresh(f1, []int{180}) + + f2 := &fakeLOAFetcher{byNode: map[int][]loaPostRow{ + 180: {{message: loaPost("echo", "Mar 1, 2099", "Mar 31, 2099"), postDate: time.Now().Add(-1 * time.Hour).Unix(), threadID: 200}}, + }} + c.refresh(f2, []int{180}) + + got := c.GetEntries("echo") + if len(got) != 2 { + t.Fatalf("expected 2 retained windows for echo, got %d (%+v)", len(got), got) + } + threads := map[int64]bool{} + for _, w := range got { + threads[w.ThreadID] = true + } + if !threads[100] || !threads[200] { + t.Errorf("expected both thread 100 and 200 retained, got %+v", threads) + } +} + +// TestRefresh_DedupesByThreadID proves re-scanning the same thread (cold-restart +// backfill or overlapping fetch) updates the window in place instead of +// duplicating it. +func TestRefresh_DedupesByThreadID(t *testing.T) { + c := &LOACache{entries: map[string][]LOAEntry{}} + + post := loaPostRow{message: loaPost("foxtrot", "Jan 1, 2099", "Jan 31, 2099"), postDate: time.Now().Add(-48 * time.Hour).Unix(), threadID: 777} + f1 := &fakeLOAFetcher{byNode: map[int][]loaPostRow{180: {post}}} + c.refresh(f1, []int{180}) + + // Re-scan the SAME thread with an edited window (same threadID, later end). + post2 := loaPostRow{message: loaPost("foxtrot", "Jan 1, 2099", "Feb 15, 2099"), postDate: time.Now().Add(-1 * time.Hour).Unix(), threadID: 777} + f2 := &fakeLOAFetcher{byNode: map[int][]loaPostRow{180: {post2}}} + c.refresh(f2, []int{180}) + + got := c.GetEntries("foxtrot") + if len(got) != 1 { + t.Fatalf("re-scanning thread 777 must not duplicate; got %d windows (%+v)", len(got), got) + } + if got[0].EndDate.Format("2006-01-02") != "2099-02-15" { + t.Errorf("dedup must update in place: EndDate = %s, want 2099-02-15", got[0].EndDate.Format("2006-01-02")) + } +} + +// TestGetEntries_EmptyWhenNone pins the empty-slice (not nil-panicking) contract +// for an unknown username. +func TestGetEntries_EmptyWhenNone(t *testing.T) { + c := &LOACache{entries: map[string][]LOAEntry{}} + got := c.GetEntries("ghost") + if got == nil { + t.Fatalf("GetEntries must return a non-nil empty slice for unknown user") + } + if len(got) != 0 { + t.Errorf("GetEntries for unknown user = %d windows, want 0", len(got)) + } +} + +// TestGetEntry_MostRelevantWindow pins the most-relevant selection used by the +// legacy GetEntry contract (active > upcoming > most-recently-ended) so /awol's +// [[LOA]] link and /loa's active/upcoming split keep rendering identically with +// multiple windows present. +func TestGetEntry_MostRelevantWindow(t *testing.T) { + now := time.Now() + c := &LOACache{entries: map[string][]LOAEntry{}} + + // User with an ended, an active, and an upcoming window — active must win. + c.entries["mix"] = []LOAEntry{ + {Username: "mix", StartDate: now.AddDate(0, 0, -20), EndDate: now.AddDate(0, 0, -10), ThreadID: 1}, // ended + {Username: "mix", StartDate: now.AddDate(0, 0, -2), EndDate: now.AddDate(0, 0, 2), ThreadID: 2}, // active + {Username: "mix", StartDate: now.AddDate(0, 0, 10), EndDate: now.AddDate(0, 0, 20), ThreadID: 3}, // upcoming + } + got, ok := c.GetEntry("mix") + if !ok || got.ThreadID != 2 { + t.Errorf("GetEntry must surface the active window (thread 2), got %+v ok=%v", got, ok) + } + + // User with only ended + upcoming — upcoming (soonest start) must win. + c.entries["future"] = []LOAEntry{ + {Username: "future", StartDate: now.AddDate(0, 0, -20), EndDate: now.AddDate(0, 0, -10), ThreadID: 4}, // ended + {Username: "future", StartDate: now.AddDate(0, 0, 30), EndDate: now.AddDate(0, 0, 40), ThreadID: 5}, // far upcoming + {Username: "future", StartDate: now.AddDate(0, 0, 5), EndDate: now.AddDate(0, 0, 8), ThreadID: 6}, // soon upcoming + } + got, ok = c.GetEntry("future") + if !ok || got.ThreadID != 6 { + t.Errorf("GetEntry must surface the soonest upcoming window (thread 6), got %+v ok=%v", got, ok) + } + + // User with only ended windows — most recently ended (latest EndDate) must win. + c.entries["past"] = []LOAEntry{ + {Username: "past", StartDate: now.AddDate(0, 0, -40), EndDate: now.AddDate(0, 0, -30), ThreadID: 7}, + {Username: "past", StartDate: now.AddDate(0, 0, -15), EndDate: now.AddDate(0, 0, -5), ThreadID: 8}, + } + got, ok = c.GetEntry("past") + if !ok || got.ThreadID != 8 { + t.Errorf("GetEntry must surface the most recently-ended window (thread 8), got %+v ok=%v", got, ok) + } + + if _, ok := c.GetEntry("nobody"); ok { + t.Errorf("GetEntry for unknown user must be false") } } func TestRefresh_NoNewPosts_IsNoOp(t *testing.T) { - c := &LOACache{entries: map[string]LOAEntry{}} - c.entries["keep"] = LOAEntry{Username: "keep", StartDate: time.Now().Add(-1 * time.Hour), EndDate: time.Now().Add(72 * time.Hour)} + c := &LOACache{entries: map[string][]LOAEntry{}} + c.entries["keep"] = []LOAEntry{{Username: "keep", StartDate: time.Now().Add(-1 * time.Hour), EndDate: time.Now().Add(72 * time.Hour)}} c.lastSyncedPostDate = 555 // Node returns no rows; the cursor must not move and the existing entry stays. @@ -311,7 +430,7 @@ func TestRefresh_NoNewPosts_IsNoOp(t *testing.T) { } func TestRefresh_FetcherError_DoesNotAdvanceCursor(t *testing.T) { - c := &LOACache{entries: map[string]LOAEntry{}} + c := &LOACache{entries: map[string][]LOAEntry{}} c.lastSyncedPostDate = 42 f := &fakeLOAFetcher{errByNode: map[int]error{180: errors.New("boom")}} @@ -326,9 +445,9 @@ func TestRefresh_FetcherError_DoesNotAdvanceCursor(t *testing.T) { } func TestGetEntryAndIsOnLOA(t *testing.T) { - c := &LOACache{entries: map[string]LOAEntry{}} - c.entries["onloa"] = LOAEntry{Username: "OnLOA", StartDate: time.Now().Add(-1 * time.Hour), EndDate: time.Now().Add(1 * time.Hour)} - c.entries["future"] = LOAEntry{Username: "Future", StartDate: time.Now().Add(24 * time.Hour), EndDate: time.Now().Add(48 * time.Hour)} + c := &LOACache{entries: map[string][]LOAEntry{}} + c.entries["onloa"] = []LOAEntry{{Username: "OnLOA", StartDate: time.Now().Add(-1 * time.Hour), EndDate: time.Now().Add(1 * time.Hour)}} + c.entries["future"] = []LOAEntry{{Username: "Future", StartDate: time.Now().Add(24 * time.Hour), EndDate: time.Now().Add(48 * time.Hour)}} // GetEntry is case-insensitive on the lookup key. if _, ok := c.GetEntry("ONLOA"); !ok { @@ -410,10 +529,10 @@ func TestIsOnLOA_ActiveWindowBoundaries(t *testing.T) { now := time.Now() const slack = time.Minute // dwarfs the captured-now vs internal-now gap - c := &LOACache{entries: map[string]LOAEntry{}} - c.entries["active"] = LOAEntry{Username: "Active", StartDate: now.Add(-slack), EndDate: now.Add(slack)} - c.entries["past"] = LOAEntry{Username: "Past", StartDate: now.Add(-2 * slack), EndDate: now.Add(-slack)} - c.entries["futurewin"] = LOAEntry{Username: "FutureWin", StartDate: now.Add(slack), EndDate: now.Add(2 * slack)} + c := &LOACache{entries: map[string][]LOAEntry{}} + c.entries["active"] = []LOAEntry{{Username: "Active", StartDate: now.Add(-slack), EndDate: now.Add(slack)}} + c.entries["past"] = []LOAEntry{{Username: "Past", StartDate: now.Add(-2 * slack), EndDate: now.Add(-slack)}} + c.entries["futurewin"] = []LOAEntry{{Username: "FutureWin", StartDate: now.Add(slack), EndDate: now.Add(2 * slack)}} if !c.IsOnLOA("active") { t.Errorf("IsOnLOA: entry within its window must be active") @@ -426,36 +545,38 @@ func TestIsOnLOA_ActiveWindowBoundaries(t *testing.T) { } } -// TestRefresh_PruneBoundary cross-checks the prune step in refresh (which calls -// isExpiredAt with time.Now()): an entry whose EndDate is still ahead of now -// survives a refresh cycle, while one already past is pruned. The exact EndDate==now -// edge is pinned by TestLOAEntry_isExpiredAt; this guards the prune wiring. -func TestRefresh_PruneBoundary(t *testing.T) { +// TestRefresh_RetentionBoundary cross-checks the retention step in refresh (which +// calls isRetired with time.Now()): a recently-ended window (EndDate within the +// retention horizon) survives a refresh cycle as history, while one whose EndDate +// predates the horizon is dropped. Replaces the former prune-on-expiry boundary +// test now that ended windows are retained. +func TestRefresh_RetentionBoundary(t *testing.T) { now := time.Now() - const slack = time.Minute - c := &LOACache{entries: map[string]LOAEntry{}} - c.entries["survivor"] = LOAEntry{Username: "Survivor", StartDate: now.Add(-slack), EndDate: now.Add(slack)} - c.entries["pruned"] = LOAEntry{Username: "Pruned", StartDate: now.Add(-2 * slack), EndDate: now.Add(-slack)} + c := &LOACache{entries: map[string][]LOAEntry{}} + // survivor: ended yesterday — within the horizon, retained as history. + c.entries["survivor"] = []LOAEntry{{Username: "Survivor", StartDate: now.AddDate(0, 0, -3), EndDate: now.AddDate(0, 0, -1), ThreadID: 1}} + // retired: ended just past the retention horizon — dropped. + c.entries["retired"] = []LOAEntry{{Username: "Retired", StartDate: historyHorizon(now).AddDate(0, 0, -2), EndDate: historyHorizon(now).AddDate(0, 0, -1), ThreadID: 2}} c.lastSyncedPostDate = 100 // warm cache ⇒ deterministic since, no new posts f := &fakeLOAFetcher{byNode: map[int][]loaPostRow{180: nil}} c.refresh(f, []int{180}) - if _, ok := c.GetEntry("survivor"); !ok { - t.Errorf("entry whose EndDate is still ahead of now must survive one refresh cycle") + if got := c.GetEntries("survivor"); len(got) != 1 { + t.Errorf("recently-ended window must survive as history, got %d windows", len(got)) } - if _, ok := c.GetEntry("pruned"); ok { - t.Errorf("entry whose EndDate is already past must be pruned") + if got := c.GetEntries("retired"); len(got) != 0 { + t.Errorf("window ended past the retention horizon must be dropped, got %d windows", len(got)) } } -// TestRefresh_LatestLOAWins pins the username-keyed overwrite in refresh: when a -// later refresh observes a new valid post for an already-cached username, the new -// entry replaces the old one ("latest LOA wins"). Keyed by lowercased username, so -// the windows/threads must be distinct to prove the replacement actually happened. -func TestRefresh_LatestLOAWins(t *testing.T) { - c := &LOACache{entries: map[string]LOAEntry{}} +// TestRefresh_AccumulatesDistinctThreads pins the multi-window history behavior +// that replaces the old "latest LOA wins" overwrite: a second valid post for the +// same username on a NEW thread is retained alongside the first (deduped only by +// ThreadID), and GetEntry still surfaces the single most-relevant window. +func TestRefresh_AccumulatesDistinctThreads(t *testing.T) { + c := &LOACache{entries: map[string][]LOAEntry{}} // First post: an LOA for "delta" with one window/thread. firstPost := time.Now().Add(-48 * time.Hour).Unix() @@ -464,32 +585,34 @@ func TestRefresh_LatestLOAWins(t *testing.T) { }} c.refresh(f1, []int{180}) - got, ok := c.GetEntry("delta") - if !ok { - t.Fatalf("expected delta entry after first refresh") - } - if got.ThreadID != 11 || got.EndDate.Format("2006-01-02") != "2099-01-31" { - t.Fatalf("first refresh entry = %+v, want thread 11 / end 2099-01-31", got) + if got := c.GetEntries("delta"); len(got) != 1 { + t.Fatalf("expected 1 window after first refresh, got %d", len(got)) } // Second post: a NEW LOA for the same username with a later window and a - // different thread. After refresh the cache must hold ONLY the newer entry. + // different thread. Both windows must now be retained (no overwrite). secondPost := time.Now().Add(-1 * time.Hour).Unix() f2 := &fakeLOAFetcher{byNode: map[int][]loaPostRow{ 180: {{message: loaPost("delta", "Feb 1, 2099", "Feb 28, 2099"), postDate: secondPost, threadID: 22}}, }} c.refresh(f2, []int{180}) - got, ok = c.GetEntry("delta") - if !ok { - t.Fatalf("expected delta entry after second refresh") + got := c.GetEntries("delta") + if len(got) != 2 { + t.Fatalf("expected 2 retained windows after second refresh, got %d (%+v)", len(got), got) } - if got.ThreadID != 22 { - t.Errorf("latest LOA wins: ThreadID = %d, want 22 (newer post)", got.ThreadID) + threads := map[int64]bool{} + for _, w := range got { + threads[w.ThreadID] = true } - if got.StartDate.Format("2006-01-02") != "2099-02-01" || got.EndDate.Format("2006-01-02") != "2099-02-28" { - t.Errorf("latest LOA wins: window = %s..%s, want 2099-02-01..2099-02-28", - got.StartDate.Format("2006-01-02"), got.EndDate.Format("2006-01-02")) + if !threads[11] || !threads[22] { + t.Errorf("both threads 11 and 22 must be retained, got %+v", threads) + } + + // Both windows are far-future ⇒ upcoming; GetEntry surfaces the soonest start. + entry, ok := c.GetEntry("delta") + if !ok || entry.ThreadID != 11 { + t.Errorf("GetEntry must surface the soonest-upcoming window (thread 11), got %+v ok=%v", entry, ok) } } @@ -533,7 +656,7 @@ func TestIsHealthy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := &LOACache{entries: map[string]LOAEntry{}} + c := &LOACache{entries: map[string][]LOAEntry{}} c.lastSuccessfulRefresh = tt.seed gotOK, gotTime := c.IsHealthy(tt.maxAge) @@ -577,7 +700,7 @@ func validLOAMessage(username string) string { func TestRefresh_AllNodesFail_DoesNotUpdateTimestamp(t *testing.T) { db, mock := newMockDB(t) - c := &LOACache{entries: map[string]LOAEntry{}} + c := &LOACache{entries: map[string][]LOAEntry{}} seed := time.Now().Add(-2 * time.Hour) c.lastSuccessfulRefresh = seed @@ -599,7 +722,7 @@ func TestRefresh_AllNodesFail_DoesNotUpdateTimestamp(t *testing.T) { func TestRefresh_PartialSuccess_UpdatesTimestamp(t *testing.T) { db, mock := newMockDB(t) - c := &LOACache{entries: map[string]LOAEntry{}} + c := &LOACache{entries: map[string][]LOAEntry{}} seed := time.Now().Add(-2 * time.Hour) c.lastSuccessfulRefresh = seed @@ -626,7 +749,7 @@ func TestRefresh_PartialSuccess_UpdatesTimestamp(t *testing.T) { func TestRefresh_RowsErrMidStream_DoesNotCount(t *testing.T) { db, mock := newMockDB(t) - c := &LOACache{entries: map[string]LOAEntry{}} + c := &LOACache{entries: map[string][]LOAEntry{}} seed := time.Now().Add(-2 * time.Hour) c.lastSuccessfulRefresh = seed @@ -678,7 +801,7 @@ func (f *concurrentLOAFetcher) fetchLOAPosts(_ int, _ int64) ([]loaPostRow, erro // mutex. With locking intact it is a fast, deterministic no-op assertion (the cache // stays usable); under -race a lock regression trips the detector and fails the run. func TestLOACache_ConcurrentRefreshAndReads(t *testing.T) { - c := &LOACache{entries: map[string]LOAEntry{}} + c := &LOACache{entries: map[string][]LOAEntry{}} fetcher := &concurrentLOAFetcher{} nodeIDs := []int{180, 400, 540} @@ -727,7 +850,7 @@ func TestLOACache_ConcurrentRefreshAndReads(t *testing.T) { func TestRefresh_RowParseFailure_StillCountsNodeAsSuccess(t *testing.T) { db, mock := newMockDB(t) - c := &LOACache{entries: map[string]LOAEntry{}} + c := &LOACache{entries: map[string][]LOAEntry{}} seed := time.Now().Add(-2 * time.Hour) c.lastSuccessfulRefresh = seed From 41251b469b66a65da79810104fd518eab8821bc1 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:14:29 -0400 Subject: [PATCH 2/3] fix(loa): apply PR #161 review fixes (comments, dead code, S1/S4 + tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the review handoff on the LOA history-store rework: - docs: reword refresh/Refresh comments — refresh drops windows past the retention horizon (keeping recently-ended ones as history) then applies fetched posts; Refresh references historyHorizon/loaRetentionYears instead of the hardcoded "past year" prose so it can't drift. - refactor: delete dead isExpiredAt (only its own test referenced it; the prune loop uses isRetired) and its test; scrub hasEnded/isActiveAt comments that cited isExpiredAt as a live coupling-partner / prune cutoff. - S1: guard threadID==0 in upsertWindow — treat 0 as non-dedupable and Warn, so a stray 0-thread LOA can't silently fold every such window into one. - S4: collapse /awol's two separate cache reads (IsOnLOA + GetEntry) into a single GetEntry snapshot per user; OnLOA is now derived from that snapshot via the new exported LOAEntry.IsActive, so a concurrent refresh can't make the [[LOA]] link disagree with the OnLOA verdict. Drops the now-unused IsOnLOA from the loaCacheReader interface (concrete method retained). - S3: keep /loa rendering one window/user via GetEntry (no GetEntries iteration); pin the choice with a comment + test. Tests added (item 3, mutation-flagged gaps): two simultaneously-active windows → latest-ending wins; exact isRetired/historyHorizon boundary (±1 tick); mixed-window in-place compaction; GetEntries copy isolation; zero-ThreadID non-dedup; single-window /loa contract; S4 single-snapshot-per-user. No user-facing change to /loa or /awol — regression-guarded by commands tests. Coverage: utils 89.4%, commands 82.8%. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/awol.go | 42 +++++++--- commands/awol_test.go | 53 +++++++++++++ commands/loa.go | 4 + utils/loa.go | 47 +++++++----- utils/loa_test.go | 175 +++++++++++++++++++++++++++++++++++++----- 5 files changed, 273 insertions(+), 48 deletions(-) diff --git a/commands/awol.go b/commands/awol.go index 2ea48bf..9e3d2cc 100644 --- a/commands/awol.go +++ b/commands/awol.go @@ -29,12 +29,14 @@ const loaCacheUnavailableFooter = "⚠️ LOA cache unavailable; On LOA column m // loaCacheReader is the minimal LOA-cache surface /awol consumes. Production // wires *utils.LOACache (GlobalLOACache); tests substitute a fake with canned // entries and a forced health verdict so the handler stays deterministic -// without touching the process-global singleton. IsHealthy gates whether the -// per-member On LOA column can be trusted (#96): when the cache is stale, -// IsOnLOA/GetEntry silently return all-clear, so the column is rendered as -// "unknown" rather than a misleading "false". +// without touching the process-global singleton. /awol reads each member's LOA +// state with a SINGLE GetEntry call and derives both the On LOA verdict +// (entry.IsActive) and the [[LOA]] link from that one snapshot (#158/S4), so the +// two can't disagree across a concurrent refresh. IsHealthy gates whether the +// per-member On LOA column can be trusted (#96): when the cache is stale, GetEntry +// silently returns all-clear, so the column is rendered as "unknown" rather than +// a misleading "false". type loaCacheReader interface { - IsOnLOA(username string) bool GetEntry(username string) (utils.LOAEntry, bool) IsHealthy(maxAge time.Duration) (bool, time.Time) } @@ -45,6 +47,12 @@ type AwolUser struct { TimeSinceLastPost string LastPostDate time.Time OnLOA bool + // LOAEntry is the single cache snapshot read for this user (see the GetEntry + // call in runAwol). HasLOAEntry records whether that read found a window, so + // the [[LOA]] link can reuse the same snapshot instead of a second, possibly + // inconsistent, cache lookup. OnLOA is derived from this snapshot's window. + LOAEntry utils.LOAEntry + HasLOAEntry bool } func Awol() Command { @@ -97,8 +105,9 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, return } - // #96: probe cache health once per invocation. When unhealthy, IsOnLOA - // returns false for everyone silently, so the On LOA column would lie. We do + // #96: probe cache health once per invocation. When unhealthy, the per-user + // GetEntry read returns no window for everyone, so the On LOA column would lie + // (every row reads all-clear). We do // NOT abort — /awol's primary signal (lastForumPostDate) is independent — but // render the column as "unknown" and warn. No Sentry capture: operational // degradation, not an internal error (ADR 0001). @@ -141,12 +150,19 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, utils.HandleError(r, i, "❌ Failed to parse uniform URL") return } + // #158/S4: read the LOA cache ONCE per user. Deriving OnLOA from this + // same snapshot (rather than a separate IsOnLOA call) means the On LOA + // verdict and the [[LOA]] link below can never disagree, even if a + // 15-min refresh lands mid-loop now that ended windows are retained. + entry, hasEntry := cache.GetEntry(member.User.Username) awolUsers = append(awolUsers, AwolUser{ Username: member.User.Username, MilpacUrl: fmt.Sprintf("https://7cav.us/rosters/profile/%s", matches[1]), TimeSinceLastPost: utils.FormatTimeSinceDuration(lastPostDate), LastPostDate: lastPostDate, - OnLOA: cache.IsOnLOA(member.User.Username), + OnLOA: hasEntry && entry.IsActive(now), + LOAEntry: entry, + HasLOAEntry: hasEntry, }) } } @@ -185,8 +201,10 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, case !cacheHealthy: loaTag = "On LOA: unknown — " case user.OnLOA: - if entry, ok := cache.GetEntry(user.Username); ok && entry.ThreadID != 0 { - loaTag = fmt.Sprintf("**[[LOA]](https://7cav.us/threads/%d/)** ", entry.ThreadID) + // Reuse the single per-user snapshot captured above — no second cache + // read — so the link can't point at a thread the row's OnLOA disagrees with. + if user.HasLOAEntry && user.LOAEntry.ThreadID != 0 { + loaTag = fmt.Sprintf("**[[LOA]](https://7cav.us/threads/%d/)** ", user.LOAEntry.ThreadID) } else { loaTag = "**[LOA]** " } @@ -263,8 +281,8 @@ func sendAwolFile(r utils.InteractionResponder, i *discordgo.InteractionCreate, } for _, user := range awolUsers { - // #96: only trust IsOnLOA when the cache is healthy; otherwise the - // column is unknown, not all-clear. + // #96: only trust the OnLOA snapshot when the cache is healthy; otherwise + // the column is unknown, not all-clear. loaTag := "" switch { case !cacheHealthy: diff --git a/commands/awol_test.go b/commands/awol_test.go index 5242ad7..869558a 100644 --- a/commands/awol_test.go +++ b/commands/awol_test.go @@ -553,3 +553,56 @@ func TestRunAwol_UnhealthyCacheFileOutputCarriesWarning(t *testing.T) { t.Fatalf("file should mark On LOA unknown.\nGot:\n%s", body) } } + +// countingLOACache wraps fakeLOACache to count GetEntry calls per username, +// proving the S4 single-snapshot invariant: /awol reads each member's LOA state +// exactly ONCE (deriving both OnLOA and the [[LOA]] link from that one snapshot), +// not once for the verdict and again for the link. +type countingLOACache struct { + *fakeLOACache + getEntryCalls map[string]int +} + +func (c *countingLOACache) GetEntry(username string) (utils.LOAEntry, bool) { + if c.getEntryCalls == nil { + c.getEntryCalls = map[string]int{} + } + c.getEntryCalls[strings.ToLower(username)]++ + return c.fakeLOACache.GetEntry(username) +} + +// TestRunAwol_S4_SingleSnapshotPerUser pins the #158/S4 fix: an active LOA member +// with a thread renders the [[LOA]] link, and the handler reads the cache for that +// member exactly once — so a concurrent refresh can't make the link and the OnLOA +// verdict disagree. +func TestRunAwol_S4_SingleSnapshotPerUser(t *testing.T) { + roster := utils.LiteRosterResponse{ + LiteProfiles: map[string]utils.LiteProfileResponse{ + "100": awolMember("Trooper.A", "100", "2026-03-01 12:00:00"), + }, + } + serveAwolRoster(t, roster, http.StatusOK) + + cache := &countingLOACache{ + fakeLOACache: healthyCache(map[string]utils.LOAEntry{ + "trooper.a": { + Username: "Trooper.A", + StartDate: mustParseAwolDate("2026-05-01 00:00:00"), + EndDate: mustParseAwolDate("2026-06-01 00:00:00"), // active at awolRefDate + ThreadID: 4242, + }, + }), + } + f := &fakeResponder{} + i := fakeAppCommandInteraction(stringOption("position", "1-7")) + + runAwol(f, cache, awolRefDate, i) + + if n := cache.getEntryCalls["trooper.a"]; n != 1 { + t.Fatalf("S4: GetEntry must be called exactly once per member, got %d", n) + } + desc := (*f.Calls()[1].Edit.Embeds)[0].Description + if !strings.Contains(desc, "**[[LOA]](https://7cav.us/threads/4242/)**") { + t.Fatalf("active member must render the [[LOA]] link from the single snapshot.\nGot:\n%s", desc) + } +} diff --git a/commands/loa.go b/commands/loa.go index 8887229..02abd43 100644 --- a/commands/loa.go +++ b/commands/loa.go @@ -119,6 +119,10 @@ func runLoa(r utils.InteractionResponder, cache loaCacheView, now time.Time, i * var activeLOAs, upcomingLOAs []LOAUser for _, member := range roster.LiteProfiles { + // #158/S3: /loa renders exactly one window per user via the single-value + // GetEntry (most-relevant) contract, deliberately NOT iterating GetEntries. + // This preserves the pre-history-store rendering; pinned by + // TestGetEntry_SingleWindowContract_ForLoaRendering in utils. entry, ok := cache.GetEntry(member.User.Username) if !ok { continue diff --git a/utils/loa.go b/utils/loa.go index 01739f8..d999b56 100644 --- a/utils/loa.go +++ b/utils/loa.go @@ -135,10 +135,11 @@ func (c *LOACache) GetEntries(username string) []LOAEntry { } // hasEnded reports whether the entry's EndDate is strictly before `now`. It is -// the single source of truth for the upper boundary shared by isActiveAt and -// isExpiredAt, so the two predicates can't drift: an entry on its EndDate -// (End==now) has NOT ended (active, not yet prunable); one whose EndDate is -// already past has ended. +// the single source of truth for the active window's upper boundary used by +// isActiveAt: an entry on its EndDate (End==now) has NOT ended and is still +// active; one whose EndDate is already past has ended. Retention (drop on +// horizon) is a separate, looser cutoff owned by isRetired — an ended window is +// not dropped, only one past historyHorizon is. func (e LOAEntry) hasEnded(now time.Time) bool { return now.After(e.EndDate) } @@ -146,21 +147,19 @@ func (e LOAEntry) hasEnded(now time.Time) bool { // isActiveAt reports whether the entry's LOA window is active at the instant // `now`. The window is inclusive at both bounds: an entry is active from its // StartDate through its EndDate (so Start==now and End==now both count as active). -// The upper bound is hasEnded (shared with isExpiredAt) — keep them coupled so -// End==now stays both active here and not-yet-expired there. Clock-injected so -// the boundary semantics are unit-testable at the exact edge (cf. PR #135); the -// production callers pass time.Now(). +// The upper bound is hasEnded. Clock-injected so the boundary semantics are +// unit-testable at the exact edge (cf. PR #135); the production callers pass +// time.Now(). IsActive is the exported wrapper used by /awol's single-snapshot read. func (e LOAEntry) isActiveAt(now time.Time) bool { return !now.Before(e.StartDate) && !e.hasEnded(now) } -// isExpiredAt reports whether the entry's LOA window has ended strictly before -// `now`. It is the strict complement of isActiveAt's inclusive upper bound (both -// via hasEnded): an entry on its EndDate (End==now) is NOT yet expired; one whose -// EndDate is already past has expired. Ended windows are no longer pruned on -// expiry — they are retained until isRetired (see historyHorizon). -func (e LOAEntry) isExpiredAt(now time.Time) bool { - return e.hasEnded(now) +// IsActive reports whether the entry's LOA window is active at `now`. Exported so +// /awol can derive its On LOA verdict from the same single GetEntry snapshot it +// uses for the [[LOA]] link, instead of a second IsOnLOA lock acquisition that a +// concurrent refresh could make inconsistent (now that ended windows are retained). +func (e LOAEntry) IsActive(now time.Time) bool { + return e.isActiveAt(now) } // isRetired reports whether the window's EndDate is older than the retention @@ -255,7 +254,8 @@ func (f sqlLOAFetcher) fetchLOAPosts(nodeID int, since int64) ([]loaPostRow, err } // Refresh fetches LOA posts from the forum DB incrementally and updates the cache. -// On first call it fetches posts from the past year; subsequent calls fetch only newer posts. +// On a cold cache it backfills from the shared retention horizon (loaRetentionYears +// before now, via historyHorizon); subsequent calls fetch only newer posts. // Multiple node IDs are supported to cover all LOA forum sections; each node is queried // separately so per-node parse counts can be logged for diagnostics. func (c *LOACache) Refresh(db *sql.DB, nodeIDs []int) { @@ -263,8 +263,9 @@ func (c *LOACache) Refresh(db *sql.DB, nodeIDs []int) { } // refresh is the fetcher-agnostic core of Refresh: it computes the incremental -// `since` cursor, prunes ended entries, then applies each node's fetched posts. -// Refresh wires in the production sqlLOAFetcher; tests supply a fake. +// `since` cursor, drops windows past the retention horizon (keeping recently-ended +// ones as history; see isRetired/historyHorizon), then applies each node's fetched +// posts. Refresh wires in the production sqlLOAFetcher; tests supply a fake. func (c *LOACache) refresh(fetcher loaPostFetcher, nodeIDs []int) { c.mu.RLock() since := c.lastSyncedPostDate @@ -349,9 +350,19 @@ func (c *LOACache) refresh(fetcher loaPostFetcher, nodeIDs []int) { // ThreadID: one forum thread is exactly one LOA, so re-scanning the same thread // (cold-restart backfill or overlapping incremental fetch) updates the existing // window in place rather than appending a duplicate. Caller holds c.mu. +// +// ThreadID 0 is treated as non-dedupable: the live query keys on a non-null PK so +// a real thread is never 0, but if one ever appeared, deduping on 0 would silently +// fold every 0-thread LOA for a user into a single window. Such an entry is always +// appended (never matched), and the anomaly is logged so it doesn't pass unnoticed. func (c *LOACache) upsertWindow(entry LOAEntry) { key := strings.ToLower(entry.Username) windows := c.entries[key] + if entry.ThreadID == 0 { + Warn("LOA window with zero ThreadID; appending without dedup", "username", entry.Username) + c.entries[key] = append(windows, entry) + return + } for i := range windows { if windows[i].ThreadID == entry.ThreadID { windows[i] = entry diff --git a/utils/loa_test.go b/utils/loa_test.go index ac25453..8c988fc 100644 --- a/utils/loa_test.go +++ b/utils/loa_test.go @@ -502,24 +502,6 @@ func TestLOAEntry_isActiveAt(t *testing.T) { } } -// TestLOAEntry_isExpiredAt pins the prune cutoff at the EXACT EndDate boundary: -// expiry is strict (now.After(EndDate)), so an entry whose End==now is NOT yet -// expired (survives the cycle) while one already one tick past End is pruned. This -// is the strict complement of isActiveAt's inclusive upper bound. -func TestLOAEntry_isExpiredAt(t *testing.T) { - entry := LOAEntry{Username: "Boundary", EndDate: mustLOATime("Jun 20, 2099")} - - if entry.isExpiredAt(entry.EndDate) { - t.Errorf("End==now must NOT be expired (final day survives prune)") - } - if entry.isExpiredAt(entry.EndDate.Add(-time.Nanosecond)) { - t.Errorf("one tick before End must NOT be expired") - } - if !entry.isExpiredAt(entry.EndDate.Add(time.Nanosecond)) { - t.Errorf("one tick after End must be expired") - } -} - // TestIsOnLOA_ActiveWindowBoundaries cross-checks the live-clock IsOnLOA wrapper // (which delegates to isActiveAt with time.Now()) against entries positioned // relative to a captured `now`. The exact-edge contract is pinned by @@ -545,6 +527,163 @@ func TestIsOnLOA_ActiveWindowBoundaries(t *testing.T) { } } +// TestGetEntry_TwoActiveWindows_LatestEndingWins pins the highest-value tie-break +// mutation testing flagged hollow: when a user holds TWO simultaneously-active +// windows, mostRelevant/GetEntry must surface the latest-ending one (state 3, +// tie=EndDate). This is the exact selection that drives /awol's [[LOA]] link, so a +// mutated `tie.After` → `tie.Before` must fail here. +func TestGetEntry_TwoActiveWindows_LatestEndingWins(t *testing.T) { + now := time.Now() + c := &LOACache{entries: map[string][]LOAEntry{}} + + // Both windows straddle now (active). Thread 2 ends later, so it must win. + c.entries["dual"] = []LOAEntry{ + {Username: "dual", StartDate: now.AddDate(0, 0, -5), EndDate: now.AddDate(0, 0, 3), ThreadID: 1}, + {Username: "dual", StartDate: now.AddDate(0, 0, -2), EndDate: now.AddDate(0, 0, 9), ThreadID: 2}, + } + got, ok := c.GetEntry("dual") + if !ok || got.ThreadID != 2 { + t.Fatalf("GetEntry must surface the latest-ending ACTIVE window (thread 2), got %+v ok=%v", got, ok) + } + + // Order-independence: same windows, reversed slice order, same verdict. + c.entries["dual"] = []LOAEntry{ + {Username: "dual", StartDate: now.AddDate(0, 0, -2), EndDate: now.AddDate(0, 0, 9), ThreadID: 2}, + {Username: "dual", StartDate: now.AddDate(0, 0, -5), EndDate: now.AddDate(0, 0, 3), ThreadID: 1}, + } + got, ok = c.GetEntry("dual") + if !ok || got.ThreadID != 2 { + t.Fatalf("latest-ending active must win regardless of slice order, got %+v ok=%v", got, ok) + } +} + +// TestLOAEntry_isRetired_HorizonBoundary pins the retention cutoff at the EXACT +// historyHorizon boundary. isRetired uses a strict Before, so a window whose +// EndDate == historyHorizon(now) is RETAINED (not yet retired); one tick older is +// retired, one tick newer is retained. There is no other direct test for either +// isRetired or historyHorizon, so this guards a Before↔!After / ±tick mutation. +func TestLOAEntry_isRetired_HorizonBoundary(t *testing.T) { + now := time.Now() + horizon := historyHorizon(now) + + atHorizon := LOAEntry{EndDate: horizon} + if atHorizon.isRetired(now) { + t.Errorf("EndDate == historyHorizon must be RETAINED (strict Before), got retired") + } + oneTickNewer := LOAEntry{EndDate: horizon.Add(time.Nanosecond)} + if oneTickNewer.isRetired(now) { + t.Errorf("EndDate one tick after the horizon must be retained, got retired") + } + oneTickOlder := LOAEntry{EndDate: horizon.Add(-time.Nanosecond)} + if !oneTickOlder.isRetired(now) { + t.Errorf("EndDate one tick before the horizon must be retired, got retained") + } +} + +// TestRefresh_MixedWindowCompaction exercises the in-place kept := windows[:0] +// filter with a SINGLE user holding interleaved retired/retained windows — the +// real-world multi-window case the single-window tests never reach. The two +// survivors must remain, in their original relative order. +func TestRefresh_MixedWindowCompaction(t *testing.T) { + now := time.Now() + c := &LOACache{entries: map[string][]LOAEntry{}} + + // [retired, retained, retired, retained] for one user. + c.entries["mixed"] = []LOAEntry{ + {Username: "mixed", StartDate: now.AddDate(-2, 0, 0), EndDate: now.AddDate(-1, 0, -30), ThreadID: 1}, // retired + {Username: "mixed", StartDate: now.AddDate(0, 0, -40), EndDate: now.AddDate(0, 0, -30), ThreadID: 2}, // retained (recent) + {Username: "mixed", StartDate: now.AddDate(-2, 0, 0), EndDate: now.AddDate(-1, 0, -20), ThreadID: 3}, // retired + {Username: "mixed", StartDate: now.AddDate(0, 0, -10), EndDate: now.AddDate(0, 0, -2), ThreadID: 4}, // retained (recent) + } + c.lastSyncedPostDate = 100 // warm cache ⇒ no new posts, isolate the compaction + + f := &fakeLOAFetcher{byNode: map[int][]loaPostRow{180: nil}} + c.refresh(f, []int{180}) + + got := c.GetEntries("mixed") + if len(got) != 2 { + t.Fatalf("expected 2 surviving windows after compaction, got %d (%+v)", len(got), got) + } + if got[0].ThreadID != 2 || got[1].ThreadID != 4 { + t.Errorf("survivors must be threads [2, 4] in order, got [%d, %d]", got[0].ThreadID, got[1].ThreadID) + } +} + +// TestGetEntry_SingleWindowContract_ForLoaRendering pins the S3 decision: /loa +// renders exactly one window per user via the single-value GetEntry contract and +// deliberately does NOT iterate GetEntries. Even when a user holds several +// concurrent UPCOMING windows, GetEntry must collapse them to one (the soonest +// start), so /loa shows a single row — matching the pre-history-store behavior. +// If a future change wires /loa onto GetEntries, this test should be revisited +// intentionally rather than silently. +func TestGetEntry_SingleWindowContract_ForLoaRendering(t *testing.T) { + now := time.Now() + c := &LOACache{entries: map[string][]LOAEntry{}} + + // Two concurrent upcoming windows for one user. + c.entries["multi"] = []LOAEntry{ + {Username: "multi", StartDate: now.AddDate(0, 0, 20), EndDate: now.AddDate(0, 0, 30), ThreadID: 1}, + {Username: "multi", StartDate: now.AddDate(0, 0, 5), EndDate: now.AddDate(0, 0, 8), ThreadID: 2}, + } + + got, ok := c.GetEntry("multi") + if !ok { + t.Fatalf("GetEntry must return a window for a user with multiple windows") + } + if got.ThreadID != 2 { + t.Errorf("single-window contract: GetEntry must surface the soonest-upcoming window (thread 2), got %d", got.ThreadID) + } + // The full history is still two windows — GetEntry is the lossy one-per-user view. + if n := len(c.GetEntries("multi")); n != 2 { + t.Errorf("GetEntries must still expose the full history (2), got %d", n) + } +} + +// TestGetEntries_CopyIsolation proves GetEntries returns a defensive copy: mutating +// a returned window must not leak back into the cache. +func TestGetEntries_CopyIsolation(t *testing.T) { + c := &LOACache{entries: map[string][]LOAEntry{}} + c.entries["iso"] = []LOAEntry{ + {Username: "iso", StartDate: time.Now().Add(-time.Hour), EndDate: time.Now().Add(time.Hour), ThreadID: 42}, + } + + got := c.GetEntries("iso") + if len(got) != 1 { + t.Fatalf("expected 1 window, got %d", len(got)) + } + got[0].ThreadID = 9999 // mutate the returned copy + + again := c.GetEntries("iso") + if again[0].ThreadID != 42 { + t.Errorf("mutating a GetEntries result must not alter the cache: ThreadID = %d, want 42", again[0].ThreadID) + } +} + +// TestUpsertWindow_ZeroThreadIDNotDeduped guards the S1 defensive path: a window +// with ThreadID 0 (impossible from the live non-null PK query, but a silent +// data-folding hazard if it ever occurred) must be appended rather than dedup- +// folded. Two distinct 0-thread windows for one user must both survive. +func TestUpsertWindow_ZeroThreadIDNotDeduped(t *testing.T) { + now := time.Now() + c := &LOACache{entries: map[string][]LOAEntry{}} + + c.upsertWindow(LOAEntry{Username: "Zed", StartDate: now.AddDate(0, 0, 1), EndDate: now.AddDate(0, 0, 5), ThreadID: 0}) + c.upsertWindow(LOAEntry{Username: "Zed", StartDate: now.AddDate(0, 0, 10), EndDate: now.AddDate(0, 0, 15), ThreadID: 0}) + + got := c.GetEntries("zed") + if len(got) != 2 { + t.Fatalf("two zero-ThreadID windows must NOT fold into one; got %d (%+v)", len(got), got) + } + + // A non-zero thread still dedupes in place alongside the un-folded zeros. + c.upsertWindow(LOAEntry{Username: "Zed", StartDate: now.AddDate(0, 0, 20), EndDate: now.AddDate(0, 0, 25), ThreadID: 7}) + c.upsertWindow(LOAEntry{Username: "Zed", StartDate: now.AddDate(0, 0, 20), EndDate: now.AddDate(0, 0, 26), ThreadID: 7}) + got = c.GetEntries("zed") + if len(got) != 3 { + t.Fatalf("expected 3 windows (2 zero + 1 deduped thread 7), got %d (%+v)", len(got), got) + } +} + // TestRefresh_RetentionBoundary cross-checks the retention step in refresh (which // calls isRetired with time.Now()): a recently-ended window (EndDate within the // retention horizon) survives a refresh cycle as history, while one whose EndDate From 6428f2e7afcf9a31cf61f804881454fe5439bfd5 Mon Sep 17 00:00:00 2001 From: SyniRon <66834451+SyniRon@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:28:05 -0400 Subject: [PATCH 3/3] refactor(loa): apply PR #161 re-review fixes (docs + dead IsOnLOA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review items 1, 3, 5 (items 2/4/6 deferred to #159): 1. Fix stale loaCacheView doc in commands/loa.go: it claimed /awol's loaCacheReader "needs IsOnLOA but not IsHealthy" — both false after 41251b46. Describe the real distinction: per-command minimal surfaces over the same cache, /awol additionally gating on health for the #96 On-LOA column. 3. Correct unhealthy-cache comments in commands/awol.go (#96 block + the loaCacheReader interface doc): IsHealthy is an age check, so an unhealthy cache is stale not empty — GetEntry may still return outdated windows. The safety is the cacheHealthy render gate forcing the column to "unknown", not GetEntry returning nothing. 5. Delete the now-caller-less LOACache.IsOnLOA method (dropped from the loaCacheReader interface in 41251b46), matching the isExpiredAt dead-code removal precedent in this PR. Remove TestIsOnLOA_ActiveWindowBoundaries (its exact-edge boundary contract is already owned, deterministically, by TestLOAEntry_isActiveAt) and the dead IsOnLOA methods on the test fakes; prune trailing IsOnLOA references in comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/awol.go | 24 +++++++++++++----------- commands/awol_test.go | 36 ++++++++++-------------------------- commands/loa.go | 9 +++++++-- utils/loa.go | 15 +-------------- utils/loa_test.go | 43 ++++--------------------------------------- 5 files changed, 35 insertions(+), 92 deletions(-) diff --git a/commands/awol.go b/commands/awol.go index 9e3d2cc..bf07092 100644 --- a/commands/awol.go +++ b/commands/awol.go @@ -33,9 +33,10 @@ const loaCacheUnavailableFooter = "⚠️ LOA cache unavailable; On LOA column m // state with a SINGLE GetEntry call and derives both the On LOA verdict // (entry.IsActive) and the [[LOA]] link from that one snapshot (#158/S4), so the // two can't disagree across a concurrent refresh. IsHealthy gates whether the -// per-member On LOA column can be trusted (#96): when the cache is stale, GetEntry -// silently returns all-clear, so the column is rendered as "unknown" rather than -// a misleading "false". +// per-member On LOA column can be trusted (#96): it is an age check on the last +// successful refresh, so an unhealthy cache is stale, not empty — GetEntry may +// still return (now possibly outdated) windows. When unhealthy, the render path +// gates the column to "unknown" rather than trusting those stale reads. type loaCacheReader interface { GetEntry(username string) (utils.LOAEntry, bool) IsHealthy(maxAge time.Duration) (bool, time.Time) @@ -105,12 +106,13 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, return } - // #96: probe cache health once per invocation. When unhealthy, the per-user - // GetEntry read returns no window for everyone, so the On LOA column would lie - // (every row reads all-clear). We do - // NOT abort — /awol's primary signal (lastForumPostDate) is independent — but - // render the column as "unknown" and warn. No Sentry capture: operational - // degradation, not an internal error (ADR 0001). + // #96: probe cache health once per invocation. IsHealthy is an age check on + // the last successful refresh, so an unhealthy cache is stale, not empty — + // per-user GetEntry reads may still return (possibly outdated) windows that + // the On LOA column would otherwise present as current truth. We do NOT abort + // — /awol's primary signal (lastForumPostDate) is independent — but the render + // path below gates the column to "unknown" via cacheHealthy and warns. No + // Sentry capture: operational degradation, not an internal error (ADR 0001). cacheHealthy, lastRefresh := cache.IsHealthy(loaCacheMaxAge) if !cacheHealthy { utils.Debug("AWOL served with unhealthy LOA cache", @@ -151,8 +153,8 @@ func runAwol(r utils.InteractionResponder, cache loaCacheReader, now time.Time, return } // #158/S4: read the LOA cache ONCE per user. Deriving OnLOA from this - // same snapshot (rather than a separate IsOnLOA call) means the On LOA - // verdict and the [[LOA]] link below can never disagree, even if a + // same snapshot (rather than a separate active-window lookup) means the + // On LOA verdict and the [[LOA]] link below can never disagree, even if a // 15-min refresh lands mid-loop now that ended windows are retained. entry, hasEntry := cache.GetEntry(member.User.Username) awolUsers = append(awolUsers, AwolUser{ diff --git a/commands/awol_test.go b/commands/awol_test.go index 869558a..4eea278 100644 --- a/commands/awol_test.go +++ b/commands/awol_test.go @@ -53,8 +53,8 @@ func serveAwolRoster(t *testing.T, roster utils.LiteRosterResponse, rosterStatus // fakeLOACache is a deterministic loaCacheReader for /awol integration tests. // entries are keyed by lowercased username (matches the production cache's -// case-folding); IsOnLOA returns true iff an entry exists, decoupling the test -// from time.Now since the handler's "now" is already injected separately. +// case-folding). The handler derives OnLOA from GetEntry's window via +// IsActive(now), so entries whose dates straddle the injected `now` read active. // healthy/lastRefresh drive the IsHealthy staleness guard; the zero value is // unhealthy, so existing tests that want the original behavior should construct // via healthyCache. @@ -69,11 +69,6 @@ func (f *fakeLOACache) GetEntry(username string) (utils.LOAEntry, bool) { return e, ok } -func (f *fakeLOACache) IsOnLOA(username string) bool { - _, ok := f.entries[strings.ToLower(username)] - return ok -} - func (f *fakeLOACache) IsHealthy(_ time.Duration) (bool, time.Time) { return f.healthy, f.lastRefresh } @@ -84,15 +79,13 @@ func healthyCache(entries map[string]utils.LOAEntry) *fakeLOACache { return &fakeLOACache{entries: entries, healthy: true, lastRefresh: awolRefDate} } -// dateAwareLOACache is a loaCacheReader whose IsOnLOA respects each entry's -// StartDate/EndDate window evaluated at a FIXED `at` instant — unlike fakeLOACache, -// whose IsOnLOA returns true for any existing entry regardless of dates. This lets -// an /awol test exercise the "entry exists but is not active today" path (upcoming -// or expired), proving such a member is excluded from the (N on LOA) tally and the -// [LOA] decoration on the healthy path. This fake substitutes its own fixed clock -// `at` for the wall clock that production's utils.LOACache.IsOnLOA reads internally -// (the real method takes no time argument). The two are fidelity-equivalent only -// because the test windows are weeks wide relative to the `at`-vs-wall-now skew. +// dateAwareLOACache is a loaCacheReader returning entries whose StartDate/EndDate +// windows are meaningful relative to a FIXED `at` instant. The handler derives +// OnLOA from GetEntry's window via IsActive(now), so this lets an /awol test +// exercise the "entry exists but is not active today" path (upcoming or expired), +// proving such a member is excluded from the (N on LOA) tally and the [LOA] +// decoration on the healthy path. `at` is also returned as the IsHealthy +// timestamp so the handler's injected `now` and the cache clock agree. type dateAwareLOACache struct { entries map[string]utils.LOAEntry at time.Time @@ -103,15 +96,6 @@ func (f *dateAwareLOACache) GetEntry(username string) (utils.LOAEntry, bool) { return e, ok } -func (f *dateAwareLOACache) IsOnLOA(username string) bool { - e, ok := f.entries[strings.ToLower(username)] - if !ok { - return false - } - // Mirror utils.LOACache's inclusive active window evaluated at `at`. - return !f.at.Before(e.StartDate) && !f.at.After(e.EndDate) -} - func (f *dateAwareLOACache) IsHealthy(_ time.Duration) (bool, time.Time) { return true, f.at // always healthy: this fake exercises the date-window path } @@ -446,7 +430,7 @@ func TestRunAwol_InactiveLOAEntryNotCountedOrTagged(t *testing.T) { // unhealthyCache builds an unhealthy fakeLOACache: entries may exist (and the // roster member may even have a "real" LOA) but the health probe reports stale, -// so the handler must NOT trust IsOnLOA/GetEntry and must render "unknown". +// so the handler must NOT trust GetEntry's window and must render "unknown". func unhealthyCache(entries map[string]utils.LOAEntry, lastRefresh time.Time) *fakeLOACache { return &fakeLOACache{entries: entries, healthy: false, lastRefresh: lastRefresh} } diff --git a/commands/loa.go b/commands/loa.go index 02abd43..d96bc32 100644 --- a/commands/loa.go +++ b/commands/loa.go @@ -16,8 +16,13 @@ import ( // entry lookup plus a health probe for the staleness guard. Production wires // *utils.LOACache (GlobalLOACache); tests substitute a fake with canned // entries and a forced health verdict so the handler stays deterministic -// without touching the process-global singleton. Distinct from /awol's -// loaCacheReader, which needs IsOnLOA but not IsHealthy. +// without touching the process-global singleton. /awol's loaCacheReader is a +// separate, per-command minimal surface over the same cache with the same +// {GetEntry, IsHealthy} shape today; the two are kept distinct so each command's +// dependency stays scoped to what it actually reads (and so they can diverge — +// see #159, which adds GetEntries to loaCacheReader). /awol additionally gates on +// IsHealthy to render its On-LOA column (#96); /loa uses it for the staleness +// guard above. type loaCacheView interface { GetEntry(username string) (utils.LOAEntry, bool) IsHealthy(maxAge time.Duration) (bool, time.Time) diff --git a/utils/loa.go b/utils/loa.go index d999b56..b6002e3 100644 --- a/utils/loa.go +++ b/utils/loa.go @@ -156,7 +156,7 @@ func (e LOAEntry) isActiveAt(now time.Time) bool { // IsActive reports whether the entry's LOA window is active at `now`. Exported so // /awol can derive its On LOA verdict from the same single GetEntry snapshot it -// uses for the [[LOA]] link, instead of a second IsOnLOA lock acquisition that a +// uses for the [[LOA]] link, instead of a second cache lock acquisition that a // concurrent refresh could make inconsistent (now that ended windows are retained). func (e LOAEntry) IsActive(now time.Time) bool { return e.isActiveAt(now) @@ -172,19 +172,6 @@ func (e LOAEntry) isRetired(now time.Time) bool { return e.EndDate.Before(historyHorizon(now)) } -// IsOnLOA returns true if the username has any currently active LOA window. -func (c *LOACache) IsOnLOA(username string) bool { - c.mu.RLock() - defer c.mu.RUnlock() - now := time.Now() - for _, w := range c.entries[strings.ToLower(username)] { - if w.isActiveAt(now) { - return true - } - } - return false -} - // IsHealthy reports whether the cache has been successfully refreshed within // maxAge. The returned timestamp is the last-successful-refresh time (zero if // the cache has never refreshed successfully) so callers can render diagnostic diff --git a/utils/loa_test.go b/utils/loa_test.go index 8c988fc..9318095 100644 --- a/utils/loa_test.go +++ b/utils/loa_test.go @@ -444,10 +444,9 @@ func TestRefresh_FetcherError_DoesNotAdvanceCursor(t *testing.T) { } } -func TestGetEntryAndIsOnLOA(t *testing.T) { +func TestGetEntry(t *testing.T) { c := &LOACache{entries: map[string][]LOAEntry{}} c.entries["onloa"] = []LOAEntry{{Username: "OnLOA", StartDate: time.Now().Add(-1 * time.Hour), EndDate: time.Now().Add(1 * time.Hour)}} - c.entries["future"] = []LOAEntry{{Username: "Future", StartDate: time.Now().Add(24 * time.Hour), EndDate: time.Now().Add(48 * time.Hour)}} // GetEntry is case-insensitive on the lookup key. if _, ok := c.GetEntry("ONLOA"); !ok { @@ -456,22 +455,13 @@ func TestGetEntryAndIsOnLOA(t *testing.T) { if _, ok := c.GetEntry("missing"); ok { t.Errorf("GetEntry for absent user should be false") } - if !c.IsOnLOA("onloa") { - t.Errorf("IsOnLOA should be true for currently-active window") - } - if c.IsOnLOA("future") { - t.Errorf("IsOnLOA should be false before StartDate") - } - if c.IsOnLOA("missing") { - t.Errorf("IsOnLOA should be false for unknown user") - } } // TestLOAEntry_isActiveAt pins the inclusive active-window contract at the EXACT // boundary instants. isActiveAt is clock-injected (cf. PR #135) precisely so the // Start==now and End==now edges can be asserted against a fixed `now` — something -// IsOnLOA's live time.Now() can never hit deterministically. The window is -// inclusive at both bounds. +// a live time.Now() could never hit deterministically. The window is inclusive at +// both bounds. IsActive (the exported wrapper /awol reads) delegates here. func TestLOAEntry_isActiveAt(t *testing.T) { now := mustLOATime("Jun 15, 2099") entry := LOAEntry{ @@ -502,30 +492,6 @@ func TestLOAEntry_isActiveAt(t *testing.T) { } } -// TestIsOnLOA_ActiveWindowBoundaries cross-checks the live-clock IsOnLOA wrapper -// (which delegates to isActiveAt with time.Now()) against entries positioned -// relative to a captured `now`. The exact-edge contract is pinned by -// TestLOAEntry_isActiveAt; this guards the wrapper's wiring (lock, lookup, -// time.Now() delegation) end-to-end. -func TestIsOnLOA_ActiveWindowBoundaries(t *testing.T) { - now := time.Now() - const slack = time.Minute // dwarfs the captured-now vs internal-now gap - - c := &LOACache{entries: map[string][]LOAEntry{}} - c.entries["active"] = []LOAEntry{{Username: "Active", StartDate: now.Add(-slack), EndDate: now.Add(slack)}} - c.entries["past"] = []LOAEntry{{Username: "Past", StartDate: now.Add(-2 * slack), EndDate: now.Add(-slack)}} - c.entries["futurewin"] = []LOAEntry{{Username: "FutureWin", StartDate: now.Add(slack), EndDate: now.Add(2 * slack)}} - - if !c.IsOnLOA("active") { - t.Errorf("IsOnLOA: entry within its window must be active") - } - if c.IsOnLOA("past") { - t.Errorf("IsOnLOA: wholly-past window must not be active") - } - if c.IsOnLOA("futurewin") { - t.Errorf("IsOnLOA: wholly-future window must not be active") - } -} // TestGetEntry_TwoActiveWindows_LatestEndingWins pins the highest-value tie-break // mutation testing flagged hollow: when a user holds TWO simultaneously-active @@ -934,7 +900,7 @@ func (f *concurrentLOAFetcher) fetchLOAPosts(_ int, _ int64) ([]loaPostRow, erro } // TestLOACache_ConcurrentRefreshAndReads fans out many goroutines that hammer -// Refresh (writer path) alongside GetEntry / IsOnLOA / IsHealthy (reader paths) on +// Refresh (writer path) alongside GetEntry / IsHealthy (reader paths) on // one cache. Its job is to fail under `go test -race` if the cache's locking ever // regresses — e.g. a dropped Lock/RLock or a read of a guarded field outside the // mutex. With locking intact it is a fast, deterministic no-op assertion (the cache @@ -970,7 +936,6 @@ func TestLOACache_ConcurrentRefreshAndReads(t *testing.T) { // Touch every guarded read path; results are intentionally // ignored — the race detector, not an assertion, is the oracle. _, _ = c.GetEntry("user1") - _ = c.IsOnLOA("user2") _, _ = c.IsHealthy(30 * time.Minute) } }()