use std::fmt;
use std::time::Duration;
use super::census::PoolCensus;
const TERMINAL_PREFIX: &str = "terminal:";
const RETRYABLE_PREFIX: &str = "retryable:";
const UNAVAILABLE_TAG: &str = "WORKER_UNAVAILABLE";
const TAIL_SEPARATOR: char = '—';
const ABSENT: &str = "none";
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum QueueServiceReason {
NoQueueDeclaration,
NoLivePollers,
PollersIncompatible,
Saturated,
}
impl QueueServiceReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NoQueueDeclaration => "NO_QUEUE_DECLARATION",
Self::NoLivePollers => "NO_LIVE_POLLERS",
Self::PollersIncompatible => "POLLERS_INCOMPATIBLE",
Self::Saturated => "SATURATED",
}
}
#[must_use]
pub const fn is_structural(self) -> bool {
matches!(self, Self::NoQueueDeclaration)
}
#[must_use]
pub fn explain(self, address: &ServiceAddress) -> String {
let ServiceAddress {
namespace,
task_queue,
activity_type,
node,
} = address;
let node = node
.as_ref()
.map_or_else(String::new, |node| format!(" pinned to node `{node}`"));
match self {
Self::NoQueueDeclaration => format!(
"no deployed contract declares task queue `{task_queue}`, \
so activity `{activity_type}` in namespace `{namespace}` \
can never be served{node}"
),
Self::NoLivePollers => format!(
"no worker is connected for `{namespace}`/`{task_queue}`, \
so activity `{activity_type}` is unserved{node}"
),
Self::PollersIncompatible => format!(
"workers are connected for `{namespace}`/`{task_queue}` but \
none serves activity `{activity_type}`{node}"
),
Self::Saturated => format!(
"a worker serving `{namespace}`/`{task_queue}` activity \
`{activity_type}`{node} would not accept the task before the \
schedule-to-start timeout"
),
}
}
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
[
Self::NoQueueDeclaration,
Self::NoLivePollers,
Self::PollersIncompatible,
Self::Saturated,
]
.into_iter()
.find(|reason| reason.as_str() == text)
}
}
impl fmt::Display for QueueServiceReason {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ExpiredClock {
ServiceAvailability,
ScheduleToStart,
}
impl ExpiredClock {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ServiceAvailability => "service_availability",
Self::ScheduleToStart => "schedule_to_start",
}
}
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
[Self::ServiceAvailability, Self::ScheduleToStart]
.into_iter()
.find(|clock| clock.as_str() == text)
}
}
impl fmt::Display for ExpiredClock {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ServiceAddress {
pub namespace: String,
pub task_queue: String,
pub activity_type: String,
pub node: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkerUnavailable {
pub reason: QueueServiceReason,
pub clock: Option<ExpiredClock>,
pub waited: Duration,
pub address: ServiceAddress,
pub census: PoolCensus,
}
impl WorkerUnavailable {
#[must_use]
pub const fn is_retryable(&self) -> bool {
!self.reason.is_structural()
}
#[must_use]
pub fn reason_string(&self) -> String {
self.to_string()
}
#[must_use]
pub fn parse(failure: &str) -> Option<UnavailableSummary> {
let body = failure
.strip_prefix(TERMINAL_PREFIX)
.or_else(|| failure.strip_prefix(RETRYABLE_PREFIX))?;
let body = body.strip_prefix(UNAVAILABLE_TAG)?;
let head = body.split(TAIL_SEPARATOR).next().unwrap_or(body);
let mut reason = None;
let mut clock = None;
let mut waited = Duration::ZERO;
let mut last_compatible_poller_age = None;
for token in head.split_whitespace() {
let Some((key, value)) = token.split_once('=') else {
continue;
};
match key {
"reason" => reason = QueueServiceReason::parse(value),
"clock" => clock = ExpiredClock::parse(value),
"waited_ms" => waited = parse_millis(value).unwrap_or_default(),
"last_compatible_poller_age_ms" => {
last_compatible_poller_age = parse_millis(value);
}
_ => {}
}
}
Some(UnavailableSummary {
reason: reason?,
clock,
waited,
last_compatible_poller_age,
})
}
}
impl fmt::Display for WorkerUnavailable {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let prefix = if self.is_retryable() {
RETRYABLE_PREFIX
} else {
TERMINAL_PREFIX
};
let clock = self.clock.map_or(ABSENT, ExpiredClock::as_str);
let age = self
.census
.last_compatible_poller_age
.map_or_else(|| ABSENT.to_owned(), |age| millis(age).to_string());
write!(
formatter,
"{prefix}{UNAVAILABLE_TAG} reason={} clock={clock} waited_ms={} \
last_compatible_poller_age_ms={age} workers_in_pool={} \
workers_serving_activity={} compatible_workers={} \
{TAIL_SEPARATOR} {}",
self.reason,
millis(self.waited),
self.census.workers_in_pool,
self.census.workers_serving_activity,
self.census.compatible_workers,
self.explain(),
)
}
}
impl WorkerUnavailable {
fn explain(&self) -> String {
self.reason.explain(&self.address)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UnavailableSummary {
pub reason: QueueServiceReason,
pub clock: Option<ExpiredClock>,
pub waited: Duration,
pub last_compatible_poller_age: Option<Duration>,
}
pub(super) fn millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn parse_millis(value: &str) -> Option<Duration> {
value.parse::<u64>().ok().map(Duration::from_millis)
}
#[cfg(test)]
mod tests {
use super::*;
fn address() -> ServiceAddress {
ServiceAddress {
namespace: "default".to_owned(),
task_queue: "general".to_owned(),
activity_type: "greet".to_owned(),
node: None,
}
}
fn census() -> PoolCensus {
PoolCensus {
workers_in_pool: 0,
workers_serving_activity: 0,
compatible_workers: 0,
last_compatible_poller_age: Some(Duration::from_millis(1_500)),
}
}
#[test]
fn structural_refusal_is_terminal_and_carries_no_clock() {
let failure = WorkerUnavailable {
reason: QueueServiceReason::NoQueueDeclaration,
clock: None,
waited: Duration::ZERO,
address: address(),
census: PoolCensus::default(),
};
assert!(!failure.is_retryable());
let rendered = failure.reason_string();
assert!(rendered.starts_with("terminal:"), "{rendered}");
assert!(
rendered.contains("reason=NO_QUEUE_DECLARATION"),
"{rendered}"
);
assert!(rendered.contains("clock=none"), "{rendered}");
}
#[test]
fn fleet_refusals_are_retryable_and_name_the_expired_clock() {
let failure = WorkerUnavailable {
reason: QueueServiceReason::NoLivePollers,
clock: Some(ExpiredClock::ServiceAvailability),
waited: Duration::from_secs(2),
address: address(),
census: census(),
};
assert!(failure.is_retryable());
let rendered = failure.reason_string();
assert!(rendered.starts_with("retryable:"), "{rendered}");
assert!(
rendered.contains("clock=service_availability"),
"{rendered}"
);
assert!(rendered.contains("waited_ms=2000"), "{rendered}");
assert!(
rendered.contains("last_compatible_poller_age_ms=1500"),
"{rendered}"
);
}
#[test]
fn the_two_clocks_render_distinctly() {
let availability = ExpiredClock::ServiceAvailability.as_str();
let schedule_to_start = ExpiredClock::ScheduleToStart.as_str();
assert_ne!(availability, schedule_to_start);
assert_eq!(
ExpiredClock::parse(availability),
Some(ExpiredClock::ServiceAvailability)
);
assert_eq!(
ExpiredClock::parse(schedule_to_start),
Some(ExpiredClock::ScheduleToStart)
);
}
#[test]
fn a_refusal_round_trips_back_to_its_typed_head() {
let failure = WorkerUnavailable {
reason: QueueServiceReason::Saturated,
clock: Some(ExpiredClock::ScheduleToStart),
waited: Duration::from_millis(750),
address: ServiceAddress {
node: Some("n1".to_owned()),
..address()
},
census: census(),
};
let parsed = WorkerUnavailable::parse(&failure.reason_string());
assert_eq!(
parsed,
Some(UnavailableSummary {
reason: QueueServiceReason::Saturated,
clock: Some(ExpiredClock::ScheduleToStart),
waited: Duration::from_millis(750),
last_compatible_poller_age: Some(Duration::from_millis(1_500)),
})
);
}
#[test]
fn an_ordinary_failure_is_not_read_as_a_queue_service_refusal() {
assert_eq!(WorkerUnavailable::parse("lost:worker lost"), None);
assert_eq!(WorkerUnavailable::parse("terminal:boom"), None);
assert_eq!(WorkerUnavailable::parse("parked:server-draining"), None);
}
#[test]
fn every_reason_round_trips_through_its_canonical_spelling() {
for reason in [
QueueServiceReason::NoQueueDeclaration,
QueueServiceReason::NoLivePollers,
QueueServiceReason::PollersIncompatible,
QueueServiceReason::Saturated,
] {
assert_eq!(QueueServiceReason::parse(reason.as_str()), Some(reason));
}
}
}