Skip to content

Commit 4052ca3

Browse files
author
gluck
committed
the translator reads nothing for a record it has already encoded
ProjectIncrementally derived the board's patches and every studied form's patches for every record, and then looked up the per-LT translation cache and discarded all of it on a hit. One board read plus one read per observed form, per record, to build a message that was never encoded. Fifty observers was fifty-one reads to produce nothing. The cache is consulted first now. A hit advances the cursors from the entry's own stamps, which is where that fact already lived: a stamp is where a form stood, and knowing it never required reading it. That alone is incorrect. snap advances only from msg.Patches, is what Encode renders transitions against, and is returned as IncrementalProjection.Form and read back as the next warm start. Skip the derivation and the board freezes, the next uncached record renders against it, and the stale value is inherited by every later pass. TestCatchUpReplaysCachedPrefixSnapshot caught exactly that on the first run, which is why it exists. So the board is caught up in ONE range read spanning everything the cached prefix skipped, at the first record that actually needs it, rather than N reads that were only ever folding into a value. Where the board stands is now carried explicitly (FormVersionOfSnapshot) instead of being assumed equal to LastFormVersion, because after a run of hits it is not. Ephemeral arias carry their patches on the record and have no accessor to catch up from, so a hit folds the inline ones. They are already decoded; it costs no read. ColdWalkWarmCache 794us -> 292us 1.93MB -> 655KB ColdWalkWarmCache8Observed 1369us -> 514us 2.36MB -> 758KB ColdWalkColdCache (control) 1.39ms -> 1.39ms unchanged The control is the point: a pass with no cache hits does not move, so the win is the discarded work and nothing else.
1 parent 26b189e commit 4052ca3

2 files changed

Lines changed: 293 additions & 28 deletions

File tree

‎internal/provider/projection.go‎

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ type IncrementalProjection[T any] struct {
2323
// study half of the cursor stamp), and must survive a warm start for
2424
// the same reason.
2525
LastStudyVersions map[string]uint64
26+
// FormVersionOfSnapshot is where Form actually stands, which is not
27+
// LastFormVersion once a run of cached entries has been skipped. Both
28+
// must survive a warm start or the next pass folds the wrong span.
29+
FormVersionOfSnapshot uint64
2630
}
2731

