fix(server): answer the editor's polls from bodies cached under a stat stamp #359

Merged
dominik.polakovics merged 1 commit from afk/358 into main 2026-10-31 17:44:45 +01:00

Closes #358

What changes

Server (internal/server/derived.go, internal/gitrepo/stamp.go)

  • Handler.derived(key, compute) is the one owner of "is the derived state current". GET /api/tree, /api/badges and /api/publish/pending compute their body through it.
  • A cached body is reused while its stamp is unchanged. The stamp has four parts:
    • a generation, bumped when each mutation lock scope ends: ServeHTTP for mutatingRoutes, and runPublishJob;
    • git.State() and SyncStatus().Tip, both held in memory;
    • Repo.RefsStamp(), a stat of HEAD, packed-refs, the reftable list and the loose main/draft refs, with no git spawn and no r.mu;
    • a stat-walk fingerprint (path, mode, size, mtime) of content/, assets/media/, editor/, the root Hugo config files (pagetree.PathRulesFiles) and .gitignore.
  • The stamp is taken before computing. Errors are never cached.
  • Concurrent requests for one key share a single computation.
  • One walk is shared per poll cycle: the memo lasts 1 s and serves each key once. A repeat of the same key walks again.
  • The git index is left out of the stamp on purpose: our own git diff rewrites it.
  • The startup sync runs before server.New, so nothing is cached at that point.
  • /api/status is untouched.
  • Response bodies are byte-identical to before. No wire shape changed, so docs/api.md is unchanged.

