1use std::collections::BTreeMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use serde_json::Value;
12
13use super::{
14 BusinessCalendar, MemoryObligationStore, Obligation, ObligationRecord, ObligationState,
15 ObligationStatus, ObligationStore, SlaError,
16};
17use crate::job::{JobInfo, JobUniqueness, JobUniquenessWindow};
18use crate::{AppState, AutumnResult};
19
20pub const CHECK_JOB: &str = "autumn_sla_check";
22
23pub const ESCALATE_JOB: &str = "autumn_sla_escalate";
25
26const ESCALATE_UNIQUE_MS: u64 = 24 * 60 * 60 * 1_000;
31
32const MAX_ATTEMPTS: u32 = 5;
34
35const BACKOFF_MS: u64 = 1_000;
37
38const RELEASE_ATTEMPTS: u32 = 5;
40
41const RELEASE_BACKOFF: std::time::Duration = std::time::Duration::from_millis(100);
43
44type BreachFuture = Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>;
45type BreachHandler = Arc<dyn Fn(AppState, SlaBreach) -> BreachFuture + Send + Sync>;
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[non_exhaustive]
50pub struct SlaBreach {
51 pub key: String,
53 pub obligation: String,
55 pub subject: String,
57 pub calendar: String,
59 pub zone: String,
61 pub generation: uuid::Uuid,
64 pub started_at: DateTime<Utc>,
66 pub due_at: DateTime<Utc>,
68 pub escalated_at: DateTime<Utc>,
70 pub token: uuid::Uuid,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
77struct CheckArgs {
78 key: String,
79 #[serde(default)]
82 generation: Option<uuid::Uuid>,
83 due_at: DateTime<Utc>,
84 #[serde(default)]
88 requeue: u32,
89 #[serde(default)]
93 revision: u64,
94}
95
96struct SlaEngine {
98 calendars: BTreeMap<String, BusinessCalendar>,
99 store: Arc<dyn ObligationStore>,
100 handlers: BTreeMap<String, BreachHandler>,
101 fallback: Option<BreachHandler>,
102}
103
104pub struct SlaPlugin {
109 calendars: BTreeMap<String, BusinessCalendar>,
110 store: Arc<dyn ObligationStore>,
111 handlers: BTreeMap<String, BreachHandler>,
112 fallback: Option<BreachHandler>,
113}
114
115impl std::fmt::Debug for SlaPlugin {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.debug_struct("SlaPlugin")
118 .field("calendars", &self.calendars.keys().collect::<Vec<_>>())
119 .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
120 .field("fallback", &self.fallback.is_some())
121 .finish_non_exhaustive()
122 }
123}
124
125impl Default for SlaPlugin {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131impl SlaPlugin {
132 #[must_use]
134 pub fn new() -> Self {
135 Self {
136 calendars: BTreeMap::new(),
137 store: Arc::new(MemoryObligationStore::new()),
138 handlers: BTreeMap::new(),
139 fallback: None,
140 }
141 }
142
143 #[must_use]
145 pub fn calendar(mut self, name: impl Into<String>, calendar: BusinessCalendar) -> Self {
146 self.calendars.insert(name.into(), calendar);
147 self
148 }
149
150 #[must_use]
152 pub fn store(mut self, store: impl ObligationStore) -> Self {
153 self.store = Arc::new(store);
154 self
155 }
156
157 #[must_use]
159 pub fn on_breach<F, Fut>(mut self, obligation: impl Into<String>, handler: F) -> Self
160 where
161 F: Fn(AppState, SlaBreach) -> Fut + Send + Sync + 'static,
162 Fut: Future<Output = AutumnResult<()>> + Send + 'static,
163 {
164 self.handlers.insert(obligation.into(), boxed(handler));
165 self
166 }
167
168 #[must_use]
170 pub fn on_any_breach<F, Fut>(mut self, handler: F) -> Self
171 where
172 F: Fn(AppState, SlaBreach) -> Fut + Send + Sync + 'static,
173 Fut: Future<Output = AutumnResult<()>> + Send + 'static,
174 {
175 self.fallback = Some(boxed(handler));
176 self
177 }
178}
179
180fn boxed<F, Fut>(handler: F) -> BreachHandler
181where
182 F: Fn(AppState, SlaBreach) -> Fut + Send + Sync + 'static,
183 Fut: Future<Output = AutumnResult<()>> + Send + 'static,
184{
185 Arc::new(move |state, breach| Box::pin(handler(state, breach)))
186}
187
188impl crate::plugin::Plugin for SlaPlugin {
189 fn build(self, app: crate::app::AppBuilder) -> crate::app::AppBuilder {
190 let engine = SlaEngine {
191 calendars: self.calendars,
192 store: self.store,
193 handlers: self.handlers,
194 fallback: self.fallback,
195 };
196 app.state_initializer(move |state| state.insert_extension(engine))
197 .jobs(vec![
198 sla_job(
199 CHECK_JOB,
200 check_job,
201 &["key", "generation", "due_at", "requeue", "revision"],
202 JobUniquenessWindow::Running,
203 ),
204 sla_job(
205 ESCALATE_JOB,
206 escalate_job,
207 &["key", "generation", "due_at"],
208 JobUniquenessWindow::TtlMs(ESCALATE_UNIQUE_MS),
209 ),
210 ])
211 }
212}
213
214fn sla_job(
215 name: &str,
216 handler: crate::job::JobHandler,
217 unique_by: &[&str],
218 window: JobUniquenessWindow,
219) -> JobInfo {
220 let mut info = JobInfo::new(name, MAX_ATTEMPTS, BACKOFF_MS, handler);
221 info.uniqueness = Some(JobUniqueness {
222 by: unique_by.iter().map(|field| (*field).to_owned()).collect(),
223 window,
224 });
225 info
226}
227
228#[derive(Clone)]
233pub struct Sla {
234 state: AppState,
235 engine: Arc<SlaEngine>,
236}
237
238impl std::fmt::Debug for Sla {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 f.debug_struct("Sla")
241 .field(
242 "calendars",
243 &self.engine.calendars.keys().collect::<Vec<_>>(),
244 )
245 .finish_non_exhaustive()
246 }
247}
248
249impl Sla {
250 pub fn from_state(state: &AppState) -> Result<Self, SlaError> {
256 let engine = state
257 .extension::<SlaEngine>()
258 .ok_or(SlaError::NotInstalled)?;
259 Ok(Self {
260 state: state.clone(),
261 engine,
262 })
263 }
264
265 #[must_use]
267 pub fn now(&self) -> DateTime<Utc> {
268 self.state.clock().now()
269 }
270
271 #[must_use]
273 pub fn calendar(&self, name: &str) -> Option<&BusinessCalendar> {
274 self.engine.calendars.get(name)
275 }
276
277 pub fn status(&self, obligation: &Obligation) -> Result<ObligationStatus, SlaError> {
283 let calendar = self.calendar_of(obligation)?;
284 let zone = self.zone_of(obligation, calendar);
285 Ok(obligation.status_with(calendar, zone, self.now()))
286 }
287
288 pub async fn track(&self, obligation: &Obligation) -> Result<ObligationStatus, SlaError> {
303 let now = self.now();
304 let calendar = self.calendar_of(obligation)?;
305 if obligation.budget().resolve(calendar).is_zero() {
306 return Err(SlaError::InvalidDuration(format!(
307 "{}: the budget is zero working time; set it with Obligation::within",
308 obligation.key()
309 )));
310 }
311 let zone = self.zone_of(obligation, calendar);
312 let resolved = obligation
313 .clone()
314 .zone(zone)
315 .starting_at(obligation.started_at().unwrap_or(now));
316 let key = resolved.key();
317 let Some(due_at) =
319 super::store::real_deadline(resolved.status_with(calendar, zone, now).due_at)
320 else {
321 return Err(SlaError::NoDeadline(key));
322 };
323 let store = &self.engine.store;
324 let generation = self.state.entropy().uuid_v4();
325 let (mut record, created) = store
326 .insert(ObligationRecord::new(resolved, generation).with_due_at(due_at))
327 .await?;
328 if !created && let Some(met) = obligation.met() {
330 store.mark_met(&key, record.generation, met).await?;
331 if let Some(fresh) = store.get(&key).await?
334 && fresh.generation == record.generation
335 {
336 record = fresh;
337 }
338 }
339 self.schedule(&key, &record, now).await
340 }
341
342 async fn schedule(
344 &self,
345 key: &str,
346 record: &ObligationRecord,
347 now: DateTime<Utc>,
348 ) -> Result<ObligationStatus, SlaError> {
349 let status = self.status_of(record, now)?;
350 let Some(due) = status.due_at else {
351 return Err(SlaError::NoDeadline(key.to_owned()));
352 };
353 if status.escalated_at.is_none() && status.state != ObligationState::Met {
354 self.schedule_check(key, record.generation, due, 0).await?;
355 }
356 Ok(status)
357 }
358
359 pub async fn meet(&self, key: &str) -> Result<Option<ObligationStatus>, SlaError> {
367 let store = &self.engine.store;
368 if let Some(record) = store.get(key).await? {
369 store.mark_met(key, record.generation, self.now()).await?;
370 }
371 self.get(key).await
372 }
373
374 pub async fn get(&self, key: &str) -> Result<Option<ObligationStatus>, SlaError> {
380 let now = self.now();
381 self.engine
382 .store
383 .get(key)
384 .await?
385 .map(|record| self.status_of(&record, now))
386 .transpose()
387 }
388
389 pub async fn statuses(&self) -> Result<Vec<ObligationStatus>, SlaError> {
395 let now = self.now();
396 self.engine
397 .store
398 .list()
399 .await?
400 .iter()
401 .map(|record| self.status_of(record, now))
402 .collect()
403 }
404
405 pub async fn reconcile(&self) -> Result<usize, SlaError> {
420 let now = self.now();
421 let mut scheduled = 0_usize;
422 let store = &self.engine.store;
423 for record in store.list().await? {
424 let status = self.calendar_status(&record, now)?;
426 if status.escalated_at.is_some() {
427 continue;
428 }
429 let due = super::store::real_deadline(status.due_at);
432 let stored = due.unwrap_or_else(super::store::no_deadline);
433 if record.due_at != Some(stored)
434 && !store
435 .set_due(&status.key, record.generation, stored)
436 .await?
437 {
438 continue;
440 }
441 let Some(due) = due else { continue };
442 if status.state == ObligationState::Met {
443 continue;
446 }
447 let revision = self.state.entropy().next_u64().max(1);
451 self.schedule_check(&status.key, record.generation, due, revision)
452 .await?;
453 scheduled = scheduled.saturating_add(1);
454 }
455 Ok(scheduled)
456 }
457
458 pub async fn forget(&self, key: &str) -> Result<bool, SlaError> {
464 self.engine.store.remove(key).await
465 }
466
467 fn calendar_of(&self, obligation: &Obligation) -> Result<&BusinessCalendar, SlaError> {
468 let name = obligation.calendar_name();
469 self.calendar(name)
470 .ok_or_else(|| SlaError::UnknownCalendar(name.to_owned()))
471 }
472
473 fn zone_of(&self, obligation: &Obligation, calendar: &BusinessCalendar) -> chrono_tz::Tz {
475 obligation
476 .time_zone()
477 .or_else(|| calendar.home_zone())
478 .unwrap_or_else(|| self.state.config_arc().time_zone.default_tz())
479 }
480
481 fn calendar_status(
483 &self,
484 record: &ObligationRecord,
485 now: DateTime<Utc>,
486 ) -> Result<ObligationStatus, SlaError> {
487 let calendar = self.calendar_of(&record.obligation)?;
488 let zone = self.zone_of(&record.obligation, calendar);
489 let mut status = record.obligation.status_with(calendar, zone, now);
490 status.escalated_at = record.escalated_at;
491 Ok(status)
492 }
493
494 fn status_of(
498 &self,
499 record: &ObligationRecord,
500 now: DateTime<Utc>,
501 ) -> Result<ObligationStatus, SlaError> {
502 let Some(due) = record.due_at else {
503 return self.calendar_status(record, now);
504 };
505 let calendar = self.calendar_of(&record.obligation)?;
506 let zone = self.zone_of(&record.obligation, calendar);
507 let mut status = if due == super::store::no_deadline() {
508 record.obligation.status_without_due(calendar, zone, now)
509 } else {
510 record.obligation.status_with_due(calendar, zone, now, due)
511 };
512 status.escalated_at = record.escalated_at;
513 Ok(status)
514 }
515
516 async fn schedule_check(
517 &self,
518 key: &str,
519 generation: uuid::Uuid,
520 due_at: DateTime<Utc>,
521 revision: u64,
522 ) -> Result<(), SlaError> {
523 let args = CheckArgs {
524 key: key.to_owned(),
525 generation: Some(generation),
526 due_at,
527 requeue: 0,
528 revision,
529 };
530 self.enqueue(CHECK_JOB, &args, Some(due_at)).await
531 }
532
533 async fn enqueue<T: Serialize + Sync>(
534 &self,
535 name: &str,
536 args: &T,
537 due_at: Option<DateTime<Utc>>,
538 ) -> Result<(), SlaError> {
539 let client = self
540 .state
541 .extension::<crate::job::JobClient>()
542 .ok_or(SlaError::NoJobRuntime)?;
543 let payload = serde_json::to_value(args).map_err(|err| SlaError::Job(err.to_string()))?;
544 client
545 .enqueue_due(name, payload, due_at)
546 .await
547 .map_err(|err| SlaError::Job(err.to_string()))
548 }
549
550 async fn requeue_if_early(&self, args: &CheckArgs) -> Result<bool, SlaError> {
557 let Some(early) = args
558 .due_at
559 .signed_duration_since(self.now())
560 .to_std()
561 .ok()
562 .filter(|early| !early.is_zero())
563 else {
564 return Ok(false);
565 };
566 if args.requeue > 0 {
567 tracing::warn!(
568 key = %args.key,
569 ?early,
570 requeue = args.requeue,
571 "SLA check ran before its deadline again; the job queue clock leads the app clock"
572 );
573 }
574 let again = CheckArgs {
575 key: args.key.clone(),
576 generation: args.generation,
577 due_at: args.due_at,
578 requeue: args.requeue.saturating_add(1),
579 revision: args.revision,
580 };
581 let client = self
582 .state
583 .extension::<crate::job::JobClient>()
584 .ok_or(SlaError::NoJobRuntime)?;
585 let payload = serde_json::to_value(&again).map_err(|err| SlaError::Job(err.to_string()))?;
586 client
587 .enqueue_relative(CHECK_JOB, payload, early)
588 .await
589 .map_err(|err| SlaError::Job(err.to_string()))?;
590 Ok(true)
591 }
592
593 async fn check(
595 &self,
596 key: &str,
597 generation: Option<uuid::Uuid>,
598 due_hint: DateTime<Utc>,
599 ) -> Result<(), SlaError> {
600 let now = self.now();
601 let store = &self.engine.store;
602 let Some(record) = store.get(key).await? else {
603 tracing::warn!(
604 %key,
605 "SLA check found no tracked obligation; replicas need a shared ObligationStore"
606 );
607 return Ok(());
608 };
609 if generation.is_some_and(|generation| generation != record.generation) {
610 return Ok(());
613 }
614 if record.escalated_at.is_some() {
615 return Ok(());
616 }
617 let status = self.status_of(&record, now)?;
618 let due_at = match (status.state, status.due_at) {
619 (ObligationState::Breached, Some(due_at)) => due_at,
620 (ObligationState::Running | ObligationState::Paused, Some(due_at))
621 if due_at != due_hint =>
622 {
623 return self.schedule_check(key, record.generation, due_at, 0).await;
625 }
626 _ => return Ok(()),
627 };
628 if !store
631 .claim_escalation(key, record.generation, due_at, now)
632 .await?
633 {
634 return Ok(());
635 }
636 let obligation = &record.obligation;
637 let breach = SlaBreach {
638 key: key.to_owned(),
639 obligation: obligation.name().to_owned(),
640 subject: obligation.subject().to_owned(),
641 calendar: obligation.calendar_name().to_owned(),
642 zone: status.zone.name().to_owned(),
643 generation: record.generation,
644 started_at: status.started_at,
645 due_at,
646 escalated_at: now,
647 token: self.state.entropy().uuid_v4(),
648 };
649 if let Err(err) = self.enqueue(ESCALATE_JOB, &breach, None).await {
650 if let Err(release) = release_claim(store.as_ref(), key, record.generation, now).await {
652 tracing::error!(
653 %key,
654 enqueue = %err,
655 release = %release,
656 "SLA escalation is claimed but not enqueued; this escalation is lost"
657 );
658 }
659 return Err(err);
660 }
661 Ok(())
662 }
663
664 async fn met_on_time(&self, breach: &SlaBreach) -> Result<bool, SlaError> {
668 let store = &self.engine.store;
669 let Some(record) = store.get(&breach.key).await? else {
670 return Ok(false);
671 };
672 let on_time = record.generation == breach.generation
675 && record.dispatch_token.is_none()
676 && record
677 .obligation
678 .met()
679 .is_some_and(|met| met <= breach.due_at);
680 if on_time && let Some(claimed_at) = record.escalated_at {
684 store
685 .release_escalation(&breach.key, breach.generation, claimed_at)
686 .await?;
687 }
688 Ok(on_time)
689 }
690
691 fn handler_for(&self, obligation: &str) -> Option<BreachHandler> {
692 self.engine
693 .handlers
694 .get(obligation)
695 .or(self.engine.fallback.as_ref())
696 .cloned()
697 }
698}
699
700fn check_job(state: AppState, payload: Value) -> BreachFuture {
701 Box::pin(async move {
702 let args: CheckArgs = serde_json::from_value(payload)?;
703 let sla = Sla::from_state(&state)?;
704 if !sla.requeue_if_early(&args).await? {
705 sla.check(&args.key, args.generation, args.due_at).await?;
706 }
707 Ok(())
708 })
709}
710
711fn escalate_job(state: AppState, payload: Value) -> BreachFuture {
712 Box::pin(async move {
713 let breach: SlaBreach = serde_json::from_value(payload)?;
714 let sla = Sla::from_state(&state)?;
715 if sla.met_on_time(&breach).await? {
716 return Ok(());
717 }
718 if !sla
722 .engine
723 .store
724 .begin_dispatch(&breach.key, breach.generation, breach.due_at, breach.token)
725 .await?
726 {
727 tracing::info!(key = %breach.key, "SLA escalation already dispatched");
728 return Ok(());
729 }
730 let Some(handler) = sla.handler_for(&breach.obligation) else {
731 tracing::warn!(key = %breach.key, "SLA breach has no handler");
732 return Ok(());
733 };
734 handler(state, breach).await
735 })
736}
737
738impl axum::extract::FromRequestParts<AppState> for Sla {
739 type Rejection = crate::AutumnError;
740
741 async fn from_request_parts(
742 _parts: &mut axum::http::request::Parts,
743 state: &AppState,
744 ) -> Result<Self, Self::Rejection> {
745 Ok(Self::from_state(state)?)
746 }
747}
748
749pub(super) async fn release_claim(
753 store: &dyn ObligationStore,
754 key: &str,
755 generation: uuid::Uuid,
756 claimed_at: DateTime<Utc>,
757) -> Result<(), SlaError> {
758 let mut delay = RELEASE_BACKOFF;
759 for attempt in 1..RELEASE_ATTEMPTS {
760 match store.release_escalation(key, generation, claimed_at).await {
761 Ok(()) => return Ok(()),
762 Err(err) => {
763 tracing::warn!(%key, attempt, error = %err, "SLA claim release failed; trying again");
764 tokio::time::sleep(delay).await;
765 delay = delay.saturating_mul(2);
766 }
767 }
768 }
769 store.release_escalation(key, generation, claimed_at).await
770}