//! 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 },
    };

    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));
        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);

        _ = self.calls.fetchAdd(1, .acq_rel);
        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;
        };
        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;
            },
        }
    }

    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 numbers. The holders stall longer than the
/// probe's whole budget, so the probe provably cannot be waiting for one of
/// their slots; the stall still ends inside the test so the holders' futures
/// can be awaited rather than abandoned.
const saturating_stall_ms = 800;
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.
    var primary: Fake = .{ .behavior = .{ .slow = .{
        .duration = .{ .raw = .fromMilliseconds(saturating_stall_ms), .clock = .awake },
        .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;
    for (&holders, &bufs) |*holder, *buf| {
        holder.* = io.concurrent(exchangeAttributed, .{ &holder_pool, io, buf }) catch |err| switch (err) {
            error.ConcurrencyUnavailable => return error.SkipZigTest,
        };
    }
    defer for (&holders) |*holder| {
        _ = holder.await(io) catch Attributed.discarded;
    };
    // 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();

    // The first entry has room for both tasks and treats them differently: it
    // fails whichever reaches it first and answers the other, slowly enough
    // that the two calls are in flight together. 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 = .{ .slow_fail = .{
            .duration = .{ .raw = .fromMilliseconds(50), .clock = .awake },
            .err = error.ConnectFailed,
        } },
        .then = .{ .slow = .{
            .duration = .{ .raw = .fromMilliseconds(100), .clock = .awake },
            .reply = alt_response_bytes,
        } },
    };
    // Slow too, so the failed-over call is still in flight while the other call
    // is being answered — a shared identity would be overwritten under it.
    var second_entry: Fake = .{ .behavior = .{ .slow = .{
        .duration = .{ .raw = .fromMilliseconds(50), .clock = .awake },
        .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;
    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);

    // Which task lands where depends on the order they take the first entry's
    // lock, so the claim is 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.
    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"));
}
