Skip to content

(fast2) Persist synthetic solver competition at quote time - #4856

Open
MartinquaXD wants to merge 1 commit into
fast-path-1from
fast-path-2
Open

(fast2) Persist synthetic solver competition at quote time#4856
MartinquaXD wants to merge 1 commit into
fast-path-1from
fast-path-2

Conversation

@MartinquaXD

Copy link
Copy Markdown
Contributor

Description

In order for all the API queries and bookkeeping to work with fast path orders we need to build "synthetic" competition data based on the quote competition.
This only happens for quote competitions which have an auction_id associated with them which is the indicator that proper auction data is needed.

Changes

populates competition_auctions, proposed_solutions and proposed_trade_executions for quotes that need it.
Because some data is unknown until an order actually gets created we store a dummy order_uid and score. Those will be updated in a later PR.
Also note that we need to handle streamed quotes in a special way. Because streamed quotes need to be stored after every sub-quote we first delete any already existing competition data before we add insert new data.

How to test

e2e tests at the end of the PR stack

Adds `shared::quote_storage::save_quote_competition`, called by the
orderbook and autopilot quote-storage paths (`QuoteStoring::save_quote`).
For quotes that carry an `auction_id`, it also writes rows into
`competition_auctions`, `proposed_solutions` and
`proposed_trade_executions` (with the user's trade recorded under a
sentinel `order_uid = 0x00…00` because the real uid isn't known yet).

JIT orders proposed by solvers are intentionally not persisted here —
they live in the `quotes.metadata` JSON blob and the driver re-encodes
them at settle time.

Also adds supporting DB helpers:
* `database::quotes::delete_and_return_row` — used later at order
  placement to promote a transient quote into an `order_quotes` row.
* `database::solver_competition_v2::{delete_by_auction_id,
  save_from_quote}` — bulk writes and cleanup for the synthetic
  competition.

Purely additive: nothing reads these rows yet.
@MartinquaXD
MartinquaXD requested a review from a team as a code owner September 3, 2026 12:38
@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinquaXD's task in 3m 16s —— View job


PR Review: Persist synthetic solver competition at quote time

  • Read PR diff and changed files
  • Analyze new quote_storage.rs logic
  • Review DB queries for correctness
  • Post inline findings
  • Post summary

Overall this looks solid: the delete-then-insert approach correctly makes streamed sub-quotes idempotent (the plain INSERT into competition_auctions has no ON CONFLICT, so the pre-delete is load-bearing), the CTE delete is safe since these tables have no FKs between them, and the UCP price encoding for the placeholder trade is consistent. Transaction ownership is cleanly delegated to the caller.

I left 2 inline comments:

  1. competition_auctions pollution (main concern) — synthetic quote auctions get an id from the same sequence as real auctions but with block = 0 / deadline = 0, which makes them surface as the "latest" in load_latest (public /v2/solver_competition/latest) and in fetch_latest_prices / fetch_latest_token_price. Worth confirming this is intended vs. handled later.
  2. Ranking gaps — skipping non-winner quotes without a solution_id while deriving uid from the enumerate() index yields non-contiguous ranking values downstream.

Neither blocks the core mechanism; the first is the one worth a decision before merge. I did not run the build/tests (the e2e coverage is noted as landing at the end of the stack).
· fast-path-2

Comment on lines +81 to +96
let auction = Auction {
id: auction_id,
// Block, deadline, and order_uids are unknown at quote time; real
// values are populated when the user places the order and a full
// auction runs.
block: 0,
deadline: 0,
order_uids: Vec::new(),
price_tokens: native_price_tokens,
price_values: native_price_values,
surplus_capturing_jit_order_owners: Vec::new(),
penalty_caps_native: Some(Vec::new()),
};
database::auction::save(&mut *tx, auction)
.await
.context("failed to save competition_auctions row")?;

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.

Synthetic quote auctions are written into competition_auctions with block: 0, deadline: 0 and an id drawn from the same auctions sequence as real auctions (get_next_auction_id). This leaks into the "latest" queries that key off that table:

  • solver_competition_v2::load_latest (public GET /v2/solver_competition/latest) does ORDER BY id DESC filtered by deadline <= current_block. A synthetic quote auction has the highest id and deadline = 0 <= current_block, so right after any fast-path quote the endpoint returns a never-settled competition with block = 0, placeholder scores, and a zero order_uid.
  • auction::fetch_latest_prices (MAX(id)) and auction::fetch_latest_token_price ("most recent auction that priced the token") will likewise start resolving against these 2-token synthetic rows.

Quotes that never become orders leave these block = 0 rows behind permanently. Is polluting the latest-competition / latest-price paths intended here, or should synthetic auctions be excluded from those queries (or given a sentinel deadline)? Unlike order_uid/score, block/deadline aren't called out as a follow-up TODO.

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.

Seems like a reasonable concern. Maybe differentiating those synthetic auctions more explicitly (even via a fast path column) may make sense.

Why don't we set block and deadline? Isn't there a well defined exclusivity period?

Comment on lines +100 to +104
for (index, quote) in data.quotes().iter().enumerate() {
let Some(solution_id) = quote.solution_id else {
continue;
};
let solution_uid = i64::try_from(index).expect("solution index fits in i64");

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.

Non-winner quotes that lack a solution_id are silently skipped, but solution_uid is derived from the original enumerate() index. So if e.g. index 1 is skipped, the persisted uids become 0, 2, 3, …. load_by_id then computes ranking = uid + 1, producing gaps (1, 3, 4) in the ranking presented via the competition API. Consider assigning uid from a running counter over the kept solutions so ranks stay contiguous.

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.

I guess the translation is done, because solver chosen solution ids might not be unique. I think this is another example of why we should chose the quote id and simply reuse it instead of relying on solvers to generate one. It will also help our internal tracing.

@MartinquaXD MartinquaXD changed the title Persist synthetic solver competition at quote time (fast2) Persist synthetic solver competition at quote time Sep 3, 2026
Comment on lines +100 to +104
for (index, quote) in data.quotes().iter().enumerate() {
let Some(solution_id) = quote.solution_id else {
continue;
};
let solution_uid = i64::try_from(index).expect("solution index fits in i64");

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.

I guess the translation is done, because solver chosen solution ids might not be unique. I think this is another example of why we should chose the quote id and simply reuse it instead of relying on solvers to generate one. It will also help our internal tracing.

@@ -0,0 +1,161 @@
//! Persistence helpers for quote competitions. Shared between the orderbook

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.

Weren't we trying to reduce the amount of code we put in the share crate? Could this module live in the database crate directly?

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.

Given we introduce a fast path db module later, I feel like the part of the code that is fake auction specific should actually live there. Wdyt?

Comment on lines +81 to +96
let auction = Auction {
id: auction_id,
// Block, deadline, and order_uids are unknown at quote time; real
// values are populated when the user places the order and a full
// auction runs.
block: 0,
deadline: 0,
order_uids: Vec::new(),
price_tokens: native_price_tokens,
price_values: native_price_values,
surplus_capturing_jit_order_owners: Vec::new(),
penalty_caps_native: Some(Vec::new()),
};
database::auction::save(&mut *tx, auction)
.await
.context("failed to save competition_auctions row")?;

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.

Seems like a reasonable concern. Maybe differentiating those synthetic auctions more explicitly (even via a fast path column) may make sense.

Why don't we set block and deadline? Isn't there a well defined exclusivity period?

Comment on lines +367 to +371
WITH
del_te AS (DELETE FROM proposed_trade_executions WHERE auction_id = $1),
del_jo AS (DELETE FROM proposed_jit_orders WHERE auction_id = $1),
del_ps AS (DELETE FROM proposed_solutions WHERE auction_id = $1)
DELETE FROM competition_auctions WHERE id = $1

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.

TIL you can do this

);
return Ok(());
}

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.

Using Ok here is misleading, the log already points at it with the error level even

Comment on lines +100 to +103
for (index, quote) in data.quotes().iter().enumerate() {
let Some(solution_id) = quote.solution_id else {
continue;
};

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.

Really asks for a filter_map but then it becomes more verbose than it already is

Comment on lines +106 to +111
// Placeholder for the user's future order — the real uid is written
// when the order is placed.
let sell = u256_to_big_decimal(&quote.quoted_sell_amount);
let buy = u256_to_big_decimal(&quote.quoted_buy_amount);
let orders = vec![CompetitionOrder {
uid: Default::default(),

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.

nano nit:

Suggested change
// Placeholder for the user's future order — the real uid is written
// when the order is placed.
let sell = u256_to_big_decimal(&quote.quoted_sell_amount);
let buy = u256_to_big_decimal(&quote.quoted_buy_amount);
let orders = vec![CompetitionOrder {
uid: Default::default(),
let sell = u256_to_big_decimal(&quote.quoted_sell_amount);
let buy = u256_to_big_decimal(&quote.quoted_buy_amount);
let orders = vec![CompetitionOrder {
// Placeholder for the user's future order — the real uid is written
// when the order is placed.
uid: Default::default(),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants