v1.3.38.2 -- Dashboard cache: stale-while-revalidate, warm-up, shared TLS probes¶
Third release of the v1.3.38 series ("dashboard paints in under a second"; scope in docs/operations/ux-performance-review.md, section 7). Four items, in the order the operator set:
gofmtof the pre-existing unformatted Go files, as a separate commit that precedes everything else (26 files, not the 11 the v1.3.38.1 note guessed from a three-package scan).- Dashboard response cache rewritten: stale-while-revalidate, single-flight, boot warm-up, background refresh, and the age of the served value in the response.
- One shared, cached TLS probe pass for
/api/certs, the Dashboard overview card and the Dashboard health card, invalidated on host and certificate changes. - Build resource cap for
make deploy-prodon the 2-vCPU host.
No schema change. Bans, reconcilers, AppSec and auth untouched. argosVersion 1.3.38.1 -> 1.3.38.2.
2. Dashboard cache¶
internal/dashboard/cache.go used to be a TTL map: a hit inside 30 s returned the value, anything else recomputed synchronously inside the request, nothing was warmed, and two tabs opening at once recomputed twice. The new cache keeps the same 30 s TTL but changes what happens around it:
| Situation | Before | Now |
|---|---|---|
| value younger than 30 s | served | served (X-Argos-Cache: hit) |
| value 30 s to 5 min old | recompute in-request | served immediately, one background refresh (stale) |
| no value, or older than 5 min | recompute in-request | recompute once; concurrent callers wait for the same compute (miss) |
| compute fails while a value exists | error | keep serving the last value, X-Argos-Cache: stale-error, warn log with key + error + value age |
| process start | first request pays everything | the four default views (overview, health, traffic 24 h, security 24 h) are loaded right after the listener is up and refreshed every 30 s for the life of the process |
| unusual ranges / host filters | same as default | refreshed in the background only while they keep being requested (last 5 min), then left alone |
Pinned set, chosen from the measured cold compute cost of each view on prod on 2026-09-25 (operator rule: sum under 3 s, i.e. 10 % of the 30 s interval; any single view over 1 s is not pinned):
| View | Cold compute | Pinned |
|---|---|---|
| overview | 0.122 s | yes |
| health | 0.030 s | yes |
| traffic 24h, all hosts | 0.826 s | yes (the one near the limit; a disk-cold first run after a reboot can exceed 1 s once) |
| security 24h | 0.016 s | yes |
| sum | 0.994 s | 3.3 % of the interval |
Two of the four could not be caught cold on the first round because the operator's open tab keeps them warm; the numbers above are the rounds where the value was a real compute (a hit is ~1 ms).
The warm-up runs in a goroutine started after ListenAndServe, so it can never delay /healthz or the first request; it shares the single SQLite connection like every other background job. Loads run under a 60 s context detached from the request that triggered them.
Every /api/dashboard/* response now carries generated_at in the body and X-Argos-Generated-At / X-Argos-Cache headers. The Dashboard's "updated Ns ago" counter is driven by the overview's generated_at instead of the time the response arrived, so a value served from cache 25 s after it was computed reads "updated 25s ago", not "0s".
Unit tests (internal/dashboard/cache_test.go, run with -race): miss then hit, stale serve + background refresh, beyond-max-stale synchronous reload, single-flight on miss (8 concurrent callers, one load) and on stale refresh, failed refresh keeps the old value, miss error propagates, pinned warm-up + periodic refresh, recently-used vs idle unpinned keys.
3. Shared TLS probe pass¶
Before, three code paths each dialled Caddy once per enabled tls_mode=auto host on every cache miss: /api/certs (sequential, 1.07-1.15 s per call on the operator's 19 hosts, no cache at all), the overview card's expiring-soon count and the health card's cert list (each 19 parallel dials under a 10 s timeout). One dashboard open plus one Certificates visit was 57 TLS handshakes against Caddy.
internal/api/certprobe.go runs one parallel pass for the whole domain set and memoises it for 5 min; the three callers read the same map. Single-flight, and a pass is rerun when the TTL elapsed, the domain set changed, or Invalidate was called. Invalidation sites, enumerated from every caller of the probe:
| Trigger | Where |
|---|---|
| host create / update / delete / toggle | Handlers.reconcile (hosts.go), already the hook every host mutation runs |
| manual cert upload / delete | same hook (both handlers call reconcile) |
| cert renew request | RenewCert (certs.go) before the reconcile |
| config import apply | not needed: ApplyImport does not reconcile; the import flow requires a restart, which starts with an empty cache |
| backup restore | container restart, empty cache |
Not on the cache: the daily notifications cert cron (notifications/cron.go) keeps its own sequential probe; it runs once a day and lives in another package. Left as is for this release and listed under Known issues as a v1.3.38.3 hygiene item (two copies of one dial diverge).
/api/certs now reports last_checked_at as the time of the probe pass instead of the time of the request, which is the honest value when the pass is shared.
Unit tests (internal/api/certprobe_test.go): one pass shared within TTL regardless of domain order, re-probe on domain-set change and on Invalidate, TTL expiry, single-flight (6 callers, 2 dials for 2 domains), nil-safe Invalidate.
4. Build resource cap¶
The v1.3.38.1 deploy (cold build cache after the operator's docker builder prune) drove the 2-vCPU LXC to load 10.9 with 57 MB free while the panel kept serving. Pre-implementation check on the host: Docker 28.3.3 with BuildKit / buildx 0.26.1 exposes no --cpu-quota, --cpu-period or --cpuset-cpus on docker build (docker build --help lists only --shm-size among resource flags), so a client-side cap cannot exist. The cap therefore lives in the Dockerfile stages, driven from the Makefile:
- Go stage:
ARG GO_BUILD_PARALLELISM=1,go build -p ${GO_BUILD_PARALLELISM}withGOMAXPROCSset to the same value. - Frontend stage:
GOMAXPROCS=1(esbuild is a Go binary spawned by vite; checked innode_modules: the only esbuild env knobs areESBUILD_BINARY_PATH,ESBUILD_MAX_BUFFERandESBUILD_WORKER_THREADS, the last one for the sync API vite does not use) andUV_THREADPOOL_SIZE=1for libuv. - Makefile:
BUILD_PARALLELISM ?= 1, passed as the build arg;make deploy-prod BUILD_PARALLELISM=2on a bigger builder.
EFFECT to verify at deploy time: peak load during make deploy-prod versus the 10.9 of v1.3.38.1 (same cold-cache condition is not reproducible without another prune, which the operator has ruled out; the comparison is against the v1.3.38.0 warm-cache deploy, peak 6.1).
Smoke gate¶
scripts/smoke/dashboard-latency.sh, now with EXPECT_CACHE=hit: after 40 s idle each cold call to /api/dashboard/health must return in <= 1 s AND carry X-Argos-Cache: hit (the warm set kept it fresh; before this release the same call was a synchronous recompute). /api/security/overview keeps its own 30 s cache and is checked on latency only.
| Panel | dashboard/health cold | security/overview cold | /api/certs | X-Argos-Cache after 40 s idle |
|---|---|---|---|---|
| 1.3.38.1 (before) | 0.034 s (compute) | 0.003 s | 1.07-1.15 s every call | n/a (no header) |
| 1.3.38.2 (after) | 0.0007 s | 0.0016 s | 1.06 s (see below) | hit on all four /api/dashboard/* at +60 s after start with nobody in the panel, and again 3 min later |
Measured EFFECT on prod after the deploy (panel started 20:07:00Z, nobody opened the panel):
- +45 s: container healthy.
- +60 s: the four default views answer in 0.6-0.8 ms with
X-Argos-Cache: hitandX-Argos-Generated-At20:07:33/34Z, i.e. the warm set's second tick; 3 min later stillhit, generated 20:10:34Z. Nodashboard cache: refresh failedwarn in the logs. /api/certs: every row'slast_checked_atis 20:07:02.77Z, the probe pass the warm-up ran two seconds after start, not the request time. But the call still takes 1.06 s: the shared probe pass removed the 19 dials, and what is left isenrichWithLastEvent, oneLOWER(message) LIKE '%domain%'over allcaddy_errorrows per host, sequential (0.9 s per host with no match on the PHASE 0 copy). The review attributed/api/certsto both causes; the dials were the smaller one. Bounding that query (source + timestamp range, orhost_domainmatch) is the first item of v1.3.38.3.- +120 s at rest: panel 1.9 % CPU, 58 MB RSS (
docker stats), the cost of refreshing the four pinned views every 30 s. - Smoke with
MAX_SECONDS=1 EXPECT_CACHE=hit: PASS (dashboard/health0.0007 s hit,security/overview0.0016 s).
Build with BUILD_PARALLELISM=1: 109 s, load 2.6 -> 3.4 -> 3.8 -> 4.0 -> 4.7 during the compile (v1.3.38.1 cold-cache: 4.5 -> 5.4 -> 8.2 over the same window), then 9.7 in the last sample, which covers image export + --force-recreate + panel start (boot-time log retention pass over 500k rows, reconcile, warm-up). The compile cap did what it could; the end-of-deploy spike is not the compiler and needs its own measurement (start the panel without a build) in the v1.3.38.3 PHASE 0. Minimum free RAM during the build: 31 MB, with 2.7 GB available.
/api/dashboard/overview could not be measured cold on 1.3.38.1: the operator's open Dashboard tab refreshes it every 30 s, which is exactly the situation the warm set now guarantees for everyone.
Files changed¶
backend/internal/dashboard/cache.go(+cache_test.go) -- SWR cache.backend/internal/dashboard/types.go--generated_at.backend/internal/api/dashboard.go-- loaders, pins, headers,WarmDashboard, probe callers on the shared pass.backend/internal/api/certprobe.go(+certprobe_test.go) -- shared probe pass.backend/internal/api/certs.go,hosts.go,handlers.go,backend/internal/server/server.go,backend/cmd/argos/main.go-- wiring and invalidation.frontend/src/api/client.ts,frontend/src/pages/Dashboard.tsx-- age fromgenerated_at.backend/Dockerfile,Makefile-- build cap.scripts/smoke/dashboard-latency.sh--EXPECT_CACHE.CHANGELOG.md,docs/release-notes/v1.3.38.2.md,mkdocs.yml,docs/operations/verification-report.md,frontend/package.json.