Designing a Minimalist Client API for a Rust Database with TCP Serialization
This article explains how to expose a lightweight, dependency‑free client API for a Rust‑based database by defining a simple TCP‑based JSON serialization protocol, detailing non‑functional and functional requirements, the Message and Command structures, and providing a concrete Ping command implementation in Rust.
Part 1 – Exposing an API to Clients
Background
Every database needs to expose an API for client interaction. While not strictly required for studying internal structures, an entry point is needed for end‑to‑end testing.
Requirements
Non‑functional Requirements
Readability – Essential for any component; the first component should be easy to understand to avoid confusion.
Minimal Dependencies – The component must avoid unnecessary dependencies to keep the learning focus on data structures and algorithms.
Simplicity and Extensibility – Simpler components improve readability and make adding new commands require little boilerplate.
Functional Requirement
The only functional requirement is the ability to trace requests by request ID for debugging.
Design
Different databases expose different client interfaces. Examples include:
SQLite – Embedded SQL database with a C client library.
Redis – In‑memory database accessed over TCP using the RESP protocol.
CouchDB – Provides a RESTful HTTP API.
For our research‑focused database we choose a lightweight JSON‑based protocol over TCP, prioritising minimal dependencies and simplicity.
The serialization protocol is based on a Message structure consisting of a small binary header and an optional JSON payload.
Implementation
We start by defining a Command that interprets client requests. The following shows a simple Ping command implementation:
<code>#[derive(Debug)]
pub struct Ping;
#[derive(Serialize, Deserialize)]
pub struct PingResponse {
pub message: String,
}
impl Ping {
pub async fn execute(self) -> Result<PingResponse> {
Ok(PingResponse {
message: "PONG".to_string(),
})
}
}
</code>The Message struct defines the wire format:
<code>pub struct Message {
/// Identifier for payload deserialization
pub cmd_id: CommandId,
/// Unique request identifier for tracing and debugging (must be UTF‑8)
pub request_id: String,
/// Optional request payload
pub payload: Option<Bytes>,
}
</code>Additional header fields such as a checksum or timestamp may be added in the future.
Building a Message from a TCP connection is performed as follows:
<code>impl Message {
pub async fn try_from_async_read(tcp_connection: &mut TcpStream) -> Result<Self> {
let cmd_id = CommandId::try_from(tcp_connection.read_u8().await?)?;
let request_id_length = tcp_connection.read_u32().await?;
let request_id = {
let mut buf = vec![0u8; request_id_length as usize];
tcp_connection.read_exact(&mut buf).await?;
String::from_utf8(buf).map_err(|_| {
Error::InvalidRequest(InvalidRequest::MessageRequestIdMustBeUtf8Encoded)
})?
};
let payload_length = tcp_connection.read_u32().await?;
let payload = if payload_length > 0 {
let mut buf = vec![0u8; payload_length as usize];
tcp_connection.read_exact(&mut buf).await?;
Some(buf.into())
} else {
None
};
Ok(Self {
cmd_id,
request_id,
payload,
})
}
}
</code>Conclusion
This article described how rldb processes client requests, including serialization, deserialization, and request‑ID propagation. The next chapter will cover cluster bootstrapping, node discovery, failure detection, and the gossip protocol implementation.
Architecture Development Notes
Focused on architecture design, technology trend analysis, and practical development experience sharing.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.