1mod 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};
40pub 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
70pub type Clock = fn() -> Timestamp;
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76#[non_exhaustive]
77pub struct CachePolicy {
78 pub records: Duration,
79 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum Served {
99 Live,
100 Cache,
102 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 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 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 pub fn jitter(mut self, jitter: Jitter) -> Self {
153 self.jitter = jitter;
154 self
155 }
156
157 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 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#[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 pub id: WorkId,
206 pub record: Option<SourceRecord>,
207 pub attempts: Vec<Attempt>,
210}
211
212impl LookupResult {
213 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 pub results: Vec<LookupResult>,
228 pub usage: Vec<(ProviderId, Usage)>,
230}
231
232#[derive(Debug, Clone)]
233pub struct SearchReport {
234 pub provider: ProviderId,
235 pub page: Page,
236 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 pub fn providers(&self) -> impl Iterator<Item = &Arc<dyn Provider>> {
280 self.entries.iter().map(|e| &e.provider)
281 }
282
283 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 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 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 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 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 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 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 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 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 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 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 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 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 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
677fn 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;