Harness: the tree poll ships the whole tree every 2 s (865 KB for 2,562 pages) — send an ETag and answer 304 #361
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#361
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
Since ADR 0030 the editor's polls answer from cached bodies, but every answer is still the whole body: on a 2,562-page site
GET /api/treeis 865 KB every two seconds, plus the badges after it, and the hosted proxy does not compress. The harness copies the body out, the proxy relays it over TLS, and the browser receives it and byte-compares it against the last one, only to find it identical nearly every time. The poll answers need to say "unchanged" without sending the body: anETagon the answer,If-None-Matchon the next request,304 Not Modifiedwith no body when nothing changed.Current behavior
Reproduced in triage (2026-11-01) on the package's generated 2,574-page site (
pollPerfFixtureininternal/server/polls_bench_test.go), driving the handler directly, with anIf-None-Matchheader on every request:GET /api/treeGET /api/badgesGET /api/publish/pendingGET /api/statusNo answer carries
ETag,Last-ModifiedorCache-Control;If-None-Matchis ignored. The bodies were byte-identical across the three polls. The ~24 ms a cached answer costs is the fingerprint walk ADR 0030 accepted; it stays. What this issue removes is everything after it: the 865 KB write through the socket, the proxy hop, and the browser's receive and compare.On the wire today, per idle tab on the hosted site: the tree body every 2 s and the badges body after each, the lock list every 2 s while a page is open. The pending list is not polled by the SPA (
createPendingChangesrefetches it on badge changes, after mutations and when its panel opens), but it is one of the derived bodies and named in the report.The client side already keeps the last body of the tree (
createPageTree), the badges (same store) and the lock list (createPageLocks) and byte-compares each new answer against it; an identical body writes no signal. Each poll goes throughsingleFlight(web/src/poll.ts). The draft tip travels in theX-Deckle-Draft-Tipheader, whichdraftTipWriterinjects on the firstWriteHeaderorWriteof every/api/response in branch mode, so the tree store judges it beside the body (observePolledTip).The hosted proxy (
proxyUnixin deckle-hosted'sserver/editor.go) is anhttputil.ReverseProxythat rewrites a few named request headers and deletes onlyX-Frame-Optionsfrom the response;If-None-Match,ETagand a304pass through it unchanged. deckle-hosted needs no change.Desired behavior
200ofGET /api/tree,GET /api/badges,GET /api/publish/pendingandGET /api/lockscarries anETag. A request whoseIf-None-Matchnames the body the server would send is answered304 Not Modifiedwith no body, the sameX-Deckle-Draft-Tipheader a200would carry (branch mode), and no other change to the headers the route sends.If-None-Matchwith the tag of the body they hold, and treat a304as a successful poll that changes nothing: the tree, badges or lock list stay as they are,connectedstays true, the badge request that follows a tree answer still goes out, and a tree304whose tip header moved still firesonForeignTip. A200replaces the held body and its tag, exactly as a changed body does today.content/that changes the tree, a lock taken or released by another editor. The intervals do not change (ADR 0030: they are the responsiveness contract).Decisions
ETagis a strong validator of the body's bytes, not the derived stamp. For a derived body the tag is computed once, when the entry is stored, and kept beside it; the lock list (in-memory, a few hundred bytes, no derived entry) is hashed per request. Why: a304means "what you hold is byte-identical to what I would send", which is exactly the predicate the SPA's byte-compare evaluates today, so the client's behaviour on a304is its existing behaviour on an identical body. The stamp changes more often than the body — everyGET /api/pagebumps the generation (ADR 0030, consequences), a save that edits an element body leaves the tree identical, a hand touch moves an mtime — and as the tag it would send full bodies the client then discards as identical. A body tag also cannot collide across languages or pages: a tag from anotherlang's tree never matches the body it would be compared against. The cost is one hash of the body per recomputation, never per poll: measured in triage on the fixture's 567 KB tree body, SHA-256 takes 0.53 ms and xxhash64 (github.com/cespare/xxhash/v2, already an indirect dependency) 0.09 ms against 125 ms for the recomputation it follows, so under half a percent of the work a changed stamp already triggers; an unchanged poll compares two short strings. The report proposed the stamp; this is the one place the brief departs from it, for the reason above.Cache-Control: no-storebeside theETag(asGET /api/media/urlalready does), so the browser never stores a tree body or revalidates on its own; the SPA keeps the tag beside the last body it already keeps and sendsIf-None-Matchitself. Why: a browser-side cache would still handfetcha synthesised200with the full body to compare, would store an 865 KB body per language URL, and cannot be exercised under vitest. Deterministic and testable beats implicit.createPendingChangesdecodes its answer and keeps no bytes, so it keeps callingfetchPendingChangesas today; the route offers the tag for a uniform contract (the four routes are the ones the report names) and because the body is tiny. The stores that keep a body — tree, badges, locks — send the condition.internal/serverwrites a body with its tag and answers304to a matchingIf-None-Match, per RFC 9110 §13.1.2 (a comma-separated list matches when any member does;*matches). The four handlers call it; no handler reads the header itself.Handler.derivedstays the one owner of "is derived state current" (CLAUDE.md "Every rule has exactly one owner") and returns the tag with the body rather than letting a caller recompute it.draftTipWriterinjects the header on the firstWriteHeader, so a304written through it carries the tip; the tree store must read the header on a304as it does on a200(requestTextWithHeadercurrently throws on any non-2xx status, so the poll's request shape needs a conditional variant that returns status, body and headers rather than throwing on304).304.main) of the badges and pending routes are200s and carry a tag like any other200of those routes.hideChildPagessections are collapsed server-side — is out of scope. It is a different change, to be measured after this one.ETag,If-None-Match,304,Cache-Control: no-store, which routes) goes intodocs/api.md"Conventions" → "Requests and responses" as one bullet beside the draft-tip bullet, and each of the four route sections gains one line pointing at it. The polls paragraph ofdocs/architecture.md(the one citing ADR 0030) gains a sentence. A new ADR records the two decisions above that are a contract — the tag is the body's bytes, the client owns the condition — and ADR 0030's last consequence ("No wire shape changed") gains a pointer to it. The README does not change. No scaffolded skill walks through the polls andscaffold/site/AGENTS.mddoes not cover the API, so neither changes; nothing a person sees changes, so the styleguide is untouched.Key interfaces
Handler.derived(key, compute) ([]byte, error)andderivedEntry— the cache entry gains the body's tag; the stamp logic is untouched. Starting hint:internal/server/derived.go.handleTree,handleBadges,handlePublishPending,handleLocks— the four writers of a poll answer. Starting hints:internal/server/server.go,git.go,pending.go,lock.go.draftTipWriter— already injectsX-Deckle-Draft-TiponWriteHeader; nothing to change, one thing to test. Starting hint:internal/server/mutation.go.requestText,requestTextWithHeaderinweb/src/api/client.ts;fetchTreePoll,fetchBadgesBodyinweb/src/api/tree.ts;fetchPageLocksBodyinweb/src/api/lock.ts— the poll reads that need to send a tag and surface a304.createPageTree(lastBody,lastBadgesBody,langSeq),createPageLocks(lastBody,seq) — the stores that hold the body and must hold its tag beside it, dropping both when the language or page changes so the next request carries no stale condition (a stale tag is harmless under the body-tag decision, but a request for a new language or page should ask for the full body outright).internal/server/derived_test.go(TestPollsReuseUnchangedState,TestSaveReflectedInNextPoll,TestHandEditReflectedInNextPoll) andpolls_bench_test.go— the tests and benchmarks that pin the cache; the304path belongs beside them.web/src/test/PageTree.test.tsx,pageTreeLang.test.ts,pollGuard.test.ts,pageLocksStore.test.tsx,TreeTranslation.test.tsx(the foreign-tip path).Acceptance criteria
go test ./internal/server: a secondGET /api/tree(and/api/badges,/api/publish/pending,/api/locks?page=…) withIf-None-Matchset to the first answer'sETagis304with an empty body; in branch mode it carriesX-Deckle-Draft-Tip.POST /api/page/savechanges a page title, the sameIf-None-Matchgets a200with a newETagand the new tree; after a save that leaves the tree body identical (an element body edit), it gets a304— pinning the body-tag decision. A hand edit undercontent/that changes the tree gets a200.GET /api/locksof the page into a200; its release does the same.If-None-Match: *and a list containing the current tag are304; a tag of another language's tree is200. An error answer (404 editor.unknownLang, a500) carries noETag.200of the four routes carriesETagandCache-Control: no-store; the route test (registered routes ⇔docs/api.mdheadings) andTestPollHandlersGoThroughDerivedpass.304tree poll leaves the tree signals untouched, keepsconnectedtrue, still fetches the badges, and firesonForeignTipwhen its tip header differs from the own tip; the next request carries the held tag; a language change sends the next tree request without the previous language's tag; a page change does the same for the locks poll; a200after a304replaces body and tag.web/src/test/pollGuard.test.tsand the tree, lock and pending-changes suites pass.go test ./internal/server -run '^$' -bench BenchmarkPoll), a conditional cached tree poll writes no body; the benchmarks keep the uncached, cached and conditional costs visible.docs/api.mdconventions and the four route sections,docs/architecture.md, the new ADR and ADR 0030's pointer are in the pull request;go test ./docs/passes.go vet ./...,go test ./...,npm testinweb/pass.Out of scope
hideChildPagessections server-side and fetching them on expansion (the report's second step).GET /api/status: 145 bytes, no lock, no content work; untouched (ADR 0030).Original report
What happens
GET /api/treeanswers from the cached body since ADR 0030, but the body is sent whole on every poll: 2,562 nodes at 337 bytes each is 865 KB every 2 s (74 KB gzipped, but the hosted proxy does not compress), plus/api/badgesand the lock poll at the same pace. Measured with the harness and Hugo sharing one core while Hugo rebuilt after a save:/api/status2 ms,/api/tree144 ms median, 865 ms worst; idle, 74 ms. On the hosted vCPU (about 3.5x slower) that is 0.3 to 3 s per poll and a steady 5 to 8 % CPU with one idle tab open, and the browser re-reads a 2,562-node tree every 2 s.Fix
Handler.derivedalready has the stamp each cached body was computed under (derivedStamp). Send it asETagon/api/tree,/api/badges,/api/publish/pendingand the lock poll; honourIf-None-Matchwith304 Not Modifiedand no body. The SPA'ssingleFlightloops (web/src/poll.ts) keep the last body on a 304. The intervals do not change (ADR 0030: they are the responsiveness contract). A changed stamp still sends the full body, so a hand edit ofcontent/shows at the next poll as today.Second step, if the full body is still too big for the browser on a large site: a tree whose
hideChildPagessections are collapsed server-side and fetched on expansion. That is a bigger change and not needed for the first.Evidence
Lab run on a synthetic 2,562-page site, harness and Hugo pinned to one core,
savebench: 4 saves in 250 to 690 ms each, 42 status and tree polls sampled during them. Hosted: gbv-aktuell on deckle-01 shows the harness at 5 to 8 % of its core with Hugo idle and one tab open.Ready to promote: bug, ready-for-agent. The body is now the agent brief; the original report is kept under "Original report".
Reproduced on the package's 2,574-page fixture: every poll answers 200 with the whole body (tree 567 KB, badges 98 KB) and no
ETagorCache-Control; anIf-None-Matchis ignored. The hosted proxy relaysETag,If-None-Matchand a 304 unchanged, so deckle-hosted needs no change.One point where the brief departs from the report, flagged for your veto: the brief makes the
ETaga hash of the body's bytes (computed once per recomputation, kept beside the cached entry) rather than the derived stamp. The stamp moves on everyGET /api/pageand on saves that leave the tree identical, so as a tag it would still ship full bodies the SPA discards as identical; a body tag gives a 304 in exactly the cases the SPA's byte-compare treats as unchanged today. If you prefer the stamp, edit the first decision and the second acceptance criterion.Also worth knowing: the pending list is not polled by the SPA (it is refetched on badge changes, mutations and panel open), so the brief gives the route a tag for a uniform contract but has the client send
If-None-Matchonly where it holds a body: tree, badges, locks.