skaidb Rust driver
Repository: github.com/porcupin26/skaidb-rust — this page is the driver's README at
v0.294.1; the repository also holds its docs, examples and changelog. Install:
[dependencies]
skaidb = "0.294"
The native, synchronous Rust client for skaidb. It speaks the binary fast-path protocol directly — SCRAM-SHA-256 or Kerberos authentication, optional TLS, prepared statements with bound parameters, pipelined batches, streamed result sets and a connection pool — and it is the reference implementation of the wire protocol that every other official driver follows.
- Wire protocol specification: https://skaidb.org/docs/PROTOCOL.html
- skaidb documentation (SQL, types, clustering, security): https://skaidb.org/docs/
- Source: https://github.com/porcupin26/skaidb-rust (the crate with the
internal crates it depends on, tagged with the skaidb version); the same
code lives in
crates/skaidb-driverof the skaidb logins are behind thekerberosfeature, which links the MIT krb5 library throughcross-krb5. Install the development headers first (libkrb5-devon Debian/Ubuntu,krb5-develon Fedora/RHEL, bundled on macOS and Windows) and enable the feature:
skaidb = { version = "0.294", features = ["kerberos"] }
The feature is off by default and never builds on static musl targets.
Inside the skaidb monorepo the same crate is named skaidb-driver
(use skaidb_driver::…); everything below applies unchanged. Minimum
supported Rust version: 1.93.
Quick start
use skaidb::{Client, Response, Value};
fn main() -> Result<(), skaidb::DriverError> {
let mut client = Client::connect_with("db1:7000", "app", "secret")?
.with_database("app")?;
client.execute("CREATE TABLE IF NOT EXISTS people (PRIMARY KEY (id))")?;
let mut insert = client.prepare("INSERT INTO people (id, name, age) VALUES (?, ?, ?)")?;
client.execute_prepared(&mut insert, &[Value::Int(1), Value::String("Ada".into()), Value::Int(36)])?;
match client.execute("SELECT id, name FROM people WHERE age > 30 ORDER BY id")? {
Response::Rows { columns, rows } => {
println!("{columns:?}");
for row in rows {
println!("{row:?}");
}
}
other => println!("unexpected: {other:?}"),
}
Ok(())
}
Every call is synchronous and blocks the calling thread. A Client is one
connection: use it from one thread at a time, and use a Pool
to share connections across threads.
Connecting
There is no DSN string in Rust; the connect functions take the same options that other drivers spell in a URL or keyword arguments.
| Other drivers' option | Rust |
|---|---|
host / port |
the addr argument, "host:port" |
seeds=[...] |
connect_many(&endpoints, …) |
user / password |
the username / password arguments |
database= |
.with_database("name") |
consistency= |
set_consistency(…) or the *_with(…, consistency) calls |
tls=, tls_ca=, tls_insecure=, tls_server_name= |
TlsConfig::new(TlsVerify::…, server_name) passed to connect_many_tls |
auth_mechanism=gssapi, gssapi_spn= |
connect_gssapi[_tls] (feature kerberos) |
Signatures
impl Client {
/// Anonymous, one endpoint — a server with authentication disabled.
pub fn connect(addr: impl ToSocketAddrs) -> Result<Client, DriverError>;
/// SCRAM-SHA-256 to one endpoint. `addr` may resolve to several socket
/// addresses; all of them are kept as failover targets.
pub fn connect_with(addr: impl ToSocketAddrs, username: &str, password: &str)
-> Result<Client, DriverError>;
/// SCRAM-SHA-256 across a seed list, plaintext.
pub fn connect_many(endpoints: &[String], username: &str, password: &str)
-> Result<Client, DriverError>;
/// SCRAM-SHA-256 across a seed list; `tls = Some(cfg)` wraps every
/// connection, including failover reconnects, in TLS.
pub fn connect_many_tls(endpoints: &[String], username: &str, password: &str,
tls: Option<TlsConfig>) -> Result<Client, DriverError>;
/// Kerberos (SASL GSSAPI) instead of a password: `principal` is the
/// client identity, `target_spn` the node's service principal
/// (`skaidb/host.example.com@REALM`). Uses the ambient ticket cache.
pub fn connect_gssapi(endpoints: &[String], principal: &str, target_spn: &str)
-> Result<Client, DriverError>;
pub fn connect_gssapi_tls(endpoints: &[String], principal: &str, target_spn: &str,
tls: Option<TlsConfig>) -> Result<Client, DriverError>;
/// Bind the session to a database: runs `USE` now and after every reconnect.
pub fn with_database(self, database: &str) -> Result<Client, DriverError>;
pub fn database(&self) -> Option<&str>;
/// The endpoint the live connection uses; all candidates in preference order.
pub fn endpoint(&self) -> &str;
pub fn endpoints(&self) -> &[String];
/// Merge more failover targets (peers discovered after connecting to a seed).
pub fn add_endpoints(&mut self, more: &[String]);
/// Drop the live connection and dial another node (a different one first).
pub fn reconnect(&mut self) -> Result<(), DriverError>;
}
Endpoint selection and failover
With more than one endpoint the driver measures TCP-connect latency to each (800 ms probe timeout), dials the nearest reachable one first and keeps the rest as failover targets; a single endpoint is dialled directly. An endpoint counts as reachable only when it both connects and authenticates — a node accepting TCP while unhealthy does not swallow the attempt. skaidb is leaderless, so any member serves any statement.
When a statement hits a transport error (broken pipe, reset, EOF) the
driver dials another endpoint, re-authenticates, re-sends its Hello,
re-enters the bound database, re-applies the scan budget and retries the
statement once. This is transparent, and it means a non-idempotent
statement can run twice if the node died after applying it but before
answering. reconnect() does the same on demand, for a pool or a caller
with its own retry policy. Prepared statements do not survive a reconnect;
see Prepared statements for how the driver hides
that.
TLS modes
use skaidb::{Client, TlsConfig, TlsVerify};
let tls = TlsConfig::new(TlsVerify::CaFile("/etc/skaidb/skai-ca.crt".into()), "skaidb")?;
let seeds = vec!["db1:7000".to_string(), "db2:7000".to_string()];
let mut client = Client::connect_many_tls(&seeds, "app", "secret", Some(tls))?;
TlsVerify |
Meaning |
|---|---|
CaFile(path) |
Trust certificates chaining to this CA file — the cluster CA from skaidbsh certs gen. The production mode. |
System |
Trust the public-CA roots (Mozilla's bundle, compiled in, identical on every platform) — for servers behind a public certificate. |
Insecure |
Encrypt but verify nothing. Self-signed development servers only. |
server_name is the SNI and verification name and must match a SAN on
the server certificate. skaidb's own certificates carry DNS:skaidb, so
the name is usually skaidb rather than the host you dialled. TLS runs on
the same binary port (7000 by default); the server's
encryption.client_tls setting (off, opportunistic, required)
decides whether plaintext is still accepted. Passing None for tls is
plaintext.
Kerberos
Build with the kerberos feature, kinit as the client principal, then:
let tls = TlsConfig::new(TlsVerify::CaFile("/etc/skaidb/skai-ca.crt".into()), "skaidb")?;
let mut client = Client::connect_gssapi_tls(
&seeds, "[email protected]", "skaidb/[email protected]", Some(tls))?;
The authenticated identity comes from the ticket, not from principal.
Inside TLS the driver binds the GSS context to the server certificate
(RFC 5929 tls-server-end-point), so a server that requires channel
binding accepts the login and one that does not ignores it. Without the
feature, connect_gssapi* returns DriverError::Auth("this driver was built
without Kerberos (GSSAPI) support").
Consistency
pub enum Consistency { One, Quorum, All }
impl Client {
pub fn set_consistency(&mut self, consistency: Consistency);
}
Every read and write carries a consistency level: One (a single
replica), Quorum (a majority of the replicas — the driver's default) or
All. set_consistency changes the default for subsequent calls; every
executing call has a *_with(…, consistency) twin that overrides it for
one statement. DDL always runs at quorum regardless of the level sent.
use skaidb::Consistency;
client.set_consistency(Consistency::One); // fast local reads
let r = client.execute_with("SELECT count(*) FROM t", Consistency::All)?;
Executing statements
impl Client {
pub fn execute(&mut self, sql: &str) -> Result<Response, DriverError>;
pub fn execute_with(&mut self, sql: &str, consistency: Consistency) -> Result<Response, DriverError>;
}
Any statement — DDL, DML, SELECT, CALL, SHOW … — goes through
execute. The result is a Response:
| Variant | Returned for |
|---|---|
Rows { columns: Vec<String>, rows: Vec<Vec<Value>> } |
a result set; cells are positional and match columns |
Mutation { affected: u64 } |
INSERT / UPDATE / DELETE |
Ddl |
a successful DDL statement |
ResultSets { sets: Vec<(Vec<String>, Vec<Vec<Value>>)> } |
a CALL whose procedure body EMITted several result sets, in order, the call's final result last |
Prepared { id, params }, RowsHeader, RowsChunk, RowsEnd |
protocol frames consumed by prepare and query_stream; not returned by execute |
Error(String) |
never returned — the driver converts it into DriverError::Server |
Rows are schema-less documents: a column a row does not carry reads as
Value::Null. String literals in SQL use single quotes; double quotes are
identifiers.
Prepared statements
pub struct Prepared { pub params: u16, /* private id and template */ }
impl Client {
pub fn prepare(&mut self, sql: &str) -> Result<Prepared, DriverError>;
pub fn execute_prepared(&mut self, stmt: &mut Prepared, params: &[Value]) -> Result<Response, DriverError>;
pub fn execute_prepared_with(&mut self, stmt: &mut Prepared, params: &[Value], consistency: Consistency)
-> Result<Response, DriverError>;
pub fn execute_batch(&mut self, stmt: &mut Prepared, rows: Vec<Vec<Value>>) -> Result<u64, DriverError>;
}
Placeholders are ?, positional. prepare parses the statement on the
server once and returns a handle; Prepared::params is the number of
placeholders. Parameters are bound as Values, typed on the wire, so a
string containing a quote or a semicolon is only ever data — never build
SQL from user input when a parameter will do.
let mut upd = client.prepare("UPDATE people SET age = ? WHERE id = ?")?;
if let Response::Mutation { affected } = client.execute_prepared(&mut upd, &[Value::Int(37), Value::Int(1)])? {
println!("updated {affected}");
}
A handle is valid only on the connection that created it. That is why the
calls take &mut Prepared: when a failover happens mid-call, the driver
re-prepares the template on the new connection, updates the handle in
place and retries once, so callers never see a stale id.
execute_batch runs one prepared statement once per parameter row in a
single round trip (the executemany wire op) and returns the total
affected count. Each row autocommits exactly like a looped
execute_prepared; on a failure the server error names the failing row
index and how many rows applied before it, and those earlier rows stay
applied. The whole request must fit one frame (64 MiB).
let mut ins = client.prepare("INSERT INTO events (id, kind) VALUES (?, ?)")?;
let rows: Vec<Vec<Value>> = (0..1000)
.map(|i| vec![Value::Int(i), Value::String("click".into())])
.collect();
let n = client.execute_batch(&mut ins, rows)?;
Only SELECT, INSERT, UPDATE, DELETE, CALL and EXPLAIN of those
can be prepared; DDL goes through execute.
Pipelining
impl Client {
pub fn pipeline(&mut self, stmts: &[&str]) -> Result<Vec<Response>, DriverError>;
pub fn pipeline_with(&mut self, stmts: &[&str], consistency: Consistency) -> Result<Vec<Response>, DriverError>;
}
All statements are written before any response is read, so a batch pays
one round trip of link latency instead of one per statement. They execute
serially, in order, with ordinary session semantics (a USE mid-batch
affects the statements after it). Per-statement failures come back
inline as Response::Error(msg) entries at that statement's index — a
failed statement does not stop the ones after it, and the call itself
returns Ok. The whole batch is retried once on a fresh connection if the
node dies mid-flight, with the same idempotency caveat as execute.
let out = client.pipeline(&["INSERT INTO t (id) VALUES (1)", "INSERT INTO t (id) VALUES (1)"])?;
assert!(matches!(out[0], Response::Mutation { .. }));
if let Response::Error(msg) = &out[1] { eprintln!("second insert failed: {msg}"); }
Streaming result sets
impl Client {
pub fn query_stream(&mut self, sql: &str) -> Result<RowStream<'_>, DriverError>;
pub fn query_stream_with(&mut self, sql: &str, consistency: Consistency) -> Result<RowStream<'_>, DriverError>;
}
pub struct RowStream<'a> {
pub columns: Vec<String>, // empty for non-row statements
pub affected: u64, // set when the statement was a mutation
// …
}
impl Iterator for RowStream<'_> { type Item = Result<Vec<Value>, DriverError>; }
execute buffers the whole result set in one frame, which is wrong for a
table scan. query_stream receives rows in chunks and holds at most one
chunk in memory, and it is also how a result larger than the server's scan
budget is read at all. Iterate the stream; each item is one row or the
error that ended the stream.
let mut n = 0u64;
{
let stream = client.query_stream("SELECT id, doc FROM big ORDER BY id")?;
for row in stream {
let row = row?;
n += 1;
}
}
println!("{n} rows");
The abandon/drain rule. A RowStream borrows the Client exclusively
until it is finished. Dropping it before the end does not cancel the
query: Drop reads and discards every remaining frame so the connection
is back at a request boundary for the next call. Breaking out of a loop
over a billion-row scan therefore still costs receiving the whole result.
To abandon a large stream cheaply, drop the Client (or the pooled
connection) instead of the stream and open a new one — or bound the query
with LIMIT in the first place. Failover applies only to sending the
request; a node dying mid-stream surfaces as an Err item and marks the
stream finished, and the caller decides whether to re-run the query (the
next call on the client fails over as usual). Non-row statements yield an
empty stream with affected set.
Streams (change feeds)
A CREATE STREAM is an ordinary table (_stream_<name>) that logs
changes; the driver polls it with keyset pagination:
impl Client {
/// Events after `after` (oldest first) and the cursor to pass next time.
pub fn stream_poll(&mut self, stream: &str, after: &str, limit: usize)
-> Result<(Vec<Vec<Value>>, String), DriverError>;
}
Each event row is id, op, k, ts, doc. Start with an empty cursor, keep
the returned one, and sleep when a poll comes back empty; the cursor
resumes exactly where you stopped, across restarts. For push delivery
subscribe to $stream/<db>/<name> with any MQTT client — the events are
identical.
let mut cursor = String::new();
loop {
let (events, next) = client.stream_poll("big_orders", &cursor, 500)?;
for ev in &events { println!("{ev:?}"); }
cursor = next;
if events.is_empty() { std::thread::sleep(std::time::Duration::from_millis(500)); }
}
Scan budget
impl Client {
pub fn set_scan_budget_rows(&mut self, rows: u64) -> Result<(), DriverError>;
}
Runs SET SCAN BUDGET ROWS n (0 = SET SCAN BUDGET DEFAULT) and
replays it after every reconnect, so a silent failover cannot revert the
session to node defaults. The budget is tightening-only server-side.
Connection pool
pub struct Pool { /* … */ }
impl Pool {
pub fn new<F>(maxsize: usize, make: F) -> Pool
where F: Fn() -> Result<Client, DriverError> + Send + Sync + 'static;
pub fn acquire(&self) -> Result<Client, DriverError>;
pub fn release(&self, client: Client);
pub fn with<T, F>(&self, work: F) -> Result<T, DriverError>
where F: FnOnce(&mut Client) -> Result<T, DriverError>;
pub fn idle_len(&self) -> usize;
pub fn close(&self);
}
Pool is Send + Sync; share it behind an Arc. Connections are built
by the closure, so they inherit whatever it configures — seeds, TLS,
credentials, session database. maxsize bounds the connections kept
idle, not the number checked out: a burst opens extras and the surplus
is dropped on return, so callers never block waiting for a slot. Idle
connections are handed out as-is; a connection the server closed while it
sat idle re-dials itself on first use, so no liveness probe is needed.
close drops every idle connection and makes acquire fail; connections
checked out at the time are dropped when returned. new panics if
maxsize is 0.
use std::sync::Arc;
use skaidb::{Client, Pool};
let eps = vec!["db1:7000".to_string(), "db2:7000".to_string()];
let pool = Arc::new(Pool::new(8, move || {
Client::connect_many(&eps, "app", "secret")?.with_database("app")
}));
let n = pool.with(|c| c.execute("SELECT count(*) FROM t"))?;
Values and type mapping
Value is the driver's parameter and result type; the composite types it
contains are re-exported alongside it.
pub enum Value {
Null,
Bool(bool),
Int(i64),
Float(f64),
Decimal(Decimal), // Decimal { mantissa: i128, scale: u32 }; Decimal::new(12345, 2) == 123.45
String(String),
Bytes(Vec<u8>),
Uuid(Uuid), // Uuid(pub [u8; 16]); Uuid::parse_str("…"), Display prints hyphenated
Timestamp(i64), // Unix time in MILLISECONDS
Array(Vec<Value>),
Document(Document), // Document(pub BTreeMap<String, Value>); ::new(), .insert(k, v), .get(k), .get_path("a.b")
}
| skaidb type | Value |
Notes |
|---|---|---|
null |
Null |
a missing field reads as Null |
bool |
Bool |
|
int64 |
Int(i64) |
|
float64 |
Float(f64) |
|
decimal |
Decimal |
exact; to_f64() for a lossy view |
string |
String |
UTF-8 |
bytes |
Bytes |
Display prints 0x… hex |
uuid |
Uuid |
16 raw bytes |
timestamp |
Timestamp(i64) |
milliseconds since the Unix epoch, UTC |
array |
Array |
a vector column is an Array of Float |
document |
Document |
keys ordered |
Only int, float, string, bool, null, array and document literals
can be written directly in SQL; decimal, uuid, bytes and timestamp
values reach the database as bound parameters (Value::Decimal(…),
Value::Uuid(…), …), which is another reason to prefer prepared statements
over string-built SQL.
Value implements Display (composite values render as JSON) and has
type_of() -> ValueType, is_null(), to_json() / Value::from_json
(via serde_json) and total_cmp for the database's ordering.
Errors
pub enum DriverError {
Io(std::io::Error), // transport: connect, read, write, TLS handshake
Proto(ProtoError), // a malformed frame from the server
Server(String), // the server rejected the statement (its message)
Auth(String), // SCRAM/GSSAPI denied, server signature mismatch, feature missing
NoEndpoint(String), // no endpoint connected + authenticated (last failure inside)
}
Server carries the server's error text as-is — syntax errors, unknown
tables, privilege denials, constraint violations, scan-budget refusals,
"server does not support pipelined requests" and so on. Io is what a
failover has already been attempted for (once): if you see it, both the
original node and every peer failed. DriverError implements
std::error::Error and Display, and converts From io::Error and
ProtoError.
Transactions
Against a standalone server, BEGIN / COMMIT / ROLLBACK are ordinary
statements sent through execute and the transaction is per-connection
session state. On a cluster there are no transactions: every statement
autocommits and BEGIN is refused. To make several statements atomic on
a cluster, put them in a procedure (CREATE PROCEDURE … BEGIN … END) and
CALL it — one round trip, and a CALL p(?) binds through the normal
prepared path. Because a failover can replay a statement once, make writes
idempotent (INSERT … ON CONFLICT DO UPDATE, keyed UPDATEs) where a
duplicate would matter.
Client identification and versions
After authenticating (and after every reconnect) the driver sends a
best-effort Hello frame naming itself rust with the crate version from
CARGO_PKG_VERSION; the pair shows up in the server's drivers table
(client_name, client_version). An older server answers the opcode with
an error, which is ignored.
Feature gates by server version, for clusters not yet on the driver's release:
| Call | Needs server |
|---|---|
prepare / execute_prepared |
≥ 0.17.0 |
execute_batch |
≥ 0.87.0 |
pipeline (tagged requests) |
returns Server("server does not support pipelined requests …") on older servers |
query_stream |
rejected with a server error on older servers; use execute |
Hello self-identification |
≥ 0.203.0 (ignored otherwise) |
Value re-exports (skaidb::{Value, Consistency, Response, ProtoError}) |
driver ≥ 0.290.4; older versions need skaidb-types / skaidb-proto as direct dependencies |
The driver's version is the skaidb version: driver X.Y.Z is the one
shipped and tested with server X.Y.Z, and the protocol is backward
compatible, so a newer driver talks to an older server (minus the gated
calls above) and vice versa.
Examples
The examples/ directory of the crate holds:
basic_usage.rs— DDL, prepared inserts, queries, updates, error handling.bench.rs— a multi-threaded load generator over one-shot and prepared statements (cargo run --release --example bench -- <addr> <user> <pass> <mode> <ops> <threads>).abench.rs— a barrier-started, time-boxed load generator with optional TLS (--tls-ca/--tls-insecure), used for the encryption A/B benchmark.
License
SSPL-1.0, the same license as skaidb. See LICENSE.