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}