TessariDBDocs
  • 0.34.2-betalatest
  • 0.34.1-beta
  • 0.34.0-beta
  • 0.33.4-beta
  • 0.33.3-beta
  • 0.33.2-beta
  • 0.33.1-beta
  • 0.33.0-beta
  • 0.32.0-beta

The Rust client

One connection is one session, values travel in the store's own codec, and following changes is a different thing from asking questions.

cargo add tessaridb-client

The client is a separate crate from the engine — tessaridb-client, licensed Apache-2.0, while the engine is BUSL-1.1. Adding one does not bring the other, which is deliberate: the licences are different and a single name would carry both. cargo add tessaridb gets the database, because that is what somebody typing the product's name into a database project means.

let mut client = Client::connect("127.0.0.1:9080").await?;
let answers = client
    .run("USE NAMESPACE prod; USE DATABASE orders; SELECT * FROM users;", None)
    .await?;

One Answer per statement, in order. The second argument is the credentials to run as — Some((name, password)), or None against a store that has no users yet.

The greeting is where a mismatch is caught

connect exchanges greetings before anything else, and a wrong protocol or a wrong major version is refused there. That placement is the point: a version mismatch discovered mid-conversation arrives as a decode failure that reads like corruption, and sends whoever is holding the error to look for a network fault that does not exist.

The four magic bytes are judged on their own, before the version bytes are read — because a peer that is not a node owes you nothing and may send three bytes and hang up. Waiting for all six first would report truncated stream when the truthful answer is that address is not a TessariDB node.

One connection is one session

USE NAMESPACE prod; is still in force on the next call on the same client. Two clients are two sessions and share nothing but the store.

That has a direct consequence for connection pools: a pooled connection that reconnects has forgotten its USE, and code that set it once at start-up will read from the wrong database after the first reconnect — with no error, because reading a namespace that exists is not a fault. Send the USE with the work, or re-send it on every checkout.

Over TLS

A node given a certificate speaks TLS 1.3 on both ports and nothing else, and a node in a cluster serves clients in the clear only when its operator chose to (node 0.21.0-beta and later; Encryption in transit).

use tessaridb_client::{Client, Operations, Tls};

// `pem` holds the authority's certificate — the bytes of ca.pem
let tls = Tls::trusting_pem(&pem)?;
let mut client = Client::connect_tls("db.example:9080", &tls).await?;
let node = Operations::at("db.example:8000").with_tls(tls);

Every connection checks the node's certificate chain against that trust and its name against the host you dialled — a DNS name, or an IP address against the certificate's IP entries — including each node a redirect sends the request to. There is no switch that skips either check. A failed handshake is Error::Tls, which is never retried. Tls::platform() trusts the operating system's store, with the platform-roots feature; default-features = false builds a client without TLS, which reaches only a node serving in the clear. From 0.8.0.

A redirect is followed

On a cluster, a request sent to a node that should not answer it comes back as a redirect (where to go), and Client::run and Client::send follow it before returning — at most three hops, never to an older leadership than one already followed, the session's namespace and database selected again on arrival, and only to the node the redirect named. A settled redirect moves the connection there; a transient one answers there and leaves the connection where it was. Each way following can stop is its own error: Error::RedirectLoop, Error::StaleRedirect, Error::WrongNode and Error::NotFollowable. A client built over a stream it cannot dial again (Client::with_stream) returns the redirect as Error::Redirected instead. From 0.7.0.

Parameters cross unchanged

let answers = client
    .run_with(
        "SELECT * FROM users WHERE name = $who;",
        None,
        [("who", Value::from("ada"))],
    )
    .await?;

A binding is a Value, not text — which is the same point the method exists to make, stated in its signature. The values travel in the store's own codec, so every type crosses without being re-read, and the node never parses a supplied value as text. That is what keeps the grammar's rule intact across a socket: a supplied value cannot become syntax, and being remote does not give that back.

A client that built the statement by formatting the value into a string would have thrown that property away at the last possible moment — which is why the method that takes bindings exists beside the one that does not.

Saying which node may answer

let query = Select::from("orders")
    .staleness("30s")               // no node further behind than this may answer
    .answered_by(Answerer::Leader)  // and it must be where writes are decided
    .build()?;

These two arrived after the crate that is on crates.io. Until the next release is published, take them from the repository: tessaridb-client = { git = "https://github.com/tessaridb/tessaridb-sdk-rust" }

Two clauses, and they are separate controls rather than one: a follower at zero lag is level, not authoritative, so no freshness bound expresses this must come from where writes are decided. The builder checks the shape of a span and never its value — a bound tighter than the cluster's floor is the node's refusal to make, and its message names the floor. See reads, routing and staleness.

Following changes

let feed = client.follow(&asked).await?;

follow consumes the client. The connection stops being a conversation: a socket delivering changes is not also answering scripts, and a type that let you try would be promising a multiplexing this protocol does not do. A caller that wants both opens two connections — which is the honest shape, and it is one line rather than a hidden queue.

A feed over a split table reads several logs, so each Change there carries a cursor; resume with Follow::resuming_at(cursor) and the node continues after that change. On any other feed cursor is None and Follow::resuming_after(sequence) is the way back. From 0.2.0.

A feed narrowed by a condition

A feed over one table can be narrowed by a condition — TessariQL without WHERE, its values bound rather than written into it:

use tessaridb_client::{Arrival, Follow, Value};

let mut feed = client
    .follow(
        &Follow::everything()
            .to_table("orders")
            .matching("total > $least")
            .binding("least", Value::from(100_i64)),
    )
    .await?;
while let Some(arrival) = feed.next().await? {
    match arrival {
        Arrival::Change(change) => println!("{} {}", change.table, change.id),
        Arrival::Progress(progress) => resume_after = progress.sequence,
    }
}

