Skip to content

Commit 2476fe9

Browse files
author
gluck
committed
phase 8: the presentation hierarchy is a form, and trunks.json dies
internal/trunk wanted the four things a form already has - durable, versioned, reducible, one writer - and had its own answer for each: an atomic-rename save(), a revision counter, a whole-file rewrite per edit, and a mutex the lock audit named. A promote edits TWO edges, so the rewrite could half-land where one patch cannot; TestTopologyForm_PromoteIsOneRecord is that claim, checked. It lives on a reserved stump, because a stump is the one node figwal names by a string the caller chooses: no marker file, no lookup, same id on every store, and it cannot be forked or bound. listStumps filters it out, or the hierarchy grows a row describing itself. trunks.json is folded in on first open and renamed. Ordering rather than a journal: the fold is idempotent, so a crash before the rename replays harmlessly, and a form that already holds edges is never migrated into. internal/trunk's own test suite is ported verbatim onto the replacement - same diagram, same claims, same names - so "it behaves as the file did" is checked rather than hoped. 458 lines of package and test deleted; the mutex goes with them.
1 parent 5af4246 commit 2476fe9

7 files changed

Lines changed: 590 additions & 510 deletions

File tree

‎internal/figaro/wire/wire.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ package wire
1010

1111
import (
1212
"github.com/jack-work/figaro/internal/store"
13-
"github.com/jack-work/figaro/internal/trunk"
1413
)
1514

