Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
221
fork

Configure Feed

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

fix(lexicons): cache ttl

Lewis: May this revision serve well! <lu5a@proton.me>

authored by lu5a.myatproto.social and committed by

Tangled b9574f3e baef2be8

+456 -51
+23 -22
Cargo.lock
··· 7405 7405 7406 7406 [[package]] 7407 7407 name = "tranquil-api" 7408 - version = "0.5.4" 7408 + version = "0.5.5" 7409 7409 dependencies = [ 7410 7410 "anyhow", 7411 7411 "axum", ··· 7456 7456 7457 7457 [[package]] 7458 7458 name = "tranquil-auth" 7459 - version = "0.5.4" 7459 + version = "0.5.5" 7460 7460 dependencies = [ 7461 7461 "anyhow", 7462 7462 "base32", ··· 7479 7479 7480 7480 [[package]] 7481 7481 name = "tranquil-cache" 7482 - version = "0.5.4" 7482 + version = "0.5.5" 7483 7483 dependencies = [ 7484 7484 "async-trait", 7485 7485 "base64 0.22.1", ··· 7493 7493 7494 7494 [[package]] 7495 7495 name = "tranquil-comms" 7496 - version = "0.5.4" 7496 + version = "0.5.5" 7497 7497 dependencies = [ 7498 7498 "async-trait", 7499 7499 "base64 0.22.1", ··· 7511 7511 7512 7512 [[package]] 7513 7513 name = "tranquil-config" 7514 - version = "0.5.4" 7514 + version = "0.5.5" 7515 7515 dependencies = [ 7516 7516 "confique", 7517 7517 "serde", ··· 7519 7519 7520 7520 [[package]] 7521 7521 name = "tranquil-crypto" 7522 - version = "0.5.4" 7522 + version = "0.5.5" 7523 7523 dependencies = [ 7524 7524 "aes-gcm", 7525 7525 "base64 0.22.1", ··· 7535 7535 7536 7536 [[package]] 7537 7537 name = "tranquil-db" 7538 - version = "0.5.4" 7538 + version = "0.5.5" 7539 7539 dependencies = [ 7540 7540 "async-trait", 7541 7541 "chrono", ··· 7552 7552 7553 7553 [[package]] 7554 7554 name = "tranquil-db-traits" 7555 - version = "0.5.4" 7555 + version = "0.5.5" 7556 7556 dependencies = [ 7557 7557 "async-trait", 7558 7558 "base64 0.22.1", ··· 7568 7568 7569 7569 [[package]] 7570 7570 name = "tranquil-infra" 7571 - version = "0.5.4" 7571 + version = "0.5.5" 7572 7572 dependencies = [ 7573 7573 "async-trait", 7574 7574 "bytes", ··· 7579 7579 7580 7580 [[package]] 7581 7581 name = "tranquil-lexicon" 7582 - version = "0.5.4" 7582 + version = "0.5.5" 7583 7583 dependencies = [ 7584 7584 "chrono", 7585 + "futures", 7585 7586 "hickory-resolver", 7586 7587 "parking_lot", 7587 7588 "reqwest", ··· 7597 7598 7598 7599 [[package]] 7599 7600 name = "tranquil-oauth" 7600 - version = "0.5.4" 7601 + version = "0.5.5" 7601 7602 dependencies = [ 7602 7603 "anyhow", 7603 7604 "axum", ··· 7620 7621 7621 7622 [[package]] 7622 7623 name = "tranquil-oauth-server" 7623 - version = "0.5.4" 7624 + version = "0.5.5" 7624 7625 dependencies = [ 7625 7626 "axum", 7626 7627 "base64 0.22.1", ··· 7653 7654 7654 7655 [[package]] 7655 7656 name = "tranquil-pds" 7656 - version = "0.5.4" 7657 + version = "0.5.5" 7657 7658 dependencies = [ 7658 7659 "aes-gcm", 7659 7660 "anyhow", ··· 7745 7746 7746 7747 [[package]] 7747 7748 name = "tranquil-repo" 7748 - version = "0.5.4" 7749 + version = "0.5.5" 7749 7750 dependencies = [ 7750 7751 "bytes", 7751 7752 "cid", ··· 7757 7758 7758 7759 [[package]] 7759 7760 name = "tranquil-ripple" 7760 - version = "0.5.4" 7761 + version = "0.5.5" 7761 7762 dependencies = [ 7762 7763 "async-trait", 7763 7764 "backon", ··· 7782 7783 7783 7784 [[package]] 7784 7785 name = "tranquil-scopes" 7785 - version = "0.5.4" 7786 + version = "0.5.5" 7786 7787 dependencies = [ 7787 7788 "axum", 7788 7789 "futures", ··· 7798 7799 7799 7800 [[package]] 7800 7801 name = "tranquil-server" 7801 - version = "0.5.4" 7802 + version = "0.5.5" 7802 7803 dependencies = [ 7803 7804 "axum", 7804 7805 "clap", ··· 7819 7820 7820 7821 [[package]] 7821 7822 name = "tranquil-signal" 7822 - version = "0.5.4" 7823 + version = "0.5.5" 7823 7824 dependencies = [ 7824 7825 "async-trait", 7825 7826 "chrono", ··· 7842 7843 7843 7844 [[package]] 7844 7845 name = "tranquil-storage" 7845 - version = "0.5.4" 7846 + version = "0.5.5" 7846 7847 dependencies = [ 7847 7848 "async-trait", 7848 7849 "aws-config", ··· 7859 7860 7860 7861 [[package]] 7861 7862 name = "tranquil-store" 7862 - version = "0.5.4" 7863 + version = "0.5.5" 7863 7864 dependencies = [ 7864 7865 "async-trait", 7865 7866 "bytes", ··· 7906 7907 7907 7908 [[package]] 7908 7909 name = "tranquil-sync" 7909 - version = "0.5.4" 7910 + version = "0.5.5" 7910 7911 dependencies = [ 7911 7912 "anyhow", 7912 7913 "axum", ··· 7928 7929 7929 7930 [[package]] 7930 7931 name = "tranquil-types" 7931 - version = "0.5.4" 7932 + version = "0.5.5" 7932 7933 dependencies = [ 7933 7934 "chrono", 7934 7935 "cid",
+1 -1
Cargo.toml
··· 26 26 ] 27 27 28 28 [workspace.package] 29 - version = "0.5.4" 29 + version = "0.5.5" 30 30 edition = "2024" 31 31 license = "AGPL-3.0-or-later" 32 32
+1
crates/tranquil-lexicon/Cargo.toml
··· 24 24 [dev-dependencies] 25 25 wiremock = { workspace = true } 26 26 tokio = { workspace = true } 27 + futures = { workspace = true }
+428 -27
crates/tranquil-lexicon/src/dynamic.rs
··· 5 5 use std::sync::Arc; 6 6 use std::sync::atomic::{AtomicBool, Ordering}; 7 7 use std::time::{Duration, Instant}; 8 + use tokio::sync::Notify; 8 9 9 10 const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); 11 + const POSITIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); 12 + const REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(60); 10 13 const MAX_DYNAMIC_SCHEMAS: usize = 1024; 11 14 12 15 struct NegativeEntry { 13 16 expires_at: Instant, 14 17 } 15 18 19 + struct PositiveEntry { 20 + doc: Arc<LexiconDoc>, 21 + expires_at: Instant, 22 + } 23 + 24 + pub(crate) enum CacheEntry { 25 + Fresh(Arc<LexiconDoc>), 26 + Stale(Arc<LexiconDoc>), 27 + } 28 + 29 + impl CacheEntry { 30 + #[cfg(test)] 31 + fn is_fresh(&self) -> bool { 32 + matches!(self, Self::Fresh(_)) 33 + } 34 + } 35 + 16 36 struct SchemaStore { 17 - schemas: HashMap<String, Arc<LexiconDoc>>, 37 + schemas: HashMap<String, PositiveEntry>, 18 38 insertion_order: VecDeque<String>, 19 39 } 20 40 21 41 pub struct DynamicRegistry { 22 42 store: RwLock<SchemaStore>, 23 43 negative_cache: RwLock<HashMap<String, NegativeEntry>>, 44 + in_flight: RwLock<HashMap<String, Arc<Notify>>>, 24 45 network_disabled: AtomicBool, 25 46 } 26 47 48 + struct InFlightGuard<'a> { 49 + registry: &'a DynamicRegistry, 50 + nsid: String, 51 + } 52 + 53 + impl Drop for InFlightGuard<'_> { 54 + fn drop(&mut self) { 55 + let notify = self.registry.in_flight.write().remove(&self.nsid); 56 + if let Some(n) = notify { 57 + n.notify_waiters(); 58 + } 59 + } 60 + } 61 + 27 62 impl DynamicRegistry { 28 63 pub fn new() -> Self { 29 64 let network_disabled = ··· 34 69 insertion_order: VecDeque::new(), 35 70 }), 36 71 negative_cache: RwLock::new(HashMap::new()), 72 + in_flight: RwLock::new(HashMap::new()), 37 73 network_disabled: AtomicBool::new(network_disabled), 38 74 } 39 75 } ··· 43 79 self.network_disabled.store(disabled, Ordering::Relaxed); 44 80 } 45 81 46 - pub fn get(&self, nsid: &str) -> Option<Arc<LexiconDoc>> { 47 - self.store.read().schemas.get(nsid).cloned() 82 + pub fn get_cached(&self, nsid: &str) -> Option<Arc<LexiconDoc>> { 83 + self.store 84 + .read() 85 + .schemas 86 + .get(nsid) 87 + .map(|e| Arc::clone(&e.doc)) 88 + } 89 + 90 + pub(crate) fn get_entry(&self, nsid: &str) -> Option<CacheEntry> { 91 + let now = Instant::now(); 92 + self.store.read().schemas.get(nsid).map(|e| { 93 + if e.expires_at > now { 94 + CacheEntry::Fresh(Arc::clone(&e.doc)) 95 + } else { 96 + CacheEntry::Stale(Arc::clone(&e.doc)) 97 + } 98 + }) 48 99 } 49 100 50 101 pub fn is_negative_cached(&self, nsid: &str) -> bool { ··· 56 107 57 108 fn insert_negative(&self, nsid: &str) { 58 109 let mut cache = self.negative_cache.write(); 59 - if cache.len() > MAX_DYNAMIC_SCHEMAS { 110 + if cache.len() >= MAX_DYNAMIC_SCHEMAS { 60 111 let now = Instant::now(); 61 112 cache.retain(|_, entry| entry.expires_at > now); 62 113 } ··· 87 138 }); 88 139 } 89 140 90 - if store 91 - .schemas 92 - .insert(nsid.clone(), Arc::clone(&arc)) 93 - .is_some() 94 - { 141 + let entry = PositiveEntry { 142 + doc: Arc::clone(&arc), 143 + expires_at: Instant::now() + POSITIVE_CACHE_TTL, 144 + }; 145 + if store.schemas.insert(nsid.clone(), entry).is_some() { 95 146 store.insertion_order.retain(|k| k != &nsid); 96 147 } 97 148 store.insertion_order.push_back(nsid.clone()); 149 + drop(store); 98 150 99 151 self.negative_cache.write().remove(&arc.id); 100 152 101 153 arc 102 154 } 103 155 156 + fn bump_expiry(&self, nsid: &str, duration: Duration) { 157 + let mut store = self.store.write(); 158 + if let Some(entry) = store.schemas.get_mut(nsid) { 159 + entry.expires_at = Instant::now() + duration; 160 + } 161 + } 162 + 104 163 pub async fn resolve_and_cache(&self, nsid: &str) -> Result<Arc<LexiconDoc>, ResolveError> { 105 - if let Some(doc) = self.get(nsid) { 106 - return Ok(doc); 164 + self.resolve_and_cache_with(nsid, |n| async move { resolve_lexicon(&n).await }) 165 + .await 166 + } 167 + 168 + async fn resolve_and_cache_with<F, Fut>( 169 + &self, 170 + nsid: &str, 171 + resolver: F, 172 + ) -> Result<Arc<LexiconDoc>, ResolveError> 173 + where 174 + F: FnOnce(String) -> Fut, 175 + Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>, 176 + { 177 + match self.get_entry(nsid) { 178 + Some(CacheEntry::Fresh(doc)) => Ok(doc), 179 + Some(CacheEntry::Stale(stale)) => self.refresh_stale(nsid, stale, resolver).await, 180 + None => self.resolve_fresh(nsid, resolver).await, 107 181 } 182 + } 108 183 184 + async fn refresh_stale<F, Fut>( 185 + &self, 186 + nsid: &str, 187 + stale: Arc<LexiconDoc>, 188 + resolver: F, 189 + ) -> Result<Arc<LexiconDoc>, ResolveError> 190 + where 191 + F: FnOnce(String) -> Fut, 192 + Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>, 193 + { 109 194 if self.network_disabled.load(Ordering::Relaxed) { 110 - return Err(ResolveError::NetworkDisabled); 195 + return Ok(stale); 111 196 } 112 197 198 + match self.acquire_leadership(nsid) { 199 + Some(_guard) => match resolver(nsid.to_string()).await { 200 + Ok(doc) => Ok(self.insert_schema(doc)), 201 + Err(e) => { 202 + self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF); 203 + tracing::warn!( 204 + nsid = nsid, 205 + error = %e, 206 + "lexicon refresh failed, serving stale cached entry" 207 + ); 208 + Ok(stale) 209 + } 210 + }, 211 + None => { 212 + self.wait_for_leader(nsid).await; 213 + Ok(self.get_cached(nsid).unwrap_or(stale)) 214 + } 215 + } 216 + } 217 + 218 + async fn resolve_fresh<F, Fut>( 219 + &self, 220 + nsid: &str, 221 + resolver: F, 222 + ) -> Result<Arc<LexiconDoc>, ResolveError> 223 + where 224 + F: FnOnce(String) -> Fut, 225 + Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>, 226 + { 227 + if self.network_disabled.load(Ordering::Relaxed) { 228 + return Err(ResolveError::NetworkDisabled); 229 + } 113 230 if self.is_negative_cached(nsid) { 114 231 return Err(ResolveError::NegativelyCached { 115 232 nsid: nsid.to_string(), ··· 117 234 }); 118 235 } 119 236 120 - match resolve_lexicon(nsid).await { 121 - Ok(doc) => Ok(self.insert_schema(doc)), 122 - Err(e) => { 123 - tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result"); 124 - self.insert_negative(nsid); 125 - Err(e) 237 + match self.acquire_leadership(nsid) { 238 + Some(_guard) => match resolver(nsid.to_string()).await { 239 + Ok(doc) => Ok(self.insert_schema(doc)), 240 + Err(e) => { 241 + self.insert_negative(nsid); 242 + tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result"); 243 + Err(e) 244 + } 245 + }, 246 + None => { 247 + self.wait_for_leader(nsid).await; 248 + match self.get_cached(nsid) { 249 + Some(doc) => Ok(doc), 250 + None if self.is_negative_cached(nsid) => { 251 + Err(ResolveError::NegativelyCached { 252 + nsid: nsid.to_string(), 253 + ttl_secs: NEGATIVE_CACHE_TTL.as_secs(), 254 + }) 255 + } 256 + None => Err(ResolveError::LeaderAborted { 257 + nsid: nsid.to_string(), 258 + }), 259 + } 126 260 } 127 261 } 128 262 } 129 263 264 + fn acquire_leadership(&self, nsid: &str) -> Option<InFlightGuard<'_>> { 265 + let mut map = self.in_flight.write(); 266 + if map.contains_key(nsid) { 267 + None 268 + } else { 269 + map.insert(nsid.to_string(), Arc::new(Notify::new())); 270 + Some(InFlightGuard { 271 + registry: self, 272 + nsid: nsid.to_string(), 273 + }) 274 + } 275 + } 276 + 277 + async fn wait_for_leader(&self, nsid: &str) { 278 + let notify = { 279 + let map = self.in_flight.read(); 280 + match map.get(nsid) { 281 + Some(n) => Arc::clone(n), 282 + None => return, 283 + } 284 + }; 285 + let notified = notify.notified(); 286 + tokio::pin!(notified); 287 + notified.as_mut().enable(); 288 + let still_active = self.in_flight.read().contains_key(nsid); 289 + if !still_active { 290 + return; 291 + } 292 + notified.as_mut().await; 293 + } 294 + 130 295 pub fn schema_count(&self) -> usize { 131 296 self.store.read().schemas.len() 297 + } 298 + 299 + #[cfg(test)] 300 + fn expire_now(&self, nsid: &str) { 301 + let mut store = self.store.write(); 302 + if let Some(entry) = store.schemas.get_mut(nsid) { 303 + entry.expires_at = Instant::now(); 304 + } 132 305 } 133 306 } 134 307 ··· 171 344 #[test] 172 345 fn test_empty_lookup() { 173 346 let registry = DynamicRegistry::new(); 174 - assert!(registry.get("com.example.nonexistent").is_none()); 347 + assert!(registry.get_cached("com.example.nonexistent").is_none()); 175 348 assert_eq!(registry.schema_count(), 0); 176 349 } 177 350 ··· 188 361 assert_eq!(arc.id, "com.example.test"); 189 362 assert_eq!(registry.schema_count(), 1); 190 363 191 - let retrieved = registry.get("com.example.test"); 364 + let retrieved = registry.get_cached("com.example.test"); 192 365 assert!(retrieved.is_some()); 193 366 assert_eq!(retrieved.unwrap().id, "com.example.test"); 367 + 368 + let entry = registry.get_entry("com.example.test").unwrap(); 369 + assert!(entry.is_fresh(), "freshly inserted entry must be fresh"); 194 370 } 195 371 196 372 #[test] ··· 211 387 } 212 388 213 389 #[test] 390 + fn test_positive_entry_reports_stale_after_ttl() { 391 + let registry = DynamicRegistry::new(); 392 + let doc = LexiconDoc { 393 + lexicon: 1, 394 + id: "pet.nel.stale".to_string(), 395 + defs: HashMap::new(), 396 + }; 397 + registry.insert_schema(doc); 398 + 399 + assert!(registry.get_entry("pet.nel.stale").unwrap().is_fresh()); 400 + 401 + registry.expire_now("pet.nel.stale"); 402 + 403 + assert!( 404 + !registry.get_entry("pet.nel.stale").unwrap().is_fresh(), 405 + "entry past expiry must be reported stale" 406 + ); 407 + } 408 + 409 + #[tokio::test] 410 + async fn test_stale_served_on_resolve_failure() { 411 + let registry = DynamicRegistry::new(); 412 + let doc = LexiconDoc { 413 + lexicon: 1, 414 + id: "pet.nel.flaky".to_string(), 415 + defs: HashMap::new(), 416 + }; 417 + registry.insert_schema(doc); 418 + registry.expire_now("pet.nel.flaky"); 419 + 420 + let result = registry 421 + .resolve_and_cache_with("pet.nel.flaky", |n| async move { 422 + Err::<LexiconDoc, _>(ResolveError::DnsLookup { 423 + domain: n, 424 + reason: "simulated failure".to_string(), 425 + }) 426 + }) 427 + .await; 428 + 429 + let served = result.expect("stale entry must be served when refresh fails"); 430 + assert_eq!(served.id, "pet.nel.flaky"); 431 + assert!( 432 + registry 433 + .get_entry("pet.nel.flaky") 434 + .unwrap() 435 + .is_fresh(), 436 + "failed refresh must bump expiry so subsequent lookups skip the resolver" 437 + ); 438 + assert!( 439 + !registry.is_negative_cached("pet.nel.flaky"), 440 + "stale refresh failure must not poison negative cache" 441 + ); 442 + } 443 + 444 + #[tokio::test] 445 + async fn test_fresh_hit_skips_resolver() { 446 + let registry = DynamicRegistry::new(); 447 + let doc = LexiconDoc { 448 + lexicon: 1, 449 + id: "pet.nel.fresh".to_string(), 450 + defs: HashMap::new(), 451 + }; 452 + registry.insert_schema(doc); 453 + 454 + let result = registry 455 + .resolve_and_cache_with("pet.nel.fresh", |_| async move { 456 + panic!("resolver must not run on fresh hit") 457 + }) 458 + .await; 459 + 460 + assert!(result.is_ok()); 461 + } 462 + 463 + #[tokio::test] 464 + async fn test_stale_served_when_network_disabled() { 465 + let registry = DynamicRegistry::new(); 466 + let doc = LexiconDoc { 467 + lexicon: 1, 468 + id: "pet.nel.offline".to_string(), 469 + defs: HashMap::new(), 470 + }; 471 + registry.insert_schema(doc); 472 + registry.expire_now("pet.nel.offline"); 473 + registry.set_network_disabled(true); 474 + 475 + let result = registry 476 + .resolve_and_cache_with("pet.nel.offline", |_| async move { 477 + panic!("resolver must not run when network disabled") 478 + }) 479 + .await; 480 + 481 + assert!(result.is_ok()); 482 + } 483 + 484 + #[tokio::test] 485 + async fn test_successful_refresh_updates_cached_at() { 486 + let registry = DynamicRegistry::new(); 487 + let doc = LexiconDoc { 488 + lexicon: 1, 489 + id: "pet.nel.refresh".to_string(), 490 + defs: HashMap::new(), 491 + }; 492 + registry.insert_schema(doc); 493 + registry.expire_now("pet.nel.refresh"); 494 + 495 + assert!( 496 + !registry 497 + .get_entry("pet.nel.refresh") 498 + .unwrap() 499 + .is_fresh() 500 + ); 501 + 502 + let refreshed = registry 503 + .resolve_and_cache_with("pet.nel.refresh", |n| async move { 504 + Ok(LexiconDoc { 505 + lexicon: 1, 506 + id: n, 507 + defs: HashMap::new(), 508 + }) 509 + }) 510 + .await 511 + .unwrap(); 512 + 513 + assert_eq!(refreshed.id, "pet.nel.refresh"); 514 + assert!( 515 + registry 516 + .get_entry("pet.nel.refresh") 517 + .unwrap() 518 + .is_fresh(), 519 + "refresh must restore freshness" 520 + ); 521 + } 522 + 523 + #[tokio::test] 524 + async fn test_single_flight_dedups_concurrent_resolves() { 525 + use std::sync::atomic::AtomicUsize; 526 + let registry = Arc::new(DynamicRegistry::new()); 527 + let calls = Arc::new(AtomicUsize::new(0)); 528 + 529 + let tasks: Vec<_> = (0..16) 530 + .map(|_| { 531 + let registry = Arc::clone(&registry); 532 + let calls = Arc::clone(&calls); 533 + tokio::spawn(async move { 534 + registry 535 + .resolve_and_cache_with("pet.nel.herd", |n| { 536 + let calls = Arc::clone(&calls); 537 + async move { 538 + calls.fetch_add(1, Ordering::SeqCst); 539 + tokio::time::sleep(Duration::from_millis(50)).await; 540 + Ok(LexiconDoc { 541 + lexicon: 1, 542 + id: n, 543 + defs: HashMap::new(), 544 + }) 545 + } 546 + }) 547 + .await 548 + }) 549 + }) 550 + .collect(); 551 + 552 + let results = futures_collect(tasks).await; 553 + results 554 + .iter() 555 + .for_each(|r| assert!(r.is_ok(), "all single-flight callers must succeed")); 556 + assert_eq!( 557 + calls.load(Ordering::SeqCst), 558 + 1, 559 + "single-flight must coalesce concurrent resolves" 560 + ); 561 + assert_eq!(registry.schema_count(), 1); 562 + } 563 + 564 + #[tokio::test] 565 + async fn test_single_flight_followers_observe_leader_failure() { 566 + use std::sync::atomic::AtomicUsize; 567 + let registry = Arc::new(DynamicRegistry::new()); 568 + let calls = Arc::new(AtomicUsize::new(0)); 569 + 570 + let tasks: Vec<_> = (0..8) 571 + .map(|_| { 572 + let registry = Arc::clone(&registry); 573 + let calls = Arc::clone(&calls); 574 + tokio::spawn(async move { 575 + registry 576 + .resolve_and_cache_with("pet.nel.failHerd", |n| { 577 + let calls = Arc::clone(&calls); 578 + async move { 579 + calls.fetch_add(1, Ordering::SeqCst); 580 + tokio::time::sleep(Duration::from_millis(50)).await; 581 + Err::<LexiconDoc, _>(ResolveError::DnsLookup { 582 + domain: n, 583 + reason: "simulated".to_string(), 584 + }) 585 + } 586 + }) 587 + .await 588 + }) 589 + }) 590 + .collect(); 591 + 592 + let results = futures_collect(tasks).await; 593 + results 594 + .iter() 595 + .for_each(|r| assert!(r.is_err(), "all followers must observe leader failure")); 596 + assert_eq!( 597 + calls.load(Ordering::SeqCst), 598 + 1, 599 + "single-flight must coalesce failing resolves too" 600 + ); 601 + assert!(registry.is_negative_cached("pet.nel.failHerd")); 602 + } 603 + 604 + async fn futures_collect<T>( 605 + handles: Vec<tokio::task::JoinHandle<T>>, 606 + ) -> Vec<T> { 607 + futures::future::join_all(handles) 608 + .await 609 + .into_iter() 610 + .map(|r| r.expect("task panicked")) 611 + .collect() 612 + } 613 + 614 + #[test] 214 615 fn test_eviction_is_fifo() { 215 616 let registry = DynamicRegistry::new(); 216 617 217 618 (0..MAX_DYNAMIC_SCHEMAS).for_each(|i| { 218 619 let doc = LexiconDoc { 219 620 lexicon: 1, 220 - id: format!("com.example.schema{}", i), 621 + id: format!("pet.nel.schema{}", i), 221 622 defs: HashMap::new(), 222 623 }; 223 624 registry.insert_schema(doc); ··· 226 627 227 628 let trigger = LexiconDoc { 228 629 lexicon: 1, 229 - id: "com.example.trigger".to_string(), 630 + id: "pet.nel.trigger".to_string(), 230 631 defs: HashMap::new(), 231 632 }; 232 633 registry.insert_schema(trigger); 233 634 234 635 assert!( 235 - registry.get("com.example.schema0").is_none(), 636 + registry.get_cached("pet.nel.schema0").is_none(), 236 637 "oldest entry should be evicted" 237 638 ); 238 639 assert!( 239 - registry.get("com.example.trigger").is_some(), 640 + registry.get_cached("pet.nel.trigger").is_some(), 240 641 "newly inserted entry should exist" 241 642 ); 242 643 let evict_count = MAX_DYNAMIC_SCHEMAS / 4; 243 644 assert!( 244 645 registry 245 - .get(&format!("com.example.schema{}", evict_count)) 646 + .get_cached(&format!("pet.nel.schema{}", evict_count)) 246 647 .is_some(), 247 648 "entry after eviction window should survive" 248 649 ); ··· 253 654 let registry = DynamicRegistry::new(); 254 655 let doc = LexiconDoc { 255 656 lexicon: 1, 256 - id: "com.example.tracked".to_string(), 657 + id: "pet.nel.tracked".to_string(), 257 658 defs: HashMap::new(), 258 659 }; 259 660 let arc = registry.insert_schema(doc); ··· 265 666 (0..MAX_DYNAMIC_SCHEMAS).for_each(|i| { 266 667 registry.insert_schema(LexiconDoc { 267 668 lexicon: 1, 268 - id: format!("com.example.filler{}", i), 669 + id: format!("pet.nel.filler{}", i), 269 670 defs: HashMap::new(), 270 671 }); 271 672 });
+1 -1
crates/tranquil-lexicon/src/registry.rs
··· 43 43 self.schemas.get(nsid).cloned().or_else(|| { 44 44 #[cfg(feature = "resolve")] 45 45 { 46 - self.dynamic.get(nsid) 46 + self.dynamic.get_cached(nsid) 47 47 } 48 48 #[cfg(not(feature = "resolve"))] 49 49 {
+2
crates/tranquil-lexicon/src/resolve.rs
··· 70 70 NegativelyCached { nsid: String, ttl_secs: u64 }, 71 71 #[error("network resolution disabled")] 72 72 NetworkDisabled, 73 + #[error("leader task for {nsid} aborted before completion")] 74 + LeaderAborted { nsid: String }, 73 75 } 74 76 75 77 pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {