templar_curator_primitives/policy/market_lock/
mod.rs

1//! Fenced market leases for serialized policy and executor state transitions.
2//!
3//! These types do not provide synchronization by themselves. Safety comes from
4//! storing the registry in serialized executor state and enforcing issued
5//! fencing tokens on downstream mutations.
6
7use alloc::vec::Vec;
8use templar_vault_kernel::{TargetId, TimestampNs};
9
10use super::state::OrderedMap;
11
12#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
13#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct LeaseOwner(pub u64);
15
16#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
17#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub struct FencingToken(pub u64);
19
20#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
21#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct LeaseDurationNs(pub u64);
23
24#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
25#[derive(Clone, PartialEq, Eq)]
26pub struct MarketLease {
27    pub target_id: TargetId,
28    pub owner: LeaseOwner,
29    pub op_id: Option<u64>,
30    pub acquired_at: TimestampNs,
31    pub expires_at: TimestampNs,
32    pub fencing_token: FencingToken,
33}
34
35impl MarketLease {
36    #[must_use]
37    pub fn from_parts(
38        target_id: TargetId,
39        owner: LeaseOwner,
40        op_id: Option<u64>,
41        acquired_at: TimestampNs,
42        expires_at: TimestampNs,
43        fencing_token: FencingToken,
44    ) -> Self {
45        Self {
46            target_id,
47            owner,
48            op_id,
49            acquired_at,
50            expires_at,
51            fencing_token,
52        }
53    }
54
55    #[must_use]
56    pub fn is_expired(&self, now: TimestampNs) -> bool {
57        now >= self.expires_at
58    }
59
60    #[must_use]
61    pub fn remaining(&self, now: TimestampNs) -> LeaseDurationNs {
62        LeaseDurationNs(u64::from(self.expires_at).saturating_sub(u64::from(now)))
63    }
64}
65
66#[templar_vault_macros::vault_derive]
67#[derive(Clone, PartialEq, Eq)]
68pub enum AcquireLeaseError {
69    ZeroTtl,
70    ExpiryOverflow,
71    AlreadyLeased { existing: MarketLease },
72}
73
74#[templar_vault_macros::vault_derive]
75#[derive(Clone, PartialEq, Eq)]
76pub enum ReleaseLeaseError {
77    NotFound {
78        target_id: TargetId,
79    },
80    OwnerMismatch {
81        target_id: TargetId,
82        expected_owner: LeaseOwner,
83        actual_owner: LeaseOwner,
84    },
85    TokenMismatch {
86        target_id: TargetId,
87        expected_token: FencingToken,
88        actual_token: FencingToken,
89    },
90}
91
92#[templar_vault_macros::vault_derive]
93#[derive(Clone, PartialEq, Eq)]
94pub enum FencingError {
95    NotCurrent {
96        target_id: TargetId,
97        presented: FencingToken,
98        current: FencingToken,
99    },
100    NotFound {
101        target_id: TargetId,
102    },
103}
104
105#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
106#[derive(Clone, Default, PartialEq, Eq)]
107pub struct MarketLeaseRegistry {
108    leases_by_target: OrderedMap<TargetId, MarketLease>,
109    next_fencing_token: u64,
110}
111
112impl MarketLeaseRegistry {
113    #[must_use]
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    #[must_use]
119    pub fn is_empty(&self) -> bool {
120        self.leases_by_target.is_empty()
121    }
122
123    #[must_use]
124    pub fn next_fencing_token(&self) -> u64 {
125        self.next_fencing_token
126    }
127
128    pub fn iter(&self) -> impl Iterator<Item = (&TargetId, &MarketLease)> {
129        self.leases_by_target.iter()
130    }
131
132    #[must_use]
133    pub fn from_parts(
134        leases_by_target: OrderedMap<TargetId, MarketLease>,
135        next_fencing_token: u64,
136    ) -> Self {
137        let max_lease_token = leases_by_target
138            .values()
139            .map(|lease| lease.fencing_token.0)
140            .max()
141            .unwrap_or(0);
142        Self {
143            leases_by_target,
144            next_fencing_token: next_fencing_token.max(max_lease_token),
145        }
146    }
147
148    #[must_use]
149    pub fn stored_len(&self) -> usize {
150        self.leases_by_target.len()
151    }
152
153    #[must_use]
154    pub fn active_len(&self, now: TimestampNs) -> usize {
155        self.leases_by_target
156            .values()
157            .filter(|lease| !lease.is_expired(now))
158            .count()
159    }
160
161    #[must_use]
162    pub fn get(&self, target_id: TargetId) -> Option<&MarketLease> {
163        self.leases_by_target.get(&target_id)
164    }
165
166    #[must_use]
167    pub fn get_active(&self, target_id: TargetId, now: TimestampNs) -> Option<&MarketLease> {
168        self.get(target_id).filter(|lease| !lease.is_expired(now))
169    }
170
171    #[must_use]
172    pub fn is_leased(&self, target_id: TargetId, now: TimestampNs) -> bool {
173        self.get_active(target_id, now).is_some()
174    }
175
176    #[must_use]
177    pub fn is_leased_by_owner(
178        &self,
179        target_id: TargetId,
180        owner: &LeaseOwner,
181        now: TimestampNs,
182    ) -> bool {
183        self.get_active(target_id, now)
184            .is_some_and(|lease| &lease.owner == owner)
185    }
186
187    #[must_use]
188    pub fn leased_targets(&self, now: TimestampNs) -> Vec<TargetId> {
189        self.leases_by_target
190            .iter()
191            .filter_map(|(target_id, lease)| (!lease.is_expired(now)).then_some(*target_id))
192            .collect()
193    }
194
195    #[must_use]
196    pub fn find_leased_targets(&self, targets: &[TargetId], now: TimestampNs) -> Vec<TargetId> {
197        targets
198            .iter()
199            .copied()
200            .filter(|target_id| self.is_leased(*target_id, now))
201            .collect()
202    }
203
204    #[must_use]
205    pub fn cleanup_expired(&self, now: TimestampNs) -> Self {
206        let mut next = self.clone();
207        next.leases_by_target
208            .retain(|_, lease| !lease.is_expired(now));
209        next
210    }
211
212    #[must_use]
213    pub fn clear(&self) -> Self {
214        let mut next = self.clone();
215        next.leases_by_target.clear();
216        next
217    }
218
219    pub fn try_acquire(
220        &self,
221        target_id: TargetId,
222        owner: LeaseOwner,
223        op_id: Option<u64>,
224        now: TimestampNs,
225        ttl: LeaseDurationNs,
226    ) -> Result<(Self, MarketLease), AcquireLeaseError> {
227        if ttl.0 == 0 {
228            return Err(AcquireLeaseError::ZeroTtl);
229        }
230
231        let expires_at = u64::from(now)
232            .checked_add(ttl.0)
233            .map(TimestampNs)
234            .ok_or(AcquireLeaseError::ExpiryOverflow)?;
235
236        let cleaned = self.cleanup_expired(now);
237
238        if let Some(existing) = cleaned.get_active(target_id, now) {
239            if existing.owner != owner {
240                return Err(AcquireLeaseError::AlreadyLeased {
241                    existing: existing.clone(),
242                });
243            }
244        }
245
246        let next_fencing_token = cleaned
247            .next_fencing_token
248            .checked_add(1)
249            .expect("fencing token overflow should be unreachable");
250
251        let lease = MarketLease {
252            target_id,
253            owner,
254            op_id,
255            acquired_at: now,
256            expires_at,
257            fencing_token: FencingToken(next_fencing_token),
258        };
259
260        let mut next = cleaned;
261        next.next_fencing_token = next_fencing_token;
262        next.leases_by_target.insert(target_id, lease.clone());
263        Ok((next, lease))
264    }
265
266    pub fn release_if_owned(
267        &self,
268        target_id: TargetId,
269        owner: &LeaseOwner,
270    ) -> Result<Self, ReleaseLeaseError> {
271        let Some(existing) = self.leases_by_target.get(&target_id) else {
272            return Err(ReleaseLeaseError::NotFound { target_id });
273        };
274
275        if &existing.owner != owner {
276            return Err(ReleaseLeaseError::OwnerMismatch {
277                target_id,
278                expected_owner: existing.owner.clone(),
279                actual_owner: owner.clone(),
280            });
281        }
282
283        let mut next = self.clone();
284        next.leases_by_target.remove(&target_id);
285        Ok(next)
286    }
287
288    pub fn release_if_owned_with_token(
289        &self,
290        target_id: TargetId,
291        owner: &LeaseOwner,
292        token: FencingToken,
293    ) -> Result<Self, ReleaseLeaseError> {
294        let Some(existing) = self.leases_by_target.get(&target_id) else {
295            return Err(ReleaseLeaseError::NotFound { target_id });
296        };
297
298        if &existing.owner != owner {
299            return Err(ReleaseLeaseError::OwnerMismatch {
300                target_id,
301                expected_owner: existing.owner.clone(),
302                actual_owner: owner.clone(),
303            });
304        }
305
306        if existing.fencing_token != token {
307            return Err(ReleaseLeaseError::TokenMismatch {
308                target_id,
309                expected_token: existing.fencing_token,
310                actual_token: token,
311            });
312        }
313
314        let mut next = self.clone();
315        next.leases_by_target.remove(&target_id);
316        Ok(next)
317    }
318
319    #[must_use]
320    pub fn force_release(&self, target_id: TargetId) -> Self {
321        let mut next = self.clone();
322        next.leases_by_target.remove(&target_id);
323        next
324    }
325
326    #[must_use]
327    pub fn force_release_by_op(&self, op_id: u64) -> Self {
328        let mut next = self.clone();
329        next.leases_by_target
330            .retain(|_, lease| lease.op_id != Some(op_id));
331        next
332    }
333
334    pub fn assert_token_current(
335        &self,
336        target_id: TargetId,
337        token: FencingToken,
338        now: TimestampNs,
339    ) -> Result<(), FencingError> {
340        let Some(current) = self.get_active(target_id, now) else {
341            return Err(FencingError::NotFound { target_id });
342        };
343
344        if current.fencing_token != token {
345            return Err(FencingError::NotCurrent {
346                target_id,
347                presented: token,
348                current: current.fencing_token,
349            });
350        }
351
352        Ok(())
353    }
354}