Files
beardrive/internal/webapp/journalcache_test.go
T
8fce9d2c46 perf(webapp): cache parsed journals so history stops re-downloading everything (BEA-85) (#133)
History folded every journal of a project on every request — 8-10s on a real
project, and paging made it worse rather than better, since each "load more"
re-downloaded and re-parsed the lot. There is no DB in this path, so no index
was ever going to help.

Journals only GROW: a device appends only to its own (the one-writer
invariant) and the hub's appendOp rewrites its key with strictly more bytes.
So the (Size, Modified) that List already reports proves a parse is still
current — no staleness window, no expiry, no new Backend method. A warm
request now pays one List and nothing else.

The cold path can never be helped by a cache, so the Get loop is concurrent
too (errgroup, limit 8) instead of one serial round trip per device.

Files() reads the same funnel, so folder listings get it for free.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:08:47 +09:00

233 lines
6.9 KiB
Go

package webapp
import (
"context"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
)
// History folded every journal of a project on EVERY request — 8-10s on a real
// project, and paging made it worse rather than better, since each "load more"
// re-downloaded and re-parsed the lot (BEA-85). Journals only grow, so the
// (Size, Modified) that List already reports proves a parse is still current.
//
// These tests pin the cache from the outside: what matters is not that a map
// exists but that a warm request touches no journal bytes, and that the feed it
// produces is the one the uncached code produced.
// countBackend counts the reads and lists the hub makes through it, per key.
type countBackend struct {
remote.Backend
mu sync.Mutex
gets map[string]int
lists int
}
func newCountBackend(be remote.Backend) *countBackend {
return &countBackend{Backend: be, gets: map[string]int{}}
}
func (b *countBackend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
b.mu.Lock()
b.gets[key]++
b.mu.Unlock()
return b.Backend.Get(ctx, key)
}
func (b *countBackend) List(ctx context.Context, prefix string) ([]remote.Object, error) {
b.mu.Lock()
b.lists++
b.mu.Unlock()
return b.Backend.List(ctx, prefix)
}
// journalGets is how many times a journal — any journal — has been fetched.
func (b *countBackend) journalGets() int {
b.mu.Lock()
defer b.mu.Unlock()
n := 0
for k, c := range b.gets {
if strings.Contains(k, "journal/") {
n += c
}
}
return n
}
func (b *countBackend) getsFor(dev string) int {
b.mu.Lock()
defer b.mu.Unlock()
n := 0
for k, c := range b.gets {
if strings.HasSuffix(k, "journal/"+dev+".jsonl") {
n += c
}
}
return n
}
func (b *countBackend) listCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.lists
}
// cachedHub is a hub over a counting backend with a seeded three-device
// project, plus the handler and the base URL for its history route.
func cachedHub(t *testing.T) (*Server, *countBackend, *fakeRemote, http.Handler, string) {
t.Helper()
var cb *countBackend
srv, p, root := newHub(t, false, func(be remote.Backend) remote.Backend {
cb = newCountBackend(be)
return cb
})
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.putAs("dev1", "alice@x.io", "Alice", "notes/plan.md", "v1")
f.putAs("dev1", "alice@x.io", "Alice", "notes/plan.md", "v2 longer")
f.putAs("dev2", "bob@x.io", "Bob", "notes/other.md", "bob's file")
f.del("dev2", "notes/other.md")
f.putAs("dev3", "carol@x.io", "Carol", "readme.md", "top")
return srv, cb, f, srv.Handler(), "/api/p/" + p.ID + "/"
}
func TestHistoryReusesParsedJournals(t *testing.T) {
_, cb, _, h, base := cachedHub(t)
if rec := do(t, h, "GET", base+"history", nil); rec.Code != 200 {
t.Fatalf("cold history: %d %s", rec.Code, rec.Body)
}
cold, lists := cb.journalGets(), cb.listCount()
if cold < 3 {
t.Fatalf("cold load fetched %d journals, want at least 3", cold)
}
for i := 0; i < 3; i++ {
if rec := do(t, h, "GET", base+"history", nil); rec.Code != 200 {
t.Fatalf("warm history: %d %s", rec.Code, rec.Body)
}
}
if got := cb.journalGets(); got != cold {
t.Fatalf("warm requests fetched %d journals, want 0 beyond the cold %d", got-cold, cold)
}
// The List is the round trip a warm request still pays: it is what proves
// nothing changed, so it must NOT be cached away.
if cb.listCount() <= lists {
t.Fatalf("list count %d did not advance past %d", cb.listCount(), lists)
}
}
func TestJournalCacheInvalidatesOnePush(t *testing.T) {
_, cb, f, h, base := cachedHub(t)
do(t, h, "GET", base+"history", nil)
before1, before2, before3 := cb.getsFor("dev1"), cb.getsFor("dev2"), cb.getsFor("dev3")
f.putAs("dev2", "bob@x.io", "Bob", "notes/other.md", "bob is back")
if rec := do(t, h, "GET", base+"history", nil); rec.Code != 200 {
t.Fatalf("history after push: %d %s", rec.Code, rec.Body)
}
if got := cb.getsFor("dev2"); got != before2+1 {
t.Fatalf("dev2 fetched %d times, want %d", got, before2+1)
}
if cb.getsFor("dev1") != before1 || cb.getsFor("dev3") != before3 {
t.Fatalf("untouched journals re-fetched: dev1 %d→%d, dev3 %d→%d",
before1, cb.getsFor("dev1"), before3, cb.getsFor("dev3"))
}
}
func TestJournalCachePagingRefetchesNothing(t *testing.T) {
_, cb, _, h, base := cachedHub(t)
rec := do(t, h, "GET", base+"history?n=2", nil)
if rec.Code != 200 {
t.Fatalf("page 1: %d %s", rec.Code, rec.Body)
}
var page struct {
Entries []HistoryEntry `json:"entries"`
Next string `json:"next_cursor"`
}
mustJSON(t, rec, &page)
if page.Next == "" {
t.Fatalf("no cursor to page with: %s", rec.Body)
}
after := cb.journalGets()
for cursor := page.Next; cursor != ""; cursor = page.Next {
page.Next = ""
mustJSON(t, do(t, h, "GET", base+"history?n=2&cursor="+url.QueryEscape(cursor), nil), &page)
}
if got := cb.journalGets(); got != after {
t.Fatalf("paging re-fetched %d journals", got-after)
}
}
// The criterion that matters most: the cache must be invisible in the output.
func TestHistoryOutputUnchangedByCache(t *testing.T) {
srv, _, _, h, base := cachedHub(t)
urls := []string{
"history", "history?path=notes/plan.md", "history?prefix=notes",
"history?n=2", "history?user=alice@x.io",
}
warm := make([]string, len(urls))
for i, u := range urls {
do(t, h, "GET", base+u, nil) // cold
warm[i] = do(t, h, "GET", base+u, nil).Body.String()
}
// Drop everything the cache holds; the next request is the uncached code
// path, byte for byte.
_, v, err := srv.projectVolume(strings.TrimSuffix(strings.TrimPrefix(base, "/api/p/"), "/"))
if err != nil {
t.Fatal(err)
}
rs := v.source.(*RemoteSource)
rs.jmu.Lock()
rs.jcache, rs.jbytes = nil, 0
rs.jmu.Unlock()
for i, u := range urls {
if got := do(t, h, "GET", base+u, nil).Body.String(); got != warm[i] {
t.Fatalf("%s differs with and without the cache:\ncached: %s\nfresh: %s", u, warm[i], got)
}
}
}
// A journal the remote no longer has must not stay resident, or a hub leaks a
// map entry per device that ever synced anything.
func TestJournalCacheDropsVanishedJournals(t *testing.T) {
srv, _, f, h, base := cachedHub(t)
do(t, h, "GET", base+"history", nil)
_, v, err := srv.projectVolume(strings.TrimSuffix(strings.TrimPrefix(base, "/api/p/"), "/"))
if err != nil {
t.Fatal(err)
}
rs := v.source.(*RemoteSource)
if err := os.Remove(filepath.Join(f.dir, "journal", "dev3.jsonl")); err != nil {
t.Fatal(err)
}
do(t, h, "GET", base+"history", nil)
rs.jmu.Lock()
defer rs.jmu.Unlock()
if _, ok := rs.jcache["journal/dev3.jsonl"]; ok {
t.Fatalf("cache kept a journal the remote no longer has: %v", rs.jcache)
}
// The running total is what the ceiling is enforced against, so a prune or
// a re-fetch that forgets to subtract would quietly disable the cache.
var sum int64
for _, c := range rs.jcache {
sum += c.bytes
}
if rs.jbytes != sum {
t.Fatalf("byte total %d, cached entries sum to %d", rs.jbytes, sum)
}
}