SPA (web/src/poll.ts)

  • singleFlight(run) returns {tick, now}:
    • a timer tick is skipped while the previous request is still pending;
    • an explicit now (a mutation's refetch, a language or page change) always goes out.
  • Used by createPageTree (tree plus badges count as one unit), createPageLocks, createHugoStatus, both publish loops, and createModules, which loses its private copy of the guard.
  • Polling intervals are unchanged.
  • Five existing web tests advanced fake timers before the previous request had settled. They now settle first. The publish "overlapping polls" test now asserts one request instead of two.

Docs

  • docs/architecture.md "Server" gains a paragraph on this.
  • New ADR docs/adr/0030-polls-answer-from-bodies-cached-under-a-stat-stamp.md records the stat walk over an inotify watcher, a directories-only walk and invalidation on mutation alone.
  • No skills or AGENTS.md changes: no command, flag, template contract or hand-editing behaviour changed.

Verification

  • make test is green: 3465 web tests and every Go package. go vet and gofmt are clean. -race passes on server and gitrepo.

  • New Go tests:

    • TestPollHandlersGoThroughDerived (the single owner);
    • TestPollsReuseUnchangedState (the second poll computes nothing; 8 concurrent cold requests compute once);
    • TestSaveReflectedInNextPoll;
    • TestHandEditReflectedInNextPoll (an in-place same-size edit, a new page directory, a hand-run commit);
    • TestPublishReflectedInNextPoll;
    • three RefsStamp tests.
  • New web tests in web/src/test/pollGuard.test.ts: a fetch that never resolves leaves at most one request in flight per loop.

  • Benchmarks are in internal/server/polls_bench_test.go (2,574 pages; dev machine):

    Benchmark Time
    Uncached tree 149 ms
    Uncached badges 303 ms
    Uncached pending 153 ms
    Fingerprint walk alone 27 ms
    Cached cycle 27 ms
  • End to end against a real harness serve pinned with taskset -c 0 (2,500 bundles under news/, 5,660 entries under content/):

    Check Result
    Second /api/badges 0.4 ms (cold: 351 ms)
    Reads on the second tree, badges or pending request 2 read syscalls, about 130 bytes (/proc/<pid>/io); no git child
    /api/status during a cold badge computation on the same core 6–43 ms; idle 0.3 ms
    Simulated editor for 5 min, including a 3 s pending loop the real SPA does not run 9 s CPU in total
    Hand sed of a title, and a new page directory both show in the next tree and badges answer

Known trade-offs

  • Hand edits: a hand edit made less than 1 s after another endpoint's walk can show one poll late, because the tree reuses that walk. Harness writes are never late: the generation covers them. This is the shared-walk window the issue chose.
  • Idle cost: an idle tab still costs one stat walk per tree poll, about 30 ms per 2 s on 5,660 entries (about 1.5% of a core). The ADR names a watcher behind the same owner as the later optimisation.
  • Index: a hand-run index-only change, such as git rm --cached, shows at the next mutation or content change, not the next poll.
  • Page open: opening a page (GET /api/page, a mutating route because it can repair uids) outdates the cache once.

🤖 Generated with Claude Code

Closes #358 ## What changes **Server (`internal/server/derived.go`, `internal/gitrepo/stamp.go`)** - `Handler.derived(key, compute)` is the one owner of "is the derived state current". `GET /api/tree`, `/api/badges` and `/api/publish/pending` compute their body through it. - A cached body is reused while its stamp is unchanged. The stamp has four parts: - a generation, bumped when each mutation lock scope ends: `ServeHTTP` for `mutatingRoutes`, and `runPublishJob`; - `git.State()` and `SyncStatus().Tip`, both held in memory; - `Repo.RefsStamp()`, a stat of `HEAD`, `packed-refs`, the reftable list and the loose main/draft refs, with no git spawn and no `r.mu`; - a stat-walk fingerprint (path, mode, size, mtime) of `content/`, `assets/media/`, `editor/`, the root Hugo config files (`pagetree.PathRulesFiles`) and `.gitignore`. - The stamp is taken before computing. Errors are never cached. - Concurrent requests for one key share a single computation. - One walk is shared per poll cycle: the memo lasts 1 s and serves each key once. A repeat of the same key walks again. - The git index is left out of the stamp on purpose: our own `git diff` rewrites it. - The startup sync runs before `server.New`, so nothing is cached at that point. - `/api/status` is untouched. - Response bodies are byte-identical to before. No wire shape changed, so `docs/api.md` is unchanged. **SPA (`web/src/poll.ts`)** - `singleFlight(run)` returns `{tick, now}`: - a timer `tick` is skipped while the previous request is still pending; - an explicit `now` (a mutation's refetch, a language or page change) always goes out. - Used by `createPageTree` (tree plus badges count as one unit), `createPageLocks`, `createHugoStatus`, both publish loops, and `createModules`, which loses its private copy of the guard. - Polling intervals are unchanged. - Five existing web tests advanced fake timers before the previous request had settled. They now settle first. The publish "overlapping polls" test now asserts one request instead of two. **Docs** - `docs/architecture.md` "Server" gains a paragraph on this. - New ADR `docs/adr/0030-polls-answer-from-bodies-cached-under-a-stat-stamp.md` records the stat walk over an inotify watcher, a directories-only walk and invalidation on mutation alone. - No skills or AGENTS.md changes: no command, flag, template contract or hand-editing behaviour changed. ## Verification - `make test` is green: 3465 web tests and every Go package. `go vet` and `gofmt` are clean. `-race` passes on `server` and `gitrepo`. - New Go tests: - `TestPollHandlersGoThroughDerived` (the single owner); - `TestPollsReuseUnchangedState` (the second poll computes nothing; 8 concurrent cold requests compute once); - `TestSaveReflectedInNextPoll`; - `TestHandEditReflectedInNextPoll` (an in-place same-size edit, a new page directory, a hand-run commit); - `TestPublishReflectedInNextPoll`; - three `RefsStamp` tests. - New web tests in `web/src/test/pollGuard.test.ts`: a fetch that never resolves leaves at most one request in flight per loop. - Benchmarks are in `internal/server/polls_bench_test.go` (2,574 pages; dev machine): | Benchmark | Time | | --- | --- | | Uncached tree | 149 ms | | Uncached badges | 303 ms | | Uncached pending | 153 ms | | Fingerprint walk alone | 27 ms | | Cached cycle | 27 ms | - End to end against a real `harness serve` pinned with `taskset -c 0` (2,500 bundles under `news/`, 5,660 entries under `content/`): | Check | Result | | --- | --- | | Second `/api/badges` | 0.4 ms (cold: 351 ms) | | Reads on the second tree, badges or pending request | 2 read syscalls, about 130 bytes (`/proc/<pid>/io`); no git child | | `/api/status` during a cold badge computation on the same core | 6–43 ms; idle 0.3 ms | | Simulated editor for 5 min, including a 3 s pending loop the real SPA does not run | 9 s CPU in total | | Hand `sed` of a title, and a new page directory | both show in the next tree and badges answer | ## Known trade-offs - **Hand edits:** a hand edit made less than 1 s after another endpoint's walk can show one poll late, because the tree reuses that walk. Harness writes are never late: the generation covers them. This is the shared-walk window the issue chose. - **Idle cost:** an idle tab still costs one stat walk per tree poll, about 30 ms per 2 s on 5,660 entries (about 1.5% of a core). The ADR names a watcher behind the same owner as the later optimisation. - **Index:** a hand-run index-only change, such as `git rm --cached`, shows at the next mutation or content change, not the next poll. - **Page open:** opening a page (`GET /api/page`, a mutating route because it can repair uids) outdates the cache once. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(server): answer the editor's polls from bodies cached under a stat stamp
All checks were successful
ci / build-and-test (pull_request) Successful in 3m57s
ci / release (pull_request) Has been skipped
392da49bc2
GET /api/tree, /api/badges and /api/publish/pending rebuilt the page tree,
re-read every page body and spawned git on every request; on a 2,500-page
site on one core the polls pinned the harness and starved /api/status.

- Handler.derived (internal/server/derived.go) is the one owner of "is the
  derived state current": each body is cached with the stamp it was
  computed under — a generation every mutation lock scope bumps when it
  ends (ServeHTTP, runPublishJob), the in-memory git state and draft tip,
  gitrepo.Repo.RefsStamp (a stat of HEAD and the ref files, no git), and
  a stat-walk fingerprint of content/, the media library, editor/, the
  root Hugo config files and .gitignore. One walk is shared per poll
  cycle; concurrent requests for a key share one computation; errors are
  never cached. /api/status is untouched.
- The SPA's poll loops (tree+badges, locks, Hugo status, both publish
  loops, modules) go through singleFlight (web/src/poll.ts): a tick that
  finds its request pending is skipped; an explicit refetch still goes out.
