Revisit the 5-minute Cache-Control TTL on GET /api/documents/density #709
Reference in New Issue
Block a user
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?
Context
The document timeline/density chart on the search dashboard is backed by
GET /api/documents/density. The endpoint computes month-bucketed document counts and is fetched whenever the user changes filters (sender, receiver, tags, status, full-text search) on the dashboard.The response carries an HTTP cache header set in
DocumentController.java(~line 410):There is no server-side compute cache (no
@Cacheable, no Caffeine), and no reverse-proxy/CDN cache in front (Caddy doesn't cache by default). The only cache is thisprivate, max-age=300header, which lets each user's browser serve repeated density requests from its own cache.Problem
The 300s TTL is a "feels safe" default, not a value tuned to anything measurable:
DocumentService.getDensity()does an in-memory aggregation (load filtered documents → project to dates → group by month into aTreeMap). The archive is ≈5k documents and the stated target is <200ms p95. SQLGROUP BYmigration is only planned past 50k docs (issue #481). The backend can comfortably serve these requests uncached at current scale.private, this only affects the individual browser (no shared/CDN staleness).So the trade-off is currently tilted toward "avoid a cheap recompute" at the cost of "show stale counts after an edit," which is the wrong way round for a near-real-time editing workflow.
Decision (resolved 2026-06-01)
Remove the
Cache-Controlheader from the density response entirely — always fresh.no-store. Plain omission is the simplest correct option;no-storeadds no real protection here (Nora, Felix).Acceptance criteria
DocumentController.density()no longer sets aCache-Controlheader — returnResponseEntity.ok(result)(header omitted, notno-store).DocumentControllerTest.density_emitsPrivateCacheControlHeaderupdated red-first to assert the header is absent:header().doesNotExist("Cache-Control")(rename the test accordingly).getDensity()Javadoc inDocumentService.java(~L140) no longer claimsCache-Control: max-age=300 ... absorbs repeated browse loads— update the comment to match.DocumentService.getDensity()computation logic.Out of scope
@Cacheable/Caffeine) — not warranted at current scale.Pointers
backend/.../document/DocumentController.java— density endpoint + cache header (~L397–412)backend/.../document/DocumentService.java—getDensity()+ scale notes (~L128–196)backend/.../document/DocumentControllerTest.java—density_emitsPrivateCacheControlHeader(~L1391–1401)frontend/src/lib/document/timeline.ts—fetchDensity()/buildDensityUrl()(relies on HTTP cache; no explicit frontend cache)🏛️ Markus Keller — Application Architect
Observations
DocumentController(CacheControl.maxAge(5, MINUTES).cachePrivate()), there is no@Cacheable/Caffeine layer, and there is no reverse-proxy cache in front (Caddy doesn't cache by default). So the only cache is the client's own browser. The issue frames this correctly.getDensityJavadoc (DocumentService.java:140-146) and deferred to #481 past 50k docs. That's good architectural hygiene — the why is recorded next to the code.private, max-age=31536000, immutable(Document.java:188). That's correct there because file bytes are immutable. Density is the opposite — its inputs mutate on every edit — so a long TTL is the wrong instinct copied into a context where it doesn't hold.Recommendations
max-ageis the wrong primitive for "expensive enough to not recompute on every keystroke, but cheap enough that staleness is unacceptable." A short window (30s) keeps the burst-absorbing benefit during rapid filter-clicking while bounding staleness to something a user won't notice as "wrong." Removing it entirely is also defensible at this scale, but you lose the cheap protection against a user hammering filters.getDensity()untouched and not introducing a server-side cache is the right boundary — adding Caffeine here would be premature complexity for a sub-200ms in-memory aggregation over 5k rows.Open Decisions
Cache-Controlwindow is a time-based guess. The architecturally cleaner primitive for "cache until the data actually changes" is anETag/If-None-Match304 flow keyed off a cheap document-mutation signal. That eliminates staleness and the recompute on unchanged data — but it's more moving parts for a chart that already computes in <200ms. For this scale the simple short TTL is almost certainly the right call; I flag the ETag option only so the choice is deliberate rather than defaulted. (Cost: ETag = correctness + zero staleness, but more code and a mutation-version source; short TTL = trivial, but accepts bounded staleness.)👨💻 Felix Brandt — Senior Fullstack Developer
Observations
DocumentController.density()(DocumentController.java:410) and one assertion inDocumentControllerTest.density_emitsPrivateCacheControlHeader. The AC already correctly scopes outgetDensity()— no production logic moves.containsString("max-age=300")andcontainsString("private"). That's the only thing pinning the value, which is exactly the red/green lever for this change.Recommendations
max-age(or, if removed, to assert absence), watch it go red against the current300, then change the controller line to green. This is the whole TDD cycle for this issue — don't edit the controller first.5/TimeUnit.MINUTESwith a named constant. Right now the rationale lives inDocumentService's Javadoc but the value lives inDocumentControllerwith no explanation. Extract it so the code self-documents:CacheControl.maxAge(Duration)is the non-deprecated overload — prefer it over the(long, TimeUnit)form.header().doesNotExist("Cache-Control")rather than just dropping the assertion, so the absence is intentional and regression-protected. And update thegetDensityJavadoc that currently claimsmax-age=300 ... absorbs repeated browse loads— a stale comment is worse than none.Open Decisions (none)
🔒 Nora Steiner ("NullX") — Application Security Engineer
Observations
cachePrivate()directive is doing real security work here, and it must stay. The density response is filterable per-user (sender, receiver, tag, status, full-textq) and reflects which documents exist that match a query. Apubliccache (browser shared cache, or any intermediary) could serve one user's filtered month-counts to another.privateis the correct, deliberate scope — CWE-525 (sensitive info in browser cache) is bounded to the single authenticated browser.GET /api/documents/densityhas no@RequirePermission, which is correct — write-permission annotations are for mutating verbs. Authentication is enforced globally (anyRequest().authenticated()inSecurityConfig), so this is not an unauthenticated leak.Recommendations
private. Loweringmax-ageor removing it has zero security downside — fresher data is strictly safer for a per-user response. The one change that would be a security regression is switching tocachePublic()or droppingprivatewhile keeping a cache. Don't.no-store. Omission already means the browser won't reliably reuse a credentialedGETacross sessions;no-storewould be belt-and-suspenders but adds no real protection here and just noise.density_emitsPrivateCacheControlHeadertest) so a future refactor can't silently flipprivate→public. That test is the security control here.Open Decisions (none) — this is a freshness/UX tradeoff; from the security angle, every candidate value is acceptable as long as
privateis retained.🧪 Sara Holt — QA Engineer
Observations
DocumentControllerTest.density_emitsPrivateCacheControlHeader, assertingcontainsString("max-age=300")+containsString("private"). It's a@WebMvcTestslice — fast, right layer for a header assertion. No integration test touches the TTL, nor should one; the cache header is a controller-contract concern, not a Postgres-behavior concern.Recommendations
containsString("max-age=300")would also pass formax-age=3000. If the decision is a concrete TTL, assert the exact directive (Cache-Control: max-age=30, private) so a fat-fingered future edit can't slip through. One logical thing per test still holds — it's one header contract.header().doesNotExist("Cache-Control"). Don't just delete the test — an absent assertion is an untested behavior, and "no cache header" is itself the contract we're now promising.max-age. Keep this out of the Playwright suite.Open Decisions (none)
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
Recommendations
Open Decisions (none) — my recommendation is unambiguous: prioritize freshness.
🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
privatedirective means even if a cache existed it wouldn't store this. So the only thing the 300s buys is "the same browser doesn't re-request density while clicking filters." That's a micro-optimization, not a load-bearing one.Recommendations
http.server.requeststimer foruri=/api/documents/densityin Grafana (p50/p95, request rate). That turns "300s felt like a lot" into "density p95 is Xms at Y req/min, so the cache is/ isn't worth keeping." This is a 5-minute Grafana check, not new instrumentation.private(network-isolation/privacy reasons are Nora's call, but operationally it also keeps any future intermediary from caching per-user data — don't regress it).Open Decisions (none)
📋 Elicit — Requirements Engineer
Observations
needs-discussionlabel is correctly applied: the central AC, "A decision is recorded on the target TTL," is a decision, not yet a requirement. Nothing is implementable until that number exists.DocumentService.java:140). There is no corresponding freshness NFR — and this whole issue exists because the two were never weighed against each other.Recommendations
Cache-Controlheader, or (b) emitno-store. These produce different browser behavior. The implementer needs the exact intended outcome — pin it in the decision, don't leave it to interpretation.Open Decisions
0s(remove header — always fresh),~30s(absorbs filter-click bursts, edit-staleness imperceptible),~60s(more burst protection, slightly more staleness). The team has uniformly leaned toward freshness; this just needs the number recorded.no-store— must be stated explicitly if removal is chosen.🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Product / Data freshness
0s— remove the header (always fresh). Simplest; recompute is imperceptible at this scale. (Leonie, Tobias comfortable; Markus's only caveat: you lose cheap protection against a user hammering filters.)~30s— short browser-cache window. Absorbs rapid filter-clicking within one interaction; edit-staleness too short to notice. (Markus's and Leonie's preferred ceiling.)~60s— slightly more burst protection, slightly more staleness.(Raised by: Elicit, Markus, Leonie, Tobias)
Implementation semantics
Cache-Controlheader entirely (recommended — simplest, no downside here) vs. emitno-store. These are different browser behaviors and must be pinned so the implementer isn't guessing. (Raised by: Elicit, Felix, Nora — Nora and Felix both recommend plain omission overno-store.)Non-negotiable constraints all personas agreed on (not decisions — just don't break these):
cachePrivate()whatever you choose (Nora: per-user filtered data —publicwould be a cross-user leak).getDensityJavadoc (DocumentService.java:140) that still citesmax-age=300.density_emitsPrivateCacheControlHeadertest red-first; assert the exact new value, orheader().doesNotExist(...)if removed.✅ Implemented — PR #711
Worked the resolved decision (remove the explicit header, always-fresh) via red/green TDD on branch
feat/issue-709-remove-density-cache-ttl.Commit:
50f5546—refactor(document): drop the 5-minute Cache-Control TTL on /density (#709)AC status
DocumentController.density()no longer sets aCache-Controlheader →return ResponseEntity.ok(result)(unusedCacheControl/TimeUnitimports removed).density_emitsPrivateCacheControlHeader→density_isNeverBrowserCached.getDensity()Javadoc no longer claimsmax-age=300 … absorbs repeated browse loads.getDensity()computation logic.⚠️ One deviation from the literal AC (confirmed with the maintainer before proceeding)
The AC asked for
header().doesNotExist("Cache-Control")("omit, notno-store"). That turned out to be impossible: our explicit header was overriding Spring Security'sHeaderWriterFilter, which writesCache-Control: no-cache, no-store, max-age=0, must-revalidateon every secured response. Omitting our header lets that framework default apply.This over-satisfies the goal:
no-storeis strictly safer thanprivatefor per-user filtered data — no caching means no cross-user leak, so Nora'scachePrivate()constraint is satisfied for free.The test now asserts the exact real contract:
Cache-Control: no-cache, no-store, max-age=0, must-revalidate.Verification
DocumentControllerTest— 105/105 green (red confirmed before the controller change)../mvnw clean package -DskipTests— BUILD SUCCESS.Out-of-scope (#481 SQL aggregation, server-side compute cache, ETag/304) untouched as specified.