What the agent asks the router#
The agent has one way to reach the router: the router's own HTTP API, over HTTPS. It
runs no commands, links against no router code and opens no shell — the binary imports
no netlink library and grep -rn "os/exec" agent/ finds nothing. Everything the web UI
shows came back from that API. Two properties of the API shape the whole design: a
call forks a process on the router, and the API serialises, so parallel calls are
no faster than sequential ones. Every cache, every interval and every refusal to poll
in this product is a consequence of those two sentences.
The cost of a call#
Each call into the router's HTTP API forks cli-shell-api on the router, and a read
takes between 150 and 800 milliseconds. Concurrency does not help: seven parallel reads
were measured taking as long as seven sequential ones
(agent/cache.go, and
docs/deploy.md "What the agent costs the router"). So the
agent budgets calls, not intervals. The only way to stop every page from blocking
on the router is to not ask the router every time.
The read-through cache#
Two freshness classes, because the two kinds of read are different in kind rather than
in degree (agent/cache.go):
| Class | Window | Why |
|---|---|---|
Configuration reads (showConfig, the command rendering, the raw form) | Until a commit invalidates them; configTTL is one hour and is only a backstop against a missed invalidation | The configuration changes only when somebody commits, and the agent is the thing that commits. This is correct, not merely fast. |
Operational reads (op-mode show) | opModeTTL, 3 seconds | They drift constantly. Long enough to serve a page's fan-out and a burst of navigation from one fetch, short enough that counters stay live. |
Per-interface detail (show interfaces <kind> <name>) | interfaceDetailTTL, 20 seconds | One router call each, for facts that change on the scale of minutes. |
Both are served stale-while-revalidate: a stale hit returns immediately and starts a single background refresh, and a key already being refreshed is not refreshed again. Only the very first read of a key ever blocks.
Four details in there are load-bearing:
- A transport failure is never cached. A router that could not be reached is retried on the next request rather than remembered as broken.
- A "path is empty" answer is cached. It is a stable negative and it is invalidated by a commit like any other configuration read.
- An invalidation generation counter guards the race. A fetch that began before a commit may finish after it, carrying the pre-commit answer; storing that with a fresh timestamp would serve stale configuration for the whole window, so the entry is left stale instead.
- A background refresh is detached from the request that noticed it, with a 30-second ceiling of its own. A browser that navigated away must not cancel the read that would have kept the next page fast.
A commit invalidates every configuration-derived entry — cfg, cmds and raw — and
triggers a re-warm (agent/vyos.go, invalidateConfig).
The primer#
A background goroutine warms the cache so the browser almost never waits
(agent/primer.go). At start-up it fetches the configuration
subtrees every page reads and the op-mode reads behind the dashboard and the hot pages.
After that:
- Configuration entries stay warm on their own, because they are invalidated by commits rather than by time. A 10-second ticker re-checks configuration only, so a commit's invalidation is repaired quickly and every other pass is a map lookup.
- Op-mode reads are not refreshed on a timer. Each one forks a process on the router, and a page that hits a stale entry is served the stale value at once and refreshed in the background — so a periodic refresh bought nothing and cost an idle router a steady load.
- The calls are deliberately not staggered. The router serialises, so spacing them does not lower the peak; it only makes the warm-up take longer.
What an idle router is actually asked#
| Source | Rate |
|---|---|
| Throughput sampler | One show interfaces counters every 5 seconds — one call for every interface, not one per interface |
| An open Dashboard | One show interfaces every 2 seconds, uncached, for the live stream |
| Per-interface detail | At most one call per interface per 20 seconds, and only while something is looking |
| The primer | Config subtrees at start and after a commit; op-mode reads at start |
Idle, that is about a dozen calls a minute. The number is not decorative: on a
two-vCPU bench router, the earlier design that sampled each interface separately held
the load average at 15, which is what motivated the single-call sampler and the
budget-not-interval rule (docs/deploy.md).
Steady-state, every page endpoint answers from the cache in well under a millisecond.
The UI's loading indicator self-delays 400 milliseconds for that reason — a warm
response paints the page before the indicator would appear, so the indicator only
shows up when something is genuinely slow
(ui/src/components/ui.tsx, Loading).
Timeouts, and who gets cancelled#
The agent keeps three HTTP clients for the router, because one timeout cannot be right
for all of it (agent/vyos.go):
| Client | Timeout | Used for |
|---|---|---|
| reads | 30 seconds | show, retrieve |
| writes | 3 minutes | /configure, /config-file — a container commit or a rollback on slow hardware runs well past 15 seconds, and timing out mid-commit leaves the caller unable to tell whether it landed |
| slow | 15 minutes | container image pulls, system images, traceroute, reboot, power off — bounded by the uplink, not the CPU |
On top of that, one browser request may spend at most 20 seconds talking to the
router in total (routerBudget), and the reads it makes are cancelled when the browser
goes away. A page that fans out to six sequential reads used to be allowed six times
the read timeout — three minutes of a held connection with nothing on the other end.
A commit is deliberately exempt. It uses the daemon's own client and is not cancelled by a closed tab, because a commit that was abandoned halfway is worse than a commit nobody is watching.
Knowing whether the router is there#
Every round trip records its outcome, so "is the router reachable right now" has an
answer outside the page that happened to fail
(agent/vyos.go, routerHealth). Reachable means the most
recent answer was a good one — not that a call was tried recently, which is why the
timestamps and the last error come with it. A router that answered with an error of
its own is reachable; it is the transport failures that mean gone.
The same counters feed /metrics: wheelhouse_vyos_calls_total and
wheelhouse_vyos_failures_total, plus the cache's hits, stale hits and misses. A cache
that has quietly stopped hitting is the difference between a fast appliance and a slow
one, and there was no way to see it.
Two consequences worth planning around#
Do not write a script that polls a per-object endpoint on a timer. Each call is
work on the router. Read the aggregate endpoints, or scrape /metrics, which the
agent serves from what it already knows.
A page can be a few seconds behind, on purpose. If you need a number that is certainly current, the telemetry stream bypasses the cache and a commit invalidates the configuration reads immediately. Everything else is within its window — see State, intent and drift for the freshness table.
See also#
- State, intent and drift — how fresh each number on a page is.
- The configuration tree — what those reads return.
- On-router, off-router, fleet — where the agent runs.
- Metrics reference — the cache and router-call series.
- API status codes — what a 502 from the agent means.
Checked against#
agent/cache.go ·
agent/primer.go ·
agent/vyos.go ·
agent/metrics.go ·
agent/main.go ·
agent/go.mod ·
ui/src/components/ui.tsx ·
docs/deploy.md