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 let mut settled = vec![false; results.len()];
71 for entry in &self.entries {
72 let caps = entry.provider.capabilities();
73 let eligible: Vec<usize> = (0..results.len())
74 .filter(|&i| results[i].record.is_none() && !settled[i])
75 .filter(|&i| caps.lookup_kinds.contains(&results[i].id.kind()))
76 .collect();
77 let ids: Vec<WorkId> = eligible.iter().map(|&i| results[i].id.clone()).collect();
78 let answers = self.lookup_at(entry, &ids, fields, &mut usage).await;
79 for (&i, (attempt, record)) in eligible.iter().zip(answers) {
80 settled[i] = matches!(attempt.outcome, Outcome::NotFound)
81 && caps.final_misses.contains(&results[i].id.kind());
82 results[i].record = record;
83 results[i].attempts.push(attempt);
84 }
85 }
86 LookupReport { results, usage }
87 }
88
89 pub(crate) async fn lookup_at(
93 &self,
94 entry: &Entry,
95 ids: &[WorkId],
96 fields: FieldSet,
97 usage: &mut Vec<(ProviderId, Usage)>,
98 ) -> Vec<(Attempt, Option<SourceRecord>)> {
99 let provider = entry.provider.id();
100 let caps = entry.provider.capabilities();
101 let attempt = |outcome, served| Attempt {
102 provider: provider.clone(),
103 outcome,
104 served,
105 };
106 if self.options.offline {
107 return self.offline_lookups(&provider, ids, fields);
108 }
109 let mut answers: Vec<Option<Answer>> = vec![None; ids.len()];
110
111 let mut pending = Vec::new();
113 let mut from_cache = 0;
114 for (i, cached) in self
115 .cached_lookups(&provider, ids, fields)
116 .into_iter()
117 .enumerate()
118 {
119 answers[i] = match cached {
120 Some(Some(record)) => {
121 from_cache += 1;
122 Some((attempt(Outcome::Found, Served::Cache), Some(record)))
123 }
124 Some(None) => {
125 from_cache += 1;
126 Some((attempt(Outcome::NotFound, Served::Cache), None))
127 }
128 None => {
129 pending.push(i);
130 None
131 }
132 };
133 }
134
135 if from_cache > 0 {
136 tracing::info!(target: "aspero::cache", provider = %provider, ids = from_cache, "lookups served from cache");
137 }
138 let wanted: Vec<(usize, WorkId)> = pending.iter().map(|&i| (i, ids[i].clone())).collect();
140 let (mut leads, joined) = self.inflight.claim(&provider, &wanted, fields);
141 let led = leads.indices();
142 let mut chunks = led.chunks(caps.max_batch.max(1));
143 while let Some(chunk) = chunks.next() {
144 leads.publish(&answers);
145 let batch: Vec<WorkId> = chunk.iter().map(|&i| ids[i].clone()).collect();
146 let answer = self
147 .call(entry, || entry.provider.lookup(&batch, fields))
148 .await;
149
150 let error = match answer {
151 Ok(resp) => {
152 if let Some(u) = resp.usage {
153 add_usage(usage, &provider, u);
154 }
155 let in_order = resp.value.len() == batch.len()
158 && resp.value.iter().zip(&batch).all(|(l, id)| &l.id == id);
159 if in_order {
160 let stored: Vec<_> = resp
161 .value
162 .iter()
163 .map(|l| (&l.id, l.record.as_ref()))
164 .collect();
165 self.remember_lookups(&provider, &stored);
166 for (&i, lookup) in chunk.iter().zip(resp.value) {
167 let outcome = match lookup.record {
168 Some(_) => Outcome::Found,
169 None => Outcome::NotFound,
170 };
171 answers[i] = Some((attempt(outcome, Served::Live), lookup.record));
172 }
173 continue;
174 }
175 Error::decode(ContractViolation)
176 }
177 Err(error) => error,
178 };
179
180 let give_up = matches!(
183 error.category(),
184 ErrorCategory::Throttled | ErrorCategory::Auth
185 );
186 let affected: Vec<usize> = if give_up {
187 chunk
188 .iter()
189 .chain(chunks.by_ref().flatten())
190 .copied()
191 .collect()
192 } else {
193 chunk.to_vec()
194 };
195 let affected_ids: Vec<WorkId> = affected.iter().map(|&i| ids[i].clone()).collect();
197 for (&i, stale) in affected
198 .iter()
199 .zip(self.stale_lookups(&provider, &affected_ids))
200 {
201 answers[i] = Some(match stale {
202 Some(record) => (attempt(Outcome::Found, Served::Stale), Some(record)),
203 None => (attempt(Outcome::Failed(error.clone()), Served::Live), None),
204 });
205 }
206 }
207 leads.publish(&answers);
208 drop(leads);
209
210 let mut abandoned = Vec::new();
212 for (i, call) in joined {
213 match call.answer().await {
214 Some(answer) => answers[i] = Some(answer),
215 None => abandoned.push(i),
216 }
217 }
218 if !abandoned.is_empty() {
219 let retry: Vec<WorkId> = abandoned.iter().map(|&i| ids[i].clone()).collect();
220 let again = Box::pin(self.lookup_at(entry, &retry, fields, usage)).await;
221 for (i, answer) in abandoned.into_iter().zip(again) {
222 answers[i] = Some(answer);
223 }
224 }
225 answers
226 .into_iter()
227 .map(|a| a.expect("every ID answered from cache, provider or failure"))
228 .collect()
229 }
230
231 fn cached_lookups(
238 &self,
239 provider: &ProviderId,
240 ids: &[WorkId],
241 fields: FieldSet,
242 ) -> Vec<Option<Option<SourceRecord>>> {
243 let now = (self.clock)();
244 let policy = self.cache_policy;
245 self.cache_entries(provider, ids)
246 .into_iter()
247 .map(|entry| {
248 let entry = entry?;
249 match entry.value {
250 Some(ref record)
251 if entry.is_fresh(now, policy.records)
252 && fields.is_subset(record.fields) =>
253 {
254 Some(entry.value)
255 }
256 None if entry.is_fresh(now, policy.misses) => Some(None),
257 _ => None,
258 }
259 })
260 .collect()
261 }
262
263 fn offline_lookups(
265 &self,
266 provider: &ProviderId,
267 ids: &[WorkId],
268 fields: FieldSet,
269 ) -> Vec<Answer> {
270 let fresh = self.cached_lookups(provider, ids, fields);
271 let any = self.stale_lookups(provider, ids);
272 let attempt = |outcome, served| Attempt {
273 provider: provider.clone(),
274 outcome,
275 served,
276 };
277 fresh
278 .into_iter()
279 .zip(any)
280 .map(|answer| match answer {
281 (Some(Some(record)), _) => (attempt(Outcome::Found, Served::Cache), Some(record)),
282 (Some(None), _) => (attempt(Outcome::NotFound, Served::Cache), None),
283 (None, Some(record)) => (attempt(Outcome::Found, Served::Stale), Some(record)),
284 (None, None) => (attempt(Outcome::Failed(Error::Offline), Served::Live), None),
285 })
286 .collect()
287 }
288
289 fn stale_lookups(&self, provider: &ProviderId, ids: &[WorkId]) -> Vec<Option<SourceRecord>> {
291 self.cache_entries(provider, ids)
292 .into_iter()
293 .map(|entry| entry.and_then(|e| e.value))
294 .collect()
295 }
296
297 fn cache_entries(
298 &self,
299 provider: &ProviderId,
300 ids: &[WorkId],
301 ) -> Vec<Option<aspero_store::CachedLookup>> {
302 self.cache
303 .as_ref()
304 .and_then(|c| c.get_lookups(provider, ids).ok())
305 .unwrap_or_else(|| vec![None; ids.len()])
306 }
307
308 fn remember_lookups(
309 &self,
310 provider: &ProviderId,
311 answers: &[(&WorkId, Option<&SourceRecord>)],
312 ) {
313 if let Some(cache) = &self.cache {
314 let _ = cache.put_lookups(provider, answers, (self.clock)());
315 }
316 }
317}
318
319#[derive(Debug, thiserror::Error)]
320#[error("provider answered a batch lookup out of order or incompletely")]
321struct ContractViolation;