Files
nxdns/src/upstream/pool.zig
T
mokhtar 025edbb093
Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m55s
Gates / test-aarch64 (push) Failing after 3h1m8s
Gates / package (push) Successful in 3m55s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 3h21m29s
milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics
2026-08-22 19:54:02 +02:00

1528 lines
69 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Priority-ordered sequential failover across upstream endpoints, with two
//! deadlines, health tracking and backoff (PLAN §9).
//!
//! The pool is itself a `transport.Client`, so the handler above it sees one
//! interface and knows nothing about how many upstreams exist.
//!
//! Two deadlines, nested. `timeouts.attempt` bounds one exchange against one
//! entry; `timeouts.total` bounds the whole failover loop, every attempt
//! together. The outer one is what the client asking the question actually
//! waits for: without it, N unreachable upstreams cost N × attempt, and the
//! resolver above has already given up. The outer expiry is `error.Timeout`
//! unconditionally — the fast-failure paths (no entry enabled) return long
//! before the budget, so an expiry means either that an attempt was in flight
//! or that the task died waiting for a slot on a saturated entry.
//! `Entry.queued_total` is what tells those two apart.
//!
//! 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.sem` 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.
//!
//! Because a saturated entry still makes tasks 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;
/// 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.
sem: std.Io.Semaphore,
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),
/// Admission samples, not a queue length: a task counts itself queued when
/// it finds `in_flight` saturated before it waits for a permit, and between
/// a releaser's `in_flight` decrement and its `sem.post` (and the mirror
/// window on acquire) that read can misjudge saturation by one. Exactness
/// would cost a lock on the hot path to size a household queue.
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 either exit of the wait for a permit. A waiter that
/// burned its budget in the queue is the field failure this records, so a
/// cancellation counts 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;
}
};
/// 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,
pub fn init(
entries: []Entry,
cfg: health.Config,
timeouts: Timeouts,
seed: u64,
) Pool {
std.debug.assert(entries.len > 0);
for (entries) |*entry| {
std.debug.assert(entry.slots.len >= 1);
std.debug.assert(entry.sem.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.
///
/// The failover loop runs raced against `timeouts.total`. Losing that race
/// cancels the loop, which unwinds whatever attempt was in flight: the wait
/// for a slot is cancelable on purpose and every slot is released by defer,
/// and the health bookkeeping around a completed exchange is uncancelable,
/// so a canceled attempt leaves no lock held, no permit lost and no counter
/// half-written.
pub fn exchange(
self: *Pool,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
const len = try transport.raceWithin(io, self.timeouts.total, exchangeLoopLen, .{
self, io, query, response_buf, selected,
});
return response_buf[0..len];
}
/// The two-pass failover loop, as a raceable task. It returns the reply's
/// length rather than its slice for the reason `Attributed` in the tests
/// below does: `Io.concurrent` stores the future's return value, so the
/// bytes are read back out of the caller's `response_buf` by `exchange`.
fn exchangeLoopLen(
self: *Pool,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError!usize {
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) {
for (self.entries) |*entry| {
if (!entry.enabled) continue;
if (pass == 0 and !self.entryAvailable(io, entry, now)) continue;
// Sampled before the wait, so a task that is about to queue is
// counted as queued whichever way the wait ends. The read races
// the releasers by one slot on purpose; see `Entry`.
const queued_at: ?std.Io.Timestamp = if (entry.in_flight.load(.acquire) >= entry.slots.len)
std.Io.Clock.awake.now(io)
else
null;
// Cancelable, unlike the health-bookkeeping locks below: a task
// waiting its turn on a saturated upstream has done nothing that
// a cancellation could corrupt, so it gives up here rather than
// queueing behind exchanges it will not use. `wait` either
// returns holding a permit or returns `error.Canceled` holding
// none. The wait itself is bounded by the holders'
// `timeouts.attempt`; the waiter's own budget only starts once
// it has a slot, and the whole loop is bounded by
// `timeouts.total` regardless.
entry.sem.wait(io) catch |err| {
if (queued_at) |started| entry.recordQueued(io, started);
return err;
};
if (queued_at) |started| entry.recordQueued(io, started);
// 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.sem.post(io);
}
// The check above ran before the wait, and the attempts this one
// queued behind may have failed the entry into backoff while it
// waited. Pass one must not touch an entry that is unavailable
// now, so re-read health 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: it probes regardless of
// backoff, which is how backoff recovers.
if (pass == 0) {
const recheck = std.Io.Clock.awake.now(io);
if (!self.entryAvailable(io, entry, recheck)) continue;
}
attempted = true;
// Before the attempt, not after it: a failover reports whoever
// answered, an all-failed exchange reports the last endpoint
// tried, and a cancellation mid-flight reports the endpoint the
// query was in. The url is owned by the Endpoint, which outlives
// the pool, so the borrow stays valid past this loop.
selected.* = entry.endpoint.url;
const result = self.attempt(io, slot.client, query, response_buf);
const completed_at = std.Io.Clock.awake.now(io);
const response = result catch |err| switch (transport.group(err)) {
.peer_fault => {
log.debug("{f}", .{AttemptFailure{ .endpoint = entry.endpoint, .err = err }});
self.recordFailure(io, entry, completed_at, err);
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,
};
self.recordSuccess(io, entry, completed_at);
return response.len;
}
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;
}
/// 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;
}
/// One exchange raced against the per-attempt budget.
///
/// 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 attempt(
self: *Pool,
io: std.Io,
entry_client: transport.Client,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var leaf_selected: ?[]const u8 = null;
return transport.raceWithin(io, self.timeouts.attempt, transport.Client.exchange, .{
entry_client, io, query, response_buf, &leaf_selected,
});
}
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,
.sem = .{ .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 "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 timeout mid-flight reports the endpoint the query was in" {
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 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.Timeout, pool.exchange(io, query_bytes, &buf, &selected));
try testing.expectEqualStrings("https://stalling.example/dns-query", selected.?);
try testing.expectEqual(@as(usize, 0), untouched.calls.load(.acquire));
}
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);
}
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 "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 without the outer
// race the exchange would take two attempt budgets and then some. The
// total is set below two attempts, which is what makes the assertion mean
// something: only the outer deadline 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.Timeout, 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 outer 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 loop was canceled inside the
// first attempt.
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 canceled by the total budget is counted and still returns its permit" {
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 waits for the entry's one permit
// until its own total 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.Timeout, waiter_pool.exchange(io, query_bytes, &buf_b, &selected));
// Where it died, not just that it died: the holder's call is still the only
// one any leaf client has seen, so the cancellation landed inside
// `Semaphore.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 canceled 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 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 fails the task that reaches it first, slowly enough that
// the second task is queued behind it, then answers that second task. One
// failure is under `test_cfg`'s threshold of two, so no backoff steers the
// waiting 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 = .{ .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{
testEntry(divergent_first_url, &first_entry, 10),
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));
try testing.expectEqual(@as(u32, 1), 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"));
}