Skip to main content

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}