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
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.
2244 lines
103 KiB
Zig
2244 lines
103 KiB
Zig
//! 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"));
|
||
}
|