1mod abstracts;
27mod check;
28mod resolve;
29pub mod retry;
30mod standard;
31pub mod transport;
32
33pub use abstracts::{Abstract, AbstractsReport};
34pub use check::{Checked, Finding, Severity, duplicates, findings};
35pub use resolve::{
36 Candidate, ResolveReport, Sighting, Verdict, Verification, Via, describe_update,
37};
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};
53
54use std::sync::Arc;
55use std::time::Duration;
56
57use aspero_core::{
58 Error, ErrorCategory, Field, FieldSet, Filter, Page, Provider, ProviderId, ResiliencePolicy,
59 SearchMode, SearchQuery, SourceRecord, Usage, WorkId,
60};
61pub use aspero_store::{Cache, SqliteCache, Stats as CacheStats, StoreError};
62use jiff::Timestamp;
63
64use crate::retry::{Jitter, random_jitter, with_retries};
65
66pub type Clock = fn() -> Timestamp;
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[non_exhaustive]
73pub struct CachePolicy {
74 pub records: Duration,
75 pub misses: Duration,
77 pub searches: Duration,
78}
79
80impl Default for CachePolicy {
81 fn default() -> Self {
82 const DAY: u64 = 86_400;
83 CachePolicy {
84 records: Duration::from_secs(7 * DAY),
85 misses: Duration::from_secs(DAY),
86 searches: Duration::from_secs(DAY),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub enum Served {
95 Live,
96 Cache,
98 Stale,
100}
101
102impl Served {
103 pub fn as_str(self) -> &'static str {
104 match self {
105 Served::Live => "live",
106 Served::Cache => "cache",
107 Served::Stale => "stale",
108 }
109 }
110}
111
112pub struct Engine {
113 entries: Vec<Entry>,
115 jitter: Jitter,
116 cache: Option<Arc<dyn Cache>>,
117 cache_policy: CachePolicy,
118 clock: Clock,
119}
120
121struct Entry {
122 provider: Arc<dyn Provider>,
123 policy: ResiliencePolicy,
124}
125
126pub struct EngineBuilder {
127 entries: Vec<(i32, Entry)>,
128 jitter: Jitter,
129 cache: Option<Arc<dyn Cache>>,
130 cache_policy: CachePolicy,
131 clock: Clock,
132}
133
134impl EngineBuilder {
135 pub fn provider(
138 mut self,
139 provider: Arc<dyn Provider>,
140 policy: ResiliencePolicy,
141 priority: i32,
142 ) -> Self {
143 self.entries.push((priority, Entry { provider, policy }));
144 self
145 }
146
147 pub fn jitter(mut self, jitter: Jitter) -> Self {
149 self.jitter = jitter;
150 self
151 }
152
153 pub fn cache(mut self, cache: Arc<dyn Cache>) -> Self {
155 self.cache = Some(cache);
156 self
157 }
158
159 pub fn cache_policy(mut self, policy: CachePolicy) -> Self {
160 self.cache_policy = policy;
161 self
162 }
163
164 pub fn clock(mut self, clock: Clock) -> Self {
166 self.clock = clock;
167 self
168 }
169
170 pub fn build(mut self) -> Engine {
171 self.entries.sort_by_key(|(priority, _)| *priority);
172 Engine {
173 entries: self.entries.into_iter().map(|(_, e)| e).collect(),
174 jitter: self.jitter,
175 cache: self.cache,
176 cache_policy: self.cache_policy,
177 clock: self.clock,
178 }
179 }
180}
181
182#[derive(Debug, Clone)]
184#[non_exhaustive]
185pub enum Outcome {
186 Found,
187 NotFound,
188 Failed(Error),
189}
190
191#[derive(Debug, Clone)]
192pub struct Attempt {
193 pub provider: ProviderId,
194 pub outcome: Outcome,
195 pub served: Served,
196}
197
198#[derive(Debug, Clone)]
199pub struct LookupResult {
200 pub id: WorkId,
202 pub record: Option<SourceRecord>,
203 pub attempts: Vec<Attempt>,
206}
207
208impl LookupResult {
209 pub fn is_not_found(&self) -> bool {
212 self.record.is_none()
213 && self
214 .attempts
215 .iter()
216 .any(|a| matches!(a.outcome, Outcome::NotFound))
217 }
218}
219
220#[derive(Debug, Clone)]
221pub struct LookupReport {
222 pub results: Vec<LookupResult>,
224 pub usage: Vec<(ProviderId, Usage)>,
226}
227
228#[derive(Debug, Clone)]
229pub struct SearchReport {
230 pub provider: ProviderId,
231 pub page: Page,
232 pub ignored_filters: Vec<Filter>,
234 pub usage: Option<Usage>,
235 pub served: Served,
236}
237
238#[derive(Debug, Clone, thiserror::Error)]
239#[non_exhaustive]
240pub enum EngineError {
241 #[error("no enabled provider is named {0:?}")]
242 UnknownProvider(String),
243 #[error("no enabled provider supports {0}")]
244 NoCapableProvider(&'static str),
245 #[error("{provider}: {error}")]
246 Provider {
247 provider: ProviderId,
248 #[source]
249 error: Error,
250 },
251}
252
253impl EngineError {
254 pub fn category(&self) -> ErrorCategory {
255 match self {
256 EngineError::UnknownProvider(_) => ErrorCategory::Usage,
257 EngineError::NoCapableProvider(_) => ErrorCategory::Unsupported,
258 EngineError::Provider { error, .. } => error.category(),
259 }
260 }
261}
262
263impl Engine {
264 pub fn builder() -> EngineBuilder {
265 EngineBuilder {
266 entries: Vec::new(),
267 jitter: random_jitter,
268 cache: None,
269 cache_policy: CachePolicy::default(),
270 clock: Timestamp::now,
271 }
272 }
273
274 pub fn providers(&self) -> impl Iterator<Item = &Arc<dyn Provider>> {
276 self.entries.iter().map(|e| &e.provider)
277 }
278
279 pub async fn lookup(&self, ids: &[WorkId], fields: FieldSet) -> LookupReport {
282 let mut results: Vec<LookupResult> = ids
283 .iter()
284 .map(|id| LookupResult {
285 id: id.clone(),
286 record: None,
287 attempts: Vec::new(),
288 })
289 .collect();
290 let mut usage: Vec<(ProviderId, Usage)> = Vec::new();
291
292 for entry in &self.entries {
293 let caps = entry.provider.capabilities();
294 let eligible: Vec<usize> = (0..results.len())
295 .filter(|&i| results[i].record.is_none())
296 .filter(|&i| caps.lookup_kinds.contains(&results[i].id.kind()))
297 .collect();
298 let ids: Vec<WorkId> = eligible.iter().map(|&i| results[i].id.clone()).collect();
299 let answers = self.lookup_at(entry, &ids, fields, &mut usage).await;
300 for (&i, (attempt, record)) in eligible.iter().zip(answers) {
301 results[i].record = record;
302 results[i].attempts.push(attempt);
303 }
304 }
305 LookupReport { results, usage }
306 }
307
308 pub async fn add_notices(&self, report: &mut LookupReport) {
313 for entry in &self.entries {
314 if !entry.provider.capabilities().notices {
315 continue;
316 }
317 let works = found_records(&report.results, |_| true);
318 for (i, found) in self.ask_about(entry, works, &mut report.usage).await {
319 let Some(record) = report.results[i].record.as_mut() else {
320 continue;
321 };
322 let work = &mut record.work;
323 resolve::merge_updates(&mut work.updates, &found.work.updates);
324 for target in found.work.update_to {
325 if !work.update_to.contains(&target) {
326 work.update_to.push(target);
327 }
328 }
329 }
330 }
331 }
332
333 pub async fn add_open_access(&self, report: &mut LookupReport) {
336 for entry in &self.entries {
337 if !entry
338 .provider
339 .capabilities()
340 .fields
341 .contains(Field::OpenAccess)
342 {
343 continue;
344 }
345 let works = found_records(&report.results, |r| r.work.open_access.is_none());
346 for (i, found) in self.ask_about(entry, works, &mut report.usage).await {
347 if let (Some(oa), Some(record)) =
348 (found.work.open_access, report.results[i].record.as_mut())
349 {
350 record.work.open_access = Some(oa);
351 }
352 }
353 }
354 }
355
356 async fn ask_about<'a>(
360 &self,
361 entry: &Entry,
362 works: impl IntoIterator<Item = (usize, &'a SourceRecord)>,
363 usage: &mut Vec<(ProviderId, Usage)>,
364 ) -> Vec<(usize, SourceRecord)> {
365 let provider = entry.provider.id();
366 let kinds = &entry.provider.capabilities().lookup_kinds;
367 let wanted: Vec<(usize, WorkId)> = works
368 .into_iter()
369 .filter(|(_, r)| r.source != provider)
370 .filter_map(|(i, r)| {
371 let id = r.ids.iter().find(|id| kinds.contains(&id.kind()))?;
372 Some((i, id.clone()))
373 })
374 .collect();
375 if wanted.is_empty() {
376 return Vec::new();
377 }
378 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
379 let answers = self.lookup_at(entry, &ids, FieldSet::ALL, usage).await;
380 wanted
381 .into_iter()
382 .zip(answers)
383 .filter_map(|((i, _), (_, record))| Some((i, record?)))
384 .collect()
385 }
386
387 async fn lookup_at(
391 &self,
392 entry: &Entry,
393 ids: &[WorkId],
394 fields: FieldSet,
395 usage: &mut Vec<(ProviderId, Usage)>,
396 ) -> Vec<(Attempt, Option<SourceRecord>)> {
397 let provider = entry.provider.id();
398 let caps = entry.provider.capabilities();
399 let attempt = |outcome, served| Attempt {
400 provider: provider.clone(),
401 outcome,
402 served,
403 };
404 let mut answers: Vec<Option<(Attempt, Option<SourceRecord>)>> = vec![None; ids.len()];
405
406 let mut pending = Vec::new();
408 let mut from_cache = 0;
409 for (i, cached) in self
410 .cached_lookups(&provider, ids, fields)
411 .into_iter()
412 .enumerate()
413 {
414 answers[i] = match cached {
415 Some(Some(record)) => {
416 from_cache += 1;
417 Some((attempt(Outcome::Found, Served::Cache), Some(record)))
418 }
419 Some(None) => {
420 from_cache += 1;
421 Some((attempt(Outcome::NotFound, Served::Cache), None))
422 }
423 None => {
424 pending.push(i);
425 None
426 }
427 };
428 }
429
430 if from_cache > 0 {
431 tracing::info!(target: "aspero::cache", provider = %provider, ids = from_cache, "lookups served from cache");
432 }
433 let mut chunks = pending.chunks(caps.max_batch.max(1));
434 while let Some(chunk) = chunks.next() {
435 let batch: Vec<WorkId> = chunk.iter().map(|&i| ids[i].clone()).collect();
436 let answer = with_retries(&entry.policy, self.jitter, || {
437 entry.provider.lookup(&batch, fields)
438 })
439 .await;
440
441 let error = match answer {
442 Ok(resp) => {
443 if let Some(u) = resp.usage {
444 add_usage(usage, &provider, u);
445 }
446 let in_order = resp.value.len() == batch.len()
449 && resp.value.iter().zip(&batch).all(|(l, id)| &l.id == id);
450 if in_order {
451 let stored: Vec<_> = resp
452 .value
453 .iter()
454 .map(|l| (&l.id, l.record.as_ref()))
455 .collect();
456 self.remember_lookups(&provider, &stored);
457 for (&i, lookup) in chunk.iter().zip(resp.value) {
458 let outcome = match lookup.record {
459 Some(_) => Outcome::Found,
460 None => Outcome::NotFound,
461 };
462 answers[i] = Some((attempt(outcome, Served::Live), lookup.record));
463 }
464 continue;
465 }
466 Error::decode(ContractViolation)
467 }
468 Err(error) => error,
469 };
470
471 let give_up = matches!(
474 error.category(),
475 ErrorCategory::Throttled | ErrorCategory::Auth
476 );
477 let affected: Vec<usize> = if give_up {
478 chunk
479 .iter()
480 .chain(chunks.by_ref().flatten())
481 .copied()
482 .collect()
483 } else {
484 chunk.to_vec()
485 };
486 let affected_ids: Vec<WorkId> = affected.iter().map(|&i| ids[i].clone()).collect();
488 for (&i, stale) in affected
489 .iter()
490 .zip(self.stale_lookups(&provider, &affected_ids))
491 {
492 answers[i] = Some(match stale {
493 Some(record) => (attempt(Outcome::Found, Served::Stale), Some(record)),
494 None => (attempt(Outcome::Failed(error.clone()), Served::Live), None),
495 });
496 }
497 }
498 answers
499 .into_iter()
500 .map(|a| a.expect("every ID answered from cache, provider or failure"))
501 .collect()
502 }
503
504 pub async fn search(
507 &self,
508 source: Option<&str>,
509 query: &SearchQuery,
510 ) -> Result<SearchReport, EngineError> {
511 let citation = query.mode == SearchMode::Citation;
512 let entry = match source {
513 Some(name) => self
514 .entries
515 .iter()
516 .find(|e| e.provider.id().as_str() == name)
517 .ok_or_else(|| EngineError::UnknownProvider(name.to_owned()))?,
518 None => self
519 .entries
520 .iter()
521 .find(|e| {
522 let caps = e.provider.capabilities();
523 caps.search && (!citation || caps.citation_search)
524 })
525 .ok_or(EngineError::NoCapableProvider(if citation {
526 "citation search"
527 } else {
528 "search"
529 }))?,
530 };
531 let provider = entry.provider.id();
532 let caps = entry.provider.capabilities();
533 let refuse = |what| EngineError::Provider {
534 provider: provider.clone(),
535 error: Error::Unsupported(what),
536 };
537 if !caps.search {
538 return Err(refuse("search"));
539 }
540 if citation && !caps.citation_search {
541 return Err(refuse("citation search"));
542 }
543
544 let ignored_filters: Vec<Filter> = query
545 .filters()
546 .into_iter()
547 .filter(|f| !caps.search_filters.contains(f))
548 .collect();
549 let mut sent = query.clone();
550 for filter in &ignored_filters {
551 match filter {
552 Filter::Years => sent.years = None,
553 Filter::WorkType => sent.work_type = None,
554 Filter::OpenAccess => sent.open_access = None,
555 _ => {}
556 }
557 }
558
559 let report = |page, usage, served| SearchReport {
560 provider: provider.clone(),
561 page,
562 ignored_filters: ignored_filters.clone(),
563 usage,
564 served,
565 };
566 let cached = self.cached_search(&provider, &sent);
567 if let Some((page, true)) = cached {
568 tracing::info!(target: "aspero::cache", provider = %provider, text = %sent.text, "search served from cache");
569 return Ok(report(page, None, Served::Cache));
570 }
571 match with_retries(&entry.policy, self.jitter, || entry.provider.search(&sent)).await {
572 Ok(resp) => {
573 self.remember_search(&provider, &sent, &resp.value);
574 Ok(report(resp.value, resp.usage, Served::Live))
575 }
576 Err(error) => match cached {
577 Some((page, _)) => Ok(report(page, None, Served::Stale)),
578 None => Err(EngineError::Provider {
579 provider: provider.clone(),
580 error,
581 }),
582 },
583 }
584 }
585
586 fn cached_lookups(
593 &self,
594 provider: &ProviderId,
595 ids: &[WorkId],
596 fields: FieldSet,
597 ) -> Vec<Option<Option<SourceRecord>>> {
598 let now = (self.clock)();
599 let policy = self.cache_policy;
600 self.cache_entries(provider, ids)
601 .into_iter()
602 .map(|entry| {
603 let entry = entry?;
604 match entry.value {
605 Some(ref record)
606 if entry.is_fresh(now, policy.records)
607 && fields.is_subset(record.fields) =>
608 {
609 Some(entry.value)
610 }
611 None if entry.is_fresh(now, policy.misses) => Some(None),
612 _ => None,
613 }
614 })
615 .collect()
616 }
617
618 fn stale_lookups(&self, provider: &ProviderId, ids: &[WorkId]) -> Vec<Option<SourceRecord>> {
620 self.cache_entries(provider, ids)
621 .into_iter()
622 .map(|entry| entry.and_then(|e| e.value))
623 .collect()
624 }
625
626 fn cache_entries(
627 &self,
628 provider: &ProviderId,
629 ids: &[WorkId],
630 ) -> Vec<Option<aspero_store::CachedLookup>> {
631 self.cache
632 .as_ref()
633 .and_then(|c| c.get_lookups(provider, ids).ok())
634 .unwrap_or_else(|| vec![None; ids.len()])
635 }
636
637 fn remember_lookups(
638 &self,
639 provider: &ProviderId,
640 answers: &[(&WorkId, Option<&SourceRecord>)],
641 ) {
642 if let Some(cache) = &self.cache {
643 let _ = cache.put_lookups(provider, answers, (self.clock)());
644 }
645 }
646
647 fn cached_search(&self, provider: &ProviderId, query: &SearchQuery) -> Option<(Page, bool)> {
649 let entry = self.cache.as_ref()?.get_search(provider, query).ok()??;
650 let fresh = entry.is_fresh((self.clock)(), self.cache_policy.searches);
651 Some((entry.value, fresh))
652 }
653
654 fn remember_search(&self, provider: &ProviderId, query: &SearchQuery, page: &Page) {
655 if let Some(cache) = &self.cache {
656 let _ = cache.put_search(provider, query, page, (self.clock)());
657 }
658 }
659}
660
661fn found_records(
663 results: &[LookupResult],
664 keep: impl Fn(&SourceRecord) -> bool,
665) -> impl Iterator<Item = (usize, &SourceRecord)> {
666 results
667 .iter()
668 .enumerate()
669 .filter_map(move |(i, r)| Some((i, r.record.as_ref().filter(|r| keep(r))?)))
670}
671
672fn add_usage(totals: &mut Vec<(ProviderId, Usage)>, provider: &ProviderId, usage: Usage) {
673 match totals.iter_mut().find(|(p, _)| p == provider) {
674 Some((_, total)) => *total = total.then(usage),
675 None => totals.push((provider.clone(), usage)),
676 }
677}
678
679#[derive(Debug, thiserror::Error)]
680#[error("provider answered a batch lookup out of order or incompletely")]
681struct ContractViolation;