GitHub user haze518 created a discussion: Sans-IO iggy-client and runtime 
abstraction in the Iggy SDK

* Feature Name: Sans-IO iggy-client and runtime abstraction in the Iggy SDK
* Start Date: 2025-08-27
* RFC PR: https://github.com/apache/iggy/pull/2124
* Tracking Issue: https://github.com/apache/iggy/issues/1714

# Summary
[summary]: #summary

We propose decoupling network I/O from protocol logic in the Iggy SDK by 
introducing a Sans-IO layer (initially a TCP client) and a runtime abstraction 
(spawn/sleep/timers). This will:
* allow using different runtimes (Tokio, async-std, smol) and, going forward, 
sync/no_std scenarios;
* unify the transport layer and improve testability (mock transport without 
real sockets).

# Motivation
[motivation]: #motivation

The current implementation is tightly coupled to a specific async runtime 
(Tokio) and network stack, which:
* complicates adding new transports/protocols (protocol logic is duplicated);
* locks us into a single runtime (hard to add smol/async-std/synchronous 
variants);
* makes protocol-level unit testing difficult (transport can’t be swapped out 
cleanly).

# Guide-level explanation
[guide-level-explanation]: #guide-level-explanation

Basic usage with the TCP client (as in benchmarks):
```rust
async fn create_client(&self) -> ClientWrapper {
    let config = TcpClientConfig {
        server_address: self.server_addr.clone(),
        nodelay: self.nodelay,
        tls_enabled: self.tls_enabled,
        tls_domain: self.tls_domain.clone(),
        tls_ca_file: self.tls_ca_file.clone(),
        tls_validate_certificate: self.tls_validate_certificate,
        ..TcpClientConfig::default()
    };

    let tokio_rt = Arc::new(TokioRuntime {});
    let client = NewTokioTcpClient::create(Arc::new(config), 
tokio_rt).unwrap_or_else(|e| {
        panic!(
            "Failed to create NewTcpClient, iggy-server has address {}, error: 
{:?}",
            self.server_addr, e
        )
    });

    Client::connect(&client).await.unwrap_or_else(|e| {
        if self.tls_enabled {
            panic!(
                "Failed to connect to iggy-server at {} with TLS enabled, 
error: {:?}\n\
                 Hint: Make sure the server is started with TLS enabled and 
self-signed certificate:\n\
                 IGGY_TCP_TLS_ENABLED=true IGGY_TCP_TLS_SELF_SIGNED=true\n\
                 or start iggy-bench with relevant tcp tls arguments: --tls 
--tls-domain <domain> --tls-ca-file <ca_file>\n",
                self.server_addr, e
            )
        } else {
            panic!(
                "Failed to connect to iggy-server at {}, error: {:?}",
                self.server_addr, e
            )
        }
    });
    ClientWrapper::TcpTokio(client)
}
```
In the draft implementation the runtime is passed explicitly; in the final 
version the choice is planned at compile time (via cfg/features).

# Reference-level explanation
[reference-level-explanation]: #reference-level-explanation

1. Runtime abstraction
```rust
pub trait Runtime: Send + Sync + 'static {
    type JoinHandle<T>: Send + 'static;

    fn spawn<F, T>(&self, fut: F) -> Self::JoinHandle<T>
    where
        F: Future<Output = T> + Send + 'static,
        T: Send + 'static;

    fn now(&self) -> std::time::Instant;
    fn sleep(&self, dur: std::time::Duration) -> Pin<Box<dyn Future<Output = 
()> + Send>>;
}
```
Goal: a minimal contract compatible with multiple runtimes, to be reused not 
only in the client but also in producer/consumer/etc. (spawn nuances may be 
refined — see Unresolved questions).
2. Transport (for now, TCP)
```rust
pub trait Transport: Send + Sync + 'static {
    type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
    type Config: ClientConfig + Clone + Send + Sync + 'static;

    fn connect(
        cfg: Arc<Self::Config>,
        server_address: SocketAddr,
    ) -> Pin<Box<dyn Future<Output = io::Result<Self::Stream>> + Send>>;
}
```
3. Sans-IO client “core” (ProtocolCore)
* serialization/parsing of frames without any knowledge of sockets
* state machine (connect/auth/work/shutdown)
* directives to “wait/connect/continue”, retry limits, etc
4. Execution
A single background executor ConnectionDriver (one Future) that:
* handles commands (connect / disconnect / shutdown)
* executes ControlAction from ProtocolCore::poll() (Connect, Wait, Noop, errors)
* when a stream is present, writes (poll_transmit) and reads (poll_read) data
* maps request/response via req_id -> wait_id and completes waits on incoming 
frames

