Skip to content
Connect > Client Libraries

Client Library Concepts

Read this page once and the references, quickstart, and use cases will make sense. It covers the object model, the three protocols, how results reach you, and the type system.

One client, named clusters

Your program creates one Client. The client owns a background pool of I/O threads and manages one or more clusters. A cluster is a named server target, you add it once, then refer to it by name in every call.

Each cluster carries up to three configurations, one per protocol, and you only fill in the ones your program uses:

  • a Relay (publish) config: to publish objects and register commands,
  • a Center (subscribe) config: to subscribe to tables,
  • a JDBC (SQL) config: to run queries and inserts.

Each configuration holds one or more shards: a shard is one server. A single cluster can span several servers per protocol; see Configuration → Sharding.

1
2
3
4
5
Client
└── Cluster "prod"
    ├── Relay (publish) config   → shard(s)
    ├── Center (subscribe) config → shard(s)
    └── JDBC (SQL) config        → shard(s)

The three protocols

The protocols are independent connections with different shapes. A useful way to hold it: JDBC asks a question, Center watches a table, Relay publishes into the fabric.

JDBC Center Relay
Model Request / response Subscribe, then live push Publish (fire-and-forget)
You call sql, sql_insert, sql_replace subscribe publish, register_command
You get back a result table a snapshot, then on_update callbacks delivery is asynchronous; failures replay from an outbox
Writes data yes (SQL / bulk) no, read/subscribe only yes (objects)

Results reach you two ways: return values and callbacks

JDBC is synchronous by default. You call sql(...) and the rows come back as the return value; the call blocks until the server answers (or sql_async takes a callback instead).

1
2
3
4
from pyami.rt.ami_client import AmiClient
client = AmiClient()
client.add_cluster(cluster, sync=True)
result = client.sql("prod", "SELECT SYM, bid, ask FROM QUOTES")   # blocks, returns rows
1
2
3
ami::Client client;
client.add_cluster(cluster, /*sync=*/true);
auto rows = client.sql("prod", "SELECT SYM, bid, ask FROM QUOTES");  // returns a TableAccessor

Center and Relay are event-driven. You attach a listener and implement the callbacks you care about. When a subscribed row changes, the client invokes on_update; when a command arrives, on_command; and so on. The callbacks that exist are on_connect, on_disconnect, on_logged_in, on_message_sent, on_message_received, on_command, on_subscribe, on_update, and on_log.

Callbacks run on the client's own I/O threads, three rules

The library calls your listener from its internal threads, not your main thread. This has hard consequences you must respect:

  • Copy the data you keep. A row-change update hands you views into the library's internal buffers that are valid only for the duration of the callback. Copy anything you store or pass to another thread; never hold the update object itself.
  • Keep the callback fast; run blocking work on your own thread. The callback runs on the client's I/O (delivery) thread, so blocking it stalls update delivery. A synchronous call on a different connection (e.g: a JDBC sql() from a Center callback) returns, but a same-connection synchronous call from its own callback can block. Queue slow work, and any same-connection call, to your own thread.
  • Guard your own shared state. Two updates can run on different threads; protect any structure your callback mutates.

Subscriptions: snapshot then deltas

When you subscribe to a table, the Center sends the current matching rows first, then a continuous stream of changes. Each change arrives in on_update as a row-change update carrying:

  • an action: 1 = add, 2 = update, 3 = delete,
  • an id: the row's stable numeric identity, present on every update (an add and its later updates and delete all carry the same id),
  • a table name, and the changed data as parallel field-name / value lists.

There is also an object_id field, but it is the key you set when publishing an object to the Relay, it is empty for rows created by SQL, so do not use it to identify subscription updates.

An update carries only the columns that changed, not the whole row. Your client must merge each update onto the row it already holds, keyed by id: if you replace the whole row you blank the untouched fields.

Merge on id, not object_id

Use the numeric id as the merge key. object_id is the Relay-publish key and is empty on subscription updates for SQL-created rows: keying on it collapses every row under "" and loses data. This was confirmed against a live instance: an added row and its later partial update both carry the same id, while object_id is blank.

Knowing when the initial load is done. on_subscribe fires when the subscription reaches the server, before the initial rows, which then arrive as on_update with action 1 (add). There is no callback that marks the snapshot complete. If you need to block until the subscription is established, pass sync=True to subscribe; otherwise treat the arrival of the initial add rows as the snapshot itself.

You can filter and project on the server in C++: subscribe with a CenterSubscription to push a WHERE filter and a column list to the Center, so it only sends the rows and columns you asked for. Python has no equivalent: its subscribe takes table names only, so filter in your callback or use the C++ client when you need the Center to do the filtering.

The type system

Both languages use the same type set, with the same wire values, so a value written by a C++ client reads back identically in Python. The common column types:

Type Type Type
Boolean Integer String
Byte Long UUID
Short Float BigDecimal
Character Double BigInteger

Binary data is carried as a Bytes/Binary column. A few types (UUID, BigDecimal, BigInteger) cannot be told apart from their C++ representation alone, so the C++ client asks you to declare them explicitly when you build a table for bulk insert, see C++ client → Bulk insert.

Temporal values are epoch integers, with no time zone

The type system does include date/time types (Date, DayTime, DateMillis, DateNanos), but a temporal value travels as a raw epoch integer (seconds / milliseconds / nanoseconds) with no time-zone information: the client applies no zone conversion, so agree the zone out of band (UTC is the safe convention). Note also that the pure-Python pyami.pyjdbc layer cannot decode these temporal types into a DataFrame; if you query over pyjdbc, carry an instant as a Long epoch or a String you control.

What the clients do not do

  • No transactions. The DB-API driver's commit and rollback are no-ops; there is no multi- statement transaction.
  • One JDBC request at a time per cluster. The SQL socket does not queue concurrent requests; serialize them, or use separate clusters.
  • No subscribe over JDBC, no SQL over Center. Each protocol does one job; compose them.

Verified live via the Python client (build 32437)

The subscription and callback behavior here was reproduced against a live instance, including the merge-by-id correction. The C++ examples share the same model but were not separately run.