top_level: true description: The GAR WebSocket protocol in full: handshake, subscribing, filtering, binary and JSON encodings, batching, heartbeats.
Generic Active Records Protocol Documentation
The wire protocol every GAR client and server speaks: the handshake, subscriptions and their filters, the binary and JSON encodings, batching, ownership, failed writes and termination.
Overview
The GAR protocol is a WebSocket-based messaging system built on top of TCP. It is used for streaming and snapshot-style data delivery and operates over a single bi-directional socket connection. When establishing a connection, clients must specify the subprotocol "gar-protocol":
Sec-WebSocket-Protocol: gar-protocol
Encoding
The server supports two formats for message payloads: binary and JSON.
- JSON (opcode 0x01) is recommended for human-readable debugging and platform-agnostic integration.
- Binary (opcode 0x02) is compact and efficient but platform-dependent due to endianness, sizing, and alignment.
If the first byte of the initial Introduction message is {, then JSON mode is assumed regardless of the WebSocket opcode.
Binary Format
- Each message begins with a
message_typeenumeration value. - Immediately followed by a binary-encoded struct corresponding to that type.
- Must conform to platform-specific alignment and byte order.
- Should only be used when both ends are aware of the exact schema and platform.
JSON Format
JSON messages are structured with a "message_type" string and a corresponding "value" object.
Example:
{
"message_type": "Subscribe",
"value": {
"subscription_mode": "Snapshot",
"nagle_interval": 0,
"name": "S1",
"key_id": 0,
"topic_id": 0,
"class_list": ["Underlier"],
"key_filter": null,
"topic_filter": null,
"exclude_key_filter": null,
"exclude_topic_filter": null
}
}
This format is more portable and ideal for most clients.
Framing
Each websocket frame contains a single message - a json {} or binary object.
Session Lifecycle
- Introduction must always be the first message. It specifies protocol version, heartbeat expectations, and optional settings such as
unique_key_and_record_updates. - Clients may then Subscribe to topics and keys or Publish messages.
- Clients must send Heartbeat messages periodically to keep the connection alive. If none is received within the specified timeout, the server will disconnect the client.
- The first heartbeat has a 10x grace period to allow for slow client setup.
- All topics and keys must be explicitly introduced before publishing record messages.
- IDs
0are considered invalid for bothtopic_idandkey_id. - Enumeration of topics and keys is not globally synchronized between client and server. Each side must track their own ID mappings.
Protocol version
Both sides carry their protocol version in the introduction — BINARY_VERSION on a binary
connection, JSON_VERSION on a JSON one (ipc/ipc.ixx) — and the receiver refuses a
connection whose version differs from its own, with the error Unsupported version from <peer>: <version>. The two constants are monotonically increasing integers with no relation to the
release version; BINARY_VERSION ticks whenever the binary layout or the schema text embedded in
the introduction changes, JSON_VERSION only when an old JSON client could no longer interoperate
(an added optional field or a new message type does not tick it). JSON clients never parse the
schema text, so a schema-text format change leaves them unaffected.
Schema on the wire
Every emitted schema surface is .gar text: the schema in the introduction, the "schema" member
of a gardb snapshot, trsgar --print-schema, and the discovery defaults — REST GET /schema and the
MCP schema_source tool. The .psi rendering is available only behind format=psi on those two
discovery surfaces.
Client identity
Every connection materializes on its peer as a g::Client key named "<application> <user> <uuid>", where the uuid is minted per connection — a library that opens one connection to download
the schema and a second to subscribe produces two distinct keys with independent lifetimes, and a
process holding two simultaneous connections is two clients. Ownership records
(active_ownership, client_introduced_key_invalidated) and the ClientTopicStats /
ClientClassStats telemetry key off this key, so it is what a disconnect releases and what a
stats row names. A proxy reports its own aggregate stats under the identity it presents to its
upstream, suffixed control, so they sit beside the per-downstream-client rows it mirrors
without colliding.
Introduction Options
The introduction message supports the following optional fields:
unique_key_and_record_updates(bool, default false): If true, only one record update will be sent per key/record even if multiple subscriptions apply. This only affects streaming record updates — snapshot data is still sent for each subscription. Note thatactive_subscription_groupwill not be reliable when using this option.disconnect_on_write_failure(bool, default false): bail-out-first writers. The server closes the connection on the first client message with a failed write — after reporting it, as a non-recoverableErrorcarryingfailed_writes(delivered even when theactive_ownershipmsg_refis 0, which is otherwise log-only). Every write that succeeded stays landed; this is not transactional — a client that needs atomicity batches its writes so each batch is safe under partial failure. Meant for critical processes that would rather restart than continue past a refused write, and for integration tests; subscriptions on the same connection go down with it, so a process that both watches and writes should keep them on separate connections. Through a proxy, a failure detected upstream reaches the client as a relayedfailed_writesError and closes the client's connection the same way, but only when the proxy learned of it — i.e. with a nonzeromsg_ref, since the proxy's own upstream connection does not carry the flag. pygar:GARClient(..., disconnect_on_write_failure=True); jsgar: thedisconnectOnWriteFailureconstructor argument. Both libraries already treat a non-recoverableErroras fatal (exit_code1, stop). The REST analogue is?fail=true(see Write-failure replies).
Record Updates
Record updates flow both ways through the same messages: a client writes with them, and a server streams
post-snapshot updates to a subscriber with them too — only the initial snapshot (and nagle-coalesced
ticks) travel as batch updates. Anything that must ride with every streamed record
therefore has to live on these messages as well as on batch_update.
The GAR protocol supports record updates via:
Binary encoding
- FixedLengthRecordUpdate:
record_iddirectly followed by the fixed-size record data. - VariableLengthRecordUpdate: serialized
binary_record_updatestruct with embedded length field.
JSON encoding
- Encoded using
json_record_updatewith clear field names and value. - Non-finite floats. JSON has no literal for them, so every JSON emitter (REST, the JSON wire protocol,
--print) spells an infinitefloat/doubleas1e999/-1e999— a literal that overflows to ±infinity in every reader (Python, JavaScript, jq, the trs reader itself) — and NaN asnull(a NaN result is absence at the record boundary). On input the trs reader accepts1e999, and the wordsinfinity/-Infinity(case-insensitive), as ±infinity. A.garfloat literal takes the same overflow spelling —1e999in a key initializer or an expression is +∞ — so a schema can say what the wire says (mkt_feed_test.gar'sgood_long_leg_no_ask_spreadpins an infinite initializer through theg::schemaintrospection round trip).
Example:
{
"message_type": "JSONRecordUpdate",
"value": {
"record_id": {"key_id": 1, "topic_id": 22},
"value": 0.3
}
}
Augment bypass (assume_cached_subscriptions_cover)
A subscription with assume_cached_subscriptions_cover: true asserts that the proxy serving it has
cached subscriptions covering every record it asks for. That proxy then skips this subscription's augment
differencing entirely and serves it — derived topics included — from its cache, removing the poller-thread
set-algebra the snapshot blocks on. The assertion is per subscription: an operator turns it on for the few
heavy subscriptions the cache was provisioned around, and every other subscription on the same proxy keeps
augmenting normally. No proxy config gates it — the assertion is the caller's own, and a wrong one
under-serves only the subscription that made it; a plain (non-proxy) server has no cache to bypass and
ignores the flag. Fail-open: anything the cache does not cover is silently absent or null on that
subscription. REST equivalent ?assume-cached=true, trsgar --assume-cached. See
augment_bypass_spec.
Record tick_time
A subscription with include_tick_time: true receives each record's tick_time: the owning server's
wall-clock nanoseconds at the record's last write. It advances on every write, including one whose
value did not change, so it answers "is the writer alive", not "did the value change". A value-unchanged
write is throttled (no update); a flagged subscription instead receives a RecordTick
(record_tick {record_id, tick_time}) — stamp only, no value — which clients that don't track stamps can
ignore. Proxies forward the flag upstream and relay the origin's stamp unchanged, never their own receipt
time; a relayed tick never triggers recomputation.
json_record_update/binary_record_updatecarry it astick_time(JSON omits the member, binary sends 0, when the subscription didn't opt in).batch_update.key_major_update.tick_timesis an array parallel totopics; JSON carries a"tick_times"object beside"topics"with the same<topic_id>member names. Empty/absent otherwise.
When a RecordTick is sent. Only once the subscription is streaming, only for keys the connection
has already introduced, and never held in a nagle window. It fires only on a real advance of the stamp:
a relay re-writing an identical record with the same stamp is silent, so a redundant snapshot on a proxy
ticks nothing. A subscription without the flag keeps the throttle and receives nothing for an unchanged
write.
How the stamp is set. A write that originates on a server — a client's write, a derived recompute, a local topic — is stamped with that server's own clock. A relay that carries an upstream stamp stores it verbatim; a relay that carries no stamp (an unflagged upstream subscription) preserves the stamp the record already holds, so two upstream subscriptions of which only one is flagged converge on the right stamp whatever their delivery order. Derived topics computed on a proxy are origin writes there and carry the proxy's clock.
Off by default — payloads keep their size and shape. pygar keeps the stamps in
client.tick_time_map[(key_id, topic_id)] and jsgar in client.tickTimeMap; both offer a record-tick
callback (register_record_tick_handler / registerRecordTickHandler).
Record diagnostics
A derived-topic record that has no value carries a structured diagnostic saying why — a
derived_diagnostic {reason, missing_inputs[], text}. reason is one of InputMissing,
KeyCastMissed, Suppressed (the null class: plain absence with a cause) or
ComputeError / InputError (the error class: the function raised or failed — text is the
stripped exception message — or one of its inputs is in error). Errors are poison: an InputError
consumer stays in error whatever its optional/default argument policy, and ?? coalesces nulls
only. missing_inputs names each absent or erroring input — its argument index and declared
parameter name, the input topic's path and the key holding it — so a client can chase a null or an
error upstream, record by record, to its root cause.
- Every subscription receives error-class diagnostics with no opt-in; the null-class reasons only
reach subscriptions that set
include_diagnostics: true. - The wire carries them as
RecordDiagnostic(record_diagnostic {record_id, diagnostic}), sent after the batches that introduce the record's key during a snapshot, and streamed on change. A diagnostic replaces the record's value: a value→diagnostic transition arrives as the ordinaryDeleteRecordfollowed by theRecordDiagnostic; a value arriving supersedes the diagnostic. - A proxy computes derived topics itself, so its diagnostics are its own computation's; a client that takes derived records from a server (trsgar, pygar, jsgar) applies the relayed diagnostic verbatim.
See null_reasons_spec.
Density
The density field controls how records are grouped in JSON output. The default density is KeyMajor.
- KeyMajor (default):
{ "key": "key1", "topics": { "topic1": <record>, "topic2": ... } } - TopicMajor:
{ "topic": "topic1", "keys": { "key1": <record>, "key2": ... } } - RecordMajor:
{ "key": "key1", "topic": "topic1", "value": <record> }(one record per object)
Batch updates
Multiple updates can be sent together in a batch. Initial snapshots after subscribing are always sent in one or more batch.
Records are sent in key-major format. Keys are introduced along with their class or class_list. Key names and classes may be omitted when keys have already been introduced.
Binary format uses the batch_update struct.
JSON format is similar, except the records within each "topics" object are JSON objects with topic_id numbers as member names: { "<topic_id>": record_value, ... }
Example:
{
"message_type": "BatchUpdate",
"value": {
"default_class": "A",
"keys": [
{
"key_id": 1,
"name": "key1",
"topics": {
"20": 10
}
},
{
"key_id": 2,
"name": "key2",
"class": "B",
"topics": {
"21": 20
}
},
{
"key_id": 3,
"name": "key3",
"classes": ["A", "B", "C"],
"topics": {
"20": 30,
"21": 31,
"22": 32
}
}
]
}
}
Subscriptions
A subscription names the classes, keys and topics a client wants and whether it is a one-shot snapshot or streaming; filters, the referencing-class list, exclusions, the class join and the nagle interval are all fields of it. The full reference is Subscriptions.
Echo
Clients may send an EchoRequest message containing a msg_ref (message reference number). The server will respond with an EchoResponse containing the same msg_ref. This can be used to confirm that all prior writes have been processed before continuing.
In proxy chains, the echo propagates end-to-end: the proxy forwards the echo to its upstream server and only responds to the client once the upstream echo response arrives. This guarantees that all prior writes — including record updates and deletes — have been fully processed through the entire chain by the time the client receives the echo response. The REST API uses this mechanism internally, so HTTP POST and DELETE responses also carry this guarantee.
Compare-exchange
A CompareExchange message applies a write only if the record's current value is what the client
expects, so two writers racing on one record cannot silently overwrite each other. It carries a
record_id, an inline msg_ref correlation id, a test (the expected current value) and a value
(what to write on a match). The server replies with a CompareExchangeResult echoing that
msg_ref and carrying success, which is true only when the value was applied. On a failure it also
returns the record's current value and a reason: CompareMismatch when that value did not match
test, so a client can retry against fresh state without a separate read, or WriteDenied /
DeleteDenied when the key is owned by another client, which no retry clears.
Null means absent, on both sides. A missing record reads as null, so:
test: nullmatches a record that is missing — this is create-if-absent. (For a topic whose type declares a default, a missing record instead reads as that default, so the matching test is the default value.)value: nullis an empty value, so a successful compare deletes the record. This holds for every topic type, including fixed-length numeric ones — a null value is never stored as a zero.
Binary clients express the same two things with a zero-length test / value byte array.
Together those give compare-and-delete: test = the value you saw, value = null. That makes a
record a lock-free "consume exactly once" flag — several readers that all observed X each
attempt the delete against X, exactly one succeeds, and the losers are told so. Nothing is claimed,
so a reader that dies mid-operation strands nothing. The same operation is available over
REST via atomic-compare-exchange=true.
Compare-exchange propagates through proxy chains: the proxy forwards it upstream and relays the result back to the originating client. The REST equivalent forwards the same way, so both surfaces are atomic through a proxy.
The edges of the null rule, and two conventions:
- A missing record on a plain topic (
bool,int, a struct of zeros — no annotated default) is matched only by a null or emptytest, never bytest: false/0: a missingxis not equal tofalse, the same wayx == falseis false in an expression whenxis missing. - An annotated default is what the missing record reads as, so a
bool default truelock is acquired withtest: trueagainst a key that has never been written — no seed record needed. - A value written by a successful compare is stored verbatim even when it equals the type default
on a
trim_default_valuestopic, so the result is always observable as a record echo. - On a fixed-length topic
value: nulldeletes; to write a zero, sendvalue: 0. - The inline
msg_refis the correlation id for the result and for any error the compare raises; theactive_ownershipmarker is sent before a compare only when it conveys ownership (client_key_id,skip_ownership_checks,ownership_action) — a plain compare sends none.
pygar/test_gar_client.py (test_compare_exchange_*) pins each of these; ipc/cas_proxy_test.py
the proxy path.
Subscription Status
A SubscriptionStatus message will be sent to delineate the lifetime of the subscription.
-
"status": "ProcessingSnapshot" is sent at the start of processing. Topic, Key, and record updates will follow.
-
"status": "NeedsContinue" is sent if snapshot_size_limit is hit; see Subscriptions above.
-
"status": "Streaming" is sent for streaming subscriptions once the snapshot has been fully processed. Additional messages are live updates.
-
"status": "Finished" is sent once the snapshot has been fully processed and the subscription mode is non-streaming. It is also sent after an unsubscribe has been processed.
Heartbeats
Clients specify a heartbeat timeout interval within their introduction. If the server does not receive a Heartbeat message within this interval, it sends the client an Error message ("Heartbeat timeout") and closes the connection gracefully — a client that still reads (e.g. one whose sending stalled) receives the error before the close; one that doesn't is dropped after one further timeout interval.
The server also specifies its timeout interval, and sends regular heartbeats. Clients may choose to respect missed heartbeats and disconnect.
Heartbeats are expected to be sent at twice the frequency of the timeout interval. e.g. with timeout of 4 seconds, heartbeats are sent every 2 seconds.
The send cadence is fixed-rate (anchored to the previous scheduled instant, not to when the handler last ran), but a missed-tick backlog never replays. The send loop is single-threaded, so a slow client that backs up the send buffer can stall it for many intervals; rather than firing a burst of catch-up heartbeats onto the already-slow client when it resumes, the sender snaps the next beat forward past the current time, collapsing the backlog to a single heartbeat one interval out.
Synchronizing without sleep
Client code (and test harnesses) frequently needs to wait until some condition holds — the server is
accepting connections, prior writes have been applied, a subscription is live, a derived value has
settled — before taking the next step. Do not approximate these waits with a fixed sleep. A
blind delay is simultaneously too short (it races under load) and too long (it wastes time on a fast
path), and it converts a missed event into a silent, intermittent failure. The protocol exposes an
explicit signal for each case; synchronize on the signal and the timing becomes deterministic.
-
Server is ready to accept connections. Launch the server with
--server-ready-out FILE; it writes the bound{protocol, url}endpoints toFILEonce it is listening. Poll until the file is non-empty, then read the URL — never sleep waiting for a port to bind. -
All prior writes have been processed. Send an EchoRequest and wait for the matching EchoResponse (see Echo). In a proxy chain the echo is end-to-end, so its response guarantees every prior record update/delete has propagated through the whole chain. The REST API uses this internally, so an HTTP
POST/DELETEresponse already carries the guarantee — no follow-up wait is needed. -
A streaming subscription is live. After subscribing, wait for the
"status": "Streaming"SubscriptionStatus message (see Subscription Status); it is sent once the initial snapshot has been fully processed and means subsequent messages are live updates. This matters most for live-only topics (history: none): such a record is pushed only to subscribers that are already streaming when it is produced and is never retained, so a subscriber still inProcessingSnapshotwhen the event fires misses it permanently. Trigger the event only after theStreamingstatus arrives. The same transition is the signal that a publisher has delivered: tooling that waits for a publishing process to exit is waiting on the wrong thing, because a publisher's exit rides the receiver's echo barrier and still — correctly — waits for a stalled server to come back. Synchronize on the publisher'sStreamingtransition (trsgar --trs-log FILErecords it), not on its exit. -
A derived value has settled before you act on it. Poll the value (over REST, or via your subscription stream) until it holds the expected value, rather than sleeping a guessed interval.
-
A non-streaming snapshot subscription has completed — most importantly a DeleteKeys / DeleteRecords delete. Wait for the
"status": "Finished"SubscriptionStatus before treating the result as done or sendingUnsubscribe: the snapshot (and any delete it performs) is processed incrementally, so anUnsubscribeissued synchronously after the subscribe cancels the in-flight work. The REST API waits forFinishedfor you — an HTTPDELETEreturns only after the delete has fully processed — so this caveat applies to direct WebSocket clients (e.g. browser/Nodesubscribecallers), not REST callers.
For ad-hoc polling, use a bounded retry with a small interval and a hard timeout (e.g. check every 100 ms up to a few seconds) so a stuck condition surfaces as a clear timeout rather than a hang. The only place a fixed delay is defensible is a negative check — confirming that something does not happen within a window.
Termination
To gracefully close a session, clients should send a Logoff message. However, clean TCP connection closure is also acceptable.
A WebSocket client that sends a close frame (code 1000 or 1001, or no code — a browser's tab teardown) gets one back and the server then closes the connection, so the client's socket reaches CLOSED rather than waiting in CLOSING. A close carrying any other code is an error: it is reported and the connection dropped without an answer.
A close frame with an empty body is close code 1005 ("no status received") — the normal
signature of a browser page dying without a clean close. The server logs it at Info (Websocket close frame received (1005 no status)) and answers it exactly like 1000. A close frame with a
1-byte body is malformed (a carried close code is two bytes): the server warns Malformed websocket close frame (1-byte payload) and disconnects. ipc/websocket_close_frame_test.bash
sends each shape over a raw socket.
If the server encounters an issue, it may respond with an Error message prior to disconnection.
The frames a peer sent before resetting the connection are still delivered ahead of the reset, so
an Error a server wrote and then closed on — a rejected subscription, a refused introduction —
reaches the client as that error rather than as a bare "connection closed".
Connection diagnostics are written from the emitting process's point of view, and the connection
code is the same at both ends of a link: a Disconnecting client on write error line in a
publisher's log is that publisher dropping its own upstream link (its outgoing write queue
overflowed), not evidence that the process is a server.
The server sends Logoff when it is shutting down (trsgar --send-shutdown), tearing every
subscription down at once. That is an orderly end, but at the socket it is indistinguishable from
the connection breaking, and both stop the client the same way. GARClient.server_logoff records
which it was, so a long-running client can exit 0 through a planned restart instead of reporting a
failure on every one — the alternative is a supervisor alert per restart, which buries the real
failures. Read it after the client stops; register_logoff_handler is the callback form. jsgar
carries the same flag as serverLogoff.
Both libraries write their log lines as YYYY-MM-DD HH:MM:SS.mmm LEVEL:<name>:<message> in local
time — the shape trsexec and the C++ core use — so a service log carrying several of them reads
as one stream rather than as interleaved timezones and formats.
The client never reconnects; the application may start it again
A GARClient (pygar and jsgar alike) holds one connection per start() and never reconnects on
its own: a lost connection, a failed connection attempt, a heartbeat timeout, a server Logoff
or a non-recoverable Error all stop the client, and the stopped callback
(register_stopped_handler / registerStoppedHandler) fires once per session with the cause in
stop_reason (stopReason):
stop_reason |
what ended the session | start again? |
|---|---|---|
stop |
the application's own stop() / logoff() |
its call |
server_logoff |
the server's Logoff — a planned restart |
once the server is back |
disconnect |
the socket closed or broke | yes |
heartbeat_timeout |
no server heartbeat within the interval; exit_code is 1 |
yes |
error |
a non-recoverable server Error (pygar also: an undecodable message); exit_code is 1 |
after fixing what the Error reported |
connect_failed |
the connection attempt was refused, timed out, or the handshake failed | when the server is expected back |
unauthorized |
the handshake was rejected with 401 | no — the credentials are wrong |
Retrying is the application's decision, made by calling start() again on the same client,
from the stopped callback included. Each start() is a fresh session that begins from the state
of a newly constructed client: every server-assigned and client-assigned id, cached record, tick
time, diagnostic and ownership marker is cleared, so the application re-issues its subscriptions
and key introductions (both are per connection — see
Session Lifecycle); registered handlers and callbacks carry over. Nothing survives
from the previous session's teardown: a pygar heartbeat thread or a jsgar send loop and socket
still winding down cannot touch the session that replaced them. One start() attempt makes one
connection; an application that wants to wait for a server pacing its own retries with a backoff
of its choosing (connect_failed comes back at once from a refused port). pygar's start()
blocks for the life of the session and returns after the stopped callback, so each session runs
on its own thread; jsgar's returns at once. The pygar suite's test_start_again_after_disconnect
and the client_restart_test (jsgar) run a client through a killed server, a refused port, a
restarted server, and a start() from inside the stopped callback.
Writer ownership (active_ownership)
Ownership of writer-owned keys is carried by the active_ownership message, sent before the key
introductions, record updates or batches it governs; it sets connection state that stays in force
until the next one, so many writes share one declaration. Its fields:
| Field | Type | Description |
|---|---|---|
msg_ref |
message_reference |
Where recoverable errors from subsequent messages are routed. If 0, errors are logged server-side and not returned. |
ownership_action |
enum |
NONE (default) / Acquire / Release. |
skip_ownership_checks |
bool |
Ignore the current owner, so the key may be acquired, released, or deleted regardless of who holds it. |
require_existing |
bool |
Only write to existing keys — do not create new keys. If msg_ref is non-zero, sends recoverable errors for missing keys/classes. |
client_key_id |
key_id |
The remote g::Client key id the sender will use to refer to this owner — how a proxy attributes ownership to a downstream client. If zero, the connecting client's own connection key is used. |
client_key_name |
string optional |
Trailing variable field; when present, atomically introduces the g::Client key named here and binds it to client_key_id. |
The schema attributes, the ownership lifecycle, the REST and CLI surfaces and the error texts are on writer_owned_keys.md.
Failed writes
Recoverable write failures do not disconnect. Every failed write from processing one client message
batches into ONE recoverable Error message whose failed_writes array identifies each failure —
a shape-scoped reason enum (record_write/key_write union), the addressed ids and names,
malformed (server-classified: a request defect vs a server-state condition), per-failure detail,
and origin (the server or proxy that detected the failure; proxies relay upstream failures
verbatim). Delivery requires a nonzero msg_ref on the active_ownership marker — with msg_ref
0 the failure is logged server-side only. pygar (register_write_errors_handler) and jsgar
(registerWriteErrorsHandler) deliver the array to a callback as
(failed_writes, message, msg_ref). A connection introduced with disconnect_on_write_failure
instead receives the batch as a non-recoverable Error (msg_ref 0 included) and is then closed —
see Introduction Options. Pinned end-to-end by ipc/write_errors_test.bash.
malformed is true for the reasons a retry could never fix: InvalidValue, UnknownTopic and
TopicNotWritable on a record write, UnknownClass on a key write. TopicNotInClass is
deliberately state, not malformed — a class downgrade running concurrently can legitimately race
a correct writer. key_id is 0 when the write addressed the key by name. Value-level failures —
an unknown class or topic name, a value that does not parse — are per-write reports; an unmapped
binary id, a name-vs-id mismatch and a framing error stay fatal, because ids are protocol state
and a desynchronized id map poisons everything after it. The Error's text is composed from the
structured fields, in the shape KeyNotFound key 'x' topic 'y' from 'backend': <detail>. A
topic-typed value naming a topic the schema lacks is refused on every surface (a client write
reports malformed InvalidValue; a subscribe naming it fails naming the topic) — it is never
quietly stored as the null topic.
The plain error callback cannot tell the two apart. pygar's register_error_handler and jsgar's
registerErrorHandler are text-only conveniences: their wrappers pass message and drop the rest of
the Error, including the recoverable flag that says whether the connection survives. Both clients
read that flag internally to decide whether to keep going, so the information reaches the library and
stops at the wrapper. A client that must distinguish "this write was rejected" from "this connection
is finished" registers for the raw message instead — register_handler("Error", …) /
registerHandler('Error', …), whose callback receives the whole Error — or, when the interest is
specifically which writes failed, uses the write-errors callback above.
Key teardown is per class
All key teardown is delivered as KeyUpdate messages carrying deleted_class — one message per
class. When a server deletes a key entirely, it sends one deleted_class KeyUpdate per class the
receiver was shown (each with an empty remaining class_list), followed by
KeyIdRetired — a connection-scoped bookkeeping message meaning only "the sender's
introduction of this key id is gone; drop your mapping for it", never a data teardown. There is
no whole-key teardown broadcast: a subscriber's view of a key ends when its last subscribed class
is deleted. (Distributed servers can each hold a subset of a key's classes, so "all classes
gone" on one server is not global truth — per-class messages are the only view-independent form.)
DeleteKeyCommand is the client→server command requesting whole-key deletion at the receiving
server; servers never broadcast it.
A key can also be downgraded: its derived class removed while named base classes — and the
records on topics those bases still cover — are kept. On the wire this is the same message,
key_update{deleted_class: Derived, class_list: [bases]}, with the retained bases as the surviving
list; no protocol version ticks for it. A receiver that predates downgrades applies it as a full
delete of the class, dropping the retained bases. pygar's register_deleted_class_handler and
jsgar's registerDeletedClassHandler are the client surface for every deleted_class update,
whole-key deletes and downgrades alike, and receive the remaining class list with each.
Example Session (Client Perspective)
Sent: {"message_type": "Introduction", "value": { "version": 650269, "heartbeat_timeout_interval": 3000, "user": "jonh" }}
Received: {"message_type": "Introduction", "value": { "version": 650269, "heartbeat_timeout_interval": 3000, "user": "jserver" }}
Sent: {"message_type": "Subscribe", "value": { "subscription_mode": "Streaming", ... }}
Received: {"message_type": "ProcessingSnapshot", "value": { "name": "S1" }}
Received: {"message_type": "TopicIntroduction", "value": { "topic_id": 18, "name": "top_bid_price" }}
...
Received: {"message_type": "SnapshotComplete", "value": { "name": "S1" }}
Sent: {"message_type": "Heartbeat", "value": { "u_milliseconds":, 1745425692890 }
Received: {"message_type": "Heartbeat", "value": { "u_milliseconds":, 1745425693895 }
...
Sent: {"message_type": "Logoff"}
Auditing Topics (Control Port)
When running in audit mode (--ws-control-port <port>), GAR will emit internal topic traffic for monitoring
g Schema
Protocol Schema
Related
- af_unix_transport.md — local connections silently use AF_UNIX sockets
rather than TCP loopback, and why a URL may resolve to
unix:. - gar_pipelining.md — streaming records between endpoints in a shell pipeline, and the failure modes to watch for.
Deployment Schema
Features in this area
- client libraries — Connecting to GAR from Python (pygar) and JavaScript (jsgar) over WebSocket.
- subscription class join — Joining a subscription's key filter against another class's key set.
- key name list subscription — Making a subscription with a finite list of key names snapshot by direct lookup instead of scanning the class.
- rpc — Calling a named GAR function and getting a structured reply.
Internals
The implementation record — every private section this page used to carry — is protocol_internals.md.
Release files for pygar-client 4.19.10
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pygar_client-4.19.10-py3-none-any.whl | Python 3 | none | any | Details |
Release files / pygar_client-4.19.10-py3-none-any.whl
| Download URL | pygar_client-4.19.10-py3-none-any.whl |
|---|---|
| Size | 44.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2b6b3a4b654205a2fe730028f5468d2f5d7efe34390bdd64289df18529a83e69
|
|
BLAKE2b-256 checksum How to use checksums |
d62cccc1ece63418e799227189852be271adfe75c936fd10388c3820e0f5b705
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.12.3
|