Skip to content

Commit 4d8e9ed

Browse files
author
gluck
committed
form: memoise the parse; walk the path instead of rebuilding the board
Reading a board re-parsed every subtree it walked, and every write rebuilt the whole board through FromMap. Against the real default board that made a key lookup 3,456x slower than the tree it replaced. Value memoises its members, so a walk parses one node per level rather than one subtree per step. SetPath rebuilds only the path and shares the rest. applyObject writes members verbatim rather than re-marshalling them. before after main Get 86,657ns 179ns 25ns Diff 806,741ns 954ns 342ns Apply 287,537ns 3,350ns 483ns Marshal 105,251ns 109,602ns 151,486ns Still slower than the AVL tree for point reads: the board is bytes plus a parse memo rather than nodes, so a write re-encodes the objects on its path. At figaro's rate -- a patch per turn, a few hundred reads -- that is microseconds against a network round trip. Round-trip tests compare JSON rather than struct identity: a memo's state is not part of a value.
1 parent 5add153 commit 4d8e9ed

7 files changed

Lines changed: 343 additions & 34 deletions

File tree

‎api/form/diff.go‎

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -195,21 +195,7 @@ func windowFor(from, to []string) *Order {
195195
}
196196

197197
// asObject reports the value as a JSON object, and false for anything else.
198-
func asObject(v Value) (map[string]Value, bool) {
199-
raw := v.Raw()
200-
if len(raw) == 0 || firstToken(raw) != '{' {
201-
return nil, false
202-
}
203-
var m map[string]json.RawMessage
204-
if json.Unmarshal(raw, &m) != nil {
205-
return nil, false
206-
}
207-
out := make(map[string]Value, len(m))
208-
for k, r := range m {
209-
out[k] = NewValue(r)
210-
}
211-
return out, true
212-
}
198+
func asObject(v Value) (map[string]Value, bool) { return v.members() }
213199

214200
// asList reports the value as a KEYED list. A bare JSON array is NOT one: its
215201
// elements have no identity, so it is a scalar and changes wholesale. Keys are

‎api/form/form.go‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,7 @@ func Additive(s Snapshot, p Patch) Patch {
198198

199199
// SetPath returns a snapshot with key set to v, creating intermediate objects.
200200
func (s Snapshot) SetPath(key string, v json.RawMessage) Snapshot {
201-
flat := map[string]json.RawMessage{}
202-
for k, val := range s.All() {
203-
flat[k] = val
204-
}
205-
flat[key] = v
206-
return FromMap(flat)
201+
return Snapshot{root: setIn(s.Root(), strings.Split(key, "."), NewValue(v))}
207202
}
208203

209204
// DeletePath returns a snapshot without key.
@@ -456,3 +451,35 @@ func encodeRawObject(m map[string]json.RawMessage) []byte {
456451
}
457452
return append(b, '}')
458453
}
454+
455+
// setIn rebuilds only the path to the leaf, sharing every subtree it does not
456+
// touch. Where a segment lands on a leaf, the rest of the path stays whole:
457+
// the key names one thing whose own name contains a dot.
458+
func setIn(node Value, segs []string, v Value) Value {
459+
if len(segs) == 0 {
460+
return v
461+
}
462+
members, isObj := node.members()
463+
next := make(map[string]json.RawMessage, len(members)+1)
464+
for k, m := range members {
465+
next[k] = m.Raw()
466+
}
467+
if !isObj {
468+
next = map[string]json.RawMessage{}
469+
}
470+
if len(segs) == 1 {
471+
next[segs[0]] = v.Raw()
472+
return NewValue(encodeRawObject(next))
473+
}
474+
child, exists := members[segs[0]]
475+
if exists {
476+
if _, childIsObj := child.members(); !childIsObj {
477+
next[strings.Join(segs, ".")] = v.Raw()
478+
return NewValue(encodeRawObject(next))
479+
}
480+
} else {
481+
child = NewValue(json.RawMessage(`{}`))
482+
}
483+
next[segs[0]] = setIn(child, segs[1:], v).Raw()
484+
return NewValue(encodeRawObject(next))
485+
}

