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}