didbot_lookup/lib.rs
1//! Looks up one named record in a repository this deployment does not
2//! hold, and caches the answer.
3//!
4//! The policy gate decides a write from the subject alone: the account, its
5//! kind, handle and depth, the client, the collection, the action and the
6//! diff. Three rules the floor wants — an author's `app.bsky.feed.threadgate`
7//! and `app.bsky.feed.postgate`, an `app.bsky.graph.block`, and a declared
8//! `community.lexicon.preference.ai` — each need one more fact, and it is the
9//! same fact for all three: the value of a public record in somebody else's
10//! repository. This crate is that capability, built once, for the evaluators
11//! that will ask for it.
12//!
13//! # Why its own crate
14//!
15//! It performs network I/O, and the two crates that would otherwise host it
16//! cannot. `didbot-policy` is the policy core: no I/O, no async, no clock,
17//! and [`Evaluator::evaluate`] takes a subject and nothing else precisely so
18//! a predicate cannot reach the network from inside a decision.
19//! `didbot-pds` holds no HTTP client and no async runtime either — the
20//! operator poll's fetch lives in `didbot-serve` for exactly that reason.
21//! Putting the reader in either would give both a `reqwest` dependency they
22//! are deliberately without. So it is a crate of its own, depending on
23//! `didbot-http` and `didbot-identity` and on neither policy crate, which
24//! leaves an evaluator free to depend on it without `didbot-serve` coming
25//! along.
26//!
27//! [`Evaluator::evaluate`]: https://docs.rs/didbot-policy
28//!
29//! # How an evaluator reaches it
30//!
31//! Two phases, because the decision channel is synchronous and the fetch is
32//! not. `didbot-pds`'s write queue calls the gate inside a synchronous
33//! critical section, so nothing under it can await.
34//!
35//! 1. **Prefetch.** The write path, which has a runtime, calls
36//! [`ForeignRecords::prefetch`] with the records this write needs. It
37//! resolves and fetches what the cache does not already hold, bounded by
38//! [`Limits::record_deadline`] per record and [`Limits::prefetch_deadline`]
39//! for the call.
40//! 2. **Read.** An evaluator holding an `Arc<ForeignRecords>` from its own
41//! construction calls [`ForeignRecords::read`], which takes a lock, reads
42//! the cache and returns. It performs no I/O and does not await, so it is
43//! callable from inside a decision.
44//!
45//! That split is what keeps the regex and Cedar engines untouched: they hold
46//! no reader, ask for no records, and their evaluation is unchanged.
47//!
48//! # What an unknown means
49//!
50//! [`Answer`] has three variants and the third one matters most. `Present`
51//! and `Absent` are both things the repository said. [`Answer::Unknown`] is
52//! everything else — not fetched yet, past the deadline, refused, a DID that
53//! does not resolve, a server that did not answer — and this reader never
54//! turns one into a refusal or into a permit. The evaluator that asked
55//! decides what an unknown means for its own rule, because only it knows
56//! whether the conservative reading of its rule is to deny or to stand
57//! aside.
58//!
59//! That leaves `Staleness` and `EvalError` to the evaluator too. A reader
60//! whose answers may be up to [`Limits::present_ttl`] old is a `BestEffort`
61//! view, and an evaluator built on it declares a `max_lag` it can live with;
62//! this crate reports no lag of its own and raises no `EvalError::Stale`,
63//! because a cache entry inside its TTL is not a stale view — it is the
64//! view, and the TTL is the propagation delay `plan/ai-preference.md` asks
65//! to have written down rather than discovered.
66//!
67//! # Memory and network, declared
68//!
69//! - At most [`Limits::entries`] answers are held, each at most
70//! [`MAX_RECORD_BYTES`] of JSON, so the cache costs at most those
71//! multiplied together. At the defaults that is 1024 records of 64 KiB,
72//! 64 MiB in the worst case and around a megabyte for records of the size
73//! a gate or a preference actually is.
74//! - One reference costs at most two requests: the DID document, then
75//! `com.atproto.repo.getRecord` for one record, or
76//! `com.atproto.repo.listRecords` for a whole collection. Both read a
77//! bounded body, and a collection read takes at most
78//! [`MAX_COLLECTION_RECORDS`] records inside that same bound.
79//! - One prefetch fetches at most [`Limits::per_write`] records, each
80//! bounded by [`Limits::record_deadline`], and the whole call by
81//! [`Limits::prefetch_deadline`].
82//! - The cache is memory only. Nothing here writes to disk, and a restart
83//! begins with an empty cache — which costs one fetch per record and can
84//! never resurrect an answer somebody has since changed.
85
86mod fetch;
87mod record;
88
89pub use fetch::{
90 Fetch, FetchFuture, Fetched, LocalRepositories, Network, NoLocalRepositories, Unknown,
91 MAX_COLLECTION_RECORDS, MAX_RECORD_BYTES,
92};
93pub use record::{RecordRef, RefError};
94
95use std::num::NonZeroUsize;
96use std::sync::atomic::{AtomicU64, Ordering};
97use std::sync::{Arc, Mutex};
98use std::time::Duration;
99
100// `tokio`'s clock, not `std`'s: a TTL and a deadline measured against it can
101// be advanced by a test without waiting, and it is the same instant in a
102// running server.
103use tokio::time::Instant;
104
105use serde_json::Value;
106
107/// How many answers are held at once, absent configuration.
108///
109/// **Reasonable range: 64 to 16384.** See this crate's memory bound: the
110/// cache costs this many times [`MAX_RECORD_BYTES`] in the worst case. A
111/// thousand is a busy deployment's working set of reply gates over a TTL,
112/// and small enough that the worst case is still a fraction of one process.
113pub const DEFAULT_ENTRIES: usize = 1024;
114
115/// How long a record this reader read is reused, absent configuration.
116///
117/// **Reasonable range: 60 to 3600 seconds.** Nothing tells this deployment
118/// that a stranger changed a record — it consumes no firehose — so this TTL
119/// *is* the propagation delay, and it is stated rather than discovered.
120/// Fifteen minutes is short enough that a person who opens their replies
121/// sees it take effect while they are still at the keyboard.
122pub const DEFAULT_PRESENT_TTL: Duration = Duration::from_secs(15 * 60);
123
124/// How long "that repository holds nothing at that key" is reused, absent
125/// configuration.
126///
127/// **Reasonable range: 30 to 900 seconds.** Shorter than
128/// [`DEFAULT_PRESENT_TTL`] on purpose: absence is the common answer — almost
129/// nobody has written an AI preference, and most posts carry no reply gate —
130/// and it is also the answer most likely to stop being true, because writing
131/// the record is exactly what a person does when they decide to be left
132/// alone. Holding an absence for as long as a value would make the moment
133/// somebody asks to be left alone the slowest thing this reader notices.
134pub const DEFAULT_ABSENT_TTL: Duration = Duration::from_secs(5 * 60);
135
136/// How long an unknown is reused, absent configuration.
137///
138/// **Reasonable range: 10 to 300 seconds.** This is the negative cache: a
139/// DID that does not resolve, a server that is down, an address this reader
140/// refuses. Holding it stops one unreachable repository from costing a
141/// fetch on every write that names it, and it is the shortest of the three
142/// because it is the one that recovers by itself.
143pub const DEFAULT_UNKNOWN_TTL: Duration = Duration::from_secs(60);
144
145/// How long one record's fetch may take, absent configuration.
146///
147/// **Reasonable range: 250 to 5000 milliseconds.** This is the deadline in
148/// `plan/policy.md`'s sense: what a write waits for somebody else's server
149/// before it is told nothing. Shorter than
150/// [`didbot_http::DEFAULT_TIMEOUT`], which is sized for a poll tick that
151/// nothing is waiting behind; a write is.
152pub const DEFAULT_RECORD_DEADLINE: Duration = Duration::from_millis(2000);
153
154/// How long one prefetch may take in total, absent configuration.
155///
156/// **Reasonable range: 500 to 10000 milliseconds.** Bounds the whole call
157/// rather than one record in it, so a write needing several records cannot
158/// cost the sum of their deadlines. Records the budget runs out on are
159/// answered as [`Unknown::Deadline`] without being fetched.
160pub const DEFAULT_PREFETCH_DEADLINE: Duration = Duration::from_millis(4000);
161
162/// How many records one write may need, absent configuration.
163///
164/// **Reasonable range: 1 to 64.** Bounds what a single write can cost
165/// somebody else's server. The number is per write rather than per rule
166/// because a rule about the accounts a record names asks the same question
167/// of each of them: one post carrying a reply, a quote and several mentions
168/// is already several repositories. Sixteen covers that post, and the reply
169/// and quote gates a thread rule wants, with room over.
170///
171/// A write needing more records than this is refused before it is judged,
172/// rather than judged on some of them; see [`MAX_NAMED_ACCOUNTS`].
173pub const DEFAULT_PER_WRITE: usize = 16;
174
175/// The most accounts one write may name while a policy that reads something
176/// about each of them applies to it.
177///
178/// **Reasonable range: 1 to 64.** A write naming more is refused outright:
179/// judging it on the first few would let a record built to name one more
180/// person than this server checks name anybody at all. The count is of
181/// accounts this deployment does not hold, since its own are read from its
182/// own store. It is also held to [`Limits::per_write`] divided by the
183/// lookups that apply, so every account named is read.
184pub const MAX_NAMED_ACCOUNTS: usize = 16;
185
186/// What a [`ForeignRecords`] is bounded by. Every field is a parameter a
187/// deployment sets on its own, with its default and its reasonable range on
188/// the constant it comes from.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct Limits {
191 /// The most answers held at once; see [`DEFAULT_ENTRIES`].
192 pub entries: usize,
193 /// How long a record that was read is reused; see
194 /// [`DEFAULT_PRESENT_TTL`].
195 pub present_ttl: Duration,
196 /// How long an absence is reused; see [`DEFAULT_ABSENT_TTL`].
197 pub absent_ttl: Duration,
198 /// How long an unknown is reused; see [`DEFAULT_UNKNOWN_TTL`].
199 pub unknown_ttl: Duration,
200 /// How long one record's fetch may take; see
201 /// [`DEFAULT_RECORD_DEADLINE`].
202 pub record_deadline: Duration,
203 /// How long one prefetch may take in total; see
204 /// [`DEFAULT_PREFETCH_DEADLINE`].
205 pub prefetch_deadline: Duration,
206 /// How many records one prefetch may fetch; see
207 /// [`DEFAULT_PER_WRITE`].
208 pub per_write: usize,
209}
210
211impl Default for Limits {
212 fn default() -> Self {
213 Self {
214 entries: DEFAULT_ENTRIES,
215 present_ttl: DEFAULT_PRESENT_TTL,
216 absent_ttl: DEFAULT_ABSENT_TTL,
217 unknown_ttl: DEFAULT_UNKNOWN_TTL,
218 record_deadline: DEFAULT_RECORD_DEADLINE,
219 prefetch_deadline: DEFAULT_PREFETCH_DEADLINE,
220 per_write: DEFAULT_PER_WRITE,
221 }
222 }
223}
224
225/// What this reader knows about one reference.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum Answer {
228 /// The repository holds records there — see [`Fetched::Present`] for
229 /// the shape, which is an array either way. An `Arc` because one cached
230 /// value is handed to every evaluator that asks for it.
231 Present(Arc<Value>),
232 /// The repository answered, and holds nothing there.
233 Absent,
234 /// Neither was established; see [`Unknown`], and this crate's "What an
235 /// unknown means".
236 Unknown(Unknown),
237}
238
239impl Answer {
240 /// A short word for a log line.
241 #[must_use]
242 pub fn as_str(&self) -> &'static str {
243 match self {
244 Answer::Present(_) => "present",
245 Answer::Absent => "absent",
246 Answer::Unknown(unknown) => unknown.as_str(),
247 }
248 }
249}
250
251/// What this reader has done since the process started, for the operator
252/// watching the numbers move.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
254pub struct Counts {
255 /// Reads answered from a cache entry inside its TTL.
256 pub hits: u64,
257 /// Reads the cache held no fresh answer for.
258 pub misses: u64,
259 /// Fetches that established neither presence nor absence, not counting
260 /// the ones in `refusals`.
261 pub unknowns: u64,
262 /// Fetches this reader refused before making a request: an address it
263 /// will not reach, or a repository this deployment holds.
264 pub refusals: u64,
265}
266
267/// One cached answer and when it stops being usable.
268struct Entry {
269 answer: Answer,
270 expires: Instant,
271}
272
273/// The reader: a bounded cache of foreign records, over one [`Fetch`].
274///
275/// Deployment-wide, not per session: `plan/ai-preference.md`'s "one
276/// resolver, one cache, deployment-wide" is the whole difference between
277/// being a good guest on somebody else's server and being the traffic that
278/// gets a deployment defederated.
279pub struct ForeignRecords {
280 fetcher: Arc<dyn Fetch>,
281 limits: Limits,
282 cache: Mutex<lru::LruCache<String, Entry>>,
283 hits: AtomicU64,
284 misses: AtomicU64,
285 unknowns: AtomicU64,
286 refusals: AtomicU64,
287}
288
289impl ForeignRecords {
290 /// A reader over `fetcher`, bounded by `limits`.
291 ///
292 /// An `entries` of zero holds one entry: a cache that holds nothing
293 /// would make every read a fetch, which is the traffic pattern this
294 /// crate exists to avoid.
295 #[must_use]
296 pub fn new(fetcher: Arc<dyn Fetch>, limits: Limits) -> Self {
297 let entries = NonZeroUsize::new(limits.entries).unwrap_or(NonZeroUsize::MIN);
298 Self {
299 fetcher,
300 limits,
301 cache: Mutex::new(lru::LruCache::new(entries)),
302 hits: AtomicU64::new(0),
303 misses: AtomicU64::new(0),
304 unknowns: AtomicU64::new(0),
305 refusals: AtomicU64::new(0),
306 }
307 }
308
309 /// A reader over the real network, refusing what `local` already holds
310 /// and refusing a private address unless `private` allows one.
311 #[must_use]
312 pub fn over_network(
313 local: Arc<dyn LocalRepositories>,
314 private: didbot_http::PrivateAddresses,
315 limits: Limits,
316 ) -> Self {
317 Self::new(Arc::new(Network::new(local, private)), limits)
318 }
319
320 /// What this reader is bounded by.
321 #[must_use]
322 pub fn limits(&self) -> Limits {
323 self.limits
324 }
325
326 /// What this reader has done since the process started.
327 #[must_use]
328 pub fn counts(&self) -> Counts {
329 Counts {
330 hits: self.hits.load(Ordering::Relaxed),
331 misses: self.misses.load(Ordering::Relaxed),
332 unknowns: self.unknowns.load(Ordering::Relaxed),
333 refusals: self.refusals.load(Ordering::Relaxed),
334 }
335 }
336
337 /// The answer held for `record`.
338 ///
339 /// Performs no I/O and never awaits, so an evaluator may call it from
340 /// inside a decision. A record nothing has fetched, and one whose
341 /// answer has aged out, are both [`Unknown::NotFetched`].
342 pub fn read(&self, record: &RecordRef) -> Answer {
343 let key = record.at_uri();
344 let now = Instant::now();
345 let mut cache = self.cache.lock().unwrap_or_else(|p| p.into_inner());
346 match cache.get(&key) {
347 Some(entry) if entry.expires > now => {
348 self.hits.fetch_add(1, Ordering::Relaxed);
349 entry.answer.clone()
350 }
351 _ => {
352 self.misses.fetch_add(1, Ordering::Relaxed);
353 Answer::Unknown(Unknown::NotFetched)
354 }
355 }
356 }
357
358 /// Fetches every record in `wanted` the cache holds no fresh answer
359 /// for, and caches what comes back.
360 ///
361 /// Bounded three ways: at most [`Limits::per_write`] records are
362 /// fetched, each fetch by [`Limits::record_deadline`], and the whole
363 /// call by [`Limits::prefetch_deadline`]. A record the budget does not
364 /// reach is cached as [`Unknown::Deadline`], so the write that asked
365 /// gets an answer either way and the next write does not queue behind
366 /// the same dead server.
367 pub async fn prefetch(&self, wanted: &[RecordRef]) {
368 let started = Instant::now();
369 for record in wanted.iter().take(self.limits.per_write) {
370 if self.fresh(&record.at_uri()) {
371 continue;
372 }
373 let left = self
374 .limits
375 .prefetch_deadline
376 .saturating_sub(started.elapsed());
377 let budget = left.min(self.limits.record_deadline);
378 let fetched = if budget.is_zero() {
379 Fetched::Unknown(Unknown::Deadline)
380 } else {
381 let began = Instant::now();
382 let fetched = match tokio::time::timeout(budget, self.fetcher.fetch(record)).await {
383 Ok(fetched) => fetched,
384 Err(_) => Fetched::Unknown(Unknown::Deadline),
385 };
386 tracing::debug!(
387 uri = %record,
388 outcome = outcome_of(&fetched),
389 latency_ms = began.elapsed().as_millis(),
390 "read a record from a repository this deployment does not hold"
391 );
392 fetched
393 };
394 self.store(record, fetched);
395 }
396 }
397
398 /// [`prefetch`](Self::prefetch) for one record, then
399 /// [`read`](Self::read) of it — for a caller that has a runtime and
400 /// wants the answer now rather than at a later decision.
401 pub async fn get(&self, record: &RecordRef) -> Answer {
402 self.prefetch(std::slice::from_ref(record)).await;
403 self.read(record)
404 }
405
406 /// Whether the cache holds an answer for `key` that has not expired.
407 /// Does not count as a read: a prefetch skipping a record it already
408 /// holds is not an evaluator asking for one.
409 fn fresh(&self, key: &str) -> bool {
410 let now = Instant::now();
411 let mut cache = self.cache.lock().unwrap_or_else(|p| p.into_inner());
412 cache.get(key).is_some_and(|entry| entry.expires > now)
413 }
414
415 /// Caches one fetch's outcome under `record`'s at-uri, for the TTL its
416 /// kind of answer gets, and counts it.
417 fn store(&self, record: &RecordRef, fetched: Fetched) {
418 let (answer, ttl) = match fetched {
419 Fetched::Present(value) => (Answer::Present(Arc::new(value)), self.limits.present_ttl),
420 Fetched::Absent => (Answer::Absent, self.limits.absent_ttl),
421 Fetched::Unknown(unknown) => {
422 match unknown {
423 Unknown::Refused(_) => self.refusals.fetch_add(1, Ordering::Relaxed),
424 _ => self.unknowns.fetch_add(1, Ordering::Relaxed),
425 };
426 (Answer::Unknown(unknown), self.limits.unknown_ttl)
427 }
428 };
429 let entry = Entry {
430 answer,
431 expires: Instant::now() + ttl,
432 };
433 self.cache
434 .lock()
435 .unwrap_or_else(|p| p.into_inner())
436 .put(record.at_uri(), entry);
437 }
438}
439
440/// The word a fetch's outcome is logged under.
441fn outcome_of(fetched: &Fetched) -> &'static str {
442 match fetched {
443 Fetched::Present(_) => "present",
444 Fetched::Absent => "absent",
445 Fetched::Unknown(unknown) => unknown.as_str(),
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use serde_json::json;
453 use std::collections::HashMap;
454
455 /// A [`Fetch`] answering from a script, counting what it was asked.
456 struct Stub {
457 answers: Mutex<HashMap<String, Fetched>>,
458 calls: AtomicU64,
459 }
460
461 impl Stub {
462 fn new(answers: impl IntoIterator<Item = (String, Fetched)>) -> Arc<Self> {
463 Arc::new(Self {
464 answers: Mutex::new(answers.into_iter().collect()),
465 calls: AtomicU64::new(0),
466 })
467 }
468
469 fn calls(&self) -> u64 {
470 self.calls.load(Ordering::SeqCst)
471 }
472
473 fn answer_with(&self, record: &RecordRef, fetched: Fetched) {
474 self.answers
475 .lock()
476 .expect("answers")
477 .insert(record.at_uri(), fetched);
478 }
479 }
480
481 impl Fetch for Stub {
482 fn fetch<'a>(&'a self, record: &'a RecordRef) -> FetchFuture<'a> {
483 Box::pin(async move {
484 self.calls.fetch_add(1, Ordering::SeqCst);
485 self.answers
486 .lock()
487 .expect("answers")
488 .get(&record.at_uri())
489 .cloned()
490 .unwrap_or(Fetched::Absent)
491 })
492 }
493 }
494
495 fn gate() -> RecordRef {
496 RecordRef::new(
497 "did:plc:abcdefghijklmnopqrstuvwx",
498 "app.bsky.feed.threadgate",
499 "3kabc",
500 )
501 }
502
503 /// The path every rule built on this reader takes: one fetch warms the
504 /// cache, and every later read inside the TTL is answered without
505 /// touching the network.
506 #[tokio::test]
507 async fn a_fetched_record_is_answered_from_cache_afterwards() {
508 let record = gate();
509 let stub = Stub::new([(record.at_uri(), Fetched::Present(json!({"allow": []})))]);
510 let reader = ForeignRecords::new(stub.clone(), Limits::default());
511
512 assert_eq!(
513 reader.get(&record).await,
514 Answer::Present(Arc::new(json!({"allow": []})))
515 );
516 reader.prefetch(std::slice::from_ref(&record)).await;
517 assert_eq!(
518 reader.read(&record),
519 Answer::Present(Arc::new(json!({"allow": []})))
520 );
521 assert_eq!(stub.calls(), 1, "the second prefetch found a fresh answer");
522 assert_eq!(reader.counts().hits, 2);
523 assert_eq!(reader.counts().misses, 0);
524 }
525
526 /// Absence is cached too, and it is the common answer: most repositories
527 /// hold no gate and no declared preference, and each of those would
528 /// otherwise cost somebody else a request per write.
529 #[tokio::test]
530 async fn an_absence_is_cached_and_is_not_an_unknown() {
531 let record = gate();
532 let stub = Stub::new([(record.at_uri(), Fetched::Absent)]);
533 let reader = ForeignRecords::new(stub.clone(), Limits::default());
534
535 assert_eq!(reader.get(&record).await, Answer::Absent);
536 assert_eq!(reader.read(&record), Answer::Absent);
537 assert_eq!(stub.calls(), 1);
538 assert_eq!(reader.counts().unknowns, 0);
539 }
540
541 /// An absence aging out is what lets somebody who writes a gate be
542 /// honoured within a stated delay, and it is deliberately sooner than a
543 /// value aging out.
544 #[tokio::test(start_paused = true)]
545 async fn an_absence_ages_out_sooner_than_a_value() {
546 let absent = gate();
547 let present = RecordRef::new(
548 "did:plc:abcdefghijklmnopqrstuvwx",
549 "app.bsky.feed.postgate",
550 "3kabc",
551 );
552 let stub = Stub::new([
553 (absent.at_uri(), Fetched::Absent),
554 (present.at_uri(), Fetched::Present(json!({"x": 1}))),
555 ]);
556 let limits = Limits {
557 absent_ttl: Duration::from_secs(60),
558 present_ttl: Duration::from_secs(600),
559 ..Limits::default()
560 };
561 let reader = ForeignRecords::new(stub, limits);
562 reader.prefetch(&[absent.clone(), present.clone()]).await;
563
564 tokio::time::advance(Duration::from_secs(61)).await;
565 assert_eq!(reader.read(&absent), Answer::Unknown(Unknown::NotFetched));
566 assert_eq!(
567 reader.read(&present),
568 Answer::Present(Arc::new(json!({"x": 1})))
569 );
570
571 tokio::time::advance(Duration::from_secs(600)).await;
572 assert_eq!(reader.read(&present), Answer::Unknown(Unknown::NotFetched));
573 }
574
575 /// A server that is down is negatively cached, so one unreachable
576 /// repository costs one fetch per TTL rather than one per write — and
577 /// the answer stays an unknown, which is not an absence and is not a
578 /// refusal of the write.
579 #[tokio::test(start_paused = true)]
580 async fn an_unreachable_repository_is_negatively_cached_then_retried() {
581 let record = gate();
582 let stub = Stub::new([(
583 record.at_uri(),
584 Fetched::Unknown(Unknown::Unreachable("connection refused".to_owned())),
585 )]);
586 let reader = ForeignRecords::new(stub.clone(), Limits::default());
587
588 reader.prefetch(std::slice::from_ref(&record)).await;
589 reader.prefetch(std::slice::from_ref(&record)).await;
590 assert_eq!(stub.calls(), 1, "the unknown is held for its own TTL");
591 assert!(matches!(
592 reader.read(&record),
593 Answer::Unknown(Unknown::Unreachable(_))
594 ));
595
596 tokio::time::advance(DEFAULT_UNKNOWN_TTL + Duration::from_secs(1)).await;
597 stub.answer_with(&record, Fetched::Present(json!({"allow": []})));
598 reader.prefetch(std::slice::from_ref(&record)).await;
599 assert_eq!(stub.calls(), 2, "the negative entry expired and it retried");
600 assert_eq!(
601 reader.read(&record),
602 Answer::Present(Arc::new(json!({"allow": []})))
603 );
604 }
605
606 /// A refusal is counted apart from every other unknown: it is this
607 /// deployment's own decision rather than somebody else's outage, and an
608 /// operator reading a rising number wants to know which.
609 #[tokio::test]
610 async fn a_refusal_is_counted_apart_from_an_outage() {
611 let refused = gate();
612 let down = RecordRef::new(
613 "did:plc:abcdefghijklmnopqrstuvwy",
614 "app.bsky.graph.block",
615 "3k",
616 );
617 let stub = Stub::new([
618 (
619 refused.at_uri(),
620 Fetched::Unknown(Unknown::Refused("hosted here".to_owned())),
621 ),
622 (
623 down.at_uri(),
624 Fetched::Unknown(Unknown::Unreachable("timed out".to_owned())),
625 ),
626 ]);
627 let reader = ForeignRecords::new(stub, Limits::default());
628 reader.prefetch(&[refused, down]).await;
629
630 let counts = reader.counts();
631 assert_eq!(counts.refusals, 1);
632 assert_eq!(counts.unknowns, 1);
633 }
634
635 /// The cache is bounded by entry count, not by how many records a
636 /// deployment's agents happen to name.
637 #[tokio::test]
638 async fn the_cache_holds_no_more_than_its_declared_entries() {
639 let limits = Limits {
640 entries: 2,
641 per_write: 8,
642 ..Limits::default()
643 };
644 let records: Vec<RecordRef> = (0..4)
645 .map(|i| {
646 RecordRef::new(
647 "did:plc:abcdefghijklmnopqrstuvwx",
648 "app.bsky.feed.threadgate",
649 format!("3k{i}"),
650 )
651 })
652 .collect();
653 let stub = Stub::new(
654 records
655 .iter()
656 .map(|r| (r.at_uri(), Fetched::Present(json!({"rkey": r.rkey})))),
657 );
658 let reader = ForeignRecords::new(stub, limits);
659 reader.prefetch(&records).await;
660
661 assert_eq!(
662 reader.read(&records[0]),
663 Answer::Unknown(Unknown::NotFetched),
664 "the oldest two made room for the newest two"
665 );
666 assert_eq!(
667 reader.read(&records[3]),
668 Answer::Present(Arc::new(json!({"rkey": "3k3"})))
669 );
670 }
671
672 /// One write cannot cost somebody else's server more than the declared
673 /// number of requests, however many records a rule asks for.
674 #[tokio::test]
675 async fn one_prefetch_fetches_no_more_than_the_per_write_bound() {
676 let limits = Limits {
677 per_write: 2,
678 ..Limits::default()
679 };
680 let records: Vec<RecordRef> = (0..5)
681 .map(|i| {
682 RecordRef::new(
683 "did:plc:abcdefghijklmnopqrstuvwx",
684 "app.bsky.feed.threadgate",
685 format!("3k{i}"),
686 )
687 })
688 .collect();
689 let stub = Stub::new([]);
690 let reader = ForeignRecords::new(stub.clone(), limits);
691 reader.prefetch(&records).await;
692 assert_eq!(stub.calls(), 2);
693 }
694
695 /// A fetch past its deadline is an unknown rather than a wait: the
696 /// write behind it finds out in bounded time that nothing was
697 /// established.
698 #[tokio::test(start_paused = true)]
699 async fn a_fetch_past_its_deadline_is_an_unknown() {
700 struct Slow;
701 impl Fetch for Slow {
702 fn fetch<'a>(&'a self, _record: &'a RecordRef) -> FetchFuture<'a> {
703 Box::pin(async {
704 tokio::time::sleep(Duration::from_secs(3600)).await;
705 Fetched::Absent
706 })
707 }
708 }
709 let record = gate();
710 let reader = ForeignRecords::new(Arc::new(Slow), Limits::default());
711 reader.prefetch(std::slice::from_ref(&record)).await;
712 assert_eq!(reader.read(&record), Answer::Unknown(Unknown::Deadline));
713 }
714
715 /// The whole call is bounded, not just each record in it, so a write
716 /// naming several dead repositories waits once rather than once per
717 /// record.
718 #[tokio::test(start_paused = true)]
719 async fn a_prefetch_stops_at_its_own_deadline() {
720 struct Slow;
721 impl Fetch for Slow {
722 fn fetch<'a>(&'a self, _record: &'a RecordRef) -> FetchFuture<'a> {
723 Box::pin(async {
724 tokio::time::sleep(Duration::from_secs(3600)).await;
725 Fetched::Absent
726 })
727 }
728 }
729 let limits = Limits {
730 record_deadline: Duration::from_secs(2),
731 prefetch_deadline: Duration::from_secs(3),
732 per_write: 4,
733 ..Limits::default()
734 };
735 let records: Vec<RecordRef> = (0..4)
736 .map(|i| {
737 RecordRef::new(
738 "did:plc:abcdefghijklmnopqrstuvwx",
739 "app.bsky.feed.threadgate",
740 format!("3k{i}"),
741 )
742 })
743 .collect();
744 let reader = ForeignRecords::new(Arc::new(Slow), limits);
745 let started = tokio::time::Instant::now();
746 reader.prefetch(&records).await;
747 assert_eq!(started.elapsed(), Duration::from_secs(3));
748 for record in &records {
749 assert_eq!(reader.read(record), Answer::Unknown(Unknown::Deadline));
750 }
751 }
752}