Skip to main content

doiget_core/
resolver_cache.rs

1//! Resolver response cache (docs/CACHE.md §1–3).
2//!
3//! Caches the [`MetadataOnlyOutcome`] of a resolve at
4//! `<cache_root>/resolver/<safekey>.toml` so a repeat resolve of the same
5//! ref within the TTL ([`crate::RESOLVER_CACHE_TTL_DAYS`], 7 days) is
6//! served from disk instead of hitting Crossref / arXiv. This is the
7//! mechanism by which `doiget verify` avoids upstream rate limits: in CI
8//! the directory is persisted across runs (e.g. `actions/cache`), so an
9//! unchanged bibliography resolves with zero network calls.
10//!
11//! The on-disk entry follows CACHE.md §2: a TOML file with
12//! `schema_version` / `fetched_at` / `ttl_seconds` / `source`, plus the
13//! resolver outcome stored as a JSON string under `response` (the
14//! `MetadataOnlyOutcome.metadata` field is arbitrary JSON that does not
15//! round-trip cleanly through TOML, so it is kept as a JSON blob).
16//!
17//! All operations are best-effort: a read miss, a stale entry, a parse
18//! error, or a write failure degrade to "no cache" rather than failing
19//! the resolve. The cache is a latency/politeness optimisation, never a
20//! correctness dependency.
21
22use camino::{Utf8Path, Utf8PathBuf};
23use chrono::{DateTime, Duration, Utc};
24use serde::{Deserialize, Serialize};
25
26use crate::orchestrator::{MetadataOnlyOptions, MetadataOnlyOutcome};
27use crate::{Ref, RESOLVER_CACHE_TTL_DAYS};
28
29/// Current cache-entry schema version (CACHE.md §2).
30const CACHE_SCHEMA_VERSION: &str = "1.0";
31
32/// On-disk cache entry (CACHE.md §2). `response` holds the
33/// `MetadataOnlyOutcome` serialized as a JSON string.
34#[derive(Debug, Serialize, Deserialize)]
35struct CacheEntry {
36    schema_version: String,
37    /// RFC 3339 UTC timestamp of the resolve that produced this entry.
38    fetched_at: String,
39    ttl_seconds: i64,
40    source: String,
41    /// `serde_json::to_string(&MetadataOnlyOutcome)`.
42    response: String,
43}
44
45/// The on-disk path for a ref's cache entry:
46/// `<cache_root>/resolver/<safekey>.toml`.
47#[must_use]
48// `pub(crate)`, not `pub`. Nothing outside `doiget-core` calls this module --
49// the orchestrator is the only consumer -- and it is absent from
50// `docs/PUBLIC_API.md`, so every one of these was an accidental semver
51// commitment, including the on-disk cache layout they encode. This cycle
52// added the `_with_options` half and doubled that surface.
53//
54// `#[cfg(test)]` on the remaining plain wrappers is not tidying: making them
55// `pub(crate)` is what revealed that production calls none of them. They
56// default the options for this module's own tests and nothing else, and `pub`
57// had been keeping the dead-code lint quiet about it. Two of the original
58// five, `read` and `write`, turned out to have no caller anywhere -- not even
59// a test -- and are gone.
60#[cfg(test)]
61pub(crate) fn cache_file(cache_root: &Utf8Path, ref_: &Ref) -> Utf8PathBuf {
62    cache_file_with_options(cache_root, ref_, MetadataOnlyOptions::default())
63}
64
65/// [`cache_file`], keyed by the options as well as the ref.
66///
67/// A default resolve and an `include_oa_location` resolve ask different
68/// questions of the network and get different answers, so they cannot share
69/// an entry. Serving a default entry to an opt-in caller would answer with
70/// `oa_url: None` -- indistinguishable from "Unpaywall was asked and this
71/// work has no OA location", which is the one thing the caller paid a
72/// request to find out.
73///
74/// The other direction is just as wrong: reading the opt-in entry and
75/// re-fetching whenever `oa_url` is `None` would re-fetch forever for
76/// exactly the closed-access works one asks about repeatedly.
77///
78/// So: two entries, separated by a SUBDIRECTORY rather than a filename
79/// suffix. A `<safekey>.oa.toml` suffix would collide -- [`Ref::safekey`]
80/// keeps `.` (it is in the allowed set), so the DOI `10.1234/foo.oa`
81/// resolved by default and the DOI `10.1234/foo` resolved with the flag
82/// would both want `doi_10.1234_foo.oa.toml`. A safekey can never contain a
83/// path separator (`/` is replaced with `_`), so a subdirectory cannot.
84#[must_use]
85pub(crate) fn cache_file_with_options(
86    cache_root: &Utf8Path,
87    ref_: &Ref,
88    opts: MetadataOnlyOptions,
89) -> Utf8PathBuf {
90    let dir = cache_root.join("resolver");
91    let dir = if opts.include_oa_location {
92        dir.join("oa")
93    } else {
94        dir
95    };
96    dir.join(format!("{}.toml", ref_.safekey().as_str()))
97}
98
99/// Read a cached outcome for `ref_` if present and still within its TTL.
100///
101/// Returns `None` on any miss condition: file absent, unparsable,
102/// expired, or a `response` blob that no longer deserializes. `now` is
103/// injected so tests can pin expiry without touching the clock.
104#[must_use]
105#[cfg(test)]
106pub(crate) fn read_at(
107    cache_root: &Utf8Path,
108    ref_: &Ref,
109    now: DateTime<Utc>,
110) -> Option<MetadataOnlyOutcome> {
111    read_at_with_options(cache_root, ref_, now, MetadataOnlyOptions::default())
112}
113
114/// [`read_at`], reading the entry keyed by `opts`. See
115/// [`cache_file_with_options`] for why the options are part of the key.
116#[must_use]
117pub(crate) fn read_at_with_options(
118    cache_root: &Utf8Path,
119    ref_: &Ref,
120    now: DateTime<Utc>,
121    opts: MetadataOnlyOptions,
122) -> Option<MetadataOnlyOutcome> {
123    let path = cache_file_with_options(cache_root, ref_, opts);
124    let text = std::fs::read_to_string(&path).ok()?;
125    let entry: CacheEntry = toml::from_str(&text).ok()?;
126    let fetched: DateTime<Utc> = DateTime::parse_from_rfc3339(&entry.fetched_at)
127        .ok()?
128        .with_timezone(&Utc);
129    if now > fetched + Duration::seconds(entry.ttl_seconds) {
130        // Stale: treat as a miss; the caller will re-fetch and overwrite.
131        return None;
132    }
133    serde_json::from_str(&entry.response).ok()
134}
135
136/// [`read`], reading the entry keyed by `opts`.
137#[must_use]
138pub(crate) fn read_with_options(
139    cache_root: &Utf8Path,
140    ref_: &Ref,
141    opts: MetadataOnlyOptions,
142) -> Option<MetadataOnlyOutcome> {
143    read_at_with_options(cache_root, ref_, Utc::now(), opts)
144}
145
146/// Write `outcome` to the cache for `ref_`. Best-effort: returns `false`
147/// (after a `tracing::debug!`) on any I/O or serialization failure rather
148/// than propagating, since a cache write must never fail a resolve.
149#[cfg(test)]
150pub(crate) fn write_at(
151    cache_root: &Utf8Path,
152    ref_: &Ref,
153    outcome: &MetadataOnlyOutcome,
154    now: DateTime<Utc>,
155) -> bool {
156    write_at_with_options(
157        cache_root,
158        ref_,
159        outcome,
160        now,
161        MetadataOnlyOptions::default(),
162    )
163}
164
165/// [`write_at`], writing the entry keyed by `opts`.
166pub(crate) fn write_at_with_options(
167    cache_root: &Utf8Path,
168    ref_: &Ref,
169    outcome: &MetadataOnlyOutcome,
170    now: DateTime<Utc>,
171    opts: MetadataOnlyOptions,
172) -> bool {
173    let response = match serde_json::to_string(outcome) {
174        Ok(s) => s,
175        Err(e) => {
176            tracing::debug!(error = %e, "resolver cache: serialize failed; skipping write");
177            return false;
178        }
179    };
180    let entry = CacheEntry {
181        schema_version: CACHE_SCHEMA_VERSION.to_string(),
182        fetched_at: now.to_rfc3339(),
183        ttl_seconds: i64::from(RESOLVER_CACHE_TTL_DAYS) * 86_400,
184        source: outcome.source.clone(),
185        response,
186    };
187    let toml_text = match toml::to_string(&entry) {
188        Ok(t) => t,
189        Err(e) => {
190            tracing::debug!(error = %e, "resolver cache: toml encode failed; skipping write");
191            return false;
192        }
193    };
194    let path = cache_file_with_options(cache_root, ref_, opts);
195    if let Some(parent) = path.parent() {
196        if let Err(e) = std::fs::create_dir_all(parent) {
197            tracing::debug!(error = %e, dir = %parent, "resolver cache: mkdir failed; skipping write");
198            return false;
199        }
200    }
201    // tmp + rename, not a plain write. A reader racing a plain write sees a
202    // half-written file, `toml::from_str` fails, and the entry degrades to a
203    // miss -- safe, per this module's best-effort contract, but it is a
204    // re-fetch nobody asked for and a `debug!` line that looks like
205    // corruption. The store next door already had the helper.
206    if let Err(e) = crate::store::atomic_write(&path, toml_text.as_bytes()) {
207        tracing::debug!(error = %e, path = %path, "resolver cache: write failed");
208        return false;
209    }
210    true
211}
212
213/// [`write()`], writing the entry keyed by `opts`.
214pub(crate) fn write_with_options(
215    cache_root: &Utf8Path,
216    ref_: &Ref,
217    outcome: &MetadataOnlyOutcome,
218    opts: MetadataOnlyOptions,
219) -> bool {
220    write_at_with_options(cache_root, ref_, outcome, Utc::now(), opts)
221}
222
223#[cfg(test)]
224#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
225mod tests {
226    use super::*;
227    use serde_json::json;
228
229    fn outcome() -> MetadataOnlyOutcome {
230        MetadataOnlyOutcome {
231            metadata_quality: Vec::new(),
232            repaired_fields: std::collections::BTreeMap::new(),
233            source: "crossref".to_string(),
234            resolver_profile: "crossref".to_string(),
235            license: Some("cc-by".to_string()),
236            oa_url: None,
237            oa_status: Some("gold".to_string()),
238            metadata: json!({"title": ["Example"], "DOI": "10.1234/x"}),
239        }
240    }
241
242    #[test]
243    fn write_then_read_round_trips() {
244        let dir = tempfile::TempDir::new().unwrap();
245        let root = Utf8Path::from_path(dir.path()).unwrap();
246        let r = Ref::parse("10.1234/x").unwrap();
247        let now = Utc::now();
248        assert!(write_at(root, &r, &outcome(), now));
249        let got = read_at(root, &r, now).expect("cache hit");
250        assert_eq!(got.source, "crossref");
251        assert_eq!(got.metadata["DOI"], "10.1234/x");
252    }
253
254    /// #539: the options are part of the key, not just the request.
255    ///
256    /// A default resolve caches `oa_url: None` because it never asked. Serving
257    /// that entry to a caller who DID ask would answer the one question it
258    /// paid a round-trip for, with a value that means something else.
259    #[test]
260    fn an_opt_in_read_does_not_hit_the_default_entry() {
261        let dir = tempfile::TempDir::new().unwrap();
262        let root = Utf8Path::from_path(dir.path()).unwrap();
263        let r = Ref::parse("10.1234/x").unwrap();
264        let now = Utc::now();
265        let with_oa = MetadataOnlyOptions::default().with_oa_location(true);
266
267        assert!(write_at(root, &r, &outcome(), now));
268        assert!(
269            read_at_with_options(root, &r, now, with_oa).is_none(),
270            "the default entry must not satisfy an opt-in read"
271        );
272        // ... and the converse, so a warm opt-in cache does not start
273        // answering default calls with a field they did not ask for.
274        let dir2 = tempfile::TempDir::new().unwrap();
275        let root2 = Utf8Path::from_path(dir2.path()).unwrap();
276        assert!(write_at_with_options(root2, &r, &outcome(), now, with_oa));
277        assert!(read_at(root2, &r, now).is_none());
278        assert!(read_at_with_options(root2, &r, now, with_oa).is_some());
279    }
280
281    /// The first version of this used a `<safekey>.oa.toml` SUFFIX, which
282    /// collides: [`Ref::safekey`] keeps `.` (it is in the allowed character
283    /// set), so the DOI `10.1234/foo.oa` resolved by default and the DOI
284    /// `10.1234/foo` resolved with the flag both wanted
285    /// `doi_10.1234_foo.oa.toml` -- one silently serving the other's answer.
286    /// A subdirectory cannot collide, because a safekey can never contain a
287    /// path separator.
288    #[test]
289    fn a_dot_oa_doi_cannot_collide_with_an_opt_in_entry() {
290        let dir = tempfile::TempDir::new().unwrap();
291        let root = Utf8Path::from_path(dir.path()).unwrap();
292        let plain = Ref::parse("10.1234/foo").unwrap();
293        let dotted = Ref::parse("10.1234/foo.oa").unwrap();
294
295        // Guard the premise: if safekey ever starts escaping `.`, this test
296        // is no longer testing what it says it is.
297        assert!(
298            dotted.safekey().as_str().ends_with(".oa"),
299            "premise: safekey keeps '.', so a '.oa' suffix is reachable"
300        );
301
302        assert_ne!(
303            cache_file_with_options(root, &dotted, MetadataOnlyOptions::default()),
304            cache_file_with_options(
305                root,
306                &plain,
307                MetadataOnlyOptions::default().with_oa_location(true)
308            ),
309        );
310    }
311
312    #[test]
313    fn miss_when_absent() {
314        let dir = tempfile::TempDir::new().unwrap();
315        let root = Utf8Path::from_path(dir.path()).unwrap();
316        let r = Ref::parse("10.1234/absent").unwrap();
317        assert!(read_at(root, &r, Utc::now()).is_none());
318    }
319
320    #[test]
321    fn miss_when_expired() {
322        let dir = tempfile::TempDir::new().unwrap();
323        let root = Utf8Path::from_path(dir.path()).unwrap();
324        let r = Ref::parse("10.1234/x").unwrap();
325        let written = Utc::now();
326        assert!(write_at(root, &r, &outcome(), written));
327        // 8 days later — past the 7-day TTL.
328        let later = written + Duration::days(8);
329        assert!(read_at(root, &r, later).is_none());
330    }
331
332    #[test]
333    fn fresh_within_ttl() {
334        let dir = tempfile::TempDir::new().unwrap();
335        let root = Utf8Path::from_path(dir.path()).unwrap();
336        let r = Ref::parse("10.1234/x").unwrap();
337        let written = Utc::now();
338        assert!(write_at(root, &r, &outcome(), written));
339        // 6 days later — still within the 7-day TTL.
340        let later = written + Duration::days(6);
341        assert!(read_at(root, &r, later).is_some());
342    }
343
344    #[test]
345    fn cache_file_path_uses_safekey() {
346        let root = Utf8Path::new("/tmp/cache");
347        let r = Ref::parse("10.1234/x").unwrap();
348        let p = cache_file(root, &r);
349        // Use components, not a substring, so the assertion is independent
350        // of the platform path separator (`/` vs `\`).
351        assert!(p.components().any(|c| c.as_str() == "resolver"));
352        assert!(p.as_str().ends_with(".toml"));
353    }
354}