Skip to content

Replicate rings to other hosts over the network - #3

Merged
alex09x merged 14 commits into
mainfrom
feat/replication
Sep 26, 2026
Merged

alex09x merged 14 commits into
mainfrom
feat/replication

Conversation

@alex09x

@alex09x alex09x commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

What

ringfire::replication: one source ring, byte-identical copies on other hosts.

  • ReplicaServer (source host) streams a ring's slot payloads to any number of mirrors, one thread and one ring reader per mirror.
  • Mirror (each other host) keeps a ring with the same geometry and the same sequence numbers in the local /dev/shm. Readers attach to it with RingConsumer exactly as on the source host and never touch the network.
  • Own binary protocol, 16-byte frame header: HELLO, GEOMETRY, DATA (up to 65,535 records per frame), GAP, HEARTBEAT, NAK, MULTICAST. Resume from the last local sequence after a reconnect (the source ring is the retransmission buffer), restart detection, busy-poll mode.
  • Two transports. TCP with TCP_NODELAY, one stream per mirror. Or UDP multicast: each DATA frame goes out once as a datagram and every mirror receives it; the TCP connection then only carries the handshake and NAK retransmission from the source ring. Mirrors hold back datagrams that overtake a hole, so rings are still written strictly in order; a 1 ms multicast heartbeat exposes a lost last datagram; a session byte drops datagrams from an earlier source incarnation.
  • CLI: ringfire serve <ring> --bind <addr> [--multicast GROUP:PORT --iface ADDR --mtu N --ttl N] [--spin] and ringfire mirror <source> <ring> [--from latest|oldest|resume|N] [--iface ADDR] [--spin].
  • FLAG_SPARSE (0x0400) marks rings whose sequence numbers may have holes; RingConsumer then skips to the next message present instead of waiting on a hole. Checked every 64 empty polls and only on sparse rings, so existing rings are unchanged.
  • New errors Unsupported and Protocol; rings with a payload arena are rejected for now.

Measured

64-byte slots, one message every 100 µs, everything busy-polling, Linux 6.8, kernel network stack:

Path Transport p50 p99 max
Loopback one way, source ring → mirror ring (Ryzen 9 7950X) TCP 6.0 µs 6.8 µs 19.7 µs
Loopback one way, source ring → mirror ring (Ryzen 9 7950X) multicast 3.3 µs 8.2 µs 66.8 µs
Two Ryzen 9 7950X hosts, 25 GbE LAN, round trip (2 network hops + 4 ring hand-offs) TCP 54.0 µs 59.1 µs 1.3 ms
Two Ryzen 9 7950X hosts, 25 GbE LAN, round trip (2 network hops + 4 ring hand-offs) multicast 53.1 µs 59.9 µs 86.9 µs

About 27 µs one way on the LAN either way: that is the two kernel network stacks. Multicast buys the tail and a cost that stays flat as mirrors are added.

Tests

tests/replication_tests.rs (TCP): exact byte-for-byte copy, oldest/latest start, hole skipping by readers, resume without duplicates, restart of the source, constant lapping without torn records. tests/replication_multicast_tests.rs: live order, history fetched over TCP then live by multicast, every third datagram dropped (fault injection) and recovered in order, tail loss recovered by heartbeat. Full suite and clippy -D warnings pass on Linux and macOS.

Next

Kernel bypass for the multicast path (AF_XDP / DPDK / Onload), blob (arena) rings, FLAG_SPARSE in the C header and Python readers.

Add ringfire::replication: ReplicaServer streams a ring's slot payloads to any
number of mirrors over TCP; Mirror keeps a ring with the same geometry and the
same sequence numbers in the local /dev/shm, so readers attach to it exactly as
on the source host. Own binary protocol (16-byte frame header: HELLO, GEOMETRY,
DATA, GAP, HEARTBEAT), batching, resume from the last local sequence, restart
detection, busy-poll mode. CLI: ringfire serve / ringfire mirror.