‎api/form/patch.go‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,15 @@ func (p Patch) Apply(v Value) (Value, error) {
173173
}
174174

175175
func (p Patch) applyObject(v Value) (Value, error) {
176-
obj, err := decodeObject(v)
177-
if err != nil {
178-
return Value{}, err
176+
src, isObj := v.members()
177+
if !isObj && len(v.Raw()) > 0 && string(v.Raw()) != "null" {
178+
return Value{}, fmt.Errorf("object patch against a non-object")
179+
}
180+
// Copied because the memo behind members() is shared with every other
181+
// reader of this value.
182+
obj := make(map[string]Value, len(src)+len(p.Object.Set))
183+
for k, val := range src {
184+
obj[k] = val
179185
}
180186
for k, nv := range p.Object.Set {
181187
// A write of an equal value keeps the bytes already stored. Providers
@@ -202,7 +208,7 @@ func (p Patch) applyObject(v Value) (Value, error) {
202208
}
203209
obj[k] = next
204210
}
205-
return encodeObject(obj)
211+
return encodeObject(obj), nil
206212
}
207213

208214
func (p Patch) applyList(v Value) (Value, error) {
@@ -530,16 +536,14 @@ func decodeObject(v Value) (map[string]Value, error) {
530536
return out, nil
531537
}
532538

533-
func encodeObject(m map[string]Value) (Value, error) {
539+
// encodeObject writes the members verbatim: their bytes are already JSON and
540+
// re-marshalling them was the cost of every apply.
541+
func encodeObject(m map[string]Value) Value {
534542
raw := make(map[string]json.RawMessage, len(m))
535543
for k, v := range m {
536544
raw[k] = v.Raw()
537545
}
538-
b, err := json.Marshal(raw)
539-
if err != nil {
540-
return Value{}, err
541-
}
542-
return NewValue(b), nil
546+
return NewValue(encodeRawObject(raw))
543547
}
544548

545549
// decodeList reads a value as a keyed list: an array of {Key, Value}.

‎api/form/perf_test.go‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package form_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/jack-work/figaro/api/form"
12+
)
13+
14+
// The numbers that justify the change, against the real default board.
15+
//
16+
// The headline is patch SIZE: what a one-field edit writes to the form channel
17+
// and re-renders into a turn, since that cost is paid on every change forever.
18+
func BenchmarkOneFieldEdit(b *testing.B) {
19+
m := realBoardFor(b)
20+
board := form.FromMap(m)
21+
22+
// The largest object-valued key on the board: what a flat patch rewrote
23+
// whole to change one field.
24+
target, field, size := largestObjectKey(b, m)
25+
b.Logf("target %s.%s, value is %d bytes", target, field, size)
26+
27+
b.Run("patch-bytes", func(b *testing.B) {
28+
b.ReportAllocs()
29+
for b.Loop() {
30+
next := board.SetPath(target+"."+field, json.RawMessage(`"changed"`))
31+
p := next.Diff(board)
32+
raw, err := json.Marshal(p)
33+
if err != nil {
34+
b.Fatal(err)
35+
}
36+
b.ReportMetric(float64(len(raw)), "patch-bytes")
37+
}
38+
})
39+
}
40+
41+
// A one-field edit, measured as bytes, against the whole value a flat patch
42+
// would have carried.
43+
func TestPatchSizeAgainstWholeValueRewrite(t *testing.T) {
44+
m := realBoardFor(t)
45+
board := form.FromMap(m)
46+
target, field, size := largestObjectKey(t, m)
47+
48+
next := board.SetPath(target+"."+field, json.RawMessage(`"changed"`))
49+
p := next.Diff(board)
50+
raw, err := json.Marshal(p)
51+
require.NoError(t, err)
52+
53+
t.Logf("key %s holds %d bytes; a one-field patch is %d bytes (%.1fx smaller)",
54+
target, size, len(raw), float64(size)/float64(len(raw)))
55+
require.Less(t, len(raw), size/2,
56+
"a one-field edit must not carry the whole value")
57+
}
58+
59+
func largestObjectKey(tb testing.TB, m map[string]json.RawMessage) (key, field string, size int) {
60+
tb.Helper()
61+
for k, v := range m {
62+
var obj map[string]json.RawMessage
63+
if json.Unmarshal(v, &obj) != nil || len(obj) == 0 {
64+
continue
65+
}
66+
if len(v) > size {
67+
key, size = k, len(v)
68+
for f := range obj {
69+
field = f
70+
}
71+
}
72+
}
73+
if key == "" {
74+
tb.Skip("no object-valued key on the fixture board")
75+
}
76+
return key, field, size
77+
}
78+
79+
func realBoardFor(tb testing.TB) map[string]json.RawMessage {
80+
tb.Helper()
81+
data, err := readFixture()
82+
require.NoError(tb, err)
83+
var m map[string]json.RawMessage
84+
require.NoError(tb, json.Unmarshal(data, &m))
85+
return m
86+
}
87+
88+
func readFixture() ([]byte, error) {
89+
for _, p := range []string{"testdata/board-default.json"} {
90+
if b, err := os.ReadFile(p); err == nil {
91+
return b, nil
92+
}
93+
}
94+
return nil, fmt.Errorf("fixture not found")
95+
}

‎api/form/regression_test.go‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package form_test
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/jack-work/figaro/api/form"
11+
)
12+
13+
// Each test here fails against the flat implementation. They are the argument
14+
// for the change, stated as things that either work or do not.
15+
16+
// A key whose own name contains a dot is addressable alongside a key that is
17+
// its prefix. Gluck's store holds both: skills.howto is a skill, and
18+
// skills.howto.md is a different skill.
19+
func TestCollidingKeysAreBothAddressable(t *testing.T) {
20+
s := form.FromMap(map[string]json.RawMessage{
21+
"skills.howto": json.RawMessage(`{"filePath":"/a.md"}`),
22+
"skills.howto.md": json.RawMessage(`{"filePath":"/b.md"}`),
23+
"succession": json.RawMessage(`["one"]`),
24+
"succession.plan": json.RawMessage(`"handover"`),
25+
})
26+
for k, want := range map[string]string{
27+
"skills.howto": `{"filePath":"/a.md"}`,
28+
"skills.howto.md": `{"filePath":"/b.md"}`,
29+
"succession": `["one"]`,
30+
"succession.plan": `"handover"`,
31+
} {
32+
got, ok := s.Get(k)
33+
require.True(t, ok, "%s is unreachable", k)
34+
assert.JSONEq(t, want, string(got), k)
35+
}
36+
}
37+
38+
// One field of a large value changes without rewriting the value. The credo on
39+
// Gluck's board is 6.5KB and was rewritten whole to change one character.
40+
func TestOneFieldOfALargeValueIsItsOwnChange(t *testing.T) {
41+
big := make([]byte, 6000)
42+
for i := range big {
43+
big[i] = 'x'
44+
}
45+
prev := form.FromMap(map[string]json.RawMessage{
46+
"system.credo": mustJSON(t, map[string]string{
47+
"content": string(big), "filePath": "/credo.md", "frontmatter": "name: figaro",
48+
}),
49+
})
50+
next := prev.SetPath("system.credo.filePath", json.RawMessage(`"/moved.md"`))
51+
52+
p := next.Diff(prev)
53+
es := p.Entries()
54+
require.Len(t, es, 1, "one field changed, so one entry: %v", es)
55+
assert.Equal(t, "system.credo.filePath", es[0].Key)
56+
57+
// The 6KB of content is not in the patch.
58+
raw, err := json.Marshal(p)
59+
require.NoError(t, err)
60+
assert.Less(t, len(raw), 300, "the patch carries the untouched content")
61+
62+
// And it still applies.
63+
got, ok := prev.Apply(p).Get("system.credo.filePath")
64+
require.True(t, ok)
65+
assert.JSONEq(t, `"/moved.md"`, string(got))
66+
}
67+
68+
// Protection sees a write at any depth. A flat patch could only name top-level
69+
// keys, so a write into a protected subtree was invisible to CheckWritable.
70+
func TestProtectionSeesANestedWrite(t *testing.T) {
71+
var managed string
72+
for _, k := range form.WellKnownKeys() {
73+
if k.Mode == form.KeySystemManaged {
74+
managed = k.Key
75+
break
76+
}
77+
}
78+
require.NotEmpty(t, managed)
79+
80+
p := form.Build(form.Snapshot{}, map[string]json.RawMessage{
81+
managed: json.RawMessage(`"forged"`),
82+
}, nil)
83+
assert.Error(t, form.CheckWritable(p, false),
84+
"a write to %s must be refused however deep it sits", managed)
85+
}
86+
87+
// A patch can be undone from itself, with no snapshot in hand.
88+
func TestAPatchCarriesItsOwnUndo(t *testing.T) {
89+
before := form.FromMap(map[string]json.RawMessage{
90+
"mantra": json.RawMessage(`"first"`),
91+
"system.model": json.RawMessage(`"opus"`),
92+
"doomed": json.RawMessage(`"here"`),
93+
})
94+
after := before.
95+
SetPath("mantra", json.RawMessage(`"second"`)).
96+
SetPath("system.cwd", json.RawMessage(`"/tmp"`)).
97+
DeletePath("doomed")
98+
99+
p := after.Diff(before)
100+
restored := after.Apply(p.Inverse())
101+
102+
for _, k := range []string{"mantra", "system.model", "doomed"} {
103+
want, _ := before.Get(k)
104+
got, ok := restored.Get(k)
105+
require.True(t, ok, "%s was not restored", k)
106+
assert.JSONEq(t, string(want), string(got), k)
107+
}
108+
_, hasNew := restored.Get("system.cwd")
109+
assert.False(t, hasNew, "undo must remove what the patch added")
110+
}
111+
112+
// Two writers touching different fields of one parent do not clobber each
113+
// other. This is what dropped system.credo when an outfit set system.model.
114+
func TestSiblingWritesUnderOneParentCompose(t *testing.T) {
115+
a := form.Build(form.Snapshot{}, map[string]json.RawMessage{"system.credo": json.RawMessage(`"largo"`)}, nil)
116+
b := form.Build(form.Snapshot{}, map[string]json.RawMessage{"system.model": json.RawMessage(`"opus"`)}, nil)
117+
118+
board := form.Snapshot{}.Apply(a).Apply(b)
119+
credo, ok := board.Get("system.credo")
120+
require.True(t, ok, "the second write clobbered the first")
121+
assert.JSONEq(t, `"largo"`, string(credo))
122+
model, ok := board.Get("system.model")
123+
require.True(t, ok)
124+
assert.JSONEq(t, `"opus"`, string(model))
125+
}
126+
127+
// A value that is not valid JSON is refused, not silently dropped.
128+
func TestAnInvalidValueIsRefusedNotDropped(t *testing.T) {
129+
s := form.FromMap(map[string]json.RawMessage{"bad": json.RawMessage(`{not json`)})
130+
_, err := json.Marshal(s)
131+
assert.Error(t, err, "a board that cannot be serialised must say so")
132+
}
133+
134+
func mustJSON(t *testing.T, v any) json.RawMessage {
135+
t.Helper()
136+
b, err := json.Marshal(v)
137+
require.NoError(t, err)
138+
return b
139+
}

0 commit comments

Comments
 (0)