| new file mode 100644 |
| index 0000000..b97c058 |
| --- /dev/null |
| +++ b/internal/memoria/warmup_test.go |
| @@ -0,0 +1,294 @@ |
| +package memoria |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/suite" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/sibling" |
| +) |
| + |
| +// warmWindow is gate 24's N: how long after the app comes up every |
| +// configured sibling must have been polled, unprompted. |
| +// |
| +// **A stated constant in the low seconds, and deliberately not a race |
| +// against anything.** Two seconds is far longer than three local polls |
| +// need and far shorter than "eventually", so it fails a warm-up that was |
| +// deleted or made lazy without failing on a loaded machine. |
| +const warmWindow = 2 * time.Second |
| + |
| +// Gate 24: **every configured sibling is polled on the wake, with no |
| +// query issued at all.** |
| +// |
| +// §6.5: a cold sibling is a wake, and a wake is slower than a deadline, so |
| +// the first search after a quiet period reliably withholds unless somebody |
| +// has already paid that cost. memoria pays it on its own wake, where |
| +// nobody is waiting. |
| +// |
| +// **The assertion is unprompted presence, not an ordering**, and the spec |
| +// works through why the ordering version cannot be written. Asynchronous, |
| +// "before the handler served a request" is a race between goroutines and |
| +// the gate is flaky. Synchronous, satisfying it means the first query |
| +// waits for three sibling wakes — which puts the latency back on the |
| +// reader and undoes the entire reason for warming. So: no query is issued |
| +// here, and the claim is that the polls happen on their own. |
| +// |
| +// MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| +// |
| +// WarmSiblings returns without starting anything |
| +// -> every fixture unpolled; red |
| +// the warm-up fires lazily on the first query instead |
| +// -> red, because this test issues no query — which is the |
| +// assertion the ordering version could not make |
| +// Warm polls only the first sibling in the roster |
| +// -> red on the other two |
| +// Warm calls Authorize instead of Poll |
| +// -> red: S2 needs an asker and there is nobody on a wake, so the |
| +// fixtures record no S1 at all. This is the mistake draft 3 made |
| +// and the one no fixture could have received. |
| +func TestEveryConfiguredSiblingIsPolledOnTheWake(t *testing.T) { |
| + var polled [3]int64 |
| + apps := make([]sibling.Sibling, 0, 3) |
| + for i, name := range []string{"Docs", "Sheets", "Calendar"} { |
| + counter := &polled[i] |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != suite.RouteS1 { |
| + t.Errorf("%s received %s %s on a wake; the warm-up is S1, and "+ |
| + "S2 and S7 both need a person who does not exist here", |
| + name, r.Method, r.URL.Path) |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + atomic.AddInt64(counter, 1) |
| + _, _ = w.Write([]byte(`{"cursor":"c1","rows":[]}`)) |
| + })) |
| + t.Cleanup(srv.Close) |
| + apps = append(apps, sibling.Sibling{Name: name, BaseURL: srv.URL, Token: "t"}) |
| + } |
| + |
| + s := storeForTest(t) |
| + custody := &Siblings{client: sibling.NewForTest(), byName: map[string]sibling.Sibling{}} |
| + for _, a := range apps { |
| + custody.byName[a.Name] = a |
| + } |
| + s.custody = custody |
| + |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + start := time.Now() |
| + go custody.Warm(context.Background(), s, logger) |
| + |
| + // **No query is issued anywhere in this test.** That is the whole |
| + // point: a lazily-fired warm-up has nothing to fire on. |
| + deadline := time.Now().Add(warmWindow) |
| + for { |
| + all := true |
| + for i := range polled { |
| + if atomic.LoadInt64(&polled[i]) == 0 { |
| + all = false |
| + } |
| + } |
| + if all { |
| + break |
| + } |
| + if time.Now().After(deadline) { |
| + for i, name := range []string{"Docs", "Sheets", "Calendar"} { |
| + if atomic.LoadInt64(&polled[i]) == 0 { |
| + t.Errorf("%s was never polled within %v of the wake: the "+ |
| + "first reader after a quiet period pays that sibling's "+ |
| + "cold start and gets a withholding notice for it", |
| + name, warmWindow) |
| + } |
| + } |
| + return |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + t.Logf("three siblings warmed in %v", time.Since(start)) |
| +} |
| + |
| +// The warm-up survives a sibling that is down, because one unreachable |
| +// app must not stop the others being warmed. |
| +// |
| +// The failure is not returned and not fatal: there is nobody to return it |
| +// to on a wake, and the reader learns about it from the withholding notice |
| +// on the page, which is where §6.5 puts it. |
| +// |
| +// MUTATION-TESTED 2026-09-07: return from Warm on the first error and the |
| +// reachable sibling goes unpolled. Reverted. |
| +func TestOneDeadSiblingDoesNotStopTheWarmUp(t *testing.T) { |
| + // One server per app, so the assertion is "which siblings were |
| + // reached" and not "how many calls arrived". A first poll costs two S1 |
| + // calls — one to take the cursor forward, one to confirm nothing |
| + // follows it — so a call count answers a different question than the |
| + // one this test asks. |
| + var docs, also int64 |
| + serve := func(counter *int64) string { |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt64(counter, 1) |
| + _, _ = w.Write([]byte(`{"cursor":"c1","rows":[]}`)) |
| + })) |
| + t.Cleanup(srv.Close) |
| + return srv.URL |
| + } |
| + |
| + custody := &Siblings{ |
| + client: sibling.NewForTest(), |
| + byName: map[string]sibling.Sibling{ |
| + // Port 1 is reserved and nothing listens there. |
| + "Dead": {Name: "Dead", BaseURL: "http://127.0.0.1:1", Token: "t"}, |
| + "Docs": {Name: "Docs", BaseURL: serve(&docs), Token: "t"}, |
| + "AlsoUp": {Name: "AlsoUp", BaseURL: serve(&also), Token: "t"}, |
| + }, |
| + } |
| + s := storeForTest(t) |
| + s.custody = custody |
| + |
| + custody.Warm(context.Background(), s, slog.New(slog.NewTextHandler(io.Discard, nil))) |
| + |
| + if atomic.LoadInt64(&docs) == 0 || atomic.LoadInt64(&also) == 0 { |
| + t.Errorf("Docs polled %d times, AlsoUp %d: one app being down must not "+ |
| + "leave the others cold, and Warm returning on the first error is "+ |
| + "how that happens", docs, also) |
| + } |
| +} |
| + |
| +// **A sibling's response body is content, and it must not reach a log.** |
| +// |
| +// AGENTS.md states this for a model provider's 4xx, which can echo the |
| +// input that caused it; a sibling's error body is the same shape of |
| +// hazard and closer to home, because the thing it can echo is a passage. |
| +// `carlos logs` is platform-held storage we cannot grep, so a body that |
| +// lands there is a disclosure nobody can retract. |
| +// |
| +// The warm-up is where this bites: it is the one path that handles a poll |
| +// error with nobody to return it to, so the tempting thing to do with the |
| +// error is log it. A review's mutation added `"err", err` to that line and |
| +// every test stayed green. |
| +// |
| +// PRESENCE FIRST, per §10's second rule: the sibling really does put the |
| +// canary in a body memoria really does read, so the absence check below is |
| +// an assertion rather than a description of an empty world. |
| +// |
| +// MUTATION-TESTED 2026-09-07: |
| +// |
| +// the error is wrapped with the response body before it is logged |
| +// -> red, the canary in the log |
| +// |
| +// **And one that SURVIVES, recorded rather than hidden**, because a gate's |
| +// coverage is a claim like any other: |
| +// |
| +// logger.Warn(..., "err", err) in Warm |
| +// -> GREEN, and it is not a defect. Every error the module produces |
| +// from a bad S1 or S3 names a status, a field or a row index and |
| +// never the body — checked, in s1.go and s3.go, rather than |
| +// assumed. So logging the error today leaks nothing. |
| +// |
| +// The error is still not logged, for the reason AGENTS.md gives about a |
| +// provider's 4xx: the safety here belongs to somebody else's discretion |
| +// about their own error strings, and this side should not depend on it. |
| +// What this test defends is memoria's own handling; the module's part of |
| +// it is the module's to keep. |
| +func TestASiblingsErrorBodyNeverReachesTheLog(t *testing.T) { |
| + const canary = "SALARY-BANDS-Q3-CONFIDENTIAL" |
| + |
| + var served int64 |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt64(&served, 1) |
| + // A sibling erroring while echoing content back. Not exotic: an |
| + // OpenAI-compatible 4xx does exactly this, and so does any handler |
| + // that renders the request into its error page. |
| + w.WriteHeader(http.StatusBadRequest) |
| + _, _ = w.Write([]byte(`{"error":"could not index ` + canary + `"}`)) |
| + })) |
| + t.Cleanup(srv.Close) |
| + |
| + var logged strings.Builder |
| + logger := slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelDebug})) |
| + |
| + custody := &Siblings{ |
| + client: sibling.NewForTest(), |
| + byName: map[string]sibling.Sibling{ |
| + "Docs": {Name: "Docs", BaseURL: srv.URL, Token: "t"}, |
| + }, |
| + } |
| + s := storeForTest(t) |
| + s.custody = custody |
| + custody.Warm(context.Background(), s, logger) |
| + |
| + // (a) the body was really served and really read. |
| + if atomic.LoadInt64(&served) == 0 { |
| + t.Fatal("the sibling was never called, so nothing below is an assertion") |
| + } |
| + if logged.Len() == 0 { |
| + t.Fatal("nothing was logged at all; the absence check below would " + |
| + "pass against a logger that swallowed everything") |
| + } |
| + if !strings.Contains(logged.String(), "Docs") { |
| + t.Error("the failure was not reported at all — silence is not the fix") |
| + } |
| + |
| + // (b) and the canary is not in it. |
| + if strings.Contains(logged.String(), canary) { |
| + t.Errorf("a sibling's error body reached the log:\n%s", logged.String()) |
| + } |
| +} |
| + |
| +// WarmSiblings starts the poll, and this is what says so. |
| +// |
| +// Gate 24 above drives `Siblings.Warm` directly, which leaves the method |
| +// production actually calls untested: a review's mutation emptied |
| +// `WarmSiblings` and every warm-up test stayed green, because none of them |
| +// went through it. |
| +// |
| +// MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| +// |
| +// WarmSiblings returns without starting the goroutine |
| +// -> red |
| +// WarmSiblings polls synchronously |
| +// -> green, and deliberately not a finding: this asserts the poll |
| +// happens, not how. §6.5's reason for asynchrony is latency, and |
| +// gate 23 is where latency is held. |
| +func TestWarmSiblingsStartsThePoll(t *testing.T) { |
| + var polled int64 |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt64(&polled, 1) |
| + _, _ = w.Write([]byte(`{"cursor":"c1","rows":[]}`)) |
| + })) |
| + t.Cleanup(srv.Close) |
| + |
| + s := storeForTest(t) |
| + s.custody = &Siblings{ |
| + client: sibling.NewForTest(), |
| + byName: map[string]sibling.Sibling{ |
| + "Docs": {Name: "Docs", BaseURL: srv.URL, Token: "t"}, |
| + }, |
| + } |
| + a := &app{store: s, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} |
| + a.WarmSiblings(context.Background()) |
| + |
| + deadline := time.Now().Add(warmWindow) |
| + for atomic.LoadInt64(&polled) == 0 { |
| + if time.Now().After(deadline) { |
| + t.Fatalf("WarmSiblings started nothing within %v", warmWindow) |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| +} |
| + |
| +// An instance with no siblings warms nothing and does not panic. |
| +// |
| +// Slice 0's wiring: custody is nil, and `WarmSiblings` is still called by |
| +// main because the gate requires the call to be there unconditionally. |
| +func TestWarmSiblingsWithNoRosterIsQuiet(t *testing.T) { |
| + s := storeForTest(t) |
| + a := &app{store: s, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} |
| + a.WarmSiblings(context.Background()) |
| +} |