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/// Pluggable fetcher used to resolve pointer batches back into data.
168///
169/// Takes a URL (and the declared compression) and returns the still-encoded
170/// payload bytes. `vgi-rpc` ships an HTTPS fetcher; tests plug in an
171/// in-memory implementation.
172///
173/// `max_bytes` is a hard ceiling on the number of bytes the fetcher may
174/// read from the remote — implementations **must** abort once it is
175/// exceeded rather than buffering an unbounded response into memory. A
176/// hostile or compromised storage URL would otherwise OOM the process
177/// before decompression's own cap is ever reached.
178pub trait Fetcher: Send + Sync {
179 fn fetch(&self, url: &str, compression: Compression, max_bytes: usize) -> Result<Vec<u8>>;
180}
181
182/// Externalization configuration.
183#[derive(Clone)]
184pub struct ExternalLocationConfig {
185 /// Payload size in bytes at which a batch is eligible for
186 /// externalization. Smaller batches stay inline. Default 1 MiB.
187 pub threshold_bytes: usize,
188 /// Compression applied to the IPC bytes before upload. Default
189 /// `Compression::None`.
190 pub compression: Compression,
191 /// Upload backend. Required when externalization is enabled.
192 pub storage: Arc<dyn ExternalStorage>,
193 /// Resolver used on the read side. Required when resolving inbound
194 /// pointer batches.
195 pub fetcher: Arc<dyn Fetcher>,
196 /// URL validator run on both the upload (post-signing) and fetch
197 /// (pre-download) paths. Defaults to [`safe_https_validator`], which
198 /// rejects non-`https` URLs and internal/non-routable hosts. Reject
199 /// a URL by returning `Err`.
200 pub url_validator: UrlValidator,
201 /// Hard ceiling on the post-decompression size of a fetched
202 /// payload. Zstd frames carry their decompressed size in the
203 /// header and `zstd::decode_all` would otherwise trust it
204 /// eagerly — a small malicious payload claiming gigabytes of
205 /// output would OOM the client. Default 1 GiB. Set to `usize::MAX`
206 /// to disable.
207 pub max_decompressed_bytes: usize,
208}
209
210impl std::fmt::Debug for ExternalLocationConfig {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 f.debug_struct("ExternalLocationConfig")
213 .field("threshold_bytes", &self.threshold_bytes)
214 .field("compression", &self.compression)
215 .finish_non_exhaustive()
216 }
217}
218
219/// Helper: scheme-only HTTPS validator.
220///
221/// **Insufficient for untrusted input** — it accepts `https://localhost`,
222/// `https://169.254.169.254` (cloud metadata), and any internal-network
223/// host. Use it only when the set of external-location URLs is fully
224/// trusted (e.g. URLs your own backend minted). For anything that can
225/// carry a client-supplied location, use [`safe_https_validator`].
226pub fn https_only_validator() -> UrlValidator {
227 Arc::new(|url: &str| {
228 if url.starts_with("https://") {
229 Ok(())
230 } else {
231 Err(RpcError::value_error(format!(
232 "external location URL must be https:// ({url})"
233 )))
234 }
235 })
236}
237
238/// Helper: default-deny HTTPS validator with SSRF protection.
239///
240/// Requires the `https` scheme, then rejects the URL when its host is —
241/// or resolves to — a loopback, private, link-local, unique-local,
242/// carrier-grade-NAT, broadcast, documentation, or unspecified address.
243/// This is the default for [`ExternalLocationConfig::new`] because the
244/// unary HTTP path resolves a *client-supplied* `vgi_rpc.location`
245/// server-side; without this a client could pivot the server into
246/// fetching `https://169.254.169.254/...` or an internal service.
247///
248/// Note: a hostname is resolved here and again at fetch time, so a
249/// DNS-rebinding attacker could still slip through the gap. Pair this
250/// with a redirect-free, size-capped fetcher (the bundled `HttpFetcher`
251/// is both) and, for high-assurance deployments, an egress firewall.
252pub fn safe_https_validator() -> UrlValidator {
253 Arc::new(|raw: &str| {
254 let url = url::Url::parse(raw)
255 .map_err(|e| RpcError::value_error(format!("invalid external location URL: {e}")))?;
256 if url.scheme() != "https" {
257 return Err(RpcError::value_error(
258 "external location URL must be https://",
259 ));
260 }
261 let host = url
262 .host()
263 .ok_or_else(|| RpcError::value_error("external location URL has no host"))?;
264 match host {
265 url::Host::Ipv4(ip) => reject_unsafe_ip(IpAddr::V4(ip)),
266 url::Host::Ipv6(ip) => reject_unsafe_ip(IpAddr::V6(ip)),
267 url::Host::Domain(name) => {
268 let lname = name.to_ascii_lowercase();
269 if lname == "localhost" || lname.ends_with(".localhost") {
270 return Err(RpcError::value_error(
271 "external location host is not publicly routable",
272 ));
273 }
274 let port = url.port_or_known_default().unwrap_or(443);
275 let addrs = (name, port).to_socket_addrs().map_err(|e| {
276 RpcError::value_error(format!("external location host does not resolve: {e}"))
277 })?;
278 let mut saw_any = false;
279 for sa in addrs {
280 saw_any = true;
281 reject_unsafe_ip(sa.ip())?;
282 }
283 if !saw_any {
284 return Err(RpcError::value_error(
285 "external location host does not resolve",
286 ));
287 }
288 Ok(())
289 }
290 }
291 })
292}
293
294/// Reject an IP address that is not safe for the server to dial: any
295/// loopback / private / link-local / unique-local / CGNAT / broadcast /
296/// documentation / unspecified / multicast address.
297fn reject_unsafe_ip(ip: IpAddr) -> Result<()> {
298 let unsafe_addr = match ip {
299 IpAddr::V4(v4) => {
300 let o = v4.octets();
301 v4.is_loopback()
302 || v4.is_private()
303 || v4.is_link_local()
304 || v4.is_unspecified()
305 || v4.is_broadcast()
306 || v4.is_multicast()
307 || v4.is_documentation()
308 // 100.64.0.0/10 — carrier-grade NAT.
309 || (o[0] == 100 && (o[1] & 0xc0) == 0x40)
310 }
311 IpAddr::V6(v6) => {
312 let seg0 = v6.segments()[0];
313 v6.is_loopback()
314 || v6.is_unspecified()
315 || v6.is_multicast()
316 // fc00::/7 — unique local.
317 || (seg0 & 0xfe00) == 0xfc00
318 // fe80::/10 — link local.
319 || (seg0 & 0xffc0) == 0xfe80
320 // IPv4-mapped (::ffff:0:0/96) — classify the embedded v4.
321 || v6
322 .to_ipv4_mapped()
323 .map(|m| reject_unsafe_ip(IpAddr::V4(m)).is_err())
324 .unwrap_or(false)
325 }
326 };
327 if unsafe_addr {
328 return Err(RpcError::value_error(
329 "external location host resolves to a non-routable / internal address",
330 ));
331 }
332 Ok(())
333}
334
335/// Helper: accept any URL (useful for local tests + MinIO).
336pub fn any_url_validator() -> UrlValidator {
337 Arc::new(|_: &str| Ok(()))
338}
339
340impl ExternalLocationConfig {
341 pub fn new(storage: Arc<dyn ExternalStorage>, fetcher: Arc<dyn Fetcher>) -> Self {
342 Self {
343 threshold_bytes: 1024 * 1024,
344 compression: Compression::None,
345 storage,
346 fetcher,
347 url_validator: safe_https_validator(),
348 max_decompressed_bytes: 1024 * 1024 * 1024,
349 }
350 }
351
352 pub fn with_threshold_bytes(mut self, n: usize) -> Self {
353 self.threshold_bytes = n;
354 self
355 }
356
357 pub fn with_compression(mut self, c: Compression) -> Self {
358 self.compression = c;
359 self
360 }
361
362 pub fn with_url_validator(mut self, v: UrlValidator) -> Self {
363 self.url_validator = v;
364 self
365 }
366
367 /// Override the hard ceiling on post-decompression payload size.
368 /// Pass `usize::MAX` to disable. Default is 1 GiB.
369 pub fn with_max_decompressed_bytes(mut self, n: usize) -> Self {
370 self.max_decompressed_bytes = n;
371 self
372 }
373}
374
375// ---------------------------------------------------------------------------
376// Serialize a batch as an IPC stream with no custom metadata.
377// ---------------------------------------------------------------------------
378
379/// Serialize one record batch as a complete IPC stream (schema + batch + EOS).
380pub fn serialize_batch_to_ipc(batch: &RecordBatch) -> Result<Vec<u8>> {
381 // External payloads carry the raw data only; the pointer batch on
382 // the outside owns the metadata. Pass `None` to omit any
383 // `custom_metadata` field on the wire.
384 write_one_batch_as(batch, batch.schema().as_ref(), None)
385}
386
387/// Read back an IPC stream containing a single batch.
388/// Fetch, decompress, and integrity-check an external-location pointer's
389/// payload, returning the raw inner IPC stream bytes. The inner stream may
390/// contain **multiple** batches (e.g. a peer that externalizes a whole
391/// per-iteration output — logs followed by the data batch), so callers that
392/// need log/exception handling should process the returned bytes as a full
393/// response stream rather than assuming a single batch.
394///
395/// Returns `Ok(None)` when `metadata` carries no `vgi_rpc.location` pointer.
396pub fn fetch_external_ipc_bytes(
397 metadata: &Metadata,
398 cfg: &ExternalLocationConfig,
399) -> Result<Option<Vec<u8>>> {
400 let Some(url) = md_get(metadata, LOCATION_KEY) else {
401 return Ok(None);
402 };
403 (cfg.url_validator)(url)?;
404 let compressed = cfg
405 .fetcher
406 .fetch(url, cfg.compression, cfg.max_decompressed_bytes)?;
407 let ipc_bytes = decompress(&compressed, cfg.compression, cfg.max_decompressed_bytes)?;
408 if let Some(expected) = md_get(metadata, LOCATION_SHA256_KEY) {
409 let actual = sha256_hex(&ipc_bytes);
410 if expected != actual.as_str() {
411 return Err(RpcError::runtime_error(format!(
412 "external location SHA-256 mismatch (expected {expected}, got {actual})"
413 )));
414 }
415 }
416 Ok(Some(ipc_bytes))
417}
418
419pub fn deserialize_single_batch(ipc_bytes: &[u8]) -> Result<RecordBatch> {
420 Ok(deserialize_single_batch_with_metadata(ipc_bytes)?.0)
421}
422
423/// Like [`deserialize_single_batch`] but also returns the batch's per-message
424/// custom metadata — some peers carry keys (e.g. the stream-state token) on the
425/// externalized inner batch rather than the outer pointer.
426pub fn deserialize_single_batch_with_metadata(ipc_bytes: &[u8]) -> Result<(RecordBatch, Metadata)> {
427 let mut r = StreamReader::new(ipc_bytes)?;
428 r.read_next()?
429 .ok_or_else(|| RpcError::runtime_error("external batch stream is empty"))
430}
431
432fn sha256_hex(bytes: &[u8]) -> String {
433 bytes_to_hex(&Sha256::digest(bytes))
434}
435
436fn compress(ipc_bytes: &[u8], compression: Compression) -> Result<Vec<u8>> {
437 match compression {
438 Compression::None => Ok(ipc_bytes.to_vec()),
439 Compression::Zstd(level) => {
440 // Use the bulk API and explicitly include the decompressed
441 // size in the frame header. Python's `zstandard.ZstdDecompressor`
442 // requires `Content-Size` to be present when decompressing
443 // a single frame in one shot.
444 let mut enc = zstd::bulk::Compressor::new(level)
445 .map_err(|e| RpcError::runtime_error(format!("zstd encoder: {e}")))?;
446 enc.set_parameter(zstd::stream::raw::CParameter::ContentSizeFlag(true))
447 .map_err(|e| RpcError::runtime_error(format!("zstd contentsize: {e}")))?;
448 enc.compress(ipc_bytes)
449 .map_err(|e| RpcError::runtime_error(format!("zstd encode: {e}")))
450 }
451 }
452}
453
454fn decompress(bytes: &[u8], compression: Compression, max_size: usize) -> Result<Vec<u8>> {
455 match compression {
456 Compression::None => {
457 if bytes.len() > max_size {
458 return Err(RpcError::runtime_error(format!(
459 "external payload {} bytes exceeds max_decompressed_bytes={max_size}",
460 bytes.len()
461 )));
462 }
463 Ok(bytes.to_vec())
464 }
465 // Stream-decode and stop if we exceed the cap. Avoids trusting
466 // the zstd frame header's declared decompressed size (which
467 // `decode_all` would otherwise allocate eagerly), blocking a
468 // remote OOM via a tiny payload claiming gigabytes of output.
469 Compression::Zstd(_) => {
470 use std::io::Read;
471 let mut decoder = zstd::Decoder::new(bytes)
472 .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
473 let mut out = Vec::new();
474 let mut buf = [0u8; 64 * 1024];
475 loop {
476 let n = decoder
477 .read(&mut buf)
478 .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
479 if n == 0 {
480 break;
481 }
482 if out.len() + n > max_size {
483 return Err(RpcError::runtime_error(format!(
484 "zstd decode: output exceeds max_decompressed_bytes={max_size}"
485 )));
486 }
487 out.extend_from_slice(&buf[..n]);
488 }
489 Ok(out)
490 }
491 }
492}
493
494// ---------------------------------------------------------------------------
495// Server-side: externalize large batches
496// ---------------------------------------------------------------------------
497
498/// Pointer-batch schema — a zero-field, zero-row batch. The Python
499/// canonical externalizes into an empty-schema batch with location
500/// metadata; we match that so the on-wire bytes are identical.
501pub fn pointer_schema() -> SchemaRef {
502 Arc::new(Schema::empty())
503}
504
505/// A batch that has been serialized (and compressed) for external storage
506/// but **not yet uploaded**.
507///
508/// Splitting the prepare step out of [`maybe_externalize_batch`] is what
509/// makes an operator cap (`max_externalized_response_bytes`) enforceable
510/// *before* the bytes leave the process: the exact payload is already in
511/// hand, so a response that would violate the cap can be refused without
512/// paying for the storage round trip. Nothing observable happens until
513/// [`upload_prepared`] is called, so dropping a `PreparedExternal` is a
514/// clean abort.
515pub struct PreparedExternal {
516 /// Raw (pre-compression) IPC bytes — what the cap is measured in.
517 raw_len: usize,
518 /// The bytes that will actually be uploaded.
519 payload: Vec<u8>,
520 /// SHA-256 of the **raw** IPC bytes.
521 sha: String,
522 /// Zero-row pointer batch matching the source batch's schema.
523 ptr: RecordBatch,
524 /// Caller metadata with any stale location keys already stripped.
525 md: Metadata,
526}
527
528impl PreparedExternal {
529 /// Size of this payload as `max_externalized_response_bytes` measures
530 /// it: the **raw** IPC bytes, captured *before* external compression.
531 ///
532 /// Pre-compression on purpose, and identical whether or not
533 /// [`ExternalLocationConfig::with_compression`] is in effect. Python's
534 /// `maybe_externalize_batch` returns exactly this quantity for cap
535 /// accounting (`raw_size = original_bytes ...`, taken before
536 /// `_codec_compress`), and TypeScript compares an uncompressed batch
537 /// size too. Charging the compressed upload instead would make one
538 /// configuration mean different things on different ports, and would
539 /// silently loosen the cap by the compression ratio — which for the
540 /// repetitive payloads that provoke externalisation is enormous.
541 ///
542 /// The access-log counter (`DispatchInfo::externalized_bytes`) stays on
543 /// the compressed number: that one answers "what left the machine",
544 /// this one answers "what did the operator allow".
545 ///
546 /// One deliberate deviation from the reference: Python's *pre-flight*
547 /// predicts with `batch.get_total_buffer_size()` and then charges the
548 /// raw IPC count, so its two numbers are only approximately equal.
549 /// Rust has already serialized by this point (the threshold gate needs
550 /// the IPC length anyway), so the pre-flight and the charge are the
551 /// same number and cannot disagree. Same units, same
552 /// "uncompressed size of the data" semantics, no estimate error.
553 pub fn cap_bytes(&self) -> usize {
554 self.raw_len
555 }
556}
557
558/// Serialize `batch` for external storage without uploading it.
559///
560/// Returns `None` under exactly the conditions [`maybe_externalize_batch`]
561/// declines to externalize (empty batch, or IPC bytes below the configured
562/// threshold), so a caller that pre-flights with this and then uploads sees
563/// the same decision it would have gotten from the one-shot helper.
564///
565/// `declared_schema` is the schema of the **enclosing** IPC stream — the one
566/// the peer already read and will validate the fetched payload against. It
567/// is not always `batch.schema()`: a worker may emit a batch that differs
568/// from its stream's declared schema in nullability, dictionary encoding or
569/// schema metadata, and inline delivery hides that completely (see
570/// [`crate::wire::write_one_batch_as`]). Declaring the batch's own schema on
571/// the uploaded payload is what turns such a difference into a client-side
572/// `Schema mismatch` the moment externalisation is switched on.
573pub fn prepare_externalize_batch(
574 batch: &RecordBatch,
575 declared_schema: &Schema,
576 inline_metadata: Option<&Metadata>,
577 cfg: &ExternalLocationConfig,
578) -> Result<Option<PreparedExternal>> {
579 if batch.num_rows() == 0 {
580 return Ok(None);
581 }
582 // Build pointer metadata, merging the caller-supplied metadata first.
583 // The location keys are added by `upload_prepared`, which is the only
584 // place that knows the URL.
585 let mut md: Metadata = inline_metadata.cloned().unwrap_or_default();
586 md.remove(LOCATION_KEY);
587 md.remove(LOCATION_SHA256_KEY);
588 md.remove(LOCATION_FETCH_MS_KEY);
589
590 // The caller's metadata is written on BOTH sides of the indirection:
591 // inside the uploaded stream and on the pointer batch. Ports disagree
592 // about which one carries per-batch keys — Python's resolver returns the
593 // *inner* batch's metadata and discards the pointer's, while this crate's
594 // resolver merges both. Writing it twice is what makes a key that must
595 // survive externalisation (the exchange cursor
596 // `vgi_rpc.stream_state#b64`, `vgi_batch_index`, partition values)
597 // survive for either client. The duplicate costs a few bytes and the
598 // values are identical, so a merge in any order agrees.
599 let ipc_bytes = write_one_batch_as(
600 batch,
601 declared_schema,
602 if md.is_empty() { None } else { Some(&md) },
603 )?;
604 if ipc_bytes.len() < cfg.threshold_bytes {
605 return Ok(None);
606 }
607 let raw_len = ipc_bytes.len();
608 let sha = sha256_hex(&ipc_bytes);
609 let payload = compress(&ipc_bytes, cfg.compression)?;
610
611 // Pointer batch: zero-row but matching the enclosing stream's schema,
612 // matching Python's `make_external_location_batch` shape so the
613 // client's IPC reader sees a consistent column count — and so the
614 // schema it validates the fetched payload against is the one the
615 // payload declares.
616 let ptr = empty_batch(declared_schema)?;
617 Ok(Some(PreparedExternal {
618 raw_len,
619 payload,
620 sha,
621 ptr,
622 md,
623 }))
624}
625
626/// Upload a [`PreparedExternal`] and return the pointer batch + metadata.
627///
628/// This is the single upload choke point: the externalised-bytes counter
629/// read by [`ExternalizedScope`] is incremented here, so a new call site
630/// cannot make the total drift from reality.
631pub fn upload_prepared(
632 prepared: PreparedExternal,
633 cfg: &ExternalLocationConfig,
634) -> Result<(RecordBatch, Metadata)> {
635 let PreparedExternal {
636 payload,
637 sha,
638 ptr,
639 mut md,
640 ..
641 } = prepared;
642 EXTERNALIZED_BYTES.with(|c| c.set(c.get() + payload.len() as u64));
643 let upload = cfg.storage.upload(&payload, cfg.compression)?;
644 // Validator runs over the final URL.
645 (cfg.url_validator)(&upload.url)?;
646 md.insert(LOCATION_KEY.to_string(), upload.url);
647 md.insert(LOCATION_SHA256_KEY.to_string(), sha);
648 Ok((ptr, md))
649}
650
651/// Decide whether to externalize `batch`; return the pointer (zero-row)
652/// batch + pointer metadata when yes, else `None`. The original batch is
653/// left untouched so the caller can emit it inline.
654///
655/// `inline_metadata` — optional custom metadata the caller wants to
656/// attach alongside the location keys (merged; location keys win).
657///
658/// Callers that must enforce a byte cap should use
659/// [`prepare_externalize_batch`] + [`upload_prepared`] instead, which lets
660/// them see the payload size before the upload happens.
661///
662/// `declared_schema` is the enclosing IPC stream's schema; see
663/// [`prepare_externalize_batch`].
664pub fn maybe_externalize_batch(
665 batch: &RecordBatch,
666 declared_schema: &Schema,
667 inline_metadata: Option<&Metadata>,
668 cfg: &ExternalLocationConfig,
669) -> Result<Option<(RecordBatch, Metadata)>> {
670 match prepare_externalize_batch(batch, declared_schema, inline_metadata, cfg)? {
671 None => Ok(None),
672 Some(prepared) => upload_prepared(prepared, cfg).map(Some),
673 }
674}
675
676// ---------------------------------------------------------------------------
677// Client-side: resolve pointer batches
678// ---------------------------------------------------------------------------
679
680/// Resolve a pointer batch (zero-row batch with `vgi_rpc.location`
681/// metadata) back into the original record batch. Non-pointer batches
682/// are returned untouched.
683///
684/// Returns `(resolved_batch, user_metadata)` where the location keys
685/// have been stripped from the metadata visible to the caller. A
686/// `vgi_rpc.location.fetch_ms` claim is appended so callers / access
687/// logs can observe the fetch latency.
688pub fn resolve_external_location(
689 batch: &RecordBatch,
690 metadata: &Metadata,
691 cfg: &ExternalLocationConfig,
692) -> Result<(RecordBatch, Metadata)> {
693 let Some(url) = md_get(metadata, LOCATION_KEY) else {
694 return Ok((batch.clone(), metadata.clone()));
695 };
696 (cfg.url_validator)(url)?;
697
698 let start = std::time::Instant::now();
699 // Cap the fetched (still-encoded) payload at `max_decompressed_bytes`:
700 // any well-formed compressed body is smaller than its decompressed
701 // form, so this is a safe ceiling that also bounds the uncompressed
702 // case. `decompress` enforces the post-decompression cap on top.
703 let compressed = cfg
704 .fetcher
705 .fetch(url, cfg.compression, cfg.max_decompressed_bytes)?;
706 let ipc_bytes = decompress(&compressed, cfg.compression, cfg.max_decompressed_bytes)?;
707
708 // Integrity check.
709 if let Some(expected) = md_get(metadata, LOCATION_SHA256_KEY) {
710 let actual = sha256_hex(&ipc_bytes);
711 if expected != actual.as_str() {
712 return Err(RpcError::runtime_error(format!(
713 "external location SHA-256 mismatch (expected {expected}, got {actual})"
714 )));
715 }
716 }
717 let (resolved, inner_md) = deserialize_single_batch_with_metadata(&ipc_bytes)?;
718 let fetch_ms = start.elapsed().as_secs_f64() * 1000.0;
719
720 // Start from the outer pointer's non-location keys, then overlay the inner
721 // (externalized) batch's metadata. Implementations differ on where they
722 // carry per-batch keys like `vgi_rpc.stream_state#b64`: the Rust server
723 // stamps them on the outer pointer, the Python server on the inner payload
724 // batch. Merging both (inner wins) recovers the token either way and
725 // matches Python's resolver, which uses the inner batch's metadata.
726 let mut user_md: Metadata = metadata
727 .iter()
728 .filter(|(k, _)| {
729 *k != LOCATION_KEY && *k != LOCATION_SHA256_KEY && *k != LOCATION_FETCH_MS_KEY
730 })
731 .map(|(k, v)| (k.clone(), v.clone()))
732 .collect();
733 for (k, v) in inner_md {
734 if k != LOCATION_KEY && k != LOCATION_SHA256_KEY && k != LOCATION_FETCH_MS_KEY {
735 user_md.insert(k, v);
736 }
737 }
738 user_md.insert(
739 LOCATION_FETCH_MS_KEY.to_string(),
740 format!("{:.2}", fetch_ms),
741 );
742 Ok((resolved, user_md))
743}
744
745// ---------------------------------------------------------------------------
746// In-memory test backend
747// ---------------------------------------------------------------------------
748//
749// Gated behind the `test-utils` feature so it doesn't show up on
750// crates.io / docs.rs for normal users. Internal tests + the
751// `external_integration` integration test enable the feature
752// transitively via the workspace.
753
754/// In-memory storage backend + fetcher pair; used by tests and CI.
755/// Thread-safe, no I/O. **Not for production use.**
756#[cfg(any(test, feature = "test-utils"))]
757pub struct InMemoryStorage {
758 map: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
759 next_id: std::sync::atomic::AtomicU64,
760 base_url: String,
761}
762
763#[cfg(any(test, feature = "test-utils"))]
764impl InMemoryStorage {
765 pub fn new() -> Arc<Self> {
766 Arc::new(Self {
767 map: std::sync::Mutex::new(std::collections::HashMap::new()),
768 next_id: std::sync::atomic::AtomicU64::new(1),
769 base_url: "https://inmem.test/".to_string(),
770 })
771 }
772
773 pub fn len(&self) -> usize {
774 self.map.lock().unwrap().len()
775 }
776
777 pub fn is_empty(&self) -> bool {
778 self.len() == 0
779 }
780}
781
782#[cfg(any(test, feature = "test-utils"))]
783impl ExternalStorage for InMemoryStorage {
784 fn upload(&self, ipc_bytes: &[u8], _compression: Compression) -> Result<UploadResult> {
785 let id = self
786 .next_id
787 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
788 let url = format!("{}{:016x}", self.base_url, id);
789 let sha = sha256_hex(
790 // Storage receives the already-compressed bytes; the sha recorded
791 // in the pointer metadata tracks the RAW ipc bytes so the
792 // caller of upload() is responsible for that value — we only
793 // store, not hash, here. Return a placeholder hash (caller
794 // replaces it from maybe_externalize_batch).
795 ipc_bytes,
796 );
797 self.map
798 .lock()
799 .unwrap()
800 .insert(url.clone(), ipc_bytes.to_vec());
801 Ok(UploadResult { url, sha256: sha })
802 }
803}
804
805#[cfg(any(test, feature = "test-utils"))]
806impl Fetcher for InMemoryStorage {
807 fn fetch(&self, url: &str, _compression: Compression, max_bytes: usize) -> Result<Vec<u8>> {
808 let bytes = self
809 .map
810 .lock()
811 .unwrap()
812 .get(url)
813 .cloned()
814 .ok_or_else(|| RpcError::runtime_error(format!("inmem fetch miss: {url}")))?;
815 if bytes.len() > max_bytes {
816 return Err(RpcError::runtime_error(format!(
817 "inmem fetch payload {} bytes exceeds max_bytes={max_bytes}",
818 bytes.len()
819 )));
820 }
821 Ok(bytes)
822 }
823}
824
825#[cfg(test)]
826mod tests {
827 use std::sync::Arc as Ar;
828
829 use arrow_array::{Int64Array, RecordBatch};
830 use arrow_schema::{DataType, Field, Schema};
831
832 use super::*;
833
834 fn big_batch(rows: usize) -> RecordBatch {
835 let schema = Arc::new(Schema::new(vec![Field::new(
836 "value",
837 DataType::Int64,
838 false,
839 )]));
840 let col: Ar<dyn arrow_array::Array> =
841 Arc::new(Int64Array::from((0..rows as i64).collect::<Vec<_>>()));
842 RecordBatch::try_new(schema, vec![col]).unwrap()
843 }
844
845 fn cfg_with(storage: Arc<InMemoryStorage>, threshold: usize) -> ExternalLocationConfig {
846 let s: Arc<dyn ExternalStorage> = storage.clone();
847 let f: Arc<dyn Fetcher> = storage;
848 ExternalLocationConfig::new(s, f)
849 .with_threshold_bytes(threshold)
850 .with_url_validator(any_url_validator())
851 }
852
853 #[test]
854 fn small_batch_stays_inline() {
855 let storage = InMemoryStorage::new();
856 let cfg = cfg_with(storage.clone(), 1024 * 1024);
857 let batch = big_batch(10);
858 let out = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg).unwrap();
859 assert!(out.is_none());
860 assert!(storage.is_empty());
861 }
862
863 #[test]
864 fn large_batch_externalizes_and_round_trips() {
865 let storage = InMemoryStorage::new();
866 let cfg = cfg_with(storage.clone(), 1024);
867 let batch = big_batch(50_000);
868
869 let (ptr, md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
870 .unwrap()
871 .unwrap();
872 assert_eq!(ptr.num_rows(), 0);
873 // Pointer batch carries the original schema (zero-row); cross-language
874 // clients expect the column count to match the result schema.
875 assert_eq!(ptr.schema().fields().len(), batch.schema().fields().len());
876 assert!(md_get(&md, LOCATION_KEY).unwrap().starts_with("https://"));
877 assert_eq!(storage.len(), 1);
878
879 let (resolved, user_md) = resolve_external_location(&ptr, &md, &cfg).unwrap();
880 assert_eq!(resolved.num_rows(), batch.num_rows());
881 assert!(md_get(&user_md, LOCATION_KEY).is_none());
882 assert!(md_get(&user_md, LOCATION_FETCH_MS_KEY).is_some());
883 }
884
885 #[test]
886 fn zstd_compression_round_trip() {
887 let storage = InMemoryStorage::new();
888 let cfg = cfg_with(storage.clone(), 1024).with_compression(Compression::Zstd(3));
889 let batch = big_batch(20_000);
890 let (ptr, md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
891 .unwrap()
892 .unwrap();
893 let (resolved, _) = resolve_external_location(&ptr, &md, &cfg).unwrap();
894 assert_eq!(resolved.num_rows(), batch.num_rows());
895 }
896
897 // An externalised payload is a standalone IPC stream and declares its own
898 // schema, whereas an inline batch rides a stream whose schema was declared
899 // once, up front. A batch differing from that declared schema only
900 // cosmetically — here, field nullability — is invisible inline (the writer
901 // never reconciles the two) and becomes a hard `Schema mismatch` at a peer
902 // that validates the fetched payload the moment externalisation is turned
903 // on. The declared schema must therefore travel with the payload.
904 #[test]
905 fn externalized_payload_declares_the_enclosing_stream_schema() {
906 let storage = InMemoryStorage::new();
907 let cfg = cfg_with(storage.clone(), 1024);
908 // What the stream promised: `value` is nullable.
909 let declared = Schema::new(vec![Field::new("value", DataType::Int64, true)]);
910 // What the worker emitted: same data, non-nullable field.
911 let batch = big_batch(50_000);
912 assert!(!batch.schema().field(0).is_nullable());
913
914 let (ptr, md) = maybe_externalize_batch(&batch, &declared, None, &cfg)
915 .unwrap()
916 .unwrap();
917 // The pointer the peer decodes carries the declared schema...
918 assert!(ptr.schema().field(0).is_nullable());
919 // ...and so does the payload behind it, so a validating peer sees
920 // the two agree.
921 let raw = cfg
922 .fetcher
923 .fetch(
924 md_get(&md, LOCATION_KEY).unwrap(),
925 cfg.compression,
926 cfg.max_decompressed_bytes,
927 )
928 .unwrap();
929 let mut reader = StreamReader::new(raw.as_slice()).unwrap();
930 let (fetched, _) = reader.read_next().unwrap().unwrap();
931 assert_eq!(fetched.schema().as_ref(), &declared);
932 assert_eq!(fetched.num_rows(), batch.num_rows());
933 }
934
935 // Java's uploader cannot carry dictionaries, so its port has to keep
936 // dictionary-encoded batches inline. Rust's writer emits the dictionary
937 // messages and the reader consumes them transparently, so no such
938 // carve-out is needed here — pinned so a future change to either side
939 // cannot quietly reintroduce the constraint.
940 #[test]
941 fn dictionary_encoded_batch_round_trips_externally() {
942 use arrow_array::types::Int32Type;
943 use arrow_array::{Array, DictionaryArray};
944
945 let storage = InMemoryStorage::new();
946 let cfg = cfg_with(storage.clone(), 1024);
947 let values: Vec<&str> = (0..20_000)
948 .map(|i| ["alpha", "beta", "gamma"][i % 3])
949 .collect();
950 let dict: DictionaryArray<Int32Type> = values.into_iter().collect();
951 let schema = Arc::new(Schema::new(vec![Field::new(
952 "label",
953 dict.data_type().clone(),
954 false,
955 )]));
956 let col: Ar<dyn arrow_array::Array> = Arc::new(dict);
957 let batch = RecordBatch::try_new(schema.clone(), vec![col]).unwrap();
958
959 let (ptr, md) = maybe_externalize_batch(&batch, schema.as_ref(), None, &cfg)
960 .unwrap()
961 .unwrap();
962 assert_eq!(storage.len(), 1);
963 let (resolved, _) = resolve_external_location(&ptr, &md, &cfg).unwrap();
964 assert_eq!(resolved.num_rows(), batch.num_rows());
965 assert_eq!(resolved.column(0).as_ref(), batch.column(0).as_ref());
966 }
967
968 #[test]
969 fn https_only_validator_rejects_plaintext() {
970 let storage = InMemoryStorage::new();
971 let s: Arc<dyn ExternalStorage> = storage.clone();
972 let f: Arc<dyn Fetcher> = storage;
973 let cfg = ExternalLocationConfig::new(s, f).with_threshold_bytes(0);
974 // In-memory URL is https, so build a forged metadata entry.
975 let batch = big_batch(1);
976 let mut bogus_md = Metadata::new();
977 bogus_md.insert("vgi_rpc.location".into(), "http://not-secure/x".into());
978 let err = resolve_external_location(&batch, &bogus_md, &cfg).unwrap_err();
979 assert!(err.message.contains("https://"));
980 }
981
982 #[test]
983 fn safe_https_validator_blocks_ssrf_targets() {
984 let v = safe_https_validator();
985 // Non-https.
986 assert!(v("http://example.com/x").is_err());
987 // IP-literal internal / non-routable targets.
988 assert!(v("https://169.254.169.254/latest/meta-data/").is_err());
989 assert!(v("https://127.0.0.1/").is_err());
990 assert!(v("https://10.0.0.1/").is_err());
991 assert!(v("https://192.168.1.1/").is_err());
992 assert!(v("https://[::1]/").is_err());
993 assert!(v("https://0.0.0.0/").is_err());
994 // Hostname forms of loopback.
995 assert!(v("https://localhost/x").is_err());
996 assert!(v("https://api.localhost/x").is_err());
997 // A public IP literal is allowed.
998 assert!(v("https://1.1.1.1/x").is_ok());
999 }
1000
1001 #[test]
1002 fn sha_mismatch_is_rejected() {
1003 let storage = InMemoryStorage::new();
1004 let cfg = cfg_with(storage.clone(), 1024);
1005 let batch = big_batch(10_000);
1006 let (ptr, mut md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
1007 .unwrap()
1008 .unwrap();
1009 // Corrupt the recorded hash.
1010 if let Some(v) = md.get_mut(LOCATION_SHA256_KEY) {
1011 *v = "deadbeef".into();
1012 }
1013 let err = resolve_external_location(&ptr, &md, &cfg).unwrap_err();
1014 assert!(err.message.contains("SHA-256 mismatch"));
1015 }
1016}