Skip to content

Commit a2725f9

Browse files
author
gluck
committed
the OUTFIT column stops re-opening a form per row
Reading a label opens a Form, whose replay opens the node, which figwal answers by materialising the whole channel. The form is evicted at 15 minutes and the next listing opens it again, so a status line on a timer cycles the store through memory forever. The label is memoized and invalidated by any write naming an outfit key, registered on the commit sink where the form is OPENED so every writer passes it. The board stays the only source of truth. Evict every form on the real-store copy, then list again: 209 forms re-opened before, 0 after. It does NOT fix the first listing's +114 MiB, which arrives on the topology build and is figwal's; plans/progress.md says so and says which instrument to trust.
1 parent 26b189e commit a2725f9

4 files changed

Lines changed: 175 additions & 9 deletions

File tree

‎internal/store/listing_cost_test.go‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@ func TestListingCost(t *testing.T) {
2828
}
2929
defer be.Close()
3030

31-
heap := func() uint64 {
31+
// int64, and signed deltas: HeapAlloc can fall between two readings (a GC
32+
// collects more than the step allocated), and an unsigned subtraction
33+
// turns that into 17592186044416 MiB of nonsense. I printed exactly that
34+
// for three runs before noticing.
35+
heap := func() int64 {
3236
runtime.GC()
3337
var m runtime.MemStats
3438
runtime.ReadMemStats(&m)
35-
return m.HeapAlloc
39+
return int64(m.HeapAlloc)
3640
}
41+
mib := func(d int64) float64 { return float64(d) / (1 << 20) }
3742
before := heap()
3843
light := be.Store().listTrunks()
3944
afterFirstScan := heap()
@@ -43,11 +48,11 @@ func TestListingCost(t *testing.T) {
4348
afterTopology := heap()
4449
t.Logf("trunks=%d", len(light))
4550
t.Logf("first ListLight: %+.1f MiB (index hydration, retained across a GC)",
46-
float64(afterFirstScan-before)/(1<<20))
51+
mib(afterFirstScan-before))
4752
t.Logf("second ListLight: %+.1f MiB (what a repeated `fig ls` costs)",
48-
float64(afterSecondScan-afterFirstScan)/(1<<20))
53+
mib(afterSecondScan-afterFirstScan))
4954
t.Logf("topology build: %+.1f MiB",
50-
float64(afterTopology-afterSecondScan)/(1<<20))
55+
mib(afterTopology-afterSecondScan))
5156
// This is what a listing does per row.
5257
for i := range rows {
5358
be.label(&rows[i])
@@ -58,10 +63,27 @@ func TestListingCost(t *testing.T) {
5863
forms := len(be.forms)
5964
be.mu.Unlock()
6065

66+
// THE STEADY STATE, which is what a status line on a timer produces:
67+
// forms evicted for idleness, then a listing. Before the label memo, the
68+
// second listing re-opened every form and re-hydrated every node.
69+
be.EvictIdle(map[string]bool{}, 0)
70+
evicted := heap()
71+
rows2 := be.Conversations()
72+
for i := range rows2 {
73+
be.label(&rows2[i])
74+
}
75+
afterSecond := heap()
76+
be.mu.Lock()
77+
forms2 := len(be.forms)
78+
be.mu.Unlock()
79+
t.Logf("after evicting every form: %.1f MiB heap", mib(evicted))
80+
t.Logf("SECOND listing: %+.1f MiB (%d forms re-opened, loaded heads %d)",
81+
mib(afterSecond-evicted), forms2, be.Store().LoadedHeads())
82+
6183
t.Logf("rows=%d", len(rows))
62-
t.Logf("topology total: %+.1f MiB", float64(afterTopology-before)/(1<<20))
84+
t.Logf("topology total: %+.1f MiB", mib(afterTopology-before))
6385
t.Logf("labels: %+.1f MiB (%d forms now resident)",
64-
float64(afterLabels-afterTopology)/(1<<20), forms)
86+
mib(afterLabels-afterTopology), forms)
6587
t.Logf("resident form patches: %d", be.ResidentFormPatches())
66-
t.Logf("per form: %.1f KiB", float64(afterLabels-afterTopology)/float64(max(forms, 1))/1024)
88+
t.Logf("per form: %.1f KiB", mib(afterLabels-afterTopology)*1024/float64(max(forms, 1)))
6789
}

