Skip to main content

aspero_engine/
lookup.rs

1//! Lookups by identifier: routing across providers, the cache, and
2//! sharing calls in flight.
3
4use aspero_core::{Error, ErrorCategory, FieldSet, ProviderId, SourceRecord, Usage, WorkId};
5
6use crate::inflight::Answer;
7use crate::{Engine, Entry, Served, add_usage};
8
9/// The outcome of one provider's attempt at one ID.
10#[derive(Debug, Clone)]
11#[non_exhaustive]
12pub enum Outcome {
13    Found,
14    NotFound,
15    Failed(Error),
16}
17
18#[derive(Debug, Clone)]
19pub struct Attempt {
20    pub provider: ProviderId,
21    pub outcome: Outcome,
22    pub served: Served,
23}
24
25#[derive(Debug, Clone)]
26pub struct LookupResult {
27    /// As requested.
28    pub id: WorkId,
29    pub record: Option<SourceRecord>,
30    /// Every provider tried, in order. Empty: no enabled provider accepts
31    /// this kind of identifier.
32    pub attempts: Vec<Attempt>,
33}
34
35impl LookupResult {
36    /// No provider found it, and at least one could answer authoritatively
37    /// (rather than every attempt failing).
38    pub fn is_not_found(&self) -> bool {
39        self.record.is_none()
40            && self
41                .attempts
42                .iter()
43                .any(|a| matches!(a.outcome, Outcome::NotFound))
44    }
45}
46
47#[derive(Debug, Clone)]
48pub struct LookupReport {
49    /// One per requested ID, in request order.
50    pub results: Vec<LookupResult>,
51    /// Combined usage per provider that reported any.
52    pub usage: Vec<(ProviderId, Usage)>,
53}
54
55impl Engine {
56    /// Looks up every ID, falling through providers by priority. Never fails
57    /// as a whole: per-ID outcomes are in the report.
58    pub async fn lookup(&self, ids: &[WorkId], fields: FieldSet) -> LookupReport {
59        let mut results: Vec<LookupResult> = ids
60            .iter()
61            .map(|id| LookupResult {
62                id: id.clone(),
63                record: None,
64                attempts: Vec::new(),
65            })
66            .collect();
67        let mut usage: Vec<(ProviderId, Usage)> = Vec::new();
68
69        // Not found where that is final (doi.org for DOIs): ask no further.
70        let mut settled = vec![false; results.len()];
71        for entry in &self.entries {
72            let caps = entry.provider.capabilities();
73            let eligible: Vec<usize> = (0..results.len())
74                .filter(|&i| results[i].record.is_none() && !settled[i])
75                .filter(|&i| caps.lookup_kinds.contains(&results[i].id.kind()))
76                .collect();
77            let ids: Vec<WorkId> = eligible.iter().map(|&i| results[i].id.clone()).collect();
78            let answers = self.lookup_at(entry, &ids, fields, &mut usage).await;
79            for (&i, (attempt, record)) in eligible.iter().zip(answers) {
80                settled[i] = matches!(attempt.outcome, Outcome::NotFound)
81                    && caps.final_misses.contains(&results[i].id.kind());
82                results[i].record = record;
83                results[i].attempts.push(attempt);
84            }
85        }
86        LookupReport { results, usage }
87    }
88
89    /// Asks one provider about `ids` (all of kinds it accepts): fresh cached
90    /// answers first, the rest in batches with retries, and stale cached
91    /// records for any that fail. One answer per ID, in order.
92    pub(crate) async fn lookup_at(
93        &self,
94        entry: &Entry,
95        ids: &[WorkId],
96        fields: FieldSet,
97        usage: &mut Vec<(ProviderId, Usage)>,
98    ) -> Vec<(Attempt, Option<SourceRecord>)> {
99        let provider = entry.provider.id();
100        let caps = entry.provider.capabilities();
101        let attempt = |outcome, served| Attempt {
102            provider: provider.clone(),
103            outcome,
104            served,
105        };
106        if self.options.offline {
107            return self.offline_lookups(&provider, ids, fields);
108        }
109        let mut answers: Vec<Option<Answer>> = vec![None; ids.len()];
110
111        // Fresh cached answers first (one cache read for the whole set).
112        let mut pending = Vec::new();
113        let mut from_cache = 0;
114        for (i, cached) in self
115            .cached_lookups(&provider, ids, fields)
116            .into_iter()
117            .enumerate()
118        {
119            answers[i] = match cached {
120                Some(Some(record)) => {
121                    from_cache += 1;
122                    Some((attempt(Outcome::Found, Served::Cache), Some(record)))
123                }
124                Some(None) => {
125                    from_cache += 1;
126                    Some((attempt(Outcome::NotFound, Served::Cache), None))
127                }
128                None => {
129                    pending.push(i);
130                    None
131                }
132            };
133        }
134
135        if from_cache > 0 {
136            tracing::info!(target: "aspero::cache", provider = %provider, ids = from_cache, "lookups served from cache");
137        }
138        // Ask about the rest, unless another caller is already asking.
139        let wanted: Vec<(usize, WorkId)> = pending.iter().map(|&i| (i, ids[i].clone())).collect();
140        let (mut leads, joined) = self.inflight.claim(&provider, &wanted, fields);
141        let led = leads.indices();
142        let mut chunks = led.chunks(caps.max_batch.max(1));
143        while let Some(chunk) = chunks.next() {
144            leads.publish(&answers);
145            let batch: Vec<WorkId> = chunk.iter().map(|&i| ids[i].clone()).collect();
146            let answer = self
147                .call(entry, || entry.provider.lookup(&batch, fields))
148                .await;
149
150            let error = match answer {
151                Ok(resp) => {
152                    if let Some(u) = resp.usage {
153                        add_usage(usage, &provider, u);
154                    }
155                    // The contract is one answer per ID, in order; anything
156                    // else is a provider bug, not a miss.
157                    let in_order = resp.value.len() == batch.len()
158                        && resp.value.iter().zip(&batch).all(|(l, id)| &l.id == id);
159                    if in_order {
160                        let stored: Vec<_> = resp
161                            .value
162                            .iter()
163                            .map(|l| (&l.id, l.record.as_ref()))
164                            .collect();
165                        self.remember_lookups(&provider, &stored);
166                        for (&i, lookup) in chunk.iter().zip(resp.value) {
167                            let outcome = match lookup.record {
168                                Some(_) => Outcome::Found,
169                                None => Outcome::NotFound,
170                            };
171                            answers[i] = Some((attempt(outcome, Served::Live), lookup.record));
172                        }
173                        continue;
174                    }
175                    Error::decode(ContractViolation)
176                }
177                Err(error) => error,
178            };
179
180            // Out of budget or credentials: don't spend more calls on this
181            // provider in this lookup.
182            let give_up = matches!(
183                error.category(),
184                ErrorCategory::Throttled | ErrorCategory::Auth
185            );
186            let affected: Vec<usize> = if give_up {
187                chunk
188                    .iter()
189                    .chain(chunks.by_ref().flatten())
190                    .copied()
191                    .collect()
192            } else {
193                chunk.to_vec()
194            };
195            // Stale-on-error: an old answer beats none.
196            let affected_ids: Vec<WorkId> = affected.iter().map(|&i| ids[i].clone()).collect();
197            for (&i, stale) in affected
198                .iter()
199                .zip(self.stale_lookups(&provider, &affected_ids))
200            {
201                answers[i] = Some(match stale {
202                    Some(record) => (attempt(Outcome::Found, Served::Stale), Some(record)),
203                    None => (attempt(Outcome::Failed(error.clone()), Served::Live), None),
204                });
205            }
206        }
207        leads.publish(&answers);
208        drop(leads);
209
210        // Answers others fetched; ask again for any they gave up on.
211        let mut abandoned = Vec::new();
212        for (i, call) in joined {
213            match call.answer().await {
214                Some(answer) => answers[i] = Some(answer),
215                None => abandoned.push(i),
216            }
217        }
218        if !abandoned.is_empty() {
219            let retry: Vec<WorkId> = abandoned.iter().map(|&i| ids[i].clone()).collect();
220            let again = Box::pin(self.lookup_at(entry, &retry, fields, usage)).await;
221            for (i, answer) in abandoned.into_iter().zip(again) {
222                answers[i] = Some(answer);
223            }
224        }
225        answers
226            .into_iter()
227            .map(|a| a.expect("every ID answered from cache, provider or failure"))
228            .collect()
229    }
230
231    // Cache access. Cache failures are never fatal: a broken cache behaves
232    // like an empty one, and failed writes are skipped.
233
234    /// For each ID, a fresh cached answer covering `fields`:
235    /// `Some(Some(record))`, `Some(None)` for a fresh "not found", or `None`
236    /// to ask the provider.
237    fn cached_lookups(
238        &self,
239        provider: &ProviderId,
240        ids: &[WorkId],
241        fields: FieldSet,
242    ) -> Vec<Option<Option<SourceRecord>>> {
243        let now = (self.clock)();
244        let policy = self.cache_policy;
245        self.cache_entries(provider, ids)
246            .into_iter()
247            .map(|entry| {
248                let entry = entry?;
249                match entry.value {
250                    Some(ref record)
251                        if entry.is_fresh(now, policy.records)
252                            && fields.is_subset(record.fields) =>
253                    {
254                        Some(entry.value)
255                    }
256                    None if entry.is_fresh(now, policy.misses) => Some(None),
257                    _ => None,
258                }
259            })
260            .collect()
261    }
262
263    /// Every answer from the cache, however old: offline.
264    fn offline_lookups(
265        &self,
266        provider: &ProviderId,
267        ids: &[WorkId],
268        fields: FieldSet,
269    ) -> Vec<Answer> {
270        let fresh = self.cached_lookups(provider, ids, fields);
271        let any = self.stale_lookups(provider, ids);
272        let attempt = |outcome, served| Attempt {
273            provider: provider.clone(),
274            outcome,
275            served,
276        };
277        fresh
278            .into_iter()
279            .zip(any)
280            .map(|answer| match answer {
281                (Some(Some(record)), _) => (attempt(Outcome::Found, Served::Cache), Some(record)),
282                (Some(None), _) => (attempt(Outcome::NotFound, Served::Cache), None),
283                (None, Some(record)) => (attempt(Outcome::Found, Served::Stale), Some(record)),
284                (None, None) => (attempt(Outcome::Failed(Error::Offline), Served::Live), None),
285            })
286            .collect()
287    }
288
289    /// For each ID, any cached record, however old.
290    fn stale_lookups(&self, provider: &ProviderId, ids: &[WorkId]) -> Vec<Option<SourceRecord>> {
291        self.cache_entries(provider, ids)
292            .into_iter()
293            .map(|entry| entry.and_then(|e| e.value))
294            .collect()
295    }
296
297    fn cache_entries(
298        &self,
299        provider: &ProviderId,
300        ids: &[WorkId],
301    ) -> Vec<Option<aspero_store::CachedLookup>> {
302        self.cache
303            .as_ref()
304            .and_then(|c| c.get_lookups(provider, ids).ok())
305            .unwrap_or_else(|| vec![None; ids.len()])
306    }
307
308    fn remember_lookups(
309        &self,
310        provider: &ProviderId,
311        answers: &[(&WorkId, Option<&SourceRecord>)],
312    ) {
313        if let Some(cache) = &self.cache {
314            let _ = cache.put_lookups(provider, answers, (self.clock)());
315        }
316    }
317}
318
319#[derive(Debug, thiserror::Error)]
320#[error("provider answered a batch lookup out of order or incompletely")]
321struct ContractViolation;