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, Page, Provider, ProviderId, ResiliencePolicy,
61 SearchMode, SearchQuery, SourceRecord, 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 }
332 }
333 }
334
335 pub async fn add_open_access(&self, report: &mut LookupReport) {
338 for entry in &self.entries {
339 if !entry
340 .provider
341 .capabilities()
342 .fields
343 .contains(Field::OpenAccess)
344 {
345 continue;
346 }
347 let works = found_records(&report.results, |r| r.work.open_access.is_none());
348 for (i, found) in self.ask_about(entry, works, &mut report.usage).await {
349 if let (Some(oa), Some(record)) =
350 (found.work.open_access, report.results[i].record.as_mut())
351 {
352 record.work.open_access = Some(oa);
353 }
354 }
355 }
356 }
357
358 async fn ask_about<'a>(
362 &self,
363 entry: &Entry,
364 works: impl IntoIterator<Item = (usize, &'a SourceRecord)>,
365 usage: &mut Vec<(ProviderId, Usage)>,
366 ) -> Vec<(usize, SourceRecord)> {
367 let provider = entry.provider.id();
368 let kinds = &entry.provider.capabilities().lookup_kinds;
369 let wanted: Vec<(usize, WorkId)> = works
370 .into_iter()
371 .filter(|(_, r)| r.source != provider)
372 .filter_map(|(i, r)| {
373 let id = r.ids.iter().find(|id| kinds.contains(&id.kind()))?;
374 Some((i, id.clone()))
375 })
376 .collect();
377 if wanted.is_empty() {
378 return Vec::new();
379 }
380 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
381 let answers = self.lookup_at(entry, &ids, FieldSet::ALL, usage).await;
382 wanted
383 .into_iter()
384 .zip(answers)
385 .filter_map(|((i, _), (_, record))| Some((i, record?)))
386 .collect()
387 }
388
389 async fn lookup_at(
393 &self,
394 entry: &Entry,
395 ids: &[WorkId],
396 fields: FieldSet,
397 usage: &mut Vec<(ProviderId, Usage)>,
398 ) -> Vec<(Attempt, Option<SourceRecord>)> {
399 let provider = entry.provider.id();
400 let caps = entry.provider.capabilities();
401 let attempt = |outcome, served| Attempt {
402 provider: provider.clone(),
403 outcome,
404 served,
405 };
406 let mut answers: Vec<Option<(Attempt, Option<SourceRecord>)>> = vec![None; ids.len()];
407
408 let mut pending = Vec::new();
410 let mut from_cache = 0;
411 for (i, cached) in self
412 .cached_lookups(&provider, ids, fields)
413 .into_iter()
414 .enumerate()
415 {
416 answers[i] = match cached {
417 Some(Some(record)) => {
418 from_cache += 1;
419 Some((attempt(Outcome::Found, Served::Cache), Some(record)))
420 }
421 Some(None) => {
422 from_cache += 1;
423 Some((attempt(Outcome::NotFound, Served::Cache), None))
424 }
425 None => {
426 pending.push(i);
427 None
428 }
429 };
430 }
431
432 if from_cache > 0 {
433 tracing::info!(target: "aspero::cache", provider = %provider, ids = from_cache, "lookups served from cache");
434 }
435 let mut chunks = pending.chunks(caps.max_batch.max(1));
436 while let Some(chunk) = chunks.next() {
437 let batch: Vec<WorkId> = chunk.iter().map(|&i| ids[i].clone()).collect();
438 let answer = with_retries(&entry.policy, self.jitter, || {
439 entry.provider.lookup(&batch, fields)
440 })
441 .await;
442
443 let error = match answer {
444 Ok(resp) => {
445 if let Some(u) = resp.usage {
446 add_usage(usage, &provider, u);
447 }
448 let in_order = resp.value.len() == batch.len()
451 && resp.value.iter().zip(&batch).all(|(l, id)| &l.id == id);
452 if in_order {
453 let stored: Vec<_> = resp
454 .value
455 .iter()
456 .map(|l| (&l.id, l.record.as_ref()))
457 .collect();
458 self.remember_lookups(&provider, &stored);
459 for (&i, lookup) in chunk.iter().zip(resp.value) {
460 let outcome = match lookup.record {
461 Some(_) => Outcome::Found,
462 None => Outcome::NotFound,
463 };
464 answers[i] = Some((attempt(outcome, Served::Live), lookup.record));
465 }
466 continue;
467 }
468 Error::decode(ContractViolation)
469 }
470 Err(error) => error,
471 };
472
473 let give_up = matches!(
476 error.category(),
477 ErrorCategory::Throttled | ErrorCategory::Auth
478 );
479 let affected: Vec<usize> = if give_up {
480 chunk
481 .iter()
482 .chain(chunks.by_ref().flatten())
483 .copied()
484 .collect()
485 } else {
486 chunk.to_vec()
487 };
488 let affected_ids: Vec<WorkId> = affected.iter().map(|&i| ids[i].clone()).collect();
490 for (&i, stale) in affected
491 .iter()
492 .zip(self.stale_lookups(&provider, &affected_ids))
493 {
494 answers[i] = Some(match stale {
495 Some(record) => (attempt(Outcome::Found, Served::Stale), Some(record)),
496 None => (attempt(Outcome::Failed(error.clone()), Served::Live), None),
497 });
498 }
499 }
500 answers
501 .into_iter()
502 .map(|a| a.expect("every ID answered from cache, provider or failure"))
503 .collect()
504 }
505
506 pub async fn search(
509 &self,
510 source: Option<&str>,
511 query: &SearchQuery,
512 ) -> Result<SearchReport, EngineError> {
513 let citation = query.mode == SearchMode::Citation;
514 let entry = match source {
515 Some(name) => self
516 .entries
517 .iter()
518 .find(|e| e.provider.id().as_str() == name)
519 .ok_or_else(|| EngineError::UnknownProvider(name.to_owned()))?,
520 None => self
521 .entries
522 .iter()
523 .find(|e| {
524 let caps = e.provider.capabilities();
525 caps.search && (!citation || caps.citation_search)
526 })
527 .ok_or(EngineError::NoCapableProvider(if citation {
528 "citation search"
529 } else {
530 "search"
531 }))?,
532 };
533 let provider = entry.provider.id();
534 let caps = entry.provider.capabilities();
535 let refuse = |what| EngineError::Provider {
536 provider: provider.clone(),
537 error: Error::Unsupported(what),
538 };
539 if !caps.search {
540 return Err(refuse("search"));
541 }
542 if citation && !caps.citation_search {
543 return Err(refuse("citation search"));
544 }
545
546 let ignored_filters: Vec<Filter> = query
547 .filters()
548 .into_iter()
549 .filter(|f| !caps.search_filters.contains(f))
550 .collect();
551 let mut sent = query.clone();
552 for filter in &ignored_filters {
553 match filter {
554 Filter::Years => sent.years = None,
555 Filter::WorkType => sent.work_type = None,
556 Filter::OpenAccess => sent.open_access = None,
557 _ => {}
558 }
559 }
560
561 let report = |page, usage, served| SearchReport {
562 provider: provider.clone(),
563 page,
564 ignored_filters: ignored_filters.clone(),
565 usage,
566 served,
567 };
568 let cached = self.cached_search(&provider, &sent);
569 if let Some((page, true)) = cached {
570 tracing::info!(target: "aspero::cache", provider = %provider, text = %sent.text, "search served from cache");
571 return Ok(report(page, None, Served::Cache));
572 }
573 match with_retries(&entry.policy, self.jitter, || entry.provider.search(&sent)).await {
574 Ok(resp) => {
575 self.remember_search(&provider, &sent, &resp.value);
576 Ok(report(resp.value, resp.usage, Served::Live))
577 }
578 Err(error) => match cached {
579 Some((page, _)) => Ok(report(page, None, Served::Stale)),
580 None => Err(EngineError::Provider {
581 provider: provider.clone(),
582 error,
583 }),
584 },
585 }
586 }
587
588 fn cached_lookups(
595 &self,
596 provider: &ProviderId,
597 ids: &[WorkId],
598 fields: FieldSet,
599 ) -> Vec<Option<Option<SourceRecord>>> {
600 let now = (self.clock)();
601 let policy = self.cache_policy;
602 self.cache_entries(provider, ids)
603 .into_iter()
604 .map(|entry| {
605 let entry = entry?;
606 match entry.value {
607 Some(ref record)
608 if entry.is_fresh(now, policy.records)
609 && fields.is_subset(record.fields) =>
610 {
611 Some(entry.value)
612 }
613 None if entry.is_fresh(now, policy.misses) => Some(None),
614 _ => None,
615 }
616 })
617 .collect()
618 }
619
620 fn stale_lookups(&self, provider: &ProviderId, ids: &[WorkId]) -> Vec<Option<SourceRecord>> {
622 self.cache_entries(provider, ids)
623 .into_iter()
624 .map(|entry| entry.and_then(|e| e.value))
625 .collect()
626 }
627
628 fn cache_entries(
629 &self,
630 provider: &ProviderId,
631 ids: &[WorkId],
632 ) -> Vec<Option<aspero_store::CachedLookup>> {
633 self.cache
634 .as_ref()
635 .and_then(|c| c.get_lookups(provider, ids).ok())
636 .unwrap_or_else(|| vec![None; ids.len()])
637 }
638
639 fn remember_lookups(
640 &self,
641 provider: &ProviderId,
642 answers: &[(&WorkId, Option<&SourceRecord>)],
643 ) {
644 if let Some(cache) = &self.cache {
645 let _ = cache.put_lookups(provider, answers, (self.clock)());
646 }
647 }
648
649 fn cached_search(&self, provider: &ProviderId, query: &SearchQuery) -> Option<(Page, bool)> {
651 let entry = self.cache.as_ref()?.get_search(provider, query).ok()??;
652 let fresh = entry.is_fresh((self.clock)(), self.cache_policy.searches);
653 Some((entry.value, fresh))
654 }
655
656 fn remember_search(&self, provider: &ProviderId, query: &SearchQuery, page: &Page) {
657 if let Some(cache) = &self.cache {
658 let _ = cache.put_search(provider, query, page, (self.clock)());
659 }
660 }
661}
662
663fn found_records(
665 results: &[LookupResult],
666 keep: impl Fn(&SourceRecord) -> bool,
667) -> impl Iterator<Item = (usize, &SourceRecord)> {
668 results
669 .iter()
670 .enumerate()
671 .filter_map(move |(i, r)| Some((i, r.record.as_ref().filter(|r| keep(r))?)))
672}
673
674fn add_usage(totals: &mut Vec<(ProviderId, Usage)>, provider: &ProviderId, usage: Usage) {
675 match totals.iter_mut().find(|(p, _)| p == provider) {
676 Some((_, total)) => *total = total.then(usage),
677 None => totals.push((provider.clone(), usage)),
678 }
679}
680
681#[derive(Debug, thiserror::Error)]
682#[error("provider answered a batch lookup out of order or incompletely")]
683struct ContractViolation;