resolver transport: udp/tcp servers, doh/dot clients, pool failover with health
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
//! Priority-ordered sequential failover across upstream endpoints, with a
|
||||
//! per-attempt deadline, 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 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.
|
||||
//!
|
||||
//! `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
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const health = @import("health.zig");
|
||||
const transport = @import("transport.zig");
|
||||
|
||||
const log = std.log.scoped(.upstream);
|
||||
|
||||
pub const Entry = struct {
|
||||
endpoint: transport.Endpoint,
|
||||
client: transport.Client,
|
||||
/// 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,
|
||||
};
|
||||
|
||||
/// A copy of one entry's health, taken under the mutex. Feeds
|
||||
/// `GET /api/upstream/health` in Phase 8.
|
||||
pub const Snapshot = struct {
|
||||
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,
|
||||
};
|
||||
|
||||
pub const Pool = struct {
|
||||
/// Caller-owned, sorted ascending by priority in `init`.
|
||||
entries: []Entry,
|
||||
cfg: health.Config,
|
||||
/// On the `.awake` clock, so a suspended Pi does not burn the budget.
|
||||
attempt_timeout: std.Io.Clock.Duration,
|
||||
mutex: std.Io.Mutex,
|
||||
rng: std.Random.DefaultPrng,
|
||||
|
||||
pub fn init(
|
||||
entries: []Entry,
|
||||
cfg: health.Config,
|
||||
attempt_timeout: std.Io.Clock.Duration,
|
||||
seed: u64,
|
||||
) Pool {
|
||||
std.debug.assert(entries.len > 0);
|
||||
// Stable, so entries sharing a priority keep their configured order.
|
||||
std.mem.sort(Entry, entries, {}, byPriority);
|
||||
return .{
|
||||
.entries = entries,
|
||||
.cfg = cfg,
|
||||
.attempt_timeout = attempt_timeout,
|
||||
.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,
|
||||
) transport.ExchangeError![]u8 {
|
||||
const self: *Pool = @ptrCast(@alignCast(ptr));
|
||||
return self.exchange(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// `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.
|
||||
pub fn exchange(
|
||||
self: *Pool,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
var last_fault: ?transport.ExchangeError = null;
|
||||
var attempted = false;
|
||||
|
||||
var pass: u8 = 0;
|
||||
while (pass < 2) : (pass += 1) {
|
||||
for (self.entries) |*entry| {
|
||||
if (!entry.enabled) continue;
|
||||
if (pass == 0 and !self.entryAvailable(io, entry, now)) continue;
|
||||
|
||||
// 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 `attempt_timeout`; the waiter's own
|
||||
// budget only starts once it has the lock.
|
||||
try entry.busy.lock(io);
|
||||
defer entry.busy.unlock(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
|
||||
// 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;
|
||||
|
||||
const result = self.attempt(io, entry.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("upstream {s} failed: {t}", .{ entry.endpoint.url, 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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// One exchange raced against the per-attempt budget. No stream read or
|
||||
/// write in 0.16.0 takes a timeout, so the budget is a second task and the
|
||||
/// loser is canceled.
|
||||
fn attempt(
|
||||
self: *Pool,
|
||||
io: std.Io,
|
||||
entry_client: transport.Client,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
var outcomes: [2]Outcome = undefined;
|
||||
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
|
||||
defer race.cancelDiscard();
|
||||
|
||||
race.concurrent(.exchange, transport.Client.exchange, .{
|
||||
entry_client, io, query, response_buf,
|
||||
}) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
};
|
||||
race.concurrent(.expiry, expire, .{ io, self.attempt_timeout }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
};
|
||||
|
||||
switch (try race.await()) {
|
||||
.exchange => |result| return result,
|
||||
.expiry => |result| {
|
||||
// A canceled sleep means this whole task is being torn down,
|
||||
// not that the upstream is slow.
|
||||
try result;
|
||||
return error.Timeout;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
const Outcome = union(enum) {
|
||||
exchange: transport.ExchangeError![]u8,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return duration.sleep(io);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
/// 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,
|
||||
calls: usize = 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.
|
||||
peak_in_flight: std.atomic.Value(u32) = .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,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = query;
|
||||
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 += 1;
|
||||
switch (self.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 .{
|
||||
.endpoint = Endpoint.parse(url) catch unreachable,
|
||||
.client = fake.client(),
|
||||
.priority = priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const test_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
|
||||
|
||||
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_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
const reply = try pool.client().exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqualSlices(u8, response_bytes, reply);
|
||||
}
|
||||
|
||||
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_timeout, 1);
|
||||
|
||||
try testing.expectEqual(@as(i32, 10), entries[0].priority);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqual(@as(usize, 1), low.calls);
|
||||
try testing.expectEqual(@as(usize, 0), high.calls);
|
||||
}
|
||||
|
||||
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_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
const reply = try pool.exchange(io, query_bytes, &buf);
|
||||
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_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
// Two failures reach `failure_threshold` and open a backoff window.
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqual(@as(usize, 2), bad.calls);
|
||||
try testing.expect(entries[0].health.backoff_until != null);
|
||||
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqual(@as(usize, 2), bad.calls);
|
||||
try testing.expectEqual(@as(usize, 3), good.calls);
|
||||
}
|
||||
|
||||
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_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf));
|
||||
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));
|
||||
try testing.expectEqual(@as(usize, 3), first.calls);
|
||||
try testing.expectEqual(@as(usize, 3), second.calls);
|
||||
}
|
||||
|
||||
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_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
try testing.expectError(error.OutOfMemory, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectEqual(@as(usize, 0), good.calls);
|
||||
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_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
try testing.expectError(error.Canceled, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectEqual(@as(usize, 0), good.calls);
|
||||
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),
|
||||
};
|
||||
const budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
|
||||
var pool: Pool = .init(&entries, test_cfg, budget, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
const reply = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqualSlices(u8, response_bytes, reply);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), slow.calls);
|
||||
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 "every entry disabled yields ConnectFailed" {
|
||||
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;
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
try testing.expectError(error.ConnectFailed, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectEqual(@as(usize, 0), one.calls);
|
||||
try testing.expectEqual(@as(usize, 0), two.calls);
|
||||
}
|
||||
|
||||
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_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
|
||||
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);
|
||||
|
||||
// A short `out` truncates rather than overflowing.
|
||||
var one: [1]Snapshot = undefined;
|
||||
try testing.expectEqual(@as(usize, 1), try pool.snapshot(io, &one));
|
||||
}
|
||||
|
||||
/// `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. Returning a `usize` also lets the test discard a result with
|
||||
/// `catch 0`.
|
||||
fn exchangeLen(pool: *Pool, io: std.Io, buf: []u8) transport.ExchangeError!usize {
|
||||
const reply = try pool.exchange(io, query_bytes, buf);
|
||||
return reply.len;
|
||||
}
|
||||
|
||||
test "concurrent exchanges through one entry do not overlap" {
|
||||
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{testEntry("https://only.example/dns-query", &fake, 10)};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf_a: [512]u8 = undefined;
|
||||
var buf_b: [512]u8 = undefined;
|
||||
|
||||
var first = io.concurrent(exchangeLen, .{ &pool, io, &buf_a }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SkipZigTest,
|
||||
};
|
||||
defer _ = first.await(io) catch 0;
|
||||
var second = io.concurrent(exchangeLen, .{ &pool, io, &buf_b }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SkipZigTest,
|
||||
};
|
||||
defer _ = second.await(io) catch 0;
|
||||
|
||||
const len_a = try first.await(io);
|
||||
const len_b = try second.await(io);
|
||||
|
||||
try testing.expectEqualSlices(u8, response_bytes, buf_a[0..len_a]);
|
||||
try testing.expectEqualSlices(u8, response_bytes, buf_b[0..len_b]);
|
||||
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);
|
||||
}
|
||||
|
||||
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_timeout, 1);
|
||||
|
||||
var buf_a: [512]u8 = undefined;
|
||||
var buf_b: [512]u8 = undefined;
|
||||
|
||||
var first = io.concurrent(exchangeLen, .{ &pool, io, &buf_a }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SkipZigTest,
|
||||
};
|
||||
defer _ = first.await(io) catch 0;
|
||||
var second = io.concurrent(exchangeLen, .{ &pool, io, &buf_b }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SkipZigTest,
|
||||
};
|
||||
defer _ = second.await(io) catch 0;
|
||||
|
||||
const len_a = try first.await(io);
|
||||
const len_b = try second.await(io);
|
||||
|
||||
// Both tasks fail over to the healthy entry and get an answer.
|
||||
try testing.expectEqualSlices(u8, response_bytes, buf_a[0..len_a]);
|
||||
try testing.expectEqualSlices(u8, response_bytes, buf_b[0..len_b]);
|
||||
try testing.expectEqual(@as(usize, 2), good.calls);
|
||||
|
||||
// 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(u64, 1), entries[0].health.total_failures);
|
||||
try testing.expect(entries[0].health.backoff_until != null);
|
||||
}
|
||||
Reference in New Issue
Block a user