Lightspeed causal gossip for high-bandwidth networks.
rumors replicates a set of messages across a fleet of peers with no
coordination: every peer holds a full replica, changes it locally (inserting
or removing messages), and reconciles pairwise with whichever peer(s) it
can reach. Replicas which transitively gossip eventually converge on the
same set of messages; rumors works hard to turn "eventually" into "ASAP".
Unlike many gossip protocols, rumors features redaction. When any peer
redacts a message, it is contagiously purged from every peer's memory,
allowing superseded messages to be garbage-collected without global
coordination. Redaction is effectively free along every axis: conveying an
arbitrary quantity of redactions costs little additional communication, and
a redacted message leaves zero residual local bookkeeping. Memory usage
therefore scales up and down with the live set of messages, and bandwidth
up and down with the quantity of previously-unknown messages.
If bandwidth is abundant and latency matters.
Most gossip protocols are designed to be thrifty with bandwidth, trading
increased rounds of communication for smaller metadata overhead. However,
bandwidth is only getting cheaper and more plentiful, whereas latency is
capped by the laws of physics. rumors is designed for today and tomorrow;
it optimizes for extremely fast convergence when bandwidth is not a primary
constraint.
rumors could be a particularly excellent fit if:
- peers produce in total less than 10,000 messages/second, and
- each peer-to-peer link offers 1 Gb/s or better.
In this regime, every change propagates at the pace of a few network round
trips per gossip hop, for any message set size that fits in memory. Required
bandwidth scales linearly down with message rate (for example, 100
messages/s at 10 Mb/s), and total set size increases cost only by a (very
slow-growing) logarithmic factor. These figures price rumors' own metadata
overhead; message bodies ride on top at their raw byte rate (at 10,000
messages/s, about 80 Mb/s per KB of mean body size). That term is a rounding
error for sub-KB bodies, and overtakes the metadata around 10 KB; past that,
you are paying to move your data, not to coordinate it, a cost no
replication scheme escapes.
At the limits: Up to roughly an order of magnitude past these bounds (a thinner link, or a faster message rate), peers degrade gracefully rather than failing outright: they may still converge, but may run stale in proportion to roughly the square of the bandwidth shortfall (derived: a session's metadata amortizes, falling roughly as the inverse square root of the backlog at realistic scales, so equilibrium repays a bandwidth deficit with its square in staleness). Past that, they will likely fall behind regardless of gossip frequency. In the other direction, past ~10 Gb/s the network ceases to be the limit at all: CPU caps message rate, and RAM caps set size.
- If the set of live messages outgrows its smallest peer. Every peer replicates the whole set; sharding is not supported.
- If you need a consistently ordered, durable history. A replicated log
gives you sequencing;
rumorsonly gives you causal ordering, which may be linearized differently between peers. - If you don't control the peers. Peers trust one another: the protocol
rejects malformed and mismatched sessions, but it is not Byzantine-tolerant.
An authorized member already has permission to write and redact any
message. Rumors relies on those members following the protocol.
Authenticating peers and securing the transport are the application's job;
the
linkmodule lists exactly what the protocol asks of the transport. - If bandwidth is your scarce resource.
rumorsbuys low latency with bandwidth: when reconciling small divergences, payloads under ~10 KB use more bandwidth for metadata than for messages. Reconciling larger divergences amortizes much of this cost, but on metered, narrow, or high-loss links, this crate strikes the wrong balance.
Peer::seed creates a new gossip network. Other peers join through
Peer::bootstrap, synchronizing with any established member. Peers
created by independent calls to seed belong to separate networks and
cannot gossip with each other.
Peer::retire leaves the network after a final synchronization with
another member. Retiring when possible and reusing a Bookmark across
restarts help keep message versions compact as peers come and go.
Peer manages a replica's lifecycle: creating a network, joining one,
attaching a bookmark, and retiring. It cannot be cloned.
Peer::into_rumors returns a Rumors handle for everyday use. Clone
that handle to send, redact, observe
messages, and gossip
concurrently. All handles share the same replica.
When the other handles have been dropped, Rumors::try_into_peer
recovers the Peer. This ensures retirement cannot happen while another
handle still uses the replica.
The Peer docs walk the full lifecycle as one runnable example,
including every retirement outcome and bootstrapping a universe without
a distinguished first peer. For a guided first encounter (two peers from
an empty project through send, gossip, and redaction, with the output each
step prints), start at tutorial. For how a session actually reconciles
two replicas (the descent, the disjoint frontier, why deletion needs no
tombstones, and how the design compares to its neighbors), read
reconciliation.
Two peers, one universe, one message, one gossip session. Shown whole,
nothing hidden, as it would sit in a main.rs (the async runtime here is
Tokio for convenience; see Runtime independence):
use rumors::Peer;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// The universe's first peer creates it; every later peer bootstraps in.
let alice = Peer::<String>::seed().into_rumors();
// A send commits right here.
alice.send("the meeting is at noon".to_string())?;
// A session runs over a `Link`: a control byte stream plus a supply
// of independent data streams (see the `link` module); here, the
// in-memory pair. Alice serves one gossip session...
let (mut near, mut far) = rumors::link::memory();
let serve = alice.clone();
tokio::spawn(async move {
serve.gossip(&mut far).await.unwrap();
});
// ...and Bob joins the universe through it, arriving as a full replica.
let rumors::Joined::Joined { peer: bob } =
Peer::<String>::bootstrap().join(&mut near).await
else {
panic!("Alice must serve the bootstrap");
};
let bob = bob.into_rumors();
// Convergence: Bob holds the message Alice sent before they ever met.
let snapshot = bob.snapshot();
let (_version, message) = snapshot.iter().next().expect("one live message");
println!("bob heard: {message}");
// Prints exactly:
// bob heard: the meeting is at noon
assert_eq!(message.as_str(), "the meeting is at noon");
Ok(())
}Snapshot(Rumors::snapshot) is a point-in-time value: iterate it, look up a message by itsVersion(Snapshot::get), or slice it by causal range (Snapshot::range). Taking one is cheap and never waits.UnorderedMessages(Rumors::unordered_messages) is the live stream, arbitrary order: everything not already inside your starting checkpoint, then everything learned afterwards, at the lowest cost. Use it by default.CausalMessages(Rumors::causal_messages) is the live stream, causal order: a message arrives only after everything it causally depends on, for an amortized logarithmic surcharge with bursts up to the size of the set. Use it only when consumers require causal delivery.Changes(Rumors::changes) is the live signal, no content: one coalesced()per observed advance of the set, for waking work that reacts to change without consuming it: gossip drivers, persist-on-change, UI refresh. It is not delivery; pair it with a checkpoint-bearing observer for that.
The live message observers expose a checkpoint:
the sound resume point for delivery across restarts. Its docs state exactly
what a resume re-observes, and why folding the yielded versions yourself is
not a substitute.
Deletions are never delivered as events. A redacted message simply
stops being live, and no redaction object exists anywhere for an observer
to yield (Rumors::redact explains why none is needed); an application
that needs deletion events sends them as ordinary messages of its own.
All of the above observe the set. To watch the wire instead — every
protocol message of a live session, as raw CBOR items, for debuggers,
recorders, and tracing adapters — attach a handler from the observe
module (Peer::observe).
A session's transport is a Link: one persistent bidirectional
control stream plus a supply of independent, individually
flow-controlled unidirectional data streams, opened lazily as
reconciliation needs them. The link module states what an
implementation must guarantee, ships the in-memory instantiation
(link::memory), and documents how to bind a real transport (QUIC
connections map streams one to one; TCP can carry one stream per
connection behind a routing listener). A conformance suite (the
conformance::link module, unlocked by the conformance cargo feature)
checks those guarantees on a caller-built link. Link's docs state
what a session promises on Ok, Err,
and cancellation.
Sessions and observers are plain futures and streams, driven entirely by
the caller. The I/O traits are Tokio's runtime-independent
AsyncRead
and AsyncWrite;
no Tokio runtime, spawning, sockets, or timers are required by this crate.
Your message type T needs serde::Serialize,
serde::de::DeserializeOwned, Eq, Send, Sync, and
'static, all demanded once, at peer construction. Payloads are
serialized as CBOR (RFC 8949).
Each bound guards replication:
Serializemust succeed on every value you send. CBOR itself imposes no format-driven failures, so aSerializeerror is a bug in the payload type: sending panics. Avoid types whoseSerializeis data-dependently fallible (for examplestd::path::PathBuf, which errors on non-UTF-8 paths).- Every encoding must decode back equal to the value sent. Each
send re-decodes its own encoding with the exact decoder receivers
run and compares by
Eq; a lossy encoding (for exampleSome(None)in a nestedOption, which decodes asNone) is the typedEncodeError, rejected at the author rather than silently diverging at every replica. The bound isEqrather thanPartialEqso the check is never spurious; this excludesf32/f64fields (NaN compares unequal to itself). - Nesting depth is bounded. Decoding a payload may recurse at
most
Peer::payload_depth_limitsteps (256 by default, ample for ordinary types); an over-deep value is rejected at send. The limit is held to exact equality fleet-wide at every handshake, so an admitted payload is transferable everywhere; the knob's docs carry the full contract.
On compatibility across versions of your own type: because CBOR
carries field and variant names, reordering struct fields or
enum variants does not break compatibility with prior versions of
your type T; however, renaming breaks compatibility. It is worth
designing around this from the get-go: consider an outer enum
indicating the version of your application-level message type, even
if it starts out only having one variant, V1.
Every feature is off by default.
conformance: the public validation suite for caller-builtlinkinstantiations (theconformance::linkmodule). Enable it from a dev-dependency; it is safe, though pointless, in an application.test-internals: this crate's own test scaffolding, enabled through its self-referential dev-dependency. Never enable it in an application.
The wire format is steady by design: each Protocol is pinned
byte-for-byte by snapshot tests, and once a version has shipped, a wire
change introduces a new protocol version.
The crate is validated by property tests stating the model's invariants (convergence under arbitrary gossip schedules, deletion honoring, observer soundness) and by the wire-format snapshots. Found a gap? An issue or a test is very welcome.