1mod 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};
38pub 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
68pub type Clock = fn() -> Timestamp;
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub struct CachePolicy {
76 pub records: Duration,
77 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum Served {
97 Live,
98 Cache,
100 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 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 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 pub fn jitter(mut self, jitter: Jitter) -> Self {
151 self.jitter = jitter;
152 self
153 }
154
155 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 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#[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 pub id: WorkId,
204 pub record: Option<SourceRecord>,
205 pub attempts: Vec<Attempt>,
208}
209
210impl LookupResult {
211 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 pub results: Vec<LookupResult>,
226 pub usage: Vec<(ProviderId, Usage)>,
228}
229
230#[derive(Debug, Clone)]
231pub struct SearchReport {
232 pub provider: ProviderId,
233 pub page: Page,
234 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 pub fn providers(&self) -> impl Iterator<Item = &Arc<dyn Provider>> {
278 self.entries.iter().map(|e| &e.provider)
279 }
280
281 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 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 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 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 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 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 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 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 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 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 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 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 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 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
675fn 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;