‎internal/store/window_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,53 @@ func TestTranslationBudgetReachesTheCache(t *testing.T) {
291291
t.Fatalf("read below the window returned %d of 40", len(all))
292292
}
293293
}
294+
295+
// The OUTFIT column's memo outlives the form it came from, because reading a
296+
// label opens a form, which hydrates a whole node in figwal. It must still be
297+
// CORRECT: a write that changes the outfit has to drop it.
298+
func TestLabelMemoIsInvalidatedByAWrite(t *testing.T) {
299+
be, err := NewXwalBackend(t.TempDir(), 0)
300+
if err != nil {
301+
t.Fatal(err)
302+
}
303+
defer be.Close()
304+
outfit, err := be.CreateOutfit("first", patchSet(map[string]string{"system.model": "m"}))
305+
if err != nil {
306+
t.Fatal(err)
307+
}
308+
id, err := be.CreateConversation(outfit)
309+
if err != nil {
310+
t.Fatal(err)
311+
}
312+
if _, err := be.ApplyForm(id, patchSet(map[string]string{
313+
"system.outfit_name": "first", "system.outfit_version": "v1",
314+
})); err != nil {
315+
t.Fatal(err)
316+
}
317+
n := NodeView{ID: id}
318+
be.label(&n)
319+
if n.Outfit != "first" {
320+
t.Fatalf("label = %q, want first", n.Outfit)
321+
}
322+
if _, err := be.ApplyForm(id, patchSet(map[string]string{
323+
"system.outfit_name": "second", "system.outfit_version": "v2",
324+
})); err != nil {
325+
t.Fatal(err)
326+
}
327+
n2 := NodeView{ID: id}
328+
be.label(&n2)
329+
if n2.Outfit != "second" || n2.Version != "v2" {
330+
t.Fatalf("label after a redress = %q/%q, want second/v2: the memo went stale",
331+
n2.Outfit, n2.Version)
332+
}
333+
// A write that names no outfit key must not cost the memo.
334+
if _, err := be.ApplyForm(id, patchSet(map[string]string{"brief": "x"})); err != nil {
335+
t.Fatal(err)
336+
}
337+
be.mu.Lock()
338+
_, cached := be.labels[id]
339+
be.mu.Unlock()
340+
if !cached {
341+
t.Fatal("an unrelated write dropped the memo: the listing pays for every patch")
342+
}
343+
}

