Skip to content

perf: prepare oracle quote contexts concurrently - #2826

Merged
graphite-app[bot] merged 1 commit into
mainfrom
arda/concurrent-oracle-quote-targets
Aug 11, 2026
Merged

perf: prepare oracle quote contexts concurrently#2826
graphite-app[bot] merged 1 commit into
mainfrom
arda/concurrent-oracle-quote-targets

Conversation

@findolor

@findolor findolor commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Why

Quote-target construction currently awaits each oracle POST inside the nested order/input/output loops. Production evidence from the st0x REST API showed about 49.6 oracle requests and about 9.7 seconds of target-build latency per cache miss, while the subsequent batched chain quote usually took only 100–600 ms.

What changed

  • prepare every valid pair and its established single-pair oracle request body in positional order
  • execute oracle POSTs through the shared process-wide client with a conservative global concurrency limit of 8
  • restore results to their original request slots before composing quote targets
  • preserve oracle-before-injector signed-context order
  • isolate an oracle error to only its corresponding pair response
  • keep the chain quote as one BatchQuoteTarget::do_quote operation
  • add tracing for oracle request, endpoint, success/failure, concurrency-limit, batching, and duration data
  • add a deterministic end-to-end test that asserts exact oracle request bodies, partial failure isolation, exact ordered RPC multicall contents, signed-context composition, distinct RPC-result slot mapping, and a single chain RPC request

Deliberate constraints

  • The existing batch encoder/API is not used automatically because order metadata exposes only an oracle URL and does not declare that the configured endpoint accepts the batch ABI body.
  • Requests remain individual POSTs and are bounded globally at 8; there is no unbounded fan-out.
  • No retry/backoff was added because the endpoint contract does not declare POST idempotency. Existing 429/503 status handling remains a per-pair failure.
  • Public response contracts, native/WASM support, timeout behavior, and chain RPC batching semantics are unchanged.

Verification

  • cargo test -p raindex_quote --lib
  • cargo clippy -p raindex_quote --all-targets --all-features -- -D warnings -D clippy::all
  • cargo fmt --all -- --check
  • nix develop .#wasm-shell -c bash -c 'CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test --target wasm32-unknown-unknown --lib -p raindex_quote'
  • nix develop .#wasm-shell -c rainix-rs-static
  • git diff --check

Stack

Depends on #2825, which adds the reusable shared HTTP client and bounded ordered oracle-request primitive.

findolor commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

How to use the Graphite Merge Queue