Add FLAG_SPARSE for rings whose sequence numbers may have holes; RingConsumer
skips to the next message present instead of waiting on a hole (only on sparse
rings, checked every 64 empty polls). Add Unsupported and Protocol errors,
integration tests, a loopback latency example and a two-host ping-pong example.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-26T03:21:53.067417Z cdd4d22 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cdd4d22b55

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/replication.rs Outdated
Comment on lines +513 to +519
let (stream, peer) = self.listener.accept()?;
let ring = SourceRing::open(&self.ring_path)?;
let (batch, spin) = (self.batch, self.spin);
thread::Builder::new()
.name(format!("ringfire-serve-{}", peer))
.spawn(move || {
let _ = serve_client(ring, stream, batch, spin);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect replacement of the source ring inode

When the producer restarts and recreates the ring at the same path, create_backing_file replaces the inode, but each accepted client retains this one SourceRing mapping indefinitely. serve_client therefore keeps sending heartbeats from the orphaned mapping and never observes records written to the new ring, so already-connected mirrors silently freeze and never reconnect. Track path/inode replacement or terminate and reopen the client session when the source ring changes.

Useful? React with 👍 / 👎.

Comment thread src/replication.rs
Comment on lines +572 to +573
wanted if wanted > write_seq + 1 => (write_seq + 1, GEOMETRY_RESET),
wanted => (wanted.max(oldest), 0),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a generation identifier to detect source restarts

A resumed mirror is marked as reset only when its requested sequence is ahead of the source. If a source restarts and publishes at least as far as the mirror's requested sequence before reconnection—for example, the mirror has 1–50 and the restarted source reaches 100—this branch treats the new epoch as a continuation and appends its 51–100 records to the old epoch. That silently combines unrelated streams despite the documented restart detection; the handshake needs a persistent source generation/instance identifier rather than inferring restarts solely from sequence ordering.

Useful? React with 👍 / 👎.

Comment thread src/replication.rs
Comment on lines +762 to +764
let (ring, resumed) = match adopted {
Some(ring) if !reset && ring.geometry.same_layout(&geometry) => (ring, true),
other => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resume only rings that have mirror semantics

With MirrorStart::Resume, any unlocked, structurally valid ordinary ring whose capacity/schema/offsets match the source passes this guard, because same_layout ignores its flags. The mirror then writes into that existing inode without changing its header to FLAG_SPARSE | FLAG_POLICY_LATEST_WINS; after a clamped resume or later gap, consumers of that ring can wait forever for missing sequences because they do not know the ring is sparse. Require the adopted header to have the expected mirror flags, otherwise recreate it.

Useful? React with 👍 / 👎.

ReplicaServer::multicast sends each DATA frame once as a datagram to a group;
the per-mirror TCP connection carries the handshake (new MULTICAST frame) and
answers NAK requests from the source ring, with GAP for unretained ranges.
Mirrors hold back datagrams that overtake a hole so rings are still written in
order, repeat an unanswered NAK after 20 ms, notice a lost last datagram through
a 1 ms multicast heartbeat and drop datagrams from an earlier source incarnation
via a session byte. CLI: serve --multicast GROUP:PORT --iface ADDR --mtu --ttl,
mirror --iface ADDR. Tests cover live order, history over TCP, injected datagram
loss and tail loss; examples take --multicast for loopback and two-host runs.
… sent sequences

Frame linger (adaptive by default, --linger-us to fix it) fills a frame before
sending it: without batching every record above ~20k/s cost a datagram and a
system call and round-trip latency went from 50 us to over a millisecond.
Mirror::step now processes at most 32 datagrams per call so a single-threaded
caller keeps control. Multicast heartbeats carry the sender's last sent
sequence instead of the ring's write_seq, which could announce a record before
its datagram and make every mirror NAK it. New tests: a lapped source in
multicast mode stays in order, datagrams from another session are ignored.
New open-loop two-host stress example; README gains the sustained-rate table.
Fault injection sends every n-th datagram after its successor; the mirror must
hold the overtaking one back and write the ring strictly by sequence.
…ze ones

GEOMETRY carries the payload arena; DATA records carry the slot payload
followed by the blob bytes its descriptor names. The source copies a blob
and re-checks the arena's reservation counter, so a payload overwritten
under it is reported as lost and becomes a GAP. The mirror writes blobs into
its own arena and rewrites the descriptor's location, keeping length, flags
and sequence. Blobs beyond the MTU ride IP fragmentation; beyond the host's
datagram limit (EMSGSIZE) the sender skips them and mirrors fetch them over
TCP. BlobConsumer learns FLAG_SPARSE hole skipping like RingConsumer.
Tests: TCP mirroring, multicast with payloads up to 70 KB, arena lapping
without corruption, hole skipping after a late join.
Adaptive lingering waited up to 50 us whenever the previous frame was recent,
which delayed steady streams near 20k/s and every record after the first of a
burst. Frames now leave at most once per 50 us unless full, and a lone record
that arrives later than that goes out immediately: quiet and bursty streams
keep the 55 us floor, loaded streams keep the datagram rate near 20k/s.
The stress example gains --burst; the arena-lapping blob test floods the arena
before the mirror connects so the lost-payload path is exercised every run.
The master stamps each record on push; consumers on the master, on same-host
mirrors and on each remote mirror stamp the read. Remote slaves estimate the
clock offset PTP-style (minimum-round-trip probe) and report in the master's
clock. Six remote and two local slaves measured at 1k and 20k msg/s over
multicast and TCP; README gains the stage table.
For routes without multicast (between sites, into clouds). A mirror announces
in HELLO that it can receive UDP unicast; the source answers with its UDP port
and a token in the MULTICAST frame (group 0.0.0.0) and the mirror punches to
that port, so the source sends to the punched address and delivery works from
behind NAT. --dup N sends every datagram N times; mirrors drop the copies by
sequence. Tests cover in-order delivery with duplicates and a mirror served
again as a source (a site hub). Measured Tokyo -> Los Angeles at 1000 msg/s:
TCP p99 one-way 99.6 ms against a 50.4 ms median, UDP unicast p99 51.7 ms
against 51.7 ms, no NAK needed; the stages example gains echo round trips
measured on the master's clock and a time-bounded clock calibration.
Linux answers a close with unread receive data by a reset, so a mirror that
had just sent a NAK saw ConnectionReset instead of end-of-file and reported a
failure when the source shut down. Resets, aborts and broken pipes now end a
mirror cleanly like EOF. The server logs a mirror it drops for any other
reason. Multicast tests probe for multicast delivery first and skip on hosts
without it (GitHub's macOS runners).
…nts; release 0.5.0

README gains a mirrors section with LAN, WAN and site-hub topologies, a
transport matrix, CLI and Rust recipes, consolidated numbers and a tuning
checklist; docs/replication.md tells the design, the protocol, what the stress
test found and every measurement. Version 0.5.0, changelog dated.
@alex09x
alex09x merged commit a9f7abc into main Sep 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant