Architecture
A view of the shared Rust client and its language bindings.
Crate dependencies
The client has five product crates. The workspace also includes aviso-e2e,
an unpublished package for end-to-end tests. Arrows below point from a crate
to its dependency; the server connection is a network call.
flowchart TB
subgraph consumers["consumers"]
direction LR
cli["aviso-cli<br/>CLI library and binary"]
py["aviso-py<br/>PyO3 cdylib<br/>(Python bindings)"]
ffi["aviso-ffi<br/>C ABI and C++ facade"]
end
core["aviso<br/>Core Rust client"]
parser["finesse<br/>Synchronous SSE parser"]
server["aviso-server<br/>(separate repo)"]
cli --> core
py --> core
py -- bundled CLI --> cli
ffi --> core
core --> parser
core -- HTTP + SSE --> server
The CLI and language bindings share the core library. The Python extension
also depends on the CLI library so the Python distribution can bundle the
aviso command. The core library does not depend on the CLI or any binding
crate.
The crates in more detail
aviso (core library)
The shared client behavior lives here. Adapters handle language-specific interfaces and application setup.
- HTTP:
reqwestwith rustls for TLS. - SSE: an in-tree parser (
finesse) implementing the WHATWG parsing algorithm. The reconnect loop is owned, not delegated; off-the-shelf SSE crates assume the WHATWGLast-Event-IDmechanism, but aviso-server’s resume contract isfrom_id/from_datein the POST body, which requires a re-POST on every reconnect. - Reconnect supervisor: a single task per
watch()call, driving a small state machine. - State store: a trait with two built-in implementations, an in-memory store and a JSON file store with crash-safe atomic writes.
- AuthProvider: an async trait with five built-in providers.
- Trigger dispatcher: a crate-private enum with a public builder API.
aviso-cli
The CLI library resolves layered config (flag > env > file > default),
builds an AvisoClient, attaches a JsonFileStore, parses listener YAML,
and dispatches subcommands. A small binary exposes it as aviso.
The standalone binary and the Python-bundled command call the same
aviso_cli::run entry point.
The CLI’s responsibilities are mostly about composition and I/O surfaces. It does not implement any of the SSE, reconnect, or trigger logic; that is all in the library.
aviso-py
The PyO3 extension crate. Built as a cdylib for the Python wheel and as an
rlib so the workspace cargo test sees its types. Exposes a synchronous
AvisoClient and an asynchronous AsyncAvisoClient over the same channel the
Rust core uses, plus typed value classes, the trigger builder, auth providers,
state stores, and the exception hierarchy.
The distribution and public Python package are named pyaviso. The compiled
extension is pyaviso._native; pyaviso/__init__.py re-exports its classes
and defines Python-side enums and type aliases.
The installed aviso command and python -m pyaviso both enter through
pyaviso.__main__:main. A native bridge releases the GIL and calls the CLI
library in-process, rather than starting a separate executable.
aviso-ffi
The C adapter exposes a stable ABI over the core library and builds static
and shared libraries. Its committed aviso.h header is generated with
cbindgen.
The hand-written aviso.hpp header provides a C++17 facade over that ABI.
It wraps C handles with RAII and translates errors into C++ exceptions.
It supports blocking calls, asynchronous calls returning std::future, and
callback-based listeners. The facade is header-only, not a separate Rust crate.
finesse
The SSE parser, kept separate so the protocol can evolve without churning the rest of the library. It owns no transport, no async runtime, and no aviso semantics: the caller drives the parser by feeding bytes in and draining typed frames out.
The parser holds bytes until a line terminator or a blank line arrives, so it
bounds how much it will hold: 16 MiB for one line and 32 MiB of data: for
one event by default. A notification arrives as one line, and the server’s
store passes on at most a few MiB, so a large polygon is never refused. A
server
that exceeds a bound ends the watch with a stream protocol error rather than
being kept in memory. Each byte is examined once, so a long line costs time
proportional to its length.
Data flow at runtime
When you call client.watch(WatchRequest::watch("mars")):
- The listening surface constructs a
WatchRequest, derives a resume key, and spawns a supervisor task. - The supervisor reads the cursor from the state store (if one is configured).
- The supervisor sends a
POST /api/v1/watchto the server with the filter and the cursor. - The server replies with an SSE stream. The supervisor reads chunks, feeds
them into the
finesseparser, and turns parsed frames intoNotificationvalues. - For each notification, the supervisor:
- runs every configured trigger,
- persists the previous notification’s sequence to the state store (next-send commit),
- sends the current notification on a bounded channel to the consumer.
- On a
connection-closingevent, a transport error, a heartbeat timeout, or a 5xx, the supervisor reconnects using the right backoff for the cause. - On a terminal error (4xx other than 401, second 401 after refresh, required trigger failure exhausted retries, history gap), the supervisor closes the stream with the error and exits.
Dropping the NotificationStream cancels the supervisor cooperatively through a
oneshot channel; the supervisor exits within one event-loop tick.
Startup confirmation
The connection runner validates HTTP 200 and the SSE media type before decoding
the body. An opening gate consumes frames until the expected Aviso control
arrives: connection_established for live-only or replay_started for a
resolved historical cursor. Headers and opening share a ten-second deadline.
Before the first handshake an expired deadline is fatal; on a reconnect of a
confirmed watch it is a lost connection, retried with backoff.
Only then does it transition to Connected, reset backoff, and publish
readiness.
NotificationStream::subscribe_ready() exposes a watch receiver whose value
stays true after the first handshake. The CLI uses it for its Listening
status; it does not infer readiness from log text or wait for the first
notification. Retry telemetry uses tracing in the listener span, with causes
and delays coalesced over five seconds. Status stays on stderr.
WatchRequest::with_startup_timeout optionally bounds the supervisor until the
first handshake, including cursor lookup, authentication, and retry sleeps.
The initial budget is removed once readiness becomes true. It never wraps
notification dispatch, persistent checkpoint writes, or consumer backpressure.
The CLI sets this budget to 30 seconds unless overridden; bindings inherit the
core’s unlimited initial retry policy and per-connection protocol validation.
Cancellation and shutdown
aviso is cooperative everywhere. There are three cancellation paths:
- Per-stream: dropping the
NotificationStreamdrops a oneshot sender; the supervisor’sselect!notices and exits. - Parent-cascade: dropping the last
AvisoClientclone trips atokio::sync::watchflip that every child supervisor observes. The stream then ends without an error. The Python and C bindings therefore keep a clone for each open stream, so a stream outlives the client object it came from. - Ctrl+C in the CLI: a signal handler triggers a graceful drain via the same per-stream mechanism for every active listener.
The supervisor’s select! is biased so cancellation cannot be starved by a
fast stream. Every supervisor await (auth header, HTTP send, chunk read, channel
send) is wrapped in the cancel arm.
Why a single supervisor per listener
A bounded channel (capacity 128 by default; 1 when a state store is configured)
sits between the supervisor and the consumer. The channel applies TCP
backpressure end to end: when the consumer falls behind, the supervisor’s send
blocks, which makes it stop reading bytes from the wire, which throttles the
server.
When a state store is configured, the channel capacity drops to 1 so the supervisor’s commit-of-previous-notification is forced to happen before the consumer can pull the next one. The user-facing contract is “pulling item N+1 implies item N is durable”, and capacity 1 is what makes that true.
Watch connections
A watch keeps one HTTP request open for as long as it runs. Over HTTP/2, all requests from one HTTP client to a server share a single TCP connection, and servers and proxies limit how many requests one connection may carry at once (nginx allows 128 by default, HAProxy 100). A watch past that limit is not refused: its request waits for a free stream, which never comes while the other watches stay open.
The client therefore keeps two kinds of HTTP client. Ordinary requests
(notify, schema, admin calls) use one, with the request timeout the caller
configured. Watches use a small set of others, each carrying at most 64
watches. When every client in the set is full, the next watch gets a new
client and therefore a new connection; when a watch ends its slot is freed,
and clients left idle at the end of the set are released. The watch clients
have no request timeout, because a watch’s response is meant to stay open;
the ten-second opening deadline and the heartbeat watchdog bound a stalled
watch instead. Clones of an AvisoClient share both.
A proxy that allows fewer than 64 streams per connection makes the watches past its limit fail the opening deadline with “no response from the server”.
The state-store contract
The store is strictly monotonic: a put whose sequence is at or below the
existing on-disk value is silently a no-op. This is what guarantees the cursor
never moves backwards, even across concurrent writers or after operator errors
editing the file.
For multiple processes, the file store uses an advisory flock on a sidecar
lockfile. The lockfile is separate from the data file because the atomic-rename
pattern would otherwise invalidate the lock between operations.
A failed put leaves both memory and disk unchanged. Aside: put and delete
on the file store are not cancellation-safe; drive them to completion. The CLI
does this.
What is intentionally not here
- Client-side schema validation. The server is the single source of truth.
aviso does not depend on
aviso-validatorsand does not pre-validate notifications. - Auto-retry on
notify. APOSTthat fails after the request body has been sent might have been processed; a blind retry would risk a duplicate. When a server-side idempotency-key contract becomes available the policy will be revisited. - A metrics surface. The core emits structured
tracingevents. Consumers that need Prometheus or OpenTelemetry metrics build a thin adapter on top.
Where to go next
- Library guide: how to use the library from your own code.
- Contributing: tests, gates, the workflow.