This repository contains two Python client libraries for VoltDB:
| Module | Main classes | Role |
|---|---|---|
voltdbclient.py |
FastSerializer, VoltProcedure |
The original client and wire codec |
voltclient.py |
VoltClient, VoltBulkLoader |
Topology-aware client built on it |
voltdbclient is the original client: a single connection to a single server, called synchronously, plus the wire-protocol serialization for all of VoltDB's data types. voltclient builds on it — VoltClient holds a connection to every cluster node, routes each call to the node that will execute it, supports asynchronous calls, and recovers automatically from cluster changes. New applications should generally start with VoltClient; existing FastSerializer code needs no changes and keeps working as-is.
Because VoltClient uses voltdbclient's serialization underneath, the two modules share the same type constants and return the same VoltResponse / VoltTable objects — they interoperate naturally, and you can migrate one call site at a time.
What VoltClient gives an application:
- One client for the whole cluster. Name any node; the client discovers the rest and keeps a connection to each.
- Client affinity. Each single-partition call goes straight to the node that leads its partition.
- Synchronous and asynchronous calls.
callblocks;call_asyncreturns aconcurrent.futures.Future, whichasynciocode can await. - Cluster changes without restarts. The cluster pushes topology changes to the client, so node failures, rejoins and elastic expansion are followed automatically.
- Bulk loading.
VoltBulkLoaderloads a table in per-partition batches, one transaction per batch. - A bound on load.
max_outstandingcaps the calls in flight, which keeps latency predictable.
Both modules are pure Python with no third-party dependencies. voltclient and the PyPI package require Python 3.9 or later; voltdbclient.py on its own still accepts Python 3.6.
Both modules are published to PyPI as one package:
pip install voltdbclient
Reading Java keystores for TLS and Kerberos authentication need optional third-party packages: pip install "voltdbclient[extras]".
The VoltDB distribution also ships both modules in lib/python, where VoltDB's own command-line tools load them from. To use that copy, put the directory on your PYTHONPATH (or append it to sys.path). voltclient imports voltdbclient, so if you copy the files rather than the directory, keep the two together.
Connect, call a procedure, and read the result:
from voltclient import VoltClient, VoltType
with VoltClient(hosts=["voltsvr1", "voltsvr2"]) as client:
response = client.call("Vote",
[VoltType.BIGINT, VoltType.INTEGER],
[5551234567, 2])
if response.status == 1: # ClientResponse.SUCCESS
for row in response.tables[0].tuples:
print(row)Give hosts any subset of the cluster: the client discovers the full membership at connect time and opens one connection per node. Use the client as a context manager (or call close()) so its background threads and sockets are always torn down.
All VoltClient constructor arguments are keywords with defaults:
| Argument | Default | Meaning |
|---|---|---|
hosts |
Names or addresses of the nodes to connect to first. Any subset of the cluster will do. | |
port |
21212 |
The client port, the same on every node. |
username, password |
"" |
Credentials, for a database with security enabled. |
usessl |
False |
Connect over TLS. |
ssl_config_file |
None |
A properties file naming the keystore and truststore, in the format VoltDB's Java tools use. |
default_cacerts |
True |
With TLS and no ssl_config_file, check the server's certificate against the system's default CA certificates. If False, the certificate is not checked at all. |
kerberos |
False |
Authenticate with Kerberos (needs voltdbclient[extras]). |
connect_timeout |
8 |
Seconds to wait for a connection to open. |
procedure_timeout |
120 |
Default timeout of a call, in seconds. A timeout passed to the call overrides it. |
max_outstanding |
3000 |
Calls in flight at once; see Bounding load. |
connection_response_timeout |
30 |
A connection idle this many seconds is pinged, and declared dead if the ping goes unanswered as long. |
initial_backoff, max_backoff, reconnect_jitter |
0.5, 30, 0.2 |
Reconnect backoff in seconds, and the fraction of random jitter added to each wait. |
For example, a TLS connection with credentials:
client = VoltClient(hosts=["voltsvr1"], username="app", password="secret",
usessl=True, ssl_config_file="/etc/voltdb/ssl.properties")call(proc_name, param_types, params, timeout=None)— send and block for theVoltResponse.call_async(...)— send and return aconcurrent.futures.Futureimmediately. Compose with the standard library:
import concurrent.futures
futures = [client.call_async("STORE.upsert",
[VoltType.STRING, VoltType.VARBINARY],
[key, value])
for key, value in batch]
for f in concurrent.futures.as_completed(futures):
if f.result().status != 1:
...asyncio applications can bridge a call with await asyncio.wrap_future(client.call_async(...)).
One caution: callbacks registered with Future.add_done_callback run on the client's network reader threads. Keep them short and never block in them (no call or call_async, no lock waits) — parse, record, hand off.
call_all_partitions(proc_name, param_types=None, params=None, timeout=None)— run a procedure once on every partition and return onePartitionCallResultper partition (sorted by partition id), each carrying either the partition'sresponseor theerrorthat ended its leg;oksummarizes. Works with procedures partitioned on their first parameter and with directed procedures. Leave the partitioning parameter out: the client supplies a key for each partition and prepends it, soparam_typesandparamscarry only the remaining parameters.call_all_partitions_asyncis the Future form. One timeout budget covers the whole fan-out.
# A procedure partitioned on its first parameter, which is its only one:
# no parameters to pass.
for result in client.call_all_partitions("CountRowsInPartition"):
if result.ok:
print(result.partition_id, result.response.tables[0].tuples)
else:
print(result.partition_id, result.error or result.response.statusString)get_partition_for_parameter(vtype, value)— which partition a value hashes to, using the same elastic hashinator the cluster uses. Useful for batching work by partition before sending it.outstanding_requests— the number of calls currently in flight.
VoltBulkLoader loads rows into one table the way the Java client's bulk loader does: rows are grouped by partition and each batch is sent as a single @LoadSinglepartitionTable transaction to the partition's leader (@LoadMultipartitionTable for a replicated table). One transaction per batch instead of per row cuts both client and server work per row.
from voltclient import VoltBulkLoader
def failed(row, outcome): # a VoltResponse or an exception
print("not loaded:", row, outcome)
with VoltBulkLoader(client, "store", batch_size=1000, upsert=True,
failure_callback=failed) as loader:
for key, value in rows:
loader.insert_row(key, value) # values in column order
print(loader.completed_row_count, loader.failed_row_count)- The table's columns, types and partition column are read from the catalog when the loader is created.
upsert=Trueneeds a primary key on the table. insert_rowchecks each value against its column (no silent coercion or truncation: astrfor anINTEGERcolumn, an out-of-range number, or a NULL partition key fails that row throughfailure_callbackwith aVoltTypeError). TIMESTAMP acceptsdatetime(naive means local time) or integer microseconds since the epoch; DECIMAL acceptsdecimal.Decimalorint; VARBINARY accepts any bytes-like value.- If the server rejects a batch (a constraint violation, a value too long for its column), none of its rows are inserted, so they are retried one at a time and only the rows that actually fail are reported. A batch whose outcome is unknown (connection lost or timed out) is retried the same way. In insert mode, a row that did load may then be reported as a duplicate-key failure, or inserted twice on a table without a primary key. Use
upsert=Trueif that matters. failure_callbackruns on a client network thread. Keep it quick, and don't call the loader (insert_row,flush,drain,close) or the client from it. To re-insert a corrected row, hand it to another thread.insert_rows(rows)inserts an iterable of rows.timeout(default: the client'sprocedure_timeout) applies to each batch.completed_row_count,failed_row_countandoutstanding_row_countreport progress, andcolumn_names/column_typesdescribe the table.flush()sends partially filled batches,drain()also waits until every row is loaded or failed (including rows other threads insert meanwhile), andclose()(or leaving thewithblock) stops accepting rows, then drains.insert_rowmay be called from several threads. Without afailure_callback, the first failure is logged as a warning andclose()logs the total.- A
batch_sizeof 500–1,000 is a good default: much larger batches leave too few in flight per partition to keep the cluster busy.max_batches_in_flight(default 32) bounds memory and server queue depth. - Supported column types: TINYINT, SMALLINT, INTEGER, BIGINT, FLOAT, VARCHAR, TIMESTAMP, DECIMAL, VARBINARY.
Both clients return the same objects, defined in voltdbclient:
VoltResponse—status(integer; 1 is success — see the VoltDB documentation for the full list),statusString(human-readable meaning),roundtripTime(milliseconds),tables(list ofVoltTable, orNone),exception(aVoltException, orNone), andappStatus/appStatusString(the status a procedure set for itself).VoltTable—columns(list ofVoltColumn, each withnameandtype) andtuples(list of rows; each row is a list of values already converted to Python types).VoltException—type(one of theVOLTEXCEPTION_*constants:NONE,EEEXCEPTION,SQLEXCEPTION,CONSTRAINTFAILURE,GENERIC) andmessage.
Parameter types are declared with the VoltType constants (VoltType.BIGINT, VoltType.STRING, ...; these alias the original module's FastSerializer.VOLTTYPE_* values), and values map between VoltDB and Python as follows:
| VoltDB type | Python type |
|---|---|
| NULL | None |
| BOOLEAN | bool |
| TINYINT | int |
| SMALLINT | int |
| INTEGER | int |
| BIGINT | int |
| FLOAT | float |
| DECIMAL | decimal.Decimal |
| STRING | str |
| TIMESTAMP | datetime.datetime |
| DATE | datetime.date |
| VARBINARY | bytes to send; array.array('B') when read |
| GEOGRAPHY_POINT | a (longitude, latitude) tuple of floats |
| GEOGRAPHY | Geography |
Python does not distinguish integer widths, but a value serialized as a given type must fit it — an out-of-range value raises an error. Array parameters are passed as Python lists, declared with their element type. Geography.geographyFromWKT("POLYGON((...))") builds a Geography from well-known text and str() turns one back into it; Geography.pointFromWKT("POINT(lng lat)") gives a point tuple. Geography and XYZPoint are importable from voltclient as well.
The client keeps its view of the cluster current by subscription, not polling: it registers for server push notifications on one of its connections, and the cluster pushes refreshed topology and procedure-catalog data whenever they change. In practice this means:
- Client affinity. Single-partition procedure calls are sent directly to the node leading their partition, and multi-partition calls to the multi-partition initiator — skipping a server-side forwarding hop. Calls the client cannot place (unknown procedures,
@AdHoc, unhashable values) simply round-robin; routing degrades, it never fails a call.affinity_stats()reports affinity-routed vs round-robin sends per connection. - Self-healing membership. Node failures, rejoins, and elastic cluster expansions flow into the connection pool and the routing tables without an application restart. A lost connection's in-flight calls fail (see the failure contract below); the pool redials and re-converges in the background with capped exponential backoff.
- No stale-routing hazard. A call routed on a topology that just changed is forwarded or transparently restarted by the cluster on the correct node — a small latency cost, never a correctness one.
client.topology exposes the current snapshot (hosts, partition leaders, procedure partitioning) and refresh_topology() remains available as a manual backstop for applications that want their own schedule on top. If the server declines the subscription (for example while the database is paused), the client keeps working and tries again two minutes later.
call_async admits at most max_outstanding calls in flight (default 3000); a caller past the cap blocks until a completion frees a slot, and the wait draws on that call's own timeout. This bound is the client's load governor, and tuning it is the single most useful performance knob:
- Throughput rises with the cap only until the cluster (or the client process) saturates — the "knee" of the curve. Past the knee, extra outstanding calls add only queue latency: throughput stays flat while tail latencies grow by orders of magnitude.
- For latency-sensitive applications, set
max_outstandingnear the knee measured on your hardware (often tens, not thousands). - The 3000 default suits bulk loading, where total throughput matters and per-call latency does not.
All errors derive from VoltClientError:
VoltConnectionError— the connection carrying a call was lost before its response arrived. The call's outcome is unknown: it may or may not have executed. The client never resends such a call, because only the application can know whether a retry is safe — retry freely if the operation is idempotent, otherwise look up the outcome first. Sends are written by a background thread, so a call made just as its connection dies may be reported through itsFuturerather than raised bycall_async; handle both.VoltNoConnectionsError— no live connections to send on.VoltTimeoutError— no response within the call's timeout (default 120s), or the call could not obtain an outstanding-request slot in time.VoltTypeError— a parameter could not be serialized as the declared type (or, forVoltBulkLoader, a row value does not fit its column).
Server-side failures (constraint violations, procedure exceptions) are not Python exceptions: they arrive as a normal VoltResponse with a non-success status and statusString.
What to expect, and why:
- A synchronous single-connection client's throughput ceiling is one call per round-trip:
FastSerializerused directly cannot exceed1/RTTcalls per second.VoltClient's pipelining removes that wall: its ceiling is the Python interpreter itself — one CPU core's worth of serialization and dispatch, independent of network latency. Writes and reads are batched on the socket, so one process sustains on the order of 50,000 single-row calls per second against a local server (measured with small rows; your numbers depend on row size and hardware). - Latency at moderate concurrency is competitive with any client: per-call time is dominated by the network and the server, and affinity routing removes a network hop from every single-partition call.
- A single Python process will not saturate a VoltDB cluster row by row — that is a property of CPython, not of this client. For loading tables,
VoltBulkLoaderruns one server transaction per batch instead of per row, which is several times faster; beyond that, run several processes.
These figures come from a benchmark run in VoltDB's own testing. It drives the voltkv workload (the schema, keys and default procedures of the voltkv example app), comparing direct FastSerializer use with VoltClient across a sweep of max_outstanding values, then compares loading rows one at a time with VoltBulkLoader at several batch sizes.
Existing FastSerializer code needs no changes. When you do want a call site on the new client, the mapping is mechanical — the types and response objects are the same:
# before
fs = FastSerializer("voltsvr1", 21212)
proc = VoltProcedure(fs, "Vote", [FastSerializer.VOLTTYPE_BIGINT,
FastSerializer.VOLTTYPE_INTEGER])
response = proc.call([5551234567, 2])
# after
client = VoltClient(hosts=["voltsvr1"])
response = client.call("Vote", [VoltType.BIGINT, VoltType.INTEGER],
[5551234567, 2])Differences to be aware of:
- One
VoltClientreplaces aFastSerializerper node plus your own pooling and failover code. - Procedures are not pre-declared; pass the parameter types with each call.
- Authentication, TLS, and Kerberos options pass through the
VoltClientconstructor toFastSerializerunchanged.
The original API remains fully supported and is the right tool for small scripts talking to one server. Create a FastSerializer connection, declare each stored procedure as a VoltProcedure with its parameter types, and call it:
from voltdbclient import *
client = FastSerializer("voltsvr1", 21212) # optional: username, password, usessl, ...
proc = VoltProcedure(client, "@SystemInformation",
[FastSerializer.VOLTTYPE_STRING])
response = proc.call(["OVERVIEW"])
client.close()Details worth knowing:
VoltProcedure.call(params, response=True, timeout=None)— withresponse=Falsethe call is sent without waiting for the result (returnsNone);timeout(seconds) raisessocket.timeoutif the server does not respond in time.- The
FastSerializerconstructor also acceptsdump_file_path, which writes all bytes sent and received to a file — occasionally useful for debugging at the wire level. - Thread safety differs between the modules. Nothing in
voltdbclientis thread-safe: give each thread its ownFastSerializer. AVoltClient, by contrast, is designed to be shared — many threads may call one instance concurrently.
The client logs through the standard logging module under the logger name voltclient (connection lifecycle at INFO/DEBUG, degraded-routing and subscription warnings at WARNING). It adds no handlers of its own.
Full API details are in the module docstrings: pydoc voltclient.
The examples directory has small programs for the schemas in the VoltDB kit's tutorials and examples:
voltclient_helloworld.py— Hello World withVoltClient: asynchronous inserts, then a query.helloworld.py— the same withFastSerializer.voter.py— the Voter example's client, withFastSerializer.
See CONTRIBUTING.md for how to build the package and run the tests.