- Benchmarks in internal/server/polls_bench_test.go; ADR 0030 and the
  architecture "Server" section record the design.

Closes #358

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

PASS — validated by the autoland lander.

Signal relied on: ci / build-and-test success on head 392da49 (web npm ci && npm run build && npm test, go vet ./..., go test ./... including the docs, route, command and skills drift tests). gofmt checked locally on the changed packages: clean.

Conventions: Conventional Commits title; Closes #358 present; branch is current with main (merge-base = main tip d788dc6).

Scope matches #358: one owner Handler.derived (internal/server/derived.go) for tree/badges/pending; generation bumped at the end of every mutMu scope (server.go ServeHTTP, publish.go runPublishJob including the panic path); the startup sync runs before server.New (cmd/harness/main.go:369 vs :390); the fingerprint covers content/, assets/media (= gitrepo publishRoots), editor/ (elements/pagetypes/modules dirs), the root Hugo config via the new pagetree.PathRulesFiles owner, and .gitignore; RefsStamp spawns no git. SPA loops go through singleFlight (web/src/poll.ts); modules.ts drops its private copy. Docs: architecture.md "Server" paragraph plus ADR 0030; api.md, AGENTS.md and the skills are correctly untouched (no wire, command or contract change). The edits to existing web tests only let the previous request settle before advancing timers, which the new guard requires; no existing Go endpoint test was edited.

[autoland] verdict: pass PASS — validated by the autoland lander. Signal relied on: `ci / build-and-test` success on head 392da49 (web `npm ci && npm run build && npm test`, `go vet ./...`, `go test ./...` including the docs, route, command and skills drift tests). gofmt checked locally on the changed packages: clean. Conventions: Conventional Commits title; `Closes #358` present; branch is current with main (merge-base = main tip d788dc6). Scope matches #358: one owner `Handler.derived` (internal/server/derived.go) for tree/badges/pending; generation bumped at the end of every mutMu scope (server.go ServeHTTP, publish.go runPublishJob including the panic path); the startup sync runs before server.New (cmd/harness/main.go:369 vs :390); the fingerprint covers content/, assets/media (= gitrepo publishRoots), editor/ (elements/pagetypes/modules dirs), the root Hugo config via the new pagetree.PathRulesFiles owner, and .gitignore; RefsStamp spawns no git. SPA loops go through singleFlight (web/src/poll.ts); modules.ts drops its private copy. Docs: architecture.md "Server" paragraph plus ADR 0030; api.md, AGENTS.md and the skills are correctly untouched (no wire, command or contract change). The edits to existing web tests only let the previous request settle before advancing timers, which the new guard requires; no existing Go endpoint test was edited.
Sign in to join this conversation.
No reviewers
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!359
No description provided.