| new file mode 100644 |
| index 0000000..d8d3ac8 |
| --- /dev/null |
| +++ b/internal/memoria/stale_refresh_test.go |
| @@ -0,0 +1,290 @@ |
| +package memoria |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/sibling" |
| + "amadan.net/oficina/suite" |
| +) |
| + |
| +type staleFixture struct { |
| + failFeed atomic.Bool |
| + fixedRuling atomic.Value |
| + store *Store |
| + app *app |
| + entered chan struct{} |
| + release chan struct{} |
| + calls atomic.Int64 |
| + askerCalls atomic.Int64 |
| + bodyCalls atomic.Int64 |
| +} |
| + |
| +func newStaleFixture(t *testing.T) *staleFixture { |
| + t.Helper() |
| + f := &staleFixture{store: storeForTest(t), entered: make(chan struct{}, 20), release: make(chan struct{})} |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Header.Get("Authorization") != "Bearer consumer-token" { |
| + t.Error("missing consumer token") |
| + w.WriteHeader(401) |
| + return |
| + } |
| + switch r.URL.Path { |
| + case suite.RouteS2: |
| + f.askerCalls.Add(1) |
| + var req suite.S2Request |
| + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + if req.Asker != (suite.Subject{Kind: suite.SubjectAddress, Value: "reader@example.test"}) { |
| + t.Errorf("wrong asker: %#v", req.Asker) |
| + } |
| + rows := make([]map[string]any, 0, len(req.Pairs)) |
| + for _, pair := range req.Pairs { |
| + ruling := suite.UNKNOWN |
| + if pair.ItemID == "item" && pair.Revision == 19 { |
| + ruling = suite.STALE |
| + } |
| + if pair.ItemID == "item" && pair.Revision == 73 { |
| + ruling = suite.VISIBLE |
| + } |
| + if override := f.fixedRuling.Load(); override != nil { |
| + ruling = override.(suite.Authorization) |
| + } |
| + rows = append(rows, map[string]any{"item_id": pair.ItemID, "revision": pair.Revision, "result": ruling}) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"results": rows}) |
| + case suite.RouteS1: |
| + f.calls.Add(1) |
| + f.entered <- struct{}{} |
| + select { |
| + case <-f.release: |
| + case <-r.Context().Done(): |
| + return |
| + } |
| + if f.failFeed.Load() { |
| + w.WriteHeader(http.StatusServiceUnavailable) |
| + return |
| + } |
| + switch r.URL.Query().Get("cursor") { |
| + case "c19": |
| + json.NewEncoder(w).Encode(suite.Feed{Cursor: "c73", Rows: []suite.FeedRow{row("item", 73, false)}}) |
| + case "c73": |
| + json.NewEncoder(w).Encode(suite.Feed{Cursor: "c73"}) |
| + default: |
| + t.Errorf("unexpected cursor %q", r.URL.Query().Get("cursor")) |
| + w.WriteHeader(500) |
| + } |
| + case suite.RouteS3 + "/item": |
| + f.bodyCalls.Add(1) |
| + if r.URL.Query().Get("revision") != "73" { |
| + t.Errorf("guessed revision %q", r.URL.Query().Get("revision")) |
| + w.WriteHeader(409) |
| + return |
| + } |
| + io.WriteString(w, "NewQuarterlyCanary quarterly planning.") |
| + default: |
| + t.Errorf("unexpected route %s", r.URL.Path) |
| + w.WriteHeader(404) |
| + } |
| + })) |
| + t.Cleanup(func() { |
| + select { |
| + case <-f.release: |
| + default: |
| + close(f.release) |
| + } |
| + f.wait(t) |
| + srv.Close() |
| + }) |
| + f.store.custody = &Siblings{client: sibling.NewForTest(), byName: map[string]sibling.Sibling{"Docs": {Name: "Docs", BaseURL: srv.URL, Token: "consumer-token"}}} |
| + if _, err := f.store.Ingest(context.Background(), IngestSpec{Source: "Docs", Key: "item", Body: "OldQuarterlyCanary quarterly planning.", ItemID: "item", SiblingRevision: 19}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.store.setCursor(context.Background(), "Docs", "c19"); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.app = &app{store: f.store, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), testIdentity: "reader@example.test"} |
| + return f |
| +} |
| + |
| +func (f *staleFixture) search(ctx context.Context) *httptest.ResponseRecorder { |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest(http.MethodGet, "/search?q=quarterly", nil).WithContext(ctx) |
| + f.app.searchResults(rec, req) |
| + return rec |
| +} |
| + |
| +func TestStaleSearchStartsBoundedCatchupWithoutWaiting(t *testing.T) { |
| + f := newStaleFixture(t) |
| + started := time.Now() |
| + result := f.search(context.Background()) |
| + if time.Since(started) > sibling.Deadline { |
| + t.Error("page waited for refetch") |
| + } |
| + if strings.Contains(result.Body.String(), "OldQuarterlyCanary") || !strings.Contains(result.Body.String(), "refetching") { |
| + t.Fatal("stale page did not withhold with existing notice") |
| + } |
| + select { |
| + case <-f.entered: |
| + case <-time.After(time.Second): |
| + t.Fatal("STALE said refetching but no S1 catchup started") |
| + } |
| + close(f.release) |
| + limit := time.Now().Add(2 * time.Second) |
| + for { |
| + cursor, err := f.store.cursor(context.Background(), "Docs") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if cursor == "c73" { |
| + break |
| + } |
| + if time.Now().After(limit) { |
| + t.Fatal("catchup did not apply new revision") |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| + before := f.askerCalls.Load() |
| + next := f.search(context.Background()) |
| + if !strings.Contains(next.Body.String(), "NewQuarterlyCanary") || strings.Contains(next.Body.String(), "OldQuarterlyCanary") { |
| + t.Error("next search did not use refreshed content") |
| + } |
| + if f.askerCalls.Load() <= before { |
| + t.Error("refreshed body rendered without fresh S2") |
| + } |
| + if f.bodyCalls.Load() != 1 { |
| + t.Errorf("S3 calls%d", f.bodyCalls.Load()) |
| + } |
| +} |
| + |
| +func (f *staleFixture) wait(t *testing.T) { |
| + t.Helper() |
| + f.store.pollMu.Lock() |
| + done := f.store.polling["Docs"] |
| + f.store.pollMu.Unlock() |
| + if done != nil { |
| + select { |
| + case <-done: |
| + case <-time.After(3 * time.Second): |
| + t.Fatal("refresh did not finish") |
| + } |
| + } |
| +} |
| + |
| +func TestStaleRefreshCoalescesWithWakeAndRepeatedRequests(t *testing.T) { |
| + for _, warmFirst := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "search first", true: "warm first"}[warmFirst], func(t *testing.T) { |
| + f := newStaleFixture(t) |
| + owner := f.store.custody.(*Siblings) |
| + warmDone := make(chan struct{}) |
| + if warmFirst { |
| + go func() { owner.Warm(context.Background(), f.store, f.app.logger); close(warmDone) }() |
| + } else { |
| + f.search(context.Background()) |
| + } |
| + select { |
| + case <-f.entered: |
| + case <-time.After(time.Second): |
| + t.Fatal("first poll not started") |
| + } |
| + if !warmFirst { |
| + go func() { owner.Warm(context.Background(), f.store, f.app.logger); close(warmDone) }() |
| + } |
| + for n := 0; n < 5; n++ { |
| + f.search(context.Background()) |
| + } |
| + select { |
| + case <-f.entered: |
| + t.Error("duplicate same-source poll started while first blocked") |
| + case <-time.After(30 * time.Millisecond): |
| + } |
| + if f.calls.Load() != 1 { |
| + t.Errorf("S1 calls while blocked%d", f.calls.Load()) |
| + } |
| + close(f.release) |
| + select { |
| + case <-warmDone: |
| + case <-time.After(3 * time.Second): |
| + t.Fatal("warm did not join refresh") |
| + } |
| + f.wait(t) |
| + }) |
| + } |
| +} |
| + |
| +func TestStaleRefreshOutlivesCancelledReader(t *testing.T) { |
| + f := newStaleFixture(t) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + f.search(ctx) |
| + select { |
| + case <-f.entered: |
| + case <-time.After(time.Second): |
| + t.Fatal("refresh not started") |
| + } |
| + cancel() |
| + close(f.release) |
| + f.wait(t) |
| + cursor, err := f.store.cursor(context.Background(), "Docs") |
| + if err != nil || cursor != "c73" { |
| + t.Errorf("reader cancellation killed refresh: cursor%q err%v", cursor, err) |
| + } |
| +} |
| + |
| +func TestStaleRefreshFailurePreservesCursorAndCanRetry(t *testing.T) { |
| + f := newStaleFixture(t) |
| + f.failFeed.Store(true) |
| + f.search(context.Background()) |
| + select { |
| + case <-f.entered: |
| + case <-time.After(time.Second): |
| + t.Fatal("refresh not started") |
| + } |
| + close(f.release) |
| + f.wait(t) |
| + cursor, err := f.store.cursor(context.Background(), "Docs") |
| + if err != nil || cursor != "c19" { |
| + t.Errorf("failed refresh advanced cursor: %q %v", cursor, err) |
| + } |
| + docs, err := f.store.Summaries(context.Background()) |
| + if err != nil || len(docs) != 1 { |
| + t.Fatal("failed refresh deleted old corpus") |
| + } |
| + if f.bodyCalls.Load() != 0 { |
| + t.Error("failed S1 guessed a revision for S3") |
| + } |
| + f.failFeed.Store(false) |
| + f.search(context.Background()) |
| + f.wait(t) |
| + cursor, err = f.store.cursor(context.Background(), "Docs") |
| + if err != nil || cursor != "c73" { |
| + t.Errorf("failed refresh blocked retry: %q %v", cursor, err) |
| + } |
| +} |
| + |
| +func TestOnlyStaleTriggersRefresh(t *testing.T) { |
| + for _, ruling := range []suite.Authorization{suite.VISIBLE, suite.NOT_VISIBLE, suite.UNKNOWN} { |
| + t.Run(string(ruling), func(t *testing.T) { |
| + f := newStaleFixture(t) |
| + f.fixedRuling.Store(ruling) |
| + f.search(context.Background()) |
| + select { |
| + case <-f.entered: |
| + t.Error("non-stale ruling triggered ingest") |
| + case <-time.After(30 * time.Millisecond): |
| + } |
| + if f.calls.Load() != 0 { |
| + t.Error("non-stale ruling fetched feed") |
| + } |
| + }) |
| + } |
| +} |