1use std::collections::HashMap;
9use std::sync::{Mutex, MutexGuard};
10
11use async_trait::async_trait;
12
13use super::types::{
14 CachedImage, CategoryRow, ItemHit, ItemQuery, ItemRow, Near, RestaurantHit, RestaurantQuery,
15 RestaurantRow, effective_price, haversine_km, query_terms,
16};
17use super::{Cache, CacheEntry, CacheKey, hex_sha256};
18use crate::error::Result;
19
20const SIGHTING_RADIUS_KM: f64 = 1.0;
23
24#[derive(Debug, Clone)]
25struct Sighting {
26 lat: f64,
27 lon: f64,
28 distance_km: Option<f64>,
29 eta: Option<i64>,
30 fee: Option<i64>,
31 free: Option<bool>,
32 fetched_at: i64,
33}
34
35#[derive(Default)]
36struct State {
37 entries: HashMap<String, CacheEntry>,
38 restaurants: HashMap<String, (RestaurantRow, i64)>,
39 sightings: HashMap<String, Vec<Sighting>>,
40 categories: HashMap<String, Vec<CategoryRow>>,
41 items: HashMap<(String, String), (ItemRow, i64)>,
42 images: HashMap<String, (Vec<u8>, String, i64)>,
43}
44
45#[derive(Default)]
47pub struct MemoryCache {
48 state: Mutex<State>,
49}
50
51impl std::fmt::Debug for MemoryCache {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("MemoryCache").finish_non_exhaustive()
54 }
55}
56
57fn merge_opt<T: Clone>(new: &mut Option<T>, old: &Option<T>) {
58 if new.is_none() {
59 new.clone_from(old);
60 }
61}
62
63fn merge_restaurant(mut new: RestaurantRow, old: &RestaurantRow) -> RestaurantRow {
65 if new.cuisines.is_empty() {
66 new.cuisines.clone_from(&old.cuisines);
67 }
68 if new.name.is_empty() {
69 new.name.clone_from(&old.name);
70 }
71 merge_opt(&mut new.chain_name, &old.chain_name);
72 merge_opt(&mut new.branch_name, &old.branch_name);
73 merge_opt(&mut new.business_type, &old.business_type);
74 merge_opt(&mut new.rating, &old.rating);
75 merge_opt(&mut new.vote_count, &old.vote_count);
76 merge_opt(&mut new.price_level, &old.price_level);
77 merge_opt(&mut new.lat, &old.lat);
78 merge_opt(&mut new.lon, &old.lon);
79 merge_opt(&mut new.has_promo, &old.has_promo);
80 merge_opt(&mut new.promo_text, &old.promo_text);
81 merge_opt(&mut new.open_now, &old.open_now);
82 merge_opt(&mut new.opening_hours, &old.opening_hours);
83 merge_opt(&mut new.photo_url, &old.photo_url);
84 new
85}
86
87fn words_match(terms: &[String], haystack: &str) -> bool {
88 let h = haystack.to_lowercase();
89 terms.iter().all(|t| h.contains(t.as_str()))
90}
91
92impl MemoryCache {
93 fn lock(&self) -> MutexGuard<'_, State> {
94 self.state
95 .lock()
96 .unwrap_or_else(std::sync::PoisonError::into_inner)
97 }
98
99 fn upsert_restaurant(
100 st: &mut State,
101 r: &RestaurantRow,
102 seen_from: Option<(f64, f64)>,
103 now: i64,
104 ) {
105 let row = match st.restaurants.get(&r.id) {
106 Some((old, _)) => merge_restaurant(r.clone(), old),
107 None => r.clone(),
108 };
109 st.restaurants.insert(r.id.clone(), (row, now));
110 if let Some((lat, lon)) = seen_from {
111 let list = st.sightings.entry(r.id.clone()).or_default();
112 list.retain(|s| haversine_km(s.lat, s.lon, lat, lon) > 0.11);
113 list.push(Sighting {
114 lat,
115 lon,
116 distance_km: r.distance_km,
117 eta: r.delivery_eta_min,
118 fee: r.delivery_fee_minor,
119 free: r.free_delivery,
120 fetched_at: now,
121 });
122 }
123 }
124
125 fn localize(st: &State, mut hit: RestaurantHit, near: &Near) -> Option<RestaurantHit> {
126 let best = st.sightings.get(&hit.row.id).and_then(|list| {
127 list.iter()
128 .map(|s| (haversine_km(near.lat, near.lon, s.lat, s.lon), s))
129 .filter(|(d, _)| *d <= SIGHTING_RADIUS_KM)
130 .min_by(|a, b| a.0.total_cmp(&b.0))
131 .map(|(_, s)| s.clone())
132 });
133 if let Some(s) = best {
134 let r = &mut hit.row;
135 r.distance_km = s.distance_km.or(r.distance_km);
136 r.delivery_eta_min = s.eta;
137 r.delivery_fee_minor = s.fee;
138 r.free_delivery = s.free;
139 hit.fetched_at = hit.fetched_at.max(s.fetched_at);
140 return hit
141 .row
142 .distance_km
143 .is_none_or(|d| d <= near.radius_km)
144 .then_some(hit);
145 }
146 let (lat, lon) = (hit.row.lat?, hit.row.lon?);
147 let d = haversine_km(near.lat, near.lon, lat, lon);
148 if d > near.radius_km {
149 return None;
150 }
151 hit.row.distance_km = Some(d);
152 hit.row.delivery_eta_min = None;
153 hit.row.delivery_fee_minor = None;
154 hit.row.free_delivery = None;
155 Some(hit)
156 }
157
158 fn hit(st: &State, id: &str, near: Option<&Near>) -> Option<RestaurantHit> {
159 let (row, fetched_at) = st.restaurants.get(id)?;
160 let hit = RestaurantHit {
161 row: row.clone(),
162 fetched_at: *fetched_at,
163 };
164 match near {
165 Some(n) => Self::localize(st, hit, n),
166 None => Some(hit),
167 }
168 }
169}
170
171#[async_trait]
172impl Cache for MemoryCache {
173 async fn get_entry(&self, key: &CacheKey) -> Result<Option<CacheEntry>> {
174 Ok(self.lock().entries.get(&key.hash()).cloned())
175 }
176
177 async fn put_entry(&self, key: &CacheKey, entry: &CacheEntry) -> Result<()> {
178 self.lock().entries.insert(key.hash(), entry.clone());
179 Ok(())
180 }
181
182 async fn index_restaurants(
183 &self,
184 rows: &[RestaurantRow],
185 seen_from: Option<(f64, f64)>,
186 now: i64,
187 ) -> Result<()> {
188 let mut st = self.lock();
189 for r in rows.iter().filter(|r| !r.id.is_empty()) {
190 Self::upsert_restaurant(&mut st, r, seen_from, now);
191 }
192 Ok(())
193 }
194
195 async fn index_items(&self, items: &[ItemRow], now: i64) -> Result<()> {
196 let mut st = self.lock();
197 for it in items.iter().filter(|i| !i.id.is_empty()) {
198 let k = (it.restaurant_id.clone(), it.id.clone());
199 let mut row = it.clone();
200 if let Some((old, _)) = st.items.get(&k) {
201 merge_opt(&mut row.category_id, &old.category_id);
202 }
203 st.items.insert(k, (row, now));
204 }
205 Ok(())
206 }
207
208 async fn index_menu(
209 &self,
210 restaurant: &RestaurantRow,
211 categories: &[CategoryRow],
212 items: &[ItemRow],
213 seen_from: Option<(f64, f64)>,
214 now: i64,
215 ) -> Result<()> {
216 let mut st = self.lock();
217 Self::upsert_restaurant(&mut st, restaurant, seen_from, now);
218 st.categories
219 .insert(restaurant.id.clone(), categories.to_vec());
220 st.items.retain(|(rid, _), _| rid != &restaurant.id);
221 for it in items.iter().filter(|i| !i.id.is_empty()) {
222 st.items
223 .insert((restaurant.id.clone(), it.id.clone()), (it.clone(), now));
224 }
225 Ok(())
226 }
227
228 async fn search_restaurants(&self, q: &RestaurantQuery) -> Result<Vec<RestaurantHit>> {
229 let st = self.lock();
230 let terms = q.text.as_deref().map(query_terms).unwrap_or_default();
231 let mut hits: Vec<RestaurantHit> = st
232 .restaurants
233 .keys()
234 .filter_map(|id| Self::hit(&st, id, q.near.as_ref()))
235 .filter(|h| q.namespace.as_ref().is_none_or(|ns| &h.row.namespace == ns))
236 .filter(|h| {
237 let r = &h.row;
238 let text = format!(
239 "{} {} {}",
240 r.name,
241 r.chain_name.as_deref().unwrap_or_default(),
242 r.cuisines.join(" ")
243 );
244 words_match(&terms, &text)
245 })
246 .collect();
247 hits.sort_by(|a, b| a.row.name.cmp(&b.row.name).then(a.row.id.cmp(&b.row.id)));
248 if let Some(l) = q.limit {
249 hits.truncate(l);
250 }
251 Ok(hits)
252 }
253
254 async fn search_items(&self, q: &ItemQuery) -> Result<Vec<ItemHit>> {
255 let st = self.lock();
256 let terms = q.text.as_deref().map(query_terms).unwrap_or_default();
257 let mut hits: Vec<ItemHit> = st
258 .items
259 .values()
260 .filter(|(it, _)| {
261 q.restaurant_id
262 .as_ref()
263 .is_none_or(|r| &it.restaurant_id == r)
264 })
265 .filter(|(it, _)| !q.available_only || it.available != Some(false))
266 .filter(|(it, _)| {
267 q.max_price_minor
268 .is_none_or(|m| effective_price(it).is_some_and(|p| p <= m))
269 })
270 .filter(|(it, _)| {
271 let text = format!(
272 "{} {}",
273 it.name,
274 it.description.as_deref().unwrap_or_default()
275 );
276 words_match(&terms, &text)
277 })
278 .filter_map(|(it, at)| {
279 let restaurant = Self::hit(&st, &it.restaurant_id, q.near.as_ref());
280 if q.near.is_some() && restaurant.is_none() {
281 return None;
282 }
283 Some(ItemHit {
284 item: it.clone(),
285 restaurant,
286 fetched_at: *at,
287 })
288 })
289 .collect();
290 hits.sort_by_key(|h| {
291 (
292 effective_price(&h.item).unwrap_or(i64::MAX),
293 h.item.id.clone(),
294 )
295 });
296 if let Some(l) = q.limit {
297 hits.truncate(l);
298 }
299 Ok(hits)
300 }
301
302 async fn restaurant(&self, id: &str) -> Result<Option<RestaurantHit>> {
303 Ok(Self::hit(&self.lock(), id, None))
304 }
305
306 async fn image(&self, url: &str, now: i64) -> Result<Option<CachedImage>> {
307 let mut st = self.lock();
308 Ok(st.images.get_mut(url).map(|(bytes, ct, at)| {
309 *at = now;
310 CachedImage {
311 url: url.to_string(),
312 path: None,
313 hash: hex_sha256(bytes),
314 bytes: bytes.len() as u64,
315 width: None,
316 height: None,
317 content_type: ct.clone(),
318 cached: true,
319 }
320 }))
321 }
322
323 async fn put_image(
324 &self,
325 url: &str,
326 bytes: &[u8],
327 content_type: &str,
328 now: i64,
329 ) -> Result<CachedImage> {
330 self.lock().images.insert(
331 url.to_string(),
332 (bytes.to_vec(), content_type.to_string(), now),
333 );
334 Ok(CachedImage {
335 url: url.to_string(),
336 path: None,
337 hash: hex_sha256(bytes),
338 bytes: bytes.len() as u64,
339 width: None,
340 height: None,
341 content_type: content_type.to_string(),
342 cached: false,
343 })
344 }
345}
346
347#[derive(Debug, Default, Clone, Copy)]
349pub struct NoCache;
350
351#[async_trait]
352impl Cache for NoCache {
353 async fn get_entry(&self, _key: &CacheKey) -> Result<Option<CacheEntry>> {
354 Ok(None)
355 }
356 async fn put_entry(&self, _key: &CacheKey, _entry: &CacheEntry) -> Result<()> {
357 Ok(())
358 }
359 async fn index_restaurants(
360 &self,
361 _rows: &[RestaurantRow],
362 _seen_from: Option<(f64, f64)>,
363 _now: i64,
364 ) -> Result<()> {
365 Ok(())
366 }
367 async fn index_items(&self, _items: &[ItemRow], _now: i64) -> Result<()> {
368 Ok(())
369 }
370 async fn index_menu(
371 &self,
372 _restaurant: &RestaurantRow,
373 _categories: &[CategoryRow],
374 _items: &[ItemRow],
375 _seen_from: Option<(f64, f64)>,
376 _now: i64,
377 ) -> Result<()> {
378 Ok(())
379 }
380 async fn search_restaurants(&self, _q: &RestaurantQuery) -> Result<Vec<RestaurantHit>> {
381 Ok(Vec::new())
382 }
383 async fn search_items(&self, _q: &ItemQuery) -> Result<Vec<ItemHit>> {
384 Ok(Vec::new())
385 }
386 async fn restaurant(&self, _id: &str) -> Result<Option<RestaurantHit>> {
387 Ok(None)
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn rest(id: &str, name: &str, cuisines: &[&str]) -> RestaurantRow {
396 RestaurantRow {
397 id: id.into(),
398 namespace: "food".into(),
399 name: name.into(),
400 cuisines: cuisines.iter().map(ToString::to_string).collect(),
401 ..RestaurantRow::default()
402 }
403 }
404
405 #[tokio::test]
406 async fn catalog_search_and_near() -> Result<()> {
407 let c = MemoryCache::default();
408 let mut a = rest("A", "Sisig Hooray", &["Filipino"]);
409 a.distance_km = Some(1.5);
410 c.index_restaurants(&[a], Some((14.55, 121.02)), 10).await?;
411 let mut b = rest("B", "Ramen Bar", &["Japanese"]);
412 b.lat = Some(1.30);
413 b.lon = Some(103.8);
414 c.index_restaurants(&[b], None, 10).await?;
415 c.index_restaurants(&[rest("A", "Sisig Hooray", &[])], None, 20)
417 .await?;
418
419 let q = |text: &str, near: Option<Near>| RestaurantQuery {
420 text: Some(text.into()),
421 near,
422 ..RestaurantQuery::default()
423 };
424 let hits = c.search_restaurants(&q("filipino", None)).await?;
425 assert_eq!(hits.len(), 1);
426 assert_eq!(hits[0].row.id, "A");
427 let near = Near {
428 lat: 14.5501,
429 lon: 121.0201,
430 radius_km: 6.0,
431 };
432 let hits = c.search_restaurants(&q("sisig", Some(near))).await?;
433 assert_eq!(hits[0].row.distance_km, Some(1.5));
434 assert!(
435 c.search_restaurants(&q("ramen", Some(near)))
436 .await?
437 .is_empty()
438 );
439
440 let item = |id: &str, price: i64| ItemRow {
441 id: id.into(),
442 restaurant_id: "A".into(),
443 name: format!("Pork Sisig {id}"),
444 price_minor: Some(price),
445 ..ItemRow::default()
446 };
447 c.index_menu(
448 &rest("A", "Sisig Hooray", &[]),
449 &[],
450 &[item("1", 15_000), item("2", 30_000)],
451 None,
452 30,
453 )
454 .await?;
455 let items = c
456 .search_items(&ItemQuery {
457 text: Some("sisig".into()),
458 max_price_minor: Some(20_000),
459 ..ItemQuery::default()
460 })
461 .await?;
462 assert_eq!(items.len(), 1);
463 assert_eq!(items[0].item.id, "1");
464 assert!(items[0].restaurant.is_some());
465 c.index_menu(
467 &rest("A", "Sisig Hooray", &[]),
468 &[],
469 &[item("2", 30_000)],
470 None,
471 40,
472 )
473 .await?;
474 let all = c.search_items(&ItemQuery::default()).await?;
475 assert_eq!(all.len(), 1);
476 Ok(())
477 }
478}