use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::Arc;
const CLOSED: u8 = 0;
const OPEN: u8 = 1;
const HALF_OPEN: u8 = 2;
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn emit_circuit_gauge(uri: &http::Uri, state: u8) {
let host = uri.authority().map_or_else(String::new, |a| a.to_string());
let v = match state {
CLOSED => 0.0,
OPEN => 1.0,
_ => 2.0,
};
metrics::gauge!("ferryman_circuit_state", "upstream" => host).set(v);
}
#[derive(Clone)]
pub struct Upstream {
pub uri: http::Uri,
state: Arc<AtomicU8>,
last_transition_unix: Arc<AtomicU64>,
pub cooldown_secs: u64,
}
impl Upstream {
pub fn new(uri: http::Uri, cooldown_secs: u64) -> Self {
Self {
uri,
state: Arc::new(AtomicU8::new(CLOSED)),
last_transition_unix: Arc::new(AtomicU64::new(0)),
cooldown_secs,
}
}
pub fn is_routable(&self) -> bool {
let state = self.state.load(Ordering::Acquire);
if state == CLOSED {
return true;
}
let stamped = self.last_transition_unix.load(Ordering::Acquire);
let now = now_secs();
if now.saturating_sub(stamped) < self.cooldown_secs {
return false;
}
if self
.last_transition_unix
.compare_exchange(stamped, now, Ordering::AcqRel, Ordering::Relaxed)
.is_err()
{
return false;
}
if self
.state
.compare_exchange(OPEN, HALF_OPEN, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
emit_circuit_gauge(&self.uri, HALF_OPEN);
}
true
}
pub fn mark_failed(&self) {
self.last_transition_unix
.store(now_secs(), Ordering::Release);
if self.state.swap(OPEN, Ordering::AcqRel) != OPEN {
emit_circuit_gauge(&self.uri, OPEN);
}
}
pub fn mark_success(&self) {
if self.state.swap(CLOSED, Ordering::AcqRel) != CLOSED {
emit_circuit_gauge(&self.uri, CLOSED);
}
}
}
fn matches_prefix(path: &str, prefix: &str) -> bool {
if prefix == "/" {
return true;
}
if !path.starts_with(prefix) {
return false;
}
if prefix.ends_with('/') {
return true;
}
matches!(path.as_bytes().get(prefix.len()), None | Some(b'/'))
}
pub struct RouteTable {
pub rules: Vec<(String, Upstream)>,
}
impl RouteTable {
pub fn new(mut rules: Vec<(String, Upstream)>) -> Self {
rules.sort_by_key(|(prefix, _)| std::cmp::Reverse(prefix.len()));
Self { rules }
}
pub fn lookup(&self, path: &str) -> Option<&Upstream> {
self.rules
.iter()
.find(|(prefix, _)| matches_prefix(path, prefix))
.map(|(_, up)| up)
.filter(|up| up.is_routable())
}
pub fn inherit_breakers(&mut self, old: &RouteTable) {
for (prefix, up) in &mut self.rules {
if let Some((_, prev)) = old.rules.iter().find(|(p, prev)| {
p == prefix && prev.uri == up.uri && prev.cooldown_secs == up.cooldown_secs
}) {
*up = prev.clone();
}
}
}
pub fn has_prefix(&self, path: &str) -> bool {
self.rules
.iter()
.any(|(prefix, _)| matches_prefix(path, prefix))
}
}
pub type SharedTable = Arc<arc_swap::ArcSwap<RouteTable>>;
#[cfg(test)]
mod tests {
use super::*;
fn upstream(cooldown_secs: u64) -> Upstream {
Upstream::new("http://localhost:8001".parse().unwrap(), cooldown_secs)
}
fn force_cooldown_elapsed(up: &Upstream) {
up.last_transition_unix.store(0, Ordering::Relaxed);
}
#[test]
fn segment_boundary_matching() {
assert!(matches_prefix("/svc-a", "/svc-a"));
assert!(matches_prefix("/svc-a/foo", "/svc-a"));
assert!(!matches_prefix("/svc-abc", "/svc-a"));
assert!(matches_prefix("/svc-a/foo", "/svc-a/"));
}
#[test]
fn root_prefix_is_catch_all() {
assert!(matches_prefix("/anything/at/all", "/"));
assert!(matches_prefix("", "/"));
}
#[test]
fn longest_prefix_wins() {
let root: http::Uri = "http://localhost:8000".parse().unwrap();
let svc_a: http::Uri = "http://localhost:8001".parse().unwrap();
let svc_a_v2: http::Uri = "http://localhost:8002".parse().unwrap();
let table = RouteTable::new(vec![
("/".to_string(), Upstream::new(root, 0)),
("/svc-a".to_string(), Upstream::new(svc_a.clone(), 0)),
("/svc-a/v2".to_string(), Upstream::new(svc_a_v2.clone(), 0)),
]);
assert_eq!(table.lookup("/svc-a/v2/x").unwrap().uri, svc_a_v2);
assert_eq!(table.lookup("/svc-a/other").unwrap().uri, svc_a);
}
#[test]
fn open_breaker_does_not_fall_through_to_shorter_prefix() {
let specific = upstream(30);
let catch_all = upstream(30);
let table = RouteTable::new(vec![
("/".to_string(), catch_all),
("/svc-a".to_string(), specific.clone()),
]);
specific.mark_failed();
assert!(table.lookup("/svc-a/x").is_none());
assert!(table.has_prefix("/svc-a/x"));
assert!(table.lookup("/other").is_some());
}
#[test]
fn reload_inherits_breaker_state_for_unchanged_rules() {
let a = upstream(30);
let old = RouteTable::new(vec![("/svc-a".to_string(), a.clone())]);
a.mark_failed();
let mut same = RouteTable::new(vec![("/svc-a".to_string(), upstream(30))]);
same.inherit_breakers(&old);
assert!(same.lookup("/svc-a").is_none(), "breaker stays open");
let mut moved = RouteTable::new(vec![(
"/svc-a".to_string(),
Upstream::new("http://localhost:9999".parse().unwrap(), 30),
)]);
moved.inherit_breakers(&old);
assert!(
moved.lookup("/svc-a").is_some(),
"new upstream starts closed"
);
}
#[test]
fn closed_is_routable() {
let up = upstream(30);
assert!(up.is_routable());
}
#[test]
fn open_not_routable_before_cooldown() {
let up = upstream(30);
up.mark_failed();
assert!(!up.is_routable());
}
#[test]
fn single_probe_under_half_open() {
let up = upstream(30);
up.mark_failed();
force_cooldown_elapsed(&up);
assert!(up.is_routable());
assert!(!up.is_routable());
}
#[test]
fn probe_success_closes_breaker() {
let up = upstream(30);
up.mark_failed();
force_cooldown_elapsed(&up);
assert!(up.is_routable()); up.mark_success();
assert!(up.is_routable()); assert!(up.is_routable());
}
#[test]
fn probe_failure_reopens_breaker() {
let up = upstream(30);
up.mark_failed();
force_cooldown_elapsed(&up);
assert!(up.is_routable()); up.mark_failed(); assert!(!up.is_routable()); }
#[test]
fn concurrent_callers_admit_exactly_one_probe() {
for _ in 0..200 {
let up = upstream(30);
up.mark_failed();
force_cooldown_elapsed(&up);
let barrier = Arc::new(std::sync::Barrier::new(8));
let admitted: usize = (0..8)
.map(|_| {
let (up, barrier) = (up.clone(), barrier.clone());
std::thread::spawn(move || {
barrier.wait();
up.is_routable() as usize
})
})
.collect::<Vec<_>>()
.into_iter()
.map(|h| h.join().unwrap())
.sum();
assert_eq!(admitted, 1);
}
}
#[test]
fn stale_half_open_reprobes() {
let up = upstream(30);
up.mark_failed();
force_cooldown_elapsed(&up);
assert!(up.is_routable()); assert!(!up.is_routable());
force_cooldown_elapsed(&up);
assert!(up.is_routable()); }
}