ifc_lite_server/routes/parse/
cache_keys.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Cache-key builders and symbolic-data cache helpers shared by the parse endpoints.
6
7use super::ParseQuery;
8use crate::services::cache::DiskCache;
9use crate::services::{DataModelEntities, OpeningFilterMode, ParquetLayout};
10use ifc_lite_processing::{SymbolicDataWithProvenance, TessellationQuality};
11
12/// Cache-key segment for a tessellation level. Empty for the default level so
13/// every pre-existing cache entry (all written at implicit `medium`) stays
14/// valid; non-default levels get distinct entries.
15pub(super) fn quality_cache_suffix(quality: TessellationQuality) -> String {
16    if quality == TessellationQuality::default() {
17        String::new()
18    } else {
19        format!("-q{}", quality.label())
20    }
21}
22
23/// The seed every derived key is built on: file hash + opening-filter suffix +
24/// quality suffix. Endpoints that receive a bare hash (the cache-check and
25/// cached-geometry routes) rebuild it from the query rather than from bytes
26/// they do not have.
27pub(crate) fn cache_key_from_parts(
28    hash: &str,
29    opening_filter: OpeningFilterMode,
30    quality: TessellationQuality,
31) -> String {
32    format!(
33        "{}-{}{}",
34        hash,
35        opening_filter.cache_key_suffix(),
36        quality_cache_suffix(quality)
37    )
38}
39
40/// Whether `hash` has the shape [`DiskCache::generate_key`] produces: 64
41/// lowercase hex characters.
42///
43/// Lives beside [`cache_key_from_parts`] because that is what it protects. The
44/// hash a client supplies is concatenated into `{hash}-{filter}{quality}` and
45/// the namespace suffix (`-parquet-v8`, `-datamodel-v8`, ...) is appended after
46/// it, so a caller-shaped string is a caller-shaped cache key. Checking the
47/// shape keeps the value to the one job it has, naming a file.
48///
49/// Applied by the hash-only stream probe (#3901). The two older hash-taking
50/// endpoints, `check_cache` and `get_cached_geometry`, do NOT call it yet: a
51/// malformed hash there names a key nobody wrote and gets a 404, which is a
52/// correct answer by a different route. Tightening them is a behaviour change
53/// to a published surface and is deliberately not part of #3901.
54pub(crate) fn is_file_digest(hash: &str) -> bool {
55    hash.len() == 64 && hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
56}
57
58/// The one rejection for a path segment that is not a file digest, shared by
59/// every route that takes one so the rule is spelled once. The value itself
60/// is not echoed: it is caller-controlled text, and its length plus the rule
61/// is all a caller needs.
62pub(crate) fn not_a_file_digest(value: &str) -> crate::error::ApiError {
63    crate::error::ApiError::BadRequest(format!(
64        "expected a file's sha256 content hash (64 lowercase hex characters); got {} character(s)",
65        value.chars().count()
66    ))
67}
68
69/// Request-level cache key: file hash + opening-filter suffix + quality suffix.
70pub(crate) fn request_cache_key(data: &[u8], query: &ParseQuery, quality: TessellationQuality) -> String {
71    cache_key_from_parts(
72        &DiskCache::generate_key(data),
73        query.opening_filter,
74        quality,
75    )
76}
77
78/// Typed `ParseResponse` cache key for the JSON transport.
79///
80/// Versioned SEPARATELY from [`request_cache_key`], which is the client-facing
81/// identifier and the seed for the parquet keys — bumping that would needlessly
82/// invalidate every parquet entry too.
83///
84/// Introduced at `v2` with issue #1841: the JSON route now emits Y-up meshes
85/// like every other transport, so the pre-existing (unversioned) entries hold
86/// raw IFC Z-up meshes and would silently serve a ROTATED model to a client
87/// that rightly expects the uniform wire frame. A new suffix retires them.
88/// v3 adds direct symbolic fill provenance (#4459), retiring otherwise valid
89/// JSON responses that cannot suppress duplicate 3D fills.
90/// v4 adds the `IfcMapConversionScaled` factors to `metadata.georeferencing`
91/// (#4653). A v3 entry has no factor fields, decodes cleanly with every factor
92/// defaulted to 1, and a client applying them places a scaled file wrong.
93/// v5 carries the embedded `symbolic_data` in the frame this response's own
94/// meshes are in (#4706). This entry is the whole response, symbols included,
95/// and it is returned BEFORE any extraction runs, so bumping
96/// [`symbolic_cache_key`] alone leaves the JSON endpoint replaying old-frame
97/// symbols for every file already on disk: measured on a translated, rotated
98/// site, a planted v4 entry came back with its grid axis at `(500, -300)`
99/// where the live parse puts it at `(0, 0)`.
100/// Bump again on any change to what `ParseResponse` means on the wire.
101pub(crate) fn json_response_cache_key(cache_key: &str) -> String {
102    format!("{cache_key}-json-v6")
103}
104
105/// The flat Parquet geometry entry for a request cache key, under the LAYOUT
106/// the client asked for.
107///
108/// THE definition of that suffix. It used to be a `format!` literal repeated in
109/// `parse_parquet` (twice), `parse_parquet_stream`, `try_cached_replay` and
110/// here, kept equal by a comment saying they bump together -- four writers and
111/// readers of one cache slot held together by prose, where a missed bump gives
112/// a reader looking up a key nobody writes, or worse a writer storing under a
113/// version an old reader still serves.
114///
115/// The two layouts get SEPARATE namespaces (`-parquet-v8` / `-parquet-v9`,
116/// from `ParquetLayout::cache_suffix`) rather than one versioned slot, and the
117/// reason is that a version bump cannot do the job here. A cache key
118/// namespaces server-side entries; it has no bearing on which client is
119/// asking. Bumping one shared key would have retired the v5 entries and then
120/// handed every client -- including one pinned to a `@ifc-lite/server-client`
121/// that predates #3888 -- a freshly generated v6 blob it decodes without error
122/// and draws wrong. Two namespaces plus an opt-in signal means a client that
123/// did not ask for the shared layout can never be served it, from cache or
124/// from a live parse, and the two entries coexist instead of evicting each
125/// other on every request.
126///
127/// `v8` / `v9` since #5984 added the `metallic` / `roughness` finish columns:
128/// an older entry replays with no finish, where absence reads exactly like
129/// "unauthored" (the same reason #3215 took the flat layout v4 -> v5 for the
130/// source-id columns). Neither reuses `v6`: that was the shared layout's
131/// namespace before #5130 moved it to `v7`, and such blobs may still be on disk.
132pub(crate) fn parquet_geometry_key(cache_key: &str, layout: ParquetLayout) -> String {
133    format!("{cache_key}-{}", layout.cache_suffix())
134}
135
136/// The flat Parquet metadata entry (the `X-IFC-Metadata` header) for a request
137/// cache key. THE definition of that suffix, for the same reason as above.
138///
139/// NOT keyed by layout, and not versioned alongside the geometry: it holds the
140/// metadata header, whose shape #3888 did not touch and which is identical for
141/// both layouts of the same file. A hit needs the geometry entry too, so the
142/// layouts still cannot cross-serve.
143///
144/// `v5` with #4653: the header's `metadata.georeferencing` gained the
145/// `IfcMapConversionScaled` factors, and a `v4` header replays them as 1 (see
146/// [`json_response_cache_key`]). The geometry keys stay put: a miss here
147/// re-parses, and the parse rewrites the geometry entry beside it.
148pub(crate) fn parquet_metadata_key(cache_key: &str) -> String {
149    format!("{cache_key}-parquet-metadata-v5")
150}
151
152/// Build the parquet geometry cache key from a file hash and opening filter,
153/// for the endpoints that receive a bare hash rather than the bytes.
154pub(crate) fn parquet_cache_key(
155    hash: &str,
156    opening_filter: OpeningFilterMode,
157    quality: TessellationQuality,
158    layout: ParquetLayout,
159) -> String {
160    parquet_geometry_key(&cache_key_from_parts(hash, opening_filter, quality), layout)
161}
162
163/// Build the parquet metadata cache key for a given file hash and opening filter.
164pub(crate) fn parquet_metadata_cache_key(
165    hash: &str,
166    opening_filter: OpeningFilterMode,
167    quality: TessellationQuality,
168) -> String {
169    parquet_metadata_key(&cache_key_from_parts(hash, opening_filter, quality))
170}
171
172/// Build the optimized-Parquet body cache key for a given file cache key.
173///
174/// `POST /api/v1/parse/parquet/optimized` was added without a key of its own,
175/// so its response had nowhere to be stored and every request re-parsed the
176/// file while the flat route beside it replayed from disk (issue #3889). This
177/// is that key.
178///
179/// Deliberately a DIFFERENT namespace from `-parquet-v9`: the two routes emit
180/// different payloads (quantized vertices, deduplicated shapes, byte colours),
181/// so a hit on one must never satisfy the other.
182///
183/// `v1` is the ara3d BOS payload as it stands after #3595 (rotation-aware
184/// instancing). Bump on EVERY change to the optimized payload's columns, to
185/// what one of them means, OR to the geometry pipeline behind them -- this key
186/// covers `process_geometry_filtered_with_quality` output just as
187/// [`parquet_cache_key`] does, and that key's own `v3` -> `v4` bump was a
188/// pipeline change with no column change at all. In practice: a bump of
189/// `-parquet-v9` almost always needs a bump here too. Otherwise a warm cache
190/// replays a pre-change blob that the decoder reads cleanly, and the change is
191/// silently absent.
192///
193/// `v2` with #5130: `MeshGeometryKey` gained `normals_hash`. The pipeline
194/// derives normals from positions and indices, so no optimized payload is
195/// expected to change -- bumped under this key's own rule (every change to
196/// the dedup behind the payload) rather than on that invariant, at the cost
197/// of one re-parse per warm file.
198pub(crate) fn parquet_optimized_cache_key(cache_key: &str) -> String {
199    format!("{cache_key}-parquet-optimized-v3")
200}
201
202/// Build the optimized-Parquet metadata cache key for a given file cache key.
203///
204/// Holds the serialized `X-IFC-Metadata` header, `optimization_stats` included,
205/// so a replay carries the same stats the live parse reported. Distinct from
206/// the flat route's `-parquet-metadata-v5` for the same reason the bodies are.
207/// A replay needs both entries, so bumping either one retires the pair.
208///
209/// `v2` with #4653: the header's `metadata.georeferencing` gained the
210/// `IfcMapConversionScaled` factors. The body keeps `v1`: a metadata miss
211/// re-parses, and the parse rewrites the body in place.
212/// `v3` with #5129: the header gained `data_model_stats`, mirroring
213/// `ParquetMetadataHeader`. A `v2` header replays with the field absent, which
214/// is a correct decode (`#[serde(skip_serializing_if)]`) of a header that
215/// silently no longer describes what this route now does -- it produces a
216/// data model too. The body keeps `v1`: a metadata miss re-parses, and the
217/// parse rewrites the body (and now the data model) in place.
218pub(crate) fn parquet_optimized_metadata_cache_key(cache_key: &str) -> String {
219    format!("{cache_key}-parquet-optimized-metadata-v3")
220}
221
222/// Build the data-model cache key for a given file cache key.
223///
224/// One definition for the writers (`parse_parquet`, `parse_parquet_stream`) and
225/// the reader (`get_data_model`): three separate literals could disagree, and a
226/// reader looking up a key nobody writes answers `202` forever.
227///
228/// The suffix bumps on EVERY change to the data-model payload's columns.
229/// `v8` adds complete material association fields (#5296); a warm `v7` cache
230/// cannot support material value checks even after the server is upgraded.
231///
232/// The entities VARIANT (#6034) is a separate namespace, not a version:
233/// `-datamodel-v8` for every STEP instance (unchanged, so a default request
234/// keeps hitting the entries already on disk) and `-datamodel-rooted-v8` for
235/// the rooted-only table. Both carry the one version number, so a payload bump
236/// retires both at once. They coexist, and neither can answer for the other:
237/// a client that asked for the rooted table must not be handed 60 MB it asked
238/// not to download, and one that did not must not lose rows it looks up.
239pub(crate) fn data_model_cache_key(cache_key: &str, entities: DataModelEntities) -> String {
240    format!("{cache_key}-datamodel{}-v8", entities.cache_infix())
241}
242
243/// Whether a data model at the CURRENT payload version is cached for
244/// `cache_key`.
245///
246/// Geometry and the data model are versioned separately, so a geometry entry
247/// outlives a data-model bump. Every geometry short-circuit must ask this
248/// before returning early: without it a deployment holding pre-bump entries
249/// reports a geometry hit, the client skips the upload (or the replay path
250/// skips the parse), nothing ever writes the current data-model key, and
251/// `fetchDataModel` polls a key nobody writes until it times out — geometry on
252/// screen with no properties and no error (issue #3869). Answering `false`
253/// costs one re-parse and rewrites both entries.
254///
255/// A cache read error answers `false`: re-parsing is the safe direction.
256///
257/// Asks about the entities VARIANT the request selects (#6034): a replay that
258/// skips the parse must not strand a client polling for the rooted table when
259/// only the full one was ever written, or the reverse.
260pub(crate) async fn has_current_data_model(
261    cache: &DiskCache,
262    cache_key: &str,
263    entities: DataModelEntities,
264) -> bool {
265    has_entry(cache, &data_model_cache_key(cache_key, entities)).await
266}
267
268/// Whether the optimized-Parquet metadata header is cached for `cache_key`.
269///
270/// The cheap pre-filter for the optimized route's `?sha256=` probe (#5128),
271/// mirroring [`has_parquet_metadata`] for the flat route: a few hundred bytes,
272/// against the body's whole-model size. It is never the decision --
273/// [`super::parquet_optimized_replay::try_cached_optimized_parquet`] still
274/// makes that, and a metadata entry present with no body beside it (or no
275/// symbolic sidecar) still falls through to the 404 the probe answers on a
276/// miss.
277pub(crate) async fn has_optimized_metadata(cache: &DiskCache, cache_key: &str) -> bool {
278    has_entry(cache, &parquet_optimized_metadata_cache_key(cache_key)).await
279}
280
281/// Whether the Parquet metadata header is cached for `cache_key`.
282///
283/// The cheap half of "is this replayable": the header is a few hundred bytes,
284/// where the geometry blob is the whole model. The hash-only stream probe
285/// (#3901) asks this, plus [`has_current_data_model`], BEFORE it takes an
286/// admission slot, so the common miss (a file the server has never seen) is
287/// answered by two small reads rather than by charging a parse slot for a disk
288/// lookup. `check_cache` asks it too, because its hit sends the client to a
289/// fetch that needs the header (#4675). It is a pre-filter, never the
290/// decision: [`try_cached_replay`] still makes that, and a metadata entry
291/// present here with no geometry beside it falls through to the same 404.
292///
293/// [`try_cached_replay`]: super::cached_replay::try_cached_replay
294pub(crate) async fn has_parquet_metadata(cache: &DiskCache, cache_key: &str) -> bool {
295    has_entry(cache, &parquet_metadata_key(cache_key)).await
296}
297
298/// Whether `key` has a readable entry.
299///
300/// Reads the value rather than asking `DiskCache::has`, which is an index
301/// lookup only: an index row whose content is gone would answer `true` here
302/// while every real reader still gets nothing, and a gate that reports present
303/// for an entry nobody can read is worse than no gate.
304///
305/// A read error answers `false`: re-parsing is the safe direction.
306async fn has_entry(cache: &DiskCache, key: &str) -> bool {
307    matches!(cache.get_bytes(key).await, Ok(Some(_)))
308}
309
310/// Build the symbolic-data cache key for a given file cache key.
311/// v2 requires direct fill provenance (#4459); v1 remains decodable but is
312/// not a fresh extraction for 3D routing. Geometry namespaces stay unchanged.
313/// v3 re-bases the stream by the mesh frame selection, placement-bounds
314/// fallback included (#4665). A v2 entry for a model the sampler cannot read
315/// is left unshifted, up to the whole offset away from the meshes.
316/// v4 re-bases it by the frame the SERVER's own meshes were baked in rather
317/// than the overlay frame this route used to resolve for itself (#4706). For a
318/// model whose `IfcSite` placement is translated, a v3 entry carries the site
319/// translation and the site rotation the meshes in the same response had
320/// dropped: it decodes cleanly and draws the 2D symbols hundreds of metres
321/// from the geometry they annotate.
322///
323/// The 2D symbol stream (`IfcAnnotation` + `IfcGrid`) is cached separately
324/// from geometry so binary-transport endpoints (Parquet, optimized Parquet,
325/// cached geometry) can expose it via `GET /api/v1/parse/symbolic/{cache_key}`,
326/// mirroring how the data model is cached and fetched (issue #900). `cache_key`
327/// is the full `{hash}-{opening_filter}` key, matching the value embedded in
328/// each response's metadata header.
329pub(crate) fn symbolic_cache_key(cache_key: &str) -> String {
330    format!("{}-symbolic-v4", cache_key)
331}
332
333/// Serialize symbolic data and write it to the cache under `{cache_key}-symbolic-v4`.
334///
335/// Always stores the JSON (even when empty) so the fetch endpoint can return a
336/// definitive `200` with empty arrays rather than looping on `202`.
337pub(crate) async fn cache_symbolic_data(cache: &DiskCache, cache_key: &str, symbolic: &impl serde::Serialize) {
338    match serde_json::to_vec(symbolic) {
339        Ok(bytes) => {
340            let key = symbolic_cache_key(cache_key);
341            if let Err(e) = cache.set_bytes(&key, &bytes).await {
342                tracing::error!(error = %e, cache_key = %cache_key, "Failed to cache symbolic data");
343            } else {
344                tracing::debug!(cache_key = %key, size = bytes.len(), "Symbolic data cached");
345            }
346        }
347        Err(e) => {
348            tracing::error!(error = %e, "Failed to serialize symbolic data for caching");
349        }
350    }
351}
352
353/// Whether symbolic data is cached for `cache_key`.
354///
355/// The optimized-Parquet route's parse is what writes the symbolic sidecar, so
356/// a replay that skips the parse must first check the sidecar is there. Without
357/// this, a body entry that outlived its symbolic entry replays forever and
358/// `GET /api/v1/parse/symbolic/{cache_key}` answers `202` to a key nobody
359/// writes -- the same shape as the geometry/data-model trap in #3869.
360///
361/// [`load_cached_symbolic`] cannot stand in: it answers `SymbolicDataWithProvenance::default()`
362/// for an absent entry and for a model with no 2D symbols alike, so absence
363/// there is indistinguishable from success.
364pub(crate) async fn has_cached_symbolic(cache: &DiskCache, cache_key: &str) -> bool {
365    has_entry(cache, &symbolic_cache_key(cache_key)).await
366}
367
368/// Load cached symbolic data for `cache_key`, defaulting to empty when the
369/// entry is absent or unreadable.
370pub(crate) async fn load_cached_symbolic(cache: &DiskCache, cache_key: &str) -> SymbolicDataWithProvenance {
371    let key = symbolic_cache_key(cache_key);
372    match cache.get_bytes(&key).await {
373        Ok(Some(bytes)) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
374            tracing::error!(error = %e, cache_key = %cache_key, "Failed to parse cached symbolic data");
375            SymbolicDataWithProvenance::default()
376        }),
377        Ok(None) => SymbolicDataWithProvenance::default(),
378        Err(e) => {
379            tracing::error!(error = %e, cache_key = %cache_key, "Failed to read cached symbolic data");
380            SymbolicDataWithProvenance::default()
381        }
382    }
383}