Harness: the tree poll ships the whole tree every 2 s (865 KB for 2,562 pages) — send an ETag and answer 304 #361

Closed
opened 2026-11-01 01:06:17 +01:00 by dominik.polakovics · 1 comment

This was generated by AI during triage.

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/tree is 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: an ETag on the answer, If-None-Match on the next request, 304 Not Modified with no body when nothing changed.

Current behavior

Reproduced in triage (2026-11-01) on the package's generated 2,574-page site (pollPerfFixture in internal/server/polls_bench_test.go), driving the handler directly, with an If-None-Match header on every request:

Request Status Body gzipped Cached answer
GET /api/tree 200, three times 567,354 B 37 KB ~24 ms
GET /api/badges 200, three times 97,687 B 6.5 KB ~24 ms
GET /api/publish/pending 200, three times 2,539 B 309 B ~24 ms
GET /api/status 200 145 B <1 ms

No answer carries ETag, Last-Modified or Cache-Control; If-None-Match is 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 (createPendingChanges refetches 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 through singleFlight (web/src/poll.ts). The draft tip travels in the X-Deckle-Draft-Tip header, which draftTipWriter injects on the first WriteHeader or Write of every /api/ response in branch mode, so the tree store judges it beside the body (observePolledTip).

The hosted proxy (proxyUnix in deckle-hosted's server/editor.go) is an httputil.ReverseProxy that rewrites a few named request headers and deletes only X-Frame-Options from the response; If-None-Match, ETag and a 304 pass through it unchanged. deckle-hosted needs no change.

Desired behavior

  • Every 200 of GET /api/tree, GET /api/badges, GET /api/publish/pending and GET /api/locks carries an ETag. A request whose If-None-Match names the body the server would send is answered 304 Not Modified with no body, the same X-Deckle-Draft-Tip header a 200 would carry (branch mode), and no other change to the headers the route sends.
  • The SPA's polling stores send If-None-Match with the tag of the body they hold, and treat a 304 as a successful poll that changes nothing: the tree, badges or lock list stay as they are, connected stays true, the badge request that follows a tree answer still goes out, and a tree 304 whose tip header moved still fires onForeignTip. A 200 replaces the held body and its tag, exactly as a changed body does today.
  • A change is still visible at the next poll, as today: a save or any other mutating route, the publish job, a hand edit under 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).
  • On the generated site, an idle editor's tree poll after the first one costs the harness the fingerprint walk and a header comparison; no body is written, and the browser receives no body.

Decisions

  • The ETag is 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: a 304 means "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 a 304 is its existing behaviour on an identical body. The stamp changes more often than the body — every GET /api/page bumps 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 another lang'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.
  • The client owns the conditional request; the browser's HTTP cache is not relied on. The answer carries Cache-Control: no-store beside the ETag (as GET /api/media/url already 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 sends If-None-Match itself. Why: a browser-side cache would still hand fetch a synthesised 200 with 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.
  • Where the SPA holds no body it sends no condition. createPendingChanges decodes its answer and keeps no bytes, so it keeps calling fetchPendingChanges as 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.
  • One owner for the conditional answer. One helper in internal/server writes a body with its tag and answers 304 to a matching If-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.derived stays 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.
  • The 304 keeps the draft tip. draftTipWriter injects the header on the first WriteHeader, so a 304 written through it carries the tip; the tree store must read the header on a 304 as it does on a 200 (requestTextWithHeader currently 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 on 304).
  • A failed computation still sends no tag and caches nothing, as ADR 0030 decided; an error answer is never 304.
  • The "unavailable" answers (no repository, no main) of the badges and pending routes are 200s and carry a tag like any other 200 of those routes.
  • Step two from the report — a tree whose hideChildPages sections are collapsed server-side — is out of scope. It is a different change, to be measured after this one.
  • Documentation. The wire contract (ETag, If-None-Match, 304, Cache-Control: no-store, which routes) goes into docs/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 of docs/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 and scaffold/site/AGENTS.md does 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) and derivedEntry — 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 injects X-Deckle-Draft-Tip on WriteHeader; nothing to change, one thing to test. Starting hint: internal/server/mutation.go.
  • requestText, requestTextWithHeader in web/src/api/client.ts; fetchTreePoll, fetchBadgesBody in web/src/api/tree.ts; fetchPageLocksBody in web/src/api/lock.ts — the poll reads that need to send a tag and surface a 304.
  • 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) and polls_bench_test.go — the tests and benchmarks that pin the cache; the 304 path belongs beside them.
  • Web tests: 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 second GET /api/tree (and /api/badges, /api/publish/pending, /api/locks?page=…) with If-None-Match set to the first answer's ETag is 304 with an empty body; in branch mode it carries X-Deckle-Draft-Tip.
  • After POST /api/page/save changes a page title, the same If-None-Match gets a 200 with a new ETag and the new tree; after a save that leaves the tree body identical (an element body edit), it gets a 304 — pinning the body-tag decision. A hand edit under content/ that changes the tree gets a 200.
  • A lock acquired by another editor turns the next conditional GET /api/locks of the page into a 200; its release does the same.
  • If-None-Match: * and a list containing the current tag are 304; a tag of another language's tree is 200. An error answer (404 editor.unknownLang, a 500) carries no ETag.
  • Every 200 of the four routes carries ETag and Cache-Control: no-store; the route test (registered routes ⇔ docs/api.md headings) and TestPollHandlersGoThroughDerived pass.
  • vitest: a 304 tree poll leaves the tree signals untouched, keeps connected true, still fetches the badges, and fires onForeignTip when 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; a 200 after a 304 replaces body and tag. web/src/test/pollGuard.test.ts and the tree, lock and pending-changes suites pass.
  • On the generated site (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.md conventions 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 test in web/ pass.

Out of scope

  • Collapsing hideChildPages sections server-side and fetching them on expansion (the report's second step).
  • Changing any poll interval, or compressing responses in the harness or the hosted proxy.
  • GET /api/status: 145 bytes, no lock, no content work; untouched (ADR 0030).
  • Any change in deckle-hosted: the proxy already relays the headers and the status.

Original report

What happens

GET /api/tree answers 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/badges and the lock poll at the same pace. Measured with the harness and Hugo sharing one core while Hugo rebuilt after a save: /api/status 2 ms, /api/tree 144 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.derived already has the stamp each cached body was computed under (derivedStamp). Send it as ETag on /api/tree, /api/badges, /api/publish/pending and the lock poll; honour If-None-Match with 304 Not Modified and no body. The SPA's singleFlight loops (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 of content/ 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 hideChildPages sections 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.

> *This was generated by AI during triage.* ## 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/tree` is 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: an `ETag` on the answer, `If-None-Match` on the next request, `304 Not Modified` with no body when nothing changed. ## Current behavior Reproduced in triage (2026-11-01) on the package's generated 2,574-page site (`pollPerfFixture` in `internal/server/polls_bench_test.go`), driving the handler directly, with an `If-None-Match` header on every request: | Request | Status | Body | gzipped | Cached answer | | --- | --- | --- | --- | --- | | `GET /api/tree` | 200, three times | 567,354 B | 37 KB | ~24 ms | | `GET /api/badges` | 200, three times | 97,687 B | 6.5 KB | ~24 ms | | `GET /api/publish/pending` | 200, three times | 2,539 B | 309 B | ~24 ms | | `GET /api/status` | 200 | 145 B | | <1 ms | No answer carries `ETag`, `Last-Modified` or `Cache-Control`; `If-None-Match` is 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 (`createPendingChanges` refetches 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 through `singleFlight` (`web/src/poll.ts`). The draft tip travels in the `X-Deckle-Draft-Tip` header, which `draftTipWriter` injects on the first `WriteHeader` or `Write` of every `/api/` response in branch mode, so the tree store judges it beside the body (`observePolledTip`). The hosted proxy (`proxyUnix` in deckle-hosted's `server/editor.go`) is an `httputil.ReverseProxy` that rewrites a few named request headers and deletes only `X-Frame-Options` from the response; `If-None-Match`, `ETag` and a `304` pass through it unchanged. deckle-hosted needs no change. ## Desired behavior - Every `200` of `GET /api/tree`, `GET /api/badges`, `GET /api/publish/pending` and `GET /api/locks` carries an `ETag`. A request whose `If-None-Match` names the body the server would send is answered `304 Not Modified` with no body, the same `X-Deckle-Draft-Tip` header a `200` would carry (branch mode), and no other change to the headers the route sends. - The SPA's polling stores send `If-None-Match` with the tag of the body they hold, and treat a `304` as a successful poll that changes nothing: the tree, badges or lock list stay as they are, `connected` stays true, the badge request that follows a tree answer still goes out, and a tree `304` whose tip header moved still fires `onForeignTip`. A `200` replaces the held body and its tag, exactly as a changed body does today. - A change is still visible at the next poll, as today: a save or any other mutating route, the publish job, a hand edit under `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). - On the generated site, an idle editor's tree poll after the first one costs the harness the fingerprint walk and a header comparison; no body is written, and the browser receives no body. ## Decisions - **The `ETag` is 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: a `304` means "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 a `304` is its existing behaviour on an identical body. The stamp changes more often than the body — every `GET /api/page` bumps 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 another `lang`'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. - **The client owns the conditional request; the browser's HTTP cache is not relied on.** The answer carries `Cache-Control: no-store` beside the `ETag` (as `GET /api/media/url` already 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 sends `If-None-Match` itself. Why: a browser-side cache would still hand `fetch` a synthesised `200` with 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. - **Where the SPA holds no body it sends no condition.** `createPendingChanges` decodes its answer and keeps no bytes, so it keeps calling `fetchPendingChanges` as 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. - **One owner for the conditional answer.** One helper in `internal/server` writes a body with its tag and answers `304` to a matching `If-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.derived` stays 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. - **The 304 keeps the draft tip.** `draftTipWriter` injects the header on the first `WriteHeader`, so a `304` written through it carries the tip; the tree store must read the header on a `304` as it does on a `200` (`requestTextWithHeader` currently 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 on `304`). - **A failed computation still sends no tag and caches nothing**, as ADR 0030 decided; an error answer is never `304`. - **The "unavailable" answers** (no repository, no `main`) of the badges and pending routes are `200`s and carry a tag like any other `200` of those routes. - **Step two from the report — a tree whose `hideChildPages` sections are collapsed server-side — is out of scope.** It is a different change, to be measured after this one. - **Documentation.** The wire contract (`ETag`, `If-None-Match`, `304`, `Cache-Control: no-store`, which routes) goes into `docs/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 of `docs/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 and `scaffold/site/AGENTS.md` does 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)` and `derivedEntry` — 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 injects `X-Deckle-Draft-Tip` on `WriteHeader`; nothing to change, one thing to test. Starting hint: `internal/server/mutation.go`. - `requestText`, `requestTextWithHeader` in `web/src/api/client.ts`; `fetchTreePoll`, `fetchBadgesBody` in `web/src/api/tree.ts`; `fetchPageLocksBody` in `web/src/api/lock.ts` — the poll reads that need to send a tag and surface a `304`. - `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`) and `polls_bench_test.go` — the tests and benchmarks that pin the cache; the `304` path belongs beside them. - Web tests: `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 second `GET /api/tree` (and `/api/badges`, `/api/publish/pending`, `/api/locks?page=…`) with `If-None-Match` set to the first answer's `ETag` is `304` with an empty body; in branch mode it carries `X-Deckle-Draft-Tip`. - [ ] After `POST /api/page/save` changes a page title, the same `If-None-Match` gets a `200` with a new `ETag` and the new tree; after a save that leaves the tree body identical (an element body edit), it gets a `304` — pinning the body-tag decision. A hand edit under `content/` that changes the tree gets a `200`. - [ ] A lock acquired by another editor turns the next conditional `GET /api/locks` of the page into a `200`; its release does the same. - [ ] `If-None-Match: *` and a list containing the current tag are `304`; a tag of another language's tree is `200`. An error answer (`404 editor.unknownLang`, a `500`) carries no `ETag`. - [ ] Every `200` of the four routes carries `ETag` and `Cache-Control: no-store`; the route test (registered routes ⇔ `docs/api.md` headings) and `TestPollHandlersGoThroughDerived` pass. - [ ] vitest: a `304` tree poll leaves the tree signals untouched, keeps `connected` true, still fetches the badges, and fires `onForeignTip` when 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; a `200` after a `304` replaces body and tag. `web/src/test/pollGuard.test.ts` and the tree, lock and pending-changes suites pass. - [ ] On the generated site (`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.md` conventions 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 test` in `web/` pass. ## Out of scope - Collapsing `hideChildPages` sections server-side and fetching them on expansion (the report's second step). - Changing any poll interval, or compressing responses in the harness or the hosted proxy. - `GET /api/status`: 145 bytes, no lock, no content work; untouched (ADR 0030). - Any change in deckle-hosted: the proxy already relays the headers and the status. ## Original report ### What happens `GET /api/tree` answers 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/badges` and the lock poll at the same pace. Measured with the harness and Hugo sharing one core while Hugo rebuilt after a save: `/api/status` 2 ms, `/api/tree` 144 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.derived` already has the stamp each cached body was computed under (`derivedStamp`). Send it as `ETag` on `/api/tree`, `/api/badges`, `/api/publish/pending` and the lock poll; honour `If-None-Match` with `304 Not Modified` and no body. The SPA's `singleFlight` loops (`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 of `content/` 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 `hideChildPages` sections 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.
Author
Owner

This was generated by AI during triage.

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 ETag or Cache-Control; an If-None-Match is ignored. The hosted proxy relays ETag, If-None-Match and 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 ETag a hash of the body's bytes (computed once per recomputation, kept beside the cached entry) rather than the derived stamp. The stamp moves on every GET /api/page and 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-Match only where it holds a body: tree, badges, locks.

> *This was generated by AI during triage.* 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 `ETag` or `Cache-Control`; an `If-None-Match` is ignored. The hosted proxy relays `ETag`, `If-None-Match` and 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 `ETag` a hash of the body's bytes (computed once per recomputation, kept beside the cached entry) rather than the derived stamp. The stamp moves on every `GET /api/page` and 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-Match` only where it holds a body: tree, badges, locks.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#361
No description provided.