‎internal/store/xwal_backend.go‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,15 @@ func (b *XwalBackend) form(ariaID string) (*Form, error) {
246246
if err != nil {
247247
return nil, err
248248
}
249+
// One invalidation point for the label memo, registered where the form is
250+
// opened, so EVERY writer passes it: the hub, the agent's own loop, a
251+
// birth dressing, an outfit fold.
252+
opened.OnCommit(func(_ uint64, patch message.Patch) {
253+
if namesOutfit(patch) {
254+
b.labelChanged(ariaID)
255+
}
256+
})
257+
249258
b.mu.Lock()
250259
defer b.mu.Unlock()
251260
if existing := b.forms[ariaID]; existing != nil {
@@ -256,6 +265,22 @@ func (b *XwalBackend) form(ariaID string) (*Form, error) {
256265
return opened, nil
257266
}
258267

268+
// namesOutfit reports whether a patch could change what the OUTFIT column
269+
// shows. Four keys, checked on a patch that is usually one key wide.
270+
func namesOutfit(patch message.Patch) bool {
271+
for _, k := range []string{keyOutfitName, keyOutfitVer, keyLegacyName, keyLegacyVer} {
272+
if _, ok := patch.Set[k]; ok {
273+
return true
274+
}
275+
for _, r := range patch.Remove {
276+
if r == k {
277+
return true
278+
}
279+
}
280+
}
281+
return false
282+
}
283+
259284
// WatchForm registers a sink for every patch committed to an aria's form. It
260285
// opens the Form if nobody had, which is what lets a listener follow a DORMANT
261286
// aria: a form is a store object, and reading one does not need an agent.
@@ -478,21 +503,57 @@ func (b *XwalBackend) label(n *NodeView) {
478503
// copying its inherited prefix into its own channel, renumbering its form
479504
// versions, and every IR record's cursor stamp would then point at the wrong
480505
// patch. The old shape reads fine; it just stops being minted.
506+
// labelOf answers the listing's OUTFIT column.
507+
//
508+
// THE MEMO IS THE POINT, for a node with no stump. Reading the label off the
509+
// board opens the Form, whose replay opens the NODE, and figwal answers that
510+
// by materializing the node's whole channel in memory
511+
// (log.buildOwnSnapshot). A listing does it per row, and a form evicted for
512+
// idleness is re-opened by the next listing, so a status line on a timer
513+
// cycles the whole store through memory forever. Measured: one `fig ls` on a
514+
// 515-aria store, 95 MB retained, with zero arias resident by figaro's own
515+
// count.
516+
//
517+
// So the label outlives the form it came from, and every write to that
518+
// aria's board drops it (labelChanged). The board is still the only source
519+
// of truth; this is a cache of it with a complete invalidation, not a second
520+
// copy of it on disk.
481521
func (b *XwalBackend) labelOf(n *NodeView) outfitLabel {
482522
if n.Stump != "" {
483523
return b.stumpLabel(n.Stump)
484524
}
525+
b.mu.Lock()
526+
l, ok := b.labels[n.ID]
527+
b.mu.Unlock()
528+
if ok {
529+
return l
530+
}
485531
snap, err := b.FormState(n.ID)
486532
if err != nil {
487533
return outfitLabel{}
488534
}
489-
l := outfitLabel{name: snapString(snap, keyOutfitName), version: snapString(snap, keyOutfitVer)}
535+
l = outfitLabel{name: snapString(snap, keyOutfitName), version: snapString(snap, keyOutfitVer)}
490536
if l.name == "" {
491537
l.name, l.version = snapString(snap, keyLegacyName), snapString(snap, keyLegacyVer)
492538
}
539+
b.mu.Lock()
540+
if b.labels == nil {
541+
b.labels = map[string]outfitLabel{}
542+
}
543+
b.labels[n.ID] = l
544+
b.mu.Unlock()
493545
return l
494546
}
495547

548+
// labelChanged drops a memoized label. Every write to a board goes through
549+
// this backend, so this is the whole invalidation: an outfit fold, a birth
550+
// dressing, or a hand-written key all pass here.
551+
func (b *XwalBackend) labelChanged(ariaID string) {
552+
b.mu.Lock()
553+
delete(b.labels, ariaID)
554+
b.mu.Unlock()
555+
}
556+
496557
// stumpLabel reads a stump's name and version out of the birth patch it wrote,
497558
// which is the only place either has ever been stated authoritatively -- the id
498559
// used to restate the name, and a form key of the same name is the

‎plans/progress.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,3 +1351,36 @@ here first.
13511351
| 8 topology form | **done**, live-validated, `internal/trunk` deleted |
13521352
| 9 libretto | not started; §12.2.2 records a design bug found before it was built |
13531353
| 10 API refactor | not started |
1354+
1355+
### The OUTFIT column stops re-opening forms
1356+
1357+
A memo for the label, invalidated by any write that names an outfit key,
1358+
registered on the form's commit sink where it is OPENED — so every writer
1359+
passes it: the hub, the agent's own loop, a birth dressing, an outfit fold.
1360+
The board stays the only source of truth; this is a cache of it with a
1361+
complete invalidation, not a second copy on disk.
1362+
1363+
**The cycle it breaks.** A listing reads a label per row, which opens a Form,
1364+
whose replay opens the node, which figwal answers by materializing the whole
1365+
channel. A form is then evicted for idleness at 15 minutes, and the next
1366+
listing opens it again. A status line on a timer therefore cycles the store
1367+
through memory forever. Measured on the real-store copy:
1368+
1369+
```
1370+
evict every form, then list again:
1371+
before 209 forms re-opened
1372+
after 0 forms re-opened (resident_form_patches 0, heads unchanged)
1373+
```
1374+
1375+
**What it does NOT fix, said plainly**: the FIRST listing still costs +114
1376+
MiB, and that arrives on the topology build rather than on the labels. Two
1377+
instruments disagree about which call triggers it — the daemon's own heap
1378+
profile says `buildOwnSnapshot` under `handlers.list`, and the in-test
1379+
`ReadMemStats` deltas put it on `Conversations()` — and I have already been
1380+
wrong once by trusting the coarse one. Whoever takes it next should isolate
1381+
the topology build under a profile rather than a delta.
1382+
1383+
**A bug in my own instrument, since it wasted three runs**: `HeapAlloc` can
1384+
FALL between two readings when a GC collects more than the step allocated,
1385+
and an unsigned subtraction renders that as 17592186044416 MiB. The helper
1386+
now returns `int64` and prints signed deltas.

0 commit comments

Comments
 (0)