Systemic: SWR stale-serve window on all mutable collab state (reactions, stars, watches, releases, identity) causes refresh flip-flop — needs a third cache class #280
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#280
Loading…
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?
Supersedes #259 (same root cause, wider blast radius, permanent fix proposed). #259 scoped this to the issue view; the same stale-serve behavior exists on stars/watches (
/{o}/{r}/api/social), releases, pulls, and identity surfaces. Closing #259 in favor of this one is appropriate.The systemic problem
Mutating collaborative state — reactions, stars, watches, release edits, issue patches — then refreshing can serve the pre-mutation state, and the next refresh shows the post-mutation state. It is intermittent by construction and affects every surface served with the SWR cache class:
v<Thread.Version>internal/issues/http.go:459internal/social/http.go:248internal/releases/http.go:355,371,380HeadLiveinternal/pulls/http.go:493internal/identity/http.go(7 sites)All use
ccSWR = "private, max-age=0, stale-while-revalidate=60"(api/env.go:582, duplicated per package inissues/http.go:175,social/http.go:185).Mechanics of the flip-flop:
stale-while-revalidate=60permits the browser to answer a refresh from its cache — the stale pre-mutation body — while revalidating in the background. Refresh 1 paints old state (reaction "gone", star "off"); the background revalidation lands; refresh 2 paints new state. Server state is correct at all times (every mutation is a versioned CAS put and the ETag is version-keyed) — the header contract is what licenses the staleness.Why the client makes it worse: the SPA's
useDatalayer (web/src/lib/data.js) has its own TTL revalidation (5–30 s by surface), and mutations invalidate only the mutation's own cache entry — a hard refresh re-requests through the browser cache, where SWR serves stale before the client TTL logic even runs.Where the design went wrong
The repo's own spec drew the line in the right place and the implementation crossed it:
docs/go/07_api.md§4 ("the central design rule") defines exactly two cache classes, both for git content: sha-addressed → immutable; ref-dependent (name-addressed git views) → SWR-60 + sha ETag. The rationale is sound: git content changes only via refs, so a ref-state ETag makes SWR safe.docs/features/07_releases_stars.md:235explicitly says "single release / list / social GETs are ref-dependent class (SWR 60 s + ETag version token)" anddocs/features/02_issues.md:258says "GET-by-num is ref-class SWR keyed on the header version." That was the wrong call: a version-keyed ETag gives correct revalidation, but SWR's stale-serve window is a correctness concession that git content can afford (refs move rarely, stale ≈ seconds old) and mutable collab state cannot (the user just clicked the button).Proposed permanent solution
Principle: the SWR stale-serve window is for content whose staleness is bounded by ref movement, not for user-mutable state. Any GET whose resource can change via a direct user action must not serve stale.
ccNoCache = "private, no-cache"— for mutation-sensitive GETs:Cache-Control: private, no-cache+ the existing version ETag.no-cacherequires revalidation before every use (the browser still caches; the ETag still makes unchanged responses 304 with zero body), so the economics stay cheap — you lose only the stale-serve window, which is exactly the bug. This is a one-line class + per-site swap;writeCachedalready implements the If-None-Match → 304 path.07_releases_stars.md:235and02_issues.md:258change "ref-dependent class (SWR 60 s)" to the new class; add a sentence to07_api.md§4 defining the third class and the rule for when it applies (mutability, not addressability, decides). Note07_api.md§4 currently says "the two cache classes (the central design rule)" — this amendment touches a stated design rule, which is why this issue is flagged for the maintainer rather than just implemented.invalidate()the affecteduseDataentries and force a browser revalidation for the mutated resource (the SDK mutation helpers can issue the follow-up GET withIf-None-Matchstripped — a conditional GET the browser must forward underno-cache). This covers same-view updates without a reload; the header contract covers the reload case.no-cache, not SWR — the same test proposed in #272's registry work would be a natural home.Laws impact (decision requested)
07_api.md— adding a third class for mutable collab state. The rule's intent ("SWR is safe because staleness is bounded by ref movement") is preserved; the amendment narrows where the class applies. Per repo law 12 (existing keys never change meaning), git-content routes keep byte-identical headers; only collab GETs change.max-age=0only (no stale window) for collab GETs — same user-visible fix, keeps the class count at two, but loses the ETag-mandated revalidation semantics clarity ofno-cache. Both are one-line header changes; the difference is documentation framing.Acceptance criteria
If-None-Matchhandling on the new class).07_api.md§4 and the two feature specs document the class assignment rule; the per-package duplicatedccSWRconstants are consolidated or reference the shared definition.no-cache; sha-ETag GETs serve SWR/immutable as spec'd.Fix PR: #292 (branch fix/issue-280) — third cache class
private, no-cacheapplied across social, releases, pulls view, and identity with version-keyed ETags intact; git-content routes untouched. Table-driven httptest per surface, coverage ≥95% held, -race green. Not merged — review requested.Review of PR #292 (fix/issue-280, third cache class). Verified in scratch worktree; docs-only follow-up pushed as
1084462.pullETag completeness (load-bearing question) — COMPLETE, no gaps. internal/pulls/http.go:515 pullETag folds HeadLive + BaseLive + Thread.Version + PR.Version + Mergeable(State+ComputedAt). Enumerated against PullView (service.go:486): every view input is covered — (a) thread fields incl. title/state/labels/participants/comment_count/review_summary: every writer bumps Thread.Version (pulls title/state/comment/merge/force-push/subscribe in service.go:918,934,1006 merge.go:267 mergeable.go:230; issues label/assignee paths; review refreshSummary review/service.go:276); (b) PR fields incl. body/merged/draft/head SHA: all writers go through savePR which bumps PR.Version (service.go:132); (c) mergeable conflicts/rebaseable/merge-base are pure functions of (baseSHA,headSHA), both folded, and state flips always land with a fresh ComputedAt; (d) HeadRefOk is deterministic in (pr.Head.SHA, HeadLive); (e) events window: every append bumps Thread.Version, pagination params are per-URL cache keys so unfolded after_seq/n is safe; (f) check results are not rendered in the view (merge-time gate only) — nothing to fold. Same-second recompute edge is immaterial since conflicts cannot change without a sha change.
Other checks — all pass: getDiff stays SWR (http.go:550, sound: patch bytes change only via ref movement; 60s staleness is the class semantics); tokenless lists (orgs/teams lists, member single, autodraft) take ccMutable without ETag = always 200, small bodies, fine for collab pages; per-package ccMutable duplication matches the writeCached/matchETag precedent, no shared import, acceptable; stableViewETag (http_test.go:108) is test-only, zero production impact, and the old-304-test race fix is genuine (background mergeable recompute moved ComputedAt between first-read and 304 assert); law-6 cost model holds (headers-only change, zero new store round trips, git hot paths untouched; per-read revalidation cost is correct for user-driven collab pages); coverage pulls 97.7 / social 99.5 / releases 99.8 / identity 97.2 (all >=95); gofmt + go vet clean; no new non-stdlib imports (only stdlib strconv in identity test); doc entries accurate (07_api §4 third class + Decisions, 03_pull_requests, 01_identity_permissions, 07_releases_stars, 08_ui_sdk — the diff-row ETag correction to shipped 'SWR, no ETag' is correct).
Fixed directly: 07_releases_stars.md:323 grouped tokenless autodraft with 'version-keyed GETs' — reworded, pushed
1084462(docs-only, no retest needed).Tests: go test -race -count=1 on all four touched packages — all ok. Backend-only change; no browser per instructions.
MERGE RECOMMENDATION: ready to merge.
Fixed by PR #292 incl. review doc fix (third cache class everywhere mutable; pullETag complete; all gates green), merged. Closing.
Superseded by #382, which elevates this to a systemic fix: shared cache-policy definition, a route-enumeration contract test, a new AGENTS.md law (mutability decides the cache class), and the summary conversion from #381. Closing in favor of #382.