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