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