Skip to main content

aspero_engine/
lib.rs

1//! Routing, caching, merging and resilience over registered providers.
2//!
3//! The engine owns what providers deliberately don't: retries and backoff
4//! ([`retry`]), request pacing and the real HTTP client ([`transport`]), and
5//! choosing which provider answers what.
6//!
7//! - **Lookup** is interchangeable across providers: each ID goes to the
8//!   providers that accept its kind, in priority order, until one finds it.
9//!   A provider that doesn't know an ID or fails falls through to the next;
10//!   every attempt is reported.
11//! - **Search** is not interchangeable (rankings differ), so it runs against
12//!   one provider: the one named, or the first capable one. Filters that
13//!   provider doesn't declare are stripped and reported as ignored, never
14//!   silently dropped.
15//!
16//! - **Resolve / verify** turn a citation claim into scored candidates and a
17//!   verdict (see [`resolve`](crate::Engine::resolve)).
18//!
19//! - **Caching**: provider answers (records, "not found", search pages) are
20//!   cached with per-kind TTLs ([`CachePolicy`]) and served stale if a
21//!   provider fails; every answer says how it was [`Served`].
22//!
23//! Not yet: fan-out search, the circuit breaker and the cross-process
24//! limiter.
25
26mod abstracts;
27mod check;
28mod resolve;
29pub mod retry;
30mod standard;
31pub mod transport;
32mod verdict;
33mod versions;
34
35pub use abstracts::{Abstract, AbstractsReport};
36pub use check::{Checked, Finding, Severity, duplicates, findings};
37pub use resolve::{Candidate, ResolveReport, Sighting, Via};
38/// Re-exported for credentials in provider configs.
39pub use secrecy::SecretString;
40#[cfg(feature = "crossref")]
41pub use standard::CrossrefConfig;
42#[cfg(feature = "datacite")]
43pub use standard::DataciteConfig;
44#[cfg(feature = "doi")]
45pub use standard::DoiConfig;
46#[cfg(feature = "europepmc")]
47pub use standard::EuropePmcConfig;
48#[cfg(feature = "openalex")]
49pub use standard::OpenAlexConfig;
50#[cfg(feature = "openlibrary")]
51pub use standard::OpenLibraryConfig;
52pub use standard::{StandardConfig, standard};
53pub use verdict::{Verdict, Verification, describe_update};
54pub use versions::VersionsReport;
55
56use std::sync::Arc;
57use std::time::Duration;
58
59use aspero_core::{
60    Error, ErrorCategory, Field, FieldSet, Filter, Page, Provider, ProviderId, ResiliencePolicy,
61    SearchMode, SearchQuery, SourceRecord, Usage, WorkId,
62};
63pub use aspero_store::{Cache, SqliteCache, Stats as CacheStats, StoreError};
64use jiff::Timestamp;
65
66use crate::retry::{Jitter, random_jitter, with_retries};
67
68/// A clock, injectable so cache expiry is testable.
69pub type Clock = fn() -> Timestamp;
70
71/// How long cached answers are trusted (DESIGN ยง5). Past these, a provider
72/// is asked again; if it then fails, the old answer is served as stale.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub struct CachePolicy {
76    pub records: Duration,
77    /// "Not found" answers: short, since works get registered.
78    pub misses: Duration,
79    pub searches: Duration,
80}
81
82impl Default for CachePolicy {
83    fn default() -> Self {
84        const DAY: u64 = 86_400;
85        CachePolicy {
86            records: Duration::from_secs(7 * DAY),
87            misses: Duration::from_secs(DAY),
88            searches: Duration::from_secs(DAY),
89        }
90    }
91}
92
93/// Where an answer came from.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum Served {
97    Live,
98    /// A cached answer within its TTL.
99    Cache,
100    /// An expired cached answer, served because the provider failed.
101    Stale,
102}
103
104impl Served {
105    pub fn as_str(self) -> &'static str {
106        match self {
107            Served::Live => "live",
108            Served::Cache => "cache",
109            Served::Stale => "stale",
110        }
111    }
112}
113
114pub struct Engine {
115    /// In priority order.
116    entries: Vec<Entry>,
117    jitter: Jitter,
118    cache: Option<Arc<dyn Cache>>,
119    cache_policy: CachePolicy,
120    clock: Clock,
121}
122
123struct Entry {
124    provider: Arc<dyn Provider>,
125    policy: ResiliencePolicy,
126}
127
128pub struct EngineBuilder {
129    entries: Vec<(i32, Entry)>,
130    jitter: Jitter,
131    cache: Option<Arc<dyn Cache>>,
132    cache_policy: CachePolicy,
133    clock: Clock,
134}
135
136impl EngineBuilder {
137    /// Registers a provider. Lower `priority` is tried first; ties keep
138    /// registration order.
139    pub fn provider(
140        mut self,
141        provider: Arc<dyn Provider>,
142        policy: ResiliencePolicy,
143        priority: i32,
144    ) -> Self {
145        self.entries.push((priority, Entry { provider, policy }));
146        self
147    }
148
149    /// Replaces the backoff jitter source (for deterministic tests).
150    pub fn jitter(mut self, jitter: Jitter) -> Self {
151        self.jitter = jitter;
152        self
153    }
154
155    /// Caches provider answers (and serves them stale if a provider fails).
156    pub fn cache(mut self, cache: Arc<dyn Cache>) -> Self {
157        self.cache = Some(cache);
158        self
159    }
160
161    pub fn cache_policy(mut self, policy: CachePolicy) -> Self {
162        self.cache_policy = policy;
163        self
164    }
165
166    /// Replaces the clock (for deterministic cache-expiry tests).
167    pub fn clock(mut self, clock: Clock) -> Self {
168        self.clock = clock;
169        self
170    }
171
172    pub fn build(mut self) -> Engine {
173        self.entries.sort_by_key(|(priority, _)| *priority);
174        Engine {
175            entries: self.entries.into_iter().map(|(_, e)| e).collect(),
176            jitter: self.jitter,
177            cache: self.cache,
178            cache_policy: self.cache_policy,
179            clock: self.clock,
180        }
181    }
182}
183
184/// The outcome of one provider's attempt at one ID.
185#[derive(Debug, Clone)]
186#[non_exhaustive]
187pub enum Outcome {
188    Found,
189    NotFound,
190    Failed(Error),
191}
192
193#[derive(Debug, Clone)]
194pub struct Attempt {
195    pub provider: ProviderId,
196    pub outcome: Outcome,
197    pub served: Served,
198}
199
200#[derive(Debug, Clone)]
201pub struct LookupResult {
202    /// As requested.
203    pub id: WorkId,
204    pub record: Option<SourceRecord>,
205    /// Every provider tried, in order. Empty: no enabled provider accepts
206    /// this kind of identifier.
207    pub attempts: Vec<Attempt>,
208}
209
210impl LookupResult {
211    /// No provider found it, and at least one could answer authoritatively
212    /// (rather than every attempt failing).
213    pub fn is_not_found(&self) -> bool {
214        self.record.is_none()
215            && self
216                .attempts
217                .iter()
218                .any(|a| matches!(a.outcome, Outcome::NotFound))
219    }
220}
221
222#[derive(Debug, Clone)]
223pub struct LookupReport {
224    /// One per requested ID, in request order.
225    pub results: Vec<LookupResult>,
226    /// Combined usage per provider that reported any.
227    pub usage: Vec<(ProviderId, Usage)>,
228}
229
230#[derive(Debug, Clone)]
231pub struct SearchReport {
232    pub provider: ProviderId,
233    pub page: Page,
234    /// Query filters the provider can't apply; they were not sent.
235    pub ignored_filters: Vec<Filter>,
236    pub usage: Option<Usage>,
237    pub served: Served,
238}
239
240#[derive(Debug, Clone, thiserror::Error)]
241#[non_exhaustive]
242pub enum EngineError {
243    #[error("no enabled provider is named {0:?}")]
244    UnknownProvider(String),
245    #[error("no enabled provider supports {0}")]
246    NoCapableProvider(&'static str),
247    #[error("{provider}: {error}")]
248    Provider {
249        provider: ProviderId,
250        #[source]
251        error: Error,
252    },
253}
254
255impl EngineError {
256    pub fn category(&self) -> ErrorCategory {
257        match self {
258            EngineError::UnknownProvider(_) => ErrorCategory::Usage,
259            EngineError::NoCapableProvider(_) => ErrorCategory::Unsupported,
260            EngineError::Provider { error, .. } => error.category(),
261        }
262    }
263}
264
265impl Engine {
266    pub fn builder() -> EngineBuilder {
267        EngineBuilder {
268            entries: Vec::new(),
269            jitter: random_jitter,
270            cache: None,
271            cache_policy: CachePolicy::default(),
272            clock: Timestamp::now,
273        }
274    }
275
276    /// Registered providers, in priority order.
277    pub fn providers(&self) -> impl Iterator<Item = &Arc<dyn Provider>> {
278        self.entries.iter().map(|e| &e.provider)
279    }
280
281    /// Looks up every ID, falling through providers by priority. Never fails
282    /// as a whole: per-ID outcomes are in the report.
283    pub async fn lookup(&self, ids: &[WorkId], fields: FieldSet) -> LookupReport {
284        let mut results: Vec<LookupResult> = ids
285            .iter()
286            .map(|id| LookupResult {
287                id: id.clone(),
288                record: None,
289                attempts: Vec::new(),
290            })
291            .collect();
292        let mut usage: Vec<(ProviderId, Usage)> = Vec::new();
293
294        for entry in &self.entries {
295            let caps = entry.provider.capabilities();
296            let eligible: Vec<usize> = (0..results.len())
297                .filter(|&i| results[i].record.is_none())
298                .filter(|&i| caps.lookup_kinds.contains(&results[i].id.kind()))
299                .collect();
300            let ids: Vec<WorkId> = eligible.iter().map(|&i| results[i].id.clone()).collect();
301            let answers = self.lookup_at(entry, &ids, fields, &mut usage).await;
302            for (&i, (attempt, record)) in eligible.iter().zip(answers) {
303                results[i].record = record;
304                results[i].attempts.push(attempt);
305            }
306        }
307        LookupReport { results, usage }
308    }
309
310    /// Adds editorial notices, in both directions, to records that another
311    /// provider supplied, from the providers that report them in detail
312    /// (Crossref): an original then lists its corrections and retractions,
313    /// a correction the work it corrects. Batched and cached, like lookups.
314    pub async fn add_notices(&self, report: &mut LookupReport) {
315        for entry in &self.entries {
316            if !entry.provider.capabilities().notices {
317                continue;
318            }
319            let works = found_records(&report.results, |_| true);
320            for (i, found) in self.ask_about(entry, works, &mut report.usage).await {
321                let Some(record) = report.results[i].record.as_mut() else {
322                    continue;
323                };
324                let work = &mut record.work;
325                resolve::merge_updates(&mut work.updates, &found.work.updates);
326                for target in found.work.update_to {
327                    if !work.update_to.contains(&target) {
328                        work.update_to.push(target);
329                    }
330                }
331            }
332        }
333    }
334
335    /// Fills in where works can be read free, for records whose provider
336    /// didn't say, from the first provider (by priority) that does.
337    pub async fn add_open_access(&self, report: &mut LookupReport) {
338        for entry in &self.entries {
339            if !entry
340                .provider
341                .capabilities()
342                .fields
343                .contains(Field::OpenAccess)
344            {
345                continue;
346            }
347            let works = found_records(&report.results, |r| r.work.open_access.is_none());
348            for (i, found) in self.ask_about(entry, works, &mut report.usage).await {
349                if let (Some(oa), Some(record)) =
350                    (found.work.open_access, report.results[i].record.as_mut())
351                {
352                    record.work.open_access = Some(oa);
353                }
354            }
355        }
356    }
357
358    /// One provider's records of works it didn't supply itself, each asked
359    /// for by an identifier it accepts: `(work index, record)` per work it
360    /// knows.
361    async fn ask_about<'a>(
362        &self,
363        entry: &Entry,
364        works: impl IntoIterator<Item = (usize, &'a SourceRecord)>,
365        usage: &mut Vec<(ProviderId, Usage)>,
366    ) -> Vec<(usize, SourceRecord)> {
367        let provider = entry.provider.id();
368        let kinds = &entry.provider.capabilities().lookup_kinds;
369        let wanted: Vec<(usize, WorkId)> = works
370            .into_iter()
371            .filter(|(_, r)| r.source != provider)
372            .filter_map(|(i, r)| {
373                let id = r.ids.iter().find(|id| kinds.contains(&id.kind()))?;
374                Some((i, id.clone()))
375            })
376            .collect();
377        if wanted.is_empty() {
378            return Vec::new();
379        }
380        let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
381        let answers = self.lookup_at(entry, &ids, FieldSet::ALL, usage).await;
382        wanted
383            .into_iter()
384            .zip(answers)
385            .filter_map(|((i, _), (_, record))| Some((i, record?)))
386            .collect()
387    }
388
389    /// Asks one provider about `ids` (all of kinds it accepts): fresh cached
390    /// answers first, the rest in batches with retries, and stale cached
391    /// records for any that fail. One answer per ID, in order.
392    async fn lookup_at(
393        &self,
394        entry: &Entry,
395        ids: &[WorkId],
396        fields: FieldSet,
397        usage: &mut Vec<(ProviderId, Usage)>,
398    ) -> Vec<(Attempt, Option<SourceRecord>)> {
399        let provider = entry.provider.id();
400        let caps = entry.provider.capabilities();
401        let attempt = |outcome, served| Attempt {
402            provider: provider.clone(),
403            outcome,
404            served,
405        };
406        let mut answers: Vec<Option<(Attempt, Option<SourceRecord>)>> = vec![None; ids.len()];
407
408        // Fresh cached answers first (one cache read for the whole set).
409        let mut pending = Vec::new();
410        let mut from_cache = 0;
411        for (i, cached) in self
412            .cached_lookups(&provider, ids, fields)
413            .into_iter()
414            .enumerate()
415        {
416            answers[i] = match cached {
417                Some(Some(record)) => {
418                    from_cache += 1;
419                    Some((attempt(Outcome::Found, Served::Cache), Some(record)))
420                }
421                Some(None) => {
422                    from_cache += 1;
423                    Some((attempt(Outcome::NotFound, Served::Cache), None))
424                }
425                None => {
426                    pending.push(i);
427                    None
428                }
429            };
430        }
431
432        if from_cache > 0 {
433            tracing::info!(target: "aspero::cache", provider = %provider, ids = from_cache, "lookups served from cache");
434        }
435        let mut chunks = pending.chunks(caps.max_batch.max(1));
436        while let Some(chunk) = chunks.next() {
437            let batch: Vec<WorkId> = chunk.iter().map(|&i| ids[i].clone()).collect();
438            let answer = with_retries(&entry.policy, self.jitter, || {
439                entry.provider.lookup(&batch, fields)
440            })
441            .await;
442
443            let error = match answer {
444                Ok(resp) => {
445                    if let Some(u) = resp.usage {
446                        add_usage(usage, &provider, u);
447                    }
448                    // The contract is one answer per ID, in order; anything
449                    // else is a provider bug, not a miss.
450                    let in_order = resp.value.len() == batch.len()
451                        && resp.value.iter().zip(&batch).all(|(l, id)| &l.id == id);
452                    if in_order {
453                        let stored: Vec<_> = resp
454                            .value
455                            .iter()
456                            .map(|l| (&l.id, l.record.as_ref()))
457                            .collect();
458                        self.remember_lookups(&provider, &stored);
459                        for (&i, lookup) in chunk.iter().zip(resp.value) {
460                            let outcome = match lookup.record {
461                                Some(_) => Outcome::Found,
462                                None => Outcome::NotFound,
463                            };
464                            answers[i] = Some((attempt(outcome, Served::Live), lookup.record));
465                        }
466                        continue;
467                    }
468                    Error::decode(ContractViolation)
469                }
470                Err(error) => error,
471            };
472
473            // Out of budget or credentials: don't spend more calls on this
474            // provider in this lookup.
475            let give_up = matches!(
476                error.category(),
477                ErrorCategory::Throttled | ErrorCategory::Auth
478            );
479            let affected: Vec<usize> = if give_up {
480                chunk
481                    .iter()
482                    .chain(chunks.by_ref().flatten())
483                    .copied()
484                    .collect()
485            } else {
486                chunk.to_vec()
487            };
488            // Stale-on-error: an old answer beats none.
489            let affected_ids: Vec<WorkId> = affected.iter().map(|&i| ids[i].clone()).collect();
490            for (&i, stale) in affected
491                .iter()
492                .zip(self.stale_lookups(&provider, &affected_ids))
493            {
494                answers[i] = Some(match stale {
495                    Some(record) => (attempt(Outcome::Found, Served::Stale), Some(record)),
496                    None => (attempt(Outcome::Failed(error.clone()), Served::Live), None),
497                });
498            }
499        }
500        answers
501            .into_iter()
502            .map(|a| a.expect("every ID answered from cache, provider or failure"))
503            .collect()
504    }
505
506    /// Searches one provider: `source` if given, otherwise the first that
507    /// supports the query's mode.
508    pub async fn search(
509        &self,
510        source: Option<&str>,
511        query: &SearchQuery,
512    ) -> Result<SearchReport, EngineError> {
513        let citation = query.mode == SearchMode::Citation;
514        let entry = match source {
515            Some(name) => self
516                .entries
517                .iter()
518                .find(|e| e.provider.id().as_str() == name)
519                .ok_or_else(|| EngineError::UnknownProvider(name.to_owned()))?,
520            None => self
521                .entries
522                .iter()
523                .find(|e| {
524                    let caps = e.provider.capabilities();
525                    caps.search && (!citation || caps.citation_search)
526                })
527                .ok_or(EngineError::NoCapableProvider(if citation {
528                    "citation search"
529                } else {
530                    "search"
531                }))?,
532        };
533        let provider = entry.provider.id();
534        let caps = entry.provider.capabilities();
535        let refuse = |what| EngineError::Provider {
536            provider: provider.clone(),
537            error: Error::Unsupported(what),
538        };
539        if !caps.search {
540            return Err(refuse("search"));
541        }
542        if citation && !caps.citation_search {
543            return Err(refuse("citation search"));
544        }
545
546        let ignored_filters: Vec<Filter> = query
547            .filters()
548            .into_iter()
549            .filter(|f| !caps.search_filters.contains(f))
550            .collect();
551        let mut sent = query.clone();
552        for filter in &ignored_filters {
553            match filter {
554                Filter::Years => sent.years = None,
555                Filter::WorkType => sent.work_type = None,
556                Filter::OpenAccess => sent.open_access = None,
557                _ => {}
558            }
559        }
560
561        let report = |page, usage, served| SearchReport {
562            provider: provider.clone(),
563            page,
564            ignored_filters: ignored_filters.clone(),
565            usage,
566            served,
567        };
568        let cached = self.cached_search(&provider, &sent);
569        if let Some((page, true)) = cached {
570            tracing::info!(target: "aspero::cache", provider = %provider, text = %sent.text, "search served from cache");
571            return Ok(report(page, None, Served::Cache));
572        }
573        match with_retries(&entry.policy, self.jitter, || entry.provider.search(&sent)).await {
574            Ok(resp) => {
575                self.remember_search(&provider, &sent, &resp.value);
576                Ok(report(resp.value, resp.usage, Served::Live))
577            }
578            Err(error) => match cached {
579                Some((page, _)) => Ok(report(page, None, Served::Stale)),
580                None => Err(EngineError::Provider {
581                    provider: provider.clone(),
582                    error,
583                }),
584            },
585        }
586    }
587
588    // Cache access. Cache failures are never fatal: a broken cache behaves
589    // like an empty one, and failed writes are skipped.
590
591    /// For each ID, a fresh cached answer covering `fields`:
592    /// `Some(Some(record))`, `Some(None)` for a fresh "not found", or `None`
593    /// to ask the provider.
594    fn cached_lookups(
595        &self,
596        provider: &ProviderId,
597        ids: &[WorkId],
598        fields: FieldSet,
599    ) -> Vec<Option<Option<SourceRecord>>> {
600        let now = (self.clock)();
601        let policy = self.cache_policy;
602        self.cache_entries(provider, ids)
603            .into_iter()
604            .map(|entry| {
605                let entry = entry?;
606                match entry.value {
607                    Some(ref record)
608                        if entry.is_fresh(now, policy.records)
609                            && fields.is_subset(record.fields) =>
610                    {
611                        Some(entry.value)
612                    }
613                    None if entry.is_fresh(now, policy.misses) => Some(None),
614                    _ => None,
615                }
616            })
617            .collect()
618    }
619
620    /// For each ID, any cached record, however old.
621    fn stale_lookups(&self, provider: &ProviderId, ids: &[WorkId]) -> Vec<Option<SourceRecord>> {
622        self.cache_entries(provider, ids)
623            .into_iter()
624            .map(|entry| entry.and_then(|e| e.value))
625            .collect()
626    }
627
628    fn cache_entries(
629        &self,
630        provider: &ProviderId,
631        ids: &[WorkId],
632    ) -> Vec<Option<aspero_store::CachedLookup>> {
633        self.cache
634            .as_ref()
635            .and_then(|c| c.get_lookups(provider, ids).ok())
636            .unwrap_or_else(|| vec![None; ids.len()])
637    }
638
639    fn remember_lookups(
640        &self,
641        provider: &ProviderId,
642        answers: &[(&WorkId, Option<&SourceRecord>)],
643    ) {
644        if let Some(cache) = &self.cache {
645            let _ = cache.put_lookups(provider, answers, (self.clock)());
646        }
647    }
648
649    /// A cached page and whether it is still fresh.
650    fn cached_search(&self, provider: &ProviderId, query: &SearchQuery) -> Option<(Page, bool)> {
651        let entry = self.cache.as_ref()?.get_search(provider, query).ok()??;
652        let fresh = entry.is_fresh((self.clock)(), self.cache_policy.searches);
653        Some((entry.value, fresh))
654    }
655
656    fn remember_search(&self, provider: &ProviderId, query: &SearchQuery, page: &Page) {
657        if let Some(cache) = &self.cache {
658            let _ = cache.put_search(provider, query, page, (self.clock)());
659        }
660    }
661}
662
663/// The records found among `results`, by index, that `keep` selects.
664fn found_records(
665    results: &[LookupResult],
666    keep: impl Fn(&SourceRecord) -> bool,
667) -> impl Iterator<Item = (usize, &SourceRecord)> {
668    results
669        .iter()
670        .enumerate()
671        .filter_map(move |(i, r)| Some((i, r.record.as_ref().filter(|r| keep(r))?)))
672}
673
674fn add_usage(totals: &mut Vec<(ProviderId, Usage)>, provider: &ProviderId, usage: Usage) {
675    match totals.iter_mut().find(|(p, _)| p == provider) {
676        Some((_, total)) => *total = total.then(usage),
677        None => totals.push((provider.clone(), usage)),
678    }
679}
680
681#[derive(Debug, thiserror::Error)]
682#[error("provider answered a batch lookup out of order or incompletely")]
683struct ContractViolation;