2 Commits
Author SHA1 Message Date
mokhtar c9701fae85 build: bump version to 0.0.13
Gates / frontend (push) Successful in 1m35s
Gates / test (push) Successful in 2m23s
Gates / test-aarch64 (push) Successful in 8m13s
Gates / package (push) Successful in 4m15s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 23m19s
Release / guard (push) Successful in 34s
Gates / frontend (push) Successful in 1m39s
Gates / test (push) Successful in 2m19s
Gates / test-aarch64 (push) Successful in 7m25s
Gates / package (push) Successful in 48s
Release / publish (push) Successful in 6m36s
Gates / container (push) Successful in 17s
Release / gates (push) Successful in 10m52s
2026-08-27 21:46:08 +02:00
mokhtar a3aa7febb4 upstream: one absolute per-query budget across queueing and failover
Gates / frontend (push) Successful in 1m46s
Gates / test (push) Successful in 2m32s
Gates / package (push) Successful in 4m20s
Gates / test-aarch64 (push) Successful in 8m15s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 30m33s
waiting for a slot now spends the query budget; truncated attempts that
expire fault the budget, not the upstream, and are never attributed.
admission sweeps in priority order before blocking. forward zones spend
read_timeout_ms once across udp, truncation and tcp. adds
nxdns_upstream_budget_exhausted_total and a 64-upstream validation limit.
2026-08-27 21:10:43 +02:00
17 changed files with 1599 additions and 208 deletions
+15
View File
@@ -4,6 +4,21 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [0.0.13] - 2026-08-27
The upstream query budget becomes one honest deadline. A busy network no longer blames a healthy standby for running out of time, and a query burst no longer queues invisibly until everything answers SERVFAIL at once.
### Fixed
- **The per-query upstream budget is now one absolute deadline, spent by everything that blocks.** Waiting for a free slot on a saturated upstream now spends the query's `upstream.total_timeout_ms` budget just like the exchange itself, instead of being invisible to it — under a burst, queries used to wait out their whole budget in the queue and then start attempts they could never finish. An attempt near the end of the budget runs truncated, and when a truncated attempt runs out of time that is evidence about the budget, not the upstream: it no longer counts against that upstream's health or success rate, and the query log no longer names an upstream that was given no fair chance. A field incident produced 279 rows blaming a standby whose health counters read zero for zero; those rows now attribute nothing.
- **A query no longer blocks behind a saturated upstream while another has capacity.** Admission sweeps the upstreams in priority order and takes the first free slot; priority now means the order among upstreams that can be admitted right now, and a query blocks only when nothing has capacity — on the highest-priority eligible upstream, bounded by the remaining budget.
- **Conditional forward zones spend `upstream.read_timeout_ms` once per query.** A UDP attempt, a truncated answer and the TCP retry now share the one budget instead of taking a fresh one each, so a slow zone resolver can no longer stretch a single query to several times the configured timeout.
### Added
- **`nxdns_upstream_budget_exhausted_total`.** A pool-wide counter of queries whose budget ran out — in the queue or mid-attempt — before any upstream answered. It carries no per-upstream label on purpose: running out of budget is a fact about the pool.
- **A configuration with more than 64 enabled upstreams is rejected at validation** with a clear message, instead of tripping an internal limit at startup.
## [0.0.12] - 2026-08-27
Overview stops re-reading the whole query log. One endpoint, one snapshot, pre-aggregated buckets — a 30-day view now costs the same on a month of history as on a day of it. Read the upgrade note first: it resets your query history.
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.12",
.version = "0.0.13",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+2 -2
View File
@@ -267,11 +267,11 @@ cat /etc/resolv.conf
curl -s http://127.0.0.1:8080/metrics | grep nxdns_upstream_
```
`nxdns_upstream_in_flight` against `nxdns_upstream_slots` is how much of an upstream's concurrency is in use right now, and `nxdns_upstream_queued_total` counts exchanges that had to wait for a slot (with `nxdns_upstream_queued_seconds_total` for how long they waited in total). Both queue counters are approximate — they are sampled when a query is admitted, not measured as a queue length. A `queued_total` climbing with each burst means queries are waiting on the upstream rather than failing at it, and a query that waits past `upstream.total_timeout_ms` is canceled in the queue and answered SERVFAIL.
`nxdns_upstream_in_flight` against `nxdns_upstream_slots` is how much of an upstream's concurrency is in use right now, and `nxdns_upstream_queued_total` counts exchanges that had to wait for a slot (with `nxdns_upstream_queued_seconds_total` for how long they waited in total). Both queue counters are approximate — they are sampled when a query is admitted, not measured as a queue length. A `queued_total` climbing with each burst means queries are waiting on the upstream rather than failing at it, and a query that waits past `upstream.total_timeout_ms` is given up on in the queue and answered SERVFAIL. `nxdns_upstream_budget_exhausted_total` counts those give-ups pool-wide — queries whose own budget ran out, in the queue or mid-attempt, before any upstream answered. It carries no `url` label on purpose: running out of budget is a fact about the pool, so the exhausted attempt is never charged to an upstream's health and is never attributed in the query log, although the row can still name the last endpoint whose success or recorded failure preceded it.
`nxdns_upstream_reuse_recoveries_total` is the other half of the picture: it counts DoT connections that went stale between exchanges and were redialed. A few are normal — a resolver is free to close an idle connection. One per query means the connection is never being reused, and every query is paying a full TLS handshake.
**Fix.** Raise `upstream.total_timeout_ms` if the queue drains but drains too slowly for the budget. Otherwise the queue is telling you the upstream is slow: an upstream answering in a few milliseconds does not fill eight concurrent slots at household query rates, so sustained queueing points at the resolver you configured, and a faster one is the fix. Adding a second upstream is not: failover is strict priority, not load spreading — a query waits for a slot on the first available upstream and only reaches the next one when that upstream fails or is in backoff, so a second entry adds no concurrent capacity to the first. The per-upstream slot count is compiled, not configured, so there is no knob to widen one upstream either.
**Fix.** Raise `upstream.total_timeout_ms` if the queue drains but drains too slowly for the budget. Otherwise the queue is telling you the upstream is slow: an upstream answering in a few milliseconds does not fill eight concurrent slots at household query rates, so sustained queueing points at the resolver you configured, and a faster one is the fix. Adding a second upstream helps only with saturation, not with latency: failover is strict priority, not load spreading. A query does take the next upstream when the first one's slots are all busy — priority orders the candidates that can be admitted right now — but once every eligible upstream is full it blocks on the highest-priority one, and it still sends one query to one upstream at a time. The per-upstream slot count is compiled, not configured, so there is no knob to widen one upstream either.
## The disk is filling up
+5 -3
View File
@@ -37,12 +37,14 @@ Timeouts for talking to upstream resolvers.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `upstream.attempt_timeout_ms` | u32 | 2500 | ms | 100120000, and not above `total_timeout_ms` | deadline on one attempt against one upstream inside the pool's failover loop (`src/upstream/pool.zig`), the whole attempt including the connect |
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100120000 | read deadline on conditional-forward-zone exchanges (`src/local/forward_client.zig`) |
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100120000 | budget for a whole conditional-forward-zone exchange (`src/local/forward_client.zig`): the UDP attempt, a TC=1 fallback and the TCP retry together |
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100120000 | per-query budget of the upstream pool (`src/upstream/pool.zig`): every failover attempt together, not one of them; also the `nxdns check` probe deadline |
The two pool budgets nest. `attempt_timeout_ms` bounds one try against one upstream; when it expires the pool records the failure and moves to the next candidate. `total_timeout_ms` bounds the whole loop, so a query against five unreachable upstreams costs the total budget once, not five attempt budgets in a row. When the total expires the in-flight attempt is canceled and the query fails with a timeout.
`read_timeout_ms` is unrelated to both. It bounds a different subsystem — the conditional-forward-zone client — so no cross-check relates it to the pool's budgets, and it is free to sit above either of them.
Budget semantics. The deadline is an instant, computed once when the query enters the pool, and every blocking step spends against it — waiting for a free slot on an upstream as much as the exchange itself. An attempt therefore runs against `min(now + attempt_timeout_ms, deadline)`, which near the end of the budget is *truncated*: shorter than the configured attempt. Attribution follows from that. A truncated attempt that expires is evidence about the budget, not about the upstream, so it leaves that upstream's health and success rate untouched and the query log's `upstream` field unchanged — the row may still name the endpoint of the preceding attributable attempt, and is null only when there was none — and it increments the pool-wide `nxdns_upstream_budget_exhausted_total` metric. An attempt that expires on its full budget, or that fails outright (a refused connection, a bad answer, the peer's own timeout), is evidence about the upstream: it is recorded against that upstream's health and names it in the query log. Either way the client is answered SERVFAIL. Forward-zone queries are not affected — they have one configured resolver, so a timeout there always names it.
`read_timeout_ms` is unrelated to both pool budgets. It bounds a different subsystem — the conditional-forward-zone client — so no cross-check relates it to the pool's budgets, and it is free to sit above either of them. Within that subsystem it is one budget for the whole exchange: a query that goes out over UDP, comes back truncated and is retried over TCP has the two legs and the fallback share `read_timeout_ms`, never one each.
### dns
@@ -318,7 +320,7 @@ Zones resolved by a specific resolver instead of the configured upstreams, for L
| `zone` | string | required | a valid domain name; unique |
| `resolver` | string | required | `udp://IP:port` or `tcp://IP:port`; the host must be an IP literal and the port is mandatory |
The resolver host must be an IP literal because resolving the resolver's own name would be a bootstrap problem. Matching is longest suffix (`src/local/forward_zones.zig`); the exchange is UDP then TCP (`src/local/forward_client.zig`) with `upstream.read_timeout_ms` as the read deadline.
The resolver host must be an IP literal because resolving the resolver's own name would be a bootstrap problem. Matching is longest suffix (`src/local/forward_zones.zig`); the exchange is UDP then TCP (`src/local/forward_client.zig`), with `upstream.read_timeout_ms` bounding the whole exchange rather than each leg.
Reverse zones are declared the same way, and one is the prerequisite for [learned client names](#learned-names):
+337
View File
@@ -0,0 +1,337 @@
# Milestone 37: Upstream failover budget — deadline ownership and honest attribution
Make the pool's failover loop own one deadline (admission included), revive the
standby under primary saturation, and stop blaming endpoints for budget
exhaustion.
## The defect (field-verified on the Pi, 0.0.11)
With defaults attempt=2500 ms / total=5000 ms and two upstreams:
1. `Pool.exchangeLoopLen` (pool.zig:277) never computes remaining time. The
semaphore wait consumes the total budget invisibly: under a traffic burst
the primary's slots saturate, a new request queues behind them while the
standby sits idle with free slots, and the outer `raceWithin(total)` cancels
whatever finally runs. 155 requests died at the 5 s cap; 706 of 717
SERVFAILs came from one bursty client. On an idle pool a fast standby works
today — the queue is the killer, not the timer arithmetic alone.
2. The reporting contradicts itself. `selected` is written before the attempt
runs (pool.zig:348), so the query log blamed the standby on 279 rows;
cancelled attempts are excluded from health (pool.zig:363), so its counters
read 0/0. Nothing records "the request exhausted its own budget."
Design reviewed and converged with Codex (thread of 2026-08-27). Defaults do
not change.
## Sessions
Three, strictly sequential: A (transport vocabulary) → B (pool) → C
(handler, forward client, surfaces).
---
## Session A: transport vocabulary
### A.1 `error.BudgetExhausted` and a fourth group (src/upstream/transport.zig)
- Add `BudgetExhausted` to `ExchangeError`.
- Add `.budget_exhausted` to `Group`; `group()` maps the new error to it. The
switch stays exhaustive with no `else` — every switch over `Group` breaks at
compile time until it handles the new group, which is the point. The
production switches are `pool.zig` (:353), `handler.zig` (:677, :735) and
`forward_client.zig` (:124). Session A owns a mechanical placeholder arm at
each (pool and forward_client: propagate the error without recording
health; handler: `=> ctx.servFail()`), plus any test switches the compiler
flags, so A's `zig build test` passes. B and C then own the real behavior
at their sites. (health.zig has no `group` call; the doh/dot occurrences
are tests, not switches.)
### A.2 Timer-origin race (src/upstream/transport.zig)
`raceWithin` collapses "the expiry task won" and "the raced operation itself
returned error.Timeout" into one `error.Timeout`. Add:
```zig
pub const RaceOutcome = enum { completed, expired };
pub fn raceUntilTagged(
io: std.Io,
expiry_at: std.Io.Clock.Timestamp,
outcome: *RaceOutcome,
comptime f: anytype,
args: anytype,
) ExchangeError!RacedPayload(f)
```
The expiry parameter is an ABSOLUTE timestamp, not a duration: a duration
computed from `deadline.toDurationFromNow()` and then slept re-anchors at
"now", drifting past the total deadline and misclassifying a nominally full
attempt. The pool computes `expiry_at = min(now + attempt, total_deadline)`
and passes the timestamp. On the expiry side the function sets `outcome.* =
.expired` and returns `error.Timeout`; on completion it sets `.completed` and
returns the raced result (which may itself be `error.Timeout` from the leaf —
that is a completed peer timeout, not an expiry). `raceWithin` keeps its
public API and behavior but delegates to the same internal harness (compute
the absolute deadline, discard the outcome) — one `Select` harness in the
file, not two copies.
### A.3 Acceptance
- [ ] `zig build test` green.
- [ ] Unit tests: tagged race distinguishes leaf `error.Timeout` (completed)
from expiry (`expired`); the untagged wrapper is unchanged behavior.
---
## Session B: the pool (src/upstream/pool.zig)
### B.1 One deadline owns the loop
- `Pool.exchange` establishes `deadline = std.Io.Timeout{ .duration =
self.timeouts.total }.toDeadline(io)` (`.awake` clock, as the loop uses
today) and passes it down. The equal-deadline outer `raceWithin` at
pool.zig:267 is REMOVED — the loop owns the deadline; two timers aimed at
the same instant race each other and let the outer cancellation bypass the
loop's classification. No replacement watchdog (the outer race never bounded
uncancelable health writes anyway; a later-firing watchdog is scope creep).
- Every blocking step consumes the deadline:
- Admission: through the ownership-safe protocol of B.2 — never a bare
`Semaphore.wait` raced against an expiry. Racing the wait with
`Select.cancelDiscard` can leak a permit: the wait may have decremented
the count in the same instant the expiry wins, and the discarded success
never reaches the caller's release-defer. If no time remains before
admission, return `error.BudgetExhausted` without waiting.
- Attempt: raced via `raceUntilTagged` against
`expiry_at = min(now + timeouts.attempt, total_deadline)`.
- `total < attempt` is already rejected by validate.zig; `total == attempt`
(and any admission overhead) simply yields truncated attempts, handled by
B.3.
### B.2 Admission without head-of-line blocking
Zig 0.16's `Semaphore` has neither try-acquire nor a timed wait, so the pool
gains a local admission helper mirroring the standard semaphore's own
mutex/decrement/condition protocol (never by patching `../zig`, never by
spinning):
- `tryAcquire()` — take a permit if one is immediately available, else fail
without blocking.
- `acquireUntil(deadline)` — timed acquisition; returns Acquired (holding a
permit), Expired (holding none), or `error.Canceled` (holding none).
`std.Io.Condition` has no timed wait in 0.16, so this is a NEW pool-local
primitive, specified exactly: state lives under one mutex (permit count +
waiter bookkeeping); the wait itself may race `Condition.wait` against a
sleep via `Select`, because a permit is only ever taken under the mutex
AFTER the race resolves — a discarded wake is a lost notification, not a
lost permit. To keep that lost notification from stranding another waiter,
an exiting waiter that may have absorbed a signal (expiry or cancellation
path) re-signals the condition before returning. Permit conservation is by
construction: the decrement and the "did I win" decision happen under the
same mutex. Every take is non-blocking (the mutex's `tryLock`): a lock
miss reads as "no permit now", and `acquireUntil` re-enters the
absolute-deadline race on each miss, so contention on the admission mutex
never carries a call past its deadline (review round 2026-08-27). Tests:
an expiry/acquisition tie leaves the permit count exact; a cancelled
waiter holds nothing and a peer waiter still wakes; a contended admission
mutex does not carry `acquireUntil` past its deadline.
Loop semantics — priority means ordering among immediately admissible
candidates:
1. Pass one, first sweep in priority order: `tryAcquire` on each available
entry; the first immediate success is attempted. A saturated entry is
skipped while another eligible entry has capacity.
2. If the attempted entry fails (peer fault), the sweep continues from the
next entry, still by `tryAcquire`; previously skipped saturated entries
are re-tried by `tryAcquire` on each subsequent sweep step (a slot may
have freed).
3. Only when no eligible entry has an immediate permit does the loop block:
`acquireUntil(remaining deadline)` on the highest-priority eligible
entry. Expired → `error.BudgetExhausted`. The no-head-of-line guarantee
is deliberately scoped to capacity observed during the sweep: once
blocked, a lower-priority slot freeing does not wake this waiter
(any-entry wakeups need multi-wait machinery this milestone does not
buy). Record this bound in the admission helper's doc comment.
4. Pass two (backoff probing) keeps today's in-order probing but admits
through the same helper bounded by the remaining deadline — it
deliberately retains blocking, one entry at a time, because probing a
backed-off entry is already a last resort.
5. The post-admission health recheck survives the refactor: after acquiring
a permit by either path in pass one, re-read health against a fresh
`now`; an entry that entered backoff while this task waited is released
(permit returned) and the sweep resumes. The existing regression test
for this recheck is retained.
6. Before any attempt starts — immediate admission included — the loop
re-reads the clock; a deadline already passed returns
`error.BudgetExhausted` with the permit returned and no endpoint named,
so an instantly-completing leaf can never manufacture evidence after
exhaustion (review round 2026-08-27). Test: an exchange whose deadline
is already gone starts no attempt.
7. The compiled pool bound (`Pool.max_entries`, 64) is enforced at config
validation: more than 64 ENABLED upstreams is `TooManyUpstreams` at path
`upstreams`, so a valid config can never trip the pool assert (review
round 2026-08-27). Tests: 64 enabled passes, 65 fails, 65 listed with
64 disabled passes.
8. Queue accounting keeps its meaning: `queued_total` and
`queued_seconds_total` count only the blocking `acquireUntil` path,
recorded whether it ends in acquisition, expiry or cancellation; a
`tryAcquire` — hit or miss — never counts as queued. Acceptance test
retained.
### B.3 Classification (uses A.2's tagged race)
| Attempt outcome | Budget it ran with | Health | `selected` | Loop action |
| --- | --- | --- | --- | --- |
| success | any | success | this endpoint | return answer |
| expiry (`expired`) | full `attempt` | failure | this endpoint | continue failover |
| expiry (`expired`) | truncated | untouched | unchanged | return `error.BudgetExhausted` |
| completed peer fault (incl. leaf Timeout) | any, even truncated | failure | this endpoint | continue failover |
| local_resource | — | untouched | unchanged | return err (as today) |
| cancellation | — | untouched | unchanged | return `error.Canceled` |
| wait exhausts deadline | — | untouched | unchanged (null if nothing ran) | return `error.BudgetExhausted` |
| completed attempt returns `error.BudgetExhausted` (a leaf may emit it once it exists) | any | untouched | unchanged | return it; counter increments once at the outer pool |
A truncated expiry is a censored observation: the pool did not grant the
configured observation interval, so it is evidence about the pool's budget,
never about the peer. No minimum-attempt floor exists.
### B.4 `selected` = last attributable endpoint
Assign `selected.*` only in the success row and the two health-recording
failure rows above — after the attempt completes, not before it starts. This
attribution rule is POOL-SPECIFIC: leaf clients (DoH, DoT, ForwardClient,
test fakes) keep their write-before-attempt behavior — they have one
endpoint and record no health, so "the endpoint I tried" is honest there.
The `Client.exchange` doc comment in transport.zig is amended to state both
contracts: implementations may write before each attempt; `Pool` documents
its stricter last-attributable rule on `Pool.exchange` itself. The pool-level
tests at pool.zig:748-771 (selected-before-attempt) and :910 invert into the
new contract's tests; leaf-client tests are untouched.
### B.5 The counter
`Pool` gains one pool-level counter, `budget_exhausted_total` (atomic u64,
incremented once per exchange that returns `error.BudgetExhausted`, never per
endpoint). `Pool.snapshot` returns per-entry rows and cannot carry a
pool-wide number without duplicating it — so the counter is exposed through a
separate getter, `Pool.budgetExhaustedTotal()`, and B owns the mechanical
plumbing at every existing snapshot call site its change touches so B's own
gate passes before C. No per-stage split (queue vs attempt) —
fixed-cardinality stage labels are deferred until an operator needs them.
### B.6 Acceptance (the decisive regressions first)
- [ ] Fast standby at shipped defaults: entry 0 stalls its full attempt
budget, entry 1 answers instantly, attempt = total/2 → entry 1 answers.
- [ ] Saturated primary, free standby: entry 0's slots all held by stalled
exchanges, entry 1 free and fast → entry 1 answers well inside the
deadline. This is the Pi reproduction; it must FAIL against the current
code and pass after B.2.
- [ ] Truncated expiry mutates no health, returns BudgetExhausted, leaves
`selected` at the last attributable endpoint.
- [ ] Truncated attempt failing with ConnectionRefused mutates health and
updates `selected`.
- [ ] Queue-only exhaustion → BudgetExhausted with `selected == null`.
- [ ] All-backoff pass two runs under the same deadline.
- [ ] External cancellation returns Canceled, counts no budget, mutates
nothing.
- [ ] `budget_exhausted_total` increments once per exhausted exchange.
- [ ] Existing invariants hold: cancelled waiter returns its permit; two
stalling upstreams cost ≤ total, not one budget each.
---
## Session C: handler, forward client, surfaces
### C.1 Handler (src/server/handler.zig)
Both `transport.group` switches (:677, :735) gain `.budget_exhausted =>
ctx.servFail()` — SERVFAIL on the wire, same as peer faults; no rcode fits
better. No per-query warn log (spam); the counter is the record.
### C.2 Forward client (src/local/forward_client.zig)
One outer budget for the whole exchange: wrap UDP attempt → truncation
fallback → TCP in a single tagged race against `read_timeout` (the UDP
receive's internal deadline and the TCP `raceWithin` at :217 collapse into
the one outer bound — remove the fresh TCP budget). Every timeout here
concerns the single configured resolver, so expiry stays `error.Timeout`
(peer evidence), never BudgetExhausted — the budget/peer distinction is pool
policy. Test: truncated-UDP-then-stalled-TCP completes or expires within one
`read_timeout`, not two. Update the doc comment on `read_timeout_ms` in the
config (validate.zig:360 note and the settings description) to say it bounds
the whole forward-zone exchange. The key is NOT renamed (anti-requirement).
### C.3 Surfaces
- Metrics: `nxdns_upstream_budget_exhausted_total` (pool-wide) wherever
`nxdns_upstream_*` counters render, read via `Pool.budgetExhaustedTotal()`.
- `/api/health` is NOT changed (decision, not omission): the counter is an
operator metric, it never changes health status, and adding it to the API
would drag openapi.yaml, admin types, fixtures, contract samples and the
admin gates into a milestone that owes them nothing. Metrics only.
- docs: `docs/reference/configuration.md` — every statement describing
`read_timeout_ms` as a per-read or per-attempt bound is rewritten to the
whole-exchange contract; the upstream timeouts section gains one paragraph
on budget semantics (deadline, truncation, attribution). The stale contract
text in `src/config/model.zig` (the setting's doc comment) and the note at
`src/config/validate.zig:360` are updated to match.
### C.4 Acceptance
- [ ] `zig build test` and `zig build test -Dintegration` green.
- [ ] Metrics test covers the new counter's rendering.
- [ ] Forward-client single-budget test per C.2.
---
## File ownership
- A: `src/upstream/transport.zig`, plus mechanical placeholder arms at the
broken `Group` switches (`src/upstream/pool.zig`, `src/server/handler.zig`,
`src/local/forward_client.zig`, test switches the compiler flags) —
sequential ownership, A runs alone; C takes forward_client.zig and
handler.zig over later in sequence.
- B: `src/upstream/pool.zig` (+ the `Client.exchange` doc contract in
transport.zig and snapshot-caller plumbing for the new getter — B runs
alone after A).
- C: `src/server/handler.zig`, `src/local/forward_client.zig`, the metrics
rendering files, `src/config/model.zig` + `src/config/validate.zig` doc
text, `docs/reference/configuration.md`.
- Orchestrator: CHANGELOG.md.
## Acceptance criteria (milestone complete)
- [ ] All session criteria; both suites green; `zig fmt --check` clean.
- [x] The saturated-primary regression demonstrably fails on pre-milestone
code and passes after. Evidence (orchestrator-run mutation check,
2026-08-27): with the pass-one `tryAcquire` sweep disabled in
`Pool.admit` — restoring pre-fix head-of-line blocking — the test
"a saturated primary defers to a standby that has capacity" fails with
`error.BudgetExhausted` out of the blocking admission path, and four
queue-accounting/attribution tests fail with it (5 failed, seed
0x91ce5df5). Sweep restored: 30/30 steps, 1892/2067 passed, 0 failed.
- [ ] Changelog: failover now works under primary saturation; SERVFAILs from
budget exhaustion are counted, not blamed on an upstream; for
upstream-pool queries the query-log `upstream` field now names only
endpoints whose outcome was recorded (may be null) — forward-zone
queries keep naming their single configured resolver as before.
## Anti-requirements
- No default timeout changes.
- No parallel/racing fan-out to multiple upstreams. Priority failover stays,
with priority defined as ordering among immediately admissible candidates
(B.2) — a saturated higher-priority entry defers to an admissible
lower-priority one; it does not outrank an idle standby by blocking on it.
- No `/api/health` or admin changes; the counter surfaces in metrics only.
- No patching of the vendored/system Zig stdlib; the admission helper is
pool-local.
- No rename of `upstream.read_timeout_ms` (doc fix only).
- No minimum-attempt floor constant.
- No per-stage split of the budget counter; no per-query budget log lines.
- No watchdog replacing the removed outer race.
- No fake upstream identity (e.g. "budget") in the query log; no new
query-log column.
- No changes to backoff policy, slot counts, or health scoring beyond the
attribution rules above.
+1 -1
View File
@@ -1012,7 +1012,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
.priority = server.priority,
.enabled = true,
.health = .init,
.sem = .{ .permits = slots.len },
.admission = .{ .permits = slots.len },
.reuse_recoveries = &recoveries,
}};
var single: pool.Pool = .init(&entries, .{}, timeouts, seed);
+5 -3
View File
@@ -57,9 +57,11 @@ pub const Config = struct {
pub const Upstream = struct {
/// Bounds one attempt against one upstream inside the pool's failover loop.
attempt_timeout_ms: u32 = 2500,
/// The forward-zone client's read deadline, and nothing else. It bounds a
/// different subsystem from the two above (`src/local/forward_client.zig`),
/// so no cross-check relates it to them.
/// The forward-zone client's whole-exchange budget, and nothing else: one
/// bound covers the UDP attempt, a TC=1 fallback and the TCP retry
/// together, not each of them. It bounds a different subsystem from the two
/// above (`src/local/forward_client.zig`), so no cross-check relates it to
/// them.
read_timeout_ms: u32 = 3000,
/// The whole-exchange budget: every failover attempt together, not one of
/// them. The pool races the entire loop against it.
+66 -2
View File
@@ -52,6 +52,7 @@ const limits = @import("limits.zig");
const logger = @import("../storage/logger.zig");
const regex = @import("../filter/regex.zig");
const safe_url = @import("../safe_url.zig");
const pool = @import("../upstream/pool.zig");
const transport = @import("../upstream/transport.zig");
const Config = model.Config;
@@ -69,6 +70,7 @@ const Prefix = address.Prefix;
/// like every other resource failure.
pub const ValidateError = error{
NoUpstreams,
TooManyUpstreams,
BadUpstreamUrl,
UpstreamHostNotIpLiteral,
DuplicateUpstreamUrl,
@@ -357,8 +359,9 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
// The only cross-check that relates two knobs of one subsystem: the pool
// races one attempt against `attempt` and the whole failover loop against
// `total`, so an attempt budget above the total one can never be reached.
// `read_timeout_ms` belongs to the forward-zone client and is deliberately
// unrelated to both.
// `read_timeout_ms` bounds the forward-zone client's whole exchange —
// UDP attempt, TC=1 fallback and TCP retry under one budget — and is
// deliberately unrelated to both.
if (up.attempt_timeout_ms > up.total_timeout_ms) {
try diags.add(
error.BadTimeout,
@@ -823,6 +826,21 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
.{},
);
}
// Each enabled upstream becomes one pool entry, and the failover loop
// tracks the entries it has spent in a fixed bitset of `Pool.max_entries`
// bits. Without this check a config past that bound reaches an assert and
// panics at startup, which is the wrong way to tell an operator that a
// number is too large. Disabled upstreams are not counted: they never
// become entries.
if (enabled_upstreams > pool.Pool.max_entries) {
try diags.add(
error.TooManyUpstreams,
"upstreams",
.{},
"{d} upstreams are enabled; nxdns is built for at most {d}",
.{ enabled_upstreams, pool.Pool.max_entries },
);
}
var client_ips: IndexSet = .empty;
for (cfg.clients, 0..) |client, i| {
@@ -1323,6 +1341,52 @@ test "error.NoUpstreams when nothing is enabled" {
try expectProblem(cfg, error.NoUpstreams, "upstreams");
}
/// `count` distinct enabled upstreams. Generated rather than written out
/// because the bound this exercises is 64, and a hand-written list that long
/// would say less than the loop does.
fn ManyUpstreams(comptime count: usize) type {
return struct {
const list: [count]model.UpstreamServer = blk: {
var built: [count]model.UpstreamServer = undefined;
for (&built, 0..) |*server, i| {
server.* = .{ .url = std.fmt.comptimePrint("https://u{d}.example/dns-query", .{i}) };
}
break :blk built;
};
};
}
fn manyUpstreams(comptime count: usize) []const model.UpstreamServer {
return &ManyUpstreams(count).list;
}
test "as many enabled upstreams as the pool holds validates cleanly" {
var cfg = baseConfig();
cfg.upstreams = manyUpstreams(pool.Pool.max_entries);
try expectClean(cfg);
}
test "error.TooManyUpstreams one enabled upstream past the pool's bound" {
// The pool asserts this bound, so without the check here a valid-looking
// config panics at startup instead of being reported.
var cfg = baseConfig();
cfg.upstreams = manyUpstreams(pool.Pool.max_entries + 1);
try expectProblem(cfg, error.TooManyUpstreams, "upstreams");
}
test "upstreams past the pool's bound are fine while they are disabled" {
// Only enabled upstreams become pool entries, so a long list with a small
// enabled subset is not near the bound at all.
var cfg = baseConfig();
cfg.upstreams = comptime blk: {
var list = manyUpstreams(pool.Pool.max_entries + 1)[0 .. pool.Pool.max_entries + 1].*;
for (list[1..]) |*server| server.enabled = false;
const frozen = list;
break :blk &frozen;
};
try expectClean(cfg);
}
test "error.BadUpstreamUrl on an unsupported scheme" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "ftp://dns.example/" }};
+147 -19
View File
@@ -109,6 +109,11 @@ pub const ForwardClient = struct {
/// `.udp` resolvers send one datagram and fall back to TCP when the answer
/// comes back with TC=1. `.tcp` resolvers skip straight to the TCP path.
///
/// `read_timeout` bounds the WHOLE exchange, truncation fallback included:
/// the instant is computed once here and every blocking step inside runs
/// against it, so a truncated UDP answer followed by a stalled TCP retry
/// costs one budget rather than two.
pub fn exchange(
self: *ForwardClient,
io: std.Io,
@@ -120,10 +125,22 @@ pub const ForwardClient = struct {
if (response_buf.len == 0) return error.BufferTooSmall;
self.stats.queries += 1;
return self.route(io, query, response_buf) catch |err| {
const expiry_at: std.Io.Clock.Timestamp = .fromNow(io, self.read_timeout);
// The outcome is deliberately discarded. A forward zone has exactly one
// configured resolver, so an expiry here is still that resolver failing
// to answer in time: `error.Timeout` is peer evidence, and the
// budget/peer distinction is upstream-pool policy.
var outcome: transport.RaceOutcome = .completed;
return transport.raceUntilTagged(io, expiry_at, &outcome, route, .{
self,
io,
query,
response_buf,
expiry_at,
}) catch |err| {
switch (transport.group(err)) {
.peer_fault, .local_resource => self.stats.failures += 1,
.cancellation => {},
.cancellation, .budget_exhausted => {},
}
return err;
};
@@ -134,11 +151,12 @@ pub const ForwardClient = struct {
io: std.Io,
query: []const u8,
response_buf: []u8,
expiry_at: std.Io.Clock.Timestamp,
) transport.ExchangeError![]u8 {
if (self.resolver.scheme == .udp) {
if (try self.exchangeUdp(io, query, response_buf)) |reply| return reply;
if (try self.exchangeUdp(io, query, response_buf, expiry_at)) |reply| return reply;
}
return self.exchangeTcp(io, query, response_buf);
return self.tcpOnce(io, query, response_buf);
}
/// `null` means the resolver set TC=1 and the caller must retry over TCP.
@@ -151,6 +169,7 @@ pub const ForwardClient = struct {
io: std.Io,
query: []const u8,
response_buf: []u8,
expiry_at: std.Io.Clock.Timestamp,
) transport.ExchangeError!?[]u8 {
const dest = self.destination();
const local = wildcardFor(dest);
@@ -166,9 +185,10 @@ pub const ForwardClient = struct {
return transport.mapPhase(err, error.SendFailed);
};
// A deadline, not a duration: a discarded foreign datagram restarts the
// receive, and a duration would hand each retry the full budget again.
const deadline = (std.Io.Timeout{ .duration = self.read_timeout }).toDeadline(io);
// The exchange-wide instant, not a fresh duration: a discarded foreign
// datagram restarts the receive, and a duration would hand each retry
// the full budget again.
const deadline: std.Io.Timeout = .{ .deadline = expiry_at };
while (true) {
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
@@ -205,18 +225,11 @@ pub const ForwardClient = struct {
}
}
/// The read budget bounds the whole TCP exchange through
/// `transport.raceWithin`. `ConnectOptions.timeout` is never set: the
/// Threaded backend panics on it (Threaded.zig:12076).
fn exchangeTcp(
self: *ForwardClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
return transport.raceWithin(io, self.read_timeout, tcpOnce, .{ self, io, query, response_buf });
}
/// Unbounded on its own: `exchange` runs it inside the exchange-wide race,
/// which is what cancels a stalled connect or read. It takes no budget of
/// its own, so a truncation fallback does not start a second one.
/// `ConnectOptions.timeout` is never set: the Threaded backend panics on it
/// (Threaded.zig:12076).
fn tcpOnce(
self: *ForwardClient,
io: std.Io,
@@ -307,6 +320,11 @@ fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transp
}
const testing = std.testing;
const build_options = @import("build_options");
const name_mod = @import("../dns/name.zig");
const packet = @import("../dns/packet.zig");
const question = @import("../dns/question.zig");
const types = @import("../dns/types.zig");
fn testBuf() [min_frame_buf]u8 {
return undefined;
@@ -467,3 +485,113 @@ test "a stashed stream error is preferred over the collapsed one" {
receiveFailure(&stream_reader, error.EndOfStream),
);
}
const one_budget_ms = 400;
/// Late enough in the budget that a second, fresh budget for the TCP leg would
/// be unmistakable in the elapsed time.
const truncate_after_ms = 300;
/// Answers the first datagram late in the budget with TC=1, which sends the
/// client to TCP — where a listener that never accepts leaves it stalled.
fn truncatingThenStallingResolver(io: std.Io, socket: *const net.Socket) void {
var buf: [2048]u8 = undefined;
const msg = socket.receive(io, &buf) catch return;
const request = packet.parse(msg.data) catch return;
(std.Io.Clock.Duration{
.raw = .fromMilliseconds(truncate_after_ms),
.clock = .awake,
}).sleep(io) catch return;
// The question has to be echoed: `transport.validateResponse` runs before
// the client reads the TC bit, so a bare header would come back as
// `BadResponse` and never reach the TCP fallback this case is about.
const q = packet.firstQuestion(request) orelse return;
var reply_buf: [512]u8 = undefined;
var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch return;
const reply = b.finish();
var parsed = dns_header.parse(reply) catch return;
parsed.flags.tc = true;
dns_header.encode(parsed, reply[0..types.header_len]);
socket.send(io, &msg.from, reply) catch return;
}
fn testQuery(io: std.Io, buf: []u8) ![]const u8 {
var id_bytes: [2]u8 = undefined;
io.random(&id_bytes);
dns_header.encode(.{
.id = std.mem.readInt(u16, &id_bytes, .big),
.flags = .{
.rcode = .no_error,
.z = 0,
.ra = false,
.rd = true,
.tc = false,
.aa = false,
.opcode = .query,
.qr = false,
},
.qdcount = 1,
.ancount = 0,
.nscount = 0,
.arcount = 0,
}, buf[0..types.header_len]);
var w: std.Io.Writer = .fixed(buf[types.header_len..]);
try question.encode(.{
.name = try name_mod.fromText("nas.lan"),
.qtype = .a,
.qclass = .in,
}, &w);
return buf[0 .. types.header_len + w.buffered().len];
}
test "one budget covers the udp leg, the TC=1 fallback and the tcp leg" {
if (!build_options.integration) return error.SkipZigTest;
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const bind_address: net.IpAddress = try .parse("127.0.0.1", 0);
const socket = try bind_address.bind(io, .{ .mode = .dgram });
defer socket.close(io);
const port = socket.address.ip4.port;
// Bound but never accepted: the connect completes out of the kernel's
// backlog and the read then waits forever, which is the stall a second
// budget would be spent on.
const tcp_address: net.IpAddress = try .parse("127.0.0.1", port);
var tcp_listener = try tcp_address.listen(io, .{ .reuse_address = true });
defer tcp_listener.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, truncatingThenStallingResolver, .{ io, &socket });
var url_buf: [64]u8 = undefined;
const url = try std.fmt.bufPrint(&url_buf, "udp://127.0.0.1:{d}", .{port});
var frame_buf: [min_frame_buf]u8 = undefined;
var fc: ForwardClient = .init(
try validate.parseResolver(url),
&frame_buf,
.{ .raw = .fromMilliseconds(one_budget_ms), .clock = .awake },
);
var query_buf: [types.header_len + types.max_name_len + 4]u8 = undefined;
const query = try testQuery(io, &query_buf);
var response_buf: [2048]u8 = undefined;
const started = std.Io.Clock.awake.now(io);
try testing.expectError(error.Timeout, fc.exchange(io, query, &response_buf));
const elapsed = started.durationTo(std.Io.Clock.awake.now(io)).nanoseconds;
// Two budgets would spend 300 ms on the UDP leg and then a fresh 400 ms on
// the stalled TCP leg. The bound sits between one budget and that sum, so
// the double-budget shape cannot pass.
try testing.expect(elapsed < @as(i96, one_budget_ms + truncate_after_ms / 2) * std.time.ns_per_ms);
try testing.expectEqual(@as(u64, 1), fc.stats.udp_truncated);
try testing.expectEqual(@as(u64, 1), fc.stats.failures);
}
+11 -2
View File
@@ -676,7 +676,10 @@ const Context = struct {
const answer = client.exchange(ctx.io, ctx.query, ctx.response_buf) catch |err| {
return switch (transport.group(err)) {
.cancellation => .drop,
.peer_fault, .local_resource => ctx.servFail(),
// A budget that ran out is SERVFAIL like any other failure: no
// rcode says "I gave up in time". It is not logged per query —
// `nxdns_upstream_budget_exhausted_total` is the record.
.peer_fault, .local_resource, .budget_exhausted => ctx.servFail(),
};
};
@@ -737,7 +740,13 @@ const Context = struct {
// the client is about to lose the socket anyway; the listener's
// own counters record the abandoned datagram.
.cancellation => return .drop,
.peer_fault, .local_resource => return ctx.servFail(),
// A budget that ran out is SERVFAIL like any other failure: no
// rcode says "I gave up in time". It is not logged per query —
// `nxdns_upstream_budget_exhausted_total` is the record — and
// the pool leaves `selected` unchanged, so the row still names
// the last attributable endpoint if there was one, and names
// none only when no attributable attempt happened.
.peer_fault, .local_resource, .budget_exhausted => return ctx.servFail(),
};
};
bump(&ctx.handler.stats.queries);
+1 -1
View File
@@ -176,7 +176,7 @@ const EntryStorage = struct {
.priority = priority,
.enabled = true,
.health = .init,
.sem = .{ .permits = self.slots.len },
.admission = .{ .permits = self.slots.len },
.reuse_recoveries = &self.recoveries,
};
}
+1 -1
View File
@@ -458,7 +458,7 @@ test "a session the upstream closed is recovered by one redial and counted, not
.priority = 10,
.enabled = true,
.health = .init,
.sem = .{ .permits = slots.len },
.admission = .{ .permits = slots.len },
.reuse_recoveries = &fixture.recoveries,
}};
var pool: pool_mod.Pool = .init(&entries, .{
+1 -1
View File
@@ -500,7 +500,7 @@ const Upstreams = struct {
.priority = server.priority,
.enabled = true,
.health = .init,
.sem = .{ .permits = entry_slots.len },
.admission = .{ .permits = entry_slots.len },
.reuse_recoveries = &self.recovery_counters[self.used],
};
self.used += 1;
+814 -139
View File
File diff suppressed because it is too large Load Diff
+152 -30
View File
@@ -1,4 +1,4 @@
//! Shared vocabulary for every upstream client: endpoint URLs, the three
//! Shared vocabulary for every upstream client: endpoint URLs, the four
//! disjoint failure groups, the `Client` interface, and response validation.
//!
//! Everything here except the `Client` vtable is pure. `validateResponse` takes
@@ -8,8 +8,10 @@
//!
//! The failure classification is the reason this file exists. Health and
//! backoff must count only what the peer did wrong: a local `OutOfMemory` says
//! nothing about the upstream, and `error.Canceled` says nothing at all. The
//! three error sets below are disjoint by construction and `group` switches
//! nothing about the upstream, `error.Canceled` says nothing at all, and
//! `error.BudgetExhausted` says the caller ran out of time before the peer was
//! given its interval. The four error sets below are disjoint by construction
//! and `group` switches
//! over them exhaustively, so a new failure mode cannot silently land in the
//! wrong bucket.
@@ -188,9 +190,15 @@ pub const LocalResource = error{
pub const Cancellation = error{Canceled};
pub const ExchangeError = PeerFault || LocalResource || Cancellation;
/// The caller's own time ran out before any peer could be given the observation
/// interval it was configured to get. Evidence about this process's budget, not
/// about any endpoint, so it is never recorded against health — that is the
/// whole reason it is not a `PeerFault`.
pub const BudgetFault = error{BudgetExhausted};
pub const Group = enum { peer_fault, local_resource, cancellation };
pub const ExchangeError = PeerFault || LocalResource || Cancellation || BudgetFault;
pub const Group = enum { peer_fault, local_resource, cancellation, budget_exhausted };
/// Exhaustive switch over `ExchangeError` — no `else` arm. A new error member
/// must break the build here, so no failure can silently land in the wrong
@@ -218,6 +226,8 @@ pub fn group(err: ExchangeError) Group {
=> .local_resource,
error.Canceled => .cancellation,
error.BudgetExhausted => .budget_exhausted,
};
}
@@ -269,29 +279,29 @@ pub fn closeBlocked(io: std.Io, target: anytype) void {
}
}
/// The payload of `f`'s return type, which `raceWithin` requires to be
/// The payload of `f`'s return type, which the race harness requires to be
/// `ExchangeError!T`. A raced function with any other error set would let a
/// failure reach the pool without passing through `group`.
fn RacedPayload(comptime f: anytype) type {
const info = @typeInfo(@TypeOf(f));
if (info != .@"fn") @compileError("raceWithin needs a function, found " ++ @typeName(@TypeOf(f)));
if (info != .@"fn") @compileError("the race harness needs a function, found " ++ @typeName(@TypeOf(f)));
const Return = info.@"fn".return_type orelse
@compileError("raceWithin needs a function with a concrete return type");
@compileError("the race harness needs a function with a concrete return type");
const union_info = switch (@typeInfo(Return)) {
.error_union => |u| u,
else => @compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return)),
else => @compileError("the race harness needs `ExchangeError!T`, found " ++ @typeName(Return)),
};
if (union_info.error_set != ExchangeError)
@compileError("raceWithin needs `ExchangeError!T`, found " ++ @typeName(Return));
@compileError("the race harness needs `ExchangeError!T`, found " ++ @typeName(Return));
return union_info.payload;
}
/// Runs `f(args...)` raced against `budget`, and cancels the loser.
///
/// No stream read or write in 0.16.0 takes a timeout, so a deadline is a second
/// task rather than a socket option. This is the one copy of that harness: the
/// pool races an attempt and its whole failover loop through it, and the
/// forward client races its TCP exchange.
/// task rather than a socket option. `raceUntilTagged` is the one copy of that
/// harness; this is the untagged wrapper for callers that own no deadline and
/// only need "a bound on this one operation".
///
/// `error.Timeout` means the budget won. A canceled sleep means the whole task
/// is being torn down rather than the budget running out, so it stays
@@ -303,33 +313,69 @@ pub fn raceWithin(
comptime f: anytype,
args: anytype,
) ExchangeError!RacedPayload(f) {
const Outcome = union(enum) {
var outcome: RaceOutcome = .completed;
return raceUntilTagged(io, .fromNow(io, budget), &outcome, f, args);
}
/// Which side of the race ended the call.
///
/// `completed` means the raced operation itself returned — including when what
/// it returned is `error.Timeout`, which is then the peer's own timeout and
/// real evidence about that peer. `expired` means the caller's clock ran out
/// with the operation still in flight, which is evidence about the budget only.
pub const RaceOutcome = enum { completed, expired };
/// `raceWithin` with the two timer origins told apart, and with an ABSOLUTE
/// expiry rather than a duration.
///
/// The timestamp is the point of the whole function. A duration recomputed from
/// a deadline and then slept re-anchors at "now", so every re-race drifts a
/// little past the caller's real deadline and a truncated attempt is then
/// indistinguishable from a full one. The caller that owns the deadline
/// computes the instant once and passes it here.
///
/// `outcome` is written before this returns on both racing paths. It is left
/// untouched when the race cannot start at all (`error.SystemResources`) or
/// when the whole task is being canceled, since neither is an observation about
/// this budget; callers initialize it to the value they want in those cases.
pub fn raceUntilTagged(
io: std.Io,
expiry_at: std.Io.Clock.Timestamp,
outcome: *RaceOutcome,
comptime f: anytype,
args: anytype,
) ExchangeError!RacedPayload(f) {
const Slot = union(enum) {
raced: ExchangeError!RacedPayload(f),
expiry: std.Io.Cancelable!void,
};
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
var slots: [2]Slot = undefined;
var race: std.Io.Select(Slot) = .init(io, &slots);
defer race.cancelDiscard();
race.concurrent(.raced, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
race.concurrent(.expiry, expire, .{ io, expiry_at }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.raced => |result| return result,
.raced => |result| {
outcome.* = .completed;
return result;
},
.expiry => |result| {
try result;
outcome.* = .expired;
return error.Timeout;
},
}
}
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
return budget.sleep(io);
fn expire(io: std.Io, expiry_at: std.Io.Clock.Timestamp) std.Io.Cancelable!void {
return expiry_at.wait(io);
}
/// A thing that sends one DNS message and returns one validated DNS message.
@@ -347,13 +393,21 @@ pub const Client = struct {
/// Returns a prefix of `response_buf`. The returned message has already
/// passed `validateResponse` against `query`.
///
/// `selected` names the resolver the exchange used. An implementation
/// writes it *before* each attempt, never after, so a failed exchange still
/// names the last resolver it tried — a SERVFAIL row without its resolver
/// explains nothing. The slice must outlive the call; every implementation
/// borrows storage it owns for at least the query's duration. Callers
/// initialize it to null: a `null` after the call means no resolver was
/// reached at all.
/// `selected` names the resolver the exchange used. A single-endpoint
/// implementation (DoH, DoT, the forward client, test fakes) may write it
/// *before* each attempt: it has one resolver and records no health, so
/// "the one I tried" is an honest answer even for a failure, and a SERVFAIL
/// row without its resolver explains nothing.
///
/// `Pool` is stricter, and documents the rule on `Pool.exchange`: it names
/// only endpoints whose outcome it recorded, so a query that ran out of
/// budget blames nobody and may leave this `null`. Both are within this
/// contract — the guarantee here is that a non-null value names a resolver
/// this exchange really used, never that a failure leaves one behind.
///
/// The slice must outlive the call; every implementation borrows storage it
/// owns for at least the query's duration. Callers initialize it to null: a
/// `null` after the call means no resolver is being reported.
pub fn exchange(
self: Client,
io: std.Io,
@@ -530,8 +584,8 @@ test "parse rejects a fragment" {
try testing.expectError(error.BadUrl, Endpoint.parse("tls://dns.google#f"));
}
test "the three error groups are disjoint" {
const sets = .{ PeerFault, LocalResource, Cancellation };
test "the four error groups are disjoint" {
const sets = .{ PeerFault, LocalResource, Cancellation, BudgetFault };
inline for (sets, 0..) |a, i| {
inline for (sets, 0..) |b, j| {
if (i >= j) continue;
@@ -548,7 +602,8 @@ test "the three error groups are disjoint" {
// member added to two sets at once cannot pass unnoticed.
const total = @typeInfo(PeerFault).error_set.?.len +
@typeInfo(LocalResource).error_set.?.len +
@typeInfo(Cancellation).error_set.?.len;
@typeInfo(Cancellation).error_set.?.len +
@typeInfo(BudgetFault).error_set.?.len;
try testing.expectEqual(total, @typeInfo(ExchangeError).error_set.?.len);
}
@@ -558,6 +613,7 @@ test "group classifies each member" {
try testing.expectEqual(Group.local_resource, group(error.OutOfMemory));
try testing.expectEqual(Group.local_resource, group(error.BufferTooSmall));
try testing.expectEqual(Group.cancellation, group(error.Canceled));
try testing.expectEqual(Group.budget_exhausted, group(error.BudgetExhausted));
}
test "mapLocal folds only local and cancellation errors" {
@@ -641,6 +697,72 @@ test "raceWithin passes the raced task's own failure through" {
);
}
test "raceUntilTagged tells a leaf Timeout apart from an expiry" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// The leaf's own timeout: it returned, so the peer really did time out and
// the outcome is `completed` even though the error is the same one an
// expiry produces.
var outcome: RaceOutcome = .expired;
const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake });
try testing.expectError(
error.Timeout,
raceUntilTagged(io, far, &outcome, racedReply, .{
io, 0, @as(ExchangeError!usize, error.Timeout),
}),
);
try testing.expectEqual(RaceOutcome.completed, outcome);
// The expiry side, distinguishable only through the tag.
outcome = .completed;
const soon: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
try testing.expectError(
error.Timeout,
raceUntilTagged(io, soon, &outcome, racedReply, .{
io, 30_000, @as(ExchangeError!usize, 7),
}),
);
try testing.expectEqual(RaceOutcome.expired, outcome);
}
test "raceUntilTagged tags a successful completion" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var outcome: RaceOutcome = .expired;
const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake });
const len = try raceUntilTagged(io, far, &outcome, racedReply, .{
io, 0, @as(ExchangeError!usize, 7),
});
try testing.expectEqual(@as(usize, 7), len);
try testing.expectEqual(RaceOutcome.completed, outcome);
}
test "raceUntilTagged honours an expiry already in the past" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// A deadline the caller has already spent: the expiry wins immediately
// rather than re-anchoring a duration at "now" and granting a fresh budget.
const past = std.Io.Clock.Timestamp.now(io, .awake)
.subDuration(.{ .raw = .fromSeconds(1), .clock = .awake });
var outcome: RaceOutcome = .completed;
const started = std.Io.Clock.awake.now(io);
try testing.expectError(
error.Timeout,
raceUntilTagged(io, past, &outcome, racedReply, .{
io, 30_000, @as(ExchangeError!usize, 7),
}),
);
try testing.expectEqual(RaceOutcome.expired, outcome);
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
try testing.expect(elapsed_ns < @as(i96, 5) * std.time.ns_per_s);
}
test "closeBlocked closes a target of either close shape" {
// The two shapes the transports use: a socket or a plain stream, which
// closes through the `Io`, and a `TlsStream`, which owns the one it was
+39 -2
View File
@@ -166,6 +166,12 @@ pub const Sample = struct {
udp_listener: ?udp_server.Snapshot = null,
tcp_listener: ?tcp_server.Snapshot = null,
upstreams: []const UpstreamSample = &.{},
/// Exchanges the pool gave up on because the request's own budget ran out.
/// Pool-wide rather than per-upstream on purpose: budget exhaustion is
/// evidence about the pool, never about an endpoint, so it carries no url
/// label and cannot live in `UpstreamSample`. Absent while no pool is
/// wired, like every other collaborator.
upstream_budget_exhausted_total: ?u64 = null,
};
pub fn handle(
@@ -271,7 +277,10 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
if (state.upstreams) |owner| {
const generation = owner.acquire(io);
defer owner.release(io, generation);
if (generation.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
if (generation.pool) |pool| {
sample.upstreams = try upstreams(pool, io, arena);
sample.upstream_budget_exhausted_total = pool.budgetExhaustedTotal();
}
}
return sample;
@@ -465,6 +474,15 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
if (sample.doh_certs != null or sample.dot_certs != null) try renderCerts(w, sample);
if (sample.upstreams.len != 0) try renderUpstreams(w, sample.upstreams);
if (sample.upstream_budget_exhausted_total) |total| {
try labeledHead(
w,
"nxdns_upstream_budget_exhausted_total",
"Exchanges that ran out of their own total budget before any upstream answered.",
"counter",
);
try w.print("nxdns_upstream_budget_exhausted_total {d}\n", .{total});
}
}
fn renderCerts(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
@@ -1540,7 +1558,7 @@ test "the queue families carry what a real pool recorded, through the real snaps
.priority = 10,
.enabled = true,
.health = .init,
.sem = .{ .permits = slots.len },
.admission = .{ .permits = slots.len },
.reuse_recoveries = &recoveries,
}};
var pool: pool_mod.Pool = .init(&entries, .{}, .{
@@ -1662,3 +1680,22 @@ fn fieldIndex(comptime name: []const u8) usize {
}
@compileError("no such counter: " ++ name);
}
test "the budget-exhausted counter renders pool-wide, without a url label" {
const text = try renderToString(testing.allocator, .{ .upstream_budget_exhausted_total = 7 });
defer testing.allocator.free(text);
try testing.expect(std.mem.containsAtLeast(
u8,
text,
1,
"# TYPE nxdns_upstream_budget_exhausted_total counter\n",
));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_budget_exhausted_total 7\n"));
// No pool, no series: an operator tells "no upstreams wired" from "zero
// exhausted budgets" the same way every other collaborator is told.
const absent = try renderToString(testing.allocator, .{});
defer testing.allocator.free(absent);
try testing.expect(!std.mem.containsAtLeast(u8, absent, 1, "nxdns_upstream_budget_exhausted_total"));
}
+1 -1
View File
@@ -495,7 +495,7 @@ const Env = struct {
.priority = 1,
.enabled = true,
.health = .init,
.sem = .{ .permits = self.pool_slots.len },
.admission = .{ .permits = self.pool_slots.len },
.reuse_recoveries = &self.pool_recoveries,
}};
self.pool_owner = .{};