Skip to content

Commit 4bdab03

Browse files
authored
feat(server,server-client): a streaming cache hit by hash, without re-uploading the file (#3907)
Closes #3901. `POST /parse/parquet-stream` accepts an optional `sha256` query parameter (the same `ParseQuery` mechanism as `parquet_layout`, so it composes with layout and quality in the cache identity). With no body and a hash, the route validates the shape (400 otherwise), builds the key and delegates to the same `try_cached_replay` the upload path uses, so "the entries a hit needs" is one list; a hash-only miss answers 404 like `GET /cache/check`; a body present ignores the hash entirely, so a client hash can only select entries that already exist. `parseParquetStream` in `@ifc-lite/server-client` hashes locally, probes, and uploads on any answer that is not a replay (a pre-change server's 400 included); `skipCacheProbe` opts out. The SSE reader moved to its own module so probe and upload share one loop, and `ParquetStreamEvent` moved to `stream_event.rs` to keep the route under the ratchet without an allowlist row. Mutation checks: disabling the probe hit path fails the no-upload test; narrowing the fallback set to 404 fails all five degradation cases; removing the non-404 passthrough fails the 500 test. Not measured against a real server or a 40 MB file; the saving is argued from the code path. Gates on the head: `cargo test -p ifc-lite-server` 269 pass, clippy -D warnings 0, module_size_ratchet 0, typecheck 0, server-client 91 pass, check-module-size 0, check-changesets 0, docs:check-samples 0, api-surface regenerated. Changesets: `@ifc-lite/server-bin` minor, `@ifc-lite/server-client` minor.
1 parent c3f0da3 commit 4bdab03

16 files changed

Lines changed: 1095 additions & 244 deletions

.changeset/stream-hit-by-hash.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@ifc-lite/server-bin': minor
3+
'@ifc-lite/server-client': minor
4+
---
5+
6+
Streaming cache hits no longer pay for the upload.
7+
8+
`POST /api/v1/parse/parquet-stream` keys on the SHA-256 of the bytes it
9+
receives, so the whole file had to arrive before the cache could be consulted.
10+
On a 40 MB model that upload was the entire cost of a hit. The route now also
11+
accepts `?sha256={hex}` with no request body: if everything the replay needs is
12+
already cached it streams it back, and otherwise answers 404 meaning "send the
13+
file". A hash that arrives alongside a body is ignored, so the received bytes
14+
still decide which entry is read and written.
15+
16+
`parseParquetStream` in `@ifc-lite/server-client` hashes the file locally and
17+
probes before uploading. The probe degrades to the upload whenever it is not
18+
answered, so pointing an upgraded client at a server that predates this change
19+
still works. This also makes a hit progressive: it used to fetch the
20+
whole model through `/cache/geometry` and hand it over as one batch. Pass
21+
`{ skipCacheProbe: true }` to upload straight away.

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ pub(crate) fn cache_key_from_parts(
3737
)
3838
}
3939

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-v5`, `-datamodel-v6`, ...) 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.
54+
pub(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+
4058
/// Request-level cache key: file hash + opening-filter suffix + quality suffix.
4159
pub(crate) fn request_cache_key(data: &[u8], query: &ParseQuery, quality: TessellationQuality) -> String {
4260
cache_key_from_parts(
@@ -187,6 +205,22 @@ pub(crate) async fn has_current_data_model(cache: &DiskCache, cache_key: &str) -
187205
has_entry(cache, &data_model_cache_key(cache_key)).await
188206
}
189207

208+
/// Whether the Parquet metadata header is cached for `cache_key`.
209+
///
210+
/// The cheap half of "is this replayable": the header is a few hundred bytes,
211+
/// where the geometry blob is the whole model. The hash-only stream probe
212+
/// (#3901) asks this, plus [`has_current_data_model`], BEFORE it takes an
213+
/// admission slot, so the common miss (a file the server has never seen) is
214+
/// answered by two small reads rather than by charging a parse slot for a disk
215+
/// lookup. It is a pre-filter, never the decision: [`try_cached_replay`] still
216+
/// makes that, and a metadata entry present here with no geometry beside it
217+
/// falls through to the same 404.
218+
///
219+
/// [`try_cached_replay`]: super::cached_replay::try_cached_replay
220+
pub(crate) async fn has_parquet_metadata(cache: &DiskCache, cache_key: &str) -> bool {
221+
has_entry(cache, &parquet_metadata_key(cache_key)).await
222+
}
223+
190224
/// Whether `key` has a readable entry.
191225
///
192226
/// Reads the value rather than asking `DiskCache::has`, which is an index

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,18 @@ fn request_cache_key_separates_content_filter_and_quality() {
267267
opening_filter: mode,
268268
tessellation_quality: None,
269269
parquet_layout: ParquetLayout::Flat,
270+
// A client-supplied hash is a SELECTOR for the hash-only
271+
// stream probe (#3901); it is not part of the cache identity
272+
// a body-carrying request builds. Set to a value that would
273+
// be visible if it leaked in.
274+
sha256: Some("f".repeat(64)),
270275
};
271276
let key = request_cache_key(data, &query, quality);
272277
assert!(key.starts_with(&hash), "the file hash must lead the key: {key}");
278+
assert!(
279+
!key.contains(&"f".repeat(64)),
280+
"request_cache_key must key off the bytes, never the query hash: {key}"
281+
);
273282
assert!(
274283
seen.insert(key.clone()),
275284
"collision: {mode:?}/{quality:?} reuses an existing key {key}"

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

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818
//! still monotonic and still ends at its own stated total.
1919
2020
use super::cache_keys::{
21-
has_current_data_model, load_cached_symbolic, parquet_geometry_key, parquet_metadata_key,
21+
cache_key_from_parts, has_current_data_model, has_parquet_metadata, is_file_digest,
22+
load_cached_symbolic, parquet_geometry_key, parquet_metadata_key,
2223
};
24+
use super::ParseQuery;
25+
use ifc_lite_processing::TessellationQuality;
2326
use crate::services::ParquetLayout;
2427
use super::parquet::ParquetMetadataHeader;
25-
use super::parquet_stream::ParquetStreamEvent;
28+
use super::stream_event::ParquetStreamEvent;
2629
use super::stream_progress::load_stream_progress;
2730
use crate::error::ApiError;
2831
use crate::services::parquet_replay_batches::split_into_batches;
@@ -32,6 +35,83 @@ use axum::response::IntoResponse;
3235
use base64::{engine::general_purpose::STANDARD, Engine};
3336
use std::convert::Infallible;
3437

38+
/// Serve `POST /api/v1/parse/parquet-stream` from a client-supplied file hash,
39+
/// with no request body at all (issue #3901).
40+
///
41+
/// The upload used to be unavoidable on a hit: the cache key is the SHA-256 of
42+
/// the RECEIVED bytes, so `extract_file` had to finish before the cache could
43+
/// be consulted, and on a 40 MB model over a real connection that upload was
44+
/// the whole cost of the hit. A client that hashes locally can name the entry
45+
/// instead.
46+
///
47+
/// The hash SELECTS; it never asserts. Everything the replay needs has to be on
48+
/// disk under that key already, which is exactly what [`try_cached_replay`]
49+
/// checks (geometry body, metadata header, a data model at the current payload
50+
/// version, plus whatever it later adds). Delegating to it rather than
51+
/// re-listing those entries here is what makes "a hash-only hit replays the
52+
/// same events as an upload hit" true by construction instead of by two lists
53+
/// agreeing. A miss answers `404`, the status
54+
/// `GET /api/v1/cache/check/{hash}` already uses for "upload it", and the
55+
/// client uploads.
56+
///
57+
/// A MISS costs no admission slot. The probe answers it from two small reads
58+
/// (the metadata header and the data-model marker) before touching the gate,
59+
/// because the common miss is a file the server has never seen and charging a
60+
/// parse slot for a disk lookup would mean every cold-cache client wins
61+
/// admission twice, probe then upload, and could be shed on the probe rather
62+
/// than queueing for the upload that would have succeeded.
63+
///
64+
/// A HIT does take one, around the replay BUILD, dropped before the response is
65+
/// returned. That is what the body-carrying hit path does, and for the same
66+
/// reason: `try_cached_replay` reads the whole geometry blob into memory,
67+
/// base64-encodes every batch, and materializes the full event vector before a
68+
/// byte is sent, which is several times the model's geometry resident at once.
69+
/// Leaving that window unbounded would make the cheapest request a client can
70+
/// send the cheapest way to exhaust the server. Draining the finished stream
71+
/// needs no slot.
72+
pub(super) async fn replay_by_client_hash(
73+
state: &AppState,
74+
query: &ParseQuery,
75+
quality: TessellationQuality,
76+
sha256: &str,
77+
) -> Result<axum::response::Response, ApiError> {
78+
if !is_file_digest(sha256) {
79+
return Err(ApiError::BadRequest(
80+
"sha256 must be a 64-character lowercase hex SHA-256 digest".to_string(),
81+
));
82+
}
83+
let cache_key = cache_key_from_parts(sha256, query.opening_filter, quality);
84+
85+
let replay = if has_parquet_metadata(&state.cache, &cache_key).await
86+
&& has_current_data_model(&state.cache, &cache_key).await
87+
{
88+
let admission_guard = state
89+
.admission
90+
.acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
91+
.await?;
92+
let replay = try_cached_replay(state, &cache_key, query.parquet_layout).await;
93+
drop(admission_guard);
94+
replay?
95+
} else {
96+
None
97+
};
98+
99+
if let Some(response) = replay {
100+
tracing::info!(
101+
cache_key = %cache_key,
102+
"Streaming cache HIT by client-supplied hash - no upload"
103+
);
104+
return Ok(response);
105+
}
106+
tracing::debug!(
107+
cache_key = %cache_key,
108+
"Hash-only stream request has nothing cached; asking the client to upload"
109+
);
110+
Err(ApiError::NotFound(format!(
111+
"Nothing cached for sha256 {sha256} under this opening_filter / tessellation_quality / parquet_layout. Resend the request with the multipart file body."
112+
)))
113+
}
114+
35115
/// Return the geometry slice from a cached combined-Parquet blob, framed as
36116
/// `[geometry_len: u32-LE][geometry_data][data_model_len: u32]...`. Returns
37117
/// `None` (rather than panicking) when the blob is too short to hold the length

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
mod cache_keys;
88
mod cached_replay;
9+
mod stream_event;
910
mod stream_progress;
1011
mod fetch;
1112
mod json;
@@ -45,6 +46,19 @@ pub struct ParseQuery {
4546
/// takes this struct, so the signal travels with the cache identity.
4647
#[serde(default)]
4748
pub parquet_layout: ParquetLayout,
49+
/// SHA-256 of the file the client is asking about, hex, lowercase (#3901).
50+
///
51+
/// Read only by `POST /api/v1/parse/parquet-stream`, and only when the
52+
/// request carries no multipart body: see
53+
/// [`cached_replay::replay_by_client_hash`] for what it does and what it
54+
/// is not allowed to do. It lives on this struct rather than in a header
55+
/// so it travels with the rest of the cache identity (`opening_filter`,
56+
/// `tessellation_quality`, `parquet_layout`) through the one place every
57+
/// parse route already parses. A hash paired with the wrong layout names a
58+
/// different entry, and splitting one identity across two transports is how
59+
/// such pairings drift apart.
60+
#[serde(default)]
61+
pub sha256: Option<String>,
4862
}
4963

5064
impl ParseQuery {

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

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,56 +9,19 @@ use super::cache_keys::{
99
request_cache_key,
1010
};
1111
use super::parquet::ParquetMetadataHeader;
12+
use super::stream_event::ParquetStreamEvent;
1213
use super::stream_progress::{cache_stream_progress, StreamProgressRecorder};
1314
use super::{extract_file, ParseQuery};
1415
use crate::error::ApiError;
1516
use crate::services::{extract_data_model, process_streaming, serialize_data_model_to_parquet};
16-
use crate::types::{ModelMetadata, ProcessingStats, StreamEvent};
17+
use crate::types::StreamEvent;
1718
use crate::AppState;
1819
use axum::{
1920
extract::{Multipart, Query, State},
2021
response::sse::{Event, KeepAlive, Sse},
2122
};
22-
use ifc_lite_processing::SymbolicData;
23-
use serde::Serialize;
2423
use std::convert::Infallible;
2524

26-
/// SSE event types for Parquet streaming.
27-
// Variant sizes differ because the payload events carry buffers; boxing them
28-
// would complicate the SSE serialization path for no runtime benefit here.
29-
#[allow(clippy::large_enum_variant)]
30-
#[derive(Debug, Clone, Serialize)]
31-
#[serde(tag = "type", rename_all = "lowercase")]
32-
pub enum ParquetStreamEvent {
33-
/// Initial event with estimated totals.
34-
Start {
35-
total_estimate: usize,
36-
cache_key: String,
37-
},
38-
/// Progress update.
39-
Progress { processed: usize, total: usize },
40-
/// Batch of geometry data as base64-encoded Parquet.
41-
Batch {
42-
/// Base64-encoded Parquet data containing this batch's meshes.
43-
data: String,
44-
/// Number of meshes in this batch.
45-
mesh_count: usize,
46-
/// Batch sequence number (1-indexed).
47-
batch_number: usize,
48-
},
49-
/// Processing complete.
50-
Complete {
51-
stats: ProcessingStats,
52-
metadata: ModelMetadata,
53-
/// 2D symbol data extracted from `IfcAnnotation` and `IfcGrid`
54-
/// entities — parity with `POST /api/v1/parse` (issue #900).
55-
#[serde(default, skip_serializing_if = "SymbolicData::is_empty")]
56-
symbolic_data: SymbolicData,
57-
},
58-
/// Error occurred.
59-
Error { message: String },
60-
}
61-
6225
/// POST /api/v1/parse/parquet-stream - Streaming parse with Parquet batches.
6326
///
6427
/// Returns SSE events with Parquet-encoded geometry batches for progressive rendering.
@@ -72,17 +35,53 @@ pub enum ParquetStreamEvent {
7235
/// - `error`: Error event with `message`
7336
///
7437
/// After `complete`, client should fetch data model via `/api/v1/data-model/{cache_key}`.
38+
///
39+
/// ## Two ways to ask
40+
///
41+
/// With a multipart `file` body: the normal path. The cache key is the SHA-256
42+
/// of the RECEIVED bytes, so the upload always completes first, and a `sha256`
43+
/// query parameter sent alongside a body is IGNORED - the bytes on the wire
44+
/// decide which entry is read and written, never the client's claim about them.
45+
///
46+
/// With `?sha256={hex}` and no body: a probe (issue #3901). It replays the
47+
/// cached stream if, and only if, every entry the replay needs already exists
48+
/// under that key, and otherwise answers `404` meaning "upload it". See
49+
/// [`cached_replay::replay_by_client_hash`]. This is what lets a 40 MB cache
50+
/// hit cost no upload.
7551
pub async fn parse_parquet_stream(
7652
State(state): State<AppState>,
7753
Query(query): Query<ParseQuery>,
78-
mut multipart: Multipart,
54+
multipart: Option<Multipart>,
7955
) -> Result<axum::response::Response, ApiError> {
8056
use crate::services::{serialize_batch_with_layout, StreamingParquetCacheWriter};
8157
use axum::response::IntoResponse;
8258
use base64::{engine::general_purpose::STANDARD, Engine};
8359
use futures::StreamExt;
8460
use std::sync::{Arc, Mutex};
8561

62+
let tessellation_quality = query.resolved_tessellation_quality()?;
63+
64+
// Hash-only probe: no body was sent, so there is nothing to extract and
65+
// nothing to parse. It is checked before the gate below only because that
66+
// gate reserves an upload that does not exist. The probe takes admission
67+
// itself, on the hit path where it has real work to bound -- see
68+
// `replay_by_client_hash`.
69+
let Some(mut multipart) = multipart else {
70+
let Some(sha256) = query.sha256.as_deref() else {
71+
// No body and no hash: there is nothing to identify a file with.
72+
// `MissingFile` (400) is what a body with no `file` field already
73+
// answers, and it says the same thing here.
74+
return Err(ApiError::MissingFile);
75+
};
76+
return super::cached_replay::replay_by_client_hash(
77+
&state,
78+
&query,
79+
tessellation_quality,
80+
sha256,
81+
)
82+
.await;
83+
};
84+
8685
// Extract file
8786
// Admission gate (bounded concurrency + byte budget): acquired BEFORE the
8887
// upload is buffered, reserving the max upload size since multipart rarely
@@ -94,8 +93,10 @@ pub async fn parse_parquet_stream(
9493
.await?;
9594
let data = extract_file(&mut multipart, state.config.max_file_size_mb).await?;
9695

97-
// Generate cache key before processing (include opening filter + quality)
98-
let tessellation_quality = query.resolved_tessellation_quality()?;
96+
// Generate cache key before processing (include opening filter + quality).
97+
// From the RECEIVED BYTES, always: a `sha256` parameter that arrived
98+
// alongside a body has no say here, so a client whose claimed hash does not
99+
// describe what it uploaded still reads and writes the entry its bytes name.
99100
let cache_key = request_cache_key(&data, &query, tessellation_quality);
100101
let cache_key_clone = cache_key.clone();
101102
// This route SHARES nothing -- a per-batch writer cannot see across a batch

0 commit comments

Comments
 (0)