milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics

This commit is contained in:
2026-08-22 19:54:02 +02:00
parent 623667e475
commit c99a37d170
14 changed files with 2099 additions and 188 deletions
+395 -77
View File
@@ -10,7 +10,9 @@
//! 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 always means an attempt was in flight.
//! 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
@@ -25,25 +27,24 @@
//! never held across an exchange, so a slow upstream cannot block a health read
//! or an attempt on some other entry.
//!
//! `Entry.busy` guards one entry's `client`. A task holds it for a whole
//! attempt against that entry — the exchange and the timeout race around it —
//! and drops it before the failover loop moves to the next candidate. This is
//! what makes the pool safe to share: `DohClient` owns a request buffer and a
//! 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 that wait is serializing, pass one re-reads health after it takes
//! `busy`: 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 `busy` then `Pool.mutex`,
//! never the reverse.
//! 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 one in-flight exchange per entry, any
//! number of entries in flight at once, and bookkeeping that never waits on a
//! peer. Two concurrent queries that resolve to the same sole upstream do
//! serialize. At household scale that is the right trade — the alternative is a
//! client instance per listener task, which multiplies TLS buffers and
//! connections for a query rate that never needed them.
//! 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");
@@ -75,18 +76,77 @@ const AttemptFailure = struct {
}
};
/// 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,
client: transport.Client,
/// 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,
/// Held for the whole of one attempt against this entry, so `client` is
/// never re-entered while it is using its own buffers. Defaulted because
/// `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,
/// 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
@@ -108,6 +168,16 @@ pub const Snapshot = struct {
/// 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
@@ -143,6 +213,10 @@ pub const Pool = struct {
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 .{
@@ -178,10 +252,11 @@ pub const Pool = struct {
/// 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
/// `Entry.busy` lock is taken cancelably on purpose and released by defer,
/// 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 and no counter half-written.
/// so a canceled attempt leaves no lock held, no permit lost and no counter
/// half-written.
pub fn exchange(
self: *Pool,
io: std.Io,
@@ -216,21 +291,48 @@ pub const Pool = struct {
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 busy upstream has done nothing that a
// cancellation could corrupt, so it gives up here rather than
// queueing behind an exchange it will not use. The wait itself
// is bounded by the holder's `timeouts.attempt`; the waiter's
// own budget only starts once it has the lock, and the whole
// loop is bounded by `timeouts.total` regardless.
try entry.busy.lock(io);
defer entry.busy.unlock(io);
// 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. Pass two skips this on purpose: it probes regardless of
// 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);
@@ -245,7 +347,7 @@ pub const Pool = struct {
// the pool, so the borrow stays valid past this loop.
selected.* = entry.endpoint.url;
const result = self.attempt(io, entry.client, query, response_buf);
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)) {
@@ -293,6 +395,11 @@ pub const Pool = struct {
.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;
@@ -419,13 +526,20 @@ const Fake = struct {
/// 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.
/// Mutated under the entry's `busy` lock, like `calls`.
then: ?Behavior = null,
calls: usize = 0,
/// 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 per-entry lock is
/// only doing its job while this stays at 1.
/// 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.
@@ -455,12 +569,17 @@ const Fake = struct {
defer _ = self.in_flight.fetchSub(1, .acq_rel);
_ = self.peak_in_flight.fetchMax(entrants, .acq_rel);
self.calls += 1;
const behavior = self.behavior;
if (self.then) |next| {
self.behavior = next;
self.then = null;
}
_ = 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,
@@ -487,12 +606,25 @@ const Fake = struct {
};
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,
.client = fake.client(),
.slots = slots,
.priority = priority,
.enabled = true,
.health = .init,
.sem = .{ .permits = slot_count },
.reuse_recoveries = &fake.recoveries,
};
}
@@ -636,7 +768,7 @@ test "a timeout mid-flight reports the endpoint the query was in" {
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);
try testing.expectEqual(@as(usize, 0), untouched.calls.load(.acquire));
}
test "an exchange that attempted nothing reports no endpoint" {
@@ -674,8 +806,8 @@ test "entries are tried in ascending priority order" {
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);
try testing.expectEqual(@as(usize, 0), high.calls);
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" {
@@ -720,12 +852,12 @@ test "an entry in backoff is skipped while another is available" {
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);
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);
try testing.expectEqual(@as(usize, 3), good.calls);
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" {
@@ -750,8 +882,8 @@ test "every entry in backoff is still probed" {
// 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);
try testing.expectEqual(@as(usize, 3), second.calls);
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" {
@@ -770,7 +902,7 @@ test "a local resource error short-circuits and records nothing" {
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);
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);
}
@@ -791,7 +923,7 @@ test "a cancellation short-circuits and records nothing" {
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);
try testing.expectEqual(@as(usize, 0), good.calls.load(.acquire));
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
}
@@ -821,7 +953,7 @@ test "an attempt that outruns the budget is a recorded Timeout" {
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);
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);
@@ -865,7 +997,7 @@ test "two stalling upstreams cost the total budget, not one budget each" {
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);
try testing.expectEqual(@as(usize, 0), second.calls.load(.acquire));
}
test "every entry disabled yields ConnectFailed without waiting out the total budget" {
@@ -895,8 +1027,8 @@ test "every entry disabled yields ConnectFailed without waiting out the total bu
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);
try testing.expectEqual(@as(usize, 0), two.calls);
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" {
@@ -939,6 +1071,15 @@ test "snapshot reports the counters in pool order" {
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));
@@ -970,18 +1111,117 @@ fn exchangeAttributed(pool: *Pool, io: std.Io, buf: []u8) transport.ExchangeErro
return .{ .reply_len = reply.len, .selected = selected };
}
test "concurrent exchanges through one entry do not overlap" {
/// 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();
// Slow enough that an unserialized second task would still be inside the
// fake when the first one is, and short enough to stay far under the
// 10-second attempt budget even when the two run back to back.
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);
@@ -992,21 +1232,99 @@ test "concurrent exchanges through one entry do not overlap" {
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;
const result_a = try first.await(io);
const result_b = try second.await(io);
_ = try first.await(io);
_ = try second.await(io);
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);
try testing.expectEqual(@as(u32, 1), fake.peak_in_flight.load(.acquire));
try testing.expectEqual(@as(u64, 2), entries[0].health.total_successes);
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
@@ -1076,8 +1394,8 @@ test "overlapping exchanges each report the entry that answered that call" {
try expectAnsweredByReporter(result_a, &buf_a);
try expectAnsweredByReporter(result_b, &buf_b);
try testing.expectEqual(@as(usize, 2), first_entry.calls);
try testing.expectEqual(@as(usize, 1), second_entry.calls);
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);
@@ -1131,11 +1449,11 @@ test "an entry that enters backoff while a task waits on it is not attempted" {
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);
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);
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);
}