Wire Order via Named Stages
SBE is positional: groups and var-data appear in a fixed schema order with no per-field tags. Two nearly identical groups back-to-back — bids then asks — are common. Swap them and the bytes still look valid; prices land on the wrong side of the book. That mistake is a type error here, not a production incident.
sbe-tool uses .parent() to hand encoder ownership back up the tree. In Rust
that fights the borrow checker. ergo-sbe uses scoped closures so nested
schemas stay readable:
#![allow(unused)]
fn main() {
pub fn encode_sample_car(buf: &mut [u8]) -> Result<usize, sbe_rt::EncodeError> {
let mut extras = OptionalExtras::default();
extras.cruise_control(true).sports_pack(true);
// Buffer pre-sized from EncodedLength; try_* still validates extent.
let len = CarEncoder::try_wrap_and_apply_header(buf, 0)
.unwrap()
.fixed(&CarFixedFields {
serial_number: 1234,
model_year: 2013,
available: true.into(),
code: Model::A,
some_numbers: [10, 20, 30, 40],
vehicle_code: [b'A', b'B', b'C', b'D', b'E', b'F'],
extras,
engine: Engine::new(
2000,
4,
[b'1', b'2', b'3'],
0i8,
false.into(),
Booster::new(BoostType::TURBO, 210),
),
})
.fuel_figures(2, |g| {
g.add(|mut e| {
e.speed(30).mpg(35.9);
e.usage_description_as_str("Urban")
})?;
g.add(|mut e| {
e.speed(60).mpg(25.0);
e.usage_description_as_str("Highway")
})?;
Ok(())
})?
.performance_figures(1, |g| {
g.add(|mut e| {
e.octane_rating(95);
e.acceleration(2, |a| {
a.add(|x| {
x.mph(30).seconds(4.0);
Ok(())
})?;
a.add(|x| {
x.mph(60).seconds(7.5);
Ok(())
})
})
})?;
Ok(())
})?
.manufacturer_as_str("Honda")?
.model_as_str("Civic VTi")?
.activation_code_as_str("abcdef")?
.encoded_length_with_header();
Ok(len)
}
}
(From sbe-feature-tour — compiles and runs in CI.)
Order is the type-state pattern, implemented as named concrete stage
structs (CarAfterFuelFigures), not Encoder<State>. The H: HeaderState
generic on every stage is a zero-sized marker for header-present vs body-only
— it does not track wire-order. Duplicating the stage graph for
HeaderPresent / HeaderAbsent would provide no latency advantage.
pub struct BookEncoder<'a> { /* buf, pos, … */ }
pub struct BookAfterBids<'a> { /* same layout */ }
pub struct BookAfterAsks<'a> { /* same layout */ }
impl BookEncoder<'a> {
pub fn bids(self, …) -> Result<BookAfterBids<'a>, …> { … }
// no asks() — bids first on the wire
}
impl BookAfterBids<'a> {
pub fn asks(self, …) -> Result<BookAfterAsks<'a>, …> { … }
}
After fixed fields you may only call the next group or var-data.
BookEncoder has no asks method. Decoders use the same idea
(BookDecoder → BookDecoderAfterBids → …).
Group bodies use |g| { g.add(|e| { … }) }: the closure ends, then chaining
continues. See Type-state and
Benchmarks for the cost claim and the 1.00× gate.