30 秒でわかる
Fly.io engineer Somtochi Onyekwere describes separating Corrosion’s Plumtree broadcast logic from sockets, timers and randomness. A caller-provided runtime enables deterministic simulation, while production integration remains a separate testing responsibility.
- 1Events in, actions outThe protocol consumes events and delegates message sends and timers to its caller.
- 2A local cluster simulationA time-ordered event heap drives roughly 2,000 node states in one process.
- 3Failures as eventsThe test can take nodes down, drop messages and later restore them.
- 4Integration still mattersA correct protocol can still fail if the surrounding runtime handles its effects incorrectly.
ポイント
- Distributed protocols face nondeterministic message delivery, timing and node failures.
- A protocol state machine can accept events and request effects without owning sockets or a clock.
- A heap of events and virtual time can simulate many nodes locally.
- Injected randomness and failures make scenarios repeatable.
- Testing the protocol core does not replace testing the production integration.
チャプターごとに整えた全文を読む
Why distributed protocols are hard to test
Networks, timers and node failures create nondeterminism, while large integration environments are expensive.
I work on distributed systems and networking software at Fly.io. This talk is about structuring distributed protocols so that we can test them deterministically and gain confidence that they work before deployment.
Distributed systems are difficult to test because many nodes coordinate over a network. Messages can be delayed, reordered or lost; timers can fire, and nodes can be unavailable. A protocol must preserve its assumptions even when these failures occur.
A large integration test is expensive to run and difficult to reproduce. A cluster with thousands of nodes involves many processes, and a subsequent run may encounter events in a different order. Both scale and nondeterminism make failures hard to investigate.
Separate the protocol from effects
The protocol accepts events and requests actions; a caller supplies the runtime.
While changing the broadcast protocol in Corrosion, an open-source SQLite replication system used at Fly.io, we noticed that the protocol described in a paper was relatively simple. We wanted to preserve that small core while making it easy to vary conditions, reproduce failures and reuse the implementation.
Protocol code often mixes its logic with thread creation, message sends and timers. If the implementation sleeps internally, a test may need to sleep too, introducing flakiness. We wanted to move those effects out of the protocol and leave a clear boundary where tests could substitute their own environment.

Our model treats the protocol as a state machine. It receives events, such as a message, a peer coming online or a fired timer, and indicates what actions should follow. It does not itself own sockets or a clock; the caller decides how to send messages and schedule timers.
The example is Plumtree, a gossip protocol for broadcasting through a cluster. A node sends full messages to eager peers, which forward them onward. Lazy peers receive message identifiers and can request a missing payload, providing a fallback path.
In Corrosion, an input such as a broadcast request goes to the Plumtree state. The protocol can ask the caller to send messages or deliver a payload to the application. The caller also supplies time, so effects are outside the core logic.
One possible interface would return a list of requested actions. We instead used a trait, or interface, implemented by the caller. It exposes effects such as sending to a node or peers, delivering a payload, notifying the application and scheduling a timer.

When the protocol requests a timer, the caller handles scheduling. Once it fires, the caller passes a timer-fired event back to the protocol. The same separation applies to messages: the application provides the runtime and can choose how the effects are performed.
For example, Corrosion buffers messages briefly to batch them efficiently. Another user of the library can choose a different implementation. The integration layer is small: provide the interface and the protocol uses that environment.
Simulating a cluster
A local event queue and virtual time run many node states without real sockets or sleeps.
This boundary is especially useful in tests because the test runtime can differ from production. Production can use threads and sockets; a local simulation instead records and queues events. No real network or thread per simulated node is required.
Our test used a binary heap ordered by event time. A loop removes the earliest event, applies it to the appropriate protocol state, and queues the messages, timers or deliveries produced in response. A map holds the state for each simulated node, allowing roughly 2,000 nodes to run on one system.

At the start, the test chooses nodes to broadcast at different times and queues those events. Processing one broadcast can generate more sends, timer requests and application deliveries. The loop keeps applying resulting events until the queue is exhausted.
Time is virtual. Instead of waiting for a real timer, the simulator jumps to the time of the next event and tells the protocol that the timer has fired. This makes a long sequence of scheduled behavior fast to execute and repeat.
Failures and trade-offs
The simulation can inject failures and measure choices, but integration still requires testing.

Failures are events too. The test can take a node down, drop messages addressed to it, notify other nodes and bring it back later. That lets us examine how the protocol responds to conditions we explicitly choose.
We also changed Plumtree to fit Corrosion, so we needed to test the consequences. The paper’s membership approach differed from ours, where nodes knew the full membership. We could vary peer selection, including whether to prefer nearby nodes, and measure how that affected broadcast delivery time.
The resulting deterministic test let us exercise and replay protocol behavior before launch. It was not a multiverse debugger, but it gave us a useful local simulation and a systematic way to mock external effects.
There is an important limitation: testing the protocol core does not prove the integration is correct. If a caller blocks while sending, it might fail to pass incoming messages to the protocol promptly. The surrounding integration still needs its own tests.
This approach is often called Sans I/O. It does not eliminate complexity; it makes the boundary and responsibility explicit. Depending on the protocol, separating requested actions from effects can make the library easier to reason about and reuse.
We also inject randomness from the application. Production uses operating-system randomness, while a seeded source makes tests reproducible. Callers must supply the environment and understand how to pass events, so the design adds integration work, but for our case the testing benefits are worth it.
同じチャンネルの記事
40:04EN
51:06EN
39:12EN
22:33EN
34:11EN
28:15EN