docs(internode): align transport adapter scope (#3064)

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
This commit is contained in:
Henry Guo
2026-05-23 00:27:27 +08:00
committed by GitHub
parent c9f0f25f55
commit 2786a4734a
6 changed files with 447 additions and 88 deletions
@@ -0,0 +1,507 @@
# RFC: Internode Transport Adapter Boundary
> Status: draft
> Last updated: 2026-05-22
> Scope: OSS internode data-plane adapter analysis, benchmark baseline, and
> transport boundary
## Summary
The current distributed internode paths use TCP-based HTTP/gRPC transports:
- `tonic` gRPC `NodeService` for most control, metadata, lock, health, and
peer operations.
- HTTP streaming routes under `/rustfs/rpc/` for remote disk file streams.
This document frames the existing work as an OSS `InternodeDataTransport`
adapter boundary. The adapter keeps RustFS data-plane logic separate from the
concrete transport backend while preserving the current TCP/HTTP behavior as
the default implementation.
Current implementation status:
- `InternodeDataTransport` exists in
`crates/ecstore/src/rpc/internode_data_transport.rs`.
- The default and only production backend is `tcp-http`; `tcp` is accepted as
an alias.
- `RUSTFS_INTERNODE_DATA_TRANSPORT` selects the backend. Blank or unset values
use `tcp-http`; invalid values fail closed.
- `RemoteDisk::read_file_stream`, `RemoteDisk::create_file`,
`RemoteDisk::append_file`, and `RemoteDisk::walk_dir` delegate to the
transport.
- `NodeService` gRPC remains the internode control plane and continues to carry
metadata/control operations.
Related design notes in this directory:
- `transport-capabilities.md`
- `transport-buffer-lifecycle.md`
- `transport-buffer-contract.md`
- `transport-fallback-and-selection.md`
## Open-source Scope
The OSS scope is:
- define a clear `InternodeDataTransport` adapter boundary;
- keep `tcp-http` as the default backend;
- keep existing TCP/HTTP behavior unchanged;
- keep internode data-plane behavior observable through metrics and baseline
tooling;
- document buffer ownership, fallback, and capability expectations for
maintainable transport code;
- avoid hardware-specific dependencies or backend implementations.
The OSS scope is not:
- RDMA support;
- DPU support;
- DOCA support;
- BlueField support;
- RoCE/InfiniBand support;
- hardware benchmark planning;
- hardware-specific backend implementation.
Hardware-specific transport experiments are outside the scope of this OSS
document.
## Goals
- Document the current internode control plane and data plane.
- Identify the existing transfer paths covered by the
`InternodeDataTransport` adapter and the paths that remain on gRPC.
- Define the minimum benchmark baseline required before transport changes.
- Sketch a pluggable transport boundary that preserves the current TCP/HTTP
behavior as the default backend.
- Document backend-neutral capability, fallback, buffer ownership, and
observability expectations.
## Non-Goals
- Implement RDMA, RoCE, InfiniBand, DPU, DOCA, DPDK, SPDK, or SmartNIC support.
- Replace `tonic` gRPC for control-plane RPCs.
- Redesign erasure coding, quorum handling, disk health tracking, or object
correctness semantics.
- Require specialized hardware for default development, CI, or ordinary RustFS
deployments.
## Current Internode Architecture
### Server-side entry points
The main HTTP server builds a hybrid service per connection:
- `rustfs/src/server/http.rs` wires a `NodeServiceServer` for gRPC.
- `rustfs/src/storage/rpc/InternodeRpcService` intercepts HTTP paths under
`/rustfs/rpc/`.
- Other HTTP/S3 traffic continues through the normal S3 service.
Compression logic already treats `/rustfs/rpc/` and `/rustfs/peer/` as internode
RPC paths and skips normal response compression for them.
### gRPC channel management
`crates/protos/src/lib.rs` creates internode gRPC channels with `tonic`
`Endpoint`:
- connect timeout
- TCP keepalive
- HTTP/2 keepalive interval and timeout
- request timeout
- optional TLS configuration
- global channel caching and failed-connection eviction
This confirms the current gRPC transport is TCP/HTTP2-based.
### NodeService layout
`crates/protos/src/node.proto` defines one `NodeService` that mixes several
classes of RPCs:
- meta service: bucket and metadata operations
- disk service: local/remote disk operations
- lock service: distributed lock operations
- peer rest service: node health, metrics, IAM/policy reload, rebalance,
profiling, events, and admin-style operations
The service layout is practical today, but it is too broad to become the
transport adapter surface. A pluggable data transport should target only disk
data streams and keep this gRPC service as the control plane.
## Control Plane vs Data Plane
### Control plane
These paths carry coordination, metadata, health, and administrative state.
They should remain on gRPC/TCP:
| Area | Client/server code | Examples | Notes |
| --- | --- | --- | --- |
| Bucket peer ops | `crates/ecstore/src/rpc/peer_s3_client.rs`, `rustfs/src/storage/rpc/bucket.rs` | `MakeBucket`, `ListBucket`, `DeleteBucket`, `GetBucketInfo`, `HealBucket` | Small metadata/control payloads. |
| Locking | `crates/ecstore/src/rpc/remote_locker.rs`, `rustfs/src/storage/rpc/lock.rs` | `Lock`, `UnLock`, `Refresh`, batch lock/unlock | Latency-sensitive but not bulk data; correctness and timeout semantics matter more than transport bandwidth. |
| Peer/admin state | `crates/ecstore/src/rpc/peer_rest_client.rs`, `rustfs/src/storage/rpc/health.rs`, `metrics.rs`, `event.rs` | `LocalStorageInfo`, `ServerInfo`, `GetMetrics`, `GetLiveEvents`, reload APIs, rebalance APIs | Operational control plane. |
| Disk metadata/control | `crates/ecstore/src/rpc/remote_disk.rs`, `rustfs/src/storage/rpc/disk.rs` | `DiskInfo`, `ReadXL`, `ReadVersion`, `ReadMetadata`, `WriteMetadata`, `RenameFile`, `RenamePart`, `Delete*`, `VerifyFile`, `CheckParts` | Usually metadata, integrity checks, or namespace mutations. |
| Connection health | `RemoteDisk`, `RemotePeerS3Client`, `PeerRestClient` | TCP connectivity probes and fault/recovery state | Must remain available even if an optional data backend is unavailable. |
### Data plane candidates
These paths move object shard bytes or stream potentially large disk data and
are the only reasonable first candidates for a pluggable transport.
| Priority | Path | Current client | Current server | Current transport | Why it matters |
| --- | --- | --- | --- | --- | --- |
| P0 | `read_file_stream` | `RemoteDisk::read_file_stream` | `handle_read_file` in `http_service.rs` | HTTP `GET /rustfs/rpc/read_file_stream` with a streaming response body | Main remote disk read stream used by bitrot readers and erasure reads. |
| P0 | `put_file_stream` | `RemoteDisk::create_file` and `RemoteDisk::append_file` | `handle_put_file` in `http_service.rs` | HTTP `PUT /rustfs/rpc/put_file_stream` with a streaming request body | Main remote disk write stream used by bitrot writers and erasure writes. |
| P1 | `walk_dir` | `RemoteDisk::walk_dir` | `handle_walk_dir` in `http_service.rs` | HTTP `GET /rustfs/rpc/walk_dir` with a streamed metadata listing | Can be high-volume during scans/healing, but it is metadata-oriented rather than object byte data. |
| P1 | `ReadAll` / `WriteAll` | `RemoteDisk::read_all` / `write_all` | gRPC unary disk handlers | gRPC unary `bytes` payload | Moves bytes today, but should be measured before treating it as a high-throughput data path. |
| P2 | proto `WriteStream` / `ReadAt` | currently not used | currently returns unimplemented | gRPC streaming definitions exist but are not implemented | Possible future API shape, not a current production path. |
## P1 Data Path Inventory
Classification:
- Covered by `InternodeDataTransport`: `RemoteDisk` opens the transfer through
the transport abstraction.
- Still direct TCP/HTTP/gRPC: bytes move over a fixed internode protocol
outside the transport abstraction.
- Metadata/control-plane only: payloads are expected to be small metadata,
namespace, lock, health, or admin messages.
- Not relevant: declared or test-only paths that are not current production
data paths.
### Covered by `InternodeDataTransport`
| Path | Owner references | Server references | Classification | Notes |
| --- | --- | --- | --- | --- |
| Remote shard read stream | `crates/ecstore/src/rpc/remote_disk.rs::RemoteDisk::read_file_stream`; `crates/ecstore/src/rpc/internode_data_transport.rs::InternodeDataTransport::open_read`; `crates/ecstore/src/bitrot.rs::create_bitrot_reader` | `rustfs/src/storage/rpc/http_service.rs::handle_read_file` | Covered by `InternodeDataTransport` | Object GET, repair reads, and erasure decode use this path for remote shard bytes. |
| Remote shard write stream | `RemoteDisk::create_file`; `RemoteDisk::append_file`; `InternodeDataTransport::open_write`; `crates/ecstore/src/bitrot.rs::create_bitrot_writer` | `rustfs/src/storage/rpc/http_service.rs::handle_put_file` | Covered by `InternodeDataTransport` | Object PUT and multipart part upload use this path for remote shard bytes. |
| Remote namespace walk stream | `RemoteDisk::walk_dir`; `InternodeDataTransport::open_walk_dir`; `crates/ecstore/src/cache_value/metacache_set.rs` walk producers | `rustfs/src/storage/rpc/http_service.rs::handle_walk_dir` | Covered by `InternodeDataTransport` | High-volume listing/scanner/heal metadata stream. It is not object byte data, but it is a large internode stream. |
| Remote zero-copy read fallback | `RemoteDisk::read_file_zero_copy` | same as remote shard read stream | Covered by `InternodeDataTransport` through `read_file_stream` | The remote path buffers the stream into `Bytes`; true zero-copy is not guaranteed for remote disks. |
### Still Direct TCP/HTTP/gRPC
| Path | Owner references | Server references | Classification | Notes |
| --- | --- | --- | --- | --- |
| `ReadAll` | `RemoteDisk::read_all`; `crates/ecstore/src/store_init.rs`; heal resume metadata readers | `rustfs/src/storage/rpc/disk.rs::handle_read_all` | Still direct gRPC | Unary `bytes` response. Currently used mostly for metadata/config files; measure before moving. |
| `WriteAll` | `RemoteDisk::write_all`; `crates/ecstore/src/store_init.rs`; heal resume metadata writers | `rustfs/src/storage/rpc/disk.rs::handle_write_all` | Still direct gRPC | Unary `bytes` request. Currently used mostly for metadata/config/checkpoint writes. |
| `ReadMultiple` | `RemoteDisk::read_multiple`; `crates/ecstore/src/set_disk/read.rs::read_multiple_files` | `rustfs/src/storage/rpc/disk.rs::handle_read_multiple` | Still direct gRPC | Returns multiple small file payloads, usually metadata/listing support. Could become large with many entries. |
| `ReadParts` | `RemoteDisk::read_parts`; `crates/ecstore/src/set_disk/read.rs::read_parts`; multipart list/complete paths | `rustfs/src/storage/rpc/disk.rs::handle_read_parts` | Still direct gRPC | Encoded `ObjectPartInfo` metadata, not object data. |
| `RenamePart` | `RemoteDisk::rename_part`; `crates/ecstore/src/set_disk/write.rs::rename_part` | `rustfs/src/storage/rpc/disk.rs::handle_rename_part` | Still direct gRPC | Carries part metadata while committing multipart data already written through stream writers. |
| `ListDir` | `RemoteDisk::list_dir`; multipart/lifecycle metadata listing callers | `rustfs/src/storage/rpc/disk.rs::handle_list_dir` | Still direct gRPC | Directory name listing, metadata/control-plane unless measured otherwise. |
| Legacy gRPC `WalkDir` | `rustfs/src/storage/rpc/node_service.rs::NodeService::walk_dir` | same file | Still direct gRPC | Server implementation remains, but current `RemoteDisk::walk_dir` uses HTTP through the transport. Keep until callers are audited or compatibility policy is set. |
### Metadata/control-plane only
| Area | Owner references | Classification | Notes |
| --- | --- | --- | --- |
| Disk metadata and namespace mutations | `RemoteDisk::{read_metadata,write_metadata,update_metadata,read_version,read_xl,rename_data,rename_file,delete*,verify_file,check_parts,disk_info}` | Metadata/control-plane only | These remain on gRPC by design. |
| Peer/bucket/admin operations | `crates/ecstore/src/rpc/{peer_s3_client.rs,peer_rest_client.rs,remote_locker.rs}` and matching `rustfs/src/storage/rpc/*` handlers | Metadata/control-plane only | Not candidates for a data-plane backend without separate measurements. |
| Store init and format operations | `crates/ecstore/src/store_init.rs` | Metadata/control-plane only | Uses `ReadAll`/`WriteAll` for small format/config objects. |
| Heal orchestration | `crates/heal/src/heal/storage.rs` and `crates/ecstore/src/set_disk.rs::heal_object` | Metadata/control-plane plus covered data reads | Heal object data reads go through `get_object_reader` and then covered shard streams; resume/checkpoint metadata uses direct gRPC disk metadata calls. |
### Not Relevant Current Paths
| Path | Owner references | Classification | Notes |
| --- | --- | --- | --- |
| Proto `Write` | `crates/protos/src/node.proto`; `rustfs/src/storage/rpc/disk.rs::handle_write` | Not relevant | Handler is unimplemented. |
| Proto `WriteStream` | `crates/protos/src/node.proto`; `rustfs/src/storage/rpc/node_service.rs::write_stream` | Not relevant | Returns `unimplemented`. |
| Proto `ReadAt` | `crates/protos/src/node.proto`; `rustfs/src/storage/rpc/node_service.rs::read_at` | Not relevant | Returns `unimplemented`. |
| E2E reliant gRPC helpers | `crates/e2e_test/src/reliant/*` | Not relevant | Test harnesses, not production internode data-path callers. |
### Current Limitations
| Risk | Limitation |
| --- | --- |
| Medium | `ReadAll` and `WriteAll` still carry unary `bytes` over gRPC. They appear metadata-oriented today, but there is no size threshold or routing policy. |
| Medium | `ReadMultiple` can aggregate many metadata files into one gRPC response. |
| Low | Legacy gRPC `WalkDir` remains implemented while `RemoteDisk::walk_dir` uses HTTP through the transport. |
| Medium | Remote `read_file_zero_copy` is a buffered read over the transport, not a remote zero-copy contract. |
| Medium | Server-side TCP HTTP route handling is outside the client-side trait. |
## Current Object Write Path
For object PUTs in distributed erasure mode, the relevant flow is:
1. Upper storage layers prepare object data and erasure metadata.
2. `SetDisks` selects local and remote disks.
3. `create_bitrot_writer` calls `disk.create_file(...)` for each shard writer.
4. For a remote disk, `RemoteDisk::create_file` delegates to
`InternodeDataTransport::open_write`.
5. `HttpWriter` sends an HTTP `PUT` to `/rustfs/rpc/put_file_stream`.
6. The remote node's `handle_put_file` opens the local file writer and copies
incoming body chunks into it.
7. `Erasure::encode` writes shards through `MultiWriter` to all selected
writers while enforcing write quorum.
This is the primary write data-plane candidate.
## Current Object Read Path
For object GETs and repair reads in distributed erasure mode, the relevant flow is:
1. `SetDisks` prepares shard readers for the selected disks.
2. `create_bitrot_reader` uses local zero-copy only when `disk.is_local()`.
3. For a remote disk, it calls `disk.read_file_stream(...)`.
4. `RemoteDisk::read_file_stream` delegates to
`InternodeDataTransport::open_read`.
5. `HttpReader` sends an HTTP `GET` to `/rustfs/rpc/read_file_stream`.
6. The remote node's `handle_read_file` opens the local disk stream and returns
it as an HTTP streaming body.
7. The erasure decoder reads from the shard streams and reconstructs the object.
This is the primary read data-plane candidate.
## Existing Metrics and Benchmark Surface
RustFS already has coarse internode metrics in `crates/io-metrics/src/internode_metrics.rs`:
- sent bytes
- received bytes
- outgoing requests
- incoming requests
- errors
- dial errors
- average dial time
These metrics are useful as a starting point. For backend comparisons, the
relevant route-level and operation-level dimensions are:
- `read_file_stream`
- `put_file_stream`
- `walk_dir`
- gRPC `ReadAll` / `WriteAll`
- gRPC control-plane request volume
Existing benchmark assets:
- `scripts/run_object_batch_bench.sh`
- `scripts/run_object_batch_bench_enhanced.sh`
- `scripts/run_object_batch_bench_abc.sh`
- `scripts/run_four_node_cluster_failover_bench.sh`
- `scripts/run_internode_transport_baseline.sh` (scenario matrix wrapper for local vs distributed TCP baseline artifacts)
- Criterion benches under `crates/ecstore/benches/`
These mostly cover S3/object workload or erasure coding performance. They do
not yet isolate internode transport cost.
## Required TCP Baseline
Before changing internode data transport behavior or comparing a non-default
backend, collect a baseline for the current TCP/HTTP/gRPC implementation.
### Topology
Minimum:
- 1-node local erasure deployment, to measure local disk and erasure overhead.
- 4-node distributed erasure deployment, to measure internode overhead.
Preferred:
- Same host count and disk layout for every run.
- Dedicated network interface or isolated VLAN.
- Fixed CPU governor and no unrelated background load.
- Recorded kernel version, NIC model, MTU, RustFS commit, Rust toolchain, and
benchmark tool versions.
### Workloads
| Workload | Sizes | Concurrency | Main signal |
| --- | --- | --- | --- |
| S3 PUT | 4 KiB, 1 MiB, 16 MiB, 128 MiB, 1 GiB | 1, 16, 64, 128 | End-to-end write throughput and tail latency. |
| S3 GET | 4 KiB, 1 MiB, 16 MiB, 128 MiB, 1 GiB | 1, 16, 64, 128 | End-to-end read throughput and tail latency. |
| Remote disk stream read | shard-sized ranges from `read_file_stream` | 1, 16, 64 | Isolated internode read path. |
| Remote disk stream write | shard-sized writes through `put_file_stream` | 1, 16, 64 | Isolated internode write path. |
| Healing / repair | missing disk or missing shard scenario | controlled | Rebuild throughput and read/write amplification. |
| Scanner walk | large bucket/object namespace | controlled | Metadata streaming pressure, not the primary object-byte transport path. |
### Measurements
Collect:
- throughput in bytes/s and objects/s
- p50, p95, p99, and max latency
- CPU utilization per process and per core
- memory RSS and allocation pressure where available
- `rustfs_system_network_internode_*` metrics
- TCP retransmits, socket errors, and NIC throughput
- disk throughput and utilization
- failure/retry/fallback counts
The baseline should produce a machine-readable artifact, for example
`target/bench/internode-transport/<timestamp>/summary.csv`, plus the exact
commands and configuration used.
### Baseline runner entry point
Use `scripts/run_internode_transport_baseline.sh` to execute a reproducible
S3 PUT/GET matrix against `local` and `distributed` scenarios and export:
- `summary.csv` (throughput/latency summary per workload and object size)
- `internode_metric_deltas.csv` (operation-level internode metric deltas when
`--metrics-url` is provided)
## Transport Abstraction Proposal
### Design principle
Keep `NodeService` as the control plane. Introduce a separate data transport
only below `RemoteDisk`, where remote disk byte streams are opened today.
The first implementation should be a no-behavior-change TCP/HTTP backend that
wraps the current `HttpReader`, `HttpWriter`, and `/rustfs/rpc/*` handlers.
Non-default backend work should not proceed until the default wrapper is
measured and adapter gaps are documented.
### Candidate boundary
The current boundary is remote disk stream transfer:
```rust
#[async_trait::async_trait]
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
fn name(&self) -> &'static str;
fn capabilities(&self) -> InternodeDataTransportCapabilities;
}
```
Initial request fields should mirror the current HTTP query parameters:
- peer endpoint
- disk reference
- volume
- path
- offset
- length
- append/create mode
- expected size
- optional stall timeout for long-running listing streams
The initial TCP backend can keep the current signed HTTP URLs internally.
### Integration point
`RemoteDisk` delegates only these methods to the data transport:
- `read_file_stream`
- `read_file_zero_copy` as a wrapper over `read_file_stream` unless the backend
supports a stronger zero-copy API
- `append_file`
- `create_file`
- `walk_dir`
All other `RemoteDisk` methods continue using the current gRPC client
until measurements prove otherwise.
### Capability model
Avoid hard-coding transport-specific assumptions into the generic interface.
Use capabilities:
- stream read
- stream write
- bounded range read
- bidirectional streaming
- backend-specific buffer registration or staging requirements
- stable buffer ownership support
- copy-reduced receive into caller-owned or backend-owned buffers
- authenticated out-of-band transfer
- transport fallback support
The first TCP backend should report only capabilities that it actually provides.
## TCP Fallback Requirements
TCP/HTTP/gRPC must remain the default and required backend.
Fallback rules:
- If no explicit data transport is configured, use the current TCP/HTTP
implementation.
- The current accepted values for `RUSTFS_INTERNODE_DATA_TRANSPORT` are
`tcp-http` and the `tcp` alias. Empty and unset values use `tcp-http`.
- Invalid configured values fail closed with an error that includes the env var
name and invalid value.
- If a future non-default backend fails initialization, either fail fast with a
clear error or fall back to TCP only when the configured policy allows
fallback.
- Runtime fallback must preserve object correctness and quorum semantics.
- Fallback events must be logged and counted in metrics.
- CI and local development must not require specialized transport hardware.
Suggested future configuration shape:
```text
RUSTFS_INTERNODE_DATA_TRANSPORT=tcp-http
RUSTFS_INTERNODE_DATA_TRANSPORT_FALLBACK=tcp
```
Do not add fallback settings until there is an implementation PR that uses them.
## Baseline Validation Commands
Dry-run command:
```bash
scripts/run_internode_transport_baseline.sh \
--access-key minioadmin \
--secret-key minioadmin \
--scenarios local=http://127.0.0.1:9000,distributed=http://127.0.0.1:9001 \
--sizes 4KiB,1MiB \
--concurrencies 1 \
--duration 10s \
--dry-run
```
Real TCP baseline command with metrics:
```bash
RUSTFS_INTERNODE_DATA_TRANSPORT=tcp-http \
scripts/run_internode_transport_baseline.sh \
--access-key "$RUSTFS_ACCESS_KEY" \
--secret-key "$RUSTFS_SECRET_KEY" \
--scenarios local=http://127.0.0.1:9000,distributed=http://127.0.0.1:9001 \
--metrics-url http://127.0.0.1:9000/metrics \
--out-dir target/bench/internode-transport/manual-run
```
Expected artifacts:
- `run_manifest.txt`
- `summary.csv`
- `internode_metric_deltas.csv` when `--metrics-url` is provided
The baseline validates the default TCP/HTTP path only. It must not be used to
claim support or performance for any non-default transport backend.
## Non-default Backend Boundary
A future non-default backend must be explicitly enabled and must not replace
`tcp-http` silently. It should be designed as an optional data-plane backend,
not as a replacement for the gRPC control plane.
A future non-default backend would need an explicit design for:
- peer capability discovery over the existing gRPC control plane;
- connection management and health mapping into existing disk fault handling;
- backend-specific buffer lifecycle and any staging or registration cache;
- buffer ownership, alignment, and lifetime rules;
- stable buffer behavior for erasure shards;
- authentication and authorization for out-of-band data transfers;
- encryption or an equivalent documented security boundary;
- timeout, cancellation, retry, and fallback behavior;
- metrics for transfer latency, bytes, queue depth, retries, fallback, and
errors.
`walk_dir`, metadata RPCs, locks, admin RPCs, and bucket coordination remain
outside the current data-plane boundary.
## Out of Scope
Hardware-specific transport experiments are outside the scope of this OSS
document. This RFC does not add a plugin system, split the adapter into a
separate crate, add accepted backend values, or implement a new transport
backend.
@@ -0,0 +1,142 @@
# Internode Transport Buffer Contract
Status: design note only. This document defines a backend-neutral buffer
ownership and lifecycle contract for the `InternodeDataTransport` adapter. It
does not implement a new backend and does not change production behavior.
## Open-source Scope
The open-source RustFS path keeps `tcp-http` as the default internode data
transport. This document defines adapter contracts only:
- no production RDMA, DPU, DOCA, BlueField, DPDK, SPDK, or hardware
acceleration backend is introduced;
- no hardware SDK, `libibverbs`, `rdma-core`, or vendor dependency is added;
- no new accepted production backend value is added;
- future external or separately maintained backends may implement the same
adapter boundary without changing RustFS core data-plane logic.
Examples of possible future external backends include DOCA/BlueField,
RDMA/RoCE, or other DPU/NIC implementations. These are examples only and are
not implemented or scheduled by this design.
## Current Adapter Surface
The current data-plane surface is byte-stream based:
| Current path | Current API shape | Current ownership |
| --- | --- | --- |
| Remote read stream | `InternodeDataTransport::open_read(...) -> FileReader` | Backend returns boxed `AsyncRead`; callers provide temporary `ReadBuf` storage per poll. |
| Remote write stream | `InternodeDataTransport::open_write(...) -> FileWriter` | Callers pass borrowed `&[u8]` slices into boxed `AsyncWrite`; the backend owns any async body staging. |
| Walk-dir stream | `InternodeDataTransport::open_walk_dir(...) -> FileReader` | Same boxed stream model as read, with a small serialized request body. |
This API is correct for the current TCP/HTTP backend. A future non-default
backend may have stricter memory ownership, buffer lifetime, or completion
requirements, so the adapter contract needs to describe those boundaries
without assuming a specific implementation.
## Buffer Ownership Model
| Buffer role | Allocator | Lifetime owner | Backend-specific state | TCP/HTTP behavior |
| --- | --- | --- | --- | --- |
| Send buffer | Caller or RustFS-owned pool | Caller until the backend accepts the buffer; backend until completion if an owned-buffer API is used | Optional backend-managed buffer, staging buffer, or registration handle | Copy into the existing `AsyncWrite` path when the backend cannot use the buffer directly. |
| Receive buffer | Caller-provided storage or backend-owned receive pool | Backend while filling; caller after completion if ownership is returned | Optional backend-owned receive buffer or backend-specific registration | Copy from `AsyncRead` into caller storage as today. |
| Control metadata | RustFS caller | Caller/request object | Not buffer-managed by the data-plane backend | Serialize into HTTP/gRPC/control-plane messages. |
| Fallback staging | TCP/HTTP backend | TCP/HTTP backend | No backend-specific registration | Existing `HttpReader`/`HttpWriter` buffering semantics. |
Backends with stricter memory requirements must not let callers mutate or reuse
a buffer while an async transfer is still in flight. A backend that cannot use a
caller buffer directly must either reject the transfer before payload movement
or copy through a clearly documented backend-owned staging buffer.
Zero-copy is not guaranteed by this contract. Backends must document whether
their path is zero-copy, copy-reduced, or staging-buffer based, and must
document where copies occur.
## Compatibility Contract
The current stream API remains the OSS compatibility contract:
```rust
#[async_trait::async_trait]
pub trait InternodeDataTransport {
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
}
```
Future extensions for backend-managed buffers should be additive and
capability-gated. A possible shape is:
```rust
pub struct TransferBuffer {
pub bytes: bytes::Bytes,
pub backend_state: Option<TransportBufferState>,
}
pub struct CompletedTransfer {
pub bytes: bytes::Bytes,
pub transfer_len: usize,
}
#[async_trait::async_trait]
pub trait BackendBufferInternodeTransport: InternodeDataTransport {
async fn write_backend_buffer(&self, request: WriteStreamRequest, buffer: TransferBuffer) -> Result<CompletedTransfer>;
async fn read_backend_buffer(&self, request: ReadStreamRequest, buffer: TransferBuffer) -> Result<CompletedTransfer>;
}
```
The concrete type of `TransportBufferState` should stay backend-private. The
generic contract only needs to state whether the buffer is usable by the
backend, who owns it during transfer, and when ownership returns.
This PR does not add this extension. It documents the boundary that a future
external backend may need.
## Required Contract for Stricter Backends
| Area | Required contract |
| --- | --- |
| Ownership | Define when caller-owned bytes become backend-owned and when ownership returns. |
| Completion | Signal completion before RustFS can reuse or mutate backend-managed memory. |
| Staging | Declare whether the backend copies through backend-owned staging buffers when direct use is unavailable. |
| Size limits | Expose any RustFS-visible `max_transfer_size`. |
| Ordering | Either provide ordered delivery or include a reassembly layer before exposing stream semantics. |
| Copy accounting | Document every known copy boundary and avoid claiming zero-copy unless the path proves it. |
## Optional Optimizations
| Area | Optional behavior |
| --- | --- |
| Buffer pooling | RustFS may keep reusable pools for send and receive buffers. |
| Backend state cache | A backend may cache backend-specific handles for long-lived buffers. |
| Scatter/gather | A backend may accept multiple shard slices without repacking when its completion model can report per-slice ownership safely. |
| Backend-owned receive | A backend may return owned receive chunks instead of filling caller-provided buffers. |
These are optional optimizations, not requirements for the OSS TCP/HTTP path.
## Current API Limitations
| Current API | Limitation for stricter backends |
| --- | --- |
| `FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>` | `AsyncRead` exposes temporary caller `ReadBuf` storage, not a stable backend-managed receive buffer or explicit completion token. |
| `FileWriter = Box<dyn AsyncWrite + Send + Sync + Unpin>` | `AsyncWrite::poll_write` receives borrowed `&[u8]` that cannot outlive the poll, so async direct transfer requires copying or a different ownership API. |
| `HttpWriter` | The async HTTP body must own `Bytes`, so borrowed write buffers are copied into `BytesMut` or `Bytes`. |
| `write_body_chunks_to_writer` | Server-side HTTP body chunks are copied into `BytesMut` before local disk write. |
| Erasure encode output | Encoded shards are represented as `Vec<Bytes>` and written through `AsyncWrite`, not a completion-aware backend-buffer API. |
| Erasure decode input | Shard reads allocate `Vec<u8>` buffers before decode; no backend-owned receive pool is visible at the transport boundary. |
These limitations do not block the current `tcp-http` backend. They describe
where a future external backend would need staging or an additive API.
## External Backend Crate Compatibility
`InternodeDataTransport` should remain implementable by future backends without
modifying RustFS core data-plane logic. In the short term, the trait and
`tcp-http` backend may remain inside `ecstore`.
A future external or separately maintained backend could live in a separate
crate if the trait, request/response types, capability report, and error model
are public and stable enough. This PR does not perform a crate split, add
runtime loading, or introduce a plugin system.
@@ -0,0 +1,125 @@
# Internode Buffer Lifecycle and Copy Count
Status: P1-D analysis only. This document records the current TCP/HTTP
internode data path and the ownership boundaries that matter for the
backend-neutral `InternodeDataTransport` adapter. It does not implement a new
backend or change production behavior.
## Open-source Scope
The OSS scope is:
- define buffer ownership and copy-count behavior for the current
`InternodeDataTransport` adapter;
- keep `tcp-http` as the default backend;
- keep existing TCP/HTTP behavior unchanged;
- document copy hotspots and ownership gaps for maintainable transport code;
- avoid hardware-specific dependencies or backend implementations.
The OSS scope is not:
- RDMA support;
- DPU support;
- DOCA support;
- BlueField support;
- RoCE/InfiniBand support;
- hardware benchmark planning;
- hardware-specific backend implementation.
## Scope
The covered paths are the large internode data-plane calls currently routed
through `InternodeDataTransport`:
| Path | Entry | Transport owner | Server owner |
| --- | --- | --- | --- |
| Read stream | `RemoteDisk::read_file_stream` in `crates/ecstore/src/rpc/remote_disk.rs` | `TcpHttpInternodeDataTransport::open_read` in `crates/ecstore/src/rpc/internode_data_transport.rs`, `HttpReader` in `crates/rio/src/http_reader.rs` | `handle_read_file` in `rustfs/src/storage/rpc/http_service.rs` |
| Write stream | `RemoteDisk::create_file`, `RemoteDisk::append_file` in `crates/ecstore/src/rpc/remote_disk.rs` | `TcpHttpInternodeDataTransport::open_write` in `crates/ecstore/src/rpc/internode_data_transport.rs`, `HttpWriter` in `crates/rio/src/http_reader.rs` | `handle_put_file` in `rustfs/src/storage/rpc/http_service.rs` |
| Walk dir stream | `RemoteDisk::walk_dir` in `crates/ecstore/src/rpc/remote_disk.rs` | `TcpHttpInternodeDataTransport::open_walk_dir`, `HttpReader` | `handle_walk_dir` in `rustfs/src/storage/rpc/http_service.rs` |
Object read/write/heal callers enter these streams through
`create_bitrot_reader` and `create_bitrot_writer` in
`crates/ecstore/src/bitrot.rs`. Erasure decode and encode then move data
through `ParallelReader` in `crates/ecstore/src/erasure_coding/decode.rs` and
`MultiWriter` in `crates/ecstore/src/erasure_coding/encode.rs`.
## Read Stream
| Step | Owner | Buffer type | Copy? | Reason |
| --- | --- | --- | --- | --- |
| Build request | `RemoteDisk::read_file_stream` | `String` fields in `ReadStreamRequest` | Yes | Volume, path, endpoint, and disk references are copied into an owned request before async transport dispatch. This is metadata, not payload. |
| Select transport | `TcpHttpInternodeDataTransport::open_read` | URL `String`, `HeaderMap` | Yes | URL and auth headers are HTTP control data. No object bytes are copied here. |
| Open local file on server | `handle_read_file`, `LocalDisk::read_file_stream` | `FileCacheReclaimReader` boxed as `FileReader` | No payload copy | The server owns an async file reader positioned at the requested offset. |
| File to HTTP body | `read_file_body_stream` | `ReaderStream<AsyncRead>` yielding `Bytes` | Yes | `ReaderStream::with_capacity` reads from the file into chunk buffers. This is the file-to-network buffer materialization point. |
| Length limiting | `rustfs_utils::net::bytes_stream` | `Bytes` | Usually no | `Bytes::truncate` adjusts the chunk view when the last chunk exceeds the requested length. It does not copy the retained prefix. |
| HTTP receive | `HttpReader::with_capacity_and_stall_timeout` | `reqwest::Response::bytes_stream()` yielding `Bytes` | Network stack dependent | The user-level object is `Bytes`; any kernel/TLS/hyper copy is below the current RustFS abstraction. |
| Stream to caller buffer | `HttpReader::poll_read` | `StreamReader<Stream<Item = Bytes>>`, caller `ReadBuf` | Yes | `StreamReader` exposes `AsyncRead`, so it copies bytes from each `Bytes` chunk into the caller-provided `ReadBuf`. |
| Bitrot verification | `BitrotReader::read` | caller `&mut [u8]`, `hash_buf: Vec<u8>` | No additional payload copy | The bitrot reader reads hash bytes into `hash_buf` and payload bytes directly into the supplied output slice. Hash calculation reads the slice. |
| Erasure shard read | `ParallelReader::read` | `Vec<u8>` per shard | Yes | Each shard read allocates `vec![0u8; shard_size]`; data is filled there before decode/reconstruction. |
| Object response write | `write_data_blocks` | slices of shard `Vec<u8>` | No extra staging copy | Decoded data block slices are written to the target writer with `write_all`; the target may copy internally. |
| Remote zero-copy helper | `RemoteDisk::read_file_zero_copy` | `Vec<u8>` then `Bytes` | Yes | The remote implementation reads the full stream into a `Vec` and converts it into `Bytes`. It is a convenience fallback, not network zero-copy. |
## Write Stream
| Step | Owner | Buffer type | Copy? | Reason |
| --- | --- | --- | --- | --- |
| Build writer request | `RemoteDisk::create_file`, `RemoteDisk::append_file` | `String` fields in `WriteStreamRequest` | Yes | Volume, path, endpoint, and disk references are copied into an owned request. This is metadata. |
| Select transport | `TcpHttpInternodeDataTransport::open_write` | URL `String`, `HeaderMap` | Yes | URL and auth headers are HTTP control data. No object bytes are copied here. |
| Erasure encode input | `Erasure::encode` in `encode.rs` | reusable `Vec<u8>` sized to `block_size` | Yes | `rustfs_utils::read_full` fills a block buffer from the source reader before encoding. |
| Erasure encode output | `Erasure::encode_data` caller in `encode.rs` | `Vec<Bytes>` per encoded block | Yes | Encoding creates shard `Bytes` for data and parity blocks before queueing them to writers. |
| Multi-writer fanout | `MultiWriter::write` | borrowed `Bytes` shards | No additional fanout copy | The writer fanout passes borrowed `Bytes` references to each `BitrotWriterWrapper`. |
| Bitrot write | `BitrotWriter::write` | shard `&[u8]`, checksum bytes | Yes for checksum bytes | The payload slice is passed to the inner writer, while checksum bytes are generated and written before payload when enabled. |
| Client HTTP writer buffer | `HttpWriter::poll_write` and `poll_write_vectored` | `BytesMut` pending chunk or `Bytes::copy_from_slice` | Yes | Small writes are coalesced with `BytesMut::extend_from_slice`; large single writes still copy into an owned `Bytes` because the async body must outlive the caller's borrowed buffer. |
| Client channel to reqwest | `HttpWriter::poll_send_pending_chunk`, `ReceiverStream` | `Bytes` | No | `BytesMut::split().freeze()` transfers owned chunk storage to `Bytes`; the mpsc channel and stream move the `Bytes` handle. |
| HTTP receive body on server | `handle_put_file` | `Incoming::into_data_stream()` yielding `Bytes` | Network stack dependent | The server receives owned `Bytes` chunks from hyper. |
| Server body coalescing | `write_body_chunks_to_writer` | `BytesMut` sized to `DEFAULT_READ_BUFFER_SIZE` | Yes | Each incoming `Bytes` chunk is copied into `pending` before writing to the local file writer. This normalizes chunk size but adds a full payload copy. |
| Local file write | `LocalDisk::create_file`, `LocalDisk::append_file`, `FileCacheReclaimWriter` | `&[u8]` into `tokio::fs::File` | Kernel dependent | RustFS passes slices to Tokio file writes. Kernel page-cache copies are below the RustFS abstraction. |
## Request and Serialization Boundaries
| Boundary | Owner | Buffer type | Copy? | Notes |
| --- | --- | --- | --- | --- |
| Read/write query parameters | `build_read_file_stream_url`, `build_put_file_stream_url` | URL-encoded `String` | Yes | Metadata only. It includes disk, volume, path, offset, length, append, and size. |
| Auth headers | `build_auth_headers` callers | `HeaderMap` | Yes | Metadata only. This is currently tied to HTTP request construction. |
| Walk dir request | `RemoteDisk::walk_dir`, `open_walk_dir`, `handle_walk_dir` | JSON `Vec<u8>` body, collected `Bytes` on server | Yes | Walk dir is a streamed response but its request body is serialized JSON control data. |
| gRPC read/write-all | `RemoteDisk::read_all`, `RemoteDisk::write_all`, `NodeService::{handle_read_all,handle_write_all}` | Prost `Bytes`/message bodies | Yes | These paths are still gRPC byte paths, not `InternodeDataTransport`; they matter for metrics and inventory but are outside this P1-D stream-copy count. |
## Hotspots
| Rank | Hotspot | Impact | Reason |
| --- | --- | --- | --- |
| 1 | `HttpWriter::poll_write` and `poll_write_vectored` | High on write path | Every borrowed caller buffer is copied into owned `BytesMut` or `Bytes` before it can be sent by an async HTTP body. |
| 2 | `write_body_chunks_to_writer` | High on write path | The server copies every received `Bytes` chunk into a coalescing `BytesMut` before local disk write. |
| 3 | `ParallelReader::read` shard buffers | High on read path | Each shard read allocates and fills a `Vec<u8>` before decode can proceed. This is also where degraded reads wait on quorum. |
| 4 | `ReaderStream::with_capacity` plus `StreamReader` | Medium on read path | Server file reads create `Bytes` chunks, then client `AsyncRead` copies those chunks into the caller's `ReadBuf`. |
| 5 | `Erasure::encode` block and shard materialization | Medium on write path | Source data is first read into a block `Vec<u8>`, then encoded into per-shard `Bytes`. This is necessary for the current erasure API. |
| 6 | `RemoteDisk::read_file_zero_copy` | Medium when used | Remote zero-copy reads buffer the whole stream into memory. The name does not mean zero-copy over the network. |
| 7 | URL/query/header/JSON serialization | Low | Metadata copies are small and not on the large payload hot path. |
## Adapter Ownership Gaps
1. `FileReader` and `FileWriter` are boxed `AsyncRead`/`AsyncWrite` trait
objects. They expose borrowed buffers per poll, not stable backend-owned
regions, transfer handles, or explicit completion ownership.
2. `InternodeDataTransport` currently returns stream traits only. Its
capabilities advertise that TCP/HTTP does not require backend-specific
buffer registration and is not a zero-copy candidate, but there is no
backend API to pass stable backend-managed buffers.
3. `HttpWriter` must own outgoing chunks because the async request body outlives
the caller's borrowed `&[u8]`. A lower-copy backend would need a different
lifetime contract or an owned buffer pool.
4. Server write handling normalizes all incoming body chunks into a new
`BytesMut`. Avoiding that copy would require passing incoming `Bytes` or
backend-owned receive buffers directly into the disk/bitrot writer contract.
5. Erasure decode owns shard `Vec<u8>` buffers and write-back happens through
`AsyncWrite`. A lower-copy backend would need explicit ownership of shard
buffers across decode, reconstruction, and network completion.
6. Erasure encode materializes `Vec<Bytes>` blocks before fanout. A
backend that can send multiple stable slices would need an encode output
representation that can be transferred without repacking.
7. The HTTP auth and URL construction boundary is part of the current TCP/HTTP
backend. A non-HTTP backend would need equivalent peer authentication and
disk addressing without assuming URL query parameters.
8. Local disk zero-copy exists only for local reads via `read_file_zero_copy`.
Remote disks deliberately fall back to network streaming and full-buffer
collection for the zero-copy helper.
@@ -0,0 +1,78 @@
# Internode Transport Capabilities
Status: design note for backend-neutral capability reporting. This document
does not add a backend and does not require specialized hardware or transport
crates.
## Open-source Scope
The OSS scope is:
- define honest capability reporting for the `InternodeDataTransport` adapter;
- keep `tcp-http` as the default backend;
- keep existing TCP/HTTP behavior unchanged;
- document the capability fields needed for maintainable transport code;
- avoid hardware-specific dependencies or backend implementations.
The OSS scope is not:
- RDMA support;
- DPU support;
- DOCA support;
- BlueField support;
- RoCE/InfiniBand support;
- hardware benchmark planning;
- hardware-specific backend implementation.
## Purpose
`InternodeDataTransportCapabilities` describes what a backend can honestly do
for RustFS internode data-plane transfers. The fields are intentionally neutral:
they can describe the current TCP/HTTP backend and a future non-default backend
without naming a specific transport implementation.
The capability report is descriptive. It does not select a backend, negotiate
with peers, or weaken object correctness semantics.
## Capability Fields
| Field | Meaning |
| --- | --- |
| `streaming_read` | The backend can open a remote disk reader for `read_file_stream`. |
| `streaming_write` | The backend can open a remote disk writer for `create_file` or `append_file`. |
| `streaming_walk_dir` | The backend can stream `walk_dir` responses. |
| `zero_copy_candidate` | The backend has an API shape that could avoid an extra user-space payload copy. This is not a promise that every transfer is zero-copy. |
| `registered_memory_required` | The backend requires pinned, registered, staged, or otherwise backend-managed buffers before payload transfer. |
| `ordered_delivery` | Bytes for each opened transfer are delivered in order. |
| `max_transfer_size` | Optional RustFS-level cap for a single transfer. `None` means no additional cap beyond the backend/protocol/runtime limits. |
| `fallback_supported` | The backend can participate in the behavior-preserving TCP fallback path. |
## TCP/HTTP Backend
The default TCP/HTTP backend reports only capabilities it actually provides:
| Field | TCP/HTTP value | Reason |
| --- | --- | --- |
| `streaming_read` | `true` | `HttpReader` streams `/rustfs/rpc/read_file_stream` responses. |
| `streaming_write` | `true` | `HttpWriter` streams `/rustfs/rpc/put_file_stream` request bodies. |
| `streaming_walk_dir` | `true` | `HttpReader` streams `/rustfs/rpc/walk_dir` responses. |
| `zero_copy_candidate` | `false` | The current path exposes `AsyncRead`/`AsyncWrite` and HTTP body chunks; it copies through normal user-space buffers. |
| `registered_memory_required` | `false` | TCP/HTTP does not require RustFS-managed pinned, registered, or backend-owned buffers. |
| `ordered_delivery` | `true` | Each HTTP request body or response body is consumed as an ordered byte stream. |
| `max_transfer_size` | `None` | RustFS does not impose an extra per-transfer cap at the capability layer. |
| `fallback_supported` | `true` | TCP/HTTP is the behavior-preserving default and fallback path. |
## Non-default Backend Fit
A future non-default backend can be described without changing the meaning of
the existing TCP report:
| Capability shape | Interpretation |
| --- | --- |
| `zero_copy_candidate=true`, `registered_memory_required=true` | The backend can only use its lower-copy path with buffers that satisfy backend-specific ownership or registration rules. |
| `zero_copy_candidate=true`, `registered_memory_required=false` | The backend may expose owned chunks or another lower-copy path without requiring caller-managed registration. |
| `max_transfer_size=Some(n)` | The backend has a RustFS-visible transfer size ceiling and callers must split larger transfers or use fallback behavior. |
| `ordered_delivery=false` | The backend cannot be used behind the current stream API without an ordering or reassembly layer. |
Unsupported or mismatched capabilities must not silently change quorum,
integrity verification, retry, or timeout semantics.
@@ -0,0 +1,144 @@
# Internode Transport Fallback and Backend Selection Model
Status: design note only. This document defines backend-neutral selection,
fallback, failure handling, negotiation, security, and observability rules for
the `InternodeDataTransport` adapter. It does not implement a new backend and
does not change production behavior.
## Open-source Scope
The open-source RustFS path keeps `tcp-http` as the default internode data
transport. This document defines adapter contracts only:
- no production RDMA, DPU, DOCA, BlueField, DPDK, SPDK, or hardware
acceleration backend is introduced;
- no hardware SDK, `libibverbs`, `rdma-core`, or vendor dependency is added;
- no new accepted production backend value is added;
- future external or separately maintained backends may implement the same
adapter boundary without changing RustFS core data-plane logic.
Examples of possible future external backends include DOCA/BlueField,
RDMA/RoCE, or other DPU/NIC implementations. These are examples only and are
not implemented or scheduled by this design. Hardware-specific backend plans
are out of scope for this document.
## Static Backend Selection
Static config is the first selection model. Existing accepted values remain:
| Config value | Meaning |
| --- | --- |
| unset | Use default TCP/HTTP backend. |
| `tcp-http` | Use default TCP/HTTP backend. |
| `tcp` | Alias for `tcp-http`. |
| any unsupported value | Fail closed with a diagnostic naming `RUSTFS_INTERNODE_DATA_TRANSPORT` and the invalid value. |
Unknown backend values must fail closed. Unsupported backend values must fail
closed. A future external backend must be explicitly enabled and must not
silently replace `tcp-http`.
Backend selection must expose an observable backend identity for metrics, logs,
and benchmark interpretation. The default and fallback path remains `tcp-http`.
## Fallback Contract
Fallback must be explicit and observable. Silent fallback is not allowed for
benchmark or production interpretation because it hides which backend moved the
payload.
| Condition | Default behavior | Explicit fallback behavior | Observability |
| --- | --- | --- | --- |
| Unsupported configured backend | Fail closed during transport construction. | Fall back only when a separately configured policy explicitly allows unsupported-backend fallback. | Error includes config key and invalid value; fallback event is counted when fallback is enabled. |
| Peer does not support selected backend | Fail before payload transfer. | Use TCP/HTTP only when both local policy and peer policy allow it. | Count peer mismatch and selected fallback backend. |
| Capability mismatch | Fail before payload transfer. | Use TCP/HTTP only if TCP satisfies the operation and policy allows fallback. | Record missing capability names or a low-cardinality reason. |
| Connection setup failure | Fail the operation. | Retry on TCP/HTTP only when fallback is allowed and no payload bytes have transferred. | Count setup failure, retry, fallback backend, and fallback result. |
| Partial transfer failure | Fail the operation and let existing object/quorum logic decide retry behavior. | Do not silently resume on another backend unless the transfer protocol can prove byte range, checksum, and idempotency boundaries. | Count partial failure with bytes completed. |
| Max transfer size exceeded | Fail before payload transfer or split at a higher layer. | Use TCP/HTTP if policy allows and TCP has no RustFS-level cap. | Record rejected size and selected backend. |
| Auth or encryption mismatch | Fail closed. | No fallback unless the fallback path satisfies the same or stronger security requirements. | Security failure metric and audit log entry. |
Fallback settings should not be added until there is an implementation that
uses them. A backend must define failure behavior before production use.
## Dynamic Negotiation Boundary
Dynamic negotiation, if added, belongs on the existing gRPC control plane. Data
transfer must start only after both peers agree on:
| Negotiated item | Required property |
| --- | --- |
| Backend name | Both peers know the backend and have it enabled. |
| Capability set | Required capabilities match the operation. |
| Max transfer size | The selected operation fits or is split before transfer starts. |
| Buffer rules | Both peers agree whether backend-managed or staged buffers are required. |
| Completion semantics | Both peers agree when a transfer is considered complete and when buffers may be reused. |
| Security mode | Authentication and encryption requirements are satisfied before any out-of-band transfer. |
| Fallback policy | Both peers agree whether TCP/HTTP fallback is allowed for this operation. |
Negotiation must not silently downgrade security or bypass existing disk
health, quorum, timeout, and integrity semantics.
## Failure Handling Requirements
| Failure mode | Requirement |
| --- | --- |
| Invalid config | Fail closed with `RUSTFS_INTERNODE_DATA_TRANSPORT` and the invalid value. |
| Backend disabled | Fail closed with the selected backend name and the missing enablement condition. |
| Backend unavailable | Fail closed with an actionable diagnostic; do not silently use TCP/HTTP. |
| Peer mismatch | Fail before payload transfer unless explicit fallback is configured. |
| Connection failure | Fail the operation and record setup failure; fallback only if policy allows and no payload bytes moved. |
| Completion failure | Return an operation error and release backend-owned resources. |
| Timeout | Return an operation error and preserve existing disk health and quorum semantics. |
| Partial transfer | Do not silently resume on another backend without a safe byte-range/checksum/idempotency proof. |
| Unsupported operation | Return a clear unsupported-operation error. |
## Security Requirements
- Backend selection must preserve peer authentication.
- Fallback must not weaken encryption or authorization.
- Out-of-band data-plane transfers must still bind to the intended disk,
volume, path, request authority, and operation.
- Partial transfers must not bypass bitrot verification or erasure quorum
handling.
- Any future external backend must document whether it relies on the same
security boundary as TCP/HTTP or requires a separate deployment boundary.
## Metrics and Observability Requirements
Metrics and logs must use low-cardinality labels or metadata:
- selected backend;
- requested backend;
- fallback backend, when used;
- operation name;
- success/failure;
- transferred bytes;
- setup failure count;
- partial transfer failure count;
- capability mismatch count;
- fallback decision count.
Backends must not add high-cardinality labels such as object names, full paths,
full URLs, peer-specific dynamic strings, memory addresses, or backend-specific
buffer keys.
## TCP/HTTP Compatibility
The `tcp-http` backend remains the default and behavior-preserving path. It
uses ordinary byte streams, does not require backend-specific buffer
registration, and remains suitable as the fallback path when an explicit
fallback policy exists.
A future non-default backend must not change the correctness semantics of
object writes, object reads, healing, bitrot verification, erasure quorum,
timeouts, or disk health handling.
## External Backend Crate Compatibility
`InternodeDataTransport` should remain implementable by future backends without
modifying RustFS core data-plane logic. In the short term, the trait and
`tcp-http` backend may remain inside `ecstore`.
A future external or separately maintained backend could live in a separate
crate if the trait, request/response types, capability report, and error model
are public and stable enough. This PR does not perform a crate split, add
runtime loading, or introduce a plugin system.
@@ -107,6 +107,9 @@ pub struct WalkDirStreamRequest {
/// This boundary is limited to remote disk streams that can move large payloads.
/// Internode metadata, lock, health, and administrative calls remain on the
/// existing gRPC control plane.
///
/// Buffer ownership, backend selection, and fallback expectations are documented
/// in `crates/ecstore/docs/internode-transport/`.
#[async_trait]
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
@@ -248,7 +251,7 @@ mod tests {
}
#[test]
fn tcp_http_capabilities_do_not_advertise_rdma_specific_features() {
fn tcp_http_capabilities_do_not_advertise_stricter_backend_features() {
let capabilities = TcpHttpInternodeDataTransport.capabilities();
assert!(!capabilities.zero_copy_candidate);
@@ -337,18 +340,19 @@ mod tests {
#[test]
fn transport_config_rejects_unknown_backend() {
let err = build_internode_data_transport(Some("rdma")).expect_err("unknown backend should fail closed");
let err = build_internode_data_transport(Some("unsupported-backend")).expect_err("unknown backend should fail closed");
assert!(err.to_string().contains(ENV_RUSTFS_INTERNODE_DATA_TRANSPORT));
assert!(err.to_string().contains("rdma"));
assert!(err.to_string().contains("unsupported-backend"));
}
#[test]
fn cached_transport_config_error_uses_raw_message() {
let err = build_internode_data_transport_result(Some("rdma")).expect_err("unknown backend should fail closed");
let err =
build_internode_data_transport_result(Some("unsupported-backend")).expect_err("unknown backend should fail closed");
assert!(!err.starts_with("io error "));
assert!(err.contains(ENV_RUSTFS_INTERNODE_DATA_TRANSPORT));
assert!(err.contains("rdma"));
assert!(err.contains("unsupported-backend"));
}
}