2832
type ProjectionStats struct {
@@ -65,7 +69,7 @@ type ProjectionConfig[T any] struct {
6569
func ProjectIncrementally[T any](config ProjectionConfig[T]) (*IncrementalProjection[T], ProjectionStats, error) {
6670
state := config.Initial
6771
snap := form.Snapshot{}
68-
var lastForm uint64
72+
var lastForm, snapAt uint64
6973
lastStudy := map[string]uint64{}
7074

7175
// A warm start reads from the watermark; a cold one reads everything.
@@ -88,6 +92,7 @@ func ProjectIncrementally[T any](config ProjectionConfig[T]) (*IncrementalProjec
8892
state = previous.State
8993
snap = previous.Form
9094
lastForm = previous.LastFormVersion
95+
snapAt = previous.FormVersionOfSnapshot
9196
for k, v := range previous.LastStudyVersions {
9297
lastStudy[k] = v
9398
}
@@ -104,7 +109,52 @@ func ProjectIncrementally[T any](config ProjectionConfig[T]) (*IncrementalProjec
104109
if msg.Role == message.RoleGenesis {
105110
continue
106111
}
112+
113+
// THE CACHE FIRST. A record whose bytes are already encoded needs no
114+
// patches: they exist only to be rendered, and the rendering is what
115+
// was cached. Deriving them and then discarding them cost one board
116+
// read plus one read per observed form, per record, on every cold
117+
// pass over a warm cache -- which is what a fingerprint bump or a
118+
// rejected watermark produces.
119+
//
120+
// The cursors still advance, from the entry's own stamps. A stamp is
121+
// where a form stood, and knowing that never required reading it.
122+
if cached, ok := lookupCached(config, entry); ok {
123+
stats.Cached++
124+
lastForm = maxU64(lastForm, entry.FormChannelVersion)
125+
for fid, upTo := range entry.StudyVersions {
126+
lastStudy[fid] = maxU64(lastStudy[fid], upTo)
127+
}
128+
// An EPHEMERAL aria has no accessor and carries its patches on
129+
// the record itself, so there is nothing to catch up from later.
130+
// They are already decoded here; applying them costs no read.
131+
if config.Form == nil {
132+
for _, patch := range msg.Patches {
133+
snap = snap.Apply(patch)
134+
}
135+
}
136+
if len(cached) > 0 {
137+
state = config.Append(state, cached, entry.LT)
138+
}
139+
continue
140+
}
141+
142+
// A MISS, so the board must be real. Skipping the derivation above
143+
// left `snap` behind wherever the last encoded record put it, and
144+
// `snap` is what Encode renders transitions against. Catch it up in
145+
// ONE range read spanning everything skipped, rather than the N reads
146+
// the per-record derivation used to do.
107147
if config.Form != nil {
148+
// Only when something was actually skipped. The guard is not an
149+
// optimisation of a nil result: it is one read per record that a
150+
// segment-backed accessor would otherwise perform to be told
151+
// nothing, and this loop runs once per record forever.
152+
if snapAt < lastForm {
153+
for _, p := range config.Form.PatchesBetween(snapAt, lastForm) {
154+
snap = snap.Apply(p)
155+
}
156+
snapAt = lastForm
157+
}
108158
// (after, upTo]: the previous entry's mark and this one's. Absolute,
109159
// so a warm start renders exactly the same patches a cold walk would.
110160
msg.Patches = config.Form.PatchesBetween(lastForm, entry.FormChannelVersion)
@@ -140,35 +190,21 @@ func ProjectIncrementally[T any](config ProjectionConfig[T]) (*IncrementalProjec
140190
}
141191
}
142192

143-
var encoded []json.RawMessage
144-
if config.Cache != nil {
145-
if cached, ok := config.Cache.Lookup(entry.LT); ok &&
146-
(cached.Fingerprint == "" || cached.Fingerprint == config.Fingerprint) &&
147-
len(cached.Payload) > 0 {
148-
encoded = cached.Payload
149-
stats.Cached++
193+
encoded, err := config.Encode(msg, snap)
194+
if err != nil {
195+
if config.ReportEncodeError != nil {
196+
config.ReportEncodeError(entry.LT, err)
150197
}
198+
return nil, stats, err
151199
}
152-
if encoded == nil {
153-
var err error
154-
encoded, err = config.Encode(msg, snap)
155-
if err != nil {
156-
if config.ReportEncodeError != nil {
157-
config.ReportEncodeError(entry.LT, err)
158-
}
159-
return nil, stats, err
160-
} else {
161-
stats.Encoded++
162-
if config.Cache != nil && len(encoded) > 0 {
163-
_, err = config.Cache.Append(store.Entry[[]json.RawMessage]{
164-
FigaroLT: entry.LT,
165-
Payload: encoded,
166-
Fingerprint: config.Fingerprint,
167-
})
168-
if err != nil && config.HandleCacheError != nil {
169-
config.HandleCacheError(entry.LT, err)
170-
}
171-
}
200+
stats.Encoded++
201+
if config.Cache != nil && len(encoded) > 0 {
202+
if _, cerr := config.Cache.Append(store.Entry[[]json.RawMessage]{
203+
FigaroLT: entry.LT,
204+
Payload: encoded,
205+
Fingerprint: config.Fingerprint,
206+
}); cerr != nil && config.HandleCacheError != nil {
207+
config.HandleCacheError(entry.LT, cerr)
172208
}
173209
}
174210
if len(encoded) > 0 {
@@ -177,6 +213,7 @@ func ProjectIncrementally[T any](config ProjectionConfig[T]) (*IncrementalProjec
177213
for _, patch := range msg.Patches {
178214
snap = snap.Apply(patch)
179215
}
216+
snapAt = maxU64(snapAt, entry.FormChannelVersion)
180217
}
181218

182219
// lastLT must be the tail of the WHOLE log, not of the suffix we walked:
@@ -194,9 +231,34 @@ func ProjectIncrementally[T any](config ProjectionConfig[T]) (*IncrementalProjec
194231
LastLT: lastLT,
195232
LastFormVersion: lastForm,
196233
LastStudyVersions: lastStudy,
234+
235+
FormVersionOfSnapshot: snapAt,
197236
}, stats, nil
198237
}
199238

239+
// lookupCached answers whether this entry is already encoded under the
240+
// current fingerprint.
241+
func lookupCached[T any](config ProjectionConfig[T], entry store.Entry[message.Message]) ([]json.RawMessage, bool) {
242+
if config.Cache == nil {
243+
return nil, false
244+
}
245+
cached, ok := config.Cache.Lookup(entry.LT)
246+
if !ok || len(cached.Payload) == 0 {
247+
return nil, false
248+
}
249+
if cached.Fingerprint != "" && cached.Fingerprint != config.Fingerprint {
250+
return nil, false
251+
}
252+
return cached.Payload, true
253+
}
254+
255+
func maxU64(a, b uint64) uint64 {
256+
if b > a {
257+
return b
258+
}
259+
return a
260+
}
261+
200262
type EncodedMessages struct {
201263
PerMessage [][]json.RawMessage
202264
LogicalTimes []uint64
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package provider_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/jack-work/figaro/internal/form"
9+
"github.com/jack-work/figaro/internal/message"
10+
"github.com/jack-work/figaro/internal/provider"
11+
"github.com/jack-work/figaro/internal/store"
12+
)
13+
14+
// countingForm records what the projection actually asked the store for.
15+
// The counter is the artifact, not the timing: a duration decays with the
16+
// machine, a read count states the shape and fails when the shape changes.
17+
type countingForm struct {
18+
patches []message.Patch
19+
calls int
20+
spanned int // total versions covered by the ranges asked for
21+
}
22+
23+
func (f *countingForm) PatchesBetween(after, upTo uint64) []message.Patch {
24+
f.calls++
25+
if upTo > uint64(len(f.patches)) {
26+
upTo = uint64(len(f.patches))
27+
}
28+
if after >= upTo {
29+
return nil
30+
}
31+
f.spanned += int(upTo - after)
32+
return f.patches[after:upTo]
33+
}
34+
35+
// projectOnce builds a log of n entries, each advancing the board by one
36+
// patch, and pre-populates the translation cache for the first `cached` of
37+
// them. Returns the accessor so the caller can read the counters.
38+
func projectOnce(t testing.TB, n, cached, observed int) (*countingForm, provider.ProjectionStats) {
39+
t.Helper()
40+
41+
board := make([]message.Patch, n)
42+
for i := range board {
43+
board[i] = message.Patch{Set: map[string]json.RawMessage{
44+
"mode": json.RawMessage(fmt.Sprintf(`"v%d"`, i)),
45+
}}
46+
}
47+
acc := &countingForm{patches: board}
48+
49+
studies := map[string]provider.Form{}
50+
studyAccs := map[string]*countingForm{}
51+
for i := 0; i < observed; i++ {
52+
id := fmt.Sprintf("@obs%02d", i)
53+
a := &countingForm{patches: board}
54+
studies[id], studyAccs[id] = a, a
55+
}
56+
57+
log := store.NewMemLog[message.Message]()
58+
cache := store.NewMemLog[[]json.RawMessage]()
59+
const fingerprint = "fp"
60+
for i := 0; i < n; i++ {
61+
versions := map[string]uint64{}
62+
for id := range studies {
63+
versions[id] = uint64(i + 1)
64+
}
65+
e, err := log.Append(store.Entry[message.Message]{
66+
Payload: message.Message{Role: message.RoleInput},
67+
FormChannelVersion: uint64(i + 1),
68+
StudyVersions: versions,
69+
})
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
if i < cached {
74+
if _, err := cache.Append(store.Entry[[]json.RawMessage]{
75+
FigaroLT: e.LT,
76+
Payload: []json.RawMessage{json.RawMessage(`{"cached":true}`)},
77+
Fingerprint: fingerprint,
78+
}); err != nil {
79+
t.Fatal(err)
80+
}
81+
}
82+
}
83+
84+
_, stats, err := provider.ProjectIncrementally(provider.ProjectionConfig[int]{
85+
Log: log,
86+
Cache: cache,
87+
Form: acc,
88+
Studies: studies,
89+
Fingerprint: fingerprint,
90+
Encode: func(m message.Message, _ form.Snapshot) ([]json.RawMessage, error) {
91+
return []json.RawMessage{json.RawMessage(`{}`)}, nil
92+
},
93+
Append: func(s int, _ []json.RawMessage, _ uint64) int { return s + 1 },
94+
})
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
for _, a := range studyAccs {
99+
acc.calls += a.calls
100+
}
101+
return acc, stats
102+
}
103+
104+
// A record whose bytes are cached costs no read, at any layer. This is the
105+
// whole change, stated as an assertion rather than as a benchmark.
106+
func TestCachedRecordsCostNoReads(t *testing.T) {
107+
acc, stats := projectOnce(t, 100, 100, 8)
108+
if stats.Cached != 100 || stats.Encoded != 0 {
109+
t.Fatalf("want 100 cached and 0 encoded, got %d and %d", stats.Cached, stats.Encoded)
110+
}
111+
if acc.calls != 0 {
112+
t.Fatalf("a fully cached pass read the board %d times; it must read nothing", acc.calls)
113+
}
114+
}
115+
116+
// The reads a partly cached pass does make are bounded by the misses, not by
117+
// the length of the log. The board catch-up over the cached prefix is ONE
118+
// range, whatever the prefix length.
119+
func TestReadsScaleWithMissesNotLength(t *testing.T) {
120+
short, _ := projectOnce(t, 100, 90, 0)
121+
long, _ := projectOnce(t, 1000, 990, 0)
122+
123+
if short.calls != long.calls {
124+
t.Fatalf("reads tracked log length: %d at 100 entries, %d at 1000, with 10 misses either way",
125+
short.calls, long.calls)
126+
}
127+
// One catch-up over the skipped prefix, then one per miss.
128+
if want := 1 + 10; short.calls != want {
129+
t.Fatalf("want %d reads for 10 misses, got %d", want, short.calls)
130+
}
131+
}
132+
133+
// Studied forms are read only for records that are actually encoded. They
134+
// feed nothing but the rendering, so a cache hit must not touch them: that
135+
// cost was one read per observed form per record, and fifty observers was
136+
// fifty-one reads to produce nothing.
137+
func TestObservedFormsAreNotReadForCachedRecords(t *testing.T) {
138+
none, _ := projectOnce(t, 100, 100, 50)
139+
if none.calls != 0 {
140+
t.Fatalf("50 observed forms cost %d reads over a fully cached pass", none.calls)
141+
}
142+
}
143+
144+
// The board still has to be correct at the first uncached record, which is
145+
// what the catch-up buys and what a naive cache-first ordering breaks.
146+
func TestSnapshotIsCurrentAtTheFirstMiss(t *testing.T) {
147+
board := []message.Patch{
148+
{Set: map[string]json.RawMessage{"mode": json.RawMessage(`"old"`)}},
149+
{Set: map[string]json.RawMessage{"mode": json.RawMessage(`"new"`)}},
150+
}
151+
acc := &countingForm{patches: board}
152+
153+
log := store.NewMemLog[message.Message]()
154+
cache := store.NewMemLog[[]json.RawMessage]()
155+
const fingerprint = "fp"
156+
for i := 0; i < 2; i++ {
157+
e, err := log.Append(store.Entry[message.Message]{
158+
Payload: message.Message{Role: message.RoleInput},
159+
FormChannelVersion: uint64(i + 1),
160+
})
161+
if err != nil {
162+
t.Fatal(err)
163+
}
164+
if i == 0 {
165+
if _, err := cache.Append(store.Entry[[]json.RawMessage]{
166+
FigaroLT: e.LT, Payload: []json.RawMessage{json.RawMessage(`{}`)}, Fingerprint: fingerprint,
167+
}); err != nil {
168+
t.Fatal(err)
169+
}
170+
}
171+
}
172+
173+
var seen form.Snapshot
174+
if _, _, err := provider.ProjectIncrementally(provider.ProjectionConfig[int]{
175+
Log: log, Cache: cache, Form: acc, Fingerprint: fingerprint,
176+
Encode: func(_ message.Message, snap form.Snapshot) ([]json.RawMessage, error) {
177+
seen = snap
178+
return []json.RawMessage{json.RawMessage(`{}`)}, nil
179+
},
180+
Append: func(s int, _ []json.RawMessage, _ uint64) int { return s + 1 },
181+
}); err != nil {
182+
t.Fatal(err)
183+
}
184+
185+
got, ok := seen.Get("mode")
186+
if !ok || string(got) != `"old"` {
187+
t.Fatalf("the encode after a cached record saw mode=%s; the skipped patch was never folded", got)
188+
}
189+
}
190+
191+
func benchProject(b *testing.B, n, cached, observed int) {
192+
b.ReportAllocs()
193+
b.ResetTimer()
194+
for i := 0; i < b.N; i++ {
195+
projectOnce(b, n, cached, observed)
196+
}
197+
}
198+
199+
// The shape a fingerprint bump or a rejected watermark produces: a cold walk
200+
// over a log whose per-LT bytes are still valid.
201+
func BenchmarkColdWalkWarmCache(b *testing.B) { benchProject(b, 500, 500, 0) }
202+
func BenchmarkColdWalkWarmCache8Observed(b *testing.B) { benchProject(b, 500, 500, 8) }
203+
func BenchmarkColdWalkColdCache(b *testing.B) { benchProject(b, 500, 0, 8) }

0 commit comments

Comments
 (0)