From 0fa121b3bbb98aafd3116839b1775dc6b90dd48a Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:02:06 +0200 Subject: [PATCH] Hold 0007's four states where two clock readings and a counter decide them 0007 decides that slow and absent are separate conditions, that the core reports four states rather than one pending state, and that it reports them progressively as a request ages rather than once at the end. None of it was in the tree: the 400 ms threshold existed nowhere, and neither did any of the four names. `src/server/states.rs` holds the part two readings of one clock and a counter settle. A request is late at 400 ms and abandoned at the deadline the transport already declares, each said once and never unsaid, because a loop polling every frame would report `late` sixty times a second and a client receiving it twice cannot tell one slow request from two. A reading first taken past the deadline reports the abandonment alone: the late report exists so a client can commit to a cached answer with budget left to draw it, and five seconds later that budget is spent. Three transport outcomes are evidence the server is absent and need no threshold, because none of them waited. A refused certificate is not one of them, which 0007 names as the near miss: it is a server that answered, and reporting it as unreachable sends a person looking for a network problem that does not exist. The abandonment count is here rather than beside the retry policy, and that closes a gap `src/server/retry.rs` states about itself: 0038 decides that a call spending all three attempts is one abandonment rather than three, that module records the decision and enforces nothing, and a loop charging three would pass every check in it. `ConsecutiveAbandonments::abandoned` takes no attempt count, so there is nothing to charge three of, and a success resets the run because the count is of consecutive abandonments rather than of abandonments. What that prevents is the two failures 0007 exists against: a client told its server is gone because one endpoint was slow, and a person shown a spinner and then an error for a request that was about to succeed. #44 stays open. Its condition drives the fake server at several delays and asserts the sequence for each, including the case where a cached answer exists; the request is #27 and the cached answer is #43. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- .github/coverage/pinned-surface | 1 + src/server/mod.rs | 9 + src/server/states.rs | 585 ++++++++++++++++++++++++++++++++ tests/thread_statements.rs | 12 + 4 files changed, 607 insertions(+) create mode 100644 src/server/states.rs diff --git a/.github/coverage/pinned-surface b/.github/coverage/pinned-surface index 40c71fe..c6adfc9 100644 --- a/.github/coverage/pinned-surface +++ b/.github/coverage/pinned-surface @@ -80,6 +80,7 @@ module src/server/certificate.rs The one exception 0029 admits to certificate module src/server/federation.rs Which second host a request may reach, and the deliberate act that admits one. module src/server/recovery.rs 0045's schedule for a server that is gone: when the next probe is due, where the doubling stops, and the hour after which the core stops asking. A defect here is a radio kept busy overnight on a device in somebody's bag, or a server that came back and was noticed hours later. module src/server/retry.rs 0038's one policy for every request the core makes: which kinds are retried inside a call, how many attempts one call may spend, the interval each wait is drawn over, and the deadline that ends it. A defect here retries something that changed the server, or keeps a caller waiting past the point 0007 says an answer is not coming. +module src/server/states.rs 0007's four states: when a request outstanding is late, when it is abandoned, which transport outcomes are evidence the server is absent, and how many abandonments make it unreachable. A defect here tells a person their server is gone because one endpoint was slow, or holds a screen on a spinner past the budget the number was divided out of. module src/server/transport.rs The bounds every request is carried inside: which deadline an attempt runs against, how many may be outstanding against one server, how long an idle connection may be reused, and how far a cancelled body is read. A defect here is a request that outlives the deadline a caller was promised, or a connection handed back to an origin it was never opened to. module src/server/write_queue.rs 0047's order, coalescing and bound for every write the core makes to a server. A defect here delivers somebody's actions out of the order they took them, keeps a queue that grows with activity rather than with breadth, or drops what a person just did instead of what they did three weeks ago. module src/cache/mod.rs The store interface, the one failure it may report, and the key type 0041 derives. diff --git a/src/server/mod.rs b/src/server/mod.rs index 2ea68aa..3ad3f9c 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -37,6 +37,14 @@ //! reason [`transport`] holds no socket, and it is where both spreads in this //! module take their draw. //! +//! [`states`] holds 0007's four states: how long a request may be outstanding +//! before it is late, when it is abandoned, that each of those is said once and +//! never unsaid, which transport outcomes are evidence the server is absent, and +//! how many abandonments with no success between them make the server +//! unreachable rather than the request unlucky. It makes no request, for the +//! same reason [`transport`] holds no socket, and where the last of its states +//! hands over to is [`recovery`]. +//! //! [`federation`] holds what 0072 decides: a second host becomes reachable only //! through an act a person performed, against one server, naming what it shares, //! and revocable without the network. Which hosts may be contacted at all is @@ -47,6 +55,7 @@ pub mod certificate; pub mod federation; pub mod recovery; pub mod retry; +pub mod states; pub mod transport; pub mod write_queue; diff --git a/src/server/states.rs b/src/server/states.rs new file mode 100644 index 0000000..30f9846 --- /dev/null +++ b/src/server/states.rs @@ -0,0 +1,585 @@ +//! The four states a request ages through, and when a server itself is gone. +//! +//! `docs/decisions/0007-a-slow-server-and-a-server-that-is-gone.md` is the +//! record and #44 is the issue. 0007 decides that slow and absent are separate +//! conditions with separate recoveries, so the core reports four states rather +//! than one pending state, reports them progressively as a request ages rather +//! than once at the end, and never makes a caller wait for the network to learn +//! what the cache could already have given it. +//! +//! # What is here, and what is deliberately not +//! +//! What is here is everything of that two readings of one clock and a counter +//! settle: when a request has been outstanding long enough to be called late, +//! when it is abandoned, that each of those is said once and never unsaid, which +//! transport outcomes are evidence the server is absent, and how many +//! abandonments with no success between them make a server unreachable rather +//! than a request unlucky. +//! +//! WHAT IS NOT HERE IS THE REQUEST. Nothing in this tree opens a connection, for +//! the reason [`super::transport`] gives about itself, so nothing here starts a +//! request, waits on one, cancels one or hears an answer. This module holds the +//! states such a loop would report from. #44's own condition drives the fake +//! server at several delays and asserts the sequence, and none of it is met by +//! anything here. +//! +//! WHAT IS ALSO NOT HERE IS THE CACHED ANSWER EACH REPORT CARRIES. 0007 has the +//! report at nought carry whether a cached answer exists and its age, and the +//! `late` and `abandoned` reports carry the same. That age is 0043's, the read +//! that produces it is #43, and a field here holding a number nothing can +//! compute would be a shape rather than a fact. The states are what a report is +//! built around and the payload is the caller's to attach. +//! +//! WHAT IS ALSO NOT HERE IS THE RECOVERY. What happens after a server is +//! declared unreachable is 0045 and is in [`super::recovery`], which is the +//! schedule this module's last state hands over to. Nothing here probes, and +//! nothing here reports a server answering again. +//! +//! # Why the abandonment count is here rather than beside the retry policy +//! +//! [`super::retry`] says of itself that the count is not there: 0038 decides +//! that a call spending all three attempts is one abandonment rather than three, +//! because 0007 declares a server unreachable after two consecutive ones, and +//! that module records the decision and enforces nothing, so a loop charging +//! three would pass every check in it. The count is 0007's rather than 0038's - +//! it is about the server rather than about a call - and +//! [`ConsecutiveAbandonments`] is where charging it wrongly now fails a case. +//! +//! What that still does not reach is the caller. Nothing in this tree runs a +//! call, so nothing charges this counter, and a loop that charged three per call +//! would fail nothing here either. What has moved is that the rule has a subject +//! and a near miss instead of only a paragraph. +//! +//! # Every number here is chosen and none is measured +//! +//! 0007 says so of its own thresholds, in the same words 0038 uses for its waits +//! and 0045 for its three. #65 is the harness that would replace a choice with a +//! number and #62 is where the budget the 400 ms is divided out of is +//! established, so until both exist a reader should take each constant below as +//! an argument rather than as a measurement. + +use core::time::Duration; + +use super::transport::A_CALL_IS_ABANDONED_AFTER; +use crate::clock::SteadyInstant; +use crate::failure::TransportOutcome; + +/// How long a request may be outstanding before it is late. +/// +/// From 0007, and derived rather than chosen for its shape. #62 publishes 1.2 +/// seconds from a cold start to the first usable tile, and a client that is +/// going to commit to what the cache already gave it needs that decision made +/// with enough of the budget left to read, decode and draw. The core cannot know +/// how long a draw takes on a television, so it takes one third of the budget +/// for its own attempt and leaves two thirds, and one third of 1.2 seconds is +/// 400 ms. +/// +/// THE DERIVATION IS THE PART THAT MATTERS AND IT IS NOT VISIBLE FROM THE +/// NUMBER. 400 ms is defensible only while the budget it was divided out of is +/// 1.2 seconds; a record says so where a constant cannot, and this is the +/// sentence that carries it to the constant. +pub const A_REQUEST_IS_LATE_AFTER: Duration = Duration::from_millis(400); + +/// How many abandonments with no success between them make the server +/// unreachable rather than the request unlucky. +/// +/// From 0007. One abandonment is a fact about a request and two consecutive ones +/// are a fact about the server. At five seconds each that is ten seconds before +/// the core says what somebody watching the screen worked out earlier, and a +/// third would make it fifteen: two is where the accounting stops being useful +/// and starts being ceremony. +pub const ABANDONMENTS_BEFORE_A_SERVER_IS_GONE: u32 = 2; + +/// One of 0007's four states. +/// +/// Four and no fifth. A fifth is a change to that record rather than a variant +/// added here, because each of these is a different thing for a client to draw +/// and the record argues each one against the two beside it. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum State { + /// An answer arrived from the server and is current. + Fresh, + /// A request is still outstanding and has passed the point at which waiting + /// for it can still meet the published budget. THE REQUEST HAS NOT FAILED, + /// and a client that draws this as an error shows a person a spinner and + /// then an error for a request that was about to succeed. + Late, + /// The core stopped waiting for that request. It says nothing about the + /// server beyond that one request, and a client that reads it as a server + /// being gone tells a person their server is down because one endpoint was + /// slow. + Abandoned, + /// The server rather than one request. Nothing is attempted against it until + /// 0045's schedule says otherwise. + Unreachable, +} + +impl State { + /// Every state, so a caller reads the set out of the crate rather than + /// keeping a copy of it. + #[must_use] + pub const fn all() -> &'static [Self] { + &[Self::Fresh, Self::Late, Self::Abandoned, Self::Unreachable] + } + + /// The name this state is reported as. + /// + /// It is what a report carries rather than the text a debug printing would + /// produce, for the reason 0100 gives: a name that changed when somebody + /// renamed a variant would change what every client's report says. + #[must_use] + pub const fn declared_name(self) -> &'static str { + match self { + Self::Fresh => "fresh", + Self::Late => "late", + Self::Abandoned => "abandoned", + Self::Unreachable => "unreachable", + } + } +} + +/// What a reading of the clock says a caller should report now. +/// +/// It is the CHANGE rather than the state, which is what "progressively" means: +/// a loop that asked twice inside the same stage would report twice, and a +/// client that received `late` a second time has no way to tell it from a second +/// request going late. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum WhatToReport { + /// Nothing has changed since the last reading. + Nothing, + /// The request has passed 400 ms and is still outstanding. + Late, + /// The request has passed the call deadline and the core has stopped waiting. + Abandoned, +} + +/// One outstanding request, as it ages. +/// +/// It holds the moment the request began and what has already been said about +/// it, and answers what a reading of the clock has newly made true. It performs +/// nothing: abandoning a request is the caller's act, and this says when 0007 +/// requires it. +/// +/// The clock is the steady one, from 0102's table by way of +/// [`super::transport::CallDeadline`], which measures the same call against the +/// same reading. An interval inside one call is exactly what that clock is for, +/// and a wall clock here would make a request late because somebody corrected +/// the time. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. It is not +/// shared between threads by anything here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgingRequest { + began: SteadyInstant, + said: WhatHasBeenSaid, +} + +/// How far through 0007's sequence one request has been reported. +/// +/// It is private and ordered, and both are the mechanism: the only transition a +/// reading can make is forward, so a state cannot be unsaid and cannot be said +/// twice. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum WhatHasBeenSaid { + OnlyThatItStarted, + ThatItIsLate, + ThatItWasAbandoned, +} + +impl AgingRequest { + /// A request that has just been made. + /// + /// 0007's report at nought - which session it belongs to, whether a cached + /// answer exists and its age - is the caller's and is made before any byte + /// leaves the machine. This type begins after it, holding only the moment. + #[must_use] + pub const fn began_at(began: SteadyInstant) -> Self { + Self { + began, + said: WhatHasBeenSaid::OnlyThatItStarted, + } + } + + /// How long the request has been outstanding. + #[must_use] + pub fn age_at(self, now: SteadyInstant) -> Duration { + now.interval_since(self.began) + } + + /// What this reading has newly made true, recording that it was said. + /// + /// The thresholds are reached at rather than passed: a reading exactly at + /// 400 ms is late, and one exactly at the call deadline is abandoned, which + /// is the boundary [`super::transport::CallDeadline::passed_at`] takes for + /// the same deadline. + /// + /// A READING PAST THE DEADLINE THAT NEVER SAW 400 MS REPORTS THE + /// ABANDONMENT ALONE, and that is a decision rather than an oversight. The + /// `late` report exists so a client can commit to a cached answer with + /// enough budget left to draw it; five seconds later that budget is spent, + /// and emitting it then would tell a client something is about to be true + /// which stopped being true four and a half seconds ago. What it costs is a + /// client that counts reports rather than reading them, and 0007 already + /// says none of these is a sentence for a person. + pub fn what_to_report_at(&mut self, now: SteadyInstant) -> WhatToReport { + let age = self.age_at(now); + + if age >= A_CALL_IS_ABANDONED_AFTER { + if self.said < WhatHasBeenSaid::ThatItWasAbandoned { + self.said = WhatHasBeenSaid::ThatItWasAbandoned; + return WhatToReport::Abandoned; + } + return WhatToReport::Nothing; + } + + if age >= A_REQUEST_IS_LATE_AFTER && self.said < WhatHasBeenSaid::ThatItIsLate { + self.said = WhatHasBeenSaid::ThatItIsLate; + return WhatToReport::Late; + } + + WhatToReport::Nothing + } +} + +/// What one transport outcome says about the server, before any counting. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum WhatAnOutcomeSaysAboutTheServer { + /// The server is not there, and 0007 reports it at once with no threshold + /// because none of the three outcomes that reach this involved waiting. + ItIsNotThere, + /// Nothing. Whatever went wrong is about this request, or about a peer that + /// answered. + NothingAboutTheServer, +} + +/// Whether an outcome is evidence the server is absent. +/// +/// Three outcomes are, and none of them needs a threshold: the name did not +/// resolve, the connection was refused, or the network could not be reached. +/// +/// A CERTIFICATE THE CORE WILL NOT ACCEPT IS NOT ONE OF THEM, and that is the +/// near miss 0007 names. It is a server that answered, it is 0029's outcome with +/// its own identity in 0004, and reporting it as unreachable sends a person +/// looking for a network problem that does not exist. A body that dropped or a +/// deadline that expired are not evidence either: the first is a server that was +/// answering and the second is what the count below is for. +#[must_use] +pub const fn what_an_outcome_says( + outcome: &TransportOutcome<'_>, +) -> WhatAnOutcomeSaysAboutTheServer { + match *outcome { + TransportOutcome::NameDidNotResolve + | TransportOutcome::ConnectionRefused + | TransportOutcome::NetworkUnreachable => WhatAnOutcomeSaysAboutTheServer::ItIsNotThere, + TransportOutcome::ConnectionDroppedMidBody + | TransportOutcome::DeadlineReached { .. } + | TransportOutcome::AnswerStalledMidBody { .. } + | TransportOutcome::PeerNotTrusted { .. } => { + WhatAnOutcomeSaysAboutTheServer::NothingAboutTheServer + } + } +} + +/// Abandonments against one server with no success between them. +/// +/// One counter per server, held by whatever holds a server. Nothing in this tree +/// holds one, which is the module documentation's point about what this does not +/// reach. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. It is not +/// shared between threads by anything here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ConsecutiveAbandonments { + counted: u32, +} + +impl ConsecutiveAbandonments { + /// A server nothing has been abandoned against. + #[must_use] + pub const fn none() -> Self { + Self { counted: 0 } + } + + /// How many are standing. + #[must_use] + pub const fn counted(self) -> u32 { + self.counted + } + + /// Charges one abandonment and answers whether the server is now gone. + /// + /// ONE CALL IS ONE ABANDONMENT HOWEVER MANY ATTEMPTS IT SPENT. 0038 decides + /// that, and this signature is where it is expressed: there is no argument + /// for a number of attempts, so a caller cannot charge three for a call that + /// used its three. + /// + /// The count saturates rather than wrapping. A third and a tenth are the + /// same state as the second, and an unreachable server that keeps being + /// asked is 0045's schedule rather than this counter's problem. + pub const fn abandoned(&mut self) -> State { + self.counted = self.counted.saturating_add(1); + if self.counted >= ABANDONMENTS_BEFORE_A_SERVER_IS_GONE { + State::Unreachable + } else { + State::Abandoned + } + } + + /// Records that the server answered, which resets the count. + /// + /// A server that answered is not gone, and 0007 says so as the reason rather + /// than as a consequence: the count is of CONSECUTIVE abandonments, so one + /// success anywhere in the sequence ends the run. + pub const fn answered(&mut self) { + self.counted = 0; + } + + /// Records evidence the server is absent, which is unreachable at once. + /// + /// The count is left where it is rather than being raised to the threshold. + /// What made the server unreachable is the evidence and not the accounting, + /// and a counter moved by something that did not wait would make the next + /// abandonment after a recovery the second of a pair that never happened. + #[must_use] + pub const fn evidence_of_absence(self) -> State { + State::Unreachable + } +} + +#[cfg(test)] +mod tests { + //! 0007's thresholds and its counter, asked of the values. + //! + //! Every case moves a clock rather than waiting on one, which is what 0102 + //! requires of the suite and what makes a five second threshold cost + //! microseconds. What these cannot ask is #44's own condition, which drives + //! the fake server at several delays; nothing in this tree makes a request + //! for a delay to answer. + + use super::{ + A_REQUEST_IS_LATE_AFTER, ABANDONMENTS_BEFORE_A_SERVER_IS_GONE, AgingRequest, + ConsecutiveAbandonments, State, WhatAnOutcomeSaysAboutTheServer, WhatToReport, + what_an_outcome_says, + }; + use crate::clock::SteadyInstant; + use crate::failure::{CertificateReason, Deadline, TransportOutcome}; + use crate::server::transport::A_CALL_IS_ABANDONED_AFTER; + use core::time::Duration; + + /// A reading of the steady clock, from whole milliseconds. + fn at(millis: u64) -> SteadyInstant { + SteadyInstant::from_nanos(millis * 1_000_000) + } + + fn began() -> AgingRequest { + AgingRequest::began_at(at(0)) + } + + /// The two thresholds are the record's, and the second is read out of the + /// transport rather than written twice: two numbers that are meant to agree + /// are two numbers that will not. + #[test] + fn the_thresholds_are_the_ones_0007_states() { + assert_eq!(A_REQUEST_IS_LATE_AFTER, Duration::from_millis(400)); + assert_eq!(A_CALL_IS_ABANDONED_AFTER, Duration::from_secs(5)); + assert_eq!(ABANDONMENTS_BEFORE_A_SERVER_IS_GONE, 2); + } + + /// The boundary itself rather than a value either side of it. The near miss + /// is a strict comparison, which leaves a request that is exactly at the + /// threshold reported as though nothing had happened. + #[test] + fn a_request_is_late_at_four_hundred_milliseconds_and_not_before() { + let mut request = began(); + assert_eq!(request.what_to_report_at(at(399)), WhatToReport::Nothing); + assert_eq!(request.what_to_report_at(at(400)), WhatToReport::Late); + } + + /// Said once. The near miss is a loop polling every frame, which would + /// report `late` sixty times a second and give a client no way to tell one + /// slow request from sixty. + #[test] + fn late_is_reported_once_however_often_the_clock_is_read() { + let mut request = began(); + assert_eq!(request.what_to_report_at(at(400)), WhatToReport::Late); + for reading in [401, 500, 1_200, 4_999] { + assert_eq!( + request.what_to_report_at(at(reading)), + WhatToReport::Nothing, + "late was reported a second time at {reading} ms" + ); + } + } + + /// The whole sequence for a request that is watched throughout, which is + /// what #44 calls the exact sequence of stages. + #[test] + fn a_watched_request_reports_late_then_abandoned_and_then_nothing() { + let mut request = began(); + let reported: Vec = [0, 399, 400, 1_200, 4_999, 5_000, 5_001, 60_000] + .into_iter() + .map(|millis| request.what_to_report_at(at(millis))) + .collect(); + + assert_eq!( + reported, + vec![ + WhatToReport::Nothing, + WhatToReport::Nothing, + WhatToReport::Late, + WhatToReport::Nothing, + WhatToReport::Nothing, + WhatToReport::Abandoned, + WhatToReport::Nothing, + WhatToReport::Nothing, + ] + ); + } + + /// A request nobody looked at until the deadline had passed reports the + /// abandonment alone. The reason is on `what_to_report_at`. + #[test] + fn a_request_first_read_past_the_deadline_does_not_report_a_late_that_is_over() { + let mut request = began(); + assert_eq!( + request.what_to_report_at(at(5_000)), + WhatToReport::Abandoned + ); + assert_eq!(request.what_to_report_at(at(5_400)), WhatToReport::Nothing); + } + + /// Never backwards. A clock that answered with an earlier reading than the + /// last one - which 0102 says the steady clock does not do, and which a + /// caller can still hand in - unsays nothing. + #[test] + fn a_reading_that_went_backwards_unsays_nothing() { + let mut request = began(); + assert_eq!( + request.what_to_report_at(at(5_000)), + WhatToReport::Abandoned + ); + assert_eq!(request.what_to_report_at(at(10)), WhatToReport::Nothing); + } + + /// The age is the interval since the request began, which is what every + /// report 0007 describes carries beside its state. + #[test] + fn the_age_is_the_interval_since_the_request_began() { + let request = AgingRequest::began_at(at(1_000)); + assert_eq!(request.age_at(at(1_450)), Duration::from_millis(450)); + assert_eq!(request.age_at(at(999)), Duration::ZERO); + } + + /// The three outcomes that are evidence, and the four that are not. The near + /// miss is the certificate, which 0007 names because it is a server that + /// answered. + #[test] + fn only_the_three_outcomes_that_never_reached_a_server_are_evidence_of_absence() { + for outcome in [ + TransportOutcome::NameDidNotResolve, + TransportOutcome::ConnectionRefused, + TransportOutcome::NetworkUnreachable, + ] { + assert_eq!( + what_an_outcome_says(&outcome), + WhatAnOutcomeSaysAboutTheServer::ItIsNotThere, + "{outcome:?} is one of 0007's three and was not read as one" + ); + } + + for outcome in [ + TransportOutcome::ConnectionDroppedMidBody, + TransportOutcome::DeadlineReached { + deadline: Deadline::WholeRequest, + elapsed: Duration::from_secs(5), + }, + TransportOutcome::AnswerStalledMidBody { + deadline: Deadline::WholeRequest, + elapsed: Duration::from_secs(5), + }, + TransportOutcome::PeerNotTrusted { + reason: CertificateReason::SelfSigned, + fingerprint: "a-fingerprint", + }, + ] { + assert_eq!( + what_an_outcome_says(&outcome), + WhatAnOutcomeSaysAboutTheServer::NothingAboutTheServer, + "{outcome:?} was read as evidence that the server is absent" + ); + } + } + + /// One abandonment is about the request and the second is about the server. + #[test] + fn the_second_consecutive_abandonment_is_the_server_and_the_first_is_not() { + let mut against = ConsecutiveAbandonments::none(); + assert_eq!(against.abandoned(), State::Abandoned); + assert_eq!(against.counted(), 1); + assert_eq!(against.abandoned(), State::Unreachable); + assert_eq!(against.counted(), 2); + } + + /// A success anywhere in the run ends it. The near miss is a counter that + /// only ever rises, which declares a server gone on two abandonments a week + /// apart with a thousand successful calls in between. + #[test] + fn a_success_resets_the_run() { + let mut against = ConsecutiveAbandonments::none(); + assert_eq!(against.abandoned(), State::Abandoned); + against.answered(); + assert_eq!(against.counted(), 0); + assert_eq!( + against.abandoned(), + State::Abandoned, + "an abandonment after a success was charged as the second of a pair" + ); + } + + /// Past the threshold the answer does not change and the count does not + /// wrap. + #[test] + fn a_server_already_gone_stays_gone_and_the_count_saturates() { + let mut against = ConsecutiveAbandonments::none(); + for _ in 0..4 { + let _ = against.abandoned(); + } + assert_eq!(against.counted(), 4); + assert_eq!(against.abandoned(), State::Unreachable); + + let mut high = ConsecutiveAbandonments::none(); + for _ in 0..3 { + let _ = high.abandoned(); + } + assert!(high.counted() >= ABANDONMENTS_BEFORE_A_SERVER_IS_GONE); + } + + /// Evidence of absence needs no count and moves none. The near miss is a + /// counter raised to the threshold by something that did not wait, which + /// makes the first abandonment after a recovery the second of a pair that + /// never happened. + #[test] + fn evidence_of_absence_is_unreachable_at_once_and_leaves_the_count_alone() { + let against = ConsecutiveAbandonments::none(); + assert_eq!(against.evidence_of_absence(), State::Unreachable); + assert_eq!(against.counted(), 0); + } + + /// The names are what a report carries, so they are asked for rather than + /// assumed from the variant. + #[test] + fn every_state_has_its_own_declared_name() { + let mut names: Vec<&str> = State::all().iter().map(|s| s.declared_name()).collect(); + assert_eq!(names, vec!["fresh", "late", "abandoned", "unreachable"]); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), State::all().len()); + } +} diff --git a/tests/thread_statements.rs b/tests/thread_statements.rs index d37668a..f160514 100644 --- a/tests/thread_statements.rs +++ b/tests/thread_statements.rs @@ -55,6 +55,9 @@ use flowfin_core::server::retry::{ Attempts, Jitter, TheWait, WhatAFailureDoes, WhatTheCallDoesNext, WhatTheRequestDoes, WhyTheCallStopped, }; +use flowfin_core::server::states::{ + AgingRequest, ConsecutiveAbandonments, State, WhatAnOutcomeSaysAboutTheServer, WhatToReport, +}; use flowfin_core::server::write_queue::{ Dropped, Entry, Target, WhatIsAsserted, WhatTheEnqueueDid, WriteQueue, }; @@ -145,6 +148,15 @@ fn an_unusable_address_is_safe_from_any_thread() { const _: () = any_thread::(); } +#[test] +fn the_four_states_a_request_ages_through_are_safe_from_any_thread() { + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); + const _: () = any_thread::(); +} + #[test] fn the_federation_register_is_safe_from_any_thread() { const _: () = any_thread::>();