ifc_lite_server/routes/parse/cached_replay.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//! Cached-replay fast path for the SSE Parquet streaming endpoint: when a
6//! request's geometry + metadata are already cached, replay them without
7//! re-parsing, in the same Start / (Batch + Progress)* / Complete shape a
8//! live parse streams (issue #3895). The geometry is split back into its
9//! original stream batches via `parquet_replay_batches::split_into_batches`
10//! (recovered from the cached blob's Parquet row-group boundaries); a blob
11//! with no recoverable boundary falls back to one oversized batch, same as
12//! before.
13//!
14//! `progress` numbers come from the `stream_progress` sidecar the live parse
15//! wrote, so a hit reports the same JOB counts a miss does (issue #3897).
16//! Entries cached before that sidecar existed have no job counts to replay;
17//! those fall back to counting emitted MESHES, which is the wrong unit but
18//! still monotonic and still ends at its own stated total.
19
20use super::cache_keys::{
21 cache_key_from_parts, has_current_data_model, has_cached_symbolic, has_parquet_metadata, is_file_digest,
22 load_cached_symbolic, not_a_file_digest, parquet_geometry_key, parquet_metadata_key,
23};
24use super::ParseQuery;
25use ifc_lite_processing::TessellationQuality;
26use crate::services::{DataModelEntities, ParquetLayout, StreamShapes};
27use super::parquet::ParquetMetadataHeader;
28use super::stream_event::ParquetStreamEvent;
29use super::stream_progress::load_stream_progress;
30use crate::error::ApiError;
31use crate::services::parquet_replay_batches::{split_into_batches, split_into_stream_batches};
32use crate::AppState;
33use axum::response::sse::{Event, KeepAlive, Sse};
34use axum::response::IntoResponse;
35use base64::{engine::general_purpose::STANDARD, Engine};
36use std::convert::Infallible;
37
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.
72pub(super) async fn replay_by_client_hash(
73 state: &AppState,
74 query: &ParseQuery,
75 quality: TessellationQuality,
76 stream_shapes: StreamShapes,
77 sha256: &str,
78) -> Result<axum::response::Response, ApiError> {
79 if !is_file_digest(sha256) {
80 return Err(not_a_file_digest(sha256));
81 }
82 let cache_key = cache_key_from_parts(sha256, query.opening_filter, quality);
83
84 let replay = if has_parquet_metadata(&state.cache, &cache_key).await
85 && has_current_data_model(&state.cache, &cache_key, query.data_model_entities).await
86 && has_cached_symbolic(&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(
93 state,
94 &cache_key,
95 query.parquet_layout,
96 stream_shapes,
97 query.data_model_entities,
98 )
99 .await;
100 drop(admission_guard);
101 replay?
102 } else {
103 None
104 };
105
106 if let Some(response) = replay {
107 tracing::info!(
108 cache_key = %cache_key,
109 "Streaming cache HIT by client-supplied hash - no upload"
110 );
111 return Ok(response);
112 }
113 tracing::debug!(
114 cache_key = %cache_key,
115 "Hash-only stream request has nothing cached; asking the client to upload"
116 );
117 Err(ApiError::NotFound(format!(
118 "Nothing cached for sha256 {sha256} under this opening_filter / tessellation_quality / parquet_layout. Resend the request with the multipart file body."
119 )))
120}
121
122/// Return the geometry slice from a cached combined-Parquet blob, framed as
123/// `[geometry_len: u32-LE][geometry_data][data_model_len: u32]...`. Returns
124/// `None` (rather than panicking) when the blob is too short to hold the length
125/// header or declares a geometry length that runs past the buffer — the caller
126/// treats that as a cache miss and re-parses.
127pub(super) fn cached_geometry_slice(cached: &[u8]) -> Option<&[u8]> {
128 let header = cached.get(0..4)?;
129 let geometry_len = u32::from_le_bytes(header.try_into().ok()?) as usize;
130 let end = 4usize.checked_add(geometry_len)?;
131 cached.get(4..end)
132}
133
134/// Try to serve a `parse_parquet_stream` request from the cache. Returns
135/// `Ok(Some(response))` on a usable cache hit (the caller drops its admission
136/// guard and returns the response as-is), `Ok(None)` on a miss or a
137/// short/corrupt cached blob (the caller falls through to the live parse,
138/// which overwrites the bad entry).
139pub(super) async fn try_cached_replay(
140 state: &AppState,
141 cache_key: &str,
142 layout: ParquetLayout,
143 stream_shapes: StreamShapes,
144 data_model_entities: DataModelEntities,
145) -> Result<Option<axum::response::Response>, ApiError> {
146 let parquet_cache_key = parquet_geometry_key(cache_key, layout);
147 let metadata_cache_key = parquet_metadata_key(cache_key);
148
149 let (Some(cached_parquet), Some(cached_metadata_json)) = (
150 state.cache.get_bytes(&parquet_cache_key).await?,
151 state.cache.get_bytes(&metadata_cache_key).await?,
152 ) else {
153 return Ok(None);
154 };
155
156 // Replaying skips the parse, and the parse is what writes the data model.
157 // A geometry entry that outlived a data-model version bump must therefore
158 // re-parse rather than replay (issue #3869). The same holds for a data
159 // model of the OTHER entities variant (#6034): geometry warmed by a
160 // default request has no rooted table behind it.
161 if !has_current_data_model(&state.cache, cache_key, data_model_entities).await
162 || !has_cached_symbolic(&state.cache, cache_key).await {
163 tracing::info!(
164 cache_key = %cache_key,
165 "Geometry cached but a required sidecar predates the current payload; re-parsing"
166 );
167 return Ok(None);
168 }
169
170 tracing::info!(
171 cache_key = %cache_key,
172 parquet_size = cached_parquet.len(),
173 "Streaming cache HIT - returning cached data as fast stream"
174 );
175
176 // Parse cached metadata
177 let mut metadata_header: ParquetMetadataHeader = serde_json::from_slice(&cached_metadata_json)
178 .map_err(|e| ApiError::Internal(format!("Failed to parse cached metadata: {}", e)))?;
179 // Stored as the live parse wrote it, `from_cache: false` included (#5542).
180 metadata_header.stats.from_cache = true;
181
182 // Load the cached symbolic stream so the Complete event reaches parity
183 // even on the cache fast-path (issue #900).
184 let symbolic_data = load_cached_symbolic(&state.cache, cache_key).await;
185
186 // The job-unit progress the live parse reported for this file.
187 let recorded_progress = load_stream_progress(&state.cache, cache_key).await;
188
189 // Extract the geometry blob (framed `[geometry_len: u32-LE][geometry_data]
190 // ...`, sliced WITHOUT `.unwrap()` panicking on a short/corrupt cached
191 // blob) and split it back into its original stream batches, each
192 // base64-encoded. Runs off the async worker (the same helper as the live
193 // path in `parse_parquet_stream`) so a large replay doesn't stall other
194 // polls.
195 let total_meshes = metadata_header.stats.total_meshes;
196 // A cross-batch client gets each batch as written, whole-stream offsets
197 // and all (#5407); a batch-local one gets each re-based to zero,
198 // which a blob a cross-batch stream wrote cannot be, so it falls back to
199 // one batch below.
200 let cross_batch = stream_shapes == StreamShapes::CrossBatch;
201 let split = if cross_batch { split_into_stream_batches } else { split_into_batches };
202 type Replayed = (String, usize, Option<(u32, u32)>);
203 let build_batches = || -> Option<Vec<Replayed>> {
204 let geometry = cached_geometry_slice(&cached_parquet)?;
205 let Some(batches) = split(geometry) else {
206 // Not a multi-row-group blob: one batch's worth of geometry, a
207 // pre-streaming cache entry, or a layout we can't align. Log it —
208 // otherwise a replay that has silently stopped being progressive
209 // is indistinguishable from one that never needed to be.
210 tracing::debug!(
211 geometry_bytes = geometry.len(),
212 "Cached geometry has no recoverable batch boundaries; replaying as one batch"
213 );
214 // Whole-stream offsets starting at zero ARE this one batch's
215 // offsets, so a cross-batch client is told bases of zero.
216 let bases = cross_batch.then_some((0, 0));
217 return Some(vec![(STANDARD.encode(geometry), total_meshes, bases)]);
218 };
219 Some(
220 batches
221 .into_iter()
222 .map(|b| (STANDARD.encode(&b.data), b.mesh_count, b.bases))
223 .collect(),
224 )
225 };
226 let batches = super::stream_batch::off_the_async_worker(build_batches);
227
228 let Some(batches) = batches else {
229 // Short/corrupt cached blob: don't panic, don't serve garbage.
230 // Fall through to the normal parse path (treat as a cache miss);
231 // the re-parse overwrites the bad cache entry.
232 tracing::warn!(
233 cache_key = %cache_key,
234 parquet_size = cached_parquet.len(),
235 "Cached parquet blob is short/corrupt; ignoring cache and re-parsing"
236 );
237 return Ok(None);
238 };
239
240 // Same Start / (Batch, Progress)* / Complete shape the live path streams
241 // (`parquet_stream.rs`'s per-batch Progress callback), so a cache hit is
242 // progressive too (issue #3895).
243 //
244 // The checkpoints only line up if there is one per batch. A sidecar from
245 // a run whose batch count differs from what we recovered (a fallback to
246 // one whole-geometry batch, say) describes a different segmentation, so
247 // it is dropped rather than misapplied.
248 let checkpoints = recorded_progress
249 .filter(|p| p.after_batch.len() == batches.len())
250 .map(|p| (p.total_jobs, p.after_batch));
251 let (total, per_batch_processed) = match checkpoints {
252 Some((total_jobs, after_batch)) => (total_jobs, Some(after_batch)),
253 // No sidecar: pre-#3897 cache entry. Mesh units, as before.
254 None => (total_meshes, None),
255 };
256
257 let sse = |event: &ParquetStreamEvent| -> Result<Event, Infallible> {
258 Ok(Event::default().data(serde_json::to_string(event).unwrap()))
259 };
260 let mut events: Vec<Result<Event, Infallible>> = Vec::with_capacity(batches.len() * 2 + 3);
261 events.push(sse(&ParquetStreamEvent::Start {
262 total_estimate: total,
263 cache_key: cache_key.to_string(),
264 }));
265 events.push(sse(&ParquetStreamEvent::Progress { processed: 0, total }));
266
267 let mut processed = 0usize;
268 for (batch_number, (data, mesh_count, bases)) in batches.into_iter().enumerate() {
269 processed = match &per_batch_processed {
270 Some(checkpoints) => checkpoints[batch_number],
271 None => processed + mesh_count,
272 };
273 events.push(sse(&ParquetStreamEvent::Batch {
274 data,
275 mesh_count,
276 batch_number: batch_number + 1,
277 vertex_base: bases.map(|(v, _)| v),
278 index_base: bases.map(|(_, i)| i),
279 }));
280 events.push(sse(&ParquetStreamEvent::Progress { processed, total }));
281 }
282
283 events.push(sse(&ParquetStreamEvent::Complete {
284 stats: metadata_header.stats,
285 metadata: metadata_header.metadata,
286 symbolic_data,
287 }));
288
289 let fast_stream: std::pin::Pin<
290 Box<dyn futures::Stream<Item = Result<Event, Infallible>> + Send>,
291 > = Box::pin(futures::stream::iter(events));
292
293 Ok(Some(
294 Sse::new(fast_stream)
295 .keep_alive(KeepAlive::default())
296 .into_response(),
297 ))
298}