An async Rust client for Tarantool, built on tokio. It speaks the binary protocol natively, is typed end to end, and stays out of your way.
[dependencies]
tarant = "0.1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }use serde::{Deserialize, Serialize};
use tarant::{Client, Iter, Update};
#[derive(Serialize, Deserialize)]
struct User {
id: u64,
name: String,
age: u32,
}
async fn example() -> tarant::Result<()> {
let client = Client::connect("tarantool://app:secret@127.0.0.1:3301").await?;
let users = client.space::<User>("users");
users.insert(&User { id: 1, name: "ann".into(), age: 30 }).await?;
let ann: Option<User> = users.get(1).await?;
let adults: Vec<User> = users.index("age").select(18).iterator(Iter::Ge).limit(100).await?;
users.update(1, Update::new().add(3, 1)).await?;
Ok(())
}The existing Rust connectors either target the C API from inside a Tarantool
process, or stopped at Tarantool 2.10 and never picked up streams, watchers or
name-addressed spaces. tarant is written against the 3.x protocol and the
tokio ecosystem, with the API surface a service actually needs.
- One connection, many requests. A
Clientis a cheap handle onto a background task that owns the socket. Requests are pipelined and matched to replies by theirsync, so concurrent callers never queue behind one another. Clone theClientinstead of opening more connections. - Types at the boundary. A tuple is anything
serdeturns into a MessagePack array: a struct, a tuple, aVec. Keys, call arguments and update operations are checked by the compiler before a byte reaches the wire. - Errors you can branch on. A rejection arrives as
ServerErrorcarrying the numericErrorCodeand the full error stack. Match onErrorCode::TUPLE_FOUND, never on a message string. - Reconnects that keep their promises. A dropped link is re-established
with backoff, the handshake replayed and every watcher re-subscribed.
Requests that were in flight fail with
Error::Closed, because the client cannot know whether the server ran them. - Nothing unsafe, nothing hidden.
#![forbid(unsafe_code)], every public item documented, zeroclippy::pedanticandclippy::nurserywarnings.
| Area | Support |
|---|---|
| CRUD | insert, replace, upsert, get, select, update, delete, typed through serde |
| Indexes | primary and secondary by name, every iterator type |
| Pagination | cursor-based (IPROTO_POSITION), correct under concurrent writes |
| Procedures | call and eval with typed arguments and returns |
| Transactions | streams, begin/commit/rollback, isolation levels, server-side timeout, rollback on drop |
| Watchers | box.broadcast subscriptions that survive reconnects, plus watch_once |
| SQL | query, execute, prepared statements, named parameters, on a client or in a transaction |
| Types | decimal, uuid, datetime, interval as first-class values, with serde |
| Session push | box.session.push streams read as they arrive |
| Protocol | feature negotiation, chap-sha1 auth, graceful-shutdown handling, MP_ERROR stacks |
Walking a whole space with offset is wrong the moment anything else writes
to it. page() returns a cursor the server resolves against the index, so
rows are neither skipped nor repeated:
use serde::Deserialize;
use tarant::{Client, Iter};
#[derive(Deserialize)]
struct Event {
id: u64,
payload: String,
}
async fn drain(client: &Client) -> tarant::Result<()> {
let events = client.space::<Event>("events");
let mut cursor: Option<String> = None;
loop {
let mut select = events.select(()).iterator(Iter::All).limit(500);
if let Some(position) = &cursor {
select = select.after(position);
}
let page = select.page().await?;
if page.rows.is_empty() {
break;
}
// ... process page.rows ...
cursor = page.position;
}
Ok(())
}use std::time::Duration;
use tarant::{Client, Isolation, TxOptions};
async fn transfer(client: &Client) -> tarant::Result<()> {
let mut tx = client.stream();
tx.begin(TxOptions::new()
.isolation(Isolation::ReadConfirmed)
.timeout(Duration::from_secs(5)))
.await?;
// ... requests on `tx`, executed strictly in order ...
tx.commit().await
}Dropping the stream mid-transaction sends a rollback, so an abandoned transaction does not hold locks until its timeout.
Interactive transactions need the server's MVCC transaction manager
(database.use_mvcc_engine: true). Without it, any DML inside a transaction
aborts it with TRANSACTION_YIELD.
A transaction can also be synchronous with TxOptions::new().synchronous()
(Tarantool 3.3+): the commit waits for a quorum of replicas to confirm it.
SQL runs on the same spaces as everything else, so the two APIs mix freely.
Statements that return rows go through query, the rest through execute;
both decode through serde exactly like tuples.
use tarant::sql::Named;
use tarant::Client;
async fn report(client: &Client) -> tarant::Result<()> {
let adults: Vec<(u64, String)> = client
.query("SELECT id, name FROM users WHERE age >= :min", (Named("min", 18),))
.await?
.rows;
let done = client.execute("DELETE FROM sessions WHERE expires < ?", 1_700_000_000u64).await?;
println!("removed {} rows", done.row_count);
// Parse once, run many times.
let insert = client.prepare("INSERT INTO events (kind, payload) VALUES (?, ?)").await?;
for kind in ["start", "stop"] {
insert.execute((kind, "{}")).await?;
}
Ok(())
}A plain value fills the next ?; a Named fills a :name. Prepared
statements survive a reconnect: the next use re-prepares transparently.
Tarantool's decimal, uuid, datetime and interval are MessagePack
extensions, not strings or numbers, and a field of that type accepts nothing
else. Each has a value type that encodes itself correctly, so it drops into a
tuple struct with no attributes:
use serde::{Deserialize, Serialize};
use tarant::{Datetime, Decimal, Interval, Uuid};
#[derive(Serialize, Deserialize)]
struct Order {
id: Uuid,
total: Decimal,
placed_at: Datetime,
valid_for: Interval,
}They parse from strings, compare and hash by value, and — behind Cargo features — convert to and from the crates you already use.
A watcher holds the latest value broadcast for a key and wakes when it changes. It survives reconnects: the client re-subscribes for you.
use tarant::Client;
async fn follow_config(client: &Client) -> tarant::Result<()> {
let mut config = client.watch("app.config").await?;
loop {
let value: serde_json::Value = config.get()?;
// ... apply it ...
config.changed().await?;
}
}Every queue operation is a stored
function whose name carries the tube — queue.tube.jobs:take — and
call sends that name verbatim, exactly as net.box does:
use tarant::{Client, Value};
type Task = (u64, String, String);
async fn work(client: &Client) -> tarant::Result<()> {
client.call::<(Task,)>("queue.tube.jobs:put", ("hello", Value::Map(vec![]))).await?;
let taken: Vec<Option<Task>> = client.call("queue.tube.jobs:take", 5.0f64).await?;
if let Some(Some((id, _state, _data))) = taken.into_iter().next() {
client.call::<(Task,)>("queue.tube.jobs:ack", id).await?;
}
Ok(())
}tests/queue.rs runs the whole lifecycle — put, take, ack, release, bury,
kick, delete, statistics — against a live server with the module installed.
One caveat, and it is queue semantics rather than the client's: the queue
pins a taken task to the session that took it and releases it when that
session ends. A reconnect opens a new session, so tasks taken before a
dropped link go back to ready, and a later ack on them fails with
"Task was not taken". If acks must survive reconnects, use the queue's
shared-session protocol: queue.cfg{ttr = N}, keep the UUID from
queue.identify() (a 16-byte binary — send it back as Value::Binary),
and re-identify after each reconnect.
Tarantool 3.0 and later. The client addresses spaces and indexes by name,
which the protocol gained in 3.0, so there is no schema fetch and no id cache
to go stale. Against 2.10–2.11, call, eval, transactions and watchers still
work; Client::server_info() reports what the handshake negotiated. CI runs
the integration suite against 3.0 and 3.8.
Rust 1.85 and later (edition 2024), on tokio 1.x. The time feature is
the one exception: it needs Rust 1.88, which is what time 0.3.47 — the
release that fixed RUSTSEC-2026-0009 — requires.
| Flag | Effect |
|---|---|
uuid |
convert to and from uuid::Uuid |
rust_decimal |
convert to and from rust_decimal::Decimal |
time |
convert Datetime to and from time::OffsetDateTime |
chrono |
convert Datetime to and from chrono::DateTime |
jiff |
convert Datetime to and from jiff::Timestamp and Zoned |
None are on by default; the crate's own value types need no dependency.
Unit tests cover the wire format against the byte sequences in the Tarantool
manual and need no server. The integration suite runs against a real instance,
and compose.yaml brings one up:
docker compose up --wait
TARANT_TEST_ADDR=tarantool://tarant:tarant@127.0.0.1:3301 cargo testWithout TARANT_TEST_ADDR the integration tests skip and the suite still
passes.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.