# Drawbacks
[drawbacks]: #drawbacks

* More intricate internals and stricter requirements around Waker/poll_* 
correctness.
* Migrating the SDK to the new client will require changes and API 
stabilization.

# Rationale and alternatives
[rationale-and-alternatives]: #rationale-and-alternatives

* Via runtime channels: simpler lifecycle but worse performance (extra 
allocations/context switches), harder to batch progress in a single poll.
* Hard Tokio coupling: loses portability and hampers core testing.

# Prior art
[prior-art]: #prior-art

* quinn-proto (Sans-IO QUIC core) + I/O adapters in quinn.
* str0m/quiche: similar separation of protocol and transport.

# Unresolved questions
[unresolved-questions]: #unresolved-questions

* Runtime contract: spawn details; Send + 'static requirements, see @numinnex's 
comment: https://github.com/apache/iggy/pull/2124#discussion_r2296547122

# Future possibilities
[future-possibilities]: #future-possibilities

* Transports: TcpTlsTransport, QuinnTransport, WebSocket, Unix sockets 
(separate RFCs/PRs).
* Other runtimes: async-std, smol, wasm32 — after stabilizing the Runtime 
contract.
* ClientWrapper. As @marvin-hansen noted, the current draft deserves a revisit. 
Today it looks like:
```rust
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum ClientWrapper {
    Iggy(IggyClient),
    Http(HttpClient),
    Tcp(TcpClient),
    Quic(QuicClient),
    TcpTokio(NewTokioTcpClient),
}
```
Adding TcpTokio in the draft leads to a combinatorial explosion as we expand 
(e.g., TcpTlsTokio, Quinn, TcpSmol, etc.). @marvin-hansen suggested:
```rust
pub enum ClientWrapper{
    Sync(TransportProtocol),
    AsyncTokio(TransportProtocol),
    AsyncCompIO(TransportProtocol),
}
```
This is better, but method duplication remains. For example:
```rust
async fn login_user(&self, username: &str, password: &str) -> 
Result<IdentityInfo, IggyError> {
    match self {
        ClientWrapper::Iggy(client) => client.login_user(username, 
password).await,
        ClientWrapper::Http(client) => client.login_user(username, 
password).await,
        ClientWrapper::Tcp(client) => client.login_user(username, 
password).await,
        ClientWrapper::Quic(client) => client.login_user(username, 
password).await,
        ClientWrapper::TcpTokio(client) => client.login_user(username, 
password).await,
    }
}
```
To avoid “generic pollution” while not bloating the enum, we can export a 
concrete type at compile time via a type alias under a feature-flag:
```diff
@@
- pub struct IggyClient {
-     pub(crate) client: IggySharedMut<ClientWrapper>,
+ pub struct IggyClient<T: Client + Debug + 'static> {
+     pub(crate) client: IggySharedMut<T>,
@@
- impl IggyClient {
+ impl<T: Client + Debug + 'static> IggyClient<T> {
@@
+ #[cfg(feature = "tokio-tcp")]
+ pub type IggyClientConcrete = IggyClient<TcpClient>;
```
Under different features we can export different aliases (Tokio TCP, QUIC, 
etc.), preventing generics from leaking into the public API and eliminating 
duplicated methods in a large enum

GitHub link: https://github.com/apache/iggy/discussions/2132

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]

Reply via email to