1615
// Capabilities are the optional behaviours a server is built with. They are
@@ -30,7 +29,9 @@ func Install(s *store.XwalStore, root string, caps Capabilities) error {
3029
if !caps.Trunks {
3130
return nil
3231
}
33-
t, err := trunk.Open(root, s.TopologyAdjacency())
32+
// The presentation hierarchy is a FORM on a reserved stump, and it folds
33+
// a legacy trunks.json in on first open. See store/topology_form.go.
34+
t, err := store.OpenTopologyTree(s, root)
3435
if err != nil {
3536
return err
3637
}

‎internal/store/topology_form.go‎

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
package store
2+
3+
// The topology form: the presentation hierarchy as a FORM rather than as a
4+
// hand-rolled file.
5+
//
6+
// `trunks.json` wanted the four things a form already has - durable,
7+
// versioned, reducible, one writer - and had its own answer for each: an
8+
// atomic-rename save(), a revision counter beside it, a whole-file rewrite
9+
// per edit, and a mutex. A promote edits TWO edges, so a whole-file rewrite
10+
// could half-land where one patch cannot.
11+
//
12+
// It lives on a reserved STUMP, because a stump is the one node figwal names
13+
// by a string the caller chooses: no marker file, nothing to look up, and
14+
// the id is the same on every store. It is not a conversation, cannot be
15+
// forked, has no outfit, and is filtered out of every listing (see
16+
// listStumps).
17+
//
18+
// It holds OVERRIDES, never a full tree (durable-forms §1: absence is the
19+
// truthful default), so a lost topology form degrades to "draw every aria
20+
// where its history puts it" rather than to a wrong tree.
21+
22+
import (
23+
"encoding/json"
24+
"fmt"
25+
"os"
26+
"path/filepath"
27+
"sort"
28+
29+
"github.com/jack-work/figaro/internal/message"
30+
"github.com/jack-work/figaro/internal/topo"
31+
)
32+
33+
// topologyStump is the reserved name. Outfit stumps are "@" + a hex content
34+
// hash, so a name with a non-hex tail cannot collide with one.
35+
const topologyStump = "@topology"
36+
37+
// stumpFormLog is a FormLog over a stump's form channel. The same channel,
38+
// the same reducer and the same durability as an aria's board; only the node
39+
// it hangs on differs.
40+
type stumpFormLog struct {
41+
store *XwalStore
42+
stump string
43+
}
44+
45+
func (l *stumpFormLog) AppendPatch(payload []byte) (uint64, error) {
46+
x, err := l.store.trunks.StumpHead(l.stump)
47+
if err != nil {
48+
return 0, err
49+
}
50+
defer x.Close()
51+
return x.Append(chanForm, 0, payload, nil)
52+
}
53+
54+
func (l *stumpFormLog) SyncThrough(index uint64) error {
55+
x, err := l.store.trunks.StumpHead(l.stump)
56+
if err != nil {
57+
return err
58+
}
59+
defer x.Close()
60+
return x.SyncChannelThrough(chanForm, index)
61+
}
62+
63+
func (l *stumpFormLog) RangePatches(from, upTo uint64, fn func(uint64, []byte) error) error {
64+
x, err := l.store.trunks.StumpHead(l.stump)
65+
if err != nil {
66+
return err
67+
}
68+
defer x.Close()
69+
var first, last uint64
70+
for _, ch := range x.Channels() {
71+
if ch.Name == chanForm {
72+
first, last = ch.First, ch.Last
73+
break
74+
}
75+
}
76+
if first == 0 && last > 0 {
77+
first = 1
78+
}
79+
if from > first {
80+
first = from
81+
}
82+
if upTo > 0 && upTo < last {
83+
last = upTo
84+
}
85+
for lt := first; lt >= 1 && lt <= last; lt++ {
86+
rec, err := x.ReadAt(chanForm, lt)
87+
if err != nil {
88+
return err
89+
}
90+
if err := fn(lt, rec.Payload); err != nil {
91+
return err
92+
}
93+
}
94+
return nil
95+
}
96+
97+
// TopologyTree is topo.Tree over a form. Every mutator is one patch, so a
98+
// promote's two edges land together or not at all.
99+
type TopologyTree struct {
100+
form *Form
101+
topo topo.Topology
102+
}
103+
104+
// OpenTopologyTree mints the reserved stump if it is not there, replays the
105+
// form, and folds a legacy trunks.json in on the way past.
106+
func OpenTopologyTree(s *XwalStore, dir string) (*TopologyTree, error) {
107+
if err := s.ensureTopologyStump(); err != nil {
108+
return nil, err
109+
}
110+
f, err := OpenForm(&stumpFormLog{store: s, stump: topologyStump})
111+
if err != nil {
112+
return nil, err
113+
}
114+
t := &TopologyTree{form: f, topo: s.TopologyAdjacency()}
115+
if err := t.migrate(filepath.Join(dir, "trunks.json")); err != nil {
116+
return nil, err
117+
}
118+
return t, nil
119+
}
120+
121+
func (s *XwalStore) ensureTopologyStump() error {
122+
s.mu.Lock()
123+
defer s.mu.Unlock()
124+
for _, st := range s.trunks.Stumps() {
125+
if st.Name == topologyStump {
126+
return nil
127+
}
128+
}
129+
if err := s.trunks.CreateStump(topologyStump); err != nil {
130+
return fmt.Errorf("xwal store: create topology stump: %w", err)
131+
}
132+
return nil
133+
}
134+
135+
// migrate folds a legacy trunks.json into the form, ONCE, and renames it.
136+
//
137+
// Ordering, not a journal: the fold is idempotent (the same overrides
138+
// reduce to the same state), so a crash before the rename replays it
139+
// harmlessly. The rename is what says the file has been read, and a form
140+
// that already holds edges is never migrated into again.
141+
func (t *TopologyTree) migrate(path string) error {
142+
b, err := os.ReadFile(path)
143+
if os.IsNotExist(err) {
144+
return nil
145+
}
146+
if err != nil {
147+
return err
148+
}
149+
var s struct {
150+
Version int `json:"version"`
151+
Parent map[string]string `json:"parent,omitempty"`
152+
}
153+
if err := json.Unmarshal(b, &s); err != nil {
154+
return fmt.Errorf("topology: parse %s: %w", path, err)
155+
}
156+
if len(s.Parent) > 0 && len(t.Edges()) == 0 {
157+
set := make(map[string]json.RawMessage, len(s.Parent))
158+
for id, parent := range s.Parent {
159+
raw, err := json.Marshal(parent)
160+
if err != nil {
161+
return err
162+
}
163+
set[id] = raw
164+
}
165+
if _, _, err := t.form.ApplyEffectPrivileged(message.Patch{Set: set}, 0); err != nil {
166+
return fmt.Errorf("topology: migrate %s: %w", path, err)
167+
}
168+
}
169+
return os.Rename(path, path+".migrated")
170+
}
171+
172+
func (t *TopologyTree) Close() { t.form.Close() }
173+
174+
// Edges is the override map, decoded from the published snapshot.
175+
func (t *TopologyTree) Edges() map[string]string {
176+
snap, _ := t.form.Snapshot()
177+
out := map[string]string{}
178+
for k := range snap.All() {
179+
if v := snap.Lookup(k); v != nil && *v != "" {
180+
out[k] = *v
181+
}
182+
}
183+
return out
184+
}
185+
186+
// Rev is the form's version: it moves on every landed edit, which is exactly
187+
// what a listing's snapshot check wants.
188+
func (t *TopologyTree) Rev() uint64 { return t.form.Version() }
189+
190+
func (t *TopologyTree) Parent(id string) (string, bool) {
191+
snap, _ := t.form.Snapshot()
192+
if v := snap.Lookup(id); v != nil && *v != "" {
193+
return *v, true
194+
}
195+
return t.topo.From(id)
196+
}
197+
198+
func (t *TopologyTree) parentVia(over map[string]string) func(string) (string, bool) {
199+
return func(id string) (string, bool) {
200+
if p, ok := over[id]; ok {
201+
return p, true
202+
}
203+
return t.topo.From(id)
204+
}
205+
}
206+
207+
func (t *TopologyTree) Children(id string) []string {
208+
parentOf := t.parentVia(t.Edges())
209+
var out []string
210+
for _, n := range t.topo.Nodes() {
211+
if p, ok := parentOf(n); ok && p == id && n != id {
212+
out = append(out, n)
213+
}
214+
}
215+
sort.Strings(out)
216+
return out
217+
}
218+
219+
func (t *TopologyTree) DeleteSet(id string) []string {
220+
kids := topo.ChildIndex(t.topo, t.parentVia(t.Edges()))
221+
return topo.DescendantClosure(kids, id)
222+
}
223+
224+
func (t *TopologyTree) Normalized() bool {
225+
for id := range t.Edges() {
226+
if t.freeOfAncestry(id) {
227+
continue
228+
}
229+
return false
230+
}
231+
return true
232+
}
233+
234+
// freeOfAncestry reports that nothing above id could be taken away from it.
235+
func (t *TopologyTree) freeOfAncestry(id string) bool {
236+
up, ok := t.topo.From(id)
237+
if !ok || up == "" {
238+
return true
239+
}
240+
over, ok := t.topo.From(up)
241+
return ok && over == ""
242+
}
243+
244+
func (t *TopologyTree) Overridden() []string {
245+
over := t.Edges()
246+
out := make([]string, 0, len(over))
247+
for id := range over {
248+
if t.freeOfAncestry(id) {
249+
continue
250+
}
251+
out = append(out, id)
252+
}
253+
sort.Strings(out)
254+
return out
255+
}
256+
257+
// Promote raises id one level, as ONE patch. Two keys in one record is what
258+
// makes it atomic; the file it replaces rewrote the whole document and could
259+
// land half of it.
260+
func (t *TopologyTree) Promote(id string) error {
261+
parent, ok := t.Parent(id)
262+
if !ok || parent == "" {
263+
return fmt.Errorf("trunk: %q is already a root", id)
264+
}
265+
grand, _ := t.Parent(parent)
266+
if grand == id {
267+
return fmt.Errorf("trunk: %q and %q already swapped", id, parent)
268+
}
269+
idUp, _ := t.topo.From(id)
270+
parentUp, _ := t.topo.From(parent)
271+
272+
patch := message.Patch{Set: map[string]json.RawMessage{}}
273+
edge(&patch, id, grand, idUp)
274+
edge(&patch, parent, id, parentUp)
275+
return t.apply(patch)
276+
}
277+
278+
// edge records a presentation edge, REMOVING the override when it agrees
279+
// with the topology. One rule, so an aria promoted back to where its history
280+
// puts it leaves no trace; otherwise Normalized() stays false forever and
281+
// every later delete repairs an empty boundary.
282+
func edge(p *message.Patch, id, parent, topoParent string) {
283+
if parent == topoParent {
284+
p.Remove = append(p.Remove, id)
285+
return
286+
}
287+
raw, _ := json.Marshal(parent)
288+
p.Set[id] = raw
289+
}
290+
291+
func (t *TopologyTree) Reparent(id, parent string) error {
292+
up, _ := t.topo.From(id)
293+
patch := message.Patch{Set: map[string]json.RawMessage{}}
294+
edge(&patch, id, parent, up)
295+
return t.apply(patch)
296+
}
297+
298+
// Forget drops every edge that names one of these arias, in either
299+
// direction: for use once they are deleted. An edge POINTING at a deleted
300+
// aria goes too, so the survivor falls back to its history rather than
301+
// hanging off a parent that is no longer there.
302+
func (t *TopologyTree) Forget(ids ...string) error {
303+
gone := make(map[string]bool, len(ids))
304+
for _, id := range ids {
305+
gone[id] = true
306+
}
307+
var remove []string
308+
for id, up := range t.Edges() {
309+
if gone[id] || gone[up] {
310+
remove = append(remove, id)
311+
}
312+
}
313+
if len(remove) == 0 {
314+
return nil
315+
}
316+
sort.Strings(remove)
317+
return t.apply(message.Patch{Remove: remove})
318+
}
319+
320+
// apply writes through the form's privileged path: presentation is harness
321+
// state, and Ensure because a Forget may name an edge already gone.
322+
func (t *TopologyTree) apply(p message.Patch) error {
323+
_, _, err := t.form.applyEffect(p, 0, Ensure, true)
324+
return err
325+
}
326+
327+
var _ topo.Tree = (*TopologyTree)(nil)

0 commit comments

Comments
 (0)