oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package sibling
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "amadan.net/oficina/suite"
13)
14
15func askerForTest() suite.Subject {
16 return suite.Subject{Kind: suite.SubjectAddress, Value: "paul@rslw.com"}
17}
18
19// answerBody writes an S2 answer as it goes on the wire.
20//
21// The module keeps its response struct unexported — DecodeS2 is the whole
22// public surface — so a fixture has to spell the JSON out. That is the
23// better fixture anyway: this is the shape a real sibling emits, and a
24// field renamed upstream shows up here as a decode failure rather than as
25// a struct literal that follows the rename silently.
26func answerBody(pairs []suite.ItemRevision, result suite.Authorization) []byte {
27 rows := make([]map[string]any, 0, len(pairs))
28 for _, p := range pairs {
29 rows = append(rows, map[string]any{
30 "item_id": p.ItemID, "revision": p.Revision, "result": string(result),
31 })
32 }
33 raw, err := json.Marshal(map[string]any{"results": rows})
34 if err != nil {
35 panic(err)
36 }
37 return raw
38}
39
40// answering stands up a sibling that pauses, then answers `result` for
41// every pair it was asked, counting the calls it received.
42func answering(t *testing.T, name string, pause time.Duration, result suite.Authorization, calls *int64) Sibling {
43 t.Helper()
44 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
45 atomic.AddInt64(calls, 1)
46 var req suite.S2Request
47 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
48 t.Errorf("%s: undecodable S2 request: %v", name, err)
49 return
50 }
51 select {
52 case <-time.After(pause):
53 case <-r.Context().Done():
54 return
55 }
56 _, _ = w.Write(answerBody(req.Pairs, result))
57 })
58 s.Name = name
59 return s
60}
61
62// Gate 23: **the deadline and the concurrency, held together**, because
63// either alone passes on the other's failure.
64//
65// §6.5: "One page of results is one round of calls, so the page's floor is
66// the slowest sibling, not their sum." A per-call deadline assertion is
67// satisfied by three sequential calls of 800 ms each — a page that waits
68// 2.4 s, which no per-call test can see. A concurrency assertion alone is
69// satisfied by a client that gives up instantly.
70//
71// **Two fixtures, and the spec is emphatic that one cannot hold both.**
72// §10 gate 23 works through why: against the deadline fixture, an overlap
73// assertion is either always red or never red depending on whose clock you
74// read, so the concurrency half needs its own fixture of comparable
75// sub-deadline latencies.
76//
77// The numbers are the spec's, not this file's:
78//
79// deadline half 3 siblings — one at 5s, two at 10ms — page under 1.2s
80// concurrency half 3 siblings at 300ms each — page under 810ms
81//
82// 810 ms is 2.7x the concurrent time and 0.9x the sequential one, so it
83// separates the two by a wide margin at both ends rather than sitting on
84// top of either. An earlier version of this test used 3x400ms/900ms and
85// 3s/1.5s, and a review showed what the slack bought: the deadline half
86// stayed **green with Deadline widened to 1200ms and to 1400ms**. The
87// bound has to be the spec's or the spec is not what is being tested.
88//
89// **The bounds are literals, not multiples of Deadline.** A test bounded
90// against the constant it is testing widens when the constant does, which
91// is the failure above in its purest form. §6.5 owes a measurement (§13),
92// and the day it arrives this test is where the new number is argued for.
93//
94// TWO-SIDED throughout: every timing bound is paired with an assertion
95// about what came back, so a client that returns instantly with everything
96// UNKNOWN fails.
97//
98// MUTATION-TESTED 2026-09-06, each verified red against BOTH fixtures then
99// reverted — per the spec's instruction, since the point of the split is
100// that neither fixture sees both faults:
101//
102// batches run sequentially -> concurrency half red at ~900ms
103// the per-call deadline goes -> deadline half red at ~5s
104// Deadline widened to 1200ms -> deadline half red (it was green before)
105func TestOnePageIsOneRoundOfCalls(t *testing.T) {
106 t.Run("concurrency: three siblings cost one sibling's time", func(t *testing.T) {
107 var a, b, c int64
108 sheets := answering(t, "Sheets", 300*time.Millisecond, suite.VISIBLE, &a)
109 docs := answering(t, "Docs", 300*time.Millisecond, suite.VISIBLE, &b)
110 cal := answering(t, "Calendar", 300*time.Millisecond, suite.VISIBLE, &c)
111
112 cands := []Candidate{
113 {sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}},
114 {docs, suite.ItemRevision{ItemID: "d1", Revision: 1}},
115 {cal, suite.ItemRevision{ItemID: "c1", Revision: 1}},
116 }
117
118 start := time.Now()
119 got := newTestClient().Rule(context.Background(), askerForTest(), cands)
120 elapsed := time.Since(start)
121
122 if elapsed > 810*time.Millisecond {
123 t.Errorf("three 300ms siblings took %v, over the spec's 810ms: the "+
124 "page is paying their sum, not their slowest", elapsed)
125 }
126 // The other half. Without it, `return allUnknown` in 0ms passes,
127 // and so does every upper bound in this file.
128 for i, d := range got {
129 if !d.Renders() {
130 t.Errorf("candidate %d = %v, want Render — a page that answers "+
131 "fast by answering nothing is not a fast page", i, d)
132 }
133 }
134 })
135
136 t.Run("deadline: one sibling at 5s costs the page its deadline", func(t *testing.T) {
137 var slow, f1, f2 int64
138 sheets := answering(t, "Sheets", 5*time.Second, suite.VISIBLE, &slow)
139 docs := answering(t, "Docs", 10*time.Millisecond, suite.VISIBLE, &f1)
140 cal := answering(t, "Calendar", 10*time.Millisecond, suite.VISIBLE, &f2)
141
142 cands := []Candidate{
143 {sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}},
144 {docs, suite.ItemRevision{ItemID: "d1", Revision: 1}},
145 {cal, suite.ItemRevision{ItemID: "c1", Revision: 1}},
146 }
147
148 start := time.Now()
149 got := newTestClient().Rule(context.Background(), askerForTest(), cands)
150 elapsed := time.Since(start)
151
152 if elapsed > 1200*time.Millisecond {
153 t.Errorf("a sibling sleeping 5s cost the page %v, over the spec's "+
154 "1.2s: the deadline is not applied, or not per call", elapsed)
155 }
156 if got[0].Renders() {
157 t.Error("the passage from the sibling that never answered rendered")
158 }
159 if !got[0].Notices() {
160 t.Error("a sibling past its deadline was silent: §6.5 makes that " +
161 "UNKNOWN, which withholds loudly")
162 }
163 // Partial pages are the normal case, not an error path.
164 for _, i := range []int{1, 2} {
165 if !got[i].Renders() {
166 t.Errorf("candidate %d was withheld because a DIFFERENT sibling "+
167 "was slow", i)
168 }
169 }
170 })
171
172 t.Run("a sibling inside the deadline is waited for", func(t *testing.T) {
173 // Without this, a Rule that never waits passes both bounds above:
174 // it answers in 0ms and every timing assertion is an upper bound.
175 // This one is a lower bound on the answer rather than on time.
176 var n int64
177 sheets := answering(t, "Sheets", 300*time.Millisecond, suite.VISIBLE, &n)
178 got := newTestClient().Rule(context.Background(), askerForTest(),
179 []Candidate{{sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}}})
180 if !got[0].Renders() {
181 t.Error("a sibling answering in 300ms, well inside an 800ms " +
182 "deadline, was treated as no answer")
183 }
184 if atomic.LoadInt64(&n) != 1 {
185 t.Errorf("the sibling received %d calls, want 1", n)
186 }
187 })
188}
189
190// **Two revisions of one item are two questions.**
191//
192// The dedupe key is the whole `(item_id, revision)` pair and not the id,
193// and nothing proved it: a review keyed the dedupe on ItemID alone and it
194// stayed green, because no fixture held two revisions of one document.
195//
196// What that mutation does in production is the leak §6.1 says STALE exists
197// to prevent. memoria holds revision 4 and revision 5 of the same
198// document; the sibling answers STALE for 4 and VISIBLE for 5; keyed on
199// the id, whichever pair is later in the batch wins for both, and the
200// withdrawn revision-4 passage renders on revision 5's permission —
201// retraction-by-edit, undone.
202//
203// MUTATION-TESTED 2026-09-06, each verified red then reverted:
204//
205// dedupe on c.Item.ItemID
206// -> one pair on the wire; red
207// scatter applies an answer to every position sharing an ItemID
208// -> rev 4 renders; red
209func TestTwoRevisionsOfOneItemAreTwoQuestions(t *testing.T) {
210 var asked []suite.ItemRevision
211 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
212 var req suite.S2Request
213 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
214 t.Errorf("undecodable S2 request: %v", err)
215 return
216 }
217 rows := make([]map[string]any, 0, len(req.Pairs))
218 for _, p := range req.Pairs {
219 asked = append(asked, p)
220 // The sibling has moved past revision 4 and stands behind 5.
221 result := string(suite.VISIBLE)
222 if p.Revision == 4 {
223 result = string(suite.STALE)
224 }
225 rows = append(rows, map[string]any{
226 "item_id": p.ItemID, "revision": p.Revision, "result": result,
227 })
228 }
229 raw, err := json.Marshal(map[string]any{"results": rows})
230 if err != nil {
231 t.Fatalf("marshal: %v", err)
232 }
233 _, _ = w.Write(raw)
234 })
235 s.Name = "Sheets"
236
237 got := newTestClient().Rule(context.Background(), askerForTest(), []Candidate{
238 {s, suite.ItemRevision{ItemID: "doc-a", Revision: 4}},
239 {s, suite.ItemRevision{ItemID: "doc-a", Revision: 5}},
240 })
241
242 if len(asked) != 2 {
243 t.Fatalf("%d pairs went on the wire for two revisions of one item, "+
244 "want 2 — one question cannot carry two answers", len(asked))
245 }
246 if got[0] != WithholdAndRefetch {
247 t.Errorf("revision 4 = %v, want WithholdAndRefetch: a passage the "+
248 "sibling has moved past must not render on the permission it "+
249 "gave a later revision", got[0])
250 }
251 if got[1] != Render {
252 t.Errorf("revision 5 = %v, want Render", got[1])
253 }
254}
255
256// **A document's ten chunks ask about it once.**
257//
258// Every chunk of one document carries the same `(item_id, revision)`, so
259// the obvious loop asks a ten-chunk document ten times. That is not merely
260// wasteful: the module maps a duplicated row to UNKNOWN, so a sibling
261// answering a pair once for a question that named it ten times partly
262// withholds its own correct answer — a page that goes quiet for a document
263// the reader is fully entitled to, on the most ordinary shape there is.
264//
265// MUTATION-TESTED 2026-09-06, each verified red then reverted:
266//
267// skip the dedupe (append every candidate's pair)
268// -> the wire assertion goes red at 10 pairs, and the batch the
269// sibling answers honestly comes back UNKNOWN for nine of them
270// map answers back to the first index only
271// -> nine candidates left at the zero Disposition; red, and it is
272// worth noting they fail *closed* — the zero value doing its job
273func TestOneDocumentIsAskedAboutOnce(t *testing.T) {
274 var pairsSeen int64
275 var calls int64
276 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
277 atomic.AddInt64(&calls, 1)
278 var req suite.S2Request
279 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
280 t.Errorf("undecodable S2 request: %v", err)
281 return
282 }
283 atomic.AddInt64(&pairsSeen, int64(len(req.Pairs)))
284 _, _ = w.Write(answerBody(req.Pairs, suite.VISIBLE))
285 })
286 s.Name = "Sheets"
287
288 var cands []Candidate
289 for i := 0; i < 10; i++ {
290 cands = append(cands, Candidate{s, suite.ItemRevision{ItemID: "doc-a", Revision: 4}})
291 }
292 // One other document, so "send exactly one pair always" does not pass.
293 cands = append(cands, Candidate{s, suite.ItemRevision{ItemID: "doc-b", Revision: 1}})
294
295 got := newTestClient().Rule(context.Background(), askerForTest(), cands)
296
297 if pairsSeen != 2 {
298 t.Errorf("%d pairs went on the wire for 11 chunks of 2 documents, want 2 — "+
299 "a duplicated row decodes to UNKNOWN, so asking twice withholds "+
300 "a passage the reader is entitled to", pairsSeen)
301 }
302 if calls != 1 {
303 t.Errorf("%d calls, want 1", calls)
304 }
305 if len(got) != 11 {
306 t.Fatalf("%d dispositions for 11 candidates", len(got))
307 }
308 for i, d := range got {
309 if !d.Renders() {
310 t.Errorf("candidate %d = %v, want Render: one answer must reach "+
311 "every chunk of the document it was about", i, d)
312 }
313 }
314}
315
316// A sibling with more candidates than one batch allows gets several calls,
317// concurrently, and every pair still gets its own answer.
318//
319// Gate 19's mixed-provenance artefact is the case that spans batches, and
320// `Authorize` refuses an oversized batch outright rather than truncating
321// it — so without splitting here, a page over the cap withholds everything
322// from that sibling and blames the sibling.
323//
324// MUTATION-TESTED 2026-09-06, each verified red then reverted:
325//
326// pass the whole slice as one batch
327// -> CheckS2Batch refuses it, everything UNKNOWN; red
328// batches() drops the final short batch
329// -> the tail candidates sit at the zero Disposition; red
330func TestASiblingOverTheBatchCapIsSplitNotTruncated(t *testing.T) {
331 var calls int64
332 var seen int64
333 s := serve(t, func(w http.ResponseWriter, r *http.Request) {
334 atomic.AddInt64(&calls, 1)
335 var req suite.S2Request
336 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
337 t.Errorf("undecodable S2 request: %v", err)
338 return
339 }
340 if len(req.Pairs) > suite.MaxS2Pairs {
341 t.Errorf("a batch of %d exceeds the cap of %d", len(req.Pairs), suite.MaxS2Pairs)
342 }
343 atomic.AddInt64(&seen, int64(len(req.Pairs)))
344 // Each batch is slow enough that three of them in a row would be
345 // visible against the bound below, and fast enough that three at
346 // once are not.
347 select {
348 case <-time.After(300 * time.Millisecond):
349 case <-r.Context().Done():
350 return
351 }
352 _, _ = w.Write(answerBody(req.Pairs, suite.VISIBLE))
353 })
354 s.Name = "Sheets"
355
356 const n = suite.MaxS2Pairs*2 + 7 // two full batches and a short one
357 cands := make([]Candidate, n)
358 for i := range cands {
359 cands[i] = Candidate{s, suite.ItemRevision{
360 ItemID: fmt.Sprintf("doc-%d", i), Revision: int64(i),
361 }}
362 }
363
364 start := time.Now()
365 got := newTestClient().Rule(context.Background(), askerForTest(), cands)
366 elapsed := time.Since(start)
367
368 if calls != 3 {
369 t.Errorf("%d calls for %d pairs at a cap of %d, want 3", calls, n, suite.MaxS2Pairs)
370 }
371 // **The batches are concurrent, and this is what says so.** The claim
372 // was in page.go's comment with nothing holding it: a review made one
373 // goroutine per sibling with its batches sequential inside, and every
374 // assertion here stayed green because there was no timing at all. The
375 // bound is the spec's concurrency number for the same reason gate 23
376 // uses it — three 300ms calls concurrently is ~300ms, sequentially
377 // ~900ms, and 810ms separates them at both ends.
378 if elapsed > 810*time.Millisecond {
379 t.Errorf("three 300ms batches took %v: a page whose cost grows with "+
380 "the number of batches grows with the corpus", elapsed)
381 }
382 if seen != n {
383 t.Errorf("%d pairs reached the sibling, want %d: a batch was truncated, "+
384 "and the pairs that fell off the end withhold while looking like "+
385 "the sibling's fault", seen, n)
386 }
387 for i, d := range got {
388 if !d.Renders() {
389 t.Fatalf("candidate %d of %d = %v, want Render", i, n, d)
390 }
391 }
392}
393
394// Totality at the page boundary: no candidate ever goes unanswered, and
395// nothing about an empty page is special-cased into a nil the caller then
396// ranges over expecting content.
397func TestRuleAnswersEveryCandidate(t *testing.T) {
398 if got := newTestClient().Rule(context.Background(), askerForTest(), nil); len(got) != 0 {
399 t.Errorf("an empty page produced %d dispositions", len(got))
400 }
401
402 t.Run("an unreachable sibling still answers for every candidate", func(t *testing.T) {
403 dead := Sibling{Name: "Sheets", BaseURL: "http://127.0.0.1:1", Token: "t"}
404 cands := []Candidate{
405 {dead, suite.ItemRevision{ItemID: "a", Revision: 1}},
406 {dead, suite.ItemRevision{ItemID: "b", Revision: 1}},
407 }
408 got := newTestClient().Rule(context.Background(), askerForTest(), cands)
409 if len(got) != 2 {
410 t.Fatalf("%d dispositions for 2 candidates", len(got))
411 }
412 for i, d := range got {
413 if d.Renders() || !d.Notices() {
414 t.Errorf("candidate %d from an unreachable sibling = %v, want "+
415 "withheld loudly", i, d)
416 }
417 }
418 })
419}
420
421// The short-answer arm of `scatter`, which nothing on the wire can reach.
422//
423// `Authorize` returns exactly one Authorization per pair asked, always, so
424// no fixture makes its result short — which is why the fallback was a guard
425// that could not fail. A mutation proved it: changing the missing-answer
426// value from UNKNOWN to VISIBLE left every test in this package green.
427//
428// So the mapping is a function and this calls it directly. The property is
429// the one §4.1 keeps repeating: **a missing answer is not a permissive one,
430// and it is never the silent one.**
431//
432// MUTATION-TESTED 2026-09-06, each verified red then reverted:
433//
434// the fallback becomes suite.VISIBLE -> red (this is the one that
435// survived before the refactor)
436// the fallback becomes suite.NOT_VISIBLE -> red on Notices()
437// scatter indexes answers[j] blindly -> panic, red
438func TestAShortAnswerSliceWithholds(t *testing.T) {
439 batch := []suite.ItemRevision{
440 {ItemID: "a", Revision: 1},
441 {ItemID: "b", Revision: 1},
442 {ItemID: "c", Revision: 1},
443 }
444 positions := map[suite.ItemRevision][]int{
445 batch[0]: {0},
446 batch[1]: {1, 3}, // two chunks of the same document
447 batch[2]: {2},
448 }
449 out := make([]Disposition, 4)
450
451 // One answer for three pairs: the shape a contract change would produce.
452 scatter(batch, []suite.Authorization{suite.VISIBLE}, positions, out)
453
454 if !out[0].Renders() {
455 t.Error("the answer that did arrive was not applied, so this test " +
456 "would pass for a scatter that withholds unconditionally")
457 }
458 for _, i := range []int{1, 2, 3} {
459 if out[i].Renders() {
460 t.Errorf("candidate %d rendered on an answer that never came", i)
461 }
462 if !out[i].Notices() {
463 t.Errorf("candidate %d went silent on an answer that never came: a "+
464 "page cannot quietly shorten itself because a slice was short", i)
465 }
466 }
467}
468