This is an automated email from the ASF dual-hosted git repository. Xuanwo pushed a commit to branch xuanwo/move-read-range-to-reader in repository https://gitbox.apache.org/repos/asf/opendal.git
commit 4c806eff0587fabe8501f316925e843a26ebc05a Author: Xuanwo <[email protected]> AuthorDate: Mon Jun 1 17:42:41 2026 +0800 docs: Add RFC for moving read range to reader --- .../docs/rfcs/0000_move_read_range_to_reader.md | 485 +++++++++++++++++++++ core/core/src/docs/rfcs/mod.rs | 4 + 2 files changed, 489 insertions(+) diff --git a/core/core/src/docs/rfcs/0000_move_read_range_to_reader.md b/core/core/src/docs/rfcs/0000_move_read_range_to_reader.md new file mode 100644 index 000000000..db30140fc --- /dev/null +++ b/core/core/src/docs/rfcs/0000_move_read_range_to_reader.md @@ -0,0 +1,485 @@ +- Proposal Name: `move_read_range_to_reader` +- Start Date: 2026-06-01 +- RFC PR: N/A +- Tracking Issue: N/A + +# Summary + +Change OpenDAL's raw `Access::read` contract so it returns a reusable raw +reader for a path and read condition set. Byte ranges move from `Access::read` +arguments to methods on the returned reader. + +Today `Access::read(path, OpRead { range, .. })` opens one concrete range stream. +This matches HTTP object stores, where every range read is a request, but it is a +poor fit for handle based services such as `fs`, `hdfs`, `sftp`, and +`monoiofs`. Those services can keep an opened handle and execute multiple +range reads against it, but the current raw API forces the core reader planner to +call `Access::read` again for every planned range. + +This RFC keeps the `Access::read` operation, but changes what the returned +reader means: + +- `Access::read(path, OpRead)` creates a reusable raw `oio::Read`. +- `oio::Read::stream(range)` creates a range-scoped byte stream. +- `oio::Read::read(range)` reads one range into a `Buffer`. +- `oio::Read::fetch(ranges)` reads multiple ranges and returns buffers in input + order. + +The public `Operator::read`, `Operator::reader`, `Reader::read`, +`Reader::fetch`, and stream conversion APIs keep their existing user-facing +shape. The change is in the raw contract and the internal execution plan. + +# Motivation + +OpenDAL's public `Reader` is already range based and cloneable. Users can call: + +```rust +let reader = op.reader("path/to/file").await?; + +let head = reader.read(0..1024).await?; +let tail = reader.read(4096..8192).await?; +let parts = reader.fetch(vec![0..1024, 4096..8192]).await?; +``` + +However, the current implementation plans these public calls by generating new +raw range streams. `ReadGenerator::next_reader` and `ChunkedReader` both call +`Access::read(path, args.with_range(range))` for every range. For `s3`, `gcs`, +and `azblob`, this is natural: each range is a new HTTP request. For `fs` and +`hdfs`, this means every range repeats open and seek work even though the target +data can be represented by a reusable handle. + +The root cause is that `Access::read` is currently modeled as a transport-level +range request. OpenDAL needs a storage-neutral raw model: + +- Object stores can implement a raw reader that issues a ranged request on + each `stream(range)`. +- File-like services can implement a raw reader that opens once lazily and + uses positioned reads for `stream`, `read`, and `fetch`. +- In-memory or database-backed services can implement a raw reader that + slices cached values directly. + +This keeps the public range-based reader model from RFC-4382 while removing the +S3-specific assumption from the raw API. + +# Guide-level explanation + +Users do not need a new API. + +Single reads still work: + +```rust +let data = op.read("path/to/file").await?; +let part = op.read_with("path/to/file").range(1024..2048).await?; +``` + +Reusable readers still work: + +```rust +let reader = op.reader("path/to/file").await?; + +let first = reader.read(0..1024).await?; +let second = reader.read(4096..8192).await?; +let ranges = reader.fetch(vec![0..1024, 4096..8192]).await?; +``` + +The difference is internal. OpenDAL creates one reusable raw reader for +`reader("path/to/file")`. After that, range operations are executed by this raw +reader instead of reopening through `Access::read` for every range. + +For HTTP object stores, the raw reader stores the path and read arguments, then +sends a new ranged request for each range. Behavior and cost stay the same. + +For handle based services, the raw reader may lazily open the file handle once +and reuse it for subsequent range reads. For example, `fs` can use positioned +reads against a shared file handle, and `hdfs` can use `pread`-style APIs. + +# Reference-level explanation + +## Raw read contract + +`Access::read` remains the raw read operation: + +```rust,ignore +pub trait Access: Send + Sync + Debug + Unpin + 'static { + type Reader: oio::Read; + + fn read( + &self, + path: &str, + args: OpRead, + ) -> impl Future<Output = Result<(RpRead, Self::Reader)>> + MaybeSend; +} +``` + +The meaning changes: + +- `path` and `OpRead` select a path and read condition set. +- The returned `Self::Reader` is a reusable raw read executor for that path and + condition set. +- No range is selected by `Access::read`. + +`OpRead` should no longer contain `BytesRange`: + +```rust,ignore +pub struct OpRead { + if_match: Option<String>, + if_none_match: Option<String>, + if_modified_since: Option<Timestamp>, + if_unmodified_since: Option<Timestamp>, + override_content_type: Option<String>, + override_cache_control: Option<String>, + override_content_disposition: Option<String>, + version: Option<String>, +} +``` + +`OpReader` remains an internal execution policy used by OpenDAL's public +`Reader`. It controls `concurrent`, `chunk`, `gap`, `prefetch`, and +`content_length_hint`. It must not become part of the backend `Access::read` +contract. + +`ReadOptions` and `ReaderOptions` should be converted differently: + +```rust,ignore +impl From<options::ReadOptions> for (OpRead, BytesRange, OpReader); +impl From<options::ReaderOptions> for (OpRead, OpReader); +``` + +## Reader and range stream split + +The current `oio::Read` is a range-scoped byte stream. It should be renamed to a +stream concept: + +```rust,ignore +pub type ReadStream = Box<dyn ReadStreamDyn>; + +pub trait ReadStream: Unpin + Send + Sync { + fn read(&mut self) -> impl Future<Output = Result<Buffer>> + MaybeSend; + + fn read_all(&mut self) -> impl Future<Output = Result<Buffer>> + MaybeSend { + async { + let mut bufs = vec![]; + loop { + match self.read().await { + Ok(buf) if buf.is_empty() => break, + Ok(buf) => bufs.push(buf), + Err(err) => return Err(err), + } + } + Ok(bufs.into_iter().flatten().collect()) + } + } +} +``` + +The new `oio::Read` becomes the reusable raw reader: + +```rust,ignore +pub type Reader = Arc<dyn ReadDyn>; + +pub trait Read: Send + Sync + Debug + Unpin + 'static { + fn stream( + &self, + range: BytesRange, + ) -> impl Future<Output = Result<(RpRead, ReadStream)>> + MaybeSend; + + fn read( + &self, + range: BytesRange, + ) -> impl Future<Output = Result<(RpRead, Buffer)>> + MaybeSend { + async { + let (rp, mut stream) = self.stream(range).await?; + let buffer = stream.read_all().await?; + Ok((rp, buffer)) + } + } + + fn fetch( + &self, + ranges: Vec<BytesRange>, + ) -> impl Future<Output = Result<(RpRead, Vec<Buffer>)>> + MaybeSend { + async { + let mut observed = RpRead::default(); + let mut out = Vec::with_capacity(ranges.len()); + + for range in ranges { + let (rp, buffer) = self.read(range).await?; + observed.merge_if_absent(rp); + out.push(buffer); + } + + Ok((observed, out)) + } + } +} +``` + +The exact object-safe `ReadDyn` and `ReadStreamDyn` forms should follow the +existing `oio::Read` / `ReadDyn` pattern. + +## `fetch` semantics + +`oio::Read::fetch` is a batch range primitive for one raw reader. + +Semantics: + +- `ranges` are read from the same path and read condition set. +- The returned `Vec<Buffer>` has the same length and order as the input ranges. +- `fetch(vec![])` returns an empty vector and should not perform storage I/O. +- `fetch` must not apply public `gap` merging. The public `Reader` is + responsible for deciding which ranges should be read. +- `fetch` must not own public `concurrent` or `prefetch` policy. The public + `Reader` is responsible for scheduling calls into the raw reader. +- `RpRead` represents read response metadata observed while serving the batch. + It is not per-range metadata. + +The public `Reader::fetch` can keep its existing behavior: + +1. Merge requested ranges according to `OpReader::gap`. +2. Split the merged ranges into execution batches according to + `OpReader::concurrent` and `OpReader::prefetch`. +3. Execute `raw_reader.fetch(batch)` for each batch. +4. Slice merged buffers back into the original requested ranges. + +This preserves OpenDAL's current execution policy while allowing native readers +to optimize each batch. Backends that do not provide a specialized +implementation get the default `fetch` implementation based on repeated +`read(range)` calls. + +## Public operator path + +`Operator::read_options` should split public options into read args, range, +and execution policy: + +```rust,ignore +async fn read_inner(acc: Accessor, path: String, opts: ReadOptions) -> Result<Buffer> { + let (op_read, range, op_reader) = opts.into(); + let (rp, raw_reader) = acc.read(&path, op_read).await?; + let reader = Reader::new(acc, path, raw_reader, op_reader, rp); + reader.read(range.to_range()).await +} +``` + +`Operator::reader_options` should create the reusable raw reader once: + +```rust,ignore +async fn reader_inner(acc: Accessor, path: String, opts: ReaderOptions) -> Result<Reader> { + let (op_read, op_reader) = opts.into(); + let (rp, raw_reader) = acc.read(&path, op_read).await?; + Ok(Reader::new(acc, path, raw_reader, op_reader, rp)) +} +``` + +For services where `Access::read` currently performs remote I/O, the returned +raw reader should be lazy. This preserves the lazy reader behavior: creating +`op.reader(path)` should not require opening the underlying data request. + +## Read context + +`ReadContext` should hold the reusable raw reader: + +```rust,ignore +pub struct ReadContext { + acc: Accessor, + path: String, + args: OpRead, + options: OpReader, + reader: oio::Reader, + metadata: OnceLock<Metadata>, +} +``` + +`acc`, `path`, and `args` are still useful for planning, especially when +OpenDAL needs `stat` to resolve unbounded ranges for chunked reads or +`AsyncSeek` adapters. Actual range I/O should go through `reader.stream`, +`reader.read`, or `reader.fetch`, not through repeated `acc.read` calls. + +`Reader::metadata()` remains cache-only. The cache is updated from `RpRead` +returned by `stream`, `read`, or `fetch`. + +## Layers + +Existing layers continue to wrap `Access::read`, but the wrapping point moves +from a range stream to a reusable raw reader. + +### Type eraser + +The type eraser should erase raw readers: + +```rust,ignore +async fn read(&self, path: &str, args: OpRead) -> Result<(RpRead, oio::Reader)> { + self.inner + .read(path, args) + .await + .map(|(rp, r)| (rp, Arc::new(r) as oio::Reader)) +} +``` + +### Complete layer + +`CompleteLayer` currently reads `args.range().size()` in `Access::read` and +wraps the returned range stream. After this RFC, range size is only known when +`stream`, `read`, or `fetch` is called. + +`CompleteLayer` should wrap the raw reader and validate each returned range +body: + +- `stream(range)` wraps the returned `ReadStream` with `CompleteReadStream`. +- `read(range)` checks the returned buffer length if `range.size()` is known. +- `fetch(ranges)` checks each buffer against the corresponding range if + `range.size()` is known. + +### Error context layer + +`ErrorContextLayer` should wrap the raw reader and attach operation context +to every range operation: + +- service +- path +- range +- already-read bytes for streams + +### Correctness check layer + +`CorrectnessCheckLayer` can keep checking `read_with_*` capabilities in +`Access::read`, because those options still belong to the path and read +condition set. + +## Presign read + +Presigned reads still describe one concrete request, so they still need a +range. This range should not move back into `OpRead`. Instead, +`PresignOperation::Read` should carry range separately: + +```rust,ignore +pub enum PresignOperation { + Read { + args: OpRead, + range: BytesRange, + }, + // ... +} +``` + +`presign_read_options` converts `ReadOptions` into `(OpRead, BytesRange, +OpReader)` and discards `OpReader`. + +## Backend implementation model + +### HTTP object stores + +Services like `s3`, `gcs`, `azblob`, `oss`, and `http` should return a reader +that stores `core`, `path`, and `OpRead`. + +Each `stream(range)` sends the same request that the current `Access::read` +sends today. For example, S3's current logic: + +```rust,ignore +let resp = self.core.s3_get_object(path, args.range(), &args).await?; +``` + +becomes reader logic: + +```rust,ignore +let resp = self.core.s3_get_object(&self.path, range, &self.args).await?; +``` + +This keeps object-store behavior unchanged. + +### Memory and database-like services + +Services that already have the data content in memory can store the content or +lookup key in their raw reader. `read(range)` and `fetch(ranges)` can slice +buffers directly. + +### Handle based services + +`fs`, `hdfs`, `hdfs-native`, `sftp`, and `monoiofs` can reuse opened handles: + +- `fs`: lazily open a file and use platform positioned reads. +- `hdfs`: lazily open a file and use `pread`-style APIs. +- `hdfs-native`: store `FileReader` and use `read_range` / + `read_range_stream`. +- `monoiofs`: reuse its existing worker model that already performs + `read_at`. +- `sftp`: keep the connection and file handle in the raw reader. + +These readers can implement `fetch` by executing multiple positioned reads +against the same handle. + +# Drawbacks + +- This is a breaking raw API change. Third-party `Access` implementations must + be updated. +- It touches many services because every `Access::read` implementation currently + receives a range. +- The rename from range-scoped `oio::Read` to `oio::ReadStream` is large but + necessary to avoid overloading one trait with two meanings. +- Some services may need extra lazy state to preserve the current + `op.reader(path)` behavior. + +# Rationale and alternatives + +## Add `Access::open_read_at` + +Rejected. It adds a parallel backend operation for an execution optimization. +OpenDAL's raw operation surface should stay centered on semantic operations: +`stat`, `read`, `write`, `list`, `delete`, `copy`, and `rename`. + +## Add a public capability for `read_at` + +Rejected. Users do not get a new public operation. Whether a backend reuses an +opened handle is an internal execution detail. Public `Capability` should only +describe user-visible behavior. + +## Add optional `read_at` to range streams + +Rejected. A range stream is already scoped to one byte range. Deriving other +ranges from it gives one stream two responsibilities and makes layer wrapping +harder. The reusable raw reader should be the place that knows how to execute +additional ranges. + +## Add `ReadPlan` beside `Access::read` + +Rejected. It improves execution planning but keeps the old range-scoped +`Access::read` as the primary contract. This RFC fixes the raw contract instead +of adding a second planning hook. + +# Prior art + +## RFC-4382 Range Based Read API + +RFC-4382 moved OpenDAL's public `Reader` toward stateless range reads and called +out future `read_ranges` and native `read_at` support. This RFC keeps that +public direction but changes the raw contract so raw readers can implement +those optimizations without repeated `Access::read` calls. + +## object_store + +`object_store` exposes both single-range and multi-range reads through +`get_range` and `get_ranges`. OpenDAL's public `Reader::fetch` serves a similar +user-facing purpose. This RFC places the raw batch primitive on the raw +reader, where both object-store requests and file-handle positioned reads can be +implemented. + +# Unresolved questions + +## Fetch batching policy + +The initial implementation can preserve current public behavior by batching +merged ranges according to `OpReader::concurrent`. We should benchmark whether +the default policy should prefer one large `raw.fetch` call, several batches, or +one task per merged range for different backend classes. + +## Native preferred read size + +This RFC keeps `OpReader` as OpenDAL's execution policy and does not introduce +backend preferred read sizes. A later RFC can revisit service-provided execution +hints if we have enough benchmark evidence. + +# Future possibilities + +- Native multi-range HTTP requests for services that support them. +- Platform-specific positioned read implementations for `fs`. +- Better `fetch` batching heuristics based on workload and service class. +- Integration with future completion-based I/O support. diff --git a/core/core/src/docs/rfcs/mod.rs b/core/core/src/docs/rfcs/mod.rs index eacc9e17f..72c723efe 100644 --- a/core/core/src/docs/rfcs/mod.rs +++ b/core/core/src/docs/rfcs/mod.rs @@ -21,6 +21,10 @@ #[doc = include_str!("0000_example.md")] pub mod rfc_0000_example {} +/// Move Read Range To Reader +#[doc = include_str!("0000_move_read_range_to_reader.md")] +pub mod rfc_0000_move_read_range_to_reader {} + /// Object native API #[doc = include_str!("0041_object_native_api.md")] pub mod rfc_0041_object_native_api {}
