templar_curator_primitives/policy/refresh_plan/
mod.rs

1//! Refresh plan for updating market principal data.
2
3use alloc::{collections::BTreeSet, vec::Vec};
4use templar_vault_kernel::TargetId;
5
6use super::cooldown::Cooldown;
7use super::target_set::find_first_duplicate;
8
9/// A plan for refreshing market principal data.
10#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
11#[derive(Clone, Default)]
12pub struct RefreshPlan {
13    /// Ordered list of target IDs to refresh.
14    pub targets: Vec<TargetId>,
15    /// Cooldown tracking for rate-limiting refreshes.
16    pub cooldown: Cooldown,
17}
18
19impl RefreshPlan {
20    #[must_use]
21    pub fn new(targets: Vec<TargetId>) -> Self {
22        Self {
23            targets,
24            cooldown: Cooldown::unlimited(),
25        }
26    }
27
28    #[must_use]
29    pub fn empty() -> Self {
30        Self::default()
31    }
32
33    #[must_use]
34    pub fn with_cooldown(mut self, cooldown_ns: u64) -> Self {
35        self.cooldown = Cooldown::new(cooldown_ns);
36        self
37    }
38
39    #[must_use]
40    pub fn with_last_refresh(mut self, last_refresh_ns: u64) -> Self {
41        self.cooldown = self.cooldown.record(last_refresh_ns);
42        self
43    }
44
45    #[must_use]
46    pub fn is_empty(&self) -> bool {
47        self.targets.is_empty()
48    }
49
50    #[must_use]
51    pub fn len(&self) -> usize {
52        self.targets.len()
53    }
54
55    #[must_use]
56    pub fn is_ready(&self, current_ns: u64) -> bool {
57        self.cooldown.is_ready(current_ns)
58    }
59
60    /// Validate a refresh plan.
61    ///
62    /// Checks:
63    /// - Plan is not empty
64    /// - No duplicate targets
65    pub fn validate(&self) -> Result<(), RefreshPlanError> {
66        if self.is_empty() {
67            return Err(RefreshPlanError::EmptyPlan);
68        }
69
70        if let Some(dup) = find_first_duplicate(&self.targets) {
71            return Err(RefreshPlanError::DuplicateTarget { target_id: dup });
72        }
73
74        Ok(())
75    }
76
77    /// Check if a refresh is allowed based on cooldown.
78    pub fn check_cooldown(&self, current_ns: u64) -> Result<(), RefreshPlanError> {
79        self.cooldown.check(current_ns).map_err(|e| match e {
80            super::cooldown::CooldownError::OnCooldown {
81                last_event_ns,
82                interval_ns,
83                current_ns,
84            } => RefreshPlanError::OnCooldown {
85                last_refresh_ns: last_event_ns,
86                cooldown_ns: interval_ns,
87                current_ns,
88            },
89        })
90    }
91
92    /// Record completion time for a refresh plan.
93    #[must_use]
94    pub fn record_completion(&self, timestamp_ns: u64) -> Self {
95        Self {
96            targets: self.targets.clone(),
97            cooldown: self.cooldown.record(timestamp_ns),
98        }
99    }
100
101    #[must_use]
102    pub fn to_target_list(&self) -> Vec<TargetId> {
103        self.targets.clone()
104    }
105
106    #[must_use]
107    pub fn cooldown_ns(&self) -> u64 {
108        self.cooldown.interval_ns
109    }
110
111    #[must_use]
112    pub fn last_refresh_ns(&self) -> Option<u64> {
113        self.cooldown.last_event_ns
114    }
115}
116
117impl From<Vec<TargetId>> for RefreshPlan {
118    fn from(targets: Vec<TargetId>) -> Self {
119        Self::new(targets)
120    }
121}
122
123/// Errors that can occur during refresh plan operations.
124#[templar_vault_macros::vault_derive]
125#[derive(Clone, PartialEq, Eq)]
126pub enum RefreshPlanError {
127    /// Plan is empty.
128    EmptyPlan,
129    /// Refresh is still on cooldown.
130    OnCooldown {
131        last_refresh_ns: Option<u64>,
132        cooldown_ns: u64,
133        current_ns: u64,
134    },
135    /// Duplicate target in plan.
136    DuplicateTarget { target_id: TargetId },
137    /// Target not found.
138    TargetNotFound { target_id: TargetId },
139}
140
141/// Build a refresh plan from a list of enabled markets.
142///
143/// # Arguments
144/// * `enabled_targets` - List of target IDs that are enabled
145/// * `cooldown_ns` - Optional cooldown between refreshes
146///
147/// # Returns
148/// A refresh plan for all enabled targets.
149pub fn build_refresh_plan(
150    enabled_targets: &[TargetId],
151    cooldown_ns: Option<u64>,
152) -> Result<RefreshPlan, RefreshPlanError> {
153    if enabled_targets.is_empty() {
154        return Err(RefreshPlanError::EmptyPlan);
155    }
156
157    let plan = RefreshPlan::new(enabled_targets.to_vec());
158    let plan = match cooldown_ns {
159        Some(ns) => plan.with_cooldown(ns),
160        None => plan,
161    };
162
163    Ok(plan)
164}
165
166/// Build a refresh plan for specific targets only.
167///
168/// # Arguments
169/// * `targets` - Specific targets to refresh
170/// * `enabled_targets` - All enabled targets (for validation)
171///
172/// # Returns
173/// A refresh plan if all specified targets are valid.
174pub fn build_targeted_refresh_plan(
175    targets: &[TargetId],
176    enabled_targets: &[TargetId],
177) -> Result<RefreshPlan, RefreshPlanError> {
178    if targets.is_empty() {
179        return Err(RefreshPlanError::EmptyPlan);
180    }
181
182    if let Some(dup) = find_first_duplicate(targets) {
183        return Err(RefreshPlanError::DuplicateTarget { target_id: dup });
184    }
185
186    let enabled_set: BTreeSet<_> = enabled_targets.iter().copied().collect();
187
188    // Validate all targets are enabled
189    for target in targets {
190        if !enabled_set.contains(target) {
191            return Err(RefreshPlanError::TargetNotFound { target_id: *target });
192        }
193    }
194
195    Ok(RefreshPlan::new(targets.to_vec()))
196}
197
198/// Filter targets that need refresh based on staleness.
199///
200/// # Arguments
201/// * `all_targets` - List of (target_id, last_refresh_ns) pairs
202/// * `max_age_ns` - Maximum age before a target is considered stale
203/// * `current_ns` - Current timestamp in nanoseconds
204///
205/// # Returns
206/// List of target IDs that are stale and need refresh.
207#[must_use]
208pub fn filter_stale_targets(
209    all_targets: &[(TargetId, u64)],
210    max_age_ns: u64,
211    current_ns: u64,
212) -> Vec<TargetId> {
213    all_targets
214        .iter()
215        .filter_map(|(target_id, last_refresh)| {
216            let age = current_ns.saturating_sub(*last_refresh);
217            if age > max_age_ns {
218                Some(*target_id)
219            } else {
220                None
221            }
222        })
223        .collect()
224}