This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-xrpc / src / reservations.rs
6.5 kB 199 lines
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}