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]
