templar_curator_primitives/policy/refresh_plan/
mod.rs

1use alloc::vec::Vec;
2use core::num::NonZeroU64;
3use templar_vault_kernel::{DurationNs, TargetId, TimestampNs};
4
5use super::cooldown::Cooldown;
6use super::target_set::find_first_duplicate;
7
8#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
9#[derive(Clone)]
10pub struct RefreshPlan {
11    targets: Vec<TargetId>,
12}
13
14#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
15#[derive(Clone, Copy, PartialEq, Eq)]
16pub struct RefreshThrottle {
17    cooldown: Cooldown,
18}
19
20#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
21#[derive(Clone, Copy, PartialEq, Eq)]
22pub struct RefreshTargetStatus {
23    target_id: TargetId,
24    last_refresh_at: Option<TimestampNs>,
25}
26
27#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
28#[derive(Clone, Copy, PartialEq, Eq)]
29pub struct RefreshTiming {
30    cooldown: DurationNs,
31    last_refresh_at: Option<TimestampNs>,
32}
33
34#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
35#[derive(Clone)]
36pub struct RefreshExecutionPlan {
37    plan: RefreshPlan,
38    throttle: RefreshThrottle,
39}
40
41impl RefreshPlan {
42    pub fn new(targets: Vec<TargetId>) -> Result<Self, RefreshPlanError> {
43        if targets.is_empty() {
44            return Err(RefreshPlanError::EmptyPlan);
45        }
46
47        if let Some(dup) = find_first_duplicate(&targets) {
48            return Err(RefreshPlanError::DuplicateTarget { target_id: dup });
49        }
50
51        Ok(Self { targets })
52    }
53
54    #[must_use]
55    pub fn len(&self) -> usize {
56        self.targets.len()
57    }
58
59    #[must_use]
60    pub fn is_empty(&self) -> bool {
61        self.targets.is_empty()
62    }
63
64    #[must_use]
65    pub fn targets(&self) -> &[TargetId] {
66        &self.targets
67    }
68
69    #[must_use]
70    pub fn into_targets(self) -> Vec<TargetId> {
71        self.targets
72    }
73}
74
75impl RefreshThrottle {
76    #[must_use]
77    pub fn default_unlimited() -> Self {
78        Self {
79            cooldown: Cooldown::unlimited(),
80        }
81    }
82
83    #[must_use]
84    pub fn new(cooldown: DurationNs, last_refresh_at: Option<TimestampNs>) -> Self {
85        let cooldown = NonZeroU64::new(cooldown.as_u64())
86            .map_or_else(Cooldown::unlimited, Cooldown::new)
87            .with_last_event_ns(last_refresh_at.map(TimestampNs::as_u64));
88
89        Self { cooldown }
90    }
91
92    #[must_use]
93    pub fn cooldown(&self) -> &Cooldown {
94        &self.cooldown
95    }
96
97    #[must_use]
98    pub fn is_ready(&self, current_time: TimestampNs) -> bool {
99        self.cooldown.is_ready(current_time.as_u64())
100    }
101
102    pub fn check(&self, current_time: TimestampNs) -> Result<(), RefreshPlanError> {
103        self.cooldown
104            .check(current_time.as_u64())
105            .map_err(|e| match e {
106                super::cooldown::CooldownError::OnCooldown {
107                    ready_at_ns,
108                    remaining_ns,
109                } => RefreshPlanError::OnCooldown {
110                    ready_at: TimestampNs(ready_at_ns),
111                    remaining: DurationNs(remaining_ns),
112                },
113            })
114    }
115
116    pub fn try_acquire(self, current_time: TimestampNs) -> Result<Self, RefreshPlanError> {
117        self.cooldown
118            .try_acquire(current_time.as_u64())
119            .map(|cooldown| Self { cooldown })
120            .map_err(|e| match e {
121                super::cooldown::CooldownError::OnCooldown {
122                    ready_at_ns,
123                    remaining_ns,
124                } => RefreshPlanError::OnCooldown {
125                    ready_at: TimestampNs(ready_at_ns),
126                    remaining: DurationNs(remaining_ns),
127                },
128            })
129    }
130
131    #[must_use]
132    pub fn record_completion(mut self, completed_at: TimestampNs) -> Self {
133        self.cooldown = self.cooldown.recorded_at(completed_at.as_u64());
134        self
135    }
136
137    #[must_use]
138    pub fn cooldown_duration(&self) -> DurationNs {
139        DurationNs(self.cooldown.interval_ns().map_or(0, NonZeroU64::get))
140    }
141
142    #[must_use]
143    pub fn last_refresh_at(&self) -> Option<TimestampNs> {
144        self.cooldown.last_event_ns().map(TimestampNs)
145    }
146
147    #[must_use]
148    pub fn cooldown_ns(&self) -> u64 {
149        self.cooldown_duration().as_u64()
150    }
151
152    #[must_use]
153    pub fn last_refresh_ns(&self) -> Option<u64> {
154        self.last_refresh_at().map(TimestampNs::as_u64)
155    }
156}
157
158impl Default for RefreshThrottle {
159    fn default() -> Self {
160        Self::default_unlimited()
161    }
162}
163
164impl RefreshTargetStatus {
165    #[must_use]
166    pub const fn new(target_id: TargetId, last_refresh_at: Option<TimestampNs>) -> Self {
167        Self {
168            target_id,
169            last_refresh_at,
170        }
171    }
172
173    #[must_use]
174    pub const fn target_id(&self) -> TargetId {
175        self.target_id
176    }
177
178    #[must_use]
179    pub const fn last_refresh_at(&self) -> Option<TimestampNs> {
180        self.last_refresh_at
181    }
182
183    #[must_use]
184    pub const fn last_refresh_ns(&self) -> Option<u64> {
185        match self.last_refresh_at {
186            Some(last_refresh_at) => Some(last_refresh_at.as_u64()),
187            None => None,
188        }
189    }
190}
191
192impl RefreshTiming {
193    #[must_use]
194    pub const fn new(cooldown: DurationNs, last_refresh_at: Option<TimestampNs>) -> Self {
195        Self {
196            cooldown,
197            last_refresh_at,
198        }
199    }
200
201    #[must_use]
202    pub const fn cooldown(&self) -> DurationNs {
203        self.cooldown
204    }
205
206    #[must_use]
207    pub const fn last_refresh_at(&self) -> Option<TimestampNs> {
208        self.last_refresh_at
209    }
210}
211
212impl RefreshExecutionPlan {
213    #[must_use]
214    pub const fn new(plan: RefreshPlan, throttle: RefreshThrottle) -> Self {
215        Self { plan, throttle }
216    }
217
218    #[must_use]
219    pub const fn plan(&self) -> &RefreshPlan {
220        &self.plan
221    }
222
223    #[must_use]
224    pub const fn throttle(&self) -> &RefreshThrottle {
225        &self.throttle
226    }
227
228    #[must_use]
229    pub fn into_parts(self) -> (RefreshPlan, RefreshThrottle) {
230        (self.plan, self.throttle)
231    }
232}
233
234#[templar_vault_macros::vault_derive]
235#[derive(Clone, PartialEq, Eq)]
236pub enum RefreshPlanError {
237    EmptyPlan,
238    OnCooldown {
239        ready_at: TimestampNs,
240        remaining: DurationNs,
241    },
242    DuplicateTarget {
243        target_id: TargetId,
244    },
245    TargetNotFound {
246        target_id: TargetId,
247    },
248    FutureRefreshTimestamp {
249        target_id: TargetId,
250        last_refresh_at: TimestampNs,
251        current_time: TimestampNs,
252    },
253}
254
255pub fn build_refresh_plan(enabled_targets: &[TargetId]) -> Result<RefreshPlan, RefreshPlanError> {
256    RefreshPlan::new(enabled_targets.to_vec())
257}
258
259pub fn build_targeted_refresh_plan(
260    targets: &[TargetId],
261    enabled_targets: &[TargetId],
262) -> Result<RefreshPlan, RefreshPlanError> {
263    let plan = RefreshPlan::new(targets.to_vec())?;
264
265    for target in plan.targets() {
266        if !enabled_targets.contains(target) {
267            return Err(RefreshPlanError::TargetNotFound { target_id: *target });
268        }
269    }
270
271    Ok(plan)
272}
273
274pub fn refresh_execution_plan(
275    targets: &[TargetId],
276    timing: RefreshTiming,
277) -> Result<RefreshExecutionPlan, RefreshPlanError> {
278    let plan = RefreshPlan::new(targets.to_vec())?;
279    let throttle = RefreshThrottle::new(timing.cooldown(), timing.last_refresh_at());
280    Ok(RefreshExecutionPlan::new(plan, throttle))
281}
282
283pub fn build_stale_refresh_plan(
284    all_targets: &[RefreshTargetStatus],
285    max_age: DurationNs,
286    current_time: TimestampNs,
287    enabled_targets: &[TargetId],
288) -> Result<Option<RefreshPlan>, RefreshPlanError> {
289    let stale_targets = filter_stale_targets(all_targets, max_age, current_time)?;
290    if stale_targets.is_empty() {
291        return Ok(None);
292    }
293
294    build_targeted_refresh_plan(&stale_targets, enabled_targets).map(Some)
295}
296
297pub fn filter_stale_targets(
298    all_targets: &[RefreshTargetStatus],
299    max_age: DurationNs,
300    current_time: TimestampNs,
301) -> Result<Vec<TargetId>, RefreshPlanError> {
302    let mut stale_targets = Vec::new();
303
304    for target in all_targets {
305        match target.last_refresh_at() {
306            None => stale_targets.push(target.target_id()),
307            Some(last_refresh_at) if last_refresh_at > current_time => {
308                return Err(RefreshPlanError::FutureRefreshTimestamp {
309                    target_id: target.target_id(),
310                    last_refresh_at,
311                    current_time,
312                });
313            }
314            Some(last_refresh_at)
315                if current_time
316                    .as_u64()
317                    .saturating_sub(last_refresh_at.as_u64())
318                    > max_age.as_u64() =>
319            {
320                stale_targets.push(target.target_id());
321            }
322            Some(_) => {}
323        }
324    }
325
326    Ok(stale_targets)
327}