1use aspero_core::{Error, ErrorCategory, FieldSet, ProviderId, SourceRecord, Usage, WorkId};
5
6use crate::inflight::Answer;
7use crate::{Engine, Entry, Served, add_usage};
8
9#[derive(Debug, Clone)]
11#[non_exhaustive]
12pub enum Outcome {
13 Found,
14 NotFound,
15 Failed(Error),
16}
17
18#[derive(Debug, Clone)]
19pub struct Attempt {
20 pub provider: ProviderId,
21 pub outcome: Outcome,
22 pub served: Served,
23}
24
25#[derive(Debug, Clone)]
26pub struct LookupResult {
27 pub id: WorkId,
29 pub record: Option<SourceRecord>,
30 pub attempts: Vec<Attempt>,
33}
34
35impl LookupResult {
36 pub fn is_not_found(&self) -> bool {
39 self.record.is_none()
40 && self
41 .attempts
42 .iter()
43 .any(|a| matches!(a.outcome, Outcome::NotFound))
44 }
45}
46
47#[derive(Debug, Clone)]
48pub struct LookupReport {
49 pub results: Vec<LookupResult>,
51 pub usage: Vec<(ProviderId, Usage)>,
53}
54
55impl Engine {
56 pub async fn lookup(&self, ids: &[WorkId], fields: FieldSet) -> LookupReport {
59 let mut results: Vec<LookupResult> = ids
60 .iter()
61 .map(|id| LookupResult {
62 id: id.clone(),
63 record: None,
64 attempts: Vec::new(),
65 })
66 .collect();
67 let mut usage: Vec<(ProviderId, Usage)> = Vec::new();
68
69 for entry in &self.entries {
70 let caps = entry.provider.capabilities();
71 let eligible: Vec<usize> = (0..results.len())
72 .filter(|&i| results[i].record.is_none())
73 .filter(|&i| caps.lookup_kinds.contains(&results[i].id.kind()))
74 .collect();
75 let ids: Vec<WorkId> = eligible.iter().map(|&i| results[i].id.clone()).collect();
76 let answers = self.lookup_at(entry, &ids, fields, &mut usage).await;
77 for (&i, (attempt, record)) in eligible.iter().zip(answers) {
78 results[i].record = record;
79 results[i].attempts.push(attempt);
80 }
81 }
82 LookupReport { results, usage }
83 }
84
85 pub(crate) async fn lookup_at(
89 &self,
90 entry: &Entry,
91 ids: &[WorkId],
92 fields: FieldSet,
93 usage: &mut Vec<(ProviderId, Usage)>,
94 ) -> Vec<(Attempt, Option<SourceRecord>)> {
95 let provider = entry.provider.id();
96 let caps = entry.provider.capabilities();
97 let attempt = |outcome, served| Attempt {
98 provider: provider.clone(),
99 outcome,
100 served,
101 };
102 if self.options.offline {
103 return self.offline_lookups(&provider, ids, fields);
104 }
105 let mut answers: Vec<Option<Answer>> = vec![None; ids.len()];
106
107 let mut pending = Vec::new();
109 let mut from_cache = 0;
110 for (i, cached) in self
111 .cached_lookups(&provider, ids, fields)
112 .into_iter()
113 .enumerate()
114 {
115 answers[i] = match cached {
116 Some(Some(record)) => {
117 from_cache += 1;
118 Some((attempt(Outcome::Found, Served::Cache), Some(record)))
119 }
120 Some(None) => {
121 from_cache += 1;
122 Some((attempt(Outcome::NotFound, Served::Cache), None))
123 }
124 None => {
125 pending.push(i);
126 None
127 }
128 };
129 }
130
131 if from_cache > 0 {
132 tracing::info!(target: "aspero::cache", provider = %provider, ids = from_cache, "lookups served from cache");
133 }
134 let wanted: Vec<(usize, WorkId)> = pending.iter().map(|&i| (i, ids[i].clone())).collect();
136 let (mut leads, joined) = self.inflight.claim(&provider, &wanted, fields);
137 let led = leads.indices();
138 let mut chunks = led.chunks(caps.max_batch.max(1));
139 while let Some(chunk) = chunks.next() {
140 leads.publish(&answers);
141 let batch: Vec<WorkId> = chunk.iter().map(|&i| ids[i].clone()).collect();
142 let answer = self
143 .call(entry, || entry.provider.lookup(&batch, fields))
144 .await;
145
146 let error = match answer {
147 Ok(resp) => {
148 if let Some(u) = resp.usage {
149 add_usage(usage, &provider, u);
150 }
151 let in_order = resp.value.len() == batch.len()
154 && resp.value.iter().zip(&batch).all(|(l, id)| &l.id == id);
155 if in_order {
156 let stored: Vec<_> = resp
157 .value
158 .iter()
159 .map(|l| (&l.id, l.record.as_ref()))
160 .collect();
161 self.remember_lookups(&provider, &stored);
162 for (&i, lookup) in chunk.iter().zip(resp.value) {
163 let outcome = match lookup.record {
164 Some(_) => Outcome::Found,
165 None => Outcome::NotFound,
166 };
167 answers[i] = Some((attempt(outcome, Served::Live), lookup.record));
168 }
169 continue;
170 }
171 Error::decode(ContractViolation)
172 }
173 Err(error) => error,
174 };
175
176 let give_up = matches!(
179 error.category(),
180 ErrorCategory::Throttled | ErrorCategory::Auth
181 );
182 let affected: Vec<usize> = if give_up {
183 chunk
184 .iter()
185 .chain(chunks.by_ref().flatten())
186 .copied()
187 .collect()
188 } else {
189 chunk.to_vec()
190 };
191 let affected_ids: Vec<WorkId> = affected.iter().map(|&i| ids[i].clone()).collect();
193 for (&i, stale) in affected
194 .iter()
195 .zip(self.stale_lookups(&provider, &affected_ids))
196 {
197 answers[i] = Some(match stale {
198 Some(record) => (attempt(Outcome::Found, Served::Stale), Some(record)),
199 None => (attempt(Outcome::Failed(error.clone()), Served::Live), None),
200 });
201 }
202 }
203 leads.publish(&answers);
204 drop(leads);
205
206 let mut abandoned = Vec::new();
208 for (i, call) in joined {
209 match call.answer().await {
210 Some(answer) => answers[i] = Some(answer),
211 None => abandoned.push(i),
212 }
213 }
214 if !abandoned.is_empty() {
215 let retry: Vec<WorkId> = abandoned.iter().map(|&i| ids[i].clone()).collect();
216 let again = Box::pin(self.lookup_at(entry, &retry, fields, usage)).await;
217 for (i, answer) in abandoned.into_iter().zip(again) {
218 answers[i] = Some(answer);
219 }
220 }
221 answers
222 .into_iter()
223 .map(|a| a.expect("every ID answered from cache, provider or failure"))
224 .collect()
225 }
226
227 fn cached_lookups(
234 &self,
235 provider: &ProviderId,
236 ids: &[WorkId],
237 fields: FieldSet,
238 ) -> Vec<Option<Option<SourceRecord>>> {
239 let now = (self.clock)();
240 let policy = self.cache_policy;
241 self.cache_entries(provider, ids)
242 .into_iter()
243 .map(|entry| {
244 let entry = entry?;
245 match entry.value {
246 Some(ref record)
247 if entry.is_fresh(now, policy.records)
248 && fields.is_subset(record.fields) =>
249 {
250 Some(entry.value)
251 }
252 None if entry.is_fresh(now, policy.misses) => Some(None),
253 _ => None,
254 }
255 })
256 .collect()
257 }
258
259 fn offline_lookups(
261 &self,
262 provider: &ProviderId,
263 ids: &[WorkId],
264 fields: FieldSet,
265 ) -> Vec<Answer> {
266 let fresh = self.cached_lookups(provider, ids, fields);
267 let any = self.stale_lookups(provider, ids);
268 let attempt = |outcome, served| Attempt {
269 provider: provider.clone(),
270 outcome,
271 served,
272 };
273 fresh
274 .into_iter()
275 .zip(any)
276 .map(|answer| match answer {
277 (Some(Some(record)), _) => (attempt(Outcome::Found, Served::Cache), Some(record)),
278 (Some(None), _) => (attempt(Outcome::NotFound, Served::Cache), None),
279 (None, Some(record)) => (attempt(Outcome::Found, Served::Stale), Some(record)),
280 (None, None) => (attempt(Outcome::Failed(Error::Offline), Served::Live), None),
281 })
282 .collect()
283 }
284
285 fn stale_lookups(&self, provider: &ProviderId, ids: &[WorkId]) -> Vec<Option<SourceRecord>> {
287 self.cache_entries(provider, ids)
288 .into_iter()
289 .map(|entry| entry.and_then(|e| e.value))
290 .collect()
291 }
292
293 fn cache_entries(
294 &self,
295 provider: &ProviderId,
296 ids: &[WorkId],
297 ) -> Vec<Option<aspero_store::CachedLookup>> {
298 self.cache
299 .as_ref()
300 .and_then(|c| c.get_lookups(provider, ids).ok())
301 .unwrap_or_else(|| vec![None; ids.len()])
302 }
303
304 fn remember_lookups(
305 &self,
306 provider: &ProviderId,
307 answers: &[(&WorkId, Option<&SourceRecord>)],
308 ) {
309 if let Some(cache) = &self.cache {
310 let _ = cache.put_lookups(provider, answers, (self.clock)());
311 }
312 }
313}
314
315#[derive(Debug, thiserror::Error)]
316#[error("provider answered a batch lookup out of order or incompletely")]
317struct ContractViolation;