Add the label Raindex-queue to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e8fb9d7-2d56-433e-a358-6084ea8691dc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@findolor
findolor changed the base branch from arda/oracle-http-primitives to graphite-base/2826 July 31, 2026 15:58
@findolor
findolor force-pushed the arda/concurrent-oracle-quote-targets branch from 7a8d133 to 67a98bf Compare July 31, 2026 16:15
@findolor
findolor force-pushed the graphite-base/2826 branch from 29be9b8 to f663494 Compare July 31, 2026 16:15
@findolor
findolor changed the base branch from graphite-base/2826 to arda/oracle-http-primitives July 31, 2026 16:15
@findolor findolor self-assigned this Aug 3, 2026
@findolor
findolor requested review from 0xgleb and JuaniRios August 5, 2026 18:47
Comment on lines +311 to +317
if preparation.oracle_url.is_some() {
debug!(
raindex = %raindex,
input_index,
output_index,
"fetched quote oracle context"
);

@0xgleb 0xgleb Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

minor: the old code timed each oracle POST and put duration_ms on both the success debug and the failure warn. Those are gone, and neither the new debug nor the warn carries a duration or an endpoint, so the only timing left is the aggregate on the completed-fetches span, which is dominated by the slowest request in the batch. Given the change is motivated by production evidence of 49.6 requests and 9.7s per cache miss, losing per-request attribution removes exactly the signal that diagnosed the problem. I'd have fetch_signed_contexts return elapsed time per request, or emit a debug with host and duration inside the per-request future, and put duration_ms plus the endpoint host back on the per-pair lines.

Comment thread crates/quote/src/order_quotes.rs Outdated
oracle_batch_request_count = 0,
"starting bounded quote oracle context fetches"
);
let oracle_results: Vec<Result<SignedContextV1, String>> = if oracle_requests.is_empty() {

@0xgleb 0xgleb Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

minor: before this change each get_order_quotes call awaited oracle POSTs serially, so it held at most one global permit at a time. Now one call runs its whole pair set through buffer_unordered(8) against the same eight-permit process-wide semaphore, so a call whose order meta points at a dead endpoint fills every permit with requests that each sit for the full 10s timeout, and refills them wave after wave at roughly 50 requests per page. Every other concurrent quote request in the process blocks on acquire(), which has no deadline and isn't covered by the reqwest timeout because the timeout only starts once the permit is held. Order meta is caller-supplied, so on the multi-tenant REST API this is one order degrading everyone. Capping in-flight requests per URL as well as globally, and putting a deadline around the permit acquisition so a stalled endpoint surfaces as a per-pair failure, would contain it.

Comment thread crates/quote/src/order_quotes.rs Outdated
Comment on lines +293 to +303
let mut oracle_results = oracle_results.into_iter();

for preparation in pair_preparations {
let oracle_context = if preparation.oracle_url.is_some() {
oracle_results
.next()
.unwrap_or_else(|| Err("Missing oracle response slot".to_string()))
.map(|context| vec![context])
} else {
Ok(vec![])
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

minor: the mapping from oracle responses back to pairs is positional by convention. The build loop pushes under if let Some(url) = &oracle_url and a separate later loop drains oracle_results.next() under preparation.oracle_url.is_some(), with nothing tying them together except both re-deriving the same predicate. It's correct today, but the unwrap_or_else(|| Err("Missing oracle response slot")) fallback only fires for trailing pairs after the iterator empties, so if a later change ever skips or dedupes a request in the build loop, every subsequent pair silently gets the previous pair's signed context and only the last one errors. That context goes on-chain as QuoteV2.signedContext, so a shift would attach an attestation to an order it was never issued for. Storing an oracle_request_index: Option<usize> on the preparation and indexing by it, plus a hard error on a length mismatch before the loop, makes the binding explicit and fails loudly.

Comment thread crates/quote/src/order_quotes.rs Outdated
Comment on lines +209 to +211
if !oracle_endpoint_counted {
oracle_urls.insert(url.clone());
oracle_endpoint_counted = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

minor: HashSet::insert is already idempotent, so this flag only saves a String clone, and oracle_url is loop-invariant across both inner loops anyway. Moving the insert up next to the extraction drops the flag entirely and keeps the inner loop about per-pair work. Worth noting it would then also count an endpoint for an order whose pairs are all self-trades and skipped, so if that distinction matters, deriving the count at log time from the prepared pairs avoids both the flag and the clones.

Comment thread crates/quote/src/order_quotes.rs Outdated
oracle_request_count = oracle_fetch_count,
oracle_endpoint_count = oracle_urls.len(),
oracle_concurrency_limit = ORACLE_REQUEST_CONCURRENCY_LIMIT,
oracle_batch_request_count = 0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

minor: this is a literal, and per the deliberate constraints the batch encoder is never invoked from this path, so the field can't be anything but 0 while the code is in this shape. The description lists batching as one of the tracing goals, so someone building a dashboard on it would be measuring nothing and might conclude batching is broken. I'd drop the field until batch-endpoint support exists, or compute it from pairs whose metadata declares batch compatibility so at least the intent is visible in the expression.

Comment thread crates/quote/src/order_quotes.rs Outdated
Comment on lines +264 to +268
Err(error) => {
let error = error.to_string();
(0..oracle_fetch_count)
.map(|_| Err(error.clone()))
.collect()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: the range index is discarded here, so this is vec!'s repeat form written as an iterator chain, and it clones the error once per element including the last. vec![Err(error); oracle_fetch_count] says the same thing and clones n-1 times.

Comment thread crates/quote/src/order_quotes.rs Outdated
Comment on lines +570 to +575
let response = routes
.iter()
.find_map(|(expected_body, response)| {
(expected_body == &body).then_some(response)
})
.and_then(Option::as_ref);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: then_some yields Option<&Option<OracleResponse>> and the trailing and_then(Option::as_ref) exists only to flatten it, which collapses two different meanings of none: no route matched the body, and a route matched but is configured to fail. The test asserts on a 503, so that distinction is load-bearing. If encode_oracle_body ever changes encoding, an unmatched body falls through to the same 503 path and the test keeps passing for the wrong reason. Using find and borrowing the payload drops the nested Option, and naming the two outcomes (respond vs unavailable) with a panic on no match would make an encoding drift fail loudly.

@findolor
findolor changed the base branch from arda/oracle-http-primitives to graphite-base/2826 August 6, 2026 06:39
@findolor
findolor force-pushed the graphite-base/2826 branch from f663494 to eb53ceb Compare August 11, 2026 10:40
@findolor
findolor force-pushed the arda/concurrent-oracle-quote-targets branch from 67a98bf to 832fcea Compare August 11, 2026 10:40
@findolor
findolor changed the base branch from graphite-base/2826 to arda/oracle-http-primitives August 11, 2026 10:40
@findolor
findolor force-pushed the arda/concurrent-oracle-quote-targets branch 2 times, most recently from 2d51004 to 4ae9e1a Compare August 11, 2026 10:46
@graphite-app

graphite-app Bot commented Aug 11, 2026

Copy link
Copy Markdown

Merge activity

## Why

Quote-target construction currently awaits each oracle POST inside the nested order/input/output loops. Production evidence from the st0x REST API showed about 49.6 oracle requests and about 9.7 seconds of target-build latency per cache miss, while the subsequent batched chain quote usually took only 100–600 ms.

## What changed

- prepare every valid pair and its established single-pair oracle request body in positional order
- execute oracle POSTs through the shared process-wide client with a conservative global concurrency limit of 8
- restore results to their original request slots before composing quote targets
- preserve oracle-before-injector signed-context order
- isolate an oracle error to only its corresponding pair response
- keep the chain quote as one `BatchQuoteTarget::do_quote` operation
- add tracing for oracle request, endpoint, success/failure, concurrency-limit, batching, and duration data
- add a deterministic end-to-end test that asserts exact oracle request bodies, partial failure isolation, exact ordered RPC multicall contents, signed-context composition, distinct RPC-result slot mapping, and a single chain RPC request

## Deliberate constraints

- The existing batch encoder/API is not used automatically because order metadata exposes only an oracle URL and does not declare that the configured endpoint accepts the batch ABI body.
- Requests remain individual POSTs and are bounded globally at 8; there is no unbounded fan-out.
- No retry/backoff was added because the endpoint contract does not declare POST idempotency. Existing 429/503 status handling remains a per-pair failure.
- Public response contracts, native/WASM support, timeout behavior, and chain RPC batching semantics are unchanged.

## Verification

- `cargo test -p raindex_quote --lib`
- `cargo clippy -p raindex_quote --all-targets --all-features -- -D warnings -D clippy::all`
- `cargo fmt --all -- --check`
- `nix develop .#wasm-shell -c bash -c 'CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test --target wasm32-unknown-unknown --lib -p raindex_quote'`
- `nix develop .#wasm-shell -c rainix-rs-static`
- `git diff --check`

## Stack

Depends on #2825, which adds the reusable shared HTTP client and bounded ordered oracle-request primitive.
@graphite-app
graphite-app Bot force-pushed the arda/oracle-http-primitives branch from eb53ceb to 19bf215 Compare August 11, 2026 11:14
@graphite-app
graphite-app Bot force-pushed the arda/concurrent-oracle-quote-targets branch from 4ae9e1a to 16049a3 Compare August 11, 2026 11:15
@graphite-app
graphite-app Bot changed the base branch from arda/oracle-http-primitives to main August 11, 2026 11:26
@graphite-app
graphite-app Bot merged commit 16049a3 into main Aug 11, 2026
19 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

@coderabbitai assess this PR size classification for the totality of the PR with the following criterias and report it in your comment:

S/M/L PR Classification Guidelines:

This guide helps classify merged pull requests by effort and complexity rather than just line count. The goal is to assess the difficulty and scope of changes after they have been completed.

Small (S)

Characteristics:

  • Simple bug fixes, typos, or minor refactoring
  • Single-purpose changes affecting 1-2 files
  • Documentation updates
  • Configuration tweaks
  • Changes that require minimal context to review

Review Effort: Would have taken 5-10 minutes

Examples:

  • Fix typo in variable name
  • Update README with new instructions
  • Adjust configuration values
  • Simple one-line bug fixes
  • Import statement cleanup

Medium (M)

Characteristics:

  • Feature additions or enhancements
  • Refactoring that touches multiple files but maintains existing behavior
  • Breaking changes with backward compatibility
  • Changes requiring some domain knowledge to review

Review Effort: Would have taken 15-30 minutes

Examples:

  • Add new feature or component
  • Refactor common utility functions
  • Update dependencies with minor breaking changes
  • Add new component with tests
  • Performance optimizations
  • More complex bug fixes

Large (L)

Characteristics:

  • Major feature implementations
  • Breaking changes or API redesigns
  • Complex refactoring across multiple modules
  • New architectural patterns or significant design changes
  • Changes requiring deep context and multiple review rounds

Review Effort: Would have taken 45+ minutes

Examples:

  • Complete new feature with frontend/backend changes
  • Protocol upgrades or breaking changes
  • Major architectural refactoring
  • Framework or technology upgrades

Additional Factors to Consider

When deciding between sizes, also consider:

  • Test coverage impact: More comprehensive test changes lean toward larger classification
  • Risk level: Changes to critical systems bump up a size category
  • Team familiarity: Novel patterns or technologies increase complexity

Notes:

  • the assessment must be for the totality of the PR, that means comparing the base branch to the last commit of the PR
  • the assessment output must be exactly one of: S, M or L (single-line comment) in format of: SIZE={S/M/L}
  • do not include any additional text, only the size classification
  • your assessment comment must not include tips or additional sections
  • do NOT tag me or anyone else on your comment

graphite-app Bot pushed a commit to ST0x-Technology/st0x.rest.api that referenced this pull request Aug 12, 2026
## Dependent PRs

- Raindex bounded oracle HTTP client (merge first): rainlanguage/raindex#2825
- Raindex batched oracle context preparation (merge second): rainlanguage/raindex#2826

## Motivation

The REST application's order-quote passes can require many oracle contexts at once. Issuing one HTTP request per context adds avoidable connection and request overhead even when the contexts share the same oracle endpoint.

## Solution

- Bump `lib/rain.orderbook` from `0fa60a6` to `4ae9e1a02`.
- Send one ABI batch request per exact oracle URL and scatter the returned contexts back to their quote pairs.
- Preserve bounded concurrency across distinct oracle endpoints with a limit of 8.
- Remove the previous per-context concurrent HTTP request path while leaving chain quote batching unchanged.

## Verification

- `nix develop -c cargo check`
- Upstream quote crate test suite — 63 passed
- Upstream strict Clippy checks
- Repository pre-commit hooks
- `git diff --check`
- Live local REST API validation against Base:
  - `/v2/swap/quote` returned HTTP 200 and `fullyFilled: true` for wtCOIN, wtNVDA, and wtMSTR.
  - Batch telemetry reduced 55 oracle contexts to 2 HTTP requests; 54 contexts succeeded and one legacy oracle endpoint returned HTTP 404.
  - A request-scoped quote recorded one oracle context in one batch request and completed successfully.
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