Skip to main content

mfp_core/catalog/
cache.rs

1//! The on-disk catalog cache.
2//!
3//! A cache younger than [`TTL_SECS`] is served without touching the network. An older one
4//! triggers a refresh, and a failed refresh falls back to the stale copy - the caller
5//! tells staleness from [`crate::model::Catalog::age_secs`]. Only a cache miss with no
6//! reachable network is an error.
7
8use std::future::Future;
9use std::path::Path;
10
11use crate::error::{Error, Result};
12use crate::model::Catalog;
13
14/// How long a cached catalog stays fresh.
15pub const TTL_SECS: i64 = 6 * 60 * 60;
16
17/// Returns the catalog, from cache where it is fresh and from the network otherwise.
18///
19/// `force_refresh` ignores freshness; a forced refresh that fails leaves the cache file
20/// intact and returns the previously cached catalog.
21pub async fn load(client: &reqwest::Client, force_refresh: bool) -> Result<Catalog> {
22    let path = crate::paths::catalog_cache_file()?;
23    resolve(&path, now_secs(), force_refresh, || refresh(client)).await
24}
25
26/// Builds a catalog from the network: the authoritative feed, then best-effort enrichment.
27async fn refresh(client: &reqwest::Client) -> Result<Catalog> {
28    let mut episodes = super::feed::fetch(client).await?;
29    let enrichment = super::enrich::enrich(client, &mut episodes).await;
30    Ok(Catalog {
31        episodes,
32        info: enrichment.info,
33        fetched_at: now_secs(),
34        enriched: enrichment.applied,
35    })
36}
37
38/// The cache policy, over an injected clock and refresh so it can be exercised offline.
39async fn resolve<F, Fut>(path: &Path, now: i64, force_refresh: bool, refresh: F) -> Result<Catalog>
40where
41    F: FnOnce() -> Fut,
42    Fut: Future<Output = Result<Catalog>>,
43{
44    let cached = read(path);
45
46    if !force_refresh
47        && let Some(catalog) = &cached
48        && catalog.age_secs(now) < TTL_SECS
49    {
50        return Ok(catalog.clone());
51    }
52
53    match refresh().await {
54        Ok(catalog) => {
55            // a catalog that cannot be cached is still a catalog
56            if let Err(error) = write(path, &catalog) {
57                tracing::debug!("the catalog cache could not be written: {error}");
58            }
59            Ok(catalog)
60        }
61        // the cache file is left exactly as it was, so a failed refresh costs nothing
62        Err(error) => match cached {
63            Some(catalog) => {
64                tracing::warn!(
65                    "serving a catalog {} seconds old: {error}",
66                    catalog.age_secs(now)
67                );
68                Ok(catalog)
69            }
70            None => Err(Error::CatalogUnavailable(format!(
71                "{error}, and no cached catalog is available"
72            ))),
73        },
74    }
75}
76
77/// Reads the cache file. An unreadable or unparseable file is a miss, not an error.
78pub fn read(path: &Path) -> Option<Catalog> {
79    let text = std::fs::read_to_string(path).ok()?;
80    match serde_json::from_str(&text) {
81        Ok(catalog) => Some(catalog),
82        Err(error) => {
83            tracing::debug!("{} could not be parsed: {error}", path.display());
84            None
85        }
86    }
87}
88
89/// Writes the catalog as a normalised JSON document.
90///
91/// The document is written beside the cache file and renamed over it, so a write cut
92/// short leaves the previous catalog rather than half of this one.
93pub fn write(path: &Path, catalog: &Catalog) -> Result<()> {
94    if let Some(parent) = path.parent() {
95        std::fs::create_dir_all(parent)?;
96    }
97    let document = serde_json::to_string_pretty(catalog).map_err(|error| {
98        Error::Internal(format!("the catalog could not be serialised: {error}"))
99    })?;
100    let partial = path.with_extension("json.tmp");
101    std::fs::write(&partial, document)?;
102    std::fs::rename(&partial, path)?;
103    Ok(())
104}
105
106fn now_secs() -> i64 {
107    std::time::SystemTime::now()
108        .duration_since(std::time::UNIX_EPOCH)
109        .map(|elapsed| elapsed.as_secs() as i64)
110        .unwrap_or(0)
111}
112
113#[cfg(test)]
114mod tests {
115    use std::cell::Cell;
116
117    use super::*;
118    use crate::model::Episode;
119
120    const NOW: i64 = 1_800_000_000;
121
122    fn catalog(fetched_at: i64, title: &str) -> Catalog {
123        Catalog {
124            info: Vec::new(),
125            episodes: vec![Episode {
126                bundle_title: None,
127                special: false,
128                title: title.into(),
129                link: "https://musicforprogramming.net/one".into(),
130                enclosure_url: "https://datashat.net/one.mp3".into(),
131                byte_len: 1,
132                duration_secs: 2,
133                published_at: 3,
134                slug: None,
135                order: None,
136                tracklist: None,
137                body: None,
138                links: None,
139            }],
140            fetched_at,
141            enriched: false,
142        }
143    }
144
145    /// A refresh that records whether it ran, so a test can assert whether the network was
146    /// reached.
147    struct Network<'a> {
148        result: Result<Catalog>,
149        calls: &'a Cell<usize>,
150    }
151
152    impl Network<'_> {
153        fn run(self) -> impl Future<Output = Result<Catalog>> {
154            self.calls.set(self.calls.get() + 1);
155            std::future::ready(self.result)
156        }
157    }
158
159    fn unreachable() -> Error {
160        Error::CatalogUnavailable("the feed is unreachable: connection refused".into())
161    }
162
163    #[tokio::test]
164    async fn a_fresh_cache_is_served_without_touching_the_network() {
165        let root = tempfile::tempdir().unwrap();
166        let path = root.path().join("catalog.json");
167        write(&path, &catalog(NOW - 3_600, "cached")).unwrap();
168        let calls = Cell::new(0);
169
170        let resolved = resolve(&path, NOW, false, || {
171            Network {
172                result: Ok(catalog(NOW, "network")),
173                calls: &calls,
174            }
175            .run()
176        })
177        .await
178        .unwrap();
179
180        assert_eq!(resolved.episodes[0].title, "cached");
181        assert_eq!(calls.get(), 0);
182    }
183
184    #[tokio::test]
185    async fn a_stale_cache_triggers_a_refresh_that_overwrites_it() {
186        let root = tempfile::tempdir().unwrap();
187        let path = root.path().join("catalog.json");
188        write(&path, &catalog(NOW - 7 * 3_600, "cached")).unwrap();
189        let calls = Cell::new(0);
190
191        let resolved = resolve(&path, NOW, false, || {
192            Network {
193                result: Ok(catalog(NOW, "network")),
194                calls: &calls,
195            }
196            .run()
197        })
198        .await
199        .unwrap();
200
201        assert_eq!(resolved.episodes[0].title, "network");
202        assert_eq!(calls.get(), 1);
203        assert_eq!(read(&path).unwrap().episodes[0].title, "network");
204    }
205
206    #[tokio::test]
207    async fn the_ttl_boundary_is_six_hours() {
208        assert_eq!(TTL_SECS, 6 * 60 * 60);
209        let root = tempfile::tempdir().unwrap();
210        let calls = Cell::new(0);
211
212        for (age, expected_calls) in [(TTL_SECS - 1, 0), (TTL_SECS, 1)] {
213            let path = root.path().join(format!("catalog-{age}.json"));
214            write(&path, &catalog(NOW - age, "cached")).unwrap();
215            calls.set(0);
216
217            resolve(&path, NOW, false, || {
218                Network {
219                    result: Ok(catalog(NOW, "network")),
220                    calls: &calls,
221                }
222                .run()
223            })
224            .await
225            .unwrap();
226
227            assert_eq!(calls.get(), expected_calls, "at age {age}");
228        }
229    }
230
231    #[tokio::test]
232    async fn a_stale_cache_is_served_when_the_refresh_fails() {
233        let root = tempfile::tempdir().unwrap();
234        let path = root.path().join("catalog.json");
235        let fetched_at = NOW - 3 * 86_400;
236        write(&path, &catalog(fetched_at, "cached")).unwrap();
237        let calls = Cell::new(0);
238
239        let resolved = resolve(&path, NOW, false, || {
240            Network {
241                result: Err(unreachable()),
242                calls: &calls,
243            }
244            .run()
245        })
246        .await
247        .unwrap();
248
249        assert_eq!(resolved.episodes[0].title, "cached");
250        assert_eq!(resolved.age_secs(NOW), 3 * 86_400);
251        assert_eq!(calls.get(), 1);
252    }
253
254    #[tokio::test]
255    async fn a_refresh_failure_with_no_cache_names_the_feed_as_unreachable() {
256        let root = tempfile::tempdir().unwrap();
257        let calls = Cell::new(0);
258
259        let error = resolve(&root.path().join("catalog.json"), NOW, false, || {
260            Network {
261                result: Err(unreachable()),
262                calls: &calls,
263            }
264            .run()
265        })
266        .await
267        .unwrap_err();
268
269        assert_eq!(error.code(), crate::error::ErrorCode::CatalogUnavailable);
270        assert!(error.to_string().contains("unreachable"));
271        assert!(error.to_string().contains("no cached catalog is available"));
272    }
273
274    #[tokio::test]
275    async fn a_forced_refresh_ignores_a_fresh_cache() {
276        let root = tempfile::tempdir().unwrap();
277        let path = root.path().join("catalog.json");
278        write(&path, &catalog(NOW - 600, "cached")).unwrap();
279        let calls = Cell::new(0);
280
281        let resolved = resolve(&path, NOW, true, || {
282            Network {
283                result: Ok(catalog(NOW, "network")),
284                calls: &calls,
285            }
286            .run()
287        })
288        .await
289        .unwrap();
290
291        assert_eq!(resolved.episodes[0].title, "network");
292        assert_eq!(calls.get(), 1);
293        assert_eq!(read(&path).unwrap().episodes[0].title, "network");
294    }
295
296    #[tokio::test]
297    async fn a_failed_forced_refresh_leaves_the_cache_file_untouched() {
298        let root = tempfile::tempdir().unwrap();
299        let path = root.path().join("catalog.json");
300        write(&path, &catalog(NOW - 600, "cached")).unwrap();
301        let before = std::fs::read(&path).unwrap();
302        let calls = Cell::new(0);
303
304        let resolved = resolve(&path, NOW, true, || {
305            Network {
306                result: Err(unreachable()),
307                calls: &calls,
308            }
309            .run()
310        })
311        .await
312        .unwrap();
313
314        assert_eq!(resolved.episodes[0].title, "cached");
315        assert_eq!(std::fs::read(&path).unwrap(), before);
316    }
317
318    #[tokio::test]
319    async fn a_corrupt_cache_file_is_a_miss_rather_than_an_error() {
320        let root = tempfile::tempdir().unwrap();
321        let path = root.path().join("catalog.json");
322        std::fs::write(&path, b"{ this is not json").unwrap();
323        let calls = Cell::new(0);
324
325        assert!(read(&path).is_none());
326
327        let resolved = resolve(&path, NOW, false, || {
328            Network {
329                result: Ok(catalog(NOW, "network")),
330                calls: &calls,
331            }
332            .run()
333        })
334        .await
335        .unwrap();
336
337        assert_eq!(resolved.episodes[0].title, "network");
338        assert_eq!(calls.get(), 1);
339    }
340
341    #[test]
342    fn a_missing_cache_file_is_a_miss_rather_than_an_error() {
343        let root = tempfile::tempdir().unwrap();
344        assert!(read(&root.path().join("nothing.json")).is_none());
345    }
346
347    /// A truncated cache file reads as a miss, which costs a network fetch, so the write
348    /// must never be observable half-done.
349    #[test]
350    fn a_written_catalog_replaces_the_last_one_and_leaves_no_partial_file() {
351        let root = tempfile::tempdir().unwrap();
352        let path = root.path().join("catalog.json");
353
354        write(&path, &catalog(NOW, "first")).unwrap();
355        write(&path, &catalog(NOW, "second")).unwrap();
356
357        assert_eq!(read(&path).unwrap().episodes[0].title, "second");
358        let entries: Vec<String> = std::fs::read_dir(root.path())
359            .unwrap()
360            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
361            .collect();
362        assert_eq!(entries, ["catalog.json"]);
363    }
364
365    #[test]
366    fn a_written_catalog_round_trips_through_the_cache_file() {
367        let root = tempfile::tempdir().unwrap();
368        let path = root.path().join("nested").join("catalog.json");
369        let original = catalog(NOW, "cached");
370
371        write(&path, &original).unwrap();
372
373        assert_eq!(read(&path).unwrap(), original);
374    }
375}