perf(history): parse each blob once and grep main's tip once per request #289
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#289
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
GET /api/element/historyparses every blob once per version instead of once per blob, and grepsmain's tip twice on a first-page request. Remove both redundancies without changing the response. Follow-up to #283 / PR #287, which named them as deliberately deferred.Current behavior
One default-window request runs:
gitrepo.NeedleFiles(tip, …)—git grep -F -lat the tip plus onecat-file --batch— for the element's version onmain.gitrepo.NeedleHistory— which, whenbeforeis empty, runsgrepHolding(start, …)again for the same tip (start == q.Tip), then the pickaxe, onegit log, and its owncat-file --batch. The tip's blobs are read a second time.versionscallinglocateElement(v.Before, …)andlocateElement(v.After, …).locateElementcallscontent.Parseon everyFileBlobit is handed, every time. Neighbouring versions share most of their blobs (NeedleHistorykeeps onecurstate and only swaps the touched files), and the tip's blobs fromNeedleFilesare parsed again for the pending row.editAuthors(merge publishes) andpendingAuthors(branch mode) locate through the same function and repeat the parse the same way.Baseline measured during triage on the opt-in cost test (300 pages × 15 elements, 2,000 publishes touching 3 pages each; go 1.26.8, git 2.47.3):
MainTipNeedleFiles— the duplicate grep + cat-fileNeedleHistory, 200 versionsThe duplicate grep accounts for most of the 21.5 ms; the parse savings come out of the 41 ms. PR #287 estimated the two changes together at roughly 30–40 ms.
Desired behavior
BeforeorAfter, a merge'sEdits, the tip's files, the draft tip's files.before) runs onegit grepatmain's tip and reads the tip's blobs once. Continuation pages (beforeset) are unchanged in shape: their window starts below the tip, so the tip's files still come from oneNeedleFilescall.Decisions
commit:pathnames, and the same blob reaches the handler fromNeedleFilesand fromNeedleHistoryin separate byte slices. Keying bycommit:pathor by slice pointer would miss both.git cat-file --batchalready prints each object's id in its header, whichreadBlobscurrently discards; carrying it onFileBlobgives a free, exact key. Working-tree files have no object id and are parsed once anyway.locateElementininternal/server/history.go. The cache extends it (or the function moves toelemopstogether with the cache);editAuthors,pendingRow,pendingAuthorsandhistoryWorkingTreekeep calling the one function. No second locate helper (CLAUDE.md "Every rule has exactly one owner").holdingFiles/grepHoldingininternal/gitrepo. The grep dedupe hasNeedleHistoryhand the handler the window-start state it already computes (the kept files holding the needle at the window's start, with content) so the handler callsNeedleFilesonly when the window does not start at the tip. The handler must not pass pre-fetched blobs into gitrepo or grep on its own.internal/gitrepo, small, and measured by the same benchmark; splitting them buys nothing.Key interfaces
gitrepo.FileBlob— gains the blob's git object id, filled byreadBlobsfrom thecat-file --batchheader. Every producer (blobsAt,holdingNeedle,historyEdits) carries it through.gitrepo.NeedleHistory(HistoryQuery)— its answer additionally names the kept files holding the needle at the window's start with content (theresidentset it greps and reads already). Shape is the agent's call: a field on a returned struct, or an extra return value.handleElementHistory— callsNeedleFilesonly on continuation pages; on the first page the tip's version comes from theNeedleHistoryanswer. The order of the two reads may change; both are pinned totip, so the response cannot.locateElement(blobs, uid, schemas)— becomes a method on, or takes, a per-request document cache; every call site inhistory.gogoes through it.internal/server/history.go,internal/gitrepo/history.go,internal/server/history_bench_test.go.Acceptance criteria
handleElementHistoryparses each distinct blob (by git object id) at most once per request, including blobs reached throughHistoryVersion.EditsandDraftEdits. A test counts parses — for example by instrumenting the cache — on a fixture where neighbouring versions share blobs.git grepatmain's tip and reads the tip's blobs with onecat-file --batch. A continuation page (beforeset) still resolves the tip's files throughNeedleFiles.go test ./internal/serverandgo test ./internal/gitrepopass with their existing history tests unchanged; the response is byte-identical for the existing fixtures (a golden comparison of a first page and a continuation page before and after is acceptable evidence).BenchmarkElementHistoryandDECKLE_HISTORY_PERF=1 … TestElementHistoryCostBoundshow the gain; the PR reports before/after medians on the same machine.locateElementremains the only place that parses a blob and finds the element by uid;grepHoldingremains the onlygit grepfor "files holding the needle at a commit". No "same as" comments.go vet ./...andgofmt -lare clean.How to verify
git phaseslog line of the cost test before and after:NeedleFilesshould drop to zero on the measured request path (or be skipped), and the "rest of the median request" should shrink by the parse savings.beforeand confirm the continuation page still answers with the same entries as today.Assumptions to check
git cat-file --batchprints<oid> SP <type> SP <size>andreadBlobscan keep the oid without a second git call (it parses that header today and dropsfields[0]).NeedleHistory's initialcurstate atstart, filtered byholdingNeedle, equals whatNeedleFiles(start, …)returns: same files, same order (both sorted by path), sameKeepfilter. If anything differs, the response would change and the gap must be closed before the dedupe lands.30–40 msestimate from PR #287 was never measured separately; the benchmark decides what the gain is.Out of scope
GET /api/element/history;docs/api.mdand ADR 0023 stay as they are.DraftEdits(branch mode) — a different commit thanmain's tip, so not a repeat.docs/architecture.mdor ADR 0023 quote a figure the change moves (ADR 0023 quotes the pickaxe at about 60 ms and "well under a quarter of a second"), update only that sentence; nothing else in the docs changes.Blocked by
None - can start immediately
Original report
Follow-up to #283 / PR #287 (
GET /api/element/history). The PR description and its review both name two speed-ups that were deliberately left out of the first cut. Filing them so they can be triaged separately from the feature.What
One default-window history request today does more work than it needs to on two counts:
internal/server/history.go(handleElementHistory) walksversionsand callslocateElementonv.Beforeandv.Afterfor each. Neighbouring versions share most of their blobs (a file untouched between two commits is the same blob on both sides), yetlocateElementrunscontent.Parseon eachFileBlobafresh every time it is asked.main's tip blobs fromNeedleFilesare parsed a further time for the pending row.main's tip is grepped twice. The handler callsgitrepo.NeedleFiles(tip, …)for the element's current version, and thengitrepo.NeedleHistoryrunsgrepHolding(start, …)again for the same tip whenbeforeis empty (start == q.Tip). Each is a separategit grep -F -lover the site directory plus its owncat-file --batch.The PR's measurement (
DECKLE_HISTORY_PERF=1 go test ./internal/server -run TestElementHistoryCostBound -v, 300 pages × 15 elements, 2,000 publishes touching 3 pages each) puts one request at a median of about 190 ms against the 250 ms bound. The PR estimates the two changes together are worth about 30–40 ms of that.Possible shape
commit:pathasreadBlobsalready keys it), solocateElementon neighbouring versions and on the tip is a lookup, not a reparse.gitrepo.HistoryVersionalready carries the blobs; the cache can live in the handler or inelemops, wherever the one owner of "parse this file and find the element" ends up.NeedleHistoryreuse the tip's grep and blobs thatNeedleFilesalready produced (or haveNeedleHistoryreturn the tip'sFileBlobs so the handler callsNeedleFilesonly on cursor pages).Both are internal to the handler and
internal/gitrepo; no wire shape changes.Acceptance
handleElementHistoryparses each distinct blob at most once per request.git grepatmain's tip, not two.internal/server/history_test.goandinternal/gitrepo/history_test.gopass unchanged).BenchmarkElementHistoryand the opt-inTestElementHistoryCostBoundshow the gain; report the before/after medians in the PR.locateElement/gitreporather than siblings (CLAUDE.md "Every rule has exactly one owner").Docs
Nothing user-facing changes.
docs/api.mdand ADR 0023 stay as they are. If the cost note indocs/architecture.md(history section) quotes the measured figures, update them.