A record that stops matching arrives as a removal, so a mirror applying the feed holds exactly the matching records. A feed that skipped changes hands over a Progress — store it as you store a change's position, or the resume point falls behind a pruned log. Only a node of minor 4 (0.33.0-beta and later) reads a condition, and an older one would send every change, so the client refuses before anything is sent: Error::NodeTooOld { found, needed: 4 }. See narrowing a feed. From 0.10.0.

Consuming a topic

A topic's consumer group hands each message to one member and forgets it only when it is acknowledged. Consumer reads under a group and calls your function once per message, in order. From 0.3.0, against an engine at 0.12.0-beta or later.

use tessaridb_client::{Client, Consumer, Settle};

let client = Client::connect("127.0.0.1:9080").await?;
let mut consumer = Consumer::new(client, ("app", "main"), "jobs", "workers")?;
let stop = consumer.stopper(); // stop.stop() from anywhere ends the loop

// Automatic: Ok acknowledges the message, Err hands it back at once.
consumer.run_auto(|message| async move {
    println!("{} (delivery {})", message.position, message.deliveries);
    Ok::<(), std::io::Error>(())
}).await?;

// Manual: your function decides — Ack, Nack(Some(delay)), or Leave it for the
// group's deadline to hand out again.
consumer.run_manual(|message| async move { Settle::Ack }).await?;

Both modes are at least once: make an effect outside the store idempotent, keyed by the topic, the group and message.position. The group, not the connection, holds the state, so a restarted process carries on where the group stands. The group is declared in the store, never by the consumer — its deadline is a choice about your work that no client can guess. The behaviour is the protocol repository's spec/consumer-v1.md, which every client follows.

A space as a cache

Space borrows a connection and makes each cache use one call (node 0.15.0-beta and later, tessaridb-client 0.5.0):

let mut cache = Space::new(&mut client, ("app", "main"), "cache")?;
cache.set("session:abc", Value::from("ada"), Some(Duration::from_secs(1800))).await?;
let page = cache.get_or_set("page:/", Duration::from_secs(60), || async { Value::from("<html>") }).await?;
if let Some(lease) = cache.lock("nightly-report", Duration::from_secs(30), None).await? {
    cache.release(lease).await?;
}

The two rules are the page's own: a plain set clears an expiry, and a lock is a lease whose release is an expiring conditional write, never a delete. The full surface is A cache over HTTP and in the clients.

Appending events to a series

use tessaridb_client::{Operations, Value};

let landed = Operations::at("127.0.0.1:8000")
    .as_user("app", &password)
    .series("prod", "metrics", "readings")
    .append(&events) // each a Value::Object
    .await?;

From 0.4.0, against an engine at 0.14.0-beta or later. The batch lands whole or not at all, and the answer is how many landed. It is sent once: appending is not idempotent, so a transport failure after the request left is yours to judge. A kind an event cannot carry — bytes, a range, a non-finite float — is Error::NotAnEvent before anything is sent. See Time-series for what a series does with the events.

A vault, and its passphrase

A vault (DEFINE VAULT) keeps SECRET fields encrypted in every copy that is not a running, unsealed node. The passphrase goes in a frame of its own, never in a statement, and appears in no error or Debug this crate produces:

use std::collections::BTreeMap;
use tessaridb_client::{Client, Value, Vault};

let mut client = Client::connect("127.0.0.1:9080").await?;
client.unseal(&store_passphrase).await?;          // the store's key, for ten minutes

let mut vault = Vault::new(&mut client, ("app", "main"), "team")?;
let fields = BTreeMap::from([("password".to_owned(), Value::from("hunter2"))]);
vault.write("github", &fields).await?;            // creates or edits, keeps recipients
let page = vault.list(None, Some(100)).await?;    // ids only, never a value
let secret = vault.reveal("github", &["password"]).await?;

A vault declared DEFINE VAULT team PASSPHRASE '…' opens with its own passphrase instead, and the store's opens nothing in it: vault.status(), vault.unseal(…), vault.seal() and vault.change_passphrase(…) act on that vault alone, and status().custody says which kind a vault is. An unseal lasts the node's period (ten minutes unless it was started otherwise) and then closes by itself; a refusal after a run of wrong passphrases means wait, and is not retried here. What unsealing opens, and what a vault with its own passphrase changes, is on Vaults.

Errors worth keeping apart

the client saysmeans
not this protocolthe four bytes were not TESS — usually the wrong port
wrong versiona major this build does not implement, carrying both numbers
refusedthe node answered, and the answer is no — the message is the node's own
a transport errorthe socket, and nothing about the store

A refusal is not an error in the client's sense. It is the node's answer, in the node's words, already naming the place in the script — so it is worth surfacing verbatim rather than wrapping in a message of your own.

A refusal says what to do next

From 0.9.0 a refusal carries its class — class on Error::Refused (and on Error::HttpRefused), typed as RefusalClass. Branch on it rather than on the message, which is the store's own prose and changes between releases. It is None from a node before 0.30.0-beta, which sends the words alone, and a class this build does not know reads as unknown, which is not retried.

What the client does not do

No schema migrations, and nothing that stands in front of TessariQL. The language is the interface: a layer that replaced it would be a second dialect to learn, with its own gaps, its own escaping and its own version.

Two modules sit beside it rather than in front of it. query builds statements under one guarantee — a value never reaches the script text; every value a caller supplies becomes a bound parameter, and only names and directions are interpolated, each checked first against a pattern deliberately narrower than the node's own lexer. It does not promise the text it emits parses, and says so: that needs the node's parser, which this crate does not depend on. mapping goes the other way, turning an answer into the types a caller declared, so reading one integer is not three matches rewritten at every call site.

The Rust client — TessariDB