vgi_rpc/external.rs
1//! External-location batches.
2//!
3//! Large result / stream batches can be uploaded to an external object
4//! store (S3 / GCS / in-memory) and replaced on the wire with a
5//! zero-row "pointer" batch carrying only metadata:
6//!
7//! `vgi_rpc.location` → URL (HTTPS by default).
8//! `vgi_rpc.location.sha256` → lowercase hex SHA-256 of the raw IPC bytes.
9//! `vgi_rpc.location.source` → debug annotation (filled when resolved).
10//!
11//! The remote payload is an Arrow IPC stream containing one batch with the
12//! original data. When compression is set, that stream is zstd-encoded
13//! before upload — the hash is over the raw (uncompressed) IPC bytes so
14//! integrity checks remain stable across compression changes.
15//!
16//! The crate stays storage-agnostic: users register an [`ExternalStorage`]
17//! implementation and a [`Fetcher`] for resolution. The companion
18//! `vgi-rpc-s3` and `vgi-rpc-gcs` crates ship ready-made backends.
19
20use std::net::{IpAddr, ToSocketAddrs};
21use std::sync::Arc;
22
23use arrow_array::RecordBatch;
24use arrow_schema::{Schema, SchemaRef};
25use sha2::{Digest, Sha256};
26
27use crate::errors::{Result, RpcError};
28use crate::metadata::{LOCATION_FETCH_MS_KEY, LOCATION_KEY, LOCATION_SHA256_KEY};
29use crate::wire::{bytes_to_hex, empty_batch, md_get, write_one_batch_as, Metadata, StreamReader};
30
31thread_local! {
32 /// Bytes uploaded to external storage during the call in flight.
33 ///
34 /// Externalised payloads never appear in the response body — only a
35 /// pointer batch does — so they are invisible to any accounting done at
36 /// the transport, and for egress they are usually the larger number by
37 /// orders of magnitude. Incremented at the single upload choke point in
38 /// [`maybe_externalize_batch`], so a new upload path cannot drift from
39 /// the total; scoped and read by [`ExternalizedScope`].
40 static EXTERNALIZED_BYTES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
41}
42
43/// Per-call accounting scope for externalised uploads.
44///
45/// Reads the total on [`finish`](Self::finish) and restores whatever the
46/// enclosing scope had accumulated, so a nested dispatch cannot swallow its
47/// caller's count. Uploads run synchronously on the dispatch thread (the HTTP
48/// path enters them via `block_in_place`), which is what makes a thread-local
49/// the right carrier here — but it also means the scope must not straddle an
50/// `.await`.
51pub struct ExternalizedScope {
52 outer: u64,
53}
54
55impl ExternalizedScope {
56 /// Begin counting this call's uploads from zero.
57 pub fn new() -> Self {
58 let outer = EXTERNALIZED_BYTES.with(|c| c.replace(0));
59 Self { outer }
60 }
61
62 /// Bytes uploaded within this scope; restores the enclosing total.
63 pub fn finish(self) -> u64 {
64 EXTERNALIZED_BYTES.with(|c| c.replace(self.outer))
65 }
66}
67
68impl Default for ExternalizedScope {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74/// Optional body compression for externalized payloads.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum Compression {
77 None,
78 /// zstd at the given level (1..=22).
79 Zstd(i32),
80}
81
82/// Result of uploading a payload to external storage.
83#[derive(Clone, Debug)]
84pub struct UploadResult {
85 /// Caller-fetchable URL for the payload (typically a pre-signed URL).
86 pub url: String,
87 /// SHA-256 of the **raw** IPC bytes, hex-lowercased.
88 pub sha256: String,
89}
90
91/// Pluggable storage backend. Implementations upload Arrow IPC bytes and
92/// return a fetchable URL.
93///
94/// Kept synchronous to preserve compatibility with the current pipe/unix
95/// dispatch loop; async-only backends should offload via a blocking
96/// thread pool.
97pub trait ExternalStorage: Send + Sync {
98 fn upload(&self, ipc_bytes: &[u8], compression: Compression) -> Result<UploadResult>;
99}
100
101// ---------------------------------------------------------------------------
102// Public `__upload_url__` wire contract
103// ---------------------------------------------------------------------------
104//
105// An intermediary (proxy, gateway) that terminates or serves the upload-URL
106// flow needs the method name and both schemas. They are published here — rather
107// than duplicated as private literals in the server and client — so the contract
108// has exactly one definition. Mirrors Python's `vgi_rpc.http` re-exports.
109
110/// The RPC method name of the upload-URL flow (`POST /__upload_url__/init`).
111pub const UPLOAD_URL_METHOD: &str = "__upload_url__";
112
113/// Upper bound on the `count` an `__upload_url__` request may ask for. The
114/// server clamps to `[1, MAX_UPLOAD_URL_COUNT]`.
115pub const MAX_UPLOAD_URL_COUNT: i64 = 100;
116
117/// Parameter schema of an `__upload_url__` request: a single `count` int64.
118pub fn upload_url_params_schema() -> SchemaRef {
119 use arrow_schema::{DataType, Field};
120 Arc::new(Schema::new(vec![Field::new(
121 "count",
122 DataType::Int64,
123 true,
124 )]))
125}
126
127/// Response schema of an `__upload_url__` reply: one row per generated URL pair.
128pub fn upload_url_response_schema() -> SchemaRef {
129 use arrow_schema::{DataType, Field, TimeUnit};
130 Arc::new(Schema::new(vec![
131 Field::new("upload_url", DataType::Utf8, false),
132 Field::new("download_url", DataType::Utf8, false),
133 Field::new(
134 "expires_at",
135 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
136 false,
137 ),
138 ]))
139}
140
141/// Pre-signed URL pair for client-side data upload.
142///
143/// `expires_at` is a Unix-epoch microseconds timestamp (UTC). Mirrors
144/// `vgi_rpc.external.UploadUrl`.
145#[derive(Clone, Debug)]
146pub struct UploadUrl {
147 pub upload_url: String,
148 pub download_url: String,
149 /// Expiration time as microseconds since the Unix epoch (UTC).
150 pub expires_at_micros: i64,
151}
152
153/// Generates pre-signed upload URL pairs for client-vended uploads.
154///
155/// Mirror of Python `vgi_rpc.external.UploadUrlProvider`. Implementations
156/// must be thread-safe — `generate_upload_url()` may be called concurrently
157/// from different request handlers.
158pub trait UploadUrlProvider: Send + Sync {
159 fn generate_upload_url(&self) -> Result<UploadUrl>;
160}
161
162/// Callback verifying an external location URL. Called on both the
163/// upload path (post-signing) and the fetch path (pre-download). Return
164/// `Err` to reject the URL with a typed [`RpcError`].
165pub type UrlValidator = Arc<dyn Fn(&str) -> Result<()> + Send + Sync>;
166
167/// Run a URL validator while ensuring a rejection cannot echo signed URL
168/// credentials through its message or traceback.
169pub fn validate_external_url(validator: &UrlValidator, raw: &str) -> Result<()> {
170 validator(raw).map_err(|mut err| {
171 let redacted = redact_external_url(raw);
172 err.message = err.message.replace(raw, &redacted);
173 err.traceback = err.traceback.replace(raw, &redacted);
174 err
175 })
176}
177
178fn redact_external_url(raw: &str) -> String {
179 let Ok(mut parsed) = url::Url::parse(raw) else {
180 return "<invalid external URL>".to_string();
181 };
182 let _ = parsed.set_username("");
183 let _ = parsed.set_password(None);
184 parsed.set_query(None);
185 parsed.set_fragment(None);
186 parsed.to_string()
187}
188
189/// Pluggable fetcher used to resolve pointer batches back into data.
190///
191/// Takes a URL (and the declared compression) and returns the still-encoded
192/// payload bytes. `vgi-rpc` ships an HTTPS fetcher; tests plug in an
193/// in-memory implementation.
194///
195/// `max_bytes` is a hard ceiling on the number of bytes the fetcher may
196/// read from the remote — implementations **must** abort once it is
197/// exceeded rather than buffering an unbounded response into memory. A
198/// hostile or compromised storage URL would otherwise OOM the process
199/// before decompression's own cap is ever reached.
200pub trait Fetcher: Send + Sync {
201 fn fetch(&self, url: &str, compression: Compression, max_bytes: usize) -> Result<Vec<u8>>;
202
203 /// Fetch with the resolver's complete safety policy. Implementations that
204 /// follow redirects should override this method, validate every target
205 /// before issuing the next request, and return the response's actual
206 /// content coding. The default preserves redirect-free custom fetchers.
207 fn fetch_with_policy(
208 &self,
209 url: &str,
210 compression: Compression,
211 max_bytes: usize,
212 validator: &UrlValidator,
213 _max_redirects: usize,
214 ) -> Result<FetchedPayload> {
215 validate_external_url(validator, url)?;
216 self.fetch(url, compression, max_bytes)
217 .map(|bytes| FetchedPayload { bytes, compression })
218 }
219}
220
221/// Encoded bytes returned by a fetcher plus the coding observed on that
222/// response. This lets inbound pointers use the storage response's coding
223/// instead of assuming it matches the local writer configuration.
224pub struct FetchedPayload {
225 pub bytes: Vec<u8>,
226 pub compression: Compression,
227}
228
229/// Externalization configuration.
230#[derive(Clone)]
231pub struct ExternalLocationConfig {
232 /// Payload size in bytes at which a batch is eligible for
233 /// externalization. Smaller batches stay inline. Default 1 MiB.
234 pub threshold_bytes: usize,
235 /// Compression applied to the IPC bytes before upload. Default
236 /// `Compression::None`.
237 pub compression: Compression,
238 /// Upload backend. Required when externalization is enabled.
239 pub storage: Arc<dyn ExternalStorage>,
240 /// Resolver used on the read side. Required when resolving inbound
241 /// pointer batches.
242 pub fetcher: Arc<dyn Fetcher>,
243 /// URL validator run on both the upload (post-signing) and fetch
244 /// (pre-download) paths. Defaults to [`safe_https_validator`], which
245 /// rejects non-`https` URLs and internal/non-routable hosts. Reject
246 /// a URL by returning `Err`.
247 pub url_validator: UrlValidator,
248 /// Hard ceiling on the post-decompression size of a fetched
249 /// payload. Zstd frames carry their decompressed size in the
250 /// header and `zstd::decode_all` would otherwise trust it
251 /// eagerly — a small malicious payload claiming gigabytes of
252 /// output would OOM the client. Default 1 GiB. Set to `usize::MAX`
253 /// to disable.
254 pub max_decompressed_bytes: usize,
255 /// Hard ceiling on bytes received from storage before decompression.
256 /// Default 1 GiB, matching the historical combined ceiling.
257 pub max_encoded_bytes: usize,
258 /// Maximum number of redirect hops. Default 5.
259 pub max_redirects: usize,
260}
261
262impl std::fmt::Debug for ExternalLocationConfig {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 f.debug_struct("ExternalLocationConfig")
265 .field("threshold_bytes", &self.threshold_bytes)
266 .field("compression", &self.compression)
267 .finish_non_exhaustive()
268 }
269}
270
271/// Helper: scheme-only HTTPS validator.
272///
273/// **Insufficient for untrusted input** — it accepts `https://localhost`,
274/// `https://169.254.169.254` (cloud metadata), and any internal-network
275/// host. Use it only when the set of external-location URLs is fully
276/// trusted (e.g. URLs your own backend minted). For anything that can
277/// carry a client-supplied location, use [`safe_https_validator`].
278pub fn https_only_validator() -> UrlValidator {
279 Arc::new(|url: &str| {
280 if url.starts_with("https://") {
281 Ok(())
282 } else {
283 Err(RpcError::value_error(
284 "external location URL must be https://",
285 ))
286 }
287 })
288}
289
290/// Helper: default-deny HTTPS validator with SSRF protection.
291///
292/// Requires the `https` scheme, then rejects the URL when its host is —
293/// or resolves to — a loopback, private, link-local, unique-local,
294/// carrier-grade-NAT, broadcast, documentation, or unspecified address.
295/// This is the default for [`ExternalLocationConfig::new`] because the
296/// unary HTTP path resolves a *client-supplied* `vgi_rpc.location`
297/// server-side; without this a client could pivot the server into
298/// fetching `https://169.254.169.254/...` or an internal service.
299///
300/// Note: a hostname is resolved here and again at fetch time, so a
301/// DNS-rebinding attacker could still slip through the gap. Pair this
302/// with a redirect-free, size-capped fetcher (the bundled `HttpFetcher`
303/// is both) and, for high-assurance deployments, an egress firewall.
304pub fn safe_https_validator() -> UrlValidator {
305 Arc::new(|raw: &str| {
306 let url = url::Url::parse(raw)
307 .map_err(|e| RpcError::value_error(format!("invalid external location URL: {e}")))?;
308 if url.scheme() != "https" {
309 return Err(RpcError::value_error(
310 "external location URL must be https://",
311 ));
312 }
313 let host = url
314 .host()
315 .ok_or_else(|| RpcError::value_error("external location URL has no host"))?;
316 match host {
317 url::Host::Ipv4(ip) => reject_unsafe_ip(IpAddr::V4(ip)),
318 url::Host::Ipv6(ip) => reject_unsafe_ip(IpAddr::V6(ip)),
319 url::Host::Domain(name) => {
320 let lname = name.to_ascii_lowercase();
321 if lname == "localhost" || lname.ends_with(".localhost") {
322 return Err(RpcError::value_error(
323 "external location host is not publicly routable",
324 ));
325 }
326 let port = url.port_or_known_default().unwrap_or(443);
327 let addrs = (name, port).to_socket_addrs().map_err(|e| {
328 RpcError::value_error(format!("external location host does not resolve: {e}"))
329 })?;
330 let mut saw_any = false;
331 for sa in addrs {
332 saw_any = true;
333 reject_unsafe_ip(sa.ip())?;
334 }
335 if !saw_any {
336 return Err(RpcError::value_error(
337 "external location host does not resolve",
338 ));
339 }
340 Ok(())
341 }
342 }
343 })
344}
345
346/// Reject an IP address that is not safe for the server to dial: any
347/// loopback / private / link-local / unique-local / CGNAT / broadcast /
348/// documentation / unspecified / multicast address.
349fn reject_unsafe_ip(ip: IpAddr) -> Result<()> {
350 let unsafe_addr = match ip {
351 IpAddr::V4(v4) => {
352 let o = v4.octets();
353 v4.is_loopback()
354 || v4.is_private()
355 || v4.is_link_local()
356 || v4.is_unspecified()
357 || v4.is_broadcast()
358 || v4.is_multicast()
359 || v4.is_documentation()
360 // 100.64.0.0/10 — carrier-grade NAT.
361 || (o[0] == 100 && (o[1] & 0xc0) == 0x40)
362 }
363 IpAddr::V6(v6) => {
364 let seg0 = v6.segments()[0];
365 v6.is_loopback()
366 || v6.is_unspecified()
367 || v6.is_multicast()
368 // fc00::/7 — unique local.
369 || (seg0 & 0xfe00) == 0xfc00
370 // fe80::/10 — link local.
371 || (seg0 & 0xffc0) == 0xfe80
372 // IPv4-mapped (::ffff:0:0/96) — classify the embedded v4.
373 || v6
374 .to_ipv4_mapped()
375 .map(|m| reject_unsafe_ip(IpAddr::V4(m)).is_err())
376 .unwrap_or(false)
377 }
378 };
379 if unsafe_addr {
380 return Err(RpcError::value_error(
381 "external location host resolves to a non-routable / internal address",
382 ));
383 }
384 Ok(())
385}
386
387/// Helper: accept any URL (useful for local tests + MinIO).
388pub fn any_url_validator() -> UrlValidator {
389 Arc::new(|_: &str| Ok(()))
390}
391
392impl ExternalLocationConfig {
393 pub fn new(storage: Arc<dyn ExternalStorage>, fetcher: Arc<dyn Fetcher>) -> Self {
394 Self {
395 threshold_bytes: 1024 * 1024,
396 compression: Compression::None,
397 storage,
398 fetcher,
399 url_validator: safe_https_validator(),
400 max_decompressed_bytes: 1024 * 1024 * 1024,
401 max_encoded_bytes: 1024 * 1024 * 1024,
402 max_redirects: 5,
403 }
404 }
405
406 pub fn with_threshold_bytes(mut self, n: usize) -> Self {
407 self.threshold_bytes = n;
408 self
409 }
410
411 pub fn with_compression(mut self, c: Compression) -> Self {
412 self.compression = c;
413 self
414 }
415
416 pub fn with_url_validator(mut self, v: UrlValidator) -> Self {
417 self.url_validator = v;
418 self
419 }
420
421 /// Override the hard ceiling on post-decompression payload size.
422 /// Pass `usize::MAX` to disable. Default is 1 GiB.
423 pub fn with_max_decompressed_bytes(mut self, n: usize) -> Self {
424 self.max_decompressed_bytes = n;
425 self
426 }
427
428 /// Override the hard ceiling on encoded bytes read from storage.
429 pub fn with_max_encoded_bytes(mut self, n: usize) -> Self {
430 self.max_encoded_bytes = n;
431 self
432 }
433
434 /// Override the maximum number of redirect hops.
435 pub fn with_max_redirects(mut self, n: usize) -> Self {
436 self.max_redirects = n;
437 self
438 }
439}
440
441// ---------------------------------------------------------------------------
442// Serialize a batch as an IPC stream with no custom metadata.
443// ---------------------------------------------------------------------------
444
445/// Serialize one record batch as a complete IPC stream (schema + batch + EOS).
446pub fn serialize_batch_to_ipc(batch: &RecordBatch) -> Result<Vec<u8>> {
447 // External payloads carry the raw data only; the pointer batch on
448 // the outside owns the metadata. Pass `None` to omit any
449 // `custom_metadata` field on the wire.
450 write_one_batch_as(batch, batch.schema().as_ref(), None)
451}
452
453/// Read back an IPC stream containing a single batch.
454/// Fetch, decompress, and integrity-check an external-location pointer's
455/// payload, returning the raw inner IPC stream bytes. The inner stream may
456/// contain **multiple** batches (e.g. a peer that externalizes a whole
457/// per-iteration output — logs followed by the data batch), so callers that
458/// need log/exception handling should process the returned bytes as a full
459/// response stream rather than assuming a single batch.
460///
461/// Returns `Ok(None)` when `metadata` carries no `vgi_rpc.location` pointer.
462pub fn fetch_external_ipc_bytes(
463 metadata: &Metadata,
464 cfg: &ExternalLocationConfig,
465) -> Result<Option<Vec<u8>>> {
466 let Some(url) = md_get(metadata, LOCATION_KEY) else {
467 return Ok(None);
468 };
469 let fetched = cfg.fetcher.fetch_with_policy(
470 url,
471 cfg.compression,
472 cfg.max_encoded_bytes,
473 &cfg.url_validator,
474 cfg.max_redirects,
475 )?;
476 let ipc_bytes = decompress(
477 &fetched.bytes,
478 fetched.compression,
479 cfg.max_decompressed_bytes,
480 )?;
481 if let Some(expected) = md_get(metadata, LOCATION_SHA256_KEY) {
482 let actual = sha256_hex(&ipc_bytes);
483 if expected != actual.as_str() {
484 return Err(RpcError::runtime_error(format!(
485 "external location checksum: SHA-256 mismatch (expected {expected}, got {actual})"
486 )));
487 }
488 }
489 Ok(Some(ipc_bytes))
490}
491
492pub fn deserialize_single_batch(ipc_bytes: &[u8]) -> Result<RecordBatch> {
493 Ok(deserialize_single_batch_with_metadata(ipc_bytes)?.0)
494}
495
496/// Like [`deserialize_single_batch`] but also returns the batch's per-message
497/// custom metadata — some peers carry keys (e.g. the stream-state token) on the
498/// externalized inner batch rather than the outer pointer.
499pub fn deserialize_single_batch_with_metadata(ipc_bytes: &[u8]) -> Result<(RecordBatch, Metadata)> {
500 let mut r = StreamReader::new(ipc_bytes)?;
501 r.read_next()?
502 .ok_or_else(|| RpcError::runtime_error("external batch stream is empty"))
503}
504
505fn sha256_hex(bytes: &[u8]) -> String {
506 bytes_to_hex(&Sha256::digest(bytes))
507}
508
509/// Zstd's decoder allocates its history window independently from emitted
510/// output. Bound that window to the smallest power of two covering the output
511/// cap, with a 512 KiB floor for zstd's streaming level-1 profile and a 2 GiB
512/// maximum.
513fn zstd_window_log_for_limit(max_size: usize) -> u32 {
514 const INTEROPERABLE_WINDOW_LOG_FLOOR: u32 = 19;
515 let bounded = max_size.max(1 << INTEROPERABLE_WINDOW_LOG_FLOOR);
516 let ceil_log = usize::BITS - bounded.saturating_sub(1).leading_zeros();
517 ceil_log.clamp(INTEROPERABLE_WINDOW_LOG_FLOOR, 31)
518}
519
520fn compress(ipc_bytes: &[u8], compression: Compression) -> Result<Vec<u8>> {
521 match compression {
522 Compression::None => Ok(ipc_bytes.to_vec()),
523 Compression::Zstd(level) => {
524 // Use the bulk API and explicitly include the decompressed
525 // size in the frame header. Python's `zstandard.ZstdDecompressor`
526 // requires `Content-Size` to be present when decompressing
527 // a single frame in one shot.
528 let mut enc = zstd::bulk::Compressor::new(level)
529 .map_err(|e| RpcError::runtime_error(format!("zstd encoder: {e}")))?;
530 enc.set_parameter(zstd::stream::raw::CParameter::ContentSizeFlag(true))
531 .map_err(|e| RpcError::runtime_error(format!("zstd contentsize: {e}")))?;
532 enc.compress(ipc_bytes)
533 .map_err(|e| RpcError::runtime_error(format!("zstd encode: {e}")))
534 }
535 }
536}
537
538fn decompress(bytes: &[u8], compression: Compression, max_size: usize) -> Result<Vec<u8>> {
539 match compression {
540 Compression::None => {
541 if bytes.len() > max_size {
542 return Err(RpcError::runtime_error(format!(
543 "external payload {} bytes exceeds max_decompressed_bytes={max_size}",
544 bytes.len()
545 )));
546 }
547 Ok(bytes.to_vec())
548 }
549 // Stream-decode and stop if we exceed the cap. Avoids trusting
550 // the zstd frame header's declared decompressed size (which
551 // `decode_all` would otherwise allocate eagerly), blocking a
552 // remote OOM via a tiny payload claiming gigabytes of output.
553 Compression::Zstd(_) => {
554 use std::io::Read;
555 let mut decoder = zstd::Decoder::new(bytes)
556 .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
557 decoder
558 .window_log_max(zstd_window_log_for_limit(max_size))
559 .map_err(|e| RpcError::runtime_error(format!("zstd window limit: {e}")))?;
560 let mut out = Vec::new();
561 let mut buf = [0u8; 64 * 1024];
562 loop {
563 let n = decoder
564 .read(&mut buf)
565 .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
566 if n == 0 {
567 break;
568 }
569 if out.len() + n > max_size {
570 return Err(RpcError::runtime_error(format!(
571 "zstd decode: output exceeds max_decompressed_bytes={max_size}"
572 )));
573 }
574 out.extend_from_slice(&buf[..n]);
575 }
576 Ok(out)
577 }
578 }
579}
580
581// ---------------------------------------------------------------------------
582// Server-side: externalize large batches
583// ---------------------------------------------------------------------------
584
585/// Pointer-batch schema — a zero-field, zero-row batch. The Python
586/// canonical externalizes into an empty-schema batch with location
587/// metadata; we match that so the on-wire bytes are identical.
588pub fn pointer_schema() -> SchemaRef {
589 Arc::new(Schema::empty())
590}
591
592/// A batch that has been serialized (and compressed) for external storage
593/// but **not yet uploaded**.
594///
595/// Splitting the prepare step out of [`maybe_externalize_batch`] is what
596/// makes an operator cap (`max_externalized_response_bytes`) enforceable
597/// *before* the bytes leave the process: the exact payload is already in
598/// hand, so a response that would violate the cap can be refused without
599/// paying for the storage round trip. Nothing observable happens until
600/// [`upload_prepared`] is called, so dropping a `PreparedExternal` is a
601/// clean abort.
602pub struct PreparedExternal {
603 /// Raw (pre-compression) IPC bytes — what the cap is measured in.
604 raw_len: usize,
605 /// The bytes that will actually be uploaded.
606 payload: Vec<u8>,
607 /// SHA-256 of the **raw** IPC bytes.
608 sha: String,
609 /// Zero-row pointer batch matching the source batch's schema.
610 ptr: RecordBatch,
611 /// Caller metadata with any stale location keys already stripped.
612 md: Metadata,
613}
614
615impl PreparedExternal {
616 /// Size of this payload as `max_externalized_response_bytes` measures
617 /// it: the **raw** IPC bytes, captured *before* external compression.
618 ///
619 /// Pre-compression on purpose, and identical whether or not
620 /// [`ExternalLocationConfig::with_compression`] is in effect. Python's
621 /// `maybe_externalize_batch` returns exactly this quantity for cap
622 /// accounting (`raw_size = original_bytes ...`, taken before
623 /// `_codec_compress`), and TypeScript compares an uncompressed batch
624 /// size too. Charging the compressed upload instead would make one
625 /// configuration mean different things on different ports, and would
626 /// silently loosen the cap by the compression ratio — which for the
627 /// repetitive payloads that provoke externalisation is enormous.
628 ///
629 /// The access-log counter (`DispatchInfo::externalized_bytes`) stays on
630 /// the compressed number: that one answers "what left the machine",
631 /// this one answers "what did the operator allow".
632 ///
633 /// One deliberate deviation from the reference: Python's *pre-flight*
634 /// predicts with `batch.get_total_buffer_size()` and then charges the
635 /// raw IPC count, so its two numbers are only approximately equal.
636 /// Rust has already serialized by this point (the threshold gate needs
637 /// the IPC length anyway), so the pre-flight and the charge are the
638 /// same number and cannot disagree. Same units, same
639 /// "uncompressed size of the data" semantics, no estimate error.
640 pub fn cap_bytes(&self) -> usize {
641 self.raw_len
642 }
643}
644
645/// Serialize `batch` for external storage without uploading it.
646///
647/// Returns `None` under exactly the conditions [`maybe_externalize_batch`]
648/// declines to externalize (empty batch, or IPC bytes below the configured
649/// threshold), so a caller that pre-flights with this and then uploads sees
650/// the same decision it would have gotten from the one-shot helper.
651///
652/// `declared_schema` is the schema of the **enclosing** IPC stream — the one
653/// the peer already read and will validate the fetched payload against. It
654/// is not always `batch.schema()`: a worker may emit a batch that differs
655/// from its stream's declared schema in nullability, dictionary encoding or
656/// schema metadata, and inline delivery hides that completely (see
657/// [`crate::wire::write_one_batch_as`]). Declaring the batch's own schema on
658/// the uploaded payload is what turns such a difference into a client-side
659/// `Schema mismatch` the moment externalisation is switched on.
660pub fn prepare_externalize_batch(
661 batch: &RecordBatch,
662 declared_schema: &Schema,
663 inline_metadata: Option<&Metadata>,
664 cfg: &ExternalLocationConfig,
665) -> Result<Option<PreparedExternal>> {
666 if batch.num_rows() == 0 {
667 return Ok(None);
668 }
669 // Build pointer metadata, merging the caller-supplied metadata first.
670 // The location keys are added by `upload_prepared`, which is the only
671 // place that knows the URL.
672 let mut md: Metadata = inline_metadata.cloned().unwrap_or_default();
673 md.remove(LOCATION_KEY);
674 md.remove(LOCATION_SHA256_KEY);
675 md.remove(LOCATION_FETCH_MS_KEY);
676
677 // The caller's metadata is written on BOTH sides of the indirection:
678 // inside the uploaded stream and on the pointer batch. Ports disagree
679 // about which one carries per-batch keys — Python's resolver returns the
680 // *inner* batch's metadata and discards the pointer's, while this crate's
681 // resolver merges both. Writing it twice is what makes a key that must
682 // survive externalisation (the exchange cursor
683 // `vgi_rpc.stream_state#b64`, `vgi_batch_index`, partition values)
684 // survive for either client. The duplicate costs a few bytes and the
685 // values are identical, so a merge in any order agrees.
686 let ipc_bytes = write_one_batch_as(
687 batch,
688 declared_schema,
689 if md.is_empty() { None } else { Some(&md) },
690 )?;
691 if ipc_bytes.len() < cfg.threshold_bytes {
692 return Ok(None);
693 }
694 let raw_len = ipc_bytes.len();
695 let sha = sha256_hex(&ipc_bytes);
696 let payload = compress(&ipc_bytes, cfg.compression)?;
697
698 // Pointer batch: zero-row but matching the enclosing stream's schema,
699 // matching Python's `make_external_location_batch` shape so the
700 // client's IPC reader sees a consistent column count — and so the
701 // schema it validates the fetched payload against is the one the
702 // payload declares.
703 let ptr = empty_batch(declared_schema)?;
704 Ok(Some(PreparedExternal {
705 raw_len,
706 payload,
707 sha,
708 ptr,
709 md,
710 }))
711}
712
713/// Upload a [`PreparedExternal`] and return the pointer batch + metadata.
714///
715/// This is the single upload choke point: the externalised-bytes counter
716/// read by [`ExternalizedScope`] is incremented here, so a new call site
717/// cannot make the total drift from reality.
718pub fn upload_prepared(
719 prepared: PreparedExternal,
720 cfg: &ExternalLocationConfig,
721) -> Result<(RecordBatch, Metadata)> {
722 let PreparedExternal {
723 payload,
724 sha,
725 ptr,
726 mut md,
727 ..
728 } = prepared;
729 EXTERNALIZED_BYTES.with(|c| c.set(c.get() + payload.len() as u64));
730 let upload = cfg.storage.upload(&payload, cfg.compression)?;
731 // Validator runs over the final URL.
732 validate_external_url(&cfg.url_validator, &upload.url)?;
733 md.insert(LOCATION_KEY.to_string(), upload.url);
734 md.insert(LOCATION_SHA256_KEY.to_string(), sha);
735 Ok((ptr, md))
736}
737
738/// Decide whether to externalize `batch`; return the pointer (zero-row)
739/// batch + pointer metadata when yes, else `None`. The original batch is
740/// left untouched so the caller can emit it inline.
741///
742/// `inline_metadata` — optional custom metadata the caller wants to
743/// attach alongside the location keys (merged; location keys win).
744///
745/// Callers that must enforce a byte cap should use
746/// [`prepare_externalize_batch`] + [`upload_prepared`] instead, which lets
747/// them see the payload size before the upload happens.
748///
749/// `declared_schema` is the enclosing IPC stream's schema; see
750/// [`prepare_externalize_batch`].
751pub fn maybe_externalize_batch(
752 batch: &RecordBatch,
753 declared_schema: &Schema,
754 inline_metadata: Option<&Metadata>,
755 cfg: &ExternalLocationConfig,
756) -> Result<Option<(RecordBatch, Metadata)>> {
757 match prepare_externalize_batch(batch, declared_schema, inline_metadata, cfg)? {
758 None => Ok(None),
759 Some(prepared) => upload_prepared(prepared, cfg).map(Some),
760 }
761}
762
763// ---------------------------------------------------------------------------
764// Client-side: resolve pointer batches
765// ---------------------------------------------------------------------------
766
767/// Resolve a pointer batch (zero-row batch with `vgi_rpc.location`
768/// metadata) back into the original record batch. Non-pointer batches
769/// are returned untouched.
770///
771/// Returns `(resolved_batch, user_metadata)` where the location keys
772/// have been stripped from the metadata visible to the caller. A
773/// `vgi_rpc.location.fetch_ms` claim is appended so callers / access
774/// logs can observe the fetch latency.
775pub fn resolve_external_location(
776 batch: &RecordBatch,
777 metadata: &Metadata,
778 cfg: &ExternalLocationConfig,
779) -> Result<(RecordBatch, Metadata)> {
780 let Some(url) = md_get(metadata, LOCATION_KEY) else {
781 return Ok((batch.clone(), metadata.clone()));
782 };
783 let start = std::time::Instant::now();
784 let fetched = cfg.fetcher.fetch_with_policy(
785 url,
786 cfg.compression,
787 cfg.max_encoded_bytes,
788 &cfg.url_validator,
789 cfg.max_redirects,
790 )?;
791 let ipc_bytes = decompress(
792 &fetched.bytes,
793 fetched.compression,
794 cfg.max_decompressed_bytes,
795 )?;
796
797 // Integrity check.
798 if let Some(expected) = md_get(metadata, LOCATION_SHA256_KEY) {
799 let actual = sha256_hex(&ipc_bytes);
800 if expected != actual.as_str() {
801 return Err(RpcError::runtime_error(format!(
802 "external location checksum: SHA-256 mismatch (expected {expected}, got {actual})"
803 )));
804 }
805 }
806 let (resolved, inner_md) = deserialize_single_batch_with_metadata(&ipc_bytes)?;
807 let fetch_ms = start.elapsed().as_secs_f64() * 1000.0;
808
809 // Start from the outer pointer's non-location keys, then overlay the inner
810 // (externalized) batch's metadata. Implementations differ on where they
811 // carry per-batch keys like `vgi_rpc.stream_state#b64`: the Rust server
812 // stamps them on the outer pointer, the Python server on the inner payload
813 // batch. Merging both (inner wins) recovers the token either way and
814 // matches Python's resolver, which uses the inner batch's metadata.
815 let mut user_md: Metadata = metadata
816 .iter()
817 .filter(|(k, _)| {
818 *k != LOCATION_KEY && *k != LOCATION_SHA256_KEY && *k != LOCATION_FETCH_MS_KEY
819 })
820 .map(|(k, v)| (k.clone(), v.clone()))
821 .collect();
822 for (k, v) in inner_md {
823 if k != LOCATION_KEY && k != LOCATION_SHA256_KEY && k != LOCATION_FETCH_MS_KEY {
824 user_md.insert(k, v);
825 }
826 }
827 user_md.insert(
828 LOCATION_FETCH_MS_KEY.to_string(),
829 format!("{:.2}", fetch_ms),
830 );
831 Ok((resolved, user_md))
832}
833
834// ---------------------------------------------------------------------------
835// In-memory test backend
836// ---------------------------------------------------------------------------
837//
838// Gated behind the `test-utils` feature so it doesn't show up on
839// crates.io / docs.rs for normal users. Internal tests + the
840// `external_integration` integration test enable the feature
841// transitively via the workspace.
842
843/// In-memory storage backend + fetcher pair; used by tests and CI.
844/// Thread-safe, no I/O. **Not for production use.**
845#[cfg(any(test, feature = "test-utils"))]
846pub struct InMemoryStorage {
847 map: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
848 next_id: std::sync::atomic::AtomicU64,
849 base_url: String,
850}
851
852#[cfg(any(test, feature = "test-utils"))]
853impl InMemoryStorage {
854 pub fn new() -> Arc<Self> {
855 Arc::new(Self {
856 map: std::sync::Mutex::new(std::collections::HashMap::new()),
857 next_id: std::sync::atomic::AtomicU64::new(1),
858 base_url: "https://inmem.test/".to_string(),
859 })
860 }
861
862 pub fn len(&self) -> usize {
863 self.map.lock().unwrap().len()
864 }
865
866 pub fn is_empty(&self) -> bool {
867 self.len() == 0
868 }
869}
870
871#[cfg(any(test, feature = "test-utils"))]
872impl ExternalStorage for InMemoryStorage {
873 fn upload(&self, ipc_bytes: &[u8], _compression: Compression) -> Result<UploadResult> {
874 let id = self
875 .next_id
876 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
877 let url = format!("{}{:016x}", self.base_url, id);
878 let sha = sha256_hex(
879 // Storage receives the already-compressed bytes; the sha recorded
880 // in the pointer metadata tracks the RAW ipc bytes so the
881 // caller of upload() is responsible for that value — we only
882 // store, not hash, here. Return a placeholder hash (caller
883 // replaces it from maybe_externalize_batch).
884 ipc_bytes,
885 );
886 self.map
887 .lock()
888 .unwrap()
889 .insert(url.clone(), ipc_bytes.to_vec());
890 Ok(UploadResult { url, sha256: sha })
891 }
892}
893
894#[cfg(any(test, feature = "test-utils"))]
895impl Fetcher for InMemoryStorage {
896 fn fetch(&self, url: &str, _compression: Compression, max_bytes: usize) -> Result<Vec<u8>> {
897 let bytes = self
898 .map
899 .lock()
900 .unwrap()
901 .get(url)
902 .cloned()
903 .ok_or_else(|| RpcError::runtime_error(format!("inmem fetch miss: {url}")))?;
904 if bytes.len() > max_bytes {
905 return Err(RpcError::runtime_error(format!(
906 "inmem fetch payload {} bytes exceeds max_bytes={max_bytes}",
907 bytes.len()
908 )));
909 }
910 Ok(bytes)
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 use std::sync::Arc as Ar;
917
918 use arrow_array::{Int64Array, RecordBatch};
919 use arrow_schema::{DataType, Field, Schema};
920
921 use super::*;
922
923 fn big_batch(rows: usize) -> RecordBatch {
924 let schema = Arc::new(Schema::new(vec![Field::new(
925 "value",
926 DataType::Int64,
927 false,
928 )]));
929 let col: Ar<dyn arrow_array::Array> =
930 Arc::new(Int64Array::from((0..rows as i64).collect::<Vec<_>>()));
931 RecordBatch::try_new(schema, vec![col]).unwrap()
932 }
933
934 fn cfg_with(storage: Arc<InMemoryStorage>, threshold: usize) -> ExternalLocationConfig {
935 let s: Arc<dyn ExternalStorage> = storage.clone();
936 let f: Arc<dyn Fetcher> = storage;
937 ExternalLocationConfig::new(s, f)
938 .with_threshold_bytes(threshold)
939 .with_url_validator(any_url_validator())
940 }
941
942 #[test]
943 fn small_batch_stays_inline() {
944 let storage = InMemoryStorage::new();
945 let cfg = cfg_with(storage.clone(), 1024 * 1024);
946 let batch = big_batch(10);
947 let out = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg).unwrap();
948 assert!(out.is_none());
949 assert!(storage.is_empty());
950 }
951
952 #[test]
953 fn large_batch_externalizes_and_round_trips() {
954 let storage = InMemoryStorage::new();
955 let cfg = cfg_with(storage.clone(), 1024);
956 let batch = big_batch(50_000);
957
958 let (ptr, md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
959 .unwrap()
960 .unwrap();
961 assert_eq!(ptr.num_rows(), 0);
962 // Pointer batch carries the original schema (zero-row); cross-language
963 // clients expect the column count to match the result schema.
964 assert_eq!(ptr.schema().fields().len(), batch.schema().fields().len());
965 assert!(md_get(&md, LOCATION_KEY).unwrap().starts_with("https://"));
966 assert_eq!(storage.len(), 1);
967
968 let (resolved, user_md) = resolve_external_location(&ptr, &md, &cfg).unwrap();
969 assert_eq!(resolved.num_rows(), batch.num_rows());
970 assert!(md_get(&user_md, LOCATION_KEY).is_none());
971 assert!(md_get(&user_md, LOCATION_FETCH_MS_KEY).is_some());
972 }
973
974 #[test]
975 fn zstd_compression_round_trip() {
976 let storage = InMemoryStorage::new();
977 let cfg = cfg_with(storage.clone(), 1024).with_compression(Compression::Zstd(3));
978 let batch = big_batch(20_000);
979 let (ptr, md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
980 .unwrap()
981 .unwrap();
982 let (resolved, _) = resolve_external_location(&ptr, &md, &cfg).unwrap();
983 assert_eq!(resolved.num_rows(), batch.num_rows());
984 }
985
986 #[test]
987 fn zstd_rejects_large_window_even_when_output_is_tiny() {
988 use std::io::Write;
989
990 let mut encoder = zstd::stream::Encoder::new(Vec::new(), 1).unwrap();
991 encoder.window_log(20).unwrap();
992 encoder.include_contentsize(false).unwrap();
993 encoder.write_all(b"tiny").unwrap();
994 let frame = encoder.finish().unwrap();
995
996 let err = decompress(&frame, Compression::Zstd(1), 1024)
997 .expect_err("a 1 MiB window must exceed a 1 KiB decode budget");
998 assert!(err.message.contains("window") || err.message.contains("memory"));
999 }
1000
1001 // An externalised payload is a standalone IPC stream and declares its own
1002 // schema, whereas an inline batch rides a stream whose schema was declared
1003 // once, up front. A batch differing from that declared schema only
1004 // cosmetically — here, field nullability — is invisible inline (the writer
1005 // never reconciles the two) and becomes a hard `Schema mismatch` at a peer
1006 // that validates the fetched payload the moment externalisation is turned
1007 // on. The declared schema must therefore travel with the payload.
1008 #[test]
1009 fn externalized_payload_declares_the_enclosing_stream_schema() {
1010 let storage = InMemoryStorage::new();
1011 let cfg = cfg_with(storage.clone(), 1024);
1012 // What the stream promised: `value` is nullable.
1013 let declared = Schema::new(vec![Field::new("value", DataType::Int64, true)]);
1014 // What the worker emitted: same data, non-nullable field.
1015 let batch = big_batch(50_000);
1016 assert!(!batch.schema().field(0).is_nullable());
1017
1018 let (ptr, md) = maybe_externalize_batch(&batch, &declared, None, &cfg)
1019 .unwrap()
1020 .unwrap();
1021 // The pointer the peer decodes carries the declared schema...
1022 assert!(ptr.schema().field(0).is_nullable());
1023 // ...and so does the payload behind it, so a validating peer sees
1024 // the two agree.
1025 let raw = cfg
1026 .fetcher
1027 .fetch(
1028 md_get(&md, LOCATION_KEY).unwrap(),
1029 cfg.compression,
1030 cfg.max_decompressed_bytes,
1031 )
1032 .unwrap();
1033 let mut reader = StreamReader::new(raw.as_slice()).unwrap();
1034 let (fetched, _) = reader.read_next().unwrap().unwrap();
1035 assert_eq!(fetched.schema().as_ref(), &declared);
1036 assert_eq!(fetched.num_rows(), batch.num_rows());
1037 }
1038
1039 // Java's uploader cannot carry dictionaries, so its port has to keep
1040 // dictionary-encoded batches inline. Rust's writer emits the dictionary
1041 // messages and the reader consumes them transparently, so no such
1042 // carve-out is needed here — pinned so a future change to either side
1043 // cannot quietly reintroduce the constraint.
1044 #[test]
1045 fn dictionary_encoded_batch_round_trips_externally() {
1046 use arrow_array::types::Int32Type;
1047 use arrow_array::{Array, DictionaryArray};
1048
1049 let storage = InMemoryStorage::new();
1050 let cfg = cfg_with(storage.clone(), 1024);
1051 let values: Vec<&str> = (0..20_000)
1052 .map(|i| ["alpha", "beta", "gamma"][i % 3])
1053 .collect();
1054 let dict: DictionaryArray<Int32Type> = values.into_iter().collect();
1055 let schema = Arc::new(Schema::new(vec![Field::new(
1056 "label",
1057 dict.data_type().clone(),
1058 false,
1059 )]));
1060 let col: Ar<dyn arrow_array::Array> = Arc::new(dict);
1061 let batch = RecordBatch::try_new(schema.clone(), vec![col]).unwrap();
1062
1063 let (ptr, md) = maybe_externalize_batch(&batch, schema.as_ref(), None, &cfg)
1064 .unwrap()
1065 .unwrap();
1066 assert_eq!(storage.len(), 1);
1067 let (resolved, _) = resolve_external_location(&ptr, &md, &cfg).unwrap();
1068 assert_eq!(resolved.num_rows(), batch.num_rows());
1069 assert_eq!(resolved.column(0).as_ref(), batch.column(0).as_ref());
1070 }
1071
1072 #[test]
1073 fn https_only_validator_rejects_plaintext() {
1074 let storage = InMemoryStorage::new();
1075 let s: Arc<dyn ExternalStorage> = storage.clone();
1076 let f: Arc<dyn Fetcher> = storage;
1077 let cfg = ExternalLocationConfig::new(s, f).with_threshold_bytes(0);
1078 // In-memory URL is https, so build a forged metadata entry.
1079 let batch = big_batch(1);
1080 let mut bogus_md = Metadata::new();
1081 bogus_md.insert("vgi_rpc.location".into(), "http://not-secure/x".into());
1082 let err = resolve_external_location(&batch, &bogus_md, &cfg).unwrap_err();
1083 assert!(err.message.contains("https://"));
1084 }
1085
1086 #[test]
1087 fn safe_https_validator_blocks_ssrf_targets() {
1088 let v = safe_https_validator();
1089 // Non-https.
1090 assert!(v("http://example.com/x").is_err());
1091 // IP-literal internal / non-routable targets.
1092 assert!(v("https://169.254.169.254/latest/meta-data/").is_err());
1093 assert!(v("https://127.0.0.1/").is_err());
1094 assert!(v("https://10.0.0.1/").is_err());
1095 assert!(v("https://192.168.1.1/").is_err());
1096 assert!(v("https://[::1]/").is_err());
1097 assert!(v("https://0.0.0.0/").is_err());
1098 // Hostname forms of loopback.
1099 assert!(v("https://localhost/x").is_err());
1100 assert!(v("https://api.localhost/x").is_err());
1101 // A public IP literal is allowed.
1102 assert!(v("https://1.1.1.1/x").is_ok());
1103 }
1104
1105 #[test]
1106 fn sha_mismatch_is_rejected() {
1107 let storage = InMemoryStorage::new();
1108 let cfg = cfg_with(storage.clone(), 1024);
1109 let batch = big_batch(10_000);
1110 let (ptr, mut md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
1111 .unwrap()
1112 .unwrap();
1113 // Corrupt the recorded hash.
1114 if let Some(v) = md.get_mut(LOCATION_SHA256_KEY) {
1115 *v = "deadbeef".into();
1116 }
1117 let err = resolve_external_location(&ptr, &md, &cfg).unwrap_err();
1118 assert!(err.message.contains("SHA-256 mismatch"));
1119 }
1120}