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 Python client

pip install, a connection that is a session, parameters that cannot become syntax, and the two distinctions Python is most likely to lose.

pip install tessaridb-client

Python 3.11 or newer, no dependencies, Apache-2.0. The distribution is tessaridb-client; the import is tessaridb.

Not on PyPI yet. Until it is: pip install git+https://github.com/tessaridb/tessaridb-sdk-python

A first read

import tessaridb

with tessaridb.connect("127.0.0.1:9080", user="root", password="…") as db:
    reply = db.execute("USE NAMESPACE prod; USE DATABASE app; SELECT * FROM person;")
    records = reply.outcomes[-1]
    for row in records.rows:
        print(row.identity, row.value)

The address is a bare host:port — there is no URL scheme, because there is no second thing it could be.

One outcome per statement, in order, so outcomes[-1] is the SELECT and the two USEs are the Dones in front of it. Credentials are optional: a store with no users declared is open and runs anything, and the first DEFINE USER closes it.

The credential is spent once. It travels on the first request of the connection and never again — the store verifies a password with Argon2id at the OWASP floor, so presenting it per statement would pay that cost per statement.

One connection is one session

USE NAMESPACE prod; is still in force on the next execute on that connection. Two connections are two sessions and share nothing but the store.

That has a direct consequence for pools: a pooled connection that reconnects has forgotten its USE, and code that set it once at start-up reads 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.

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).

import tessaridb

tls = tessaridb.tls_context("ca.pem")   # or tls_context() for the system's store
conn = tessaridb.connect("db.example:9080", "ada", password, tls=tls)
http = tessaridb.HTTPClient("db.example:8000", "ada", password, tls=tls)

Every connection checks the node's certificate chain and that it names 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. A context that does not check both is refused (ValueError), so there is no way to turn the check off. A failed handshake raises TlsError, which is not retried. From 0.8.0.

A refusal says what to do next

From 0.9.0 a refusal carries its class — Refused.refusal_class (and HTTPError.refusal_class), 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.

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 execute follows 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: RedirectLoop, StaleRedirect, WrongNode and NotFollowable. From 0.7.0.

Parameters cannot become syntax

reply = db.execute(
    "USE NAMESPACE prod; USE DATABASE app; SELECT * FROM person WHERE name = $who;",
    {"who": tessaridb.Text("'; DROP COLLECTION person; --")},
)

A parameter travels as an encoded value, not as text. This is the reason the wire protocol exists: a value the server has to parse is a value that can be parsed as something else. The hostile string above comes back as the string it is, and the collection it names is still there.

Building a statement

from tessaridb import select, compare, Text

statement = (
    select("person")
    .field("name")
    .where(compare("kind", "=", Text("customer")))
    .order_by("created", descending=True)
    .limit(50)
)
rendered = statement.render()
db.execute(rendered.script, rendered.parameters)

The builder covers SELECT, CREATE, UPDATE and DELETE over one collection. Anything else you write as a script and send as one, which is always available. On a cluster, .staleness("30s") and .answered_by("LEADER") say which node may answer rather than what the answer holds — see reads, routing and staleness.

Following changes

watcher = tessaridb.connect("127.0.0.1:9080")
watcher.execute("USE NAMESPACE prod; USE DATABASE app;")
changes = watcher.subscribe(from_=0)
for change in changes:
    print(change.sequence, change.table, change.identity, change.removed)

Subscribing consumes the connection. After it the socket delivers changes and no longer answers statements; a client that wants both opens two. The USE before it is not decoration: the table is resolved against that connection's own session, and a connection that has named no namespace has nothing to watch — the node refuses, on the feed, and the refusal arrives as Refused carrying its own words.

The node drops a subscriber that stops reading after thirty seconds, and nothing is lost when it does — the log is the buffer. The loop simply ends; the way back is a new connection subscribed from changes.resume_from, the last sequence handled plus one.

On a feed over a split table each change also carries a cursor, and changes.resume_cursor holds the last one: subscribe again with subscribe(table=…, cursor=changes.resume_cursor) and the node continues after it. 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:

feed = conn.subscribe(0, "orders", condition="total > $least", parameters={"least": Integer(100)})
for arrived in feed:
    if isinstance(arrived, tessaridb.Progress):
        continue  # resume_from has moved past the changes it skipped
    print(arrived.identity, arrived.removed)

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: NodeTooOld. 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.

import threading
import tessaridb
from tessaridb import Ack, Consumer, Leave, Nack

conn = tessaridb.connect("127.0.0.1:9080")
consumer = Consumer(conn, "app", "main", "jobs", "workers", batch=10)

# Automatic: returning acknowledges the message, raising hands it back at once.
def handle(message):
    print(message.position, message.deliveries, message.value)

threading.Thread(target=consumer.run_auto, args=(handle,)).start()
# ... later, from anywhere:
consumer.stop()

# Manual: return Ack(), Nack(delay=5.0), or Leave() for the group's deadline.
consumer.run_manual(lambda message: Ack())

The loop is synchronous, like every call in this client — run it in a thread of its own. 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, and the group is declared in the store rather than by the consumer. The behaviour is the protocol repository's spec/consumer-v1.md, which every client follows.

A space as a cache

Cache makes each cache use one call over a connection you hold; a ttl is seconds or a timedelta (node 0.15.0-beta and later):

cache = Cache(conn, "app", "main", "cache")
cache.set("session:abc", Text("ada"), ttl=1800)
page = cache.get_or_set("page:/", 60, lambda: Text("<html>"))
lease = cache.lock("nightly-report", ttl=30)
if lease is not None:
    lease.release()

ttl() keeps the store's two absences apart: a Duration, NULL for never, NONE for no key.

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.

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 is in no error or repr this client produces:

import tessaridb
from tessaridb import Text, Vault

conn = tessaridb.connect("127.0.0.1:9080")
tessaridb.unseal(conn, store_passphrase)            # the store's key, for ten minutes

vault = Vault(conn, "app", "main", "team")
vault.write("github", {"password": Text("hunter2")})  # creates or edits, keeps recipients
page = vault.list(limit=100)                         # ids only, never a value
secret = vault.reveal("github", ["password"])

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 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.

The two distinctions Python is most likely to lose

none is not null. The field is not present, versus the field is present and holds nothing. Python has one None for both, so this client has tessaridb.NONE and tessaridb.NULL and never collapses them.

An integer is 64-bit, not a float. 9223372036854775807 survives a round trip. A client that read every number as a Python float would lose it silently, which is exactly what a JSON-shaped client does.

Files, backups and probes

http = tessaridb.HTTPClient("127.0.0.1:8000", user="root", password="…")
http.put("prod", "app", "assets", "logo.png", data)
listing = http.listing("prod", "app", "assets")

A listing's path carries a leading slash — a file written as logo.png lists as /logo.png. It is the specified shape, and it is not the name to hand back: strip exactly one slash, never all of them, because a file may genuinely be named with one.

Appending events to a series

landed = http.append("prod", "metrics", "readings", [
    tessaridb.Object({"sensor": tessaridb.Text("s1"), "v": tessaridb.Float(21.5),
                      "at": tessaridb.Datetime(1_790_676_000, 0)}),
])

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 — raises NotAnEvent before anything is sent. See Time-series for what a series does with the events.

The Python client — TessariDB