A data race that doesn't compile | Corentin Corgié
How I taught Rust’s type system to refuse my own parallel-Redux data races, with one false start and one mind-shift.
There’s a class of bug I’ve spent more nights chasing than I care to remember. The kind that only happens under load, vanishes when you attach a debugger, and takes three engineers a weekend to corner. Data races.
Rust’s borrow checker prevents most of them at the value level. But not all of them, and definitely not the question that interested me here: can the compiler refuse to build a parallel reducer pipeline where two reducers might write to the same piece of state?
Turns out yes. This post is the story of how I got there in ruxe, my Redux-flavored Rust learning library.
What is Redux?#
Redux is a state management pattern. It got famous in frontend JavaScript but the shape is more general; any system where state changes through discrete events fits the model.
flowchart LR<br>User([User code]) -->|dispatch event| Store<br>Store -->|state + event| Reducer<br>Reducer -->|new state| Store<br>Store -->|read| User
Three rules make Redux what it is.
One: a single source of truth. The state is owned by the store, nothing else.
Two: the state is immutable from outside. You don’t poke it directly. You dispatch events (the JS world calls them “actions”) that describe what happened.
Three: state transitions go through a pure reducer, a function (state, event) → new state. Same input, same output, no side effects.
That third rule is what makes Redux famously debuggable. Record the event stream, replay it, and you get the same final state every time. Time-travel debugging. Crash diagnosis from production traces. Reproducible bugs. Anyone who has ever tried to debug a stateful UI without that property knows why people keep reinventing Redux.
Why I cared#
At my day job I work on energy-management systems running on industrial sites. One brick of the stack uses a Redux-like pattern in Python: the control layer, where several controllers run concurrently and need a shared, consistent view of the plant state. Events flow in, a reducer pipeline computes the new state, controllers read from it.
The numbers add up faster than you’d think. A typical site has dozens of pieces of equipment, each polled on its own period, and some periods go down to 50ms. Each device can publish several dozen registers per read. That’s a continuous, high-volume event stream, and a pure Python Redux struggles to keep up.
We worked around it. We cache reads and trigger the reduction at a coarser frequency than the underlying telemetry. It works, but the pattern gets less pure: the state is no longer up-to-date with the latest telemetry, and we trade away part of what made Redux attractive in the first place.
Profiling told us where the time was actually spent: the reducer phase. The plant has many independent subsystems. Solar, battery, meter, grid controller, and so on. Each subsystem holds a slice of the global state, and each has its own reducer that only touches its slice.
flowchart LR<br>Event[Event] --> R1[Solar reducer] --> S1[Solar slice]<br>Event --> R2[Battery reducer] --> S2[Battery slice]<br>Event --> R3[Meter reducer] --> S3[Meter slice]
Here’s the observation that started everything: the reducers are independent. Solar’s reducer never touches the battery slice. Battery’s reducer never touches the meter slice. Each event is a single pass through all of them.
Run them sequentially and you get N times the latency. Run them in parallel and you get max(latency_i).
So: parallelize the reducers. Same event flow from the outside, same deterministic results, just N times less wall-clock time per dispatch.
There’s one catch.
Parallel plus shared state equals data races. If a reducer accidentally writes to another slice, you have a classic concurrency bug. Non-deterministic outputs, heisenbugs in production, debug sessions you’ll remember on your deathbed.
In most languages, that’s where the type system gives up. C++ hands you mutexes and atomics and wishes you luck. Higher-level languages give you synchronisation primitives and a memory model, but the burden of avoiding races stays on the developer; the compiler doesn’t enforce anything, the team’s discipline does.
Rust’s type system can encode the property directly. The compiler itself can refuse to build code that would race. That’s what I set out to prove in ruxe.
The mental model: slices and disjointness#
Two concepts power everything that follows. Worth defining clearly before we go further.
A slice is a sub-field of the state. If your state holds a counter, a user, and a notifications field, those are three slices.
A slice reducer is a reducer that only sees its slice. It cannot touch other slices. The type system forbids it: the function signature exposes only &Slice, nothing else.
flowchart...