A dependency-free client for cheetah-server. The protocol is newline-delimited
text over TCP, so this needs nothing but the standard library.
from cheetah_db import CheetahClient, kv
with CheetahClient("127.0.0.1", 4455, database="app") as conn:
kv.put_json(conn, "user:42", {"name": "Ada"}, upsert=True)
print(kv.get_json(conn, "user:42"))Requires Python 3.10+. There is no package on PyPI: add binders/python to
sys.path (or install it with pip install -e binders/python) and import
cheetah_db.
Each is usable on its own; higher ones are conveniences over lower ones.
| Module | What it owns |
|---|---|
protocol |
Pure codec. build_command, build_key_value_command, parse_response, parse_items, parse_cursor, decode_payload, decode_transport_payload, encode_argument, raw_argument. No socket, no state. |
hosts |
Where a client should actually dial: 0.0.0.0 → 127.0.0.1, and the WSL host's address as a fallback candidate. |
client |
CheetahClient (one socket, lock-serialized request/response, reconnect, inactivity grace) and ThreadLocalClientPool (one socket per thread). binary=True switches the socket to the byte-wise protocol. |
binary |
The byte-wise transport: frames, value type tags, BinarySession, encode_command_line/decode_response, encode_request for explicitly typed arguments. |
alias |
ALIAS — what a client cannot derive on its own: list_commands, list_argument_keys, resolve_command, describe_types, table_profile, list_profiles, alias_digest, load_session. |
kv |
The two-step write and its reads: put_value/get_value, put_json/get_json, put_bytes/get_bytes, put_values_batch (PAIR_PUT_BATCH), delete_pair, pair_purge, scan_page/scan_prefix/scan_all, pair_summary. |
graph |
The whole GRAPH_* surface: nodes, edges (single and batch), neighbors/degree/neighbor_types, query, recall/recall_batched, detached recall_async/fetch_recall/await_recall, similar, term_index (including weighted/tokens/trigrams stats), and the ambiguity trio. Recall results expose base decay, adaptive cache_decay, decay_relations, and decay_profile. |
records |
Multi-field tables: define, alter, compact, schema, tables, set_row, get_row, scan/iter_rows, delete_row, drop_table, plus field_spec. |
jobs |
The JOB micro-command: submit, status, fetch, results, await_job, supports_job_api. |
batch |
BATCH — one command, N argument sets, one round trip: build_batch, run_batch, run_batch_chunked, run_batch_async, batch, parse_batch_response, plus BatchCollector/AutoBatchPolicy, the client-level coalescing. |
predict |
Prediction tables: set_value, query, train, context_adjust, inherit, inherit_batch, backend, bench. |
admin |
The server and the registry of databases, not the data: create_database, list_databases, use_database, reset_database, system_stats, log_flush, file_checkpoint, cluster_*, fork_assign. |
keys |
Key-building primitives: fixed-width hex_segment/unhex, sha1, and integer quantize/bucketize/bucket_sweep. |
vocabulary |
TokenVocabulary — a persisted string → uint32 allocator, both directions. |
database |
CheetahDatabase — the plumbing an application writes around all of the above. Subclass it. |
server |
start_server/ensure_server_binary — spawn a server for development and tests. |
The wire is text by default: 42 costs two bytes, 0.25 four, a 32-byte key
sixty-four in hex. Pass binary and the same connection sends frames
instead — the command as a 2-byte index, each value in its own type.
conn = CheetahClient("127.0.0.1", 4455, database="app", binary=True)
conn.connect()
# or state the widths you want as defaults:
CheetahClient("127.0.0.1", 4455, binary={"uint": 4, "float": 4})Nothing above the socket changes. kv, graph, records, predict,
admin and CheetahDatabase keep building command lines; the client transcodes
them and turns each response frame back into the canonical line. A command added
to the server tomorrow works over binary the day it exists, because the server
decodes a frame into that same line before routing it.
What you get on connect is a BinarySession (conn.binary): the negotiated
widths, the command index, the argument-key dictionary, and the digest to check
a cached copy against — all delivered by the handshake, so no extra round trip.
from cheetah_db import alias
conn.binary.command_id("RECORD") # → its 2-byte index
conn.binary.matches_digest(alias.alias_digest(conn).digest) # → TrueTwo things stay true in binary mode, and one changes:
- a
key=valuevalue still may not contain whitespace — the frame is refused rather than truncated. Use thebytestype (x<hex>on the line); in binary it costs nothing; - numbers are typed automatically when a token re-renders identically, so
42travels as one byte and007stays a string; - widths are always stated explicitly when transcoding. A width-0 tag means
"the server resolves it" — from the table's profile or the session — and only
a caller that has loaded that profile (
alias.table_profile) can predict it. Getting it wrong is a misread frame, not a rounding error.
A table's numeric widths live on the server, not here, because two processes writing the same table must encode it the same way:
alias.table_profile(conn, "ngram", uint=4, float_=4)
alias.table_profile(conn, "ngram") # → resolved widths, plus what is declaredThese are not style preferences; each one is a silent failure the binder exists to prevent.
- A write is two round trips.
INSERTstores bytes and returns an absolute key;PAIR_SETbinds a name to it.kv.put_valuedoes both. For bulk work usekv.put_values_batch, which is one request per page instead of two per record. It does not make the server faster — the per-record work is unchanged — so it pays off when the client cannot pipeline, or the link has latency. value=owns the rest of the response line. AREADpayload is unescaped, so it legitimately contains commas. Splitting the whole line on,corrupts every JSON value you read back.- A leading
xmeans hex. The server decodes any argument starting withxas a hex string, so a key spelledx:thingis unaddressable in its bare form.encode_argumentescapes it; pick namespaces that do not start withx. The same rule applies insidePAIR_PUT_BATCHitems, which is why a base64 payload that happens to begin withxis hex-escaped there too. - A
next_cursormust go back verbatim, throughraw_argument. Encoding it again hex-encodes the hex, and the server resumes from a prefix that does not exist — a sweep that quietly returns its first page instead of failing. GRAPH_*splitskey=valuetokens on whitespace. No value may contain a space, so anything free-form travels base64 (protocol.encode_json_argument), andbuild_key_value_commandraises rather than sending a value that would be truncated. Free-textGRAPH_RECALLseeds travel asbase64:<…>.GRAPH_RECALLalso caps seeds at 32 per call;graph.recall_batchedbatches above that and merges with the same noisy-OR the server uses within a batch.- Recall decay diagnostics are outputs, not extra client policy.
graph.recallreturnsdecay,cache_decay,decay_relations, anddecay_profile; teach relation-specific decay throughpredictwith tablegraph_recall_decayand exact valuescarry/stop. GRAPH_NODE_SET references=replaces the stored list, it does not merge. Read the node back and write the union if you mean to extend provenance;-clears it.GRAPH_RECALLexcludes seed nodes from their own answer unlessinclude_seeds=True, so hydrating references without it returns nothing for the node the query is actually about.- Text travels raw, binary travels base64.
INSERTtakes the rest of the line as bytes, so UTF-8 documents need no wrapping, but an arbitrary byte string cannot cross a newline-delimited protocol: usekv.put_bytes, andprotocol.decode_transport_payloadto unwrap reducer output written that way. - Keys are an index, not a label. In a pair trie the key bytes are the
index, so changing a key layout means rebuilding the database. Use fixed-width
lowercase hex for numeric segments (
keys.hex_segment) — a scan is byte-ordered, andstr(n)sorts10before9. - Bucket continuous values in integers.
keys.quantizethenkeys.bucketize. In floats,(v - tol) / widthlands on224.99999999999997where exact arithmetic gives225, which silently widens a tolerance sweep from two buckets to three. - Jobs live in memory.
JOB fetchconsumes the job and a restart loses it, so treatjob_not_foundas terminal rather than retrying. - Detached recall is retrieved by id.
graph.recall_async(...)returns agraph_recall_<n>handle; pass it tograph.fetch_recallorgraph.await_recall. Omittingbudgetselects the server's maximum bounded detached sweep; an explicit value remains the limit.
A pair name maps to exactly one payload, so describing one thing with several
quantities used to mean several names (cnt:<k>, prob:<k>, meta:<k>): three
trie entries, three payloads, three round trips, and nothing keeping them
consistent. A record table declares those quantities once, with a byte width
each, and packs them into one row.
from cheetah_db import records
records.define(conn, "ngram", "cnt:uint:4,prob:float:4,label:string:12")
records.set_row(conn, "ngram", "berlin", {"cnt": 42, "prob": 0.25, "label": "city"})
# A write patches only the fields it names; the others keep their bytes.
records.set_row(conn, "ngram", "berlin", {"cnt": 43})
records.get_row(conn, "ngram", "berlin") # {'cnt': 43, 'prob': 0.25, 'label': 'city'}
for row in records.iter_rows(conn, "ngram", prefix="be"):
print(row.text, row.fields)
# The same bounded predicate contract works with or without an optional index.
page = records.select(conn, "ngram", "cnt", 100, op="gt", budget=4096)
records.configure_index(conn, "ngram", "cnt", action="create")
for row in records.iter_selected(conn, "ngram", "cnt", 100, op="gte"):
print(row.text, row.fields["cnt"])Three properties of the family are worth internalising, because they decide how you use it:
alternever rewrites a row. Field offsets never move: an added field is appended, a dropped field's bytes stay as dead space. A row written before anaddtherefore readsNonefor the new field — not0, which nobody wrote — until the nextset_rowbrings it up to the current width.compactis the only call that touches rows. It reclaims what a drop left behind, and it is explicit for that reason. It bumps the table's generation and briefly doubles its footprint while copying.- Field indexes are optional derived state.
selectreportsscanned,indexed, and a cursor either way; create an index only when repeated predicates justify maintaining an extra entry on every changed row. - A field name is an argument.
RECORD set table=t key=k <field>=<value>puts field names in the same namespace as the command's own modifiers, sotable,key,fields,limit,cursorand friends are refused — by this binder atdefine/set_rowtime, before the wire.
Values follow the same escaping rule as everywhere else: text holding a space or
starting with x travels as x<hex>, and the binder does it for you. bytes
fields read back padded to their declared width, because a fixed-width field has
no length of its own.
Cheetah is round-trip bound under bulk work, and BATCH is the server's general
answer: one command name, any target command, one request.
from cheetah_db import batch
# Raw argument lines — whatever you would have written as single commands.
result = batch.run_batch(conn, "PAIR_SET", [f"ctx:{i} {k}" for i, k in enumerate(keys)])
result.applied, result.failed, result.results[0].ok
result.raise_for_failures() # a half-written batch is an index with holes
# Object items take the key=value dialect, over modifiers shared by all of them.
batch.run_batch(conn, "GRAPH_EDGE_SET", edges, shared={"type": "knows"})
# Big enough to detach: submitted as a job, results delivered as produced.
batch.run_batch_async(
conn, "PAIR_SET", millions_of_lines,
on_result=lambda response, index: ..., # arrives while the job runs
on_progress=lambda snapshot: print(snapshot.progress),
)BATCH is not a transaction — items apply in order and independently — so
the result carries applied/failed/first_error rather than raising, and
continue_on_error (default True here) decides whether one bad item stops the
rest. The binder explicitly sends continue_on_error=1 because the server's
raw-command default is stop-on-error. An item that never ran is None in
results, positionally aligned.
The client watches for bulk work: once a command has been issued threshold
times inside window seconds it is hot. What happens then is a setting,
because a synchronous client can only coalesce by deferring the response,
and that moves the moment an error surfaces:
mode |
Behavior |
|---|---|
"advise" |
(default) logs once, per command, that batching would pay. Nothing about the wire, the ordering, or when an error surfaces changes. |
"deferred" |
actually coalesces. A hot command is queued and the caller gets a DeferredResponse that resolves on first use — full saving for a loop that ignores its responses, at the price that an item's error surfaces at that first use (or, for a response nobody reads, only through on_error). |
"off" |
never coalesce, and never look. |
conn = CheetahClient(
"127.0.0.1", 4455, database="app",
auto_batch={"mode": "deferred", "threshold": 8, "window": 0.2,
"idle": 2.0, "max_size": 256, "commands": None,
"on_error": lambda command, exc: ...},
)
...
conn.flush() # a loop that just ends leaves its tail queued
conn.batch_stats # {"batched": …, "batches": …, "direct": …}Where it is obviously the right trade, ask for it explicitly instead — same machinery, bounded to a block, flushed on exit:
with conn.batching():
for key, abs_key in rows:
conn.execute("PAIR_SET", key, abs_key) # -> DeferredResponseOrder is preserved either way: anything that cannot join the queue flushes it
first, and the server applies a batch's items in order. Never batched are the
connection-scoped commands, BATCH/JOB themselves, and the administrative
ones (SYSTEM_STATS, LOG_FLUSH, FILE_CHECKPOINT, the CLUSTER_* family,
FORK_ASSIGN) — see AUTO_BATCH_EXCLUDED.
from cheetah_db import admin
# Settings that override the server's [database] section for this database
# alone, persisted next to its data so they survive a restart.
admin.create_database(
conn,
"bench",
pair_bytes=2,
sharded_key_slots=True,
key_slot_bits=12,
payload_cache_mb=256,
)
admin.configure_database(conn, "bench", payload_cache_mb=128, graph_cache_sample=0.5)
admin.list_databases(conn) # name, path, loaded, ad_hoc, settingscreate_database refuses a name that already exists — that refusal is the whole
difference from use_database, which opens-or-creates and would silently adopt a
populated directory and ignore the settings passed, since trie geometry is
decided when the directory is made. Main-key sharding is creation-time geometry
too; changing sharded_key_slots or key_slot_bits requires reset_database.
A CheetahClient serializes send+receive under a lock, so two threads sharing
one are safe but take turns. For concurrent work give each thread its own
socket:
from cheetah_db import CheetahClient, ThreadLocalClientPool
pool = ThreadLocalClientPool(lambda: CheetahClient("127.0.0.1", 4455, database="app"))
conn = pool.acquire() # this thread's client, connected
...
pool.close_all()close_all() closes every registered socket without forgetting the thread-local client objects;
if a later command reconnects one (for example after CheetahDatabase.reset()), a final
close_all() still owns and closes that replacement socket.
The class exists because every application ends up writing the same handful of things around the free functions: pool construction, a connect that refuses a database written by an incompatible codec, a close that only closes what it owns, a read-modify-write that cannot interleave with itself, collision-checked id allocation, and payload accounting per namespace. It holds those and nothing about any particular schema.
from cheetah_db import CheetahDatabase
class ArticleStore(CheetahDatabase):
def __init__(self, **options):
super().__init__(layout={"key": "cfg:article_layout", "version": 1}, **options)
self.articles = {}
# Runs on this thread's connection after the layout check.
def on_connect(self, conn):
pass
# Called when reset() drops the database under you.
def clear_caches(self):
self.articles.clear()
def put(self, article):
article_id = self.allocate_random_id(lambda candidate: f"a:{candidate}")
# Cheetah has no transaction spanning several writes, so a completion
# marker is the commit: write incomplete, write the parts, then flip it.
self.put_json(f"a:{article_id}", {**article, "complete": False}, upsert=True)
self.put_json_batched(
[(f"as:{article_id}/{at}", section) for at, section in enumerate(article["sections"])]
)
self.put_json(f"a:{article_id}", {**article, "complete": True}, upsert=True)
return article_id
def hits(self, word):
return self.recall([f"w:{word}"], hops=1, edge_type="mention")Inherited: connect/close/reset, conn, get_value/put_value,
get_json/put_json/put_json_batched, delete_pair, scan/scan_all/
scan_json, mutate_json, allocate_random_id, timestamp, pair_summary/
namespace_summary, and the graph surface (set_node, get_node,
set_edge_batch, degree, recall, recall_async, fetch_recall, await_recall).
Hooks for subclasses: on_connect(conn) and clear_caches().
layout is optional but strongly advised. A trie key layout is not
self-describing: a codec that changed its segment widths reads an older database
as keys that simply match nothing, which looks like an empty result rather than
an error. The marker turns that into a loud failure on connect.
from cheetah_db.server import start_server
server = start_server(port=4467, data_dir="/tmp/cheetah-test")
# … talk to it on server.host:server.port …
server.stop()It builds cheetah-server (cheetah-server.exe on Windows) from this repository if the binary is
missing, which needs a Go toolchain. The platform-specific name is important because Windows cannot
spawn the extensionless Go output. graph_term_index and pair_index_bytes are left unset
unless you pass them, so the server's own configuration decides; note that
pair_index_bytes is adopted when a database directory is created and
pinned from then on, so setting it against an existing database does nothing.
cd binders/python && python3 -m unittest discover -s tests -t .No dev dependency: the runner is the standard library's. The suite covers the
protocol codec, the key primitives, the KV/graph/record/job/database call shapes,
the binary codec and the ALIAS layer, and CheetahDatabase against an
in-memory stand-in that speaks the same line
protocol. That stand-in does not prove the server answers that way — the Go
suite (go test ./src) and the gated integration test do:
cd binders/python && CHEETAH_INTEGRATION=1 python3 -m unittest discover -s tests -t .which builds and boots a real server on a free port in a temporary data
directory — note that it builds only when the binary is missing, so a stale
cheetah-server at the repository root silently tests an old protocol. Rebuild
it (go build -o cheetah-server ./src) when a command there answers
ERROR,unknown_command.