Skip to main content

autumn_web/sla/
runtime.rs

1//! The plugin, the extractor and the jobs of the SLA engine.
2
3use 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
20/// The job that checks an obligation at its deadline.
21pub const CHECK_JOB: &str = "autumn_sla_check";
22
23/// The job that runs the breach handler of an obligation.
24pub const ESCALATE_JOB: &str = "autumn_sla_escalate";
25
26/// How long the unique key of an escalation stays held after its enqueue,
27/// also after the job is done. An enqueue can fail after the queue stored
28/// the job (for example, a lost connection after the commit). The check
29/// then enqueues the breach again, and this key merges that duplicate.
30const ESCALATE_UNIQUE_MS: u64 = 24 * 60 * 60 * 1_000;
31
32/// Attempts for each SLA job before it goes to the dead letters.
33const MAX_ATTEMPTS: u32 = 5;
34
35/// The first retry delay of an SLA job.
36const BACKOFF_MS: u64 = 1_000;
37
38/// Attempts to release a claim after a failed enqueue.
39const RELEASE_ATTEMPTS: u32 = 5;
40
41/// The first delay between two release attempts. It doubles each time.
42const 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/// The typed escalation payload of a breached obligation.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[non_exhaustive]
50pub struct SlaBreach {
51    /// The unique key, `"<name>/<subject>"`.
52    pub key: String,
53    /// The obligation name.
54    pub obligation: String,
55    /// The subject, such as `"ticket:42"`.
56    pub subject: String,
57    /// The calendar name.
58    pub calendar: String,
59    /// The IANA name of the time zone.
60    pub zone: String,
61    /// The generation of the tracked record. See
62    /// [`ObligationRecord::generation`].
63    pub generation: uuid::Uuid,
64    /// The start instant.
65    pub started_at: DateTime<Utc>,
66    /// The deadline.
67    pub due_at: DateTime<Utc>,
68    /// The instant when the escalation was claimed.
69    pub escalated_at: DateTime<Utc>,
70    /// A random token for this escalation. See
71    /// [`ObligationStore::begin_dispatch`].
72    pub token: uuid::Uuid,
73}
74
75/// The payload of [`CHECK_JOB`].
76#[derive(Debug, Serialize, Deserialize)]
77struct CheckArgs {
78    key: String,
79    /// The record that this check is for. A check for a replaced record
80    /// stops. `None` checks any record for the key.
81    #[serde(default)]
82    generation: Option<uuid::Uuid>,
83    due_at: DateTime<Utc>,
84    /// How many times an early check put itself on the queue again. It is
85    /// part of the unique key, so a requeue from a running check is not
86    /// merged into that check.
87    #[serde(default)]
88    requeue: u32,
89    /// A random value for a check from `reconcile`, else 0. It is part of
90    /// the unique key, so a check that runs for the same deadline does not
91    /// absorb the new check.
92    #[serde(default)]
93    revision: u64,
94}
95
96/// The shared state of the SLA engine, kept as an [`AppState`] extension.
97struct SlaEngine {
98    calendars: BTreeMap<String, BusinessCalendar>,
99    store: Arc<dyn ObligationStore>,
100    handlers: BTreeMap<String, BreachHandler>,
101    fallback: Option<BreachHandler>,
102}
103
104/// Installs the SLA engine.
105///
106/// It adds the calendars, the [`ObligationStore`] and the breach handlers, and
107/// registers [`CHECK_JOB`] and [`ESCALATE_JOB`].
108pub 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    /// Make a plugin with no calendars and a [`MemoryObligationStore`].
133    #[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    /// Add a calendar called `name`.
144    #[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    /// Use `store` for tracked obligations.
151    #[must_use]
152    pub fn store(mut self, store: impl ObligationStore) -> Self {
153        self.store = Arc::new(store);
154        self
155    }
156
157    /// Run `handler` when an obligation called `obligation` is breached.
158    #[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    /// Run `handler` for a breach that has no named handler.
169    #[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/// The SLA engine for one request or job.
229///
230/// Use it as a handler extractor, or get it with [`Sla::from_state`]. It reads
231/// the time from the injected clock.
232#[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    /// Get the engine from `state`.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`SlaError::NotInstalled`] when the app has no [`SlaPlugin`].
255    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    /// The current instant of the injected clock.
266    #[must_use]
267    pub fn now(&self) -> DateTime<Utc> {
268        self.state.clock().now()
269    }
270
271    /// The calendar called `name`.
272    #[must_use]
273    pub fn calendar(&self, name: &str) -> Option<&BusinessCalendar> {
274        self.engine.calendars.get(name)
275    }
276
277    /// Calculate the status of `obligation` now. This does not track it.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`SlaError::UnknownCalendar`] for an unknown calendar.
282    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    /// Track `obligation` and schedule its breach check.
289    ///
290    /// A second call with the same key keeps the first record. A met instant
291    /// on `obligation` marks the record met.
292    ///
293    /// It is safe to call again. If it fails after it stored the record (for
294    /// example, the job queue refused the check), the record stays and the
295    /// next call schedules the check.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error for a zero budget, an unknown calendar, an
300    /// obligation with no deadline ([`SlaError::NoDeadline`]), a store
301    /// failure, or no job runtime.
302    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        // Refuse before the insert, so a refused obligation leaves no record.
318        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        // A new record has the met instant already. An older one gets it now.
329        if !created && let Some(met) = obligation.met() {
330            store.mark_met(&key, record.generation, met).await?;
331            // Read the record again: another call can have changed its met
332            // instant or its deadline since the insert.
333            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    /// Put the check job of `record` on the queue, if it is still open.
343    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    /// Mark the obligation `key` met now.
360    ///
361    /// Returns `None` when `key` is not tracked.
362    ///
363    /// # Errors
364    ///
365    /// Returns an error for a store failure.
366    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    /// The status of the tracked obligation `key`.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error for a store failure or an unknown calendar.
379    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    /// The status of each tracked obligation, sorted by key.
390    ///
391    /// # Errors
392    ///
393    /// Returns an error for a store failure or an unknown calendar.
394    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    /// Store the deadline of each record that is not escalated, on the
406    /// current calendars. Put a check job on the queue there for each record
407    /// that is not met. Returns the number of checks it added.
408    ///
409    /// Call it once from the new version after a deploy that changes a
410    /// calendar, for example from an `on_startup` hook. Without it, a record
411    /// keeps the deadline that `track` stored. It is safe to call at any
412    /// time. Each call adds one check for each open record. An extra check
413    /// does no harm: only one claim can succeed.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error for a store failure, an unknown calendar, or no job
418    /// runtime.
419    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            // The deadline on this replica's calendars, not the stored one.
425            let status = self.calendar_status(&record, now)?;
426            if status.escalated_at.is_some() {
427                continue;
428            }
429            // No deadline in the scan horizon: store the marker that no
430            // claim matches, so an old check cannot escalate.
431            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                // The record was escalated or replaced after the read.
439                continue;
440            }
441            let Some(due) = due else { continue };
442            if status.state == ObligationState::Met {
443                // Met on time on this calendar: the stored deadline is
444                // enough, and no check is necessary.
445                continue;
446            }
447            // A check for `due` can be running with an older read, also
448            // after a failed earlier call. A new unique key stops that
449            // check from absorbing this one.
450            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    /// Stop tracking `key`. Returns `true` if it was tracked.
459    ///
460    /// # Errors
461    ///
462    /// Returns an error for a store failure.
463    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    /// The zone of the obligation, then of the calendar, then the app default.
474    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    /// The status of `record` on this replica's calendar.
482    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    /// The status of `record`. The stored deadline, if set, decides the
495    /// deadline and the state. Thus a replica with an old calendar agrees
496    /// with the record.
497    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    /// If the injected clock is before the deadline in `args`, put the check
551    /// on the queue again after the difference, and return `true`.
552    ///
553    /// A check job runs early when the queue clock leads the app clock. The
554    /// requeue uses no retry attempt, so a large clock difference does not
555    /// send the check to the dead letters.
556    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    /// Run the breach check of `key`. `due_hint` is the deadline in the job.
594    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            // A `forget` and a new `track` replaced this record. The new
611            // record has its own check.
612            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                // The deadline moved. Check again at the new deadline.
624                return self.schedule_check(key, record.generation, due_at, 0).await;
625            }
626            _ => return Ok(()),
627        };
628        // Claim this instance only: a `forget` and `track` since the read
629        // makes a new record with another generation.
630        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            // Release the claim, so that the retry of this check can claim again.
651            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    /// Whether the record of `breach` was met by its deadline after the
665    /// claim, for example by a late `track` with an earlier met instant. If
666    /// so, release the claim: this breach does not run.
667    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        // Once an escalate job began the dispatch, a late on-time reply does
673        // not cancel it: the retries of that job must still run.
674        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        // Release the claim that the store holds. It can be newer than the
681        // one in `breach`: the unique key can keep an older job. A record
682        // met on time cannot be claimed again, so this is safe.
683        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        // One escalate job for each record runs the handler. Another job
719        // for the same record (for example after the unique key expired),
720        // or a job for a deadline that `reconcile` moved, stops here.
721        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
749/// Release the claim of `key` after a failed enqueue. Try again after a store
750/// error, up to [`RELEASE_ATTEMPTS`] times. A claim that stays set stops each
751/// retry of the check, so the escalation is lost.
752pub(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}