milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics
This commit is contained in:
+395
-77
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user