This repository has no description
1use knot_cache::{Admitted, Expiring, GroupQuota, Quotas, Rejected, TotalQuota};
2use knot_runtime::UnixMicros;
3use knot_types::{AccountDid, RepoDid, UnixSeconds};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub(crate) enum ReserveDecision {
7 Fresh,
8 Renewed,
9 HeldByOther,
10 PerActorFull,
11 GlobalFull,
12}
13
14knot_types::scalar_newtype! {
15 pub struct ReservationTtl(i64);
16 pub struct PerActorQuota(usize);
17 pub struct GlobalQuota(usize);
18}
19
20pub struct Reservations {
21 held: Expiring<RepoDid, AccountDid, AccountDid>,
22 ttl: ReservationTtl,
23}
24
25fn micros(seconds: UnixSeconds) -> UnixMicros {
26 UnixMicros::new((seconds.get().max(0) as u64).saturating_mul(1_000_000))
27}
28
29impl Reservations {
30 pub fn new(ttl: ReservationTtl, per_actor: PerActorQuota, global: GlobalQuota) -> Self {
31 Self {
32 held: Expiring::new(Quotas {
33 per_group: GroupQuota::new(per_actor.get()),
34 total: TotalQuota::new(global.get()),
35 }),
36 ttl,
37 }
38 }
39
40 pub(crate) fn prune(&self, now: UnixSeconds) -> Vec<RepoDid> {
41 self.held.prune(micros(now))
42 }
43
44 pub(crate) fn try_reserve(
45 &self,
46 repo: &RepoDid,
47 actor: &AccountDid,
48 now: UnixSeconds,
49 ) -> ReserveDecision {
50 let expires_at = micros(now.saturating_add_secs(self.ttl.get()));
51 match self.held.admit_or_renew(
52 repo.clone(),
53 actor.clone(),
54 actor.clone(),
55 expires_at,
56 micros(now),
57 ) {
58 Ok(Admitted::Inserted) => ReserveDecision::Fresh,
59 Ok(Admitted::Occupied(holder)) if &holder == actor => ReserveDecision::Renewed,
60 Ok(Admitted::Occupied(_)) => ReserveDecision::HeldByOther,
61 Err(Rejected::Group) => ReserveDecision::PerActorFull,
62 Err(Rejected::Total) => ReserveDecision::GlobalFull,
63 }
64 }
65
66 pub(crate) fn holder_is(&self, repo: &RepoDid, actor: &AccountDid, now: UnixSeconds) -> bool {
67 self.held
68 .get(repo, micros(now))
69 .is_some_and(|holder| &holder == actor)
70 }
71
72 pub(crate) fn release(&self, repo: &RepoDid) {
73 self.held.remove(repo);
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 fn actor(suffix: &str) -> AccountDid {
82 AccountDid::new(format!("did:web:{suffix}")).unwrap()
83 }
84
85 fn repo(suffix: &str) -> RepoDid {
86 RepoDid::new(format!("did:web:{suffix}.olaren.dev")).unwrap()
87 }
88
89 fn at(seconds: i64) -> UnixSeconds {
90 UnixSeconds::new(seconds)
91 }
92
93 #[test]
94 fn a_reservation_binds_to_its_actor_and_blocks_a_stranger() {
95 let reservations = Reservations::new(
96 ReservationTtl::new(3_600),
97 PerActorQuota::new(8),
98 GlobalQuota::new(64),
99 );
100 assert_eq!(
101 reservations.try_reserve(&repo("squid"), &actor("nel.pet"), at(0)),
102 ReserveDecision::Fresh
103 );
104 assert_eq!(
105 reservations.try_reserve(&repo("squid"), &actor("olaren.dev"), at(1)),
106 ReserveDecision::HeldByOther,
107 "different account cannot take a live reservation"
108 );
109 assert!(reservations.holder_is(&repo("squid"), &actor("nel.pet"), at(1)));
110 assert!(!reservations.holder_is(&repo("squid"), &actor("olaren.dev"), at(1)));
111 }
112
113 #[test]
114 fn re_reserving_by_the_same_actor_renews_the_lease() {
115 let reservations = Reservations::new(
116 ReservationTtl::new(100),
117 PerActorQuota::new(8),
118 GlobalQuota::new(64),
119 );
120 assert_eq!(
121 reservations.try_reserve(&repo("squid"), &actor("nel.pet"), at(0)),
122 ReserveDecision::Fresh
123 );
124 assert_eq!(
125 reservations.try_reserve(&repo("squid"), &actor("nel.pet"), at(50)),
126 ReserveDecision::Renewed
127 );
128 assert!(
129 reservations.holder_is(&repo("squid"), &actor("nel.pet"), at(140)),
130 "renewal pushed the expiry out from the second call instead of the first"
131 );
132 }
133
134 #[test]
135 fn the_per_actor_limit_bounds_one_account_without_touching_another() {
136 let reservations = Reservations::new(
137 ReservationTtl::new(3_600),
138 PerActorQuota::new(2),
139 GlobalQuota::new(64),
140 );
141 assert_eq!(
142 reservations.try_reserve(&repo("a"), &actor("nel.pet"), at(0)),
143 ReserveDecision::Fresh
144 );
145 assert_eq!(
146 reservations.try_reserve(&repo("b"), &actor("nel.pet"), at(0)),
147 ReserveDecision::Fresh
148 );
149 assert_eq!(
150 reservations.try_reserve(&repo("c"), &actor("nel.pet"), at(0)),
151 ReserveDecision::PerActorFull,
152 "one account is held to its per-actor budget"
153 );
154 assert_eq!(
155 reservations.try_reserve(&repo("c"), &actor("olaren.dev"), at(0)),
156 ReserveDecision::Fresh,
157 "different account keeps its own budget"
158 );
159 }
160
161 #[test]
162 fn the_global_limit_bounds_the_total_across_accounts() {
163 let reservations = Reservations::new(
164 ReservationTtl::new(3_600),
165 PerActorQuota::new(64),
166 GlobalQuota::new(2),
167 );
168 assert_eq!(
169 reservations.try_reserve(&repo("a"), &actor("nel.pet"), at(0)),
170 ReserveDecision::Fresh
171 );
172 assert_eq!(
173 reservations.try_reserve(&repo("b"), &actor("olaren.dev"), at(0)),
174 ReserveDecision::Fresh
175 );
176 assert_eq!(
177 reservations.try_reserve(&repo("c"), &actor("teq.dev"), at(0)),
178 ReserveDecision::GlobalFull
179 );
180 }
181
182 #[test]
183 fn an_expired_reservation_is_pruned_and_frees_its_slot() {
184 let reservations = Reservations::new(
185 ReservationTtl::new(100),
186 PerActorQuota::new(8),
187 GlobalQuota::new(64),
188 );
189 reservations.try_reserve(&repo("squid"), &actor("nel.pet"), at(0));
190 assert!(reservations.prune(at(50)).is_empty(), "not yet expired");
191 let pruned = reservations.prune(at(150));
192 assert_eq!(pruned, vec![repo("squid")], "expired lease is reaped");
193 assert_eq!(
194 reservations.try_reserve(&repo("squid"), &actor("olaren.dev"), at(160)),
195 ReserveDecision::Fresh,
196 "once the lease lapses a different account may claim DID"
197 );
198 }
199}