Skip to main content

weir_client/
lib.rs

1//! Synchronous blocking client for the weir daemon.
2//!
3//! Connects over a Unix domain socket (or TCP + mutual TLS behind the `tls`
4//! feature) and exchanges frames using the weir wire protocol v1. Each method
5//! issues one request and reads one response — no pipelining. For concurrent
6//! producers, create one [`WeirClient`] per thread.
7//!
8//! # Platforms
9//!
10//! The Unix-socket transport is, unsurprisingly, Unix-only. **The TCP + mutual
11//! TLS transport is not** — it builds wherever rustls does, Windows included,
12//! as of 2.0.3. Before then it was gated on `unix` by accident: it lives in its
13//! own module but imported two types that happened to sit in the Unix one.
14//!
15//! # Throughput: one record per round-trip
16//!
17//! [`push`](WeirClient::push) is synchronous: it sends one frame and blocks for
18//! the ack before returning, so one connection carries one record at a time.
19//! What that costs depends on the tier, and the two are bounded by different
20//! things:
21//!
22//! - **`Buffered`** is round-trip bound. The daemon acks from memory, so the
23//!   ceiling is roughly `1 / RTT`.
24//! - **`Durable`** is fsync bound, not RTT bound. The ack waits on the daemon's
25//!   fsync, which dominates the round trip by orders of magnitude — batching at
26//!   the daemon amortises it across concurrent producers, which is why fanning
27//!   out helps this tier *more* than it helps `Buffered`.
28//!
29//! Measured on one machine (Apple M3 Max, Unix socket, 256-byte records): a bare
30//! AF_UNIX round trip is **~7 µs**, and a single client reaches **~32,000 rec/s
31//! `Buffered`** and **~5,700 rec/s `Durable`**. On a 2-vCPU CI runner the same
32//! figures are ~13,900 and ~3,700. Treat these as the shape of the problem, not
33//! as a spec: they move with hardware, filesystem, and record size. An earlier
34//! version of this paragraph quoted "~50 µs RTT" and "~20k rec/s", which matched
35//! neither tier on any machine we have measured.
36//!
37//! To go faster, **fan out across connections**: create one `WeirClient` per
38//! producer thread (they're independent and the daemon handles many
39//! concurrently). Ordering is only guaranteed within a single connection's
40//! sequential pushes, not across connections, and it is the order records are
41//! written to the daemon's WAB, not the order the sink receives them: strands,
42//! retry cool-downs, parked segments, `weir-ctl` requeues and the HTTP sink's
43//! concurrent POSTs can all reorder delivery.
44//!
45//! **A pooled client needs no protocol change at all** — the wire protocol
46//! already permits several frames in flight on one connection, and fanning out
47//! across connections is measurable today: roughly 5× on `Buffered` at 16
48//! connections and 9× on `Durable` at 48. A built-in batched-push *frame* is the
49//! part that needs new wire machinery. An earlier version of this paragraph
50//! attributed the protocol cost to both and so steered readers away from the one
51//! option that already works.
52//!
53//! # Example
54//!
55//! ```no_run
56//! # #[cfg(unix)] {
57//! use weir_client::{WeirClient, Durability}; // Durability is re-exported from weir-core
58//!
59//! let mut client = WeirClient::connect("/run/weir/weir.sock").unwrap();
60//! client.push(b"hello world", Durability::Durable).unwrap();
61//! # }
62//! ```
63//!
64//! # Knowing *which* record you pushed
65//!
66//! [`push`](WeirClient::push) returns `()`: the record was accepted, and nothing
67//! more. [`push_tracked`](WeirClient::push_tracked) instead returns a
68//! [`RecordCoordinate`] — the buffer segment the record landed in, its index
69//! within that segment, and the `record_id` the downstream sink will see for it
70//! as its idempotency key. That is what lets a producer reconcile what it sent
71//! against what arrived, rather than carrying its own sequence inside the
72//! payload and reconciling server-side.
73//!
74//! ```no_run
75//! # #[cfg(unix)] {
76//! use weir_client::{WeirClient, Durability};
77//!
78//! let mut client = WeirClient::connect("/run/weir/weir.sock").unwrap();
79//! let at = client.push_tracked(b"hello world", Durability::Durable).unwrap();
80//! println!("{} record {} -> {}", at.segment(), at.index(), at.record_id_hex());
81//! # }
82//! ```
83//!
84//! It is a **buffer address, not a per-producer sequence**: other producers'
85//! records interleave, so your own indices have holes. See
86//! [`push_tracked`](WeirClient::push_tracked) for the full list of what it does
87//! and does not promise, including how to detect a daemon that predates it.
88//!
89//! # Ack vs. delivery
90//!
91//! A successful [`push`](WeirClient::push) means the record is **durably buffered
92//! at the requested [`Durability`] tier** — fsync'd to the write-ahead buffer for
93//! [`Durable`](Durability::Durable), in memory for
94//! [`Buffered`](Durability::Buffered). It does **not** mean the record has reached
95//! your downstream sink yet: the daemon drains buffered records to the sink in
96//! batches, only once a WAB segment seals (its size threshold, or daemon
97//! shutdown). For a small smoke test the sink may not be touched at all — watch
98//! `weir_records_ack_total` (acceptance), not the sink-commit metric, to confirm
99//! the daemon took your records.
100//!
101//! [`push_tracked`](WeirClient::push_tracked) does not change this. Its
102//! coordinate says *where the record will be read from*, not that it has been
103//! read — it is an address, not a delivery receipt.
104//!
105//! # Running the daemon
106//!
107//! This crate is the producer side; the daemon is the `weir-server` binary:
108//!
109//! ```text
110//! mkdir -p /run/weir/wab            # the daemon does not create its directories
111//! weir-server --wab-dir /run/weir/wab --socket-path /run/weir/weir.sock
112//! ```
113//!
114//! `--wab-dir` must already exist. On macOS, do not place the socket directly in
115//! `/tmp` (it is a symlink the hardened bind rejects) — use a dedicated `0700`
116//! directory. Run `weir-server --help` for the full option list.
117//!
118//! # Observability
119//!
120//! The daemon serves Prometheus metrics at `127.0.0.1:9185/metrics` by default.
121//! The counters a producer cares about are labelled by tier/reason, e.g.
122//! `weir_records_accepted_total{tier="durable"}`,
123//! `weir_records_ack_total{tier="durable"}`, and
124//! `weir_records_nack_total{tier="durable",reason="empty_payload"}`.
125#![deny(missing_docs)]
126
127/// Transport-agnostic client core. Split out of `unix` so that `tls` — which
128/// needs a `TcpStream` and rustls, and nothing Unix-specific — is gated on the
129/// feature alone rather than on the platform.
130mod client;
131
132pub use client::{BatchOutcome, ClientError, DefaultTransport, WeirClient};
133
134#[cfg(unix)]
135mod unix;
136
137/// Re-export of [`weir_core::Durability`] so the common producer path needs a
138/// single crate import (`weir_client::Durability`).
139pub use weir_core::Durability;
140
141/// Re-exported: the durable address [`WeirClient::push_tracked`] returns for an
142/// accepted record. Lives in `weir-core` because both ends of the socket build
143/// it from the same bytes.
144pub use weir_core::RecordCoordinate;
145
146/// Re-export of [`weir_core::NackReason`] — the payload of [`ClientError::Nack`].
147/// Re-exported so consumers can match on the reason (e.g. to distinguish the
148/// connection-closing Nacks) without taking a direct dependency on `weir-core`.
149pub use weir_core::NackReason;
150
151#[cfg(feature = "tls")]
152mod tls;
153
154#[cfg(feature = "tls")]
155pub use tls::{ClientTlsConfig, TlsStream};