This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-keyfill / tests / fill.rs
15 kB 442 lines
1use std::sync::atomic::{AtomicUsize, Ordering}; 2use std::sync::{Arc, Mutex}; 3 4use futures::StreamExt; 5use knot_atproto::Atproto; 6use knot_cob::{CobHome, CobStore}; 7use knot_cobs::{Grant, MembersChange, Registration, RegistryChange}; 8use knot_git::{Layout, Repo}; 9use knot_index::{Coverage, Index, KeyReprieve, KeyTtl, Resolved, SweepFloor}; 10use knot_keyfill::{ 11 AccountBudget, BusyRetry, Cursors, Pace, SettleFloor, SettledPause, StalledBackoff, fill_once, 12}; 13use knot_resource::{Burst, HostKey, HostPacer, RateLimit, RefillMicros, Slots}; 14use knot_runtime::{ 15 FakeHttp, HttpRequest, HttpResponse, K256Signer, ManualClock, NetworkError, SeededEntropy, 16 Signer, UnixMicros, 17}; 18use knot_types::{ 19 AccountDid, KnotId, OfferedKey, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds, 20}; 21use tempfile::TempDir; 22use tokio_util::sync::CancellationToken; 23use url::Url; 24 25const KNOT_DID: &str = "did:web:nel.pet"; 26const NEL: &str = "did:plc:nel"; 27const OLAREN: &str = "did:plc:olaren"; 28const TEQ: &str = "did:plc:teq"; 29const BAILEY: &str = "did:plc:bailey"; 30const NEL_PDS: &str = "https://pds.nel.pet"; 31const OLAREN_PDS: &str = "https://pds.olaren.dev"; 32 33type Responder = Box<dyn Fn(&HttpRequest) -> Result<HttpResponse, NetworkError> + Send + Sync>; 34 35fn responding(status: http::StatusCode, body: bytes::Bytes) -> HttpResponse { 36 HttpResponse { 37 status, 38 headers: http::HeaderMap::new(), 39 body, 40 } 41} 42 43struct Hosted { 44 owner: OwnerDid, 45 repo: RepoDid, 46 name: RepoName, 47} 48 49fn account(did: &str) -> AccountDid { 50 AccountDid::new(did).unwrap() 51} 52 53fn anemone() -> Hosted { 54 Hosted { 55 owner: OwnerDid::new(NEL).unwrap(), 56 repo: RepoDid::new("did:plc:squid").unwrap(), 57 name: RepoName::new("anemone").unwrap(), 58 } 59} 60 61fn barnacle() -> Hosted { 62 Hosted { 63 owner: OwnerDid::new(OLAREN).unwrap(), 64 repo: RepoDid::new("did:plc:limpet").unwrap(), 65 name: RepoName::new("barnacle").unwrap(), 66 } 67} 68 69fn hosted_index(scratch: &TempDir, repos: &[Hosted], members: &[AccountDid]) -> Arc<Index> { 70 let meta_path = scratch.path().join("meta"); 71 Repo::create(&meta_path).unwrap(); 72 let layout = Layout::new(scratch.path().join("repos")); 73 let meta = Repo::open(&meta_path).unwrap(); 74 let store = CobStore::new(&meta); 75 let home = CobHome::from(&KnotId::new(KNOT_DID).unwrap()); 76 let signer = K256Signer::generate(&SeededEntropy::new(3)); 77 let registration = |hosted: &Hosted| { 78 layout.create(&hosted.repo).unwrap(); 79 RegistryChange::Register(Registration { 80 owner: hosted.owner.clone(), 81 rkey: RepoRkey::new(hosted.name.as_str()).unwrap(), 82 name: hosted.name.clone(), 83 repo: hosted.repo.clone(), 84 created_at: UnixSeconds::new(1), 85 }) 86 }; 87 let (first, rest) = repos.split_first().expect("a knot under test hosts a repo"); 88 let registry = store 89 .create(&home, &registration(first), &signer, UnixSeconds::new(1)) 90 .unwrap() 91 .object; 92 rest.iter().for_each(|hosted| { 93 store 94 .update( 95 &home, 96 registry, 97 &registration(hosted), 98 &signer, 99 UnixSeconds::new(2), 100 ) 101 .unwrap(); 102 }); 103 104 let granted = |subject: &AccountDid| { 105 MembersChange::Add(Grant { 106 subject: subject.clone(), 107 added_by: account(NEL), 108 created_at: UnixSeconds::new(1), 109 }) 110 }; 111 if let Some((first, rest)) = members.split_first() { 112 let roll = store 113 .create(&home, &granted(first), &signer, UnixSeconds::new(1)) 114 .unwrap() 115 .object; 116 rest.iter().for_each(|subject| { 117 store 118 .update(&home, roll, &granted(subject), &signer, UnixSeconds::new(2)) 119 .unwrap(); 120 }); 121 } 122 123 let index = Arc::new(Index::new(meta_path, layout)); 124 index.rebuild().unwrap(); 125 index.warm_collaborators(); 126 index 127} 128 129fn atproto_with(responder: Responder) -> Arc<Atproto<FakeHttp<Responder>, ManualClock>> { 130 Arc::new(Atproto::new( 131 FakeHttp::new(responder), 132 ManualClock::new(UnixMicros::new(1_000_000_000)), 133 KnotId::new(KNOT_DID).unwrap(), 134 knot_atproto::PlcDirectory::new(Url::parse("https://plc.directory/").unwrap()).unwrap(), 135 )) 136} 137 138fn atproto_answering( 139 status: http::StatusCode, 140 calls: Arc<AtomicUsize>, 141) -> Arc<Atproto<FakeHttp<Responder>, ManualClock>> { 142 atproto_with(Box::new(move |_request: &HttpRequest| { 143 calls.fetch_add(1, Ordering::SeqCst); 144 Ok(responding(status, bytes::Bytes::new())) 145 })) 146} 147 148fn did_document(did: &str, handle: &str, pds: &str) -> bytes::Bytes { 149 let signing = K256Signer::generate(&SeededEntropy::new(5)); 150 let multibase = knot_types::crypto::multikey(0xe7, signing.public_key().as_bytes()); 151 let body = serde_json::json!({ 152 "id": did, 153 "alsoKnownAs": [format!("at://{handle}")], 154 "verificationMethod": [{ 155 "id": format!("{did}#atproto"), 156 "type": "Multikey", 157 "controller": did, 158 "publicKeyMultibase": multibase, 159 }], 160 "service": [{ 161 "id": "#atproto_pds", 162 "type": "AtprotoPersonalDataServer", 163 "serviceEndpoint": pds, 164 }] 165 }); 166 bytes::Bytes::from(serde_json::to_vec(&body).unwrap()) 167} 168 169fn atproto_serving_two_accounts() -> Arc<Atproto<FakeHttp<Responder>, ManualClock>> { 170 atproto_with(Box::new(move |request: &HttpRequest| { 171 let url = request.url.as_str(); 172 let body = if url.contains("listRecords") { 173 bytes::Bytes::from_static(br#"{"records":[]}"#) 174 } else if url.ends_with(NEL) { 175 did_document(NEL, "nel.pet", NEL_PDS) 176 } else if url.ends_with(OLAREN) { 177 did_document(OLAREN, "olaren.dev", OLAREN_PDS) 178 } else { 179 return Ok(responding(http::StatusCode::NOT_FOUND, bytes::Bytes::new())); 180 }; 181 Ok(responding(http::StatusCode::OK, body)) 182 })) 183} 184 185fn atproto_recording( 186 seen: Arc<Mutex<Vec<String>>>, 187) -> Arc<Atproto<FakeHttp<Responder>, ManualClock>> { 188 atproto_with(Box::new(move |request: &HttpRequest| { 189 let url = request.url.as_str(); 190 if url.contains("listRecords") { 191 return Ok(responding( 192 http::StatusCode::OK, 193 bytes::Bytes::from_static(br#"{"records":[]}"#), 194 )); 195 } 196 if url.ends_with(NEL) { 197 return Ok(responding( 198 http::StatusCode::OK, 199 did_document(NEL, "nel.pet", NEL_PDS), 200 )); 201 } 202 if let Some(did) = url.rsplit('/').next() { 203 seen.lock().unwrap().push(did.to_string()); 204 } 205 Ok(responding( 206 http::StatusCode::SERVICE_UNAVAILABLE, 207 bytes::Bytes::new(), 208 )) 209 })) 210} 211 212fn now() -> UnixSeconds { 213 UnixSeconds::new(1_000) 214} 215 216fn brisk() -> Pace { 217 Pace { 218 busy: BusyRetry::from_millis(0), 219 floor: SettleFloor::from_millis(0), 220 ttl: KeyTtl::from_secs(3_600), 221 reprieve: KeyReprieve::from_secs(300, 21_600), 222 sweep: SweepFloor::DEFAULT, 223 stalled: StalledBackoff::from_secs(1), 224 settled: SettledPause::from_secs(1), 225 members: AccountBudget::new(64), 226 suspected: AccountBudget::new(256), 227 host: RateLimit { 228 burst: Burst::new(1), 229 refill: RefillMicros::new(1_000), 230 }, 231 } 232} 233 234#[tokio::test(flavor = "multi_thread", worker_threads = 2)] 235async fn a_gone_document_completes_the_set_and_an_outage_leaves_it_warming() { 236 let outcome = |status: http::StatusCode| async move { 237 let scratch = tempfile::tempdir().unwrap(); 238 let index = hosted_index(&scratch, &[anemone()], &[]); 239 let calls = Arc::new(AtomicUsize::new(0)); 240 let atproto = atproto_answering(status, Arc::clone(&calls)); 241 let pacer = HostPacer::new(brisk().host); 242 assert_eq!(index.keys().coverage(), Coverage::Warming); 243 fill_once( 244 &index, 245 &atproto, 246 &Slots::testing(4), 247 &pacer, 248 brisk(), 249 &Cursors::default(), 250 ) 251 .await; 252 assert!( 253 calls.load(Ordering::SeqCst) > 0, 254 "the fill made an outbound request" 255 ); 256 index.keys().coverage() 257 }; 258 259 assert_eq!( 260 outcome(http::StatusCode::NOT_FOUND).await, 261 Coverage::Ready, 262 "a permanently unresolvable owner is recorded with an empty key set, so the set is complete" 263 ); 264 assert_eq!( 265 outcome(http::StatusCode::SERVICE_UNAVAILABLE).await, 266 Coverage::Warming, 267 "a transient failure doesn't teach the knot anything, so it mustn't claim a complete set" 268 ); 269} 270 271#[tokio::test(flavor = "multi_thread", worker_threads = 2)] 272async fn keys_the_knot_already_read_survive_an_outage_and_a_refused_listing() { 273 let scratch = tempfile::tempdir().unwrap(); 274 let index = hosted_index(&scratch, &[anemone()], &[]); 275 let key = OfferedKey::from_bytes(vec![9]); 276 let spent = KeyTtl::from_secs(1).lease_from(UnixSeconds::new(100)); 277 index.keys().record(&account(NEL), vec![key.clone()], spent); 278 let pacer = HostPacer::new(brisk().host); 279 280 let unreachable = atproto_answering(http::StatusCode::SERVICE_UNAVAILABLE, Arc::default()); 281 fill_once( 282 &index, 283 &unreachable, 284 &Slots::testing(4), 285 &pacer, 286 brisk(), 287 &Cursors::default(), 288 ) 289 .await; 290 assert_eq!( 291 index.keys().coverage(), 292 Coverage::Ready, 293 "an owner the knot has read before keeps its last keys through an outage, so one \ 294 unreachable PDS mustn't reopen the knot to every offered key" 295 ); 296 assert_eq!( 297 index.owner_of_key(&key, now()), 298 Resolved::Ready(Some(account(NEL))), 299 "the reprieve keeps the keys the knot last read" 300 ); 301 302 index.keys().record(&account(NEL), vec![key.clone()], spent); 303 let listing = Arc::new(AtomicUsize::new(0)); 304 let refusing = { 305 let listing = Arc::clone(&listing); 306 atproto_with(Box::new(move |request: &HttpRequest| { 307 let url = request.url.as_str(); 308 if url.contains("listRecords") { 309 listing.fetch_add(1, Ordering::SeqCst); 310 return Ok(responding( 311 http::StatusCode::BAD_REQUEST, 312 bytes::Bytes::new(), 313 )); 314 } 315 Ok(responding( 316 http::StatusCode::OK, 317 did_document(NEL, "nel.pet", NEL_PDS), 318 )) 319 })) 320 }; 321 fill_once( 322 &index, 323 &refusing, 324 &Slots::testing(4), 325 &pacer, 326 brisk(), 327 &Cursors::default(), 328 ) 329 .await; 330 assert!( 331 listing.load(Ordering::SeqCst) > 0, 332 "the fill read from the PDS" 333 ); 334 assert_eq!( 335 index.owner_of_key(&key, now()), 336 Resolved::Ready(Some(account(NEL))), 337 "an account whose DID document resolves hasn't gone anywhere, so the knot mustn't read \ 338 a 400 from a record listing as proof the account stopped publishing keys" 339 ); 340} 341 342#[tokio::test(flavor = "multi_thread", worker_threads = 2)] 343async fn the_fill_takes_a_turn_at_each_host_it_reads_from_and_stops_on_shutdown() { 344 let scratch = tempfile::tempdir().unwrap(); 345 let index = hosted_index(&scratch, &[anemone(), barnacle()], &[]); 346 let atproto = atproto_serving_two_accounts(); 347 let pacer = HostPacer::new(brisk().host); 348 349 fill_once( 350 &index, 351 &atproto, 352 &Slots::testing(4), 353 &pacer, 354 brisk(), 355 &Cursors::default(), 356 ) 357 .await; 358 359 assert_eq!( 360 index.keys().coverage(), 361 Coverage::Ready, 362 "both owners resolved, so every account that may push has a record" 363 ); 364 ["plc.directory", "pds.nel.pet", "pds.olaren.dev"] 365 .iter() 366 .for_each(|host| { 367 assert!( 368 !pacer.reserve_now(&HostKey::new(host), UnixMicros::new(0)), 369 "the fill must take a turn at {host} before it reads from it, \ 370 or a knot whose members share one PDS spends its whole rate at that host" 371 ); 372 }); 373 assert!( 374 pacer.reserve_now(&HostKey::new("pds.teq.dev"), UnixMicros::new(0)), 375 "a host the fill never read from is due immediately. The bookings above are the fill's \ 376 own work" 377 ); 378 379 let shutdown = CancellationToken::new(); 380 let task = knot_keyfill::spawn( 381 Arc::clone(&index), 382 Arc::clone(&atproto), 383 Slots::testing(4), 384 brisk(), 385 shutdown.clone(), 386 ); 387 shutdown.cancel(); 388 tokio::time::timeout(std::time::Duration::from_secs(5), task) 389 .await 390 .expect("a shutting-down knot mustn't wait out the pause between fill passes") 391 .expect("the fill task stops without panicking"); 392} 393 394#[tokio::test(flavor = "multi_thread", worker_threads = 2)] 395async fn members_are_read_whole_on_first_contact_then_renewed_one_budget_turn_per_pass() { 396 let scratch = tempfile::tempdir().unwrap(); 397 let members = [account(OLAREN), account(TEQ), account(BAILEY)]; 398 let index = hosted_index(&scratch, &[anemone()], &members); 399 let seen = Arc::new(Mutex::new(Vec::new())); 400 let atproto = atproto_recording(Arc::clone(&seen)); 401 let pacer = HostPacer::new(brisk().host); 402 let pace = Pace { 403 members: AccountBudget::new(1), 404 ..brisk() 405 }; 406 let cursor = Cursors::default(); 407 408 fill_once(&index, &atproto, &Slots::testing(4), &pacer, pace, &cursor).await; 409 assert_eq!( 410 index.keys().coverage(), 411 Coverage::Ready, 412 "the pushers are what coverage waits on, so an unreadable member mustn't make the knot \ 413 doubt the keys it checks pushes against" 414 ); 415 let mut attempted = seen.lock().unwrap().clone(); 416 attempted.sort(); 417 assert_eq!( 418 attempted, 419 vec![BAILEY.to_string(), OLAREN.to_string(), TEQ.to_string()], 420 "the renewal budget paces rereads, so a member the knot has never read mustn't wait \ 421 its turn behind it and be refused at the handshake for the passes in between" 422 ); 423 424 let spent = KeyTtl::from_secs(1).lease_from(UnixSeconds::new(0)); 425 members 426 .iter() 427 .for_each(|member| _ = index.keys().record(member, Vec::new(), spent)); 428 seen.lock().unwrap().clear(); 429 430 futures::stream::iter(0..3) 431 .for_each(|_| async { 432 fill_once(&index, &atproto, &Slots::testing(4), &pacer, pace, &cursor).await; 433 }) 434 .await; 435 let order = seen.lock().unwrap().clone(); 436 assert_eq!( 437 order.iter().map(String::as_str).collect::<Vec<_>>(), 438 vec![BAILEY, OLAREN, TEQ], 439 "one member per pass in turn, or members whose PDS stays down keep the front of \ 440 the queue and the knot never reads the rest" 441 ); 442}