feat: cache merged /facts and /nodes in memory, stale on backend failure #12

Merged
benvin merged 7 commits from benvin/cache-facts into main 2026-09-05 23:15:43 +10:00
Member

Why

A busy Puppetboard re-fans-out the same /facts query every few seconds, and a 502 is worse than 30-second-old facts when every PuppetDB is unreachable.

How

  • Add a Cache interface (get reports fresh/stale/miss, put, stats) keyed on <path>?<params> with keys and repeated values sorted, plus a no-op default so uncached paths behave exactly as before.
  • Route serveMerged/serveUnion/serveSummed through serveCached, so the S3 reports cache drops in at cacheFor without touching a handler.
  • Back /facts and /nodes with a byte-bounded LRU: facts_ttl (default 30s, clamped to a 30s hard cap) and facts_cache_bytes (default 64 MiB); expired entries are kept and served only when every backend fails.
  • Single-flight identical keys so N concurrent requests cause one upstream fan-out.
  • Surface cache state and serving_stale in /healthz, cache settings in config show, semantics in the README.
## Why A busy Puppetboard re-fans-out the same `/facts` query every few seconds, and a `502` is worse than 30-second-old facts when every PuppetDB is unreachable. ## How - Add a `Cache` interface (get reports fresh/stale/miss, put, stats) keyed on `<path>?<params>` with keys and repeated values sorted, plus a no-op default so uncached paths behave exactly as before. - Route `serveMerged`/`serveUnion`/`serveSummed` through `serveCached`, so the S3 reports cache drops in at `cacheFor` without touching a handler. - Back `/facts` and `/nodes` with a byte-bounded LRU: `facts_ttl` (default 30s, clamped to a 30s hard cap) and `facts_cache_bytes` (default 64 MiB); expired entries are kept and served only when every backend fails. - Single-flight identical keys so N concurrent requests cause one upstream fan-out. - Surface `cache` state and `serving_stale` in `/healthz`, cache settings in `config show`, semantics in the README.
unkin-agent force-pushed benvin/cache-facts from c9fe2fbc4c to c0aab83c20 2026-09-05 21:03:55 +10:00 Compare
unkin-agent added 7 commits 2026-09-05 23:06:05 +10:00
A busy Puppetboard re-fans-out the same /facts query every few seconds, and a
502 is worse than 30-second-old facts when every PuppetDB is unreachable.

- Add a `Cache` interface (get reports fresh/stale/miss, put, stats) keyed on
  `<path>?<params>` with keys and repeated values sorted, plus a no-op default
  so uncached paths behave exactly as before.
- Route serveMerged/serveUnion/serveSummed through `serveCached`, so the
  reports cache drops in at `cacheFor` without touching a handler.
- Back /facts and /nodes with a byte-bounded LRU: `facts_ttl` (default 30s,
  clamped to a 30s cap) and `facts_cache_bytes` (default 64 MiB); expired
  entries are kept and served only when every backend fails.
- Single-flight identical keys so N concurrent requests cause one fan-out.
- Surface `cache` state and `serving_stale` in /healthz and the cache settings
  in `config show`.
- flightGroup.Do recovers a panicking fn so the leader and every waiter get a non-nil error instead of a zero-value success served as 200 []
- Note in the README that facts_cache_bytes budgets body bytes only
A single flight is built by whichever request arrived first, but every
request on that key waits for it. Running the fan-out on the leader's
cancelable request context hands the leader's disconnect to followers
whose own connections are healthy: they get 502 all backends failed.
Waiters also parked on a WaitGroup, so a follower whose own client went
away stayed blocked until the leader finished.

Run the flight on a context detached from the leader's request and
bounded by the configured timeout, and pass that context into build so
the fan-out uses it. Give Do a context so a waiter can abandon a flight
it no longer needs; the leader ignores it and always runs fn to
completion, keeping the cache warm for the others. A caller that
abandons on its own cancellation writes no response.
A stale fallback is byte-identical to a fresh response, so a client has
no way to tell it is holding data pdbmux served only because every
backend was down; the sole signal is a log line and a /healthz counter.

Set X-Cache to hit, miss or stale and Age to whole seconds since the
served copy was stored on every response from a cached path. Neither
header is emitted by OpenVoxDB, so nothing upstream is shadowed.
Reference-count flightCall so the fan-out context ends with the last
caller waiting on it, keeping cfg.Timeout as the upper bound. A leader
leaving with a follower still parked no longer disturbs the flight, and
a solo requester disconnecting releases the upstream sockets at once
instead of holding them for the whole timeout.

Return errFlightAbandoned from Do rather than inferring the abandon path
from the request context's sentinel, and read Age off the server's clock
so it matches the timestamp the cache stored.
Cache the merged body with its provenance already injected
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
de61ec5081
Rebasing onto main brought in the per-request sourceInjector, which now
runs inside the cached build: what a cache entry holds is the merged body
with pdbmux_source already stamped and upstream records of that name
already dropped.

Injecting on the way out instead would mean storing the un-injected
records plus a per-certname backend map and re-marshalling every record on
every hit, which is the work the cache exists to avoid. Baking it in stays
correct because the value names the backend that supplied the data — a
property of that fetch, not of the caller reading it — so it ages out with
the body it labels, and because the injection gate is a pure function of
path and query, both of which are already in the cache key.

Update the two cache tests whose byte-exact bodies predate the fact, and
add tests for the composition: attribution survives a cache hit on /facts
and /nodes, it ages with its entry rather than tracking a node that moved,
gated and ungated queries cache separately, and suppression of an upstream
fact of that name survives into the entry.
unkin-agent force-pushed benvin/cache-facts from 2feb365c31 to de61ec5081 2026-09-05 23:06:05 +10:00 Compare
benvin merged commit 83c89ad426 into main 2026-09-05 23:15:43 +10:00
benvin deleted branch benvin/cache-facts 2026-09-05 23:15:44 +10:00
Sign in to join this conversation.