Files
nxdns/src/upstream/pool.zig
T
mokhtar f168247b33
Gates / frontend (push) Successful in 2m23s
Gates / test (push) Successful in 3m15s
Gates / test-aarch64 (push) Successful in 8m38s
Gates / package (push) Successful in 5m0s
Gates / container (push) Successful in 19s
CI / gates (push) Successful in 17m17s
Release / guard (push) Successful in 37s
Gates / frontend (push) Successful in 2m15s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 7m33s
Gates / package (push) Successful in 51s
Gates / container (push) Successful in 10s
Release / gates (push) Successful in 11m14s
Release / publish (push) Successful in 8m35s
cut: build the release with the official zig tarball, skip hidden bundle files; re-pin 0.0.17
The first 0.0.17 cut (run 687) failed verify-pins in CI for two reasons. The asset generator embedded admin/dist/.src-hash, a freshness stamp that CI's artifact copy does not carry; it now skips dotfiles. And the Arch zig package emits different code than the ziglang.org tarball that CI installs, so the cut downloads the pinned tarball (ZIG_TARBALL_SHA256 in gates.yml, the full digest keys the cache) and builds the release with it. flake.nix is re-pinned to the bytes both now produce.

The saturated-primary pool test gates its holders on a semaphore instead of sleeps and releases every spawned holder on the way out, so a loaded runner cannot flake it. The package job uploads the payload before the pin check and runs the check when the version or flake.nix changed against the parent. The verify-a-release recipe clones the tag first and builds with the official zig.
2026-09-08 23:57:07 +02:00

2244 lines
103 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Priority-ordered sequential failover across upstream endpoints, with two
//! deadlines, health tracking and backoff (PLAN §9).
//!
//! The pool is itself a `transport.Client`, so the handler above it sees one
//! interface and knows nothing about how many upstreams exist.
//!
//! One deadline, computed once. `Pool.exchange` turns `timeouts.total` into an
//! absolute instant and the loop spends it: admission waits against it, and
//! every attempt is raced against `min(now + timeouts.attempt, deadline)`. No
//! outer race wraps the loop. Two timers aimed at the same instant race each
//! other, and the outer one wins by cancelling the loop from outside, which
//! throws away the loop's own classification of what just happened.
//!
//! What the deadline expiring means depends on which side of it ran. An attempt
//! that got its full `timeouts.attempt` and did not answer is evidence about
//! that peer, recorded as a failure. An attempt the deadline cut short, and a
//! wait for a slot that never ended, are evidence about this process's budget:
//! they are `error.BudgetExhausted`, counted by `budgetExhaustedTotal` and
//! recorded against no endpoint. That distinction is why the pool needs
//! `transport.raceUntilTagged` rather than a bare timeout.
//!
//! Two passes, not one. Pass one walks the enabled entries that health says are
//! available. Pass two runs only when pass one attempted nothing, and skips the
//! backoff check: every endpoint being in backoff must not turn into SERVFAIL
//! for every client. A probe is better than a guaranteed failure, and the probe
//! is how backoff recovers.
//!
//! Concurrency (PLAN §9, spec S4.2): several handler tasks share one `Pool`,
//! and they hold two different locks at two different scopes.
//!
//! `Pool.mutex` guards health mutation, the jitter RNG and `snapshot`. It is
//! never held across an exchange, so a slow upstream cannot block a health read
//! or an attempt on some other entry.
//!
//! An entry owns one leaf client per slot, each behind its own `Slot.busy`
//! mutex, plus an `Entry.admission` carrying one permit per slot. A task takes a
//! permit, then the first free slot's mutex, and holds that mutex for a whole
//! attempt against the entry — the exchange and the timeout race around it —
//! before the failover loop moves to the next candidate. The mutex is what
//! makes the pool safe to share: `DohClient` owns a request buffer and a
//! transfer buffer, `DotClient` owns four TLS buffers and the stream state
//! built on them, so one client may be inside `exchange` only once at a time.
//!
//! Priority orders the entries that can be admitted *now*. A saturated entry is
//! stepped over while a lower-priority one has a free slot, because blocking on
//! the higher-priority entry spends the caller's whole budget in a queue while
//! an idle standby watches. Only when no eligible entry can admit immediately
//! does a task block, and then on the highest-priority eligible entry alone.
//!
//! Because a saturated entry can still make a task wait, pass one re-reads
//! health after it acquires a slot: the attempts a task queued behind can fail
//! the entry into backoff while it waits, and pass one must not attempt an entry
//! that is unavailable by the time it gets its turn. Lock order is always
//! `Slot.busy` then `Pool.mutex`, never the reverse.
//!
//! The guarantee is therefore: at most `slots.len` in-flight exchanges per
//! entry, any number of entries in flight at once, and bookkeeping that never
//! waits on a peer.
const std = @import("std");
const events = @import("../storage/events.zig");
const health = @import("health.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");
const log = std.log.scoped(.upstream);
/// The one line `exchange` writes about a failed attempt, as a value.
///
/// It is a value rather than a format string at the call site for the same
/// reason `dot_client.Diagnostic` is: a `std.log` line is not readable from a
/// unit test under the default test runner, so the test below reads this
/// instead of stderr. An upstream url is operator-supplied and a DoH one carries
/// its credential in the path — `https://dns.nextdns.io/abcd12` is a whole
/// NextDNS account identifier — so it reaches the line through
/// `safe_url.redactQuoted`. Quoted rather than bare because the error name
/// follows it: a redacted authority may still hold a space and a `:`, so
/// unquoted, a url ending `ok failed: Timeout` would report a failure that did
/// not happen.
const AttemptFailure = struct {
endpoint: transport.Endpoint,
err: transport.ExchangeError,
pub fn format(self: AttemptFailure, w: *std.Io.Writer) std.Io.Writer.Error!void {
try w.print("upstream {f} failed: {t}", .{ safe_url.redactQuoted(self.endpoint.url), self.err });
}
};
/// How many leaf clients the composition root builds per enabled upstream, and
/// therefore how many exchanges one upstream may have in flight at once.
///
/// Compiled, not configured (anti-requirement A2). The listeners admit up to 64
/// UDP queries in flight and 64 TCP connections per family, so a burst reaches
/// the pool tens wide; 8 concurrent exchanges per upstream clear the 30-query
/// burst the Pi reproduced in at most four waves even before DoT session reuse
/// removes the per-exchange handshake. The cost is bounded and paid once at
/// startup: a DoT entry holds 8 × 4 × `tls.Client.min_buffer_len` of TLS
/// buffers, about 533 KiB, and a DoH entry two small buffers per slot over one
/// shared `std.http.Client`.
pub const slots_per_entry = 8;
/// Admission control for one entry: one permit per slot, a try-acquire, and an
/// acquire bounded by the caller's deadline. `std.Io.Semaphore` offers neither
/// in 0.16, and `std.Io.Condition` has no timed wait, so this mirrors the
/// standard semaphore's mutex/count/condition protocol with the two operations
/// the failover loop needs added. The stdlib is not patched and nothing spins.
///
/// The mutex owns the permit count and the decision to take one, and the caller
/// takes its permit only *after* the timed race has resolved. That ordering is
/// the whole design: racing a bare `Semaphore.wait` against an expiry can
/// decrement the count in the same instant the expiry wins, and the discarded
/// success never reaches the caller's release, so the permit is gone for good. A
/// wait discarded here loses a notification instead, which the exiting waiter
/// repairs by re-signalling.
///
/// Bound worth knowing: `acquireUntil` blocks on one entry. A permit freeing on
/// some *other* entry does not wake this waiter, so the pool's no-head-of-line
/// guarantee covers only the capacity its `tryAcquire` sweep observed before it
/// blocked. Waking on any of several entries needs multi-wait machinery the pool
/// does not have.
pub const Admission = struct {
mutex: std.Io.Mutex = .init,
cond: std.Io.Condition = .init,
/// One per slot at build time; `Pool.init` asserts it.
permits: usize = 0,
/// Which side of `acquireUntil` ended the wait. `.expired` holds no permit.
pub const Outcome = enum { acquired, expired };
/// Takes a permit if one is free this instant; never blocks.
///
/// A contended mutex reads as "no permit now" rather than as a reason to
/// wait. The failover loop calls this on every candidate in turn, so
/// blocking here for a descheduled permit holder would stall the whole
/// sweep and spend the caller's budget outside `acquireUntil`, which is the
/// only step allowed to consume it.
pub fn tryAcquire(self: *Admission, io: std.Io) bool {
if (!self.mutex.tryLock()) return false;
defer self.mutex.unlock(io);
if (self.permits == 0) return false;
self.permits -= 1;
return true;
}
/// Returns a permit. Uncancelable, so a torn-down attempt still gives its
/// slot back.
pub fn release(self: *Admission, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.permits += 1;
self.cond.signal(io);
}
/// Waits for a permit until `deadline`. Returns `.acquired` holding one,
/// `.expired` holding none, or an error holding none — `error.Canceled`
/// when the whole task is torn down, `error.SystemResources` when the race
/// cannot start.
pub fn acquireUntil(
self: *Admission,
io: std.Io,
deadline: std.Io.Clock.Timestamp,
) transport.ExchangeError!Outcome {
while (true) {
// Every take is non-blocking, including the retries. A take that
// waits on the mutex spends time no deadline bounds: before the
// first race it carries the call past the deadline outright, and
// after a wake it can return `.acquired` late. A miss instead
// re-enters the race below, which is aimed at the same absolute
// instant every time, so the cycle ends at the deadline with
// `.expired` — the correct answer, not a spin.
if (self.tryAcquire(io)) return .acquired;
var race: transport.RaceOutcome = .completed;
_ = transport.raceUntilTagged(io, deadline, &race, waitForPermit, .{ self, io }) catch |err| {
// This task may be leaving with a notification meant for a peer:
// `Condition`'s cancel path consumes a pending signal before it
// reports the cancellation, and a wait whose success the race
// discarded consumed one outright. Hand it back before exiting.
self.resignal(io);
if (err == error.Timeout and race == .expired) return .expired;
return err;
};
// The wait saw a permit but did not take one, so another task may
// have taken it first. The loop re-checks rather than assuming, and
// does not spin: `waitForPermit` blocks whenever the count is zero.
}
}
/// Returns once the count is non-zero, without touching it.
fn waitForPermit(self: *Admission, io: std.Io) transport.ExchangeError!void {
self.mutex.lock(io) catch return error.Canceled;
defer self.mutex.unlock(io);
while (self.permits == 0) self.cond.wait(io, &self.mutex) catch return error.Canceled;
}
/// Only when a permit is actually there to hand over: an unconditional
/// signal would wake a peer that would find nothing.
fn resignal(self: *Admission, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
if (self.permits > 0) self.cond.signal(io);
}
};
/// One leaf client of an entry, and the lock that keeps one task inside it.
pub const Slot = struct {
client: transport.Client,
/// Held for the whole of one attempt, so `client` is never re-entered while
/// it is using its own buffers. Defaulted because `Pool.init` sorts
/// `entries` by value, which may only copy unlocked mutexes, and it runs
/// before the pool is reachable by any task.
busy: std.Io.Mutex = .init,
};
pub const Entry = struct {
endpoint: transport.Endpoint,
/// Caller-owned, `len >= 1`, never resized: `DotClient` pins live TLS state
/// and `Slot.client` is an erased pointer at it.
slots: []Slot,
/// Lower is tried first (PLAN §11.2 `upstreams.priority`).
priority: i32,
enabled: bool,
health: health.State,
/// One permit per slot, so a task waits here rather than spinning over
/// `Slot.busy`. `.{ .permits = slots.len }` at build time; `Pool.init`
/// asserts it. Its mutex and condition are plain values, so the sort in
/// `init` may copy it.
admission: Admission,
in_flight: std.atomic.Value(u32) = .init(0),
/// Test-only: the most exchanges this entry ever had in flight at once.
peak_in_flight: std.atomic.Value(u32) = .init(0),
/// Blocked admissions, not a queue length: a task counts itself queued
/// exactly when it has run out of immediately admissible entries and blocks
/// on this one, whichever way that block ends. A `tryAcquire` — hit or miss
/// — is not a queue, so it never counts.
queued_total: std.atomic.Value(u64) = .init(0),
queued_ns_total: std.atomic.Value(u64) = .init(0),
/// Points into the composition root's stable counter storage, never into an
/// `Entry`: `Pool.init` sorts entries by value, so a pointer taken into one
/// before the sort would dangle onto a different upstream's field.
/// Incremented by a DoT slot client when a stale reused session was
/// recovered by its single redial. Zero forever for DoH entries.
reuse_recoveries: *std.atomic.Value(u64),
/// Both counters, on every exit of a blocking wait for a permit. A waiter
/// that burned its budget in the queue is the field failure this records, so
/// an expiry and a cancellation count exactly like an acquisition.
fn recordQueued(self: *Entry, io: std.Io, started: std.Io.Timestamp) void {
const elapsed = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
_ = self.queued_total.fetchAdd(1, .monotonic);
_ = self.queued_ns_total.fetchAdd(@intCast(@max(elapsed, 0)), .monotonic);
}
/// The first slot whose client is free. Called only while holding a permit,
/// where a free slot is guaranteed.
fn takeSlot(self: *Entry) ?*Slot {
for (self.slots) |*slot| {
if (slot.busy.tryLock()) return slot;
}
return null;
}
};
/// One exchange, raced until `expiry_at` and tagged with which side won.
///
/// `race` distinguishes an expiry from the leaf returning `error.Timeout` of
/// its own, which are the same error and opposite evidence: the first is about
/// the clock the caller set, the second is about the peer.
///
/// The leaf client reports an identity of its own, which the pool discards: the
/// entry's endpoint is the pool's own naming of the same resolver, and it is
/// what the caller was handed. A leaf writing its identity into the caller's
/// slot would let a test fake overwrite the endpoint that actually answered.
fn attemptUntil(
io: std.Io,
entry_client: transport.Client,
query: []const u8,
response_buf: []u8,
expiry_at: std.Io.Clock.Timestamp,
race: *transport.RaceOutcome,
) transport.ExchangeError![]u8 {
var leaf_selected: ?[]const u8 = null;
return transport.raceUntilTagged(io, expiry_at, race, transport.Client.exchange, .{
entry_client, io, query, response_buf, &leaf_selected,
});
}
/// A copy of one entry's health, taken under the mutex. Feeds `/metrics` and
/// the `/api/health` upstream condition.
pub const Snapshot = struct {
/// Whole, not redacted. `GET /api/upstreams` already serves the same url in
/// full to a session, so redacting here would hide nothing from that reader
/// and would make two responses of one API disagree. A consumer reachable
/// without a session has to redact it itself.
url: []const u8,
enabled: bool,
available: bool,
consecutive_failures: u32,
total_successes: u64,
total_failures: u64,
success_rate: f32,
last_success_at: ?std.Io.Timestamp,
last_error_at: ?std.Io.Timestamp,
/// Borrowed from the entry; valid until that entry's next failure.
last_error: []const u8,
backoff_until: ?std.Io.Timestamp,
/// Exchanges in flight against this entry at the instant of the read, and
/// the ceiling they cannot cross (`slots.len`). `peak_in_flight` stays out:
/// it is a test assertion, not an operator's number.
in_flight: u32,
slots: u32,
/// Admission samples; see `Entry.queued_total`. Seconds rather than
/// nanoseconds because Prometheus counts time in seconds.
queued_total: u64,
queued_seconds_total: f64,
reuse_recoveries_total: u64,
};
/// The two budgets, named rather than positional: they are the same type, so
/// two parameters in a row could be swapped at a call site and still compile,
/// and the swap would be invisible until an operator watched a query take five
/// attempts of two and a half seconds each.
pub const Timeouts = struct {
/// Bounds one exchange against one entry.
attempt: std.Io.Clock.Duration,
/// Bounds the whole failover loop.
total: std.Io.Clock.Duration,
};
pub const Pool = struct {
/// Caller-owned, sorted ascending by priority in `init`.
entries: []Entry,
cfg: health.Config,
/// Both on the `.awake` clock, so a suspended Pi does not burn a budget.
timeouts: Timeouts,
mutex: std.Io.Mutex,
rng: std.Random.DefaultPrng,
/// The diagnostics store. Defaulted rather than an `init` parameter: the
/// composition root wires it after the pool exists, and the pool is fully
/// usable without it — `nxdns check` and every unit test here run with no
/// store at all. Every emit here sits outside `mutex`; see
/// `recordDiagnostics`.
diagnostics: ?*events.Store = null,
/// Exchanges that ran out of budget, counted once each by `exchange`. A
/// pool-wide number rather than a per-entry one on purpose: budget
/// exhaustion is this process failing to give any peer its interval, so
/// attributing it to an endpoint would be the very lie this milestone
/// removes. Read through `budgetExhaustedTotal`, since `Snapshot` is
/// per-entry and has nowhere to put it.
budget_exhausted_total: std.atomic.Value(u64) = .init(0),
/// The failover loop tracks which entries it has already spent in a bitset
/// on its stack, so the number of upstreams one pool may hold is bounded.
/// A household config has two; `web/metrics.zig` already renders at most
/// this many.
pub const max_entries = 64;
pub fn init(
entries: []Entry,
cfg: health.Config,
timeouts: Timeouts,
seed: u64,
) Pool {
std.debug.assert(entries.len > 0);
std.debug.assert(entries.len <= max_entries);
for (entries) |*entry| {
std.debug.assert(entry.slots.len >= 1);
std.debug.assert(entry.admission.permits == entry.slots.len);
}
// Stable, so entries sharing a priority keep their configured order.
std.mem.sort(Entry, entries, {}, byPriority);
return .{
.entries = entries,
.cfg = cfg,
.timeouts = timeouts,
.mutex = .init,
.rng = .init(seed),
};
}
fn byPriority(_: void, a: Entry, b: Entry) bool {
return a.priority < b.priority;
}
pub fn client(self: *Pool) transport.Client {
return .{ .ptr = self, .exchangeFn = exchangeErased };
}
fn exchangeErased(
ptr: *anyopaque,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
const self: *Pool = @ptrCast(@alignCast(ptr));
return self.exchange(io, query, response_buf, selected);
}
/// `response_buf` is handed to each attempt in turn, so a failed attempt
/// may have written into it. The returned slice is only meaningful on
/// success; on error the buffer's contents are undefined.
///
/// `selected` follows a stricter rule here than the `transport.Client`
/// contract requires: the pool names the last endpoint whose outcome it
/// actually recorded, written after the attempt rather than before it. An
/// exchange that ran out of budget while an attempt was still in flight
/// therefore leaves `selected` at the previous endpoint, or at `null` if it
/// never got that far, because "the endpoint that was in the way when the
/// clock ran out" is not evidence about that endpoint and the query log must
/// not print it as though it were.
///
/// The whole call is bounded by one deadline derived from `timeouts.total`
/// here and spent by the loop below: no outer race, so an expiry is always
/// classified by the step that owned it.
pub fn exchange(
self: *Pool,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
const deadline: std.Io.Clock.Timestamp = .fromNow(io, self.timeouts.total);
return self.failover(io, query, response_buf, selected, deadline) catch |err| {
// Once per exhausted exchange, wherever the exhaustion was
// detected — the loop's own waits and attempts, or a leaf that
// reported one of its own.
if (err == error.BudgetExhausted) {
_ = self.budget_exhausted_total.fetchAdd(1, .monotonic);
}
return err;
};
}
/// Exchanges that ended in `error.BudgetExhausted`, pool-wide.
pub fn budgetExhaustedTotal(self: *const Pool) u64 {
return self.budget_exhausted_total.load(.acquire);
}
/// The two-pass failover loop.
///
/// Pass one sweeps the entries health says are available, in priority
/// order, admitting through `tryAcquire` so a saturated entry is stepped
/// over rather than waited on. It blocks only when the sweep found no
/// capacity anywhere, and then on the highest-priority candidate alone.
/// Pass two runs when pass one attempted nothing and probes every enabled
/// entry regardless of backoff, which is how backoff recovers; it keeps
/// today's one-at-a-time blocking, since probing a backed-off entry is
/// already a last resort.
fn failover(
self: *Pool,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
deadline: std.Io.Clock.Timestamp,
) transport.ExchangeError![]u8 {
const now = std.Io.Clock.awake.now(io);
var last_fault: ?transport.ExchangeError = null;
var attempted = false;
var pass: u8 = 0;
while (pass < 2) : (pass += 1) {
// Entries this call is finished with: attempted already, or found
// unavailable by the post-admission recheck. Cleared per pass,
// because pass two only runs when pass one attempted nothing and
// must reconsider everything it skipped.
var spent: std.StaticBitSet(max_entries) = .initEmpty();
while (true) {
// An admission that never came ends the exchange: nothing ran,
// so nothing is attributable and `selected` stays as it is.
const index = try self.admit(io, spent, pass, now, deadline) orelse break;
spent.set(index);
const entry = &self.entries[index];
// A permit means a slot's mutex is free, so failing to find one
// is a protocol bug in this loop, not a runtime condition.
const slot = entry.takeSlot() orelse unreachable;
const entrants = entry.in_flight.fetchAdd(1, .acq_rel) + 1;
_ = entry.peak_in_flight.fetchMax(entrants, .acq_rel);
defer {
slot.busy.unlock(io);
_ = entry.in_flight.fetchSub(1, .acq_rel);
// Uncancelable, so a canceled attempt still returns its
// permit.
entry.admission.release(io);
}
// Health was read before the permit was taken, and the attempts
// this one waited behind may have failed the entry into backoff
// meanwhile. Pass one must not touch an entry that is
// unavailable now, so re-read against a fresh `now` and move on
// if it is — the defer above releases the slot before the
// `continue`. Pass two skips this on purpose.
if (pass == 0) {
const recheck = std.Io.Clock.awake.now(io);
if (!self.entryAvailable(io, entry, recheck)) continue;
}
// Nothing is left to observe with. Admission and the recheck
// above can each land after the deadline has already passed,
// and starting an attempt against an expired timestamp would
// race the leaf against a timer that has already fired: an
// instantly-completing leaf would win nondeterministically and
// manufacture a success, or evidence about a peer, out of a
// budget that was already gone. The defer above returns the
// permit; no endpoint is named, because none was observed.
const started_at: std.Io.Clock.Timestamp = .now(io, .awake);
if (deadline.compare(.lte, started_at)) return error.BudgetExhausted;
attempted = true;
// The full attempt budget unless the deadline lands first, in
// which case the peer is being given less than the configured
// observation interval and an expiry says nothing about it.
const full_expiry = started_at.addDuration(self.timeouts.attempt);
const truncated = deadline.compare(.lt, full_expiry);
const expiry_at = if (truncated) deadline else full_expiry;
var race: transport.RaceOutcome = .completed;
const result = attemptUntil(io, slot.client, query, response_buf, expiry_at, &race);
const completed_at = std.Io.Clock.awake.now(io);
const response = result catch |err| {
// A censored observation: the pool cut the attempt short,
// so the expiry is evidence about this budget and about no
// peer. A leaf's own `error.Timeout` is not this case — the
// race tag is what tells the two apart.
if (truncated and race == .expired) return error.BudgetExhausted;
switch (transport.group(err)) {
.peer_fault => {
log.debug("{f}", .{AttemptFailure{ .endpoint = entry.endpoint, .err = err }});
self.recordFailure(io, entry, completed_at, err);
selected.* = entry.endpoint.url;
last_fault = err;
continue;
},
// The next upstream would hit the same wall, and this
// says nothing about any peer, so it is never recorded.
.local_resource => return err,
.cancellation => return error.Canceled,
// The caller's budget, not this endpoint's conduct.
.budget_exhausted => return err,
}
};
self.recordSuccess(io, entry, completed_at);
selected.* = entry.endpoint.url;
return response;
}
if (attempted) break;
}
// Guaranteed non-null whenever any entry is enabled: pass two attempts
// every enabled entry regardless of backoff.
if (last_fault) |err| return err;
return error.ConnectFailed;
}
/// Takes a permit on the next entry to attempt, and returns its index.
/// `null` means this pass has no candidate left.
///
/// Pass one sweeps: every eligible candidate is offered a non-blocking
/// `tryAcquire` in priority order, including ones an earlier sweep step
/// skipped, since a slot may have freed since. That sweep is what keeps a
/// saturated entry from blocking the query. Blocking is the fallback, not
/// the first move, and it waits on the highest-priority candidate only.
///
/// Pass two probes instead: it takes the next enabled entry in priority
/// order and blocks for its permit, one entry at a time. Probing a
/// backed-off entry is already a last resort, so the pass keeps the order
/// the operator configured rather than letting a saturated higher-priority
/// candidate be skipped over — the deadline, not a lock miss, is what ends
/// it.
fn admit(
self: *Pool,
io: std.Io,
spent: std.StaticBitSet(max_entries),
pass: u8,
now: std.Io.Timestamp,
deadline: std.Io.Clock.Timestamp,
) transport.ExchangeError!?usize {
if (pass != 0) {
for (0..self.entries.len) |index| {
if (spent.isSet(index)) continue;
if (!self.entries[index].enabled) continue;
return try self.admitBlocking(io, index, deadline);
}
return null;
}
var first_eligible: ?usize = null;
for (self.entries, 0..) |*entry, index| {
if (spent.isSet(index)) continue;
if (!entry.enabled) continue;
if (!self.entryAvailable(io, entry, now)) continue;
if (entry.admission.tryAcquire(io)) return index;
if (first_eligible == null) first_eligible = index;
}
const index = first_eligible orelse return null;
return try self.admitBlocking(io, index, deadline);
}
/// Blocks for one entry's permit until `deadline`, counting the wait.
fn admitBlocking(
self: *Pool,
io: std.Io,
index: usize,
deadline: std.Io.Clock.Timestamp,
) transport.ExchangeError!usize {
const entry = &self.entries[index];
const started = std.Io.Clock.awake.now(io);
defer entry.recordQueued(io, started);
switch (try entry.admission.acquireUntil(io, deadline)) {
.acquired => return index,
.expired => return error.BudgetExhausted,
}
}
/// Copies health into `out` in pool order; returns the number written.
pub fn snapshot(self: *Pool, io: std.Io, out: []Snapshot) std.Io.Cancelable!usize {
const now = std.Io.Clock.awake.now(io);
try self.mutex.lock(io);
defer self.mutex.unlock(io);
const count = @min(self.entries.len, out.len);
for (self.entries[0..count], out[0..count]) |*entry, *slot| {
slot.* = .{
.url = entry.endpoint.url,
.enabled = entry.enabled,
.available = entry.enabled and entry.health.available(now),
.consecutive_failures = entry.health.consecutive_failures,
.total_successes = entry.health.total_successes,
.total_failures = entry.health.total_failures,
.success_rate = entry.health.successRate(),
.last_success_at = entry.health.last_success_at,
.last_error_at = entry.health.last_error_at,
.last_error = entry.health.lastError(),
.backoff_until = entry.health.backoff_until,
.in_flight = entry.in_flight.load(.acquire),
.slots = @intCast(entry.slots.len),
.queued_total = entry.queued_total.load(.acquire),
.queued_seconds_total = @as(f64, @floatFromInt(entry.queued_ns_total.load(.acquire))) / std.time.ns_per_s,
.reuse_recoveries_total = entry.reuse_recoveries.load(.acquire),
};
}
return count;
}
fn entryAvailable(
self: *Pool,
io: std.Io,
entry: *const Entry,
now: std.Io.Timestamp,
) bool {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return entry.health.available(now);
}
fn recordSuccess(self: *Pool, io: std.Io, entry: *Entry, at: std.Io.Timestamp) void {
{
// Uncancelable: this section takes no Io and never blocks on a
// peer. Losing the bookkeeping for a completed exchange to a
// cancellation that arrives one instruction later would corrupt
// health for good.
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
entry.health.recordSuccess(at);
}
// The block above closes before this line, and that ordering is the
// constraint: the store takes a mutex of its own, and no task may hold
// one of the two while it takes the other.
self.recordDiagnostics(io, entry, .success);
}
fn recordFailure(
self: *Pool,
io: std.Io,
entry: *Entry,
at: std.Io.Timestamp,
err: transport.ExchangeError,
) void {
{
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
entry.health.recordFailure(at, @errorName(err), self.cfg, self.rng.random().int(u32));
}
// After the pool mutex is released, for the reason `recordSuccess`
// states.
self.recordDiagnostics(io, entry, .{ .failure = @errorName(err) });
}
const Outcome = union(enum) { success, failure: []const u8 };
/// The store takes a mutex of its own, so this runs after the pool's is
/// released.
///
/// A success is the steady state of the whole program, so `resolve` is
/// built to issue no SQL when nothing is open (`storage/events.zig`).
fn recordDiagnostics(self: *Pool, io: std.Io, entry: *Entry, outcome: Outcome) void {
const store = self.diagnostics orelse return;
const url = entry.endpoint.url;
const now_s = std.Io.Clock.real.now(io).toSeconds();
switch (outcome) {
.success => store.resolve(io, now_s, .upstream_exchange, url),
.failure => |name| {
var label_buf: [events.Store.max_subject_label_len]u8 = undefined;
const label = std.fmt.bufPrint(&label_buf, "{f}", .{safe_url.redact(url)}) catch &label_buf;
var detail_buf: [events.Store.max_detail_len]u8 = undefined;
const detail = std.fmt.bufPrint(&detail_buf, "upstream {f} failed: {s}", .{
safe_url.redactQuoted(url),
name,
}) catch &detail_buf;
store.report(io, now_s, .upstream_exchange, url, label, .warning, detail);
},
}
}
};
const events_fixture = @import("../storage/events_fixture.zig");
const testing = std.testing;
/// A query for example.com A: id 0x1234, RD set, one question.
const query_bytes =
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01";
/// The matching response: the question echoed plus one A record.
const response_bytes =
"\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01" ++
"\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22";
/// The same question answered differently, so a reply identifies the entry that
/// produced it: one A record, a different address.
const alt_response_bytes =
"\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01" ++
"\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x0a\x00\x00\x01";
/// Stands in for a DoH or DoT client. Every behaviour the pool has to react to
/// is one variant, and every call is counted so a test can assert that an entry
/// in backoff was not touched.
const Fake = struct {
behavior: Behavior,
/// Replaces `behavior` after the first call. One entry that fails the task
/// which reaches it first and answers the next is what makes two concurrent
/// exchanges end on different entries; a single behaviour cannot say that.
then: ?Behavior = null,
/// Guards `behavior` and `then`. One fake now backs every slot of an entry,
/// so the swap runs under as many tasks as the entry has slots.
mutex: std.Io.Mutex = .init,
calls: std.atomic.Value(usize) = .init(0),
in_flight: std.atomic.Value(u32) = .init(0),
/// The most tasks ever inside `exchangeFn` at once. The entry's slot count
/// is the ceiling this must never cross.
peak_in_flight: std.atomic.Value(u32) = .init(0),
/// Slot storage for the entry this fake backs, and the recovery counter
/// that entry points at. Both live here so one declared `Fake` is one whole
/// test upstream; `testEntry` wires as many of the slots as a test wants.
slots: [slots_per_entry]Slot = undefined,
recoveries: std.atomic.Value(u64) = .init(0),
const Behavior = union(enum) {
/// Copy these bytes into the caller's buffer and return them.
reply: []const u8,
/// Fail with this error.
fail: transport.ExchangeError,
/// Sleep, then reply. Used to outrun the pool's attempt budget.
slow: struct { duration: std.Io.Clock.Duration, reply: []const u8 },
/// Sleep, then fail. Holds the entry's lock long enough for a second
/// task to queue on it before the failure opens a backoff window.
slow_fail: struct { duration: std.Io.Clock.Duration, err: transport.ExchangeError },
/// Wait for the gate, then reply. A test that needs two calls provably
/// in flight together cannot get that from a duration: a loaded
/// scheduler can start the second call after the first one's sleep
/// expired, and no poll can recover an overlap that already passed.
/// The gate makes the test, not the clock, decide when a call returns.
hold: struct { gate: *std.Io.Semaphore, reply: []const u8 },
/// Wait for the gate, then fail. The failing half of `hold`, with the
/// same constraint: the test releases the call once it has observed
/// whatever had to be true while the call was still in flight.
hold_fail: struct { gate: *std.Io.Semaphore, err: transport.ExchangeError },
};
fn exchangeFn(
ptr: *anyopaque,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
_ = query;
// Deliberately not the endpoint url: the pool must report its own
// entry, so a test can tell the two apart.
selected.* = "fake://leaf";
const self: *Fake = @ptrCast(@alignCast(ptr));
_ = self.calls.fetchAdd(1, .acq_rel);
// The behaviour is taken before `in_flight` rises: a test that waits
// on the count to know a call is inside this fake also needs to know
// which behaviour that call drew, or a second call could still take
// the first behaviour from under it.
const behavior = behavior: {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const current = self.behavior;
if (self.then) |next| {
self.behavior = next;
self.then = null;
}
break :behavior current;
};
const entrants = self.in_flight.fetchAdd(1, .acq_rel) + 1;
defer _ = self.in_flight.fetchSub(1, .acq_rel);
_ = self.peak_in_flight.fetchMax(entrants, .acq_rel);
switch (behavior) {
.reply => |bytes| return copy(bytes, response_buf),
.fail => |err| return err,
.slow => |slow| {
try slow.duration.sleep(io);
return copy(slow.reply, response_buf);
},
.slow_fail => |slow| {
try slow.duration.sleep(io);
return slow.err;
},
.hold => |held| {
try held.gate.wait(io);
return copy(held.reply, response_buf);
},
.hold_fail => |held| {
try held.gate.wait(io);
return held.err;
},
}
}
fn copy(bytes: []const u8, response_buf: []u8) transport.ExchangeError![]u8 {
if (bytes.len > response_buf.len) return error.ResponseTooLarge;
@memcpy(response_buf[0..bytes.len], bytes);
return response_buf[0..bytes.len];
}
fn client(self: *Fake) transport.Client {
return .{ .ptr = self, .exchangeFn = exchangeFn };
}
};
fn testEntry(url: []const u8, fake: *Fake, priority: i32) Entry {
return testEntrySlots(url, fake, priority, 1);
}
/// An entry whose `slot_count` slots all run through one fake, borrowing that
/// fake's slot storage and recovery counter. One slot unless a test is about
/// concurrency: the assertions below read the fake's own counters, and one fake
/// per entry is what makes those readable.
fn testEntrySlots(url: []const u8, fake: *Fake, priority: i32, slot_count: usize) Entry {
std.debug.assert(slot_count >= 1 and slot_count <= fake.slots.len);
const slots = fake.slots[0..slot_count];
for (slots) |*slot| slot.* = .{ .client = fake.client() };
return .{
.endpoint = Endpoint.parse(url) catch unreachable,
.slots = slots,
.priority = priority,
.enabled = true,
.health = .init,
.admission = .{ .permits = slot_count },
.reuse_recoveries = &fake.recoveries,
};
}
const Endpoint = transport.Endpoint;
/// Long enough that no test can outlive a backoff it just set, and short enough
/// that nothing waits on it.
const test_cfg: health.Config = .{
.failure_threshold = 2,
.base_backoff_ms = 60_000,
.max_backoff_ms = 60_000,
};
/// Long enough that nothing in a test reaches either budget unless the test is
/// about a budget, and short enough that a stuck test still ends.
const test_timeouts: Timeouts = .{
.attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .clock = .awake },
};
fn expectFailureLine(expected: []const u8, url: []const u8, err: transport.ExchangeError) !void {
var buf: [8 * safe_url.max_len]u8 = undefined;
const line = try std.fmt.bufPrint(&buf, "{f}", .{AttemptFailure{
.endpoint = try .parse(url),
.err = err,
}});
try testing.expectEqualStrings(expected, line);
}
test "the failed-attempt line names an upstream by a url carrying no credential" {
// A NextDNS DoH upstream puts the whole account identifier in the path, and
// this line ran at `debug` on every peer fault, so a debug-level operator
// persisted it to the journal once per failure.
try expectFailureLine(
"upstream 'https://dns.nextdns.io' failed: Timeout",
"https://dns.nextdns.io/abcd12",
error.Timeout,
);
try expectFailureLine(
"upstream 'https://cdn.example:8443' failed: TlsFailed",
"https://cdn.example:8443/d/hunter2/dns-query",
error.TlsFailed,
);
// What it still says, because an operator reading a failover has to know
// which upstream failed: the scheme, the host and the port.
try expectFailureLine(
"upstream 'tls://9.9.9.9:853' failed: ConnectFailed",
"tls://9.9.9.9:853",
error.ConnectFailed,
);
}
test "a canceled admission waiter holds nothing and its peer still wakes" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var admission: Admission = .{ .permits = 0 };
const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake });
var canceled = io.concurrent(Admission.acquireUntil, .{ &admission, io, far }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
var peer = io.concurrent(Admission.acquireUntil, .{ &admission, io, far }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = peer.await(io) catch Admission.Outcome.expired;
// Long enough that both tasks are provably inside the wait: neither can
// reach a permit, because none exists yet.
const settle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
try settle.sleep(io);
try testing.expectError(error.Canceled, canceled.cancel(io));
// One permit, one signal. The canceled task may have absorbed that signal
// on its way out; if it did not hand it back, the peer would wait here
// forever and this test would hang.
admission.release(io);
try testing.expectEqual(Admission.Outcome.acquired, try peer.await(io));
try testing.expectEqual(@as(usize, 0), admission.permits);
}
fn holdAdmissionMutex(admission: *Admission, io: std.Io, hold: std.Io.Clock.Duration) void {
admission.mutex.lockUncancelable(io);
defer admission.mutex.unlock(io);
hold.sleep(io) catch {};
}
test "a contended admission mutex does not carry acquireUntil past its deadline" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// One permit, but the mutex guarding it is held far past the caller's
// deadline. Every take must therefore run inside the deadline race: a
// blocking lock before the race starts would return `.acquired` only once
// the holder let go, long after the budget was gone.
var admission: Admission = .{ .permits = 1 };
const hold: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake };
var holder = io.concurrent(holdAdmissionMutex, .{ &admission, io, hold }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer holder.await(io);
const settle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
try settle.sleep(io);
const deadline: std.Io.Clock.Timestamp = .fromNow(io, settle);
// `.expired` is the whole assertion: the permit is free, so a take that
// waited on the mutex outside the race would report `.acquired` instead,
// hundreds of milliseconds after the deadline. The exit path itself still
// waits for the mutex, uncancelably, to re-signal the condition.
try testing.expectEqual(Admission.Outcome.expired, try admission.acquireUntil(io, deadline));
try testing.expectEqual(@as(usize, 1), admission.permits);
}
test "an admission expiry racing a release leaves the permit count exact" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// The tie the design exists for: the permit is taken by the caller under
// the mutex only after the race resolves, so a discarded wait can lose a
// notification but never a permit. Repeated because the interleaving is a
// race — a leak would show as a count that drifts below one.
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(2), .clock = .awake };
for (0..50) |_| {
var admission: Admission = .{ .permits = 0 };
const deadline: std.Io.Clock.Timestamp = .fromNow(io, tick);
var waiter = io.concurrent(Admission.acquireUntil, .{ &admission, io, deadline }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
try tick.sleep(io);
admission.release(io);
switch (try waiter.await(io)) {
.acquired => admission.release(io),
.expired => {},
}
try testing.expectEqual(@as(usize, 1), admission.permits);
}
}
test "Pool satisfies the Client interface" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{testEntry("https://a.example/dns-query", &fake, 10)};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
const reply = try pool.client().exchange(io, query_bytes, &buf, &selected);
try testing.expectEqualSlices(u8, response_bytes, reply);
}
test "the pool reports the endpoint that answered, not the leaf client's own name" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{testEntry("https://a.example/dns-query", &fake, 10)};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
_ = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqualStrings("https://a.example/dns-query", selected.?);
}
test "a failover reports the endpoint that answered, not the first one tried" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var bad: Fake = .{ .behavior = .{ .fail = error.Timeout } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://bad.example/dns-query", &bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
_ = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqualStrings("https://good.example/dns-query", selected.?);
}
test "an all-failed exchange reports the last endpoint attempted" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var first: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
var last: Fake = .{ .behavior = .{ .fail = error.Timeout } };
var entries = [_]Entry{
testEntry("https://first.example/dns-query", &first, 10),
testEntry("https://last.example/dns-query", &last, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(error.Timeout, pool.exchange(io, query_bytes, &buf, &selected));
try testing.expectEqualStrings("https://last.example/dns-query", selected.?);
}
test "a truncated attempt blames the budget, not the endpoint it was in" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// The total budget is shorter than one attempt, so the first attempt is
// truncated: the endpoint never got the observation interval it was
// configured to get, and an expiry says nothing about it.
var stalling: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} } };
var untouched: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://stalling.example/dns-query", &stalling, 10),
testEntry("https://untouched.example/dns-query", &untouched, 20),
};
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(200), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(60), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(
error.BudgetExhausted,
pool.exchange(io, query_bytes, &buf, &selected),
);
// Nothing is attributable: no outcome was recorded, so no endpoint is
// named and no health moved.
try testing.expect(selected == null);
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
try testing.expectEqual(@as(u32, 0), entries[0].health.consecutive_failures);
try testing.expectEqual(@as(?std.Io.Timestamp, null), entries[0].health.last_error_at);
try testing.expectEqual(@as(usize, 0), untouched.calls.load(.acquire));
try testing.expectEqual(@as(u64, 1), pool.budgetExhaustedTotal());
}
test "a truncated attempt that fails on its own still blames the endpoint" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// Same truncated budget, but the attempt completes with a peer fault of
// its own before the deadline. A completed failure is real evidence
// whatever budget it ran with, so health moves and `selected` names it.
var refusing: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
var entries = [_]Entry{testEntry("https://refusing.example/dns-query", &refusing, 10)};
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(200), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(60), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(
error.ConnectFailed,
pool.exchange(io, query_bytes, &buf, &selected),
);
try testing.expectEqualStrings("https://refusing.example/dns-query", selected.?);
try testing.expectEqual(@as(u64, 1), entries[0].health.total_failures);
try testing.expectEqualStrings("ConnectFailed", entries[0].health.lastError());
try testing.expectEqual(@as(u64, 0), pool.budgetExhaustedTotal());
}
test "an exchange that attempted nothing reports no endpoint" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{testEntry("https://a.example/dns-query", &fake, 10)};
entries[0].enabled = false;
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(error.ConnectFailed, pool.exchange(io, query_bytes, &buf, &selected));
try testing.expect(selected == null);
}
test "entries are tried in ascending priority order" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var low: Fake = .{ .behavior = .{ .reply = response_bytes } };
var high: Fake = .{ .behavior = .{ .reply = response_bytes } };
// Registered out of order: priority, not position, decides.
var entries = [_]Entry{
testEntry("https://high.example/dns-query", &high, 100),
testEntry("https://low.example/dns-query", &low, 10),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
try testing.expectEqual(@as(i32, 10), entries[0].priority);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
_ = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqual(@as(usize, 1), low.calls.load(.acquire));
try testing.expectEqual(@as(usize, 0), high.calls.load(.acquire));
}
test "a peer fault fails over to the next entry and is recorded" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var bad: Fake = .{ .behavior = .{ .fail = error.Timeout } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://bad.example/dns-query", &bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
const reply = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqualSlices(u8, response_bytes, reply);
try testing.expectEqual(@as(u32, 1), entries[0].health.consecutive_failures);
try testing.expectEqual(@as(u64, 1), entries[0].health.total_failures);
try testing.expectEqualStrings("Timeout", entries[0].health.lastError());
try testing.expectEqual(@as(u64, 1), entries[1].health.total_successes);
}
test "an entry in backoff is skipped while another is available" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var bad: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://bad.example/dns-query", &bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
// Two failures reach `failure_threshold` and open a backoff window.
var selected: ?[]const u8 = null;
_ = try pool.exchange(io, query_bytes, &buf, &selected);
_ = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqual(@as(usize, 2), bad.calls.load(.acquire));
try testing.expect(entries[0].health.backoff_until != null);
_ = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqual(@as(usize, 2), bad.calls.load(.acquire));
try testing.expectEqual(@as(usize, 3), good.calls.load(.acquire));
}
test "every entry in backoff is still probed" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var first: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
var second: Fake = .{ .behavior = .{ .fail = error.BadResponse } };
var entries = [_]Entry{
testEntry("https://first.example/dns-query", &first, 10),
testEntry("https://second.example/dns-query", &second, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf, &selected));
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf, &selected));
try testing.expect(entries[0].health.backoff_until != null);
try testing.expect(entries[1].health.backoff_until != null);
// Pass one now has no candidate at all. Pass two probes both anyway.
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf, &selected));
try testing.expectEqual(@as(usize, 3), first.calls.load(.acquire));
try testing.expectEqual(@as(usize, 3), second.calls.load(.acquire));
}
test "a local resource error short-circuits and records nothing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var broke: Fake = .{ .behavior = .{ .fail = error.OutOfMemory } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://broke.example/dns-query", &broke, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(error.OutOfMemory, pool.exchange(io, query_bytes, &buf, &selected));
try testing.expectEqual(@as(usize, 0), good.calls.load(.acquire));
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
try testing.expectEqual(@as(u32, 0), entries[0].health.consecutive_failures);
}
test "a cancellation short-circuits and records nothing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var canceled: Fake = .{ .behavior = .{ .fail = error.Canceled } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://canceled.example/dns-query", &canceled, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(error.Canceled, pool.exchange(io, query_bytes, &buf, &selected));
try testing.expectEqual(@as(usize, 0), good.calls.load(.acquire));
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
// A torn-down task is not a budget that ran out, and it names no endpoint.
try testing.expectEqual(@as(u64, 0), pool.budgetExhaustedTotal());
try testing.expect(selected == null);
}
test "an attempt that outruns the budget is a recorded Timeout" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var slow: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://slow.example/dns-query", &slow, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
// A tight attempt budget under a loose total one, so the attempt deadline
// is the only one that can fire and the failover after it has room to run.
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(20), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
const reply = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqualSlices(u8, response_bytes, reply);
try testing.expectEqual(@as(usize, 1), slow.calls.load(.acquire));
try testing.expectEqual(@as(u32, 1), entries[0].health.consecutive_failures);
try testing.expectEqualStrings("Timeout", entries[0].health.lastError());
try testing.expectEqual(@as(u64, 1), entries[1].health.total_successes);
}
test "a stalled primary fails over to a fast standby inside the total budget" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// The shipped shape of the defaults: attempt is half of total, so the
// primary gets one whole attempt and the standby still has room to answer
// inside the same deadline.
var stalled: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} } };
var standby: Fake = .{ .behavior = .{ .reply = alt_response_bytes } };
var entries = [_]Entry{
testEntry("https://stalled.example/dns-query", &stalled, 10),
testEntry("https://standby.example/dns-query", &standby, 20),
};
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(150), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(300), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
const reply = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqualSlices(u8, alt_response_bytes, reply);
try testing.expectEqualStrings("https://standby.example/dns-query", selected.?);
// The primary got its full configured interval and did not answer, which is
// evidence about the primary — so it is recorded, unlike a truncated one.
try testing.expectEqual(@as(u32, 1), entries[0].health.consecutive_failures);
try testing.expectEqualStrings("Timeout", entries[0].health.lastError());
try testing.expectEqual(@as(u64, 0), pool.budgetExhaustedTotal());
}
test "pass two probes every backed-off entry under the one deadline" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const cfg: health.Config = .{
.failure_threshold = 1,
.base_backoff_ms = 60_000,
.max_backoff_ms = 60_000,
};
var first: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
var second: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
var entries = [_]Entry{
testEntry("https://first.example/dns-query", &first, 10),
testEntry("https://second.example/dns-query", &second, 20),
};
// One exchange under generous budgets puts both entries into backoff.
var seeding_pool: Pool = .init(&entries, cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(
error.ConnectFailed,
seeding_pool.exchange(io, query_bytes, &buf, &selected),
);
try testing.expect(entries[0].health.backoff_until != null);
try testing.expect(entries[1].health.backoff_until != null);
// Now both stall. Pass one has no candidate at all, so pass two probes
// them in order — sharing the one deadline rather than granting each probe
// a fresh attempt budget.
first.behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} };
second.behavior = first.behavior;
var probing_pool: Pool = .init(&entries, cfg, .{
.attempt = .{ .raw = .fromMilliseconds(100), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(150), .clock = .awake },
}, 1);
const started = std.Io.Clock.awake.now(io);
selected = null;
try testing.expectError(
error.BudgetExhausted,
probing_pool.exchange(io, query_bytes, &buf, &selected),
);
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
// Both were probed, and the pair cost the total budget rather than one
// attempt budget each.
try testing.expectEqual(@as(usize, 2), first.calls.load(.acquire));
try testing.expectEqual(@as(usize, 2), second.calls.load(.acquire));
try testing.expect(elapsed_ns < @as(i96, 200) * std.time.ns_per_ms);
// The first probe ran a full attempt and is recorded; the second was cut
// short by the deadline and is not, so `selected` still names the first.
try testing.expectEqualStrings("https://first.example/dns-query", selected.?);
try testing.expectEqual(@as(u64, 2), entries[0].health.total_failures);
try testing.expectEqual(@as(u64, 1), entries[1].health.total_failures);
try testing.expectEqual(@as(u64, 1), probing_pool.budgetExhaustedTotal());
}
test "an exchange whose deadline is already gone starts no attempt" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// A budget of nothing: the entry is healthy and its permit is free, so
// admission and the health recheck both succeed, and only the remaining-time
// check stands between the loop and an attempt. Racing a leaf against a
// timestamp that has already passed is a coin toss — an instantly answering
// fake can win it — so a pool that omits the check manufactures a success
// out of a budget that was already spent.
var fake: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{testEntry("https://only.example/dns-query", &fake, 10)};
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(0), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(0), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(
error.BudgetExhausted,
pool.exchange(io, query_bytes, &buf, &selected),
);
try testing.expectEqual(@as(usize, 0), fake.calls.load(.acquire));
try testing.expect(selected == null);
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
try testing.expectEqual(@as(u64, 0), entries[0].health.total_successes);
try testing.expectEqual(@as(u64, 1), pool.budgetExhaustedTotal());
// The permit was released on the way out, so the entry is usable again.
try testing.expectEqual(@as(usize, 1), entries[0].admission.permits);
}
/// The pass-two probing test's numbers: the holder's stall outlives the
/// probe's whole budget, so the probe provably died waiting rather than after
/// being served.
const probe_holder_stall_ms = 800;
const probe_order_total_ms = 200;
test "pass two probes in priority order rather than stepping over a saturated entry" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const cfg: health.Config = .{
.failure_threshold = 1,
.base_backoff_ms = 60_000,
.max_backoff_ms = 60_000,
};
var first: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
var second: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
var entries = [_]Entry{
testEntry("https://first.example/dns-query", &first, 10),
testEntry("https://second.example/dns-query", &second, 20),
};
// One exchange under generous budgets puts both entries into backoff, so
// every exchange after it reaches pass two.
var seeding_pool: Pool = .init(&entries, cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectError(
error.ConnectFailed,
seeding_pool.exchange(io, query_bytes, &buf, &selected),
);
try testing.expect(entries[0].health.backoff_until != null);
try testing.expect(entries[1].health.backoff_until != null);
first.behavior = .{ .slow = .{
.duration = .{ .raw = .fromMilliseconds(probe_holder_stall_ms), .clock = .awake },
.reply = response_bytes,
} };
second.behavior = .{ .reply = alt_response_bytes };
var probe_pool: Pool = .init(&entries, cfg, .{
.attempt = .{ .raw = .fromMilliseconds(probe_order_total_ms), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(probe_order_total_ms), .clock = .awake },
}, 1);
var holder_buf: [512]u8 = undefined;
var holder = io.concurrent(exchangeAttributed, .{ &seeding_pool, io, &holder_buf }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = holder.await(io) catch Attributed.discarded;
// The first entry's one permit is provably taken before the probe asks.
try awaitInFlight(io, &first, 1);
const calls_before = second.calls.load(.acquire);
var probe_buf: [512]u8 = undefined;
selected = null;
try testing.expectError(
error.BudgetExhausted,
probe_pool.exchange(io, query_bytes, &probe_buf, &selected),
);
// The claim: pass two waits for the higher-priority entry it is probing.
// A sweep that stepped over the saturated entry would have been answered
// instantly by the second one instead.
try testing.expectEqual(calls_before, second.calls.load(.acquire));
try testing.expect(selected == null);
try testing.expectEqual(@as(u64, 1), probe_pool.budgetExhaustedTotal());
const held = try holder.await(io);
try testing.expectEqualSlices(u8, response_bytes, holder_buf[0..held.reply_len]);
}
test "budget_exhausted_total counts exhausted exchanges once each" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var stalling: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} } };
var entries = [_]Entry{testEntry("https://stalling.example/dns-query", &stalling, 10)};
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(200), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(40), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
try testing.expectEqual(@as(u64, 0), pool.budgetExhaustedTotal());
for (1..4) |expected| {
try testing.expectError(
error.BudgetExhausted,
pool.exchange(io, query_bytes, &buf, &selected),
);
// Once per exchange, never once per endpoint attempted inside it.
try testing.expectEqual(@as(u64, expected), pool.budgetExhaustedTotal());
}
// An exchange that answers adds nothing.
stalling.behavior = .{ .reply = response_bytes };
_ = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqual(@as(u64, 3), pool.budgetExhaustedTotal());
}
test "two stalling upstreams cost the total budget, not one budget each" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// Both entries stall past their own attempt budget, so an exchange that
// gave each one a fresh budget would cost two of them and then some. The
// total is set below two attempts, which is what makes the assertion mean
// something: only the one deadline the loop owns can end this call in time.
var first: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} } };
var second: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} } };
var entries = [_]Entry{
testEntry("https://first.example/dns-query", &first, 10),
testEntry("https://second.example/dns-query", &second, 20),
};
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(200), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(60), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
const started = std.Io.Clock.awake.now(io);
var selected: ?[]const u8 = null;
try testing.expectError(
error.BudgetExhausted,
pool.exchange(io, query_bytes, &buf, &selected),
);
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
// Under one attempt budget, so the deadline is provably what fired. The
// bound is generous against a loaded CI box; the failure it catches is a
// whole extra attempt, not a scheduling hiccup.
try testing.expect(elapsed_ns < @as(i96, 200) * std.time.ns_per_ms);
// The second entry was never reached: the deadline landed inside the first
// attempt, which is a truncated one and so ends the exchange.
try testing.expectEqual(@as(usize, 0), second.calls.load(.acquire));
}
test "every entry disabled yields ConnectFailed without waiting out the total budget" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var one: Fake = .{ .behavior = .{ .reply = response_bytes } };
var two: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://one.example/dns-query", &one, 10),
testEntry("https://two.example/dns-query", &two, 20),
};
for (&entries) |*entry| entry.enabled = false;
// A total budget long enough that waiting it out would be unmistakable.
// The outer expiry returns `error.Timeout` unconditionally, so this pins
// that the fast-failure path still wins its own race.
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
const started = std.Io.Clock.awake.now(io);
var selected: ?[]const u8 = null;
try testing.expectError(error.ConnectFailed, pool.exchange(io, query_bytes, &buf, &selected));
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);
try testing.expectEqual(@as(usize, 0), one.calls.load(.acquire));
try testing.expectEqual(@as(usize, 0), two.calls.load(.acquire));
}
test "snapshot reports the counters in pool order" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var bad: Fake = .{ .behavior = .{ .fail = error.Timeout } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://bad.example/dns-query", &bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
_ = try pool.exchange(io, query_bytes, &buf, &selected);
_ = try pool.exchange(io, query_bytes, &buf, &selected);
var out: [4]Snapshot = undefined;
const written = try pool.snapshot(io, &out);
try testing.expectEqual(@as(usize, 2), written);
try testing.expectEqualStrings("https://bad.example/dns-query", out[0].url);
try testing.expect(out[0].enabled);
try testing.expect(!out[0].available);
try testing.expectEqual(@as(u32, 2), out[0].consecutive_failures);
try testing.expectEqual(@as(u64, 2), out[0].total_failures);
try testing.expectEqual(@as(u64, 0), out[0].total_successes);
try testing.expectEqual(@as(f32, 0.0), out[0].success_rate);
try testing.expectEqualStrings("Timeout", out[0].last_error);
try testing.expect(out[0].last_error_at != null);
try testing.expect(out[0].backoff_until != null);
try testing.expectEqualStrings("https://good.example/dns-query", out[1].url);
try testing.expect(out[1].available);
try testing.expectEqual(@as(u64, 2), out[1].total_successes);
try testing.expectEqual(@as(f32, 1.0), out[1].success_rate);
try testing.expect(out[1].last_success_at != null);
try testing.expectEqual(@as(?std.Io.Timestamp, null), out[1].backoff_until);
// The concurrency fields: nothing is in flight once both exchanges have
// returned, the ceiling is the entry's slot count, and an uncontended pool
// queued nobody and recovered no session.
try testing.expectEqual(@as(u32, 0), out[1].in_flight);
try testing.expectEqual(@as(u32, 1), out[1].slots);
try testing.expectEqual(@as(u64, 0), out[1].queued_total);
try testing.expectEqual(@as(f64, 0), out[1].queued_seconds_total);
try testing.expectEqual(@as(u64, 0), out[1].reuse_recoveries_total);
// A short `out` truncates rather than overflowing.
var one: [1]Snapshot = undefined;
try testing.expectEqual(@as(usize, 1), try pool.snapshot(io, &one));
}
/// One concurrent call's whole result: how much of its buffer the reply filled,
/// and which resolver the pool said answered it.
///
/// `Io.concurrent` stores the return value in the future, so the reply slice is
/// reduced to its length here and the bytes are read back out of the caller's
/// buffer. `selected` survives the trip because it borrows an `Endpoint.url`,
/// which outlives the pool.
const Attributed = struct {
reply_len: usize,
selected: ?[]const u8,
/// What a teardown `await` discards into once the assertions above it have
/// already taken the value.
const discarded: Attributed = .{ .reply_len = 0, .selected = null };
};
/// Each call keeps its own `selected` out-value rather than dropping it: the
/// pointer is written per call, on the stack of the task that made it, and a
/// pool that hung it off `*Pool` instead would hand one call's identity to
/// another. Nothing but a per-call capture can see that.
fn exchangeAttributed(pool: *Pool, io: std.Io, buf: []u8) transport.ExchangeError!Attributed {
var selected: ?[]const u8 = null;
const reply = try pool.exchange(io, query_bytes, buf, &selected);
return .{ .reply_len = reply.len, .selected = selected };
}
/// Blocks until `count` tasks are inside `fake.exchangeFn` at once.
///
/// `io.concurrent` does not promise the task it spawns has started, let alone
/// reached the leaf client, so a sleep proves nothing about who holds an entry's
/// permits. The queued counters are admission samples, and a test about them has
/// to *know* the entry is occupied before it starts the task that queues —
/// otherwise the waiter may be the one that wins the permit and the test passes
/// on the wrong interleaving. Bounded so a fake that never runs fails the test
/// instead of hanging the suite.
fn awaitInFlight(io: std.Io, fake: *Fake, count: u32) !void {
const step: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1), .clock = .awake };
const steps = 10_000;
for (0..steps) |_| {
if (fake.in_flight.load(.acquire) >= count) return;
try step.sleep(io);
}
return error.FakeNeverEnteredExchange;
}
/// The stall one test's slow fake sleeps for. Long enough that two tasks
/// serialized through one slot would take twice it, short enough that a test
/// suite still ends promptly.
const overlap_stall_ms = 100;
test "concurrent exchanges through one entry overlap" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromMilliseconds(overlap_stall_ms), .clock = .awake },
.reply = response_bytes,
} } };
var entries = [_]Entry{testEntrySlots("https://only.example/dns-query", &fake, 10, 2)};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf_a: [512]u8 = undefined;
var buf_b: [512]u8 = undefined;
const started = std.Io.Clock.awake.now(io);
var first = io.concurrent(exchangeAttributed, .{ &pool, io, &buf_a }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = first.await(io) catch Attributed.discarded;
var second = io.concurrent(exchangeAttributed, .{ &pool, io, &buf_b }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = second.await(io) catch Attributed.discarded;
const result_a = try first.await(io);
const result_b = try second.await(io);
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
try testing.expectEqualSlices(u8, response_bytes, buf_a[0..result_a.reply_len]);
try testing.expectEqualSlices(u8, response_bytes, buf_b[0..result_b.reply_len]);
try testing.expectEqualStrings("https://only.example/dns-query", result_a.selected.?);
try testing.expectEqualStrings("https://only.example/dns-query", result_b.selected.?);
try testing.expectEqual(@as(usize, 2), fake.calls.load(.acquire));
// The defect this milestone fixes: both tasks were inside the fake at once,
// and the pair cost one stall rather than two.
try testing.expectEqual(@as(u32, 2), fake.peak_in_flight.load(.acquire));
try testing.expect(elapsed_ns < @as(i96, 2 * overlap_stall_ms) * std.time.ns_per_ms);
try testing.expectEqual(@as(u64, 2), entries[0].health.total_successes);
// Neither task found the entry saturated, so neither queued.
try testing.expectEqual(@as(u64, 0), entries[0].queued_total.load(.acquire));
}
test "an entry never runs more exchanges at once than it has slots" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.reply = response_bytes,
} } };
var entries = [_]Entry{testEntrySlots("https://only.example/dns-query", &fake, 10, 2)};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var bufs: [3][512]u8 = undefined;
var calls: [3]std.Io.Future(transport.ExchangeError!Attributed) = undefined;
for (&calls, &bufs) |*call, *buf| {
call.* = io.concurrent(exchangeAttributed, .{ &pool, io, buf }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
}
// Every future is awaited before the first assertion: a failed assertion
// between two awaits would leave a task running past the end of the test.
var results: [3]transport.ExchangeError!Attributed = undefined;
for (&calls, &results) |*call, *result| result.* = call.await(io);
for (results, &bufs) |result, *buf| {
const value = try result;
try testing.expectEqualSlices(u8, response_bytes, buf[0..value.reply_len]);
}
try testing.expectEqual(@as(usize, 3), fake.calls.load(.acquire));
// The third task waited for a permit rather than entering a third client:
// the slot count, not the task count, is the ceiling.
try testing.expectEqual(@as(u32, 2), fake.peak_in_flight.load(.acquire));
try testing.expectEqual(@as(u32, 2), entries[0].peak_in_flight.load(.acquire));
}
test "a task that waits for a saturated entry is counted as queued" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromMilliseconds(overlap_stall_ms), .clock = .awake },
.reply = response_bytes,
} } };
var entries = [_]Entry{testEntry("https://only.example/dns-query", &fake, 10)};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf_a: [512]u8 = undefined;
var buf_b: [512]u8 = undefined;
var first = io.concurrent(exchangeAttributed, .{ &pool, io, &buf_a }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = first.await(io) catch Attributed.discarded;
// The counter is an admission sample, so the second task has to find the
// entry already occupied. Waiting for the first task to be inside the fake
// is what makes that true of every run.
try awaitInFlight(io, &fake, 1);
var second = io.concurrent(exchangeAttributed, .{ &pool, io, &buf_b }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = second.await(io) catch Attributed.discarded;
_ = try first.await(io);
_ = try second.await(io);
try testing.expectEqual(@as(u64, 1), entries[0].queued_total.load(.acquire));
try testing.expect(entries[0].queued_ns_total.load(.acquire) > 0);
// An uncontended call adds neither.
const queued_ns = entries[0].queued_ns_total.load(.acquire);
fake.behavior = .{ .reply = response_bytes };
var selected: ?[]const u8 = null;
_ = try pool.exchange(io, query_bytes, &buf_a, &selected);
try testing.expectEqual(@as(u64, 1), entries[0].queued_total.load(.acquire));
try testing.expectEqual(queued_ns, entries[0].queued_ns_total.load(.acquire));
}
/// The stall the holder of the entry's one permit sits in below, and the whole
/// budget the task queued behind it gets. The gap between them is the test's
/// whole argument: the holder is still inside the fake long after the waiter's
/// budget is gone, so the waiter can only have died waiting for a permit.
const holder_stall_ms = 600;
const waiter_total_ms = 100;
test "a waiter that spends its whole budget queueing blames nobody but the budget" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromMilliseconds(holder_stall_ms), .clock = .awake },
.reply = response_bytes,
} } };
var entries = [_]Entry{testEntry("https://only.example/dns-query", &fake, 10)};
// Two pools over one `entries` slice. A budget is a property of the pool,
// not of a call, and this test needs two: a generous one for the task that
// holds the permit and a short one for the task that queues behind it. A
// `Pool` borrows `entries`, so both see the same semaphore, the same queue
// counters and the same health; only `mutex` and the jitter RNG are
// per-pool. Sharing health across two pool mutexes is sound here because
// nothing writes it concurrently: the waiter dies before it attempts
// anything, and the holder's success is written before the last call runs.
var holder_pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var waiter_pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromSeconds(30), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(waiter_total_ms), .clock = .awake },
}, 1);
var buf_a: [512]u8 = undefined;
var buf_b: [512]u8 = undefined;
var holder = io.concurrent(exchangeAttributed, .{ &holder_pool, io, &buf_a }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = holder.await(io) catch Attributed.discarded;
// The whole test rests on the holder owning the entry's one permit before
// the waiter asks for one. `io.concurrent` does not order those two, so the
// wait is on the fake itself: once a task is inside `exchangeFn` the permit
// is provably taken, and the call below can only queue.
try awaitInFlight(io, &fake, 1);
// This call never reaches the fake: it is the only entry, so there is no
// admissible alternative and the call waits for the entry's one permit
// until its own budget expires. That is the field failure — a burst dying
// in the queue — and it has to be visible.
var selected: ?[]const u8 = null;
try testing.expectError(
error.BudgetExhausted,
waiter_pool.exchange(io, query_bytes, &buf_b, &selected),
);
// Nothing ran, so nothing is named. A query log row blaming this upstream
// for a queue it never entered is the reporting defect this replaces.
try testing.expect(selected == null);
try testing.expectEqual(@as(u64, 1), waiter_pool.budgetExhaustedTotal());
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
// Where it died, not just that it died: the holder's call is still the only
// one any leaf client has seen, so the budget ran out inside the admission
// wait rather than inside an attempt.
try testing.expectEqual(@as(usize, 1), fake.calls.load(.acquire));
try testing.expectEqual(@as(u64, 1), entries[0].queued_total.load(.acquire));
try testing.expect(entries[0].queued_ns_total.load(.acquire) > 0);
// The holder's own budget is generous, so it answers rather than expiring:
// the permit comes back from a completed attempt.
const held = try holder.await(io);
try testing.expectEqualSlices(u8, response_bytes, buf_a[0..held.reply_len]);
// The expired waiter returned the permit it never held, so the entry is
// usable again — and the fresh call is the second one to reach the fake.
fake.behavior = .{ .reply = response_bytes };
const reply = try waiter_pool.exchange(io, query_bytes, &buf_b, &selected);
try testing.expectEqualSlices(u8, response_bytes, reply);
try testing.expectEqual(@as(usize, 2), fake.calls.load(.acquire));
}
/// The saturated-primary test's budgets. The holders do not stall on a clock at
/// all: they are held on a gate the test releases, so the probe's own budget is
/// the only duration the test depends on.
const probe_attempt_ms = 150;
const probe_total_ms = 300;
test "a saturated primary defers to a standby that has capacity" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// The Pi reproduction: the primary is healthy but every slot is held by a
// slow exchange, and the standby is idle. Priority orders the entries that
// can be admitted now; it does not entitle a saturated entry to hold a
// query until the whole budget is gone.
// The holders are released by the test, not by a clock: a fixed stall can
// expire before a loaded scheduler lets the poller observe both calls, and
// an overlap that already passed cannot be recovered by polling.
var gate: std.Io.Semaphore = .{};
var primary: Fake = .{ .behavior = .{ .hold = .{ .gate = &gate, .reply = response_bytes } } };
var standby: Fake = .{ .behavior = .{ .reply = alt_response_bytes } };
var entries = [_]Entry{
testEntrySlots("https://primary.example/dns-query", &primary, 10, 2),
testEntry("https://standby.example/dns-query", &standby, 20),
};
// Two pools over one `entries` slice, as the queued-waiter test above does:
// the holders need a budget long enough to finish, the probe a budget short
// enough that waiting for a slot would be fatal.
var holder_pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var probe_pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(probe_attempt_ms), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(probe_total_ms), .clock = .awake },
}, 1);
var bufs: [2][512]u8 = undefined;
var holders: [2]std.Io.Future(transport.ExchangeError!Attributed) = undefined;
var spawned: usize = 0;
// Every spawned holder is released and awaited on any exit, including the
// skip when the second spawn is refused: a holder left blocked on the gate
// would outlive the stack it reads.
defer for (holders[0..spawned]) |*holder| {
_ = holder.await(io) catch Attributed.discarded;
};
defer for (0..spawned) |_| gate.post(io);
for (&holders, &bufs) |*holder, *buf| {
holder.* = io.concurrent(exchangeAttributed, .{ &holder_pool, io, buf }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
spawned += 1;
}
// Both permits provably taken before the probe asks for one.
try awaitInFlight(io, &primary, 2);
var probe_buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
const started = std.Io.Clock.awake.now(io);
const reply = try probe_pool.exchange(io, query_bytes, &probe_buf, &selected);
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
try testing.expectEqualSlices(u8, alt_response_bytes, reply);
try testing.expectEqualStrings("https://standby.example/dns-query", selected.?);
// Well inside the probe's own budget: it never queued on the primary.
try testing.expect(elapsed_ns < @as(i96, probe_total_ms) * std.time.ns_per_ms);
try testing.expectEqual(@as(usize, 1), standby.calls.load(.acquire));
// The primary was never entered a third time, and never counted a failure:
// it was skipped for want of capacity, not blamed for anything.
try testing.expectEqual(@as(usize, 2), primary.calls.load(.acquire));
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
}
/// The two entries of the test below, each answering with bytes only it
/// produces so a call's reply proves which entry served it independently of
/// what the pool reported.
const divergent_first_url = "https://first.example/dns-query";
const divergent_second_url = "https://second.example/dns-query";
fn expectAnsweredByReporter(result: Attributed, buf: []const u8) !void {
const url = result.selected orelse return error.TestExpectedSelectedResolver;
const reply = if (std.mem.eql(u8, url, divergent_first_url))
alt_response_bytes
else if (std.mem.eql(u8, url, divergent_second_url))
response_bytes
else
return error.TestUnexpectedResolver;
try testing.expectEqualSlices(u8, reply, buf[0..result.reply_len]);
}
test "overlapping exchanges each report the entry that answered that call" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// Two gates, one per call the first entry takes. Nothing in this test
// sleeps: the test releases each call itself, so the schedule it asserts
// on is the schedule it built.
var gate_a: std.Io.Semaphore = .{};
var gate_b: std.Io.Semaphore = .{};
// The first entry has room for both tasks and treats them differently: it
// fails whichever reaches it first and answers the other. One failure is
// under `test_cfg`'s threshold of two, so no backoff steers the failed-over
// task away and the two calls end on different entries.
var first_entry: Fake = .{
.behavior = .{ .hold_fail = .{ .gate = &gate_a, .err = error.ConnectFailed } },
.then = .{ .hold = .{ .gate = &gate_b, .reply = alt_response_bytes } },
};
var second_entry: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntrySlots(divergent_first_url, &first_entry, 10, 2),
testEntry(divergent_second_url, &second_entry, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf_a: [512]u8 = undefined;
var buf_b: [512]u8 = undefined;
var first = io.concurrent(exchangeAttributed, .{ &pool, io, &buf_a }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = first.await(io) catch Attributed.discarded;
// Registered right after the task and after its await, so it runs before
// the await: any early return below (a failed wait, a skipped second
// task, a failed assertion) releases the held call first and the await
// then returns, instead of the test deadlocking on a gate nobody posted.
// Extra permits on an already-released gate are harmless.
defer gate_a.post(io);
// A count of one means the first call is inside the entry with its
// behaviour already drawn, so it holds gate A and the second call will draw
// gate B.
try awaitInFlight(io, &first_entry, 1);
var second = io.concurrent(exchangeAttributed, .{ &pool, io, &buf_b }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = second.await(io) catch Attributed.discarded;
defer gate_b.post(io);
// Both tasks are now inside the first entry, the first on `hold_fail` and
// the second on `hold`. That is the overlap every assertion below rests
// on, and it is established here rather than assumed.
try awaitInFlight(io, &first_entry, 2);
// Release the first call only. It fails, fails over to the second entry
// and is answered there while the second call is still held inside the
// first entry, so a shared identity would be overwritten under it.
gate_a.post(io);
const result_a = try first.await(io);
gate_b.post(io);
const result_b = try second.await(io);
// The first task reached the first entry first, but the claim stays over
// the pair: one identity each, and each one matching the bytes that call
// received.
try testing.expect(!std.mem.eql(u8, result_a.selected.?, result_b.selected.?));
try expectAnsweredByReporter(result_a, &buf_a);
try expectAnsweredByReporter(result_b, &buf_b);
try testing.expectEqual(@as(usize, 2), first_entry.calls.load(.acquire));
try testing.expectEqual(@as(usize, 1), second_entry.calls.load(.acquire));
// Both tasks were admitted to the first entry at once, which is what makes
// them concurrent rather than serialized behind one permit. The wait above
// already required this; the counter records that it held to the end.
try testing.expectEqual(@as(u32, 2), first_entry.peak_in_flight.load(.acquire));
try testing.expectEqual(@as(u64, 1), entries[0].health.total_failures);
try testing.expectEqual(@as(u64, 1), entries[0].health.total_successes);
try testing.expectEqual(@as(u64, 1), entries[1].health.total_successes);
}
test "an entry that enters backoff while a task waits on it is not attempted" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// One failure is enough to open the backoff window, so the first task's
// failure makes the entry unavailable to the second task, which by then is
// already past its own availability check and waiting on `busy`.
const cfg: health.Config = .{
.failure_threshold = 1,
.base_backoff_ms = 60_000,
.max_backoff_ms = 60_000,
};
var slow_bad: Fake = .{ .behavior = .{ .slow_fail = .{
.duration = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.err = error.ConnectFailed,
} } };
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://slow-bad.example/dns-query", &slow_bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, cfg, test_timeouts, 1);
var buf_a: [512]u8 = undefined;
var buf_b: [512]u8 = undefined;
var first = io.concurrent(exchangeAttributed, .{ &pool, io, &buf_a }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = first.await(io) catch Attributed.discarded;
var second = io.concurrent(exchangeAttributed, .{ &pool, io, &buf_b }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer _ = second.await(io) catch Attributed.discarded;
const result_a = try first.await(io);
const result_b = try second.await(io);
// Both tasks fail over to the healthy entry and get an answer, and both
// report it: the identity is the entry that answered, not the one that
// failed on the way there.
try testing.expectEqualSlices(u8, response_bytes, buf_a[0..result_a.reply_len]);
try testing.expectEqualSlices(u8, response_bytes, buf_b[0..result_b.reply_len]);
try testing.expectEqualStrings("https://good.example/dns-query", result_a.selected.?);
try testing.expectEqualStrings("https://good.example/dns-query", result_b.selected.?);
try testing.expectEqual(@as(usize, 2), good.calls.load(.acquire));
// The point of the test: the entry was attempted once, not twice. Without
// the re-check the second task would take the lock and attempt it anyway.
try testing.expectEqual(@as(usize, 1), slow_bad.calls.load(.acquire));
try testing.expectEqual(@as(u64, 1), entries[0].health.total_failures);
try testing.expect(entries[0].health.backoff_until != null);
}
test "a successful exchange with nothing open costs the store no statement" {
if (@FieldType(events.Store, "statements") != u64) return error.SkipZigTest;
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{testEntry("https://good.example/dns-query", &good, 10)};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
pool.diagnostics = &fx.store;
var buf: [512]u8 = undefined;
const before = fx.store.statements;
var selected: ?[]const u8 = null;
for (0..20) |_| _ = try pool.exchange(io, query_bytes, &buf, &selected);
try testing.expectEqual(before, fx.store.statements);
try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events"));
}
test "a failing then recovering upstream leaves exactly one resolved episode" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var flaky: Fake = .{ .behavior = .{ .fail = error.Timeout } };
var standby: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{
testEntry("https://flaky.example/dns-query", &flaky, 10),
testEntry("https://standby.example/dns-query", &standby, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
pool.diagnostics = &fx.store;
var buf: [512]u8 = undefined;
var selected: ?[]const u8 = null;
_ = try pool.exchange(io, query_bytes, &buf, &selected);
// Backoff would park the failing entry, so the second failure is driven
// through `recordFailure` itself rather than through another exchange.
pool.recordFailure(io, &entries[0], std.Io.Clock.awake.now(io), error.ConnectFailed);
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqual(@as(i64, 2), try fx.count("SELECT occurrences FROM operational_events"));
try testing.expectEqualStrings("upstream.exchange", try fx.text("SELECT code FROM operational_events"));
try testing.expectEqualStrings(
"https://flaky.example/dns-query",
try fx.text("SELECT subject_key FROM operational_events"),
);
flaky.behavior = .{ .reply = response_bytes };
pool.recordSuccess(io, &entries[0], std.Io.Clock.awake.now(io));
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
try testing.expectEqual(
@as(i64, 0),
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
try testing.expectEqual(@as(i64, 2), try fx.count("SELECT occurrences FROM operational_events"));
}