Skip to content
Draft
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
62 changes: 53 additions & 9 deletions crates/autopilot/src/infra/persistence/dto/auction.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use {
super::order::Order,
super::order::{Order, OrdersJson},
crate::domain::{self, auction::Price},
alloy::primitives::{Address, U256},
eth_domain_types as eth,
Expand All @@ -11,16 +11,18 @@ use {

/// Converts the auction into the shape that gets archived to the DB and S3.
///
/// The order list comes in already serialized because those bytes are shared
/// with the `/solve` request (see [`OrdersJson`]).
///
/// Takes the auction by reference so the caller can keep using it afterwards
/// without a deep clone.
pub fn from_domain(auction: &domain::RawAuctionData) -> RawAuctionData {
pub fn from_domain(
auction: &domain::RawAuctionData,
orders: OrdersJson,
) -> RawAuctionData<OrdersJson> {
RawAuctionData {
block: auction.block,
orders: auction
.orders
.iter()
.map(super::order::from_domain)
.collect(),
orders,
prices: auction
.prices
.iter()
Expand All @@ -30,12 +32,19 @@ pub fn from_domain(auction: &domain::RawAuctionData) -> RawAuctionData {
}
}

/// The archived auction. Generic over the order list so the write path can
/// splice in the pre-rendered [`OrdersJson`] while the read path deserializes
/// into [`Order`]s. One definition, so the two shapes can't drift apart.
///
/// `Deserialize` is only ever instantiated at `O = Vec<Order>`; the derive puts
/// the bound on the impl, not on the struct, so `RawAuctionData<OrdersJson>`
/// simply has no `Deserialize` impl.
#[serde_as]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawAuctionData {
pub struct RawAuctionData<O = Vec<Order>> {
pub block: u64,
pub orders: Vec<Order>,
pub orders: O,
#[serde_as(as = "BTreeMap<_, HexOrDecimalU256>")]
pub prices: BTreeMap<Address, U256>,
#[serde(default)]
Expand Down Expand Up @@ -79,3 +88,38 @@ impl Auction {
})
}
}

#[cfg(test)]
mod tests {
use super::*;

/// The archived auction is written with pre-rendered orders and read back
/// into `Vec<Order>`, so the two shapes must stay in sync.
#[tokio::test]
async fn archived_auction_round_trips() {
let token = Address::from([1u8; 20]);
let auction = domain::RawAuctionData {
block: 42,
orders: vec![],
prices: [(
token.into(),
Price::try_new(U256::from(1000).into()).unwrap(),
)]
.into_iter()
.collect(),
surplus_capturing_jit_order_owners: vec![Address::from([2u8; 20])],
};

let written = from_domain(&auction, OrdersJson::new(&auction.orders).await);
let json = serde_json::to_string(&written).unwrap();

let read: RawAuctionData = serde_json::from_str(&json).unwrap();
assert_eq!(read.block, 42);
assert!(read.orders.is_empty());
Comment on lines +113 to +118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 comments on this test:

  1. why are the orders empty? Wouldn't it make more sense to have at least 1 order in ther?
  2. this shows in a nutshell that from_domain() is broken at the moment. Nothing guarantees that the OrdersJson you pass in actually matches the RawAuctionData::orders. This makes this code quite fragile IMO. Especially with JSON streaming and auction diffing coming up I feel like this will age super poorly. We'll keep all the complexity with very little upside since we can serialize JSON faster than the network can transfer it anyway.

assert_eq!(read.prices, written.prices);
assert_eq!(
read.surplus_capturing_jit_order_owners,
written.surplus_capturing_jit_order_owners
);
}
}
74 changes: 73 additions & 1 deletion crates/autopilot/src/infra/persistence/dto/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ use {
configs::fee_factor::FeeFactor,
eth_domain_types as eth,
number::serialization::HexOrDecimalU256,
serde::{Deserialize, Serialize},
serde::{Deserialize, Serialize, Serializer},
serde_json::value::RawValue,
serde_with::serde_as,
std::sync::Arc,
};

#[serde_as]
Expand Down Expand Up @@ -124,6 +126,44 @@ pub fn to_domain(order: Order) -> domain::Order {
}
}

/// The auction's order list, already rendered to JSON. Serializes verbatim so
/// the archived auction and the `/solve` request body can splice in the same
/// bytes instead of each converting and serializing the orders themselves.
/// Cheap to clone.
#[derive(Clone, Debug)]
pub struct OrdersJson(Arc<RawValue>);

impl OrdersJson {
/// Converts the orders into their DTO shape on the calling thread (they are
/// borrowed, so this can't be moved into the background without a deep
/// clone) and renders them to JSON on the blocking pool.
pub async fn new(orders: &[domain::Order]) -> Self {
let orders: Vec<Order> = {
let _timer = observe::metrics::metrics()
.on_auction_overhead_start("autopilot", "convert_orders");
orders.iter().map(from_domain).collect()
};

tokio::task::spawn_blocking(move || {
let _timer = observe::metrics::metrics()
.on_auction_overhead_start("autopilot", "serialize_orders");
// `to_raw_value` keeps the serializer output as-is; unlike
// `RawValue::from_string` it doesn't re-scan the JSON to validate it.
let json = serde_json::value::to_raw_value(&orders)
.expect("orders should be JSON serializable");
OrdersJson(Arc::from(json))
})
.await
.expect("order serialization should not panic")
}
}

impl Serialize for OrdersJson {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}

impl From<boundary::OrderUid> for domain::OrderUid {
fn from(uid: boundary::OrderUid) -> Self {
Self(uid.0)
Expand Down Expand Up @@ -383,3 +423,35 @@ impl From<domain::auction::order::Side> for database::orders::OrderKind {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Wrapper {
orders: OrdersJson,
block: u64,
}

#[test]
fn orders_json_is_spliced_verbatim() {
let orders = serde_json::json!([{"uid": "0x01"}, {"uid": "0x02"}]);
let orders = OrdersJson(Arc::from(serde_json::value::to_raw_value(&orders).unwrap()));

assert_eq!(
serde_json::to_string(&Wrapper { orders, block: 7 }).unwrap(),
r#"{"orders":[{"uid":"0x01"},{"uid":"0x02"}],"block":7}"#
);
}

#[tokio::test]
async fn empty_orders_json() {
let orders = OrdersJson::new(&[]).await;

assert_eq!(orders.0.get(), "[]");
// both consumers get the very same bytes instead of re-rendering them
assert!(Arc::ptr_eq(&orders.0, &orders.clone().0));
}
}
14 changes: 10 additions & 4 deletions crates/autopilot/src/infra/persistence/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,20 @@ impl Persistence {
///
/// Only the conversion into the archival shape is on the run loop's
/// critical path; the serialization and both sinks happen in background
/// tasks. The auction is taken by reference so the conversion doesn't
/// need a deep clone.
/// tasks. The order list is not converted here at all: those bytes are
/// serialized once and shared with the `/solve` request. The auction is
/// taken by reference so the conversion doesn't need a deep clone.
#[instrument(skip_all)]
pub fn archive_auction(&self, id: domain::auction::Id, auction: &domain::RawAuctionData) {
pub fn archive_auction(
&self,
id: domain::auction::Id,
auction: &domain::RawAuctionData,
orders: dto::order::OrdersJson,
) {
let auction_data = {
let _timer = observe::metrics::metrics()
.on_auction_overhead_start("autopilot", "convert_auction");
dto::auction::from_domain(auction)
dto::auction::from_domain(auction, orders)
};

let upload_to_s3 = !auction.orders.is_empty();
Expand Down
12 changes: 5 additions & 7 deletions crates/autopilot/src/infra/solvers/dto/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ use {
crate::{
boundary,
domain,
infra::{
persistence::dto::{self, order::Order},
solvers::InjectIntoHttpRequest,
},
infra::{persistence::dto::order::OrdersJson, solvers::InjectIntoHttpRequest},
},
alloy::primitives::{Address, U256},
brotli::enc::writer::CompressorWriter,
Expand Down Expand Up @@ -42,6 +39,7 @@ pub struct Request {
impl Request {
pub async fn new(
auction: &domain::Auction,
orders: OrdersJson,
trusted_tokens: &HashSet<Address>,
deadline: chrono::DateTime<chrono::Utc>,
compress: bool,
Expand All @@ -50,7 +48,7 @@ impl Request {
observe::metrics::metrics().on_auction_overhead_start("autopilot", "serialize_request");
let helper = RequestHelper {
id: auction.id,
orders: auction.orders.iter().map(dto::order::from_domain).collect(),
orders,
tokens: auction
.prices
.iter()
Expand Down Expand Up @@ -175,13 +173,13 @@ impl Response {
}

#[serde_as]
#[derive(Clone, Debug, Default, Serialize)]
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RequestHelper {
#[serde_as(as = "DisplayFromStr")]
pub id: i64,
pub tokens: Vec<Token>,
pub orders: Vec<Order>,
pub orders: OrdersJson,
pub deadline: DateTime<Utc>,
pub surplus_capturing_jit_order_owners: Vec<Address>,
}
Expand Down
Loading
Loading