blob: a24bd2af77325d72fca536353190c2c0c8fcf601 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! An [`ObjectStore`] that makes a local filesystem behave like S3 or GCS.
//!
//! Each request costs:
//!
//! 1. a connection from a pool of [`MAX_CONNECTIONS`],
//! 2. a time to first byte, drawn at random from a latency distribution,
//! 3. a transfer time at [`BYTES_PER_SECOND`] for each response body.
//!
//! The store does not override [`ObjectStore::get_ranges`]. `AmazonS3` and
//! `GoogleCloudStorage` do not override it either. The trait default merges
//! ranges that are less than 1MiB apart and sends each merged range as a
//! separate GET. Thus the simulated request count is the same as on S3. A
//! wrapper that overrides `get_ranges` charges one request where S3 charges
//! several.
//!
//! The store does not model request failures, retries or a shared network
//! limit.
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use futures::StreamExt;
use futures::stream::BoxStream;
use object_store::path::Path;
use object_store::{
CopyOptions, GetOptions, GetResult, GetResultPayload, ListResult, MultipartUpload,
ObjectMeta, ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult,
Result,
};
use rand::Rng;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tokio::time::Instant;
// The latency tables are an equal mix of S3 and GCS. Each table has 10 values
// from S3 and 10 values from GCS, at the 5%, 15%, ..., 95% quantiles of each.
// Thus one draw is S3-like or GCS-like with equal probability.
//
// Measured on 2026-10-13 with the `object_store` clients, from a VM in the
// same region as the bucket:
// - S3: c7i.2xlarge in us-east-2. GCS: e2-standard-8 in europe-west1.
// - S3 is approximately 3x faster to the first byte than GCS.
/// Time to first byte of a range GET.
/// S3: P50≈24ms, P95≈30ms. GCS: P50≈75ms, P95≈210ms.
const GET_TTFB_MS: &[u64] = &[
19, 21, 22, 23, 24, 25, 26, 27, 29, 35, // S3
30, 45, 55, 65, 75, 90, 110, 130, 165, 230, // GCS
];
/// Time to first byte of a HEAD.
/// S3: P50≈11ms, P95≈16ms. GCS: P50≈18ms, P95≈28ms.
const HEAD_TTFB_MS: &[u64] = &[
8, 9, 10, 10, 11, 11, 12, 12, 13, 16, // S3
13, 15, 16, 17, 18, 18, 19, 21, 23, 28, // GCS
];
/// Time to first byte of one LIST page.
/// S3: P50≈50ms, P95≈60ms. GCS: P50≈83ms, P95≈170ms.
const LIST_TTFB_MS: &[u64] = &[
40, 45, 47, 48, 49, 50, 51, 52, 54, 60, // S3
50, 62, 70, 75, 80, 86, 95, 106, 130, 200, // GCS
];
/// Throughput of one connection. For a 64MiB range GET, the measured median
/// after the time to first byte is approximately 100MiB/s on S3 and 80MiB/s
/// on GCS.
pub const BYTES_PER_SECOND: u64 = 90 * 1024 * 1024;
/// Maximum number of requests in flight.
pub const MAX_CONNECTIONS: usize = 128;
/// Maximum keys in one LIST page. S3 and GCS both use 1000.
const LIST_PAGE_SIZE: usize = 1000;
#[derive(Debug, Clone, Copy)]
struct Costs {
get_ttfb_ms: &'static [u64],
head_ttfb_ms: &'static [u64],
list_ttfb_ms: &'static [u64],
bytes_per_second: u64,
list_page_size: usize,
}
/// Presents `T` (usually a `LocalFileSystem`) as remote object storage. See
/// the [module docs](self).
#[derive(Debug)]
pub struct SimulatedObjectStore<T: ObjectStore> {
inner: T,
costs: Costs,
connections: Arc<Semaphore>,
}
impl<T: ObjectStore> SimulatedObjectStore<T> {
pub fn new(inner: T) -> Self {
let costs = Costs {
get_ttfb_ms: GET_TTFB_MS,
head_ttfb_ms: HEAD_TTFB_MS,
list_ttfb_ms: LIST_TTFB_MS,
bytes_per_second: BYTES_PER_SECOND,
list_page_size: LIST_PAGE_SIZE,
};
Self {
inner,
costs,
connections: Arc::new(Semaphore::new(MAX_CONNECTIONS)),
}
}
/// Charges one LIST request for each page of `keys`.
fn paged(
&self,
keys: BoxStream<'static, Result<ObjectMeta>>,
) -> BoxStream<'static, Result<ObjectMeta>> {
let costs = self.costs;
let connections = Arc::clone(&self.connections);
futures::stream::unfold((keys, 0usize), move |(mut keys, seen)| {
let connections = Arc::clone(&connections);
async move {
let key = keys.next().await;
// Each page needs the token from the previous page, thus pages
// are serial. An empty listing also costs one request.
let new_page = seen % costs.list_page_size == 0;
if new_page && (key.is_some() || seen == 0) {
let _permit = round_trip(&connections, costs.list_ttfb_ms).await;
}
Some((key?, (keys, seen + 1)))
}
})
.boxed()
}
}
/// Waits for a free connection and for the time to first byte. The request
/// keeps the connection until it drops the returned permit.
async fn round_trip(
connections: &Arc<Semaphore>,
ttfb_ms: &[u64],
) -> OwnedSemaphorePermit {
let permit = Arc::clone(connections)
.acquire_owned()
.await
.expect("connection pool is never closed");
let idx = rand::rng().random_range(0..ttfb_ms.len());
tokio::time::sleep(Duration::from_millis(ttfb_ms[idx])).await;
permit
}
fn transfer_time(bytes: u64, bytes_per_second: u64) -> Duration {
Duration::from_secs_f64(bytes as f64 / bytes_per_second as f64)
}
/// Charges transfer time while the caller reads the body. The connection stays
/// in use until the body is fully read or dropped.
fn throttle(
result: GetResult,
permit: OwnedSemaphorePermit,
bytes_per_second: u64,
) -> GetResult {
let meta = result.meta.clone();
let range = result.range.clone();
let attributes = result.attributes.clone();
let extensions = result.extensions.clone();
// The deadline is absolute, so time spent on the local read and timer
// overshoot do not add to the simulated transfer time. The clock starts
// when the caller first reads the body.
let body = futures::stream::unfold(
(result.into_stream(), None, 0u64, permit),
move |(mut body, start, mut sent, permit)| async move {
let start = start.unwrap_or_else(Instant::now);
let chunk = body.next().await?;
if let Ok(bytes) = &chunk {
sent += bytes.len() as u64;
tokio::time::sleep_until(start + transfer_time(sent, bytes_per_second))
.await;
}
Some((chunk, (body, Some(start), sent, permit)))
},
);
GetResult {
payload: GetResultPayload::Stream(body.boxed()),
meta,
range,
attributes,
extensions,
}
}
impl<T: ObjectStore> fmt::Display for SimulatedObjectStore<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SimulatedObjectStore({})", self.inner)
}
}
/// Only reads and lists are charged. Writes go directly to `inner`, because
/// benchmarks do not measure them.
#[async_trait]
impl<T: ObjectStore> ObjectStore for SimulatedObjectStore<T> {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
opts: PutOptions,
) -> Result<PutResult> {
self.inner.put_opts(location, payload, opts).await
}
async fn put_multipart_opts(
&self,
location: &Path,
opts: PutMultipartOptions,
) -> Result<Box<dyn MultipartUpload>> {
self.inner.put_multipart_opts(location, opts).await
}
async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
let ttfb_ms = match options.head {
true => self.costs.head_ttfb_ms,
false => self.costs.get_ttfb_ms,
};
let permit = round_trip(&self.connections, ttfb_ms).await;
let result = self.inner.get_opts(location, options).await?;
// A HEAD result has a range that covers the full object, but the
// caller never reads its body, so it pays no transfer time.
Ok(throttle(result, permit, self.costs.bytes_per_second))
}
fn delete_stream(
&self,
locations: BoxStream<'static, Result<Path>>,
) -> BoxStream<'static, Result<Path>> {
self.inner.delete_stream(locations)
}
fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
self.paged(self.inner.list(prefix))
}
// S3 and GCS override this to start the listing at `offset` on the server.
// The trait default lists all keys and then filters them.
fn list_with_offset(
&self,
prefix: Option<&Path>,
offset: &Path,
) -> BoxStream<'static, Result<ObjectMeta>> {
self.paged(self.inner.list_with_offset(prefix, offset))
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
let result = self.inner.list_with_delimiter(prefix).await?;
let entries = result.objects.len() + result.common_prefixes.len();
let pages = entries.div_ceil(self.costs.list_page_size).max(1);
for _ in 0..pages {
let _permit = round_trip(&self.connections, self.costs.list_ttfb_ms).await;
}
Ok(result)
}
async fn copy_opts(
&self,
from: &Path,
to: &Path,
options: CopyOptions,
) -> Result<()> {
self.inner.copy_opts(from, to, options).await
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use bytes::Bytes;
use object_store::memory::InMemory;
use object_store::{ObjectStoreExt, PutPayload};
use super::*;
/// Records the range of each GET that reaches the inner store. On S3, each
/// one is a separate HTTP request.
#[derive(Debug, Default)]
struct Recording {
inner: InMemory,
gets: Mutex<Vec<std::ops::Range<u64>>>,
}
impl fmt::Display for Recording {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Recording")
}
}
#[async_trait]
impl ObjectStore for Recording {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
opts: PutOptions,
) -> Result<PutResult> {
self.inner.put_opts(location, payload, opts).await
}
async fn put_multipart_opts(
&self,
location: &Path,
opts: PutMultipartOptions,
) -> Result<Box<dyn MultipartUpload>> {
self.inner.put_multipart_opts(location, opts).await
}
async fn get_opts(
&self,
location: &Path,
options: GetOptions,
) -> Result<GetResult> {
let result = self.inner.get_opts(location, options).await?;
self.gets.lock().unwrap().push(result.range.clone());
Ok(result)
}
fn delete_stream(
&self,
locations: BoxStream<'static, Result<Path>>,
) -> BoxStream<'static, Result<Path>> {
self.inner.delete_stream(locations)
}
fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
self.inner.list(prefix)
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
self.inner.list_with_delimiter(prefix).await
}
async fn copy_opts(
&self,
from: &Path,
to: &Path,
options: CopyOptions,
) -> Result<()> {
self.inner.copy_opts(from, to, options).await
}
}
const TTFB_MS: u64 = 10;
const HEAD_TTFB_MS: u64 = 3;
/// A store with constant times to first byte, 1MiB/s and 3 keys per page.
async fn store(objects: &[(&str, usize)]) -> SimulatedObjectStore<Recording> {
let inner = Recording::default();
for (name, len) in objects {
let payload = PutPayload::from(vec![7u8; *len]);
inner.put(&Path::from(*name), payload).await.unwrap();
}
SimulatedObjectStore {
inner,
costs: Costs {
get_ttfb_ms: &[TTFB_MS],
head_ttfb_ms: &[HEAD_TTFB_MS],
list_ttfb_ms: &[TTFB_MS],
bytes_per_second: 1024 * 1024,
list_page_size: 3,
},
connections: Arc::new(Semaphore::new(MAX_CONNECTIONS)),
}
}
fn ms(elapsed: Duration) -> u64 {
elapsed.as_millis() as u64
}
#[tokio::test(start_paused = true)]
async fn distant_ranges_are_separate_requests() {
let store = store(&[("f", 8 << 20)]).await;
let ranges = [0..1024, (3 << 20)..(3 << 20) + 1024];
let data = store.get_ranges(&Path::from("f"), &ranges).await.unwrap();
assert_eq!(data, vec![Bytes::from(vec![7u8; 1024]); 2]);
assert_eq!(store.inner.gets.lock().unwrap().len(), 2);
}
#[tokio::test(start_paused = true)]
async fn near_ranges_are_one_request() {
let store = store(&[("f", 8 << 20)]).await;
store
.get_ranges(&Path::from("f"), &[0..1024, 2048..4096])
.await
.unwrap();
assert_eq!(*store.inner.gets.lock().unwrap(), vec![0..4096]);
}
#[tokio::test(start_paused = true)]
async fn get_costs_ttfb_plus_transfer() {
let store = store(&[("f", 1 << 20)]).await;
let start = Instant::now();
store
.get_range(&Path::from("f"), 0..256 * 1024)
.await
.unwrap();
// 10ms TTFB plus 256KiB at 1MiB/s.
assert_eq!(ms(start.elapsed()), TTFB_MS + 250);
}
#[tokio::test(start_paused = true)]
async fn head_pays_only_the_head_ttfb() {
let store = store(&[("f", 1 << 20)]).await;
let start = Instant::now();
store.head(&Path::from("f")).await.unwrap();
assert_eq!(ms(start.elapsed()), HEAD_TTFB_MS);
}
#[tokio::test(start_paused = true)]
async fn list_costs_one_request_per_page() {
for (keys, pages) in [(0, 1), (1, 1), (3, 1), (4, 2), (6, 2), (7, 3)] {
let names: Vec<_> = (0..keys).map(|i| format!("k{i}")).collect();
let objects: Vec<_> = names.iter().map(|n| (n.as_str(), 1)).collect();
let store = store(&objects).await;
let start = Instant::now();
let listed: Vec<_> = store.list(None).collect().await;
assert_eq!(listed.len(), keys);
assert_eq!(ms(start.elapsed()), pages * TTFB_MS, "{keys} keys");
}
}
#[tokio::test(start_paused = true)]
async fn list_with_offset_skips_earlier_pages() {
let names: Vec<_> = (0..9).map(|i| format!("k{i}")).collect();
let objects: Vec<_> = names.iter().map(|n| (n.as_str(), 1)).collect();
let store = store(&objects).await;
let start = Instant::now();
let listed: Vec<_> = store
.list_with_offset(None, &Path::from("k5"))
.collect()
.await;
// k6, k7 and k8 fit in one page.
assert_eq!(listed.len(), 3);
assert_eq!(ms(start.elapsed()), TTFB_MS);
}
/// A Parquet file with 8 columns. Random `i64` values do not compress, so
/// each column chunk is larger than 1MiB and is not merged with the next.
fn wide_parquet() -> Bytes {
use arrow::array::{ArrayRef, Int64Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use rand::{SeedableRng, rngs::StdRng};
let mut rng = StdRng::seed_from_u64(42);
let fields: Vec<_> = (0..8)
.map(|i| Field::new(format!("c{i}"), DataType::Int64, false))
.collect();
let schema = Arc::new(Schema::new(fields));
let arrays: Vec<ArrayRef> = (0..8)
.map(|_| {
let values: Vec<i64> = (0..200_000).map(|_| rng.random()).collect();
Arc::new(Int64Array::from(values)) as ArrayRef
})
.collect();
let batch = RecordBatch::try_new(Arc::clone(&schema), arrays).unwrap();
let mut buf = Vec::new();
let mut writer = ArrowWriter::try_new(&mut buf, schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
Bytes::from(buf)
}
/// Returns the number of GET requests that the scan in `sql` sends.
async fn gets_for_query(sql: &str) -> usize {
use datafusion::execution::object_store::ObjectStoreUrl;
use datafusion::prelude::{ParquetReadOptions, SessionContext};
let store = Arc::new(store(&[]).await);
store
.put(&Path::from("t.parquet"), wide_parquet().into())
.await
.unwrap();
let ctx = SessionContext::new();
let url = ObjectStoreUrl::parse("mem://").unwrap();
ctx.register_object_store(url.as_ref(), Arc::clone(&store) as _);
ctx.register_parquet("t", "mem:///t.parquet", ParquetReadOptions::default())
.await
.unwrap();
// Do not count the reads of schema inference.
store.inner.gets.lock().unwrap().clear();
ctx.sql(sql).await.unwrap().collect().await.unwrap();
store.inner.gets.lock().unwrap().len()
}
#[tokio::test(start_paused = true)]
async fn distant_column_chunks_cost_more_requests() {
// DataFusion reads both column chunks with one `get_ranges` call. On
// S3 that is two requests, because the chunks are far apart.
let one = gets_for_query("SELECT sum(c0) FROM t").await;
let two = gets_for_query("SELECT sum(c0), sum(c7) FROM t").await;
assert_eq!(two, one + 1);
}
}