Integrating & extending weir
weir isn't only a daemon you run as-is — it's a set of crates you can compose. Each piece is usable without pulling in the daemon, so you can take just the parts you need: produce records from your own app, forward to your own destination, read the on-disk buffer directly, or run operations against a live daemon. The crate boundaries are real (enforced by the dependency graph, not convention — see Architecture → Workspace & crate boundaries).
Which crate do I need?
| You want to… | Use | Pulls in the daemon? |
|---|---|---|
| Run the buffer as a service | weir-server (the weir-server binary) | it is the daemon |
| Push records from your own app | weir-client | no |
| Forward records to a custom destination | weir-sink-sdk (implement Sink) | no (to author + test) |
| Read / inspect / recover WAB segments off disk | weir-wab | no |
| Operate a running daemon from the shell | weir-ctl | no |
| Build a producer/client in another language | the wire protocol (no Rust dep at all) | no |
The crates this guide covers are sibling directories under crates/ — weir-client,
weir-sink-sdk, weir-wab, weir-core, weir-server, weir-ctl — so a
path dependency points at crates/<name> (e.g.
weir-client = { path = "../weir/crates/weir-client" }).
crates/ also holds weir-attest (tamper-evidence over sealed segments),
weir-sink-s3 (the S3-API sink), weir-rs (a single-crate facade that
re-exports core/client/sink-sdk/wab behind features) and weir-testkit
(publish = false, test harness only).
Produce from your own program
The synchronous client is one blocking round-trip per push — you don't need an
async runtime to use it. See the Quickstart
for the full example and the Cargo.toml dependency block; in short:
#![allow(unused)] fn main() { use weir_client::WeirClient; use weir_core::Durability; let mut client = WeirClient::connect("/run/weir/weir.sock")?; client.push(b"hello, weir!", Durability::Durable)?; }
For throughput, fan out across connections (one WeirClient per producer
thread) — a single connection is bounded by one ack per record, not by the
socket round trip: the daemon does not read the next frame until the current one
is acked, so on Durable a lone connection gets ~1 fsync per record however the
client is written. Fanning out gives each group fsync more records to ride —
measured on one M3 Max over a loopback Unix socket, 16 connections reached
5.2× a single client's Buffered rate and 48 reached 9.4× its Durable
rate. The gain is not monotonic, though: a few Durable connections spread thin
across a multi-shard daemon can land below the single-connection rate — see the
shard_count caveat. See the
weir-client crate docs for the ordering caveat.
The tier is per call, not per client.
Durabilityis an argument topush, and it becomes a byte on that record's wire envelope — so oneWeirClientcan sendDurablefor the record it cannot lose andBufferedfor the telemetry beside it, interleaved, with no second connection and no reconnect. The daemon dispatches per record inside a single flush batch, so theBufferedrecords genuinely skip the fsync they share a batch with. This is usually the right shape: reach for the fast path where the data is replaceable, and pay for durability only where it matters.
Already inside an async runtime?
push()is a blocking call — calling it directly from anasync fnblocks the executor thread and starves the runtime. See Producing from an async runtime below.
Producing from an async runtime
WeirClient is synchronous and blocking: every push() writes a frame and
then blocks the calling thread until the daemon's ack comes back. On an async
runtime that calling thread is an executor thread — so a naive
client.push(..)? inside an async fn blocks the executor and starves every
other task and timer scheduled on it. It compiles, passes a one-record smoke
test, and then falls apart under load: a measured 300-push burst run this way
took ~2.4 s and fired zero of the ~47 heartbeat ticks expected in that
window — the timers never got to run because the push monopolised the thread.
Why spawn_blocking is not the fix here. The obvious reflex —
tokio::task::spawn_blocking(move || client.push(..)) — does not fit this
client. push() takes &mut self and the client owns a single live connection
(one record per round-trip, no pipelining), so it can't be shared across the
blocking-pool tasks tokio hands work to. You'd either reconnect on every push
(throwing away the persistent connection) or wrap the one client in a Mutex
and serialise every push through it — which collapses to a single connection and
kills the per-connection fan-out the client docs recommend for throughput.
Recommended bridge: a pool of dedicated producer threads. Stand up a fixed
pool of plain std::threads, each owning one persistent WeirClient and
draining a bounded channel of jobs. The async side enqueues a job and .awaits
a reply channel for the durable ack:
- bounded channel → real back-pressure: when producers can't keep up, the
channel fills and the async caller's
sendawaits, instead of unboundedly buffering; - one client per thread → fan-out: N threads = N independent connections, exactly the parallelism the Produce note recommends, with each connection driven sequentially (ordering holds within a connection, not across them);
- per-job reply channel → the ack contract survives end-to-end: the async caller learns the push was durably acked (or why it failed), so the at-least-once guarantee reaches your async code.
Reconnect after poison. A producer thread must handle a poisoned client. Once
a connection-fatal failure occurs, the client poisons itself and every
subsequent call fails fast until it is dropped and rebuilt. Branch on the client
API rather than matching the #[non_exhaustive] error enum:
WeirClient::is_poisoned(&self) -> bool—trueonce the client must be rebuilt;ClientError::is_recoverable(&self) -> bool—truemeans the connection is still usable (retry/continue on the same client);falsemeans drop the client and reconnect.
Recoverable (connection untouched, keep using it): the local pre-send
ClientError::PayloadTooLarge rejection, the local pre-send
ClientError::EmptyPayload rejection (a zero-length payload rejected before any
bytes are sent), ClientError::NoDefaultDurability, and
ClientError::Nack(NackReason::InternalError) (the one transient Nack the daemon
keeps the connection open for). Everything else is non-recoverable and the daemon
has closed (or the socket is dead): all other server Nacks, plus
ClientError::Io and ClientError::Protocol. Note the name collision — the
recoverable local ClientError::PayloadTooLarge (payload over the protocol
hard cap, rejected before any bytes are sent) is a distinct variant from the
non-recoverable server ClientError::Nack(NackReason::PayloadTooLarge) (payload
over the daemon's configured max_payload_bytes, which the daemon Nacks then
closes). After a push error, if client.is_poisoned() (equivalently
!err.is_recoverable()), drop and rebuild the client before the next job.
Set a read timeout (and re-apply it on reconnect). By default every push
blocks forever waiting for the daemon's ack. A wedged daemon (hung flusher,
SIGSTOP, half-open socket) would freeze the producer thread indefinitely; with
no thread draining the bounded channel, it fills and the back-pressure becomes a
permanent stall instead of a recoverable hiccup. Call
set_read_timeout
(and set_write_timeout
to bound a stalled request write) right after connect, so a stalled reply
surfaces as a ClientError::Io timeout the thread can act on. The timeout lives
on the socket, so a fresh connection starts with none — you must re-apply
it after every reconnect, not just at startup. Pick a value comfortably above
the daemon's Durable ack latency (its own ACK_TIMEOUT is 30 s, so e.g. 45–60 s lets
a legitimate Nack win rather than racing it).
A sketch of one producer thread (the async-side glue — channel wiring and the spawn of N of these — is left out for brevity):
#![allow(unused)] fn main() { use std::time::Duration; use tokio::sync::oneshot; use weir_client::{ClientError, Durability, WeirClient}; // One job: bytes to push, the tier, and where to send the ack back. struct Job { payload: Vec<u8>, durability: Durability, reply: oneshot::Sender<Result<(), ClientError>>, } // Open a connection and apply the timeouts. Called at startup AND on every // reconnect — the timeouts live on the socket, so a fresh connection has none. fn connect(sock: &str) -> WeirClient { let client = WeirClient::connect(sock).expect("connect"); // Bound the ack wait so a wedged daemon can't freeze this thread forever. client.set_read_timeout(Some(Duration::from_secs(60))).expect("set read timeout"); client.set_write_timeout(Some(Duration::from_secs(60))).expect("set write timeout"); client } // Runs on a dedicated std::thread; owns ONE persistent connection. fn producer_thread(sock: &str, jobs: std::sync::mpsc::Receiver<Job>) { let mut client = connect(sock); for job in jobs { // blocks until the next job; ends on channel close let result = client.push(&job.payload, job.durability); if let Err(e) = &result { // Surface WHY the push failed, not just THAT it did: Display gives the // human reason, is_recoverable() / is_poisoned() give the next action. tracing::warn!( error = %e, // ClientError Display recoverable = e.is_recoverable(), // false → drop & reconnect poisoned = client.is_poisoned(), // true → client must be rebuilt "push failed" ); // Drop the dead client and reconnect; a poisoned client fails every // later call. Recoverable errors (local rejects, InternalError Nack) // leave the connection usable, so we keep it. `connect` re-applies the // timeouts the fresh socket would otherwise lack. if !e.is_recoverable() || client.is_poisoned() { client = connect(sock); } } let _ = job.reply.send(result); // async caller .await's this for the durable ack } } }
Wiring N of these (channel choice matters).
std::sync::mpscis single-consumer: oneReceivercannot be shared across the pool, so give the pool either N independent channels (round-robin dispatch from the async side) or a shared/MPMC consumer (e.g.crossbeam-channel). And the async-sidesendmust never block an executor thread — use a boundedtry_sendto shed load (or offload the blocking send), not a blockingsend.
A first-class async producer type is a candidate for a future release; today this bridge is the recommended way to produce from async code.
Write a custom sink
Implement the Sink trait from weir-sink-sdk
(depends only on weir-core — no daemon, no tokio needed to build or test it):
#![allow(unused)] fn main() { use weir_sink_sdk::{BasicSinkError, CommitResult, Payload, Sink, SinkBatch, SinkHealth}; struct MySink { /* a client handle, etc. */ } impl Sink for MySink { type Error = BasicSinkError; // ready-made error; or your own SinkError async fn commit(&self, batch: SinkBatch) -> Result<CommitResult, BasicSinkError> { // `batch.dedup_token()` is a content-derived idempotency handle for this // batch — pass it downstream if your target can deduplicate on one. // If a duplicate is expensive, read "Choosing an idempotency key" below // before you build on it: `batch.record_ids()` is usually the right one. let mut committed = Vec::new(); let mut dead_lettered = Vec::new(); // Vec<(Payload, String)> for record in batch.into_records() { match deliver(record.as_ref()) { // your delivery logic Ok(()) => committed.push(record), // PERMANENT rejection (e.g. a 4xx): dead-letter the record paired // with a human-readable reason. dead_lettered is Vec<(Payload, // String)> — the record AND why it was rejected, for the operator. Err(reason) => dead_lettered.push((record, reason)), } } // A *transient* failure instead returns Err, so the drain retries the // WHOLE segment with backoff (don't dead-letter on transient errors): // return Err(BasicSinkError::transient("503 from upstream")); Ok(CommitResult::new(committed, dead_lettered)) } async fn health(&self) -> SinkHealth { SinkHealth::Healthy } } }
Every record handed to commit must end up in exactly one of committed or
dead_lettered (the drain enforces this before confirming the segment). To build
a Payload yourself — e.g. in a unit test — use Payload::from(vec),
Payload::from("text"), Payload::from(&b"bytes"[..]), or
Payload::copy_from_slice(bytes); read its bytes with payload.as_ref() /
&payload[..].
CommitResult exposes both partitions as public fields for reading back what
a commit returned — committed: Vec<R> and dead_lettered: Vec<(R, String)>,
where each dead-letter entry pairs the record with its human-readable reason
(it's #[non_exhaustive], so construct via CommitResult::new, but read the
fields directly):
#![allow(unused)] fn main() { let result = my_sink.commit(batch).await?; println!("committed {} records", result.committed.len()); for (record, reason) in &result.dead_lettered { eprintln!("dead-lettered {} bytes: {reason}", record.as_ref().len()); } }
Choosing an idempotency key
weir's delivery is at-least-once: after a crash, or when a stranded segment
auto-resumes, the drain re-commits records it has already handed you. A
SinkBatch carries two different handles for recognising that, and picking the
wrong one is a silent bug rather than a visible failure.
batch.dedup_token() | batch.record_ids() | |
|---|---|---|
| Scope | the whole batch, one value | one value per record |
| Derived from | the payload bytes | the record's WAB coordinate + its bytes |
| Two records with identical bytes | same value | different values |
| Same batch re-sent after a retry | same value | same values |
Same records re-split because sink_max_batch_size changed | different value | same values |
Read that last row twice. The drain re-reads sink_max_batch_size on every
call, so an operator editing it — or a redeployed pod with a different config —
changes where the batch boundaries fall on the next delivery of an
already-partly-delivered segment. A batch-level key is a function of the batch,
so it moves; a RecordId names the record's position in the buffer, so it does
not.
- A duplicate is cheap (an archive, a log, an analytics table you
SELECT DISTINCTover):dedup_token()is simpler, and its precondition — keepsink_max_batch_sizestable — is easy to hold. - A duplicate is expensive (a billing row, a metering event, anything that
would need a refund): key on
record_ids(). Write the id as a column with aUNIQUEconstraint, or send it as a per-requestIdempotency-Key. This is what the built-in Postgres and MySQL sinks do whensink_postgres_id_column/sink_mysql_id_columnis set, and what the HTTP sink does in per-record mode.
Do not reach for a hash of the payload as a dedup key. It is not just
weaker — it loses data: a producer emitting repetitive records (a heartbeat, a
fixed-shape metering event) hands the downstream the same key for genuinely
distinct events, and a correctly-implemented idempotent endpoint keeps the first
and discards the rest. That is what RecordId exists to prevent.
The drain always supplies record_ids(); None means the batch was hand-built,
which only happens in a sink author's own tests. If your sink's correctness
rests on them, treat None as a programming error and fail loudly rather than
substituting a key that is a lie.
You can unit-test a sink that does no real I/O against the contract with no
runtime — the test in the weir-sink-sdk crate
(a_custom_sink_can_be_driven_and_unit_tested) shows a tiny block_on helper
that polls a ready future to completion, then asserts on result.committed /
result.dead_lettered. See also
Testing → Sink integration.
The
block_onhelper is for ready futures only. It busy-polls with a no-op waker, so it only works whencommit/healthfinish synchronously (an in-memoryVecpush, no yield point). Point it at a sink that does a realawaitthat yields — a socket read, a timer,tokio::fs,sqlx,reqwest— and the loop spins the CPU forever and never makes progress, because the no-op waker can never reschedule aPoll::Pendingfuture. For a sink that does real async I/O, use a#[tokio::test]and.awaitthe commit directly — see the next section.
Unit-testing a real-I/O sink
A sink that touches a real async resource (a file, a socket, a DB connection)
returns Poll::Pending at its await points, so the no-op-waker block_on
above will not drive it. Use a real runtime: a #[tokio::test] lets you
.await the commit directly, and a tempfile/tempdir gives the sink a real I/O
target with no external service. Assert on the returned CommitResult —
committed for what landed, dead_lettered for what was permanently rejected:
#![allow(unused)] fn main() { // dev-dependencies: tokio = { version = "1", features = ["macros", "rt", "fs"] } // tempfile = "3" use weir_sink_sdk::{CommitResult, Payload, Sink, SinkBatch, SinkHealth}; #[tokio::test] async fn commits_good_records_and_dead_letters_bad_ones() { let dir = tempfile::tempdir().unwrap(); let sink = FileSink::new(dir.path().join("out.log")); // your real-I/O sink // `SinkBatch::from` derives the dedup token from the records, exactly as // the drain does. Use `SinkBatch::new` if you need to pin a specific token. let batch = SinkBatch::from(vec![ Payload::from(&b"good"[..]), Payload::from(&b""[..]), // your sink rejects empty records Payload::from(&b"also-good"[..]), ]); let result: CommitResult = sink.commit(batch).await.unwrap(); assert_eq!(result.committed.len(), 2); assert_eq!(result.dead_lettered.len(), 1); assert_eq!(&result.dead_lettered[0].0[..], b""); // The two good records are now on disk in the tempfile; the dir is removed // when `dir` drops at end of test. } }
weir-testkitis not a sink unit-test surface. It ispublish = falseand spawns the whole daemon as a child process forweir-server's integration tests — useful for end-to-end coverage, not for exercising aSinkimpl in isolation. Unit-test your sink directly as above (callcommitand assert on theCommitResult); reach for a real daemon only for end-to-end tests.
Parsing payloads and rejecting malformed input
If your sink parses each payload into a struct, do it inside commit and
keep the original Payload beside the parsed value. Records that fail to parse
are dead-lettered with a reason; retrying them would not make unparseable bytes
parse, so the failure is permanent by definition:
#![allow(unused)] fn main() { use weir_sink_sdk::{CommitResult, Payload, Sink, SinkBatch, SinkError, SinkHealth}; impl Sink for MySink { type Error = MySinkError; async fn commit(&self, batch: SinkBatch) -> Result<CommitResult, MySinkError> { let mut committed = Vec::new(); let mut dead_lettered = Vec::new(); for payload in batch.into_records() { match Parsed::try_from(payload.as_ref()) { Ok(_parsed) => { // deliver `_parsed` downstream … committed.push(payload); } // Malformed on arrival — permanent, so dead-letter it. The // dead-letter store receives exactly the bytes weir handed you. Err(e) => dead_lettered.push((payload, format!("unparseable payload: {e}"))), } } Ok(CommitResult::new(committed, dead_lettered)) } async fn health(&self) -> SinkHealth { SinkHealth::Healthy } } }
committed and dead_lettered must together account for every record in
the batch — see CommitResult for what happens
if they do not.
Changed in 2.0. weir 1.x let a sink declare
type Record = MyRecordand implement aSinkRecordtrait withfrom_payload/into_payload. That is gone: records are alwaysPayload. The old design carried a real footgun — the per-record dead-letter path recovered bytes viainto_payloadwhile the whole-batch-Errpath used the original payload, so a non-byte-preserving round-trip made the two disagree. Parsing insidecommitcannot drift that way, because there is only ever one set of bytes.
Wrapping a non-Sync handle
Sink requires Send + Sync, but some client handles are Send and not
Sync (e.g. rusqlite::Connection, which holds an SQLite handle behind a
RefCell). Wrap such a handle in a std::sync::Mutex (or tokio::sync::Mutex
if you hold it across an .await) to recover Sync:
#![allow(unused)] fn main() { struct SqliteSink { conn: std::sync::Mutex<rusqlite::Connection>, // Connection: Send + !Sync → Mutex makes it Sync } }
The lock looks like a serialisation point but costs nothing in practice: the
drain is single-threaded and calls commit on one segment at a time, so
commits are already serialised — the Mutex is never actually contended. It is
there only to satisfy the Sink: Send + Sync bound, not to arbitrate concurrent
callers.
Running it inside the shipped daemon today means building a
weir-serverwith your sink wired into the sink-selection path (effectively a small fork) — there is no dynamic plugin/registration path yet. The SDK's present value is authoring and testing a sink against a stable, frozen contract; a first-class entry point for downstream sinks is a candidate for a future minor release.
Read or recover WAB segments directly
weir-wab is a standalone, runtime-free reader of the on-disk segment format
(depends only on weir-core + crc32fast). Point SegmentReader at any segment
file and it streams each record, verifying its CRC32:
#![allow(unused)] fn main() { use weir_wab::SegmentReader; // The ACTIVE segment holds records buffered but not yet drained: for record in SegmentReader::open("/var/lib/weir/wab/shard_00/seg_00000001.wab")? { let payload = record?; // io::Error on a CRC mismatch / truncation // … inspect, re-emit, or recover `payload` … } }
Do not read a segment back to confirm a push "landed" — that races the drain. Segment files are transient: the moment a segment drains, the daemon writes a
seg_*.wab.confirmedsidecar and deletes the segment file. This is true for the activeseg_*.wabtoo, not just sealed segments — so a "push, then open the segment withSegmentReaderto verify" workflow can find zero records while everything is working perfectly: the records were accepted, drained, and the file deleted out from under you. The source of truth for "did it land" is metrics (weir_records_ack_totalfor acceptance), not the on-disk file. UseSegmentReaderto inspect or recover buffered data, not to prove a write succeeded.
Which file holds your data depends on its lifecycle:
seg_*.wab(active) — currently being written; un-drained records live here. Disappears the instant the segment drains (see the warning above).seg_*.wab.sealed(sealed) — full/idle, awaiting drain. Short-lived: once the sink commits it, the daemon writes aseg_*.wab.confirmedsidecar and deletes the sealed file. So pointing at a.sealedpath in a live, healthy deployment often finds nothing — and a reader that opens one and gets zero records is not necessarily an error, the segment may simply have drained. For live un-delivered data, read the active.wab.dead_letter/dl_*.wab.sealed— records the sink permanently rejected; this is the durable, non-transient place to find data (and whatweir-ctl dl requeuereads).
This is the parser the daemon and weir-ctl both use (one implementation, no
drift), so an offline inspector or a bespoke recovery tool reads exactly what the
daemon wrote. The WAB on-disk format documents the byte
layout. (weir-ctl dl requeue is built on exactly this — it reads dead-letter
segments with weir-wab and re-submits them through the daemon's socket.)
Beyond streaming records, weir-wab exposes a small read/inspect surface for
forensics and recovery tools: verify_sealed_segment walks a sealed segment
end-to-end and returns a SegmentVerification (header + footer) or a structured
SegmentVerifyError on the first fault; list_segment_files enumerates a
directory's segment files each tagged with its SegmentState (Active /
Sealed / Confirmed); and SegmentReader carries accessors —
header() for the parsed header, terminated_cleanly() to tell a clean
end-of-records sentinel from an unsealed active tail, and into_inner() /
get_ref() to take or borrow the underlying BufReader<File> (e.g. to seek past
damage and resume after an Err item).
Operate a running daemon
weir-ctl is a thin admin tool over the socket + the WAB reader; it does not
depend on weir-server, so installing it doesn't drag in the daemon's
dependencies. It covers health, push, metrics, segments (per-shard WAB
inspect), and dl (dead-letter list / drop / requeue). See
Monitoring and the dl requeue notes there.
Pass the global --json flag for machine-readable output: the read/inspect
commands (health, metrics, segments, dl list) emit a structured JSON
object instead of the human table, and the mutating commands (push, dl drop,
dl requeue) emit a small JSON result object — handy for scripting or piping
into jq. The human output stays the default.
The takeaway: depend on weir-server only when you actually run the daemon.
Everything else — producing, forwarding, reading the buffer, operating — is
available from the smaller crates on their own.