Skip to main content

wowlab_engine_adapter_data/remote/cache/
mod.rs

1//! Provides a patch-versioned memory, disk, and network cache.
2
3mod disk;
4mod error;
5#[cfg(test)]
6mod tests;
7
8use std::sync::Arc;
9
10use moka::future::Cache;
11use serde::{Serialize, de::DeserializeOwned};
12use wowlab_fs::{directory, path::PathBuf};
13use wowlab_supabase::SupabaseClient;
14use wowlab_types::data::{
15    CombatRatingsFlat, CombatRatingsMultByIlvlFlat, CurveFlat, CurvePointFlat, GemPropertiesFlat,
16    HpPerStaFlat, ItemBonusFlat, ItemDataFlat, ItemOffsetCurveFlat, ItemScalingConfigFlat,
17    ItemScalingData, ItemSquishEraFlat, PowerTypeFlat, RandPropPointsFlat, SpecDataFlat,
18    SpellDataFlat, SpellScalingFlat, TraitTreeFlat,
19};
20
21use super::query::{Query, Table};
22
23#[rustfmt::skip]
24pub use error::{CacheError, CacheOperation};
25
26const CACHE_MAX_SPELLS: u64 = 50_000;
27const CACHE_MAX_TRAITS: u64 = 1_000;
28const CACHE_MAX_ITEMS: u64 = 50_000;
29const CACHE_MAX_SPECS: u64 = 128;
30
31/// Bounds within-patch drift from hotfixed rows.
32const CACHE_TTL_SECS: u64 = 3_600;
33const CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(CACHE_TTL_SECS);
34
35const FETCH_ALL_LIMIT: u32 = 200_000;
36const POWER_TYPE_FETCH_LIMIT: u32 = 10_000;
37
38const DISK_CATEGORY_SPELLS: &str = "spells";
39const DISK_CATEGORY_TRAITS: &str = "traits";
40const DISK_CATEGORY_ITEMS: &str = "items";
41const DISK_CATEGORY_SPECS: &str = "specs";
42const DISK_CATEGORY_SCALING: &str = "scaling_data";
43
44#[rustfmt::skip]
45const DISK_CATEGORIES: [&str; 5] = [
46    DISK_CATEGORY_SPELLS,
47    DISK_CATEGORY_TRAITS,
48    DISK_CATEGORY_ITEMS,
49    DISK_CATEGORY_SPECS,
50    DISK_CATEGORY_SCALING,
51];
52
53fn single_by(table: Table, col: &str, val: i32) -> String {
54    Query::from(table).eq(col, val).into_path()
55}
56
57fn select_all(table: Table, limit: u32) -> String {
58    Query::from(table).limit(limit).into_path()
59}
60
61fn ordered_spells(
62    ids: &[i32],
63    by_id: &mut wowlab_types::sim::IntMap<i32, SpellDataFlat>,
64) -> Result<Vec<SpellDataFlat>, CacheError> {
65    ids.iter()
66        .map(|id| {
67            by_id
68                .remove(id)
69                .ok_or_else(|| CacheError::missing(Table::Spells.into_str(), "id", id.to_string()))
70        })
71        .collect()
72}
73
74fn entry_key(category: &str, key: impl std::fmt::Display) -> String {
75    format!("{category}:{key}")
76}
77
78/// Three-layer cache (memory, disk, network) for game data keyed by patch version.
79pub struct GameDataCache {
80    client: SupabaseClient,
81    patch: String,
82    cache_dir: PathBuf,
83
84    spells: Cache<i32, SpellDataFlat>,
85    traits: Cache<i32, TraitTreeFlat>,
86    items: Cache<i32, ItemDataFlat>,
87    specs: Cache<i32, SpecDataFlat>,
88    scaling_data: Cache<(), Arc<ItemScalingData>>,
89}
90
91impl GameDataCache {
92    /// Creates a patch-versioned cache rooted at `cache_dir`.
93    ///
94    /// # Errors
95    /// Returns an error if the cache directory cannot be created.
96    pub fn new(
97        client: SupabaseClient,
98        patch: impl Into<String>,
99        cache_dir: impl Into<PathBuf>,
100    ) -> Result<Self, CacheError> {
101        let patch = patch.into();
102        let cache_dir = cache_dir.into();
103
104        directory::ensure(&cache_dir).map_err(|source| {
105            CacheError::filesystem(CacheOperation::CreateDirectory, None, source)
106        })?;
107
108        let cache = Self {
109            client,
110            patch: patch.clone(),
111            cache_dir,
112            spells: Cache::builder()
113                .max_capacity(CACHE_MAX_SPELLS)
114                .time_to_live(CACHE_TTL)
115                .build(),
116            traits: Cache::builder()
117                .max_capacity(CACHE_MAX_TRAITS)
118                .time_to_live(CACHE_TTL)
119                .build(),
120            items: Cache::builder()
121                .max_capacity(CACHE_MAX_ITEMS)
122                .time_to_live(CACHE_TTL)
123                .build(),
124            specs: Cache::builder()
125                .max_capacity(CACHE_MAX_SPECS)
126                .time_to_live(CACHE_TTL)
127                .build(),
128            scaling_data: Cache::builder()
129                .max_capacity(1)
130                .time_to_live(CACHE_TTL)
131                .build(),
132        };
133
134        if !cache.patch_matches()? {
135            tracing::info!(%patch, "Patch changed; clearing disk cache");
136            cache.clear_disk()?;
137            cache.write_patch_version()?;
138        }
139
140        Ok(cache)
141    }
142
143    pub(crate) async fn get_spell(&self, id: i32) -> Result<SpellDataFlat, CacheError> {
144        self.get_cached(&self.spells, DISK_CATEGORY_SPELLS, id, |key| async move {
145            let path = single_by(Table::Spells, "id", key);
146
147            self.get_single::<SpellDataFlat>(&path, Table::Spells.into_str(), "id", key)
148                .await
149        })
150        .await
151    }
152
153    pub(crate) async fn get_spells(&self, ids: &[i32]) -> Result<Vec<SpellDataFlat>, CacheError> {
154        if ids.is_empty() {
155            return Ok(vec![]);
156        }
157
158        let mut by_id = wowlab_types::sim::IntMap::with_capacity_and_hasher(
159            ids.len(),
160            std::hash::BuildHasherDefault::default(),
161        );
162        let mut missing = Vec::with_capacity(ids.len());
163
164        for &id in ids {
165            match self
166                .read_memory_or_disk(&self.spells, DISK_CATEGORY_SPELLS, id)
167                .await
168            {
169                Some(v) => {
170                    by_id.insert(id, v);
171                }
172                None => missing.push(id),
173            }
174        }
175
176        if !missing.is_empty() {
177            let path = Query::from(Table::Spells)
178                .r#in("id", missing.iter().copied())
179                .into_path();
180            let fetched: Vec<SpellDataFlat> =
181                self.client
182                    .get_json(&path, "game")
183                    .await
184                    .map_err(|source| {
185                        CacheError::remote(entry_key(DISK_CATEGORY_SPELLS, "batch"), source)
186                    })?;
187
188            for spell in fetched {
189                self.write_disk(DISK_CATEGORY_SPELLS, spell.id, &spell)?;
190                // #t(rust_clone_in_loop) clone required to insert into both disk and memory cache
191                self.spells.insert(spell.id, spell.clone()).await;
192                by_id.insert(spell.id, spell);
193            }
194        }
195
196        ordered_spells(ids, &mut by_id)
197    }
198
199    pub(crate) async fn get_trait_tree(&self, spec_id: i32) -> Result<TraitTreeFlat, CacheError> {
200        self.get_cached(
201            &self.traits,
202            DISK_CATEGORY_TRAITS,
203            spec_id,
204            |key| async move {
205                let path = single_by(Table::SpecsTraits, "spec_id", key);
206
207                self.get_single::<TraitTreeFlat>(
208                    &path,
209                    Table::SpecsTraits.into_str(),
210                    "spec_id",
211                    key,
212                )
213                .await
214            },
215        )
216        .await
217    }
218
219    pub(crate) async fn get_item(&self, id: i32) -> Result<ItemDataFlat, CacheError> {
220        self.get_cached(&self.items, DISK_CATEGORY_ITEMS, id, |key| async move {
221            let path = single_by(Table::Items, "id", key);
222
223            self.get_single::<ItemDataFlat>(&path, Table::Items.into_str(), "id", key)
224                .await
225        })
226        .await
227    }
228
229    pub(crate) async fn get_spec(&self, id: i32) -> Result<SpecDataFlat, CacheError> {
230        self.get_cached(&self.specs, DISK_CATEGORY_SPECS, id, |key| async move {
231            let path = single_by(Table::Specs, "id", key);
232
233            self.get_single::<SpecDataFlat>(&path, Table::Specs.into_str(), "id", key)
234                .await
235        })
236        .await
237    }
238
239    pub(crate) async fn get_scaling_data(&self) -> Result<Arc<ItemScalingData>, CacheError> {
240        let value = Box::pin(self.scaling_data.try_get_with((), self.load_scaling_data()))
241            .await
242            .map_err(|source| CacheError::shared("scaling_data:all", source))?;
243
244        Ok(value)
245    }
246
247    pub(crate) async fn get_power_types(&self) -> Result<Vec<PowerTypeFlat>, CacheError> {
248        self.client
249            .get_json::<Vec<PowerTypeFlat>>(
250                &select_all(Table::PowerTypes, POWER_TYPE_FETCH_LIMIT),
251                "game",
252            )
253            .await
254            .map_err(|source| CacheError::remote("power_types:all", source))
255    }
256
257    pub(crate) fn client(&self) -> &SupabaseClient {
258        &self.client
259    }
260
261    #[cfg(test)]
262    pub(crate) fn clear_all(&self) -> Result<(), CacheError> {
263        self.spells.invalidate_all();
264        self.traits.invalidate_all();
265        self.items.invalidate_all();
266        self.specs.invalidate_all();
267        self.scaling_data.invalidate_all();
268        self.clear_disk()?;
269        tracing::info!("All caches cleared");
270
271        Ok(())
272    }
273
274    #[cfg(test)]
275    pub(crate) async fn invalidate_spell(&self, id: i32) {
276        self.spells.invalidate(&id).await;
277        self.log_remove(DISK_CATEGORY_SPELLS, id);
278    }
279
280    async fn read_memory_or_disk<V>(
281        &self,
282        mem_cache: &Cache<i32, V>,
283        disk_category: &str,
284        key: i32,
285    ) -> Option<V>
286    where
287        V: Clone + DeserializeOwned + Send + Sync + 'static,
288    {
289        if let Some(v) = mem_cache.get(&key).await {
290            return Some(v);
291        }
292
293        let v: V = self.read_disk_or_miss(disk_category, key)?;
294
295        mem_cache.insert(key, v.clone()).await;
296
297        Some(v)
298    }
299
300    async fn get_cached<'a, V, F, Fut>(
301        &'a self,
302        mem_cache: &Cache<i32, V>,
303        disk_category: &str,
304        key: i32,
305        fetch: F,
306    ) -> Result<V, CacheError>
307    where
308        V: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
309        F: FnOnce(i32) -> Fut,
310        Fut: Future<Output = Result<V, CacheError>> + 'a,
311    {
312        // docref:start data-resolution-cache-read-through
313        let value = mem_cache
314            .try_get_with(key, async {
315                if let Some(v) = self.read_disk_or_miss::<V>(disk_category, key) {
316                    return Ok(v);
317                }
318                let v: V = fetch(key).await?;
319                self.write_disk(disk_category, key, &v)?;
320                Ok(v)
321            })
322            .await
323            .map_err(|source| CacheError::shared(entry_key(disk_category, key), source))?;
324        Ok(value)
325        // docref:end data-resolution-cache-read-through
326    }
327
328    async fn load_scaling_data(&self) -> Result<Arc<ItemScalingData>, CacheError> {
329        if let Some(data) = self.read_scaling_data_disk_or_miss() {
330            return Ok(Arc::new(data));
331        }
332
333        let item_bonuses_path = select_all(Table::ItemBonuses, FETCH_ALL_LIMIT);
334        let curves_path = select_all(Table::Curves, FETCH_ALL_LIMIT);
335        let curve_points_path = select_all(Table::CurvePoints, FETCH_ALL_LIMIT);
336        let rand_prop_points_path = select_all(Table::RandPropPoints, FETCH_ALL_LIMIT);
337        let item_scaling_configs_path = select_all(Table::ItemScalingConfigs, FETCH_ALL_LIMIT);
338        let item_offset_curves_path = select_all(Table::ItemOffsetCurves, FETCH_ALL_LIMIT);
339        let item_squish_eras_path = select_all(Table::ItemSquishEras, FETCH_ALL_LIMIT);
340        let combat_ratings_path = select_all(Table::CombatRatings, FETCH_ALL_LIMIT);
341        let hp_per_sta_path = select_all(Table::HpPerSta, FETCH_ALL_LIMIT);
342        let spell_scaling_path = select_all(Table::SpellScaling, FETCH_ALL_LIMIT);
343        let combat_ratings_mult_path = select_all(Table::CombatRatingsMultByIlvl, FETCH_ALL_LIMIT);
344        let gem_properties_path = select_all(Table::GemProperties, FETCH_ALL_LIMIT);
345
346        let item_bonuses_fut = self
347            .client
348            .get_json::<Vec<ItemBonusFlat>>(&item_bonuses_path, "game");
349        let curves_fut = self.client.get_json::<Vec<CurveFlat>>(&curves_path, "game");
350        let curve_points_fut = self
351            .client
352            .get_json::<Vec<CurvePointFlat>>(&curve_points_path, "game");
353        let rand_prop_points_fut = self
354            .client
355            .get_json::<Vec<RandPropPointsFlat>>(&rand_prop_points_path, "game");
356        let item_scaling_configs_fut = self
357            .client
358            .get_json::<Vec<ItemScalingConfigFlat>>(&item_scaling_configs_path, "game");
359        let item_offset_curves_fut = self
360            .client
361            .get_json::<Vec<ItemOffsetCurveFlat>>(&item_offset_curves_path, "game");
362        let item_squish_eras_fut = self
363            .client
364            .get_json::<Vec<ItemSquishEraFlat>>(&item_squish_eras_path, "game");
365        let combat_ratings_fut = self
366            .client
367            .get_json::<Vec<CombatRatingsFlat>>(&combat_ratings_path, "game");
368        let hp_per_sta_fut = self
369            .client
370            .get_json::<Vec<HpPerStaFlat>>(&hp_per_sta_path, "game");
371        let spell_scaling_fut = self
372            .client
373            .get_json::<Vec<SpellScalingFlat>>(&spell_scaling_path, "game");
374        let combat_ratings_mult_fut = self
375            .client
376            .get_json::<Vec<CombatRatingsMultByIlvlFlat>>(&combat_ratings_mult_path, "game");
377        let gem_properties_fut = self
378            .client
379            .get_json::<Vec<GemPropertiesFlat>>(&gem_properties_path, "game");
380
381        let (
382            item_bonuses,
383            curves,
384            curve_points,
385            rand_prop_points,
386            item_scaling_configs,
387            item_offset_curves,
388            item_squish_eras,
389            combat_ratings,
390            hp_per_sta,
391            spell_scaling,
392            combat_ratings_mult_by_ilvl,
393            gem_properties,
394        ) = futures::try_join!(
395            item_bonuses_fut,
396            curves_fut,
397            curve_points_fut,
398            rand_prop_points_fut,
399            item_scaling_configs_fut,
400            item_offset_curves_fut,
401            item_squish_eras_fut,
402            combat_ratings_fut,
403            hp_per_sta_fut,
404            spell_scaling_fut,
405            combat_ratings_mult_fut,
406            gem_properties_fut
407        )
408        .map_err(|source| CacheError::remote("scaling_data:all", source))?;
409
410        let data = Arc::new(ItemScalingData::from_flat(
411            item_bonuses,
412            curves,
413            curve_points,
414            rand_prop_points,
415            item_scaling_configs,
416            item_offset_curves,
417            item_squish_eras,
418            combat_ratings,
419            hp_per_sta,
420            spell_scaling,
421            combat_ratings_mult_by_ilvl,
422            gem_properties,
423        ));
424
425        self.write_scaling_data_disk(data.as_ref())?;
426
427        Ok(data)
428    }
429
430    #[cfg(test)]
431    fn log_remove(&self, category: &str, key: i32) {
432        if let Err(e) = self.remove_disk(category, key) {
433            tracing::warn!(category, key, error = %e, "Failed to invalidate disk cache entry");
434        }
435    }
436
437    async fn get_single<T>(
438        &self,
439        path: &str,
440        resource: &str,
441        key: &str,
442        value: i32,
443    ) -> Result<T, CacheError>
444    where
445        T: DeserializeOwned,
446    {
447        let entry_key = format!("{resource}:{key}={value}");
448        let items: Vec<T> = self
449            .client
450            .get_json(path, "game")
451            .await
452            .map_err(|source| CacheError::remote(&entry_key, source))?;
453
454        items
455            .into_iter()
456            .next()
457            .ok_or_else(|| CacheError::missing(resource, key, value.to_string()))
458    }
459}
460impl std::fmt::Debug for GameDataCache {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        f.debug_struct("GameDataCache")
463            .field("client", &"<SupabaseClient>")
464            .field("patch", &self.patch)
465            .field("cache_dir", &self.cache_dir)
466            .field("spells", &self.spells.entry_count())
467            .field("traits", &self.traits.entry_count())
468            .field("items", &self.items.entry_count())
469            .field("specs", &self.specs.entry_count())
470            .field("scaling_data", &self.scaling_data.entry_count())
471            .finish()
472    }
473}