Skip to content

Commit 55fab2c

Browse files
authored
feat(server): cache the optimized parquet route like the flat one (#3894)
Closes #3889. `POST /api/v1/parse/parquet/optimized` re-parsed on every request because the response shape had no cache key. It now has two, `{seed}-parquet-optimized-v1` for the body and `{seed}-parquet-optimized-metadata-v1` for the `X-IFC-Metadata` header stored verbatim, so a replay reports the same `optimization_stats` the live parse did. The namespace is separate from `-parquet-v5`, so a cached flat response never satisfies the optimized route and vice versa. The handler moves to its own module so `parquet.rs` stays under the ratchet. Three design points on the record: the write is synchronous rather than backgrounded, because a background write races the very next request and this is the small payload; a hit also requires the symbolic sidecar, since the optimized parse writes it and replaying past a missing one would leave `GET /parse/symbolic/{key}` polling a key nobody writes; and a truncated or orphaned cached blob degrades to a miss with a warning instead of a 500 on every later request. Tests: a second identical request replays without parsing (a sentinel written over the cached body comes back), flat-cached does not satisfy optimized and vice versa with anti-vacuity checks, a missing sidecar re-parses and writes one, a body without metadata re-parses, a different file does not read the first file's entry, and key uniqueness across all suffixes. Both keys documented in docs/guide/server.md and cache_keys.rs. Not verified against a live server or the 57.9 MB file from the issue; what is proven is that the second request does not parse. Gates on the head: `cargo test -p ifc-lite-server` 0 (246 passed), clippy -D warnings 0, module_size_ratchet 6/6, check-module-size 0, check-changesets 0. Changeset: `@ifc-lite/server-bin` minor.
1 parent 3284390 commit 55fab2c

8 files changed

Lines changed: 752 additions & 131 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@ifc-lite/server-bin': minor
3+
---
4+
5+
`POST /api/v1/parse/parquet/optimized` is now cached, so a repeat request is a disk read instead of a full re-parse (#3889). The route was added without a cache key of its own, which left the two Parquet endpoints with opposite properties: the flat route stored its large payload and replayed it, while the optimized route rebuilt its small payload on every request, and got no benefit from a flat response cached seconds earlier either. On a 57.9 MB file the flat route roughly halved on its second call and the optimized route did not improve at all.
6+
7+
The optimized response now has its own key pair, `{sha256}-{filter}-parquet-optimized-v1` for the body and `{sha256}-{filter}-parquet-optimized-metadata-v1` for the `X-IFC-Metadata` header, `optimization_stats` included, so a replay reports what the live parse reported. They are a separate namespace from the flat route's `-parquet-v5` / `-parquet-metadata-v4` on purpose: the optimized payload is quantized and deduplicated, so a cached flat response must never satisfy the optimized route or the other way round. Bump the suffix on any change to the optimized payload's columns.
8+
9+
Two details of the write. It happens before the response goes out rather than in a background task, because this payload is the small one and a background write races the very next request, which is the request the cache exists to serve. And a hit requires the symbolic sidecar to still be present alongside the body and metadata: the optimized parse is what writes that sidecar, so replaying past a missing one would leave `GET /api/v1/parse/symbolic/{cache_key}` polling a key nobody writes.

apps/server/src/routes/parse/cache_keys.rs

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,40 @@ pub(crate) fn parquet_metadata_cache_key(
100100
)
101101
}
102102

103+
/// Build the optimized-Parquet body cache key for a given file cache key.
104+
///
105+
/// `POST /api/v1/parse/parquet/optimized` was added without a key of its own,
106+
/// so its response had nowhere to be stored and every request re-parsed the
107+
/// file while the flat route beside it replayed from disk (issue #3889). This
108+
/// is that key.
109+
///
110+
/// Deliberately a DIFFERENT namespace from `-parquet-v5`: the two routes emit
111+
/// different payloads (quantized vertices, deduplicated shapes, byte colours),
112+
/// so a hit on one must never satisfy the other.
113+
///
114+
/// `v1` is the ara3d BOS payload as it stands after #3595 (rotation-aware
115+
/// instancing). Bump on EVERY change to the optimized payload's columns, to
116+
/// what one of them means, OR to the geometry pipeline behind them -- this key
117+
/// covers `process_geometry_filtered_with_quality` output just as
118+
/// [`parquet_cache_key`] does, and that key's own `v3` -> `v4` bump was a
119+
/// pipeline change with no column change at all. In practice: a bump of
120+
/// `-parquet-v5` almost always needs a bump here too. Otherwise a warm cache
121+
/// replays a pre-change blob that the decoder reads cleanly, and the change is
122+
/// silently absent.
123+
pub(crate) fn parquet_optimized_cache_key(cache_key: &str) -> String {
124+
format!("{cache_key}-parquet-optimized-v1")
125+
}
126+
127+
/// Build the optimized-Parquet metadata cache key for a given file cache key.
128+
///
129+
/// Holds the serialized `X-IFC-Metadata` header, `optimization_stats` included,
130+
/// so a replay carries the same stats the live parse reported. Versioned in
131+
/// lockstep with [`parquet_optimized_cache_key`], and distinct from the flat
132+
/// route's `-parquet-metadata-v4` for the same reason the bodies are.
133+
pub(crate) fn parquet_optimized_metadata_cache_key(cache_key: &str) -> String {
134+
format!("{cache_key}-parquet-optimized-metadata-v1")
135+
}
136+
103137
/// Build the data-model cache key for a given file cache key.
104138
///
105139
/// One definition for the writers (`parse_parquet`, `parse_parquet_stream`) and
@@ -129,10 +163,19 @@ pub(crate) fn data_model_cache_key(cache_key: &str) -> String {
129163
///
130164
/// A cache read error answers `false`: re-parsing is the safe direction.
131165
pub(crate) async fn has_current_data_model(cache: &DiskCache, cache_key: &str) -> bool {
132-
matches!(
133-
cache.get_bytes(&data_model_cache_key(cache_key)).await,
134-
Ok(Some(_))
135-
)
166+
has_entry(cache, &data_model_cache_key(cache_key)).await
167+
}
168+
169+
/// Whether `key` has a readable entry.
170+
///
171+
/// Reads the value rather than asking `DiskCache::has`, which is an index
172+
/// lookup only: an index row whose content is gone would answer `true` here
173+
/// while every real reader still gets nothing, and a gate that reports present
174+
/// for an entry nobody can read is worse than no gate.
175+
///
176+
/// A read error answers `false`: re-parsing is the safe direction.
177+
async fn has_entry(cache: &DiskCache, key: &str) -> bool {
178+
matches!(cache.get_bytes(key).await, Ok(Some(_)))
136179
}
137180

138181
/// Build the symbolic-data cache key for a given file cache key.
@@ -167,6 +210,21 @@ pub(crate) async fn cache_symbolic_data(cache: &DiskCache, cache_key: &str, symb
167210
}
168211
}
169212

213+
/// Whether symbolic data is cached for `cache_key`.
214+
///
215+
/// The optimized-Parquet route's parse is what writes the symbolic sidecar, so
216+
/// a replay that skips the parse must first check the sidecar is there. Without
217+
/// this, a body entry that outlived its symbolic entry replays forever and
218+
/// `GET /api/v1/parse/symbolic/{cache_key}` answers `202` to a key nobody
219+
/// writes -- the same shape as the geometry/data-model trap in #3869.
220+
///
221+
/// [`load_cached_symbolic`] cannot stand in: it answers `SymbolicData::default()`
222+
/// for an absent entry and for a model with no 2D symbols alike, so absence
223+
/// there is indistinguishable from success.
224+
pub(crate) async fn has_cached_symbolic(cache: &DiskCache, cache_key: &str) -> bool {
225+
has_entry(cache, &symbolic_cache_key(cache_key)).await
226+
}
227+
170228
/// Load cached symbolic data for `cache_key`, defaulting to empty when the
171229
/// entry is absent or unreadable.
172230
pub(crate) async fn load_cached_symbolic(cache: &DiskCache, cache_key: &str) -> SymbolicData {

apps/server/src/routes/parse/cache_keys_tests.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ fn derived_keys_never_collide_with_each_other() {
242242
data_model_cache_key(seed),
243243
parquet_cache_key("0ab20f4e4014", OpeningFilterMode::Default, TessellationQuality::Medium),
244244
parquet_metadata_cache_key("0ab20f4e4014", OpeningFilterMode::Default, TessellationQuality::Medium),
245+
parquet_optimized_cache_key(seed),
246+
parquet_optimized_metadata_cache_key(seed),
245247
];
246248
let unique: std::collections::HashSet<&String> = derived.iter().collect();
247249
assert_eq!(unique.len(), derived.len(), "derived keys collide: {derived:?}");
@@ -281,3 +283,39 @@ ENDSEC;";
281283

282284
assert_eq!(detect_schema_version(content), "IFC2X3");
283285
}
286+
287+
/// The optimized-Parquet route got a key of its own with #3889. Its whole
288+
/// point is that it is a DIFFERENT namespace from the flat route's: the two
289+
/// emit different payloads, so a hit on one must never satisfy the other.
290+
/// Deriving the optimized key from the flat one (or reusing `-parquet-v5`)
291+
/// would put a quantized, deduplicated payload where a client expecting flat
292+
/// meshes reads it.
293+
#[test]
294+
fn optimized_parquet_keys_are_a_distinct_namespace_from_the_flat_route() {
295+
let hash = "0ab20f4e4014";
296+
let seed = format!("{hash}-default");
297+
298+
assert_eq!(
299+
parquet_optimized_cache_key(&seed),
300+
format!("{seed}-parquet-optimized-v1")
301+
);
302+
assert_eq!(
303+
parquet_optimized_metadata_cache_key(&seed),
304+
format!("{seed}-parquet-optimized-metadata-v1")
305+
);
306+
307+
// Neither optimized key may equal, or be a prefix-shadow of, the flat pair.
308+
let flat = parquet_cache_key(hash, OpeningFilterMode::Default, TessellationQuality::Medium);
309+
let flat_metadata =
310+
parquet_metadata_cache_key(hash, OpeningFilterMode::Default, TessellationQuality::Medium);
311+
for optimized in [
312+
parquet_optimized_cache_key(&seed),
313+
parquet_optimized_metadata_cache_key(&seed),
314+
] {
315+
assert_ne!(optimized, flat);
316+
assert_ne!(optimized, flat_metadata);
317+
assert_ne!(optimized, symbolic_cache_key(&seed));
318+
assert_ne!(optimized, data_model_cache_key(&seed));
319+
assert_ne!(optimized, json_response_cache_key(&seed));
320+
}
321+
}

apps/server/src/routes/parse/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ mod cached_replay;
99
mod fetch;
1010
mod json;
1111
mod parquet;
12+
mod parquet_optimized;
1213
mod parquet_stream;
1314

1415
pub use fetch::{check_cache, get_cached_geometry, get_data_model, get_symbolic};
1516
pub use json::{parse_full, parse_metadata, parse_stream};
16-
pub use parquet::{parse_parquet, parse_parquet_optimized};
17+
pub use parquet::parse_parquet;
18+
pub use parquet_optimized::parse_parquet_optimized;
1719
pub use parquet_stream::parse_parquet_stream;
1820

1921
use crate::error::ApiError;
@@ -258,6 +260,9 @@ mod ifczip_tests;
258260
#[cfg(test)]
259261
mod parquet_tests;
260262

263+
#[cfg(test)]
264+
mod parquet_optimized_tests;
265+
261266
#[cfg(test)]
262267
mod json_tests;
263268

apps/server/src/routes/parse/parquet.rs

Lines changed: 6 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@ use super::cache_keys::{
99
};
1010
use super::{extract_file, ParseQuery};
1111
use crate::error::ApiError;
12-
use crate::services::{
13-
extract_data_model, serialize_data_model_to_parquet, serialize_to_parquet,
14-
serialize_to_parquet_optimized_with_stats, OptimizedStats, VERTEX_MULTIPLIER,
15-
};
12+
use crate::services::{extract_data_model, serialize_data_model_to_parquet, serialize_to_parquet};
1613
use crate::types::{ModelMetadata, ProcessingStats};
1714
use crate::AppState;
1815
use axum::{
@@ -237,7 +234,11 @@ pub async fn parse_parquet(
237234
let metadata_json_clone = metadata_json.clone();
238235
let cache = state.cache.clone();
239236

240-
// Cache in background (don't block response)
237+
// Cache in background (don't block response). Deliberately NOT the
238+
// optimized route's synchronous write (#3889): this payload is the large
239+
// one, so blocking the response on it costs the client real time. The
240+
// trade is a window where an immediate repeat request re-parses because
241+
// the write has not landed yet.
241242
tokio::spawn(async move {
242243
if let Err(e) = cache
243244
.set_bytes(&parquet_cache_key, &combined_parquet_clone)
@@ -269,124 +270,3 @@ pub async fn parse_parquet(
269270

270271
Ok(response)
271272
}
272-
273-
/// Response header containing metadata for optimized Parquet response.
274-
#[derive(Debug, Clone, Serialize)]
275-
pub struct OptimizedParquetMetadataHeader {
276-
pub cache_key: String,
277-
pub metadata: ModelMetadata,
278-
pub stats: ProcessingStats,
279-
#[serde(skip_serializing_if = "Option::is_none")]
280-
pub mesh_coordinate_space: Option<String>,
281-
#[serde(skip_serializing_if = "Option::is_none")]
282-
pub site_transform: Option<Vec<f64>>,
283-
#[serde(skip_serializing_if = "Option::is_none")]
284-
pub building_transform: Option<Vec<f64>>,
285-
pub optimization_stats: OptimizedStats,
286-
/// Vertex multiplier for dequantization (10,000 = 0.1mm precision)
287-
pub vertex_multiplier: f32,
288-
}
289-
290-
/// POST /api/v1/parse/parquet/optimized - Full parse with ara3d BOS-optimized Parquet format.
291-
///
292-
/// Returns highly optimized binary Parquet data with:
293-
/// - Integer quantized vertices (0.1mm precision)
294-
/// - Mesh deduplication (instancing)
295-
/// - Byte colors instead of floats
296-
/// - Optional normals
297-
///
298-
/// Query params:
299-
/// - `normals=true` - Include normals (default: false, compute on client)
300-
///
301-
/// Typical compression: 3-5x smaller than basic Parquet, 50-75x smaller than JSON.
302-
pub async fn parse_parquet_optimized(
303-
State(state): State<AppState>,
304-
Query(query): Query<ParseQuery>,
305-
mut multipart: Multipart,
306-
) -> Result<Response, ApiError> {
307-
// Extract file from multipart
308-
// Admission gate (bounded concurrency + byte budget): acquired BEFORE the
309-
// upload is buffered, reserving the max upload size since multipart rarely
310-
// declares a length up front. Held for the request's whole lifetime so a
311-
// disconnected-but-still-running job keeps its memory slot.
312-
let admission_guard = state
313-
.admission
314-
.acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
315-
.await?;
316-
let data = extract_file(&mut multipart, state.config.max_file_size_mb).await?;
317-
318-
// Generate cache key (include opening filter so different modes get different cache entries)
319-
let tessellation_quality = query.resolved_tessellation_quality()?;
320-
let cache_key = request_cache_key(&data, &query, tessellation_quality);
321-
322-
tracing::info!(
323-
cache_key = %cache_key,
324-
size = data.len(),
325-
"Processing with optimized Parquet output (ara3d BOS format)"
326-
);
327-
328-
// Parse content
329-
let content = data;
330-
let opening_filter = query.opening_filter;
331-
332-
// Process on blocking thread pool (CPU-intensive). Extract the 2D symbol
333-
// stream (IfcAnnotation + IfcGrid) alongside geometry for endpoint parity
334-
// (issue #900) — it's cached and served via the symbolic fetch endpoint.
335-
// Guard rides the blocking task (see parse_full).
336-
let ((result, symbolic_data), _admission) = tokio::task::spawn_blocking(move || {
337-
(
338-
rayon::join(
339-
|| process_geometry_filtered_with_quality(&content, opening_filter, tessellation_quality),
340-
|| extract_symbolic_data(&content),
341-
),
342-
admission_guard,
343-
)
344-
})
345-
.await?;
346-
347-
// Cache the symbolic stream so the client can fetch it via
348-
// `GET /api/v1/parse/symbolic/{cache_key}`.
349-
cache_symbolic_data(&state.cache, &cache_key, &symbolic_data).await;
350-
351-
// Serialize to optimized Parquet (with deduplication, quantization, etc.)
352-
// Don't include normals by default - client can compute them
353-
let (parquet_data, opt_stats) =
354-
serialize_to_parquet_optimized_with_stats(&result.meshes, false)?;
355-
356-
tracing::info!(
357-
input_meshes = opt_stats.input_meshes,
358-
unique_meshes = opt_stats.unique_meshes,
359-
unique_materials = opt_stats.unique_materials,
360-
mesh_reuse_ratio = opt_stats.mesh_reuse_ratio,
361-
payload_size = parquet_data.len(),
362-
"Optimized Parquet serialization complete"
363-
);
364-
365-
// Create metadata header
366-
let metadata_header = OptimizedParquetMetadataHeader {
367-
cache_key,
368-
metadata: result.metadata,
369-
stats: result.stats,
370-
mesh_coordinate_space: result.mesh_coordinate_space,
371-
site_transform: result.site_transform,
372-
building_transform: result.building_transform,
373-
optimization_stats: opt_stats,
374-
vertex_multiplier: VERTEX_MULTIPLIER,
375-
};
376-
377-
let metadata_json = serde_json::to_string(&metadata_header)?;
378-
379-
// Build response with binary body and metadata header
380-
let response = Response::builder()
381-
.status(StatusCode::OK)
382-
.header(
383-
header::CONTENT_TYPE,
384-
"application/x-parquet-geometry-optimized",
385-
)
386-
.header("X-IFC-Metadata", metadata_json)
387-
.header(header::CONTENT_LENGTH, parquet_data.len())
388-
.body(Body::from(parquet_data))
389-
.map_err(|e| ApiError::Internal(e.to_string()))?;
390-
391-
Ok(response)
392-
}

0 commit comments

Comments
 (0)