Skip to main content

jsonld_context_processing/
cache.rs

1//! Memoization for processed contexts.
2//!
3//! Building a [`Context`] from a syntactic [`jsonld_syntax::context::Context`]
4//! is expensive: every entity in a JSON-LD document with the same `@context`
5//! triggers the full [context-processing algorithm][1]. Real-world payloads
6//! (NGSI-LD, schema.org) commonly share one `@context` across thousands of
7//! nodes, so memoizing the result of `process_full` against a stable key
8//! collapses that work to a single run.
9//!
10//! [1]: https://www.w3.org/TR/json-ld11-api/#context-processing-algorithm
11//!
12//! # Soundness
13//!
14//! A cached result is only safe to reuse when the inputs to processing match
15//! those of the cached run: the active context, the local context value, the
16//! base URL, and the [`Options`]. The cache is also implicitly scoped to a
17//! single [`Loader`][L] instance — different loaders may resolve the same IRI
18//! to different documents.
19//!
20//! [L]: jsonld_core::Loader
21//!
22//! # Cache key
23//!
24//! - **active context fingerprint**: hash of `Arc::as_ptr` for `definitions`
25//!   and `previous_context` plus the scalar fields. Two contexts that share
26//!   their `Arc<Definitions>` (because one is a `Clone` of the other and
27//!   neither has been mutated since) have the same fingerprint — cheap and
28//!   sound thanks to copy-on-write via `Arc::make_mut`.
29//! - **local context content hash**: the syntactic context is rendered via its
30//!   [`Print`] implementation and hashed. Costs one allocation per lookup but
31//!   stays far smaller than running the algorithm.
32//! - **base URL** and **options**: trivial scalar hashing.
33//!
34//! # Hit verification
35//!
36//! The hash alone is not trusted. Each entry retains a clone of the inputs
37//! and a hit is verified by exact comparison, which defends against two
38//! failure modes:
39//!
40//! - **64-bit collisions**: two different input tuples hashing to the same
41//!   key must not serve each other's results.
42//! - **`Arc` pointer reuse (ABA)**: the fingerprint identifies `definitions`
43//!   by address, and the allocator may hand a dropped context's address to a
44//!   new one. The retained clone keeps the `Arc` alive for the entry's
45//!   lifetime, so its address cannot be recycled — and any mutation of a
46//!   sharing context goes through `Arc::make_mut`, which is forced to
47//!   reallocate while the entry holds a second reference.
48
49use crate::Options;
50use jsonld_core::{Context, HashMap};
51use jsonld_syntax::Print;
52use parking_lot::Mutex;
53use std::{
54    fmt::Write as _,
55    hash::{BuildHasher, Hash, Hasher},
56    sync::Arc,
57};
58
59/// `fmt::Write` adapter that streams written bytes into a [`Hasher`],
60/// avoiding the temporary `String` that `pretty_print().to_string()` would
61/// otherwise allocate.
62struct HashWriter<'a, H: Hasher>(&'a mut H);
63
64impl<H: Hasher> std::fmt::Write for HashWriter<'_, H> {
65    #[inline]
66    fn write_str(&mut self, s: &str) -> std::fmt::Result {
67        self.0.write(s.as_bytes());
68        Ok(())
69    }
70}
71
72/// One cache entry: the memoized result plus a retained clone of every input
73/// the hash fingerprints, for exact verification on hit.
74struct CacheEntry<T, B> {
75    /// Clone of the active context captured at insertion. Cloning retains
76    /// the `definitions`/`previous_context` `Arc`s, which is what makes the
77    /// pointer-identity comparison in [`Self::matches`] sound (see the
78    /// module docs on hit verification).
79    active: Context<T, B>,
80    local: jsonld_syntax::context::Context,
81    base_url: Option<T>,
82    options: Options,
83    result: Arc<Context<T, B>>,
84}
85
86impl<T: PartialEq, B: PartialEq> CacheEntry<T, B> {
87    /// Exact comparison of every input folded into [`cache_key`].
88    fn matches(&self, active: &Context<T, B>, local: &jsonld_syntax::context::Context, base_url: Option<&T>, options: Options) -> bool {
89        self.active.definitions_arc_ptr() == active.definitions_arc_ptr()
90            && self.active.previous_context_arc_ptr() == active.previous_context_arc_ptr()
91            && self.active.original_base_url() == active.original_base_url()
92            && self.active.base_iri() == active.base_iri()
93            && self.active.vocabulary() == active.vocabulary()
94            && self.active.default_language() == active.default_language()
95            && self.active.default_base_direction() == active.default_base_direction()
96            && self.base_url.as_ref() == base_url
97            && self.options == options
98            && self.local == *local
99    }
100}
101
102/// Cache of processed contexts.
103///
104/// Pass one to
105/// [`Process::process_full_with_cache`][crate::Process::process_full_with_cache].
106/// Processing options and base URL are part of the key, so they may vary freely
107/// between calls; the [`Loader`][jsonld_core::Loader] is not, so a cache must not
108/// outlive the loader it was populated against. One cache per document is the
109/// natural unit.
110///
111/// Entries are never evicted, so the cache grows with the number of distinct
112/// contexts processed through it. [`Self::clear`] releases them.
113pub struct ProcessingCache<T, B> {
114    entries: Mutex<HashMap<u64, CacheEntry<T, B>>>,
115}
116
117impl<T, B> ProcessingCache<T, B> {
118    /// Creates an empty cache.
119    #[must_use]
120    pub fn new() -> Self {
121        Self {
122            entries: Mutex::new(HashMap::default()),
123        }
124    }
125
126    /// Discards every memoized context.
127    pub fn clear(&self) {
128        self.entries.lock().clear();
129    }
130
131    /// Returns the number of memoized contexts.
132    pub fn len(&self) -> usize {
133        self.entries.lock().len()
134    }
135
136    /// Checks whether nothing has been memoized yet.
137    pub fn is_empty(&self) -> bool {
138        self.entries.lock().is_empty()
139    }
140
141    /// Looks the key up and verifies the hit against the actual inputs. A
142    /// hash collision verifies false and reads as a miss.
143    pub(crate) fn get(
144        &self,
145        key: u64,
146        active: &Context<T, B>,
147        local: &jsonld_syntax::context::Context,
148        base_url: Option<&T>,
149        options: Options,
150    ) -> Option<Arc<Context<T, B>>>
151    where
152        T: PartialEq,
153        B: PartialEq,
154    {
155        let entries = self.entries.lock();
156        let entry = entries.get(&key)?;
157        entry.matches(active, local, base_url, options).then(|| Arc::clone(&entry.result))
158    }
159
160    /// Stores `result` under `key`, retaining clones of the inputs for hit
161    /// verification. A colliding entry is overwritten — correctness never
162    /// depends on which of the colliding tuples occupies the slot.
163    pub(crate) fn insert(
164        &self,
165        key: u64,
166        active: Context<T, B>,
167        local: jsonld_syntax::context::Context,
168        base_url: Option<T>,
169        options: Options,
170        result: Arc<Context<T, B>>,
171    ) {
172        self.entries.lock().insert(
173            key,
174            CacheEntry {
175                active,
176                local,
177                base_url,
178                options,
179                result,
180            },
181        );
182    }
183}
184
185impl<T, B> Default for ProcessingCache<T, B> {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191/// Computes the lookup key for a `(active, local, base_url, options)` tuple.
192///
193/// All inputs that influence the algorithm's result must be folded into the
194/// hash for the cache to be sound.
195pub(crate) fn cache_key<T, B>(active_context: &Context<T, B>, local_context: &jsonld_syntax::context::Context, base_url: Option<&T>, options: Options) -> u64
196where
197    T: Hash,
198    B: Hash,
199{
200    let mut hasher = foldhash::fast::FixedState::default().build_hasher();
201
202    // Active context fingerprint: Arc-pointer identity + scalar fields.
203    active_context.definitions_arc_ptr().hash(&mut hasher);
204    active_context.previous_context_arc_ptr().hash(&mut hasher);
205    active_context.original_base_url().hash(&mut hasher);
206    active_context.base_iri().hash(&mut hasher);
207    active_context.vocabulary().hash(&mut hasher);
208    active_context.default_language().hash(&mut hasher);
209    active_context.default_base_direction().hash(&mut hasher);
210
211    // Local context content hash: stream Print output into the hasher
212    // instead of materializing it as a `String`. The ignored `Result` is
213    // always `Ok`: `HashWriter`'s `fmt::Write` impl is infallible.
214    let _ = write!(HashWriter(&mut hasher), "{}", local_context.pretty_print());
215
216    base_url.hash(&mut hasher);
217
218    options.processing_mode.hash(&mut hasher);
219    options.override_protected.hash(&mut hasher);
220    options.propagate.hash(&mut hasher);
221    options.vocab.hash(&mut hasher);
222
223    hasher.finish()
224}
225
226#[cfg(test)]
227mod tests {
228    #![allow(clippy::unwrap_used)]
229    use super::*;
230    use iri_rs::IriBuf;
231    use jsonld_core::ProcessingMode;
232    use rdfx::BlankIdBuf;
233
234    /// Same `u64` key, different inputs: verification must read as a miss
235    /// instead of serving the other tuple's result (collision defense; the
236    /// pointer-reuse defense is structural — the entry retains the `Arc`s).
237    #[test]
238    fn colliding_key_is_a_miss_not_a_false_hit() {
239        let cache: ProcessingCache<IriBuf, BlankIdBuf> = ProcessingCache::new();
240        let local = jsonld_syntax::context::Context::One(jsonld_syntax::ContextEntry::Null);
241
242        let active_a: Context<IriBuf, BlankIdBuf> = Context::new(Some(IriBuf::new("http://a/".to_string()).unwrap()));
243        let active_b: Context<IriBuf, BlankIdBuf> = Context::new(Some(IriBuf::new("http://b/".to_string()).unwrap()));
244
245        cache.insert(42, active_a.clone(), local.clone(), None, Options::default(), Arc::new(Context::new(None)));
246
247        // Different active context under the same key: miss.
248        assert!(cache.get(42, &active_b, &local, None, Options::default()).is_none());
249
250        // Different options under the same key: miss.
251        let options_1_0 = Options {
252            processing_mode: ProcessingMode::JsonLd1_0,
253            ..Default::default()
254        };
255        assert!(cache.get(42, &active_a, &local, None, options_1_0).is_none());
256
257        // Matching inputs: hit.
258        assert!(cache.get(42, &active_a, &local, None, Options::default()).is_some());
259    }
260}