Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.9.1] - 2026-07-14

### Fixed

- **`MatchResult` round-trips non-self-describing serde formats again
(#135).** 0.9.0's decode-time validation (#117) deserializes through a wire
struct whose `outcome` is `Option<MatchOutcome>`, but `Serialize` still
emitted a bare `MatchOutcome`. JSON tolerated the asymmetry; a positional
decoder (bincode) read the enum variant index where the option tag was
expected and failed with `UnexpectedVariant` on every 0.9.0 payload.
`outcome` is now serialized as `Some(outcome)`: the JSON payload is
byte-identical (serde flattens `Some`), and bincode encode/decode are
symmetric. Bincode payloads written by 0.9.0 do not decode (they never
did — 0.9.0 could not decode its own output); JSON payloads from any
version are unaffected. Pinned by a bincode leg on the round-trip
property test plus deterministic shape guards (`bincode` added as a
dev-dependency only).

## [0.9.0] - 2026-07-14

Major hardening release: ten engine-correctness issues (#111–#120, PRs
Expand Down
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pricelevel"
version = "0.9.0"
version = "0.9.1"
edition = "2024"
authors = ["Joaquin Bejar <jb@taunais.com>"]
description = "A high-performance, lock-free price level implementation for limit order books in Rust. This library provides the building blocks for creating efficient trading systems with support for multiple order types and concurrent access patterns."
Expand Down Expand Up @@ -54,6 +54,10 @@ criterion = { version = "0.8", default-features = false, features = ["html_repor
# `rusty-fork` / `wait-timeout` deps) we do not use; `std` keeps the core
# strategy API.
proptest = { version = "1", default-features = false, features = ["std", "bit-set"] }
# bincode is dev-only: it pins the non-self-describing serde round-trip of
# `MatchResult` (issue #135) — the shape downstream consumers (OrderBook-rs's
# NATS bincode path) encode with. It never ships in the library surface.
bincode = { version = "2.0", default-features = false, features = ["serde", "std"] }
# loom is a dev-only model checker, gated behind `--cfg loom`. It is NOT a
# runtime dependency. It models the cancel-vs-partial-fill linearization (issue
# #81); see `tests/loom/cancel_match.rs`. loom cannot instrument dashmap /
Expand Down
18 changes: 18 additions & 0 deletions src/execution/match_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,27 @@ pub struct MatchResult {
filled_order_ids: Vec<Id>,

/// Terminal classification of the match (filled / killed / rejected / ...).
///
/// Serialized as `Some(outcome)` so the emitted shape is symmetric with
/// [`MatchResultWire`]'s `Option<MatchOutcome>`. JSON is unaffected —
/// serde flattens `Some` away, so the payload still carries the bare
/// value — but a non-self-describing format (bincode) decodes
/// positionally and must find the option tag the wire struct reads
/// back (#135).
#[serde(serialize_with = "serialize_outcome_as_some")]
outcome: MatchOutcome,
}

/// Serializes `outcome` wrapped in `Some` — see the field doc on
/// [`MatchResult::outcome`] (#135). Kept as a free function because serde's
/// `serialize_with` requires this exact signature.
fn serialize_outcome_as_some<S>(outcome: &MatchOutcome, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_some(outcome)
}

/// Wire form of [`MatchResult`] used for validated deserialization.
///
/// It mirrors `MatchResult`'s serialized shape field-for-field (identical
Expand Down
80 changes: 76 additions & 4 deletions src/execution/tests/match_result_trade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,20 +768,92 @@ mod tests {
result
}

/// #135 regression: the `Serialize` shape must be symmetric with the
/// validated wire struct. Before the fix the bare `outcome` variant index
/// landed where the wire's `Option` tag was expected and bincode decode
/// failed with `UnexpectedVariant` on every payload 0.9.0 produced.
#[test]
fn bincode_round_trip_is_symmetric_issue_135() {
// Empty result (NotFilled) — the minimal shape that 0.9.0 failed on.
let empty = MatchResult::new(Id::from_u64(10), Quantity::new(100));
let bytes = match bincode::serde::encode_to_vec(&empty, bincode::config::standard()) {
Ok(bytes) => bytes,
Err(error) => panic!("bincode encode failed: {error}"),
};
let decoded: MatchResult =
match bincode::serde::decode_from_slice(&bytes, bincode::config::standard()) {
Ok((value, _)) => value,
Err(error) => panic!("bincode decode failed: {error}"),
};
assert_eq!(decoded.outcome(), MatchOutcome::NotFilled);
assert_eq!(decoded.remaining_quantity().as_u64(), 100);

// Partially filled result with a trade — outcome variant index 1.
let mut partial = MatchResult::new(Id::from_u64(10), Quantity::new(100));
assert!(partial.add_trade(sample_trade(25)).is_ok());
let bytes = match bincode::serde::encode_to_vec(&partial, bincode::config::standard()) {
Ok(bytes) => bytes,
Err(error) => panic!("bincode encode failed: {error}"),
};
let decoded: MatchResult =
match bincode::serde::decode_from_slice(&bytes, bincode::config::standard()) {
Ok((value, _)) => value,
Err(error) => panic!("bincode decode failed: {error}"),
};
assert_eq!(decoded.outcome(), MatchOutcome::PartiallyFilled);
assert_eq!(decoded.remaining_quantity().as_u64(), 75);
assert_eq!(decoded.trades().len(), 1);
}

/// #135 guard: wrapping `outcome` in `Some` on the serialize side must
/// not change the JSON payload — serde flattens the option away, so the
/// key still carries the bare snake_case value.
#[test]
fn json_payload_keeps_bare_outcome_issue_135() {
let result = MatchResult::new(Id::from_u64(10), Quantity::new(100));
let json = match serde_json::to_string(&result) {
Ok(json) => json,
Err(error) => panic!("json encode failed: {error}"),
};
assert!(
json.contains("\"outcome\":\"not_filled\""),
"outcome must stay a bare value in JSON, got: {json}"
);
}

proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]

/// Any valid result built through the public API round-trips both serde
/// JSON (outcome preserved) and Display/FromStr (benign outcome
/// re-derived identically) with no field drift — the validator never
/// rejects a legitimately-constructed value.
/// Any valid result built through the public API round-trips serde
/// JSON (outcome preserved), bincode (positional, non-self-describing
/// — pins the encode/decode shape symmetry of #135), and
/// Display/FromStr (benign outcome re-derived identically) with no
/// field drift — the validator never rejects a
/// legitimately-constructed value.
#[test]
fn valid_result_round_trips_both_encodings(
initial in 1u64..=100_000,
specs in prop::collection::vec((20u64..=40, 1u64..=100_000, any::<bool>()), 0..12),
) {
let original = build_valid_result(initial, &specs);

// bincode is positional: it only decodes if the Serialize shape
// matches the wire struct field-for-field (#135).
let bytes = bincode::serde::encode_to_vec(&original, bincode::config::standard())
.map_err(|e| TestCaseError::fail(format!("bincode encode: {e}")))?;
let (bin_decoded, _): (MatchResult, usize) =
bincode::serde::decode_from_slice(&bytes, bincode::config::standard())
.map_err(|e| TestCaseError::fail(format!("valid bincode must decode: {e}")))?;
prop_assert_eq!(bin_decoded.order_id(), original.order_id());
prop_assert_eq!(
bin_decoded.remaining_quantity().as_u64(),
original.remaining_quantity().as_u64()
);
prop_assert_eq!(bin_decoded.is_complete(), original.is_complete());
prop_assert_eq!(bin_decoded.outcome(), original.outcome());
prop_assert_eq!(bin_decoded.trades().len(), original.trades().len());
prop_assert_eq!(bin_decoded.filled_order_ids(), original.filled_order_ids());

// serde JSON must decode and preserve every field, outcome included.
let json = serde_json::to_string(&original)
.map_err(|e| TestCaseError::fail(format!("serialize: {e}")))?;
Expand Down
Loading