Skip to main content

grabcli_core/cache/
memory.rs

1//! In-memory [`Cache`] backends: [`MemoryCache`] (everything in maps, for
2//! tests and short-lived embedders) and [`NoCache`] (stores nothing).
3//!
4//! `MemoryCache`'s catalog search is a plain case-insensitive substring
5//! match on words, not the FTS ranking of the SQLite backend; "near" uses
6//! known coordinates or a sighting within 1 km of the query point.
7
8use 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
20/// A sighting within this distance of the query point counts as "seen from
21/// this address".
22const 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/// Everything in memory, behind one mutex.
46#[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
63/// Keep known fields when the new row lacks them.
64fn 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/// A cache that stores nothing: every read misses, every write succeeds.
348#[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        // Re-index without cuisines keeps the known ones.
416        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        // A new menu drops items no longer on it.
466        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}