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, IdKind, Page, Provider, ProviderId,
61    ResiliencePolicy, SearchMode, SearchQuery, SourceRecord, Update, 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                // OpenAlex flags a retraction or withdrawal notice itself as
332                // retracted, without details: name the record as its own
333                // notice, so every row about the one event shares `notice`.
334                let own_doi = record.ids.iter().find(|id| id.kind() == IdKind::Doi);
335                if work.update_to.iter().any(|t| t.kind.is_retraction())
336                    && let Some(own) = own_doi
337                {
338                    let bare = |u: &&mut Update| u.kind.is_retraction() && u.notice.is_none();
339                    for update in work.updates.iter_mut().filter(bare) {
340                        update.notice = Some(own.clone());
341                    }
342                }
343            }
344        }
345    }
346
347    /// Fills in where works can be read free, for records whose provider
348    /// didn't say, from the first provider (by priority) that does.
349    pub async fn add_open_access(&self, report: &mut LookupReport) {
350        for entry in &self.entries {
351            if !entry
352                .provider
353                .capabilities()
354                .fields
355                .contains(Field::OpenAccess)
356            {
357                continue;
358            }
359            let works = found_records(&report.results, |r| r.work.open_access.is_none());
360            for (i, found) in self.ask_about(entry, works, &mut report.usage).await {
361                if let (Some(oa), Some(record)) =
362                    (found.work.open_access, report.results[i].record.as_mut())
363                {
364                    record.work.open_access = Some(oa);
365                }
366            }
367        }
368    }
369
370    /// One provider's records of works it didn't supply itself, each asked
371    /// for by an identifier it accepts: `(work index, record)` per work it
372    /// knows.
373    async fn ask_about<'a>(
374        &self,
375        entry: &Entry,
376        works: impl IntoIterator<Item = (usize, &'a SourceRecord)>,
377        usage: &mut Vec<(ProviderId, Usage)>,
378    ) -> Vec<(usize, SourceRecord)> {
379        let provider = entry.provider.id();
380        let kinds = &entry.provider.capabilities().lookup_kinds;
381        let wanted: Vec<(usize, WorkId)> = works
382            .into_iter()
383            .filter(|(_, r)| r.source != provider)
384            .filter_map(|(i, r)| {
385                let id = r.ids.iter().find(|id| kinds.contains(&id.kind()))?;
386                Some((i, id.clone()))
387            })
388            .collect();
389        if wanted.is_empty() {
390            return Vec::new();
391        }
392        let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
393        let answers = self.lookup_at(entry, &ids, FieldSet::ALL, usage).await;
394        wanted
395            .into_iter()
396            .zip(answers)
397            .filter_map(|((i, _), (_, record))| Some((i, record?)))
398            .collect()
399    }
400
401    /// Asks one provider about `ids` (all of kinds it accepts): fresh cached
402    /// answers first, the rest in batches with retries, and stale cached
403    /// records for any that fail. One answer per ID, in order.
404    async fn lookup_at(
405        &self,
406        entry: &Entry,
407        ids: &[WorkId],
408        fields: FieldSet,
409        usage: &mut Vec<(ProviderId, Usage)>,
410    ) -> Vec<(Attempt, Option<SourceRecord>)> {
411        let provider = entry.provider.id();
412        let caps = entry.provider.capabilities();
413        let attempt = |outcome, served| Attempt {
414            provider: provider.clone(),
415            outcome,
416            served,
417        };
418        let mut answers: Vec<Option<(Attempt, Option<SourceRecord>)>> = vec![None; ids.len()];
419
420        // Fresh cached answers first (one cache read for the whole set).
421        let mut pending = Vec::new();
422        let mut from_cache = 0;
423        for (i, cached) in self
424            .cached_lookups(&provider, ids, fields)
425            .into_iter()
426            .enumerate()
427        {
428            answers[i] = match cached {
429                Some(Some(record)) => {
430                    from_cache += 1;
431                    Some((attempt(Outcome::Found, Served::Cache), Some(record)))
432                }
433                Some(None) => {
434                    from_cache += 1;
435                    Some((attempt(Outcome::NotFound, Served::Cache), None))
436                }
437                None => {
438                    pending.push(i);
439                    None
440                }
441            };
442        }
443
444        if from_cache > 0 {
445            tracing::info!(target: "aspero::cache", provider = %provider, ids = from_cache, "lookups served from cache");
446        }
447        let mut chunks = pending.chunks(caps.max_batch.max(1));
448        while let Some(chunk) = chunks.next() {
449            let batch: Vec<WorkId> = chunk.iter().map(|&i| ids[i].clone()).collect();
450            let answer = with_retries(&entry.policy, self.jitter, || {
451                entry.provider.lookup(&batch, fields)
452            })
453            .await;
454
455            let error = match answer {
456                Ok(resp) => {
457                    if let Some(u) = resp.usage {
458                        add_usage(usage, &provider, u);
459                    }
460                    // The contract is one answer per ID, in order; anything
461                    // else is a provider bug, not a miss.
462                    let in_order = resp.value.len() == batch.len()
463                        && resp.value.iter().zip(&batch).all(|(l, id)| &l.id == id);
464                    if in_order {
465                        let stored: Vec<_> = resp
466                            .value
467                            .iter()
468                            .map(|l| (&l.id, l.record.as_ref()))
469                            .collect();
470                        self.remember_lookups(&provider, &stored);
471                        for (&i, lookup) in chunk.iter().zip(resp.value) {
472                            let outcome = match lookup.record {
473                                Some(_) => Outcome::Found,
474                                None => Outcome::NotFound,
475                            };
476                            answers[i] = Some((attempt(outcome, Served::Live), lookup.record));
477                        }
478                        continue;
479                    }
480                    Error::decode(ContractViolation)
481                }
482                Err(error) => error,
483            };
484
485            // Out of budget or credentials: don't spend more calls on this
486            // provider in this lookup.
487            let give_up = matches!(
488                error.category(),
489                ErrorCategory::Throttled | ErrorCategory::Auth
490            );
491            let affected: Vec<usize> = if give_up {
492                chunk
493                    .iter()
494                    .chain(chunks.by_ref().flatten())
495                    .copied()
496                    .collect()
497            } else {
498                chunk.to_vec()
499            };
500            // Stale-on-error: an old answer beats none.
501            let affected_ids: Vec<WorkId> = affected.iter().map(|&i| ids[i].clone()).collect();
502            for (&i, stale) in affected
503                .iter()
504                .zip(self.stale_lookups(&provider, &affected_ids))
505            {
506                answers[i] = Some(match stale {
507                    Some(record) => (attempt(Outcome::Found, Served::Stale), Some(record)),
508                    None => (attempt(Outcome::Failed(error.clone()), Served::Live), None),
509                });
510            }
511        }
512        answers
513            .into_iter()
514            .map(|a| a.expect("every ID answered from cache, provider or failure"))
515            .collect()
516    }
517
518    /// Searches one provider: `source` if given, otherwise the first that
519    /// supports the query's mode.
520    pub async fn search(
521        &self,
522        source: Option<&str>,
523        query: &SearchQuery,
524    ) -> Result<SearchReport, EngineError> {
525        let citation = query.mode == SearchMode::Citation;
526        let entry = match source {
527            Some(name) => self
528                .entries
529                .iter()
530                .find(|e| e.provider.id().as_str() == name)
531                .ok_or_else(|| EngineError::UnknownProvider(name.to_owned()))?,
532            None => self
533                .entries
534                .iter()
535                .find(|e| {
536                    let caps = e.provider.capabilities();
537                    caps.search && (!citation || caps.citation_search)
538                })
539                .ok_or(EngineError::NoCapableProvider(if citation {
540                    "citation search"
541                } else {
542                    "search"
543                }))?,
544        };
545        let provider = entry.provider.id();
546        let caps = entry.provider.capabilities();
547        let refuse = |what| EngineError::Provider {
548            provider: provider.clone(),
549            error: Error::Unsupported(what),
550        };
551        if !caps.search {
552            return Err(refuse("search"));
553        }
554        if citation && !caps.citation_search {
555            return Err(refuse("citation search"));
556        }
557
558        let ignored_filters: Vec<Filter> = query
559            .filters()
560            .into_iter()
561            .filter(|f| !caps.search_filters.contains(f))
562            .collect();
563        let mut sent = query.clone();
564        for filter in &ignored_filters {
565            match filter {
566                Filter::Years => sent.years = None,
567                Filter::WorkType => sent.work_type = None,
568                Filter::OpenAccess => sent.open_access = None,
569                _ => {}
570            }
571        }
572
573        let report = |page, usage, served| SearchReport {
574            provider: provider.clone(),
575            page,
576            ignored_filters: ignored_filters.clone(),
577            usage,
578            served,
579        };
580        let cached = self.cached_search(&provider, &sent);
581        if let Some((page, true)) = cached {
582            tracing::info!(target: "aspero::cache", provider = %provider, text = %sent.text, "search served from cache");
583            return Ok(report(page, None, Served::Cache));
584        }
585        match with_retries(&entry.policy, self.jitter, || entry.provider.search(&sent)).await {
586            Ok(resp) => {
587                self.remember_search(&provider, &sent, &resp.value);
588                Ok(report(resp.value, resp.usage, Served::Live))
589            }
590            Err(error) => match cached {
591                Some((page, _)) => Ok(report(page, None, Served::Stale)),
592                None => Err(EngineError::Provider {
593                    provider: provider.clone(),
594                    error,
595                }),
596            },
597        }
598    }
599
600    // Cache access. Cache failures are never fatal: a broken cache behaves
601    // like an empty one, and failed writes are skipped.
602
603    /// For each ID, a fresh cached answer covering `fields`:
604    /// `Some(Some(record))`, `Some(None)` for a fresh "not found", or `None`
605    /// to ask the provider.
606    fn cached_lookups(
607        &self,
608        provider: &ProviderId,
609        ids: &[WorkId],
610        fields: FieldSet,
611    ) -> Vec<Option<Option<SourceRecord>>> {
612        let now = (self.clock)();
613        let policy = self.cache_policy;
614        self.cache_entries(provider, ids)
615            .into_iter()
616            .map(|entry| {
617                let entry = entry?;
618                match entry.value {
619                    Some(ref record)
620                        if entry.is_fresh(now, policy.records)
621                            && fields.is_subset(record.fields) =>
622                    {
623                        Some(entry.value)
624                    }
625                    None if entry.is_fresh(now, policy.misses) => Some(None),
626                    _ => None,
627                }
628            })
629            .collect()
630    }
631
632    /// For each ID, any cached record, however old.
633    fn stale_lookups(&self, provider: &ProviderId, ids: &[WorkId]) -> Vec<Option<SourceRecord>> {
634        self.cache_entries(provider, ids)
635            .into_iter()
636            .map(|entry| entry.and_then(|e| e.value))
637            .collect()
638    }
639
640    fn cache_entries(
641        &self,
642        provider: &ProviderId,
643        ids: &[WorkId],
644    ) -> Vec<Option<aspero_store::CachedLookup>> {
645        self.cache
646            .as_ref()
647            .and_then(|c| c.get_lookups(provider, ids).ok())
648            .unwrap_or_else(|| vec![None; ids.len()])
649    }
650
651    fn remember_lookups(
652        &self,
653        provider: &ProviderId,
654        answers: &[(&WorkId, Option<&SourceRecord>)],
655    ) {
656        if let Some(cache) = &self.cache {
657            let _ = cache.put_lookups(provider, answers, (self.clock)());
658        }
659    }
660
661    /// A cached page and whether it is still fresh.
662    fn cached_search(&self, provider: &ProviderId, query: &SearchQuery) -> Option<(Page, bool)> {
663        let entry = self.cache.as_ref()?.get_search(provider, query).ok()??;
664        let fresh = entry.is_fresh((self.clock)(), self.cache_policy.searches);
665        Some((entry.value, fresh))
666    }
667
668    fn remember_search(&self, provider: &ProviderId, query: &SearchQuery, page: &Page) {
669        if let Some(cache) = &self.cache {
670            let _ = cache.put_search(provider, query, page, (self.clock)());
671        }
672    }
673}
674
675/// The records found among `results`, by index, that `keep` selects.
676fn found_records(
677    results: &[LookupResult],
678    keep: impl Fn(&SourceRecord) -> bool,
679) -> impl Iterator<Item = (usize, &SourceRecord)> {
680    results
681        .iter()
682        .enumerate()
683        .filter_map(move |(i, r)| Some((i, r.record.as_ref().filter(|r| keep(r))?)))
684}
685
686fn add_usage(totals: &mut Vec<(ProviderId, Usage)>, provider: &ProviderId, usage: Usage) {
687    match totals.iter_mut().find(|(p, _)| p == provider) {
688        Some((_, total)) => *total = total.then(usage),
689        None => totals.push((provider.clone(), usage)),
690    }
691}
692
693#[derive(Debug, thiserror::Error)]
694#[error("provider answered a batch lookup out of order or incompletely")]
695struct ContractViolation;