templar_proxy_oracle_kernel/proxy/circuit_breaker/
set.rs

1use alloc::collections::BTreeMap;
2use alloc::vec;
3use alloc::vec::Vec;
4
5#[cfg(any(feature = "borsh", feature = "schemars"))]
6use alloc::format;
7#[cfg(any(feature = "borsh", feature = "schemars"))]
8use alloc::string::ToString;
9#[cfg(feature = "schemars")]
10use alloc::{borrow::ToOwned, boxed::Box};
11use templar_primitives::Nanoseconds;
12
13use crate::{primitive::AccountId, Price};
14
15use super::{
16    CircuitBreaker, CircuitBreakerError, CircuitBreakerRule, CircuitBreakerStatus, Observation,
17    RingBuffer,
18};
19
20serialize! {
21    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22    /// Shared sampling configuration for a circuit breaker set.
23    pub struct CircuitBreakerSetConfig {
24        /// Minimum elapsed time between persisted observations.
25        ///
26        /// A value of zero persists every resolved proxy price. Rules evaluate sampled candidates
27        /// that advance accepted history unless the set is already manually or breaker-tripped.
28        pub sample_interval_ns: Nanoseconds,
29        /// Maximum number of observations retained by the set.
30        ///
31        /// A value of zero is a coherent no-op history configuration: observations are not
32        /// retained, so breakers that need prior samples cannot trip until history capacity is
33        /// raised and enough accepted observations have accumulated.
34        pub history_len: u32,
35    }
36}
37
38serialize! {
39    #[derive(Debug, Clone, PartialEq, Eq)]
40    pub enum CircuitBreakerEvent {
41        ManualTripSet {
42            is_manually_tripped: bool,
43            actor: AccountId,
44            metadata: Option<Vec<u8>>,
45        },
46        ConfigSet {
47            config: CircuitBreakerSetConfig,
48        },
49        Added {
50            breaker_id: u32,
51            breaker: CircuitBreaker,
52        },
53        Removed {
54            breaker_id: u32,
55        },
56        EnforcementSet {
57            breaker_id: u32,
58            is_enforced: bool,
59        },
60        Rearmed {
61            breaker_id: u32,
62            armed_after_ns: Nanoseconds,
63        },
64        Tripped {
65            breaker_id: u32,
66            tripped_at_ns: Nanoseconds,
67            price_update: Observation,
68            is_enforced: bool,
69        },
70    }
71}
72
73serialize! {
74    #[derive(Debug, Clone, PartialEq, Eq)]
75    pub struct CircuitBreakerOutcome<T = ()> {
76        pub value: T,
77        pub events: Vec<CircuitBreakerEvent>,
78    }
79}
80
81impl<T> CircuitBreakerOutcome<T> {
82    #[must_use]
83    pub fn new(value: T) -> Self {
84        Self {
85            value,
86            events: Vec::new(),
87        }
88    }
89
90    #[must_use]
91    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> CircuitBreakerOutcome<U> {
92        CircuitBreakerOutcome {
93            value: f(self.value),
94            events: self.events,
95        }
96    }
97
98    #[must_use]
99    pub fn with_events(self, events: Vec<CircuitBreakerEvent>) -> Self {
100        Self { events, ..self }
101    }
102}
103
104impl CircuitBreakerOutcome<()> {
105    #[must_use]
106    pub const fn empty() -> Self {
107        Self {
108            value: (),
109            events: Vec::new(),
110        }
111    }
112
113    #[must_use]
114    pub fn with_value<T>(self, value: T) -> CircuitBreakerOutcome<T> {
115        CircuitBreakerOutcome {
116            value,
117            events: self.events,
118        }
119    }
120}
121
122serialize! {
123    #[derive(Debug, Clone, PartialEq, Eq)]
124    pub enum PriceBlockedReason {
125        ManuallyTripped,
126        BreakerTripped { blocking_breaker_ids: Vec<u32> },
127    }
128}
129
130pub type PriceAcceptance = Result<Price, PriceBlockedReason>;
131
132serialize! {
133    #[derive(Debug, Clone, PartialEq, Eq)]
134    pub struct UncheckedCircuitBreakerSet<R = CircuitBreaker> {
135        pub sample_interval_ns: Nanoseconds,
136        pub accepted_history: RingBuffer<Observation>,
137        pub observed_history: RingBuffer<Observation>,
138        pub next_id: u32,
139        pub is_manually_tripped: bool,
140        pub breakers: BTreeMap<u32, CircuitBreakerState<R>>,
141    }
142}
143
144#[cfg_attr(
145    feature = "serde",
146    derive(::serde::Deserialize, ::serde::Serialize),
147    serde(
148        try_from = "UncheckedCircuitBreakerSet<R>",
149        into = "UncheckedCircuitBreakerSet<R>",
150        bound(
151            serialize = "R: Clone + ::serde::Serialize",
152            deserialize = "R: ::serde::Deserialize<'de>"
153        )
154    )
155)]
156#[cfg_attr(
157    feature = "schemars",
158    derive(::schemars::JsonSchema),
159    schemars(transparent)
160)]
161#[cfg_attr(
162    feature = "borsh",
163    derive(::borsh::BorshSerialize, ::borsh::BorshSchema)
164)]
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct CircuitBreakerSet<R = CircuitBreaker>(UncheckedCircuitBreakerSet<R>);
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum CircuitBreakerSetParseError {
170    BreakerIdOutOfRange,
171    HistoryCapacityMismatch,
172    InvalidConfiguration,
173}
174
175impl core::fmt::Display for CircuitBreakerSetParseError {
176    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
177        match self {
178            Self::BreakerIdOutOfRange => write!(f, "circuit breaker ID is out of range"),
179            Self::HistoryCapacityMismatch => {
180                write!(f, "circuit breaker histories have mismatched capacities")
181            }
182            Self::InvalidConfiguration => write!(f, "invalid circuit breaker configuration"),
183        }
184    }
185}
186
187fn validate_structure<R>(
188    value: &UncheckedCircuitBreakerSet<R>,
189) -> Result<(), CircuitBreakerSetParseError> {
190    if value
191        .breakers
192        .keys()
193        .next_back()
194        .is_some_and(|breaker_id| *breaker_id >= value.next_id)
195    {
196        return Err(CircuitBreakerSetParseError::BreakerIdOutOfRange);
197    }
198    if value.accepted_history.capacity() != value.observed_history.capacity() {
199        return Err(CircuitBreakerSetParseError::HistoryCapacityMismatch);
200    }
201    Ok(())
202}
203
204impl<R> TryFrom<UncheckedCircuitBreakerSet<R>> for CircuitBreakerSet<R> {
205    type Error = CircuitBreakerSetParseError;
206
207    fn try_from(value: UncheckedCircuitBreakerSet<R>) -> Result<Self, Self::Error> {
208        validate_structure(&value)?;
209        Ok(Self(value))
210    }
211}
212
213impl<R> From<CircuitBreakerSet<R>> for UncheckedCircuitBreakerSet<R> {
214    fn from(value: CircuitBreakerSet<R>) -> Self {
215        value.0
216    }
217}
218
219// Keep policy, runtime status, and histories serialized as one set. A split
220// policy/runtime storage layout was benchmarked for NEAR and Soroban, but the
221// accepted-update path got more expensive because the extra storage keys
222// outweighed the smaller runtime write. Revisit only with new storage-cost
223// assumptions or a benchmark showing the common update path improves.
224impl<R> CircuitBreakerSet<R> {
225    #[must_use]
226    /// Returns an empty, no-op set with zero retained history.
227    ///
228    /// Breakers can still be added later, but history-dependent breakers cannot trip until the
229    /// set is configured with enough history capacity and samples have accumulated.
230    pub fn empty() -> Self {
231        Self::new(CircuitBreakerSetConfig {
232            sample_interval_ns: Nanoseconds::zero(),
233            history_len: 0,
234        })
235    }
236
237    #[must_use]
238    pub fn new(config: CircuitBreakerSetConfig) -> Self {
239        Self(UncheckedCircuitBreakerSet {
240            sample_interval_ns: config.sample_interval_ns,
241            accepted_history: RingBuffer::new(config.history_len),
242            observed_history: RingBuffer::new(config.history_len),
243            next_id: 0,
244            is_manually_tripped: false,
245            breakers: BTreeMap::new(),
246        })
247    }
248
249    pub fn set_manual_trip(
250        &mut self,
251        is_manually_tripped: bool,
252        actor: AccountId,
253        metadata: Option<Vec<u8>>,
254    ) -> CircuitBreakerOutcome {
255        if self.0.is_manually_tripped == is_manually_tripped {
256            return CircuitBreakerOutcome::empty();
257        }
258
259        self.set_manual_trip_state(is_manually_tripped);
260        CircuitBreakerOutcome::empty().with_events(vec![CircuitBreakerEvent::ManualTripSet {
261            is_manually_tripped,
262            actor,
263            metadata,
264        }])
265    }
266
267    fn set_manual_trip_state(&mut self, is_manually_tripped: bool) {
268        self.0.is_manually_tripped = is_manually_tripped;
269    }
270
271    fn add_state(&mut self, breaker_id: u32, breaker: R) -> Result<(), CircuitBreakerError> {
272        if breaker_id != self.0.next_id {
273            return Err(CircuitBreakerError::UnexpectedBreakerId {
274                expected: self.0.next_id,
275                actual: breaker_id,
276            });
277        }
278
279        self.0.next_id = self
280            .0
281            .next_id
282            .checked_add(1)
283            .ok_or(CircuitBreakerError::TooManyBreakers)?;
284        self.0
285            .breakers
286            .insert(breaker_id, CircuitBreakerState::new(breaker));
287        Ok(())
288    }
289
290    pub fn remove(
291        &mut self,
292        breaker_id: u32,
293    ) -> Result<CircuitBreakerOutcome, CircuitBreakerError> {
294        self.remove_state(breaker_id)?;
295        Ok(CircuitBreakerOutcome::empty()
296            .with_events(vec![CircuitBreakerEvent::Removed { breaker_id }]))
297    }
298
299    fn remove_state(&mut self, breaker_id: u32) -> Result<(), CircuitBreakerError> {
300        self.0
301            .breakers
302            .remove(&breaker_id)
303            .ok_or(CircuitBreakerError::BreakerNotFound { breaker_id })?;
304        Ok(())
305    }
306
307    fn get_mut(
308        &mut self,
309        breaker_id: u32,
310    ) -> Result<&mut CircuitBreakerState<R>, CircuitBreakerError> {
311        self.0
312            .breakers
313            .get_mut(&breaker_id)
314            .ok_or(CircuitBreakerError::BreakerNotFound { breaker_id })
315    }
316
317    #[must_use]
318    pub fn sample_interval_ns(&self) -> Nanoseconds {
319        self.0.sample_interval_ns
320    }
321
322    #[must_use]
323    pub fn accepted_history(&self) -> &RingBuffer<Observation> {
324        &self.0.accepted_history
325    }
326
327    #[must_use]
328    pub fn observed_history(&self) -> &RingBuffer<Observation> {
329        &self.0.observed_history
330    }
331
332    #[must_use]
333    pub fn next_id(&self) -> u32 {
334        self.0.next_id
335    }
336
337    #[must_use]
338    pub fn is_manually_tripped(&self) -> bool {
339        self.0.is_manually_tripped
340    }
341
342    #[must_use]
343    pub fn breakers(&self) -> &BTreeMap<u32, CircuitBreakerState<R>> {
344        &self.0.breakers
345    }
346
347    #[must_use]
348    pub fn breaker_count(&self) -> usize {
349        self.0.breakers.len()
350    }
351
352    #[must_use]
353    pub fn is_blocking(&self) -> bool {
354        self.0.is_manually_tripped
355            || self
356                .0
357                .breakers
358                .values()
359                .any(CircuitBreakerState::is_blocking)
360    }
361
362    #[must_use]
363    pub fn blocking_reason(&self) -> Option<PriceBlockedReason> {
364        if self.0.is_manually_tripped {
365            return Some(PriceBlockedReason::ManuallyTripped);
366        }
367        let blocking_breaker_ids = self.blocking_breaker_ids();
368        (!blocking_breaker_ids.is_empty()).then_some(PriceBlockedReason::BreakerTripped {
369            blocking_breaker_ids,
370        })
371    }
372
373    fn should_persist_sample(&self, now: Nanoseconds) -> bool {
374        self.0
375            .observed_history
376            .last()
377            .is_none_or(|last| now.saturating_sub(last.observed_at_ns) >= self.0.sample_interval_ns)
378    }
379
380    fn blocking_breaker_ids(&self) -> Vec<u32> {
381        self.0
382            .breakers
383            .iter()
384            .filter_map(|(id, breaker)| breaker.is_blocking().then_some(*id))
385            .collect()
386    }
387}
388
389impl<R: CircuitBreakerRule> CircuitBreakerSet<R> {
390    pub fn validate(&self) -> Result<(), CircuitBreakerSetParseError> {
391        let config = CircuitBreakerSetConfig {
392            sample_interval_ns: self.0.sample_interval_ns,
393            history_len: self.0.accepted_history.capacity(),
394        };
395        if self.0.observed_history.capacity() != config.history_len
396            || self.0.breakers.values().any(|state| {
397                !state
398                    .breaker
399                    .is_valid_for(config.sample_interval_ns, config.history_len)
400            })
401        {
402            return Err(CircuitBreakerSetParseError::InvalidConfiguration);
403        }
404        Ok(())
405    }
406
407    pub fn set_config(
408        &mut self,
409        config: CircuitBreakerSetConfig,
410    ) -> Result<CircuitBreakerOutcome, CircuitBreakerError> {
411        let current = CircuitBreakerSetConfig {
412            sample_interval_ns: self.0.sample_interval_ns,
413            history_len: self.0.accepted_history.capacity(),
414        };
415        if self.0.breakers.values().any(|state| {
416            !state
417                .breaker
418                .is_valid_for(config.sample_interval_ns, config.history_len)
419        }) {
420            return Err(CircuitBreakerError::InvalidConfiguration);
421        }
422        if config == current {
423            return Ok(CircuitBreakerOutcome::empty());
424        }
425        self.0.sample_interval_ns = config.sample_interval_ns;
426
427        self.0.accepted_history.set_capacity(config.history_len);
428        self.0.observed_history.set_capacity(config.history_len);
429        Ok(CircuitBreakerOutcome::empty()
430            .with_events(vec![CircuitBreakerEvent::ConfigSet { config }]))
431    }
432}
433
434impl CircuitBreakerSet<CircuitBreaker> {
435    pub fn add(
436        &mut self,
437        breaker_id: u32,
438        breaker: CircuitBreaker,
439    ) -> Result<CircuitBreakerOutcome, CircuitBreakerError> {
440        let config = CircuitBreakerSetConfig {
441            sample_interval_ns: self.0.sample_interval_ns,
442            history_len: self.0.accepted_history.capacity(),
443        };
444        if !breaker.is_valid_for(config.sample_interval_ns, config.history_len) {
445            return Err(CircuitBreakerError::InvalidConfiguration);
446        }
447        self.add_state(breaker_id, breaker.clone())?;
448        Ok(
449            CircuitBreakerOutcome::empty().with_events(vec![CircuitBreakerEvent::Added {
450                breaker_id,
451                breaker,
452            }]),
453        )
454    }
455
456    pub fn set_enforced(
457        &mut self,
458        breaker_id: u32,
459        is_enforced: bool,
460    ) -> Result<CircuitBreakerOutcome, CircuitBreakerError> {
461        let breaker = self.get_mut(breaker_id)?;
462        if breaker.is_enforced == is_enforced {
463            return Ok(CircuitBreakerOutcome::empty());
464        }
465        breaker.is_enforced = is_enforced;
466        Ok(
467            CircuitBreakerOutcome::empty().with_events(vec![CircuitBreakerEvent::EnforcementSet {
468                breaker_id,
469                is_enforced,
470            }]),
471        )
472    }
473
474    pub fn rearm(
475        &mut self,
476        breaker_id: u32,
477        armed_after_ns: Nanoseconds,
478    ) -> Result<CircuitBreakerOutcome, CircuitBreakerError> {
479        let breaker = self.get_mut(breaker_id)?;
480        let status = CircuitBreakerStatus::ArmedAfter {
481            timestamp_ns: armed_after_ns,
482        };
483        if breaker.status == status {
484            return Ok(CircuitBreakerOutcome::empty());
485        }
486        breaker.status = status;
487        Ok(
488            CircuitBreakerOutcome::empty().with_events(vec![CircuitBreakerEvent::Rearmed {
489                breaker_id,
490                armed_after_ns,
491            }]),
492        )
493    }
494}
495
496#[cfg(feature = "borsh")]
497impl<R: ::borsh::BorshDeserialize> ::borsh::BorshDeserialize for CircuitBreakerSet<R> {
498    fn deserialize_reader<Reader: ::borsh::io::Read>(
499        reader: &mut Reader,
500    ) -> ::borsh::io::Result<Self> {
501        let unchecked =
502            <UncheckedCircuitBreakerSet<R> as ::borsh::BorshDeserialize>::deserialize_reader(
503                reader,
504            )?;
505        unchecked.try_into().map_err(|_| {
506            ::borsh::io::Error::new(
507                ::borsh::io::ErrorKind::InvalidData,
508                "could not parse circuit breaker set",
509            )
510        })
511    }
512}
513
514impl<R: CircuitBreakerRule> CircuitBreakerSet<R> {
515    pub fn try_accept_price(
516        &mut self,
517        price: Price,
518        now: Nanoseconds,
519    ) -> Result<CircuitBreakerOutcome<PriceAcceptance>, CircuitBreakerError> {
520        if !price.has_strictly_positive_confidence_interval() {
521            return Err(CircuitBreakerError::InvalidPrice);
522        }
523
524        let price_update = Observation {
525            price,
526            observed_at_ns: now,
527        };
528        let accepted_history_should_advance = self
529            .0
530            .accepted_history
531            .last()
532            .is_none_or(|last| price.publish_time_ns > last.price.publish_time_ns);
533        let should_persist_sample = self.should_persist_sample(now);
534        let observed_history_should_advance = self
535            .0
536            .observed_history
537            .last()
538            .is_none_or(|last| price.publish_time_ns > last.price.publish_time_ns);
539        if should_persist_sample && observed_history_should_advance {
540            self.0.observed_history.push(price_update);
541        }
542
543        if self.0.is_manually_tripped {
544            return Ok(CircuitBreakerOutcome::new(Err(
545                PriceBlockedReason::ManuallyTripped,
546            )));
547        }
548
549        let blocking_breaker_ids = self.blocking_breaker_ids();
550
551        // Short-circuit in the case of already-blocking breakers: do not update
552        // accepted_history or test untripped breakers against a stale accepted_history.
553        if !blocking_breaker_ids.is_empty() {
554            return Ok(CircuitBreakerOutcome::new(Err(
555                PriceBlockedReason::BreakerTripped {
556                    blocking_breaker_ids,
557                },
558            )));
559        }
560
561        let proposed_acceptance =
562            ProposedPriceAcceptance::new(&self.0.accepted_history, price_update);
563
564        let acceptance = self.apply_armed_breaker_transitions(&proposed_acceptance, now);
565
566        if acceptance.value.is_ok() && should_persist_sample && accepted_history_should_advance {
567            self.0.accepted_history = proposed_acceptance.accepted_history;
568        }
569        Ok(acceptance)
570    }
571
572    fn apply_armed_breaker_transitions(
573        &mut self,
574        proposed_acceptance: &ProposedPriceAcceptance,
575        now: Nanoseconds,
576    ) -> CircuitBreakerOutcome<PriceAcceptance> {
577        let mut events = vec![];
578        let mut blocking_breaker_ids = vec![];
579
580        for (breaker_id, breaker) in &mut self.0.breakers {
581            if let Some(event) =
582                breaker.apply_armed_transition(*breaker_id, proposed_acceptance, now)
583            {
584                events.push(event);
585            }
586            if breaker.is_blocking() {
587                blocking_breaker_ids.push(*breaker_id);
588            }
589        }
590
591        let value = if blocking_breaker_ids.is_empty() {
592            Ok(proposed_acceptance.price_update.price)
593        } else {
594            Err(PriceBlockedReason::BreakerTripped {
595                blocking_breaker_ids,
596            })
597        };
598        CircuitBreakerOutcome::new(value).with_events(events)
599    }
600}
601
602pub(crate) struct ProposedPriceAcceptance {
603    price_update: Observation,
604    accepted_history: RingBuffer<Observation>,
605}
606
607impl ProposedPriceAcceptance {
608    pub(crate) fn new(
609        accepted_history: &RingBuffer<Observation>,
610        price_update: Observation,
611    ) -> Self {
612        let mut accepted_history = accepted_history.clone();
613        accepted_history.push(price_update);
614        Self {
615            price_update,
616            accepted_history,
617        }
618    }
619}
620
621serialize! {
622    #[derive(Debug, Clone, PartialEq, Eq)]
623    pub struct CircuitBreakerState<R = CircuitBreaker> {
624        pub breaker: R,
625        pub is_enforced: bool,
626        pub status: CircuitBreakerStatus,
627    }
628}
629
630impl<R> CircuitBreakerState<R> {
631    #[must_use]
632    pub fn new(breaker: R) -> Self {
633        Self {
634            breaker,
635            is_enforced: true,
636            status: CircuitBreakerStatus::ArmedAfter {
637                timestamp_ns: Nanoseconds::zero(),
638            },
639        }
640    }
641
642    pub fn is_blocking(&self) -> bool {
643        self.is_enforced && matches!(self.status, CircuitBreakerStatus::Tripped { .. })
644    }
645}
646
647impl<R: CircuitBreakerRule> CircuitBreakerState<R> {
648    pub(crate) fn apply_armed_transition(
649        &mut self,
650        breaker_id: u32,
651        proposed_acceptance: &ProposedPriceAcceptance,
652        now: Nanoseconds,
653    ) -> Option<CircuitBreakerEvent> {
654        if self.is_armed_at(now)
655            && self
656                .breaker
657                .should_trip(&proposed_acceptance.accepted_history)
658        {
659            Some(self.trip(breaker_id, proposed_acceptance.price_update, now))
660        } else {
661            None
662        }
663    }
664
665    fn is_armed_at(&self, now: Nanoseconds) -> bool {
666        matches!(
667            self.status,
668            CircuitBreakerStatus::ArmedAfter { timestamp_ns } if now >= timestamp_ns
669        )
670    }
671
672    fn trip(
673        &mut self,
674        breaker_id: u32,
675        price_update: Observation,
676        now: Nanoseconds,
677    ) -> CircuitBreakerEvent {
678        self.status = CircuitBreakerStatus::Tripped {
679            tripped_at_ns: now,
680            price_update,
681        };
682        CircuitBreakerEvent::Tripped {
683            breaker_id,
684            tripped_at_ns: now,
685            price_update,
686            is_enforced: self.is_enforced,
687        }
688    }
689}