mod channel;
mod file_append;
mod local_loop;
mod process;
mod respond;
mod sse;
mod webhook;
use std::borrow::Cow;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use bytes::Bytes;
use serde::Serialize;
use super::latency::LatencyEstimate;
use super::neuron::{Cx, Firing, Neuron, NeuronKind};
use super::signal::{Command, Signal};
use kuebiko::{render_family, render_labelled};
pub use channel::{ChannelOut, Delivery};
pub use file_append::FileAppend;
pub use local_loop::{ActRateLimit, Breaker, BreakerState, Retry};
use local_loop::{Admission, run};
pub use process::Process;
pub use respond::Respond;
pub use sse::SseOut;
pub use webhook::Webhook;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ActError {
Status(u16),
Timeout(String),
Unreachable(String),
Transient(String),
Fatal(String),
}
impl std::fmt::Display for ActError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Status(status) => write!(f, "status {status}"),
Self::Timeout(what) => write!(f, "{what}: timed out"),
Self::Unreachable(m) | Self::Transient(m) | Self::Fatal(m) => f.write_str(m),
}
}
}
impl std::error::Error for ActError {}
impl ActError {
pub fn retry(message: impl Into<String>) -> Self {
Self::Transient(message.into())
}
pub fn fatal(message: impl Into<String>) -> Self {
Self::Fatal(message.into())
}
pub fn retryable(&self) -> bool {
match self {
Self::Status(status) => *status == 429 || *status >= 500,
Self::Timeout(_) | Self::Unreachable(_) | Self::Transient(_) => true,
Self::Fatal(_) => false,
}
}
}
pub type ActFuture = Pin<Box<dyn Future<Output = Result<(), ActError>> + Send + 'static>>;
pub enum Action {
Done(Result<(), ActError>),
Pending(ActFuture),
}
impl Action {
pub fn ok() -> Self {
Self::Done(Ok(()))
}
pub fn pending(future: impl Future<Output = Result<(), ActError>> + Send + 'static) -> Self {
Self::Pending(Box::pin(future))
}
}
pub trait Act: Send + Sync + 'static {
fn name(&self) -> &str;
fn act(&self, command: &Command, signal: &Signal) -> Action;
fn prepare(&self, _runtime: &tokio::runtime::Handle) {}
fn describe(&self) -> serde_json::Value {
serde_json::Value::Null
}
fn events(&self) -> Option<tokio::sync::broadcast::Receiver<Bytes>> {
None
}
}
struct LoopState {
consecutive_failures: u32,
open_until: Option<Instant>,
trial: bool,
tokens: f64,
refilled_at: Instant,
}
#[derive(Default)]
struct LoopCounters {
acted: AtomicU64,
failed: AtomicU64,
retries: AtomicU64,
dropped_open: AtomicU64,
dropped_rate: AtomicU64,
quiet_refractory: AtomicU64,
inflight: AtomicU64,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct EffectorMetrics {
pub acted: u64,
pub failed: u64,
pub retries: u64,
pub dropped_open: u64,
pub dropped_rate: u64,
pub quiet_refractory: u64,
pub inflight: u64,
pub breaker: BreakerState,
pub latency_ms: super::latency::LatencySnapshot,
}
struct EffectorInner {
act: Box<dyn Act>,
plain: Command,
retry: Retry,
breaker: Breaker,
rate: Option<ActRateLimit>,
refractory: Option<Duration>,
epoch: Instant,
last_acted: AtomicU64,
state: Mutex<LoopState>,
counters: LoopCounters,
latency: LatencyEstimate,
}
#[derive(Clone)]
pub struct Effector {
inner: Arc<EffectorInner>,
}
impl Effector {
pub fn new(act: impl Act) -> Self {
let now = Instant::now();
let plain = Command::new(act.name(), serde_json::Value::Null);
Self {
inner: Arc::new(EffectorInner {
act: Box::new(act),
plain,
retry: Retry::default(),
breaker: Breaker::default(),
rate: None,
refractory: None,
epoch: now,
last_acted: AtomicU64::new(0),
state: Mutex::new(LoopState {
consecutive_failures: 0,
open_until: None,
trial: false,
tokens: 0.0,
refilled_at: now,
}),
counters: LoopCounters::default(),
latency: LatencyEstimate::new(),
}),
}
}
fn configure(mut self, f: impl FnOnce(&mut EffectorInner)) -> Self {
f(Arc::get_mut(&mut self.inner).expect("configured before use"));
self
}
pub fn retry(self, retry: Retry) -> Self {
self.configure(|i| i.retry = retry)
}
pub fn no_retry(self) -> Self {
self.retry(Retry {
attempts: 1,
..Retry::default()
})
}
pub fn breaker(self, breaker: Breaker) -> Self {
self.configure(|i| i.breaker = breaker)
}
pub fn rate_limit(self, rate: ActRateLimit) -> Self {
self.configure(|i| {
i.state.get_mut().unwrap_or_else(|e| e.into_inner()).tokens = f64::from(rate.burst);
i.rate = Some(rate);
})
}
pub fn refractory(self, period: Duration) -> Self {
self.configure(|i| i.refractory = Some(period))
}
pub fn metrics(&self) -> EffectorMetrics {
let c = &self.inner.counters;
let load = |a: &AtomicU64| a.load(Ordering::Relaxed);
let state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
let breaker = match state.open_until {
None => BreakerState::Closed,
Some(until) if Instant::now() < until => BreakerState::Open,
Some(_) => BreakerState::HalfOpen,
};
EffectorMetrics {
acted: load(&c.acted),
failed: load(&c.failed),
retries: load(&c.retries),
dropped_open: load(&c.dropped_open),
dropped_rate: load(&c.dropped_rate),
quiet_refractory: load(&c.quiet_refractory),
inflight: load(&c.inflight),
breaker,
latency_ms: self.inner.latency.snapshot(),
}
}
#[cfg(test)]
pub(crate) fn act(&self) -> &dyn Act {
&*self.inner.act
}
}
impl<A: Act> From<A> for Effector {
fn from(act: A) -> Self {
Self::new(act)
}
}
impl Neuron for Effector {
fn name(&self) -> &str {
self.inner.act.name()
}
fn kind(&self) -> NeuronKind {
NeuronKind::Effector
}
fn stimulate(&self, signal: Signal, cx: &Cx<'_>) -> Firing {
let inner = &self.inner;
let delay = match inner.admit(cx.now) {
Admission::After(delay) => delay,
Admission::Refractory | Admission::Open | Admission::RateLimited => {
return Firing::Quiet;
}
};
let command = match signal.payload.as_command() {
Some(command) => Cow::Borrowed(command),
None => match signal.payload.to_json() {
Some(args) => Cow::Owned(Command::new(self.name(), args)),
None => Cow::Borrowed(&inner.plain),
},
};
let started = cx.now;
let spawn = |pending: Option<ActFuture>, attempt: u32, delay: Duration| {
cx.efferent().spawn(run(
self.inner.clone(),
command.clone().into_owned(),
signal.clone(),
pending,
attempt,
started,
delay,
));
};
if !delay.is_zero() {
spawn(None, 0, delay);
return Firing::Fire(Vec::new());
}
match inner.act.act(&command, &signal) {
Action::Done(Ok(())) => inner.succeeded(started),
Action::Done(Err(error)) => {
if error.retryable() && inner.retry.attempts > 1 {
inner.counters.retries.fetch_add(1, Ordering::Relaxed);
spawn(None, 1, inner.backoff(1));
} else {
inner.failed(&error);
}
}
Action::Pending(future) => spawn(Some(future), 0, Duration::ZERO),
}
Firing::Fire(Vec::new())
}
fn prepare(&self, cx: &Cx<'_>) {
self.inner.act.prepare(cx.efferent());
}
fn describe(&self) -> serde_json::Value {
let inner = &self.inner;
serde_json::json!({
"retry": {
"attempts": inner.retry.attempts,
"backoff_ms": inner.retry.backoff.as_secs_f64() * 1e3,
},
"breaker": {
"failures": inner.breaker.failures,
"cooldown_ms": inner.breaker.cooldown.as_secs_f64() * 1e3,
},
"rate_limit": inner.rate.map(|r| serde_json::json!({
"per_second": r.per_second, "burst": r.burst,
})),
"refractory_ms": inner.refractory.map(|d| d.as_secs_f64() * 1e3),
"act": inner.act.describe(),
})
}
fn metrics(&self) -> serde_json::Value {
serde_json::to_value(Effector::metrics(self)).unwrap_or_default()
}
fn events(&self) -> Option<tokio::sync::broadcast::Receiver<Bytes>> {
self.inner.act.events()
}
fn render_prometheus(&self, out: &mut String, labels: &str) {
let m = Effector::metrics(self);
for (name, value) in [
("acted", m.acted),
("failed", m.failed),
("retries", m.retries),
("dropped_open", m.dropped_open),
("dropped_rate", m.dropped_rate),
("quiet_refractory", m.quiet_refractory),
] {
render_labelled(
out,
&format!("furnace_nerve_effector_{name}_total"),
labels,
value,
);
}
render_labelled(out, "furnace_nerve_effector_inflight", labels, m.inflight);
render_labelled(
out,
"furnace_nerve_effector_circuit_open",
labels,
u8::from(m.breaker != BreakerState::Closed),
);
}
}
pub(crate) fn render_prometheus_help(out: &mut String) {
for (name, help) in [
("acted_total", "Commands the effector carried out"),
("failed_total", "Commands that failed after their retries"),
("retries_total", "Retries"),
(
"dropped_open_total",
"Commands dropped while the circuit was open",
),
("dropped_rate_total", "Commands dropped by the rate limit"),
(
"quiet_refractory_total",
"Commands ignored in the refractory period",
),
] {
render_family(
out,
&format!("furnace_nerve_effector_{name}"),
"counter",
help,
);
}
render_family(
out,
"furnace_nerve_effector_inflight",
"gauge",
"Commands in progress",
);
render_family(
out,
"furnace_nerve_effector_circuit_open",
"gauge",
"1 while the effector's circuit breaker is open or half open",
);
}