Skip to content

feat: send transactions directly to leader TPU#1419

Open
satyam455 wants to merge 1 commit into
magicblock-labs:masterfrom
satyam455:feat/749-leader-tpu-transactions
Open

feat: send transactions directly to leader TPU#1419
satyam455 wants to merge 1 commit into
magicblock-labs:masterfrom
satyam455:feat/749-leader-tpu-transactions

Conversation

@satyam455

@satyam455 satyam455 commented Jul 13, 2026

Copy link
Copy Markdown

Implemented direct leader TPU transaction delivery while keeping the existing RPC load-balancer path as a fallback.

Changes:

  • added the Solana TPU client dependency
  • initialized TPU leader discovery from the configured WebSocket endpoint
  • submitted transactions concurrently through RPC and current/upcoming leader TPUs
  • preserved confirmation when either delivery path succeeds
  • kept RPC delivery working when TPU initialization or submission fails
  • enabled the required Agave unstable API feature
  • type-erased the TPU future to prevent compiler query-depth errors
  • updated agent documentation

Validation:

  • make fmt
  • cargo check -p magicblock-committor-service
  • cargo clippy -p magicblock-rpc-client --all-targets -- -D warnings
  • cargo test -p magicblock-rpc-client
  • git diff --check

Part of #749.

Summary by CodeRabbit

  • New Features

    • Transactions can now be delivered concurrently through RPC and, when configured, directly to Solana leader TPUs.
    • RPC delivery remains preferred when both paths succeed.
    • Successful TPU delivery can allow confirmation to continue if RPC submission fails.
  • Documentation

    • Updated transaction submission and confirmation guidance, including delivery behavior and reliability guarantees.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

MagicblockRpcClient now optionally sends serialized transactions directly to Solana TPU using a lazily initialized TpuClient when a WebSocket URL is configured. JSON-RPC and TPU delivery run concurrently, with RPC success preferred and TPU-only success allowing confirmation to continue. Workspace and crate dependencies were updated, and documentation now describes the transport flow, future type erasure, confirmation behavior, and related invariants.

Suggested reviewers: thlorenz, bmuddha, gabrielepicco, taco-paco

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/749-leader-tpu-transactions

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@magicblock-rpc-client/src/lib.rs`:
- Around line 344-373: Replace the single-shot OnceCell caching in tpu_sender
with a synchronized retry strategy that does not permanently retain None after
TpuClient::new fails. Track the cached result and retry eligibility using a
cooldown/backoff or TTL, while reusing a successfully initialized TpuSendFn
until refresh is needed; update the associated documentation to describe the
chosen retry behavior and failure semantics.
- Around line 696-727: Bound the TPU delivery future in send_transaction with a
tokio::time::timeout using the appropriate existing timeout duration or a
clearly defined bounded value. Handle timeout expiration as an unsuccessful TPU
send while preserving the current serialization, sender lookup, RPC error
handling, and signature fallback behavior; ensure a stalled
tpu_sender(wire_transaction) cannot delay completion after RPC succeeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dbd529c4-ffe3-43d6-9668-cbb0ada2e1c3

📥 Commits

Reviewing files that changed from the base of the PR and between 43b6afc and f9c21d6.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • .agents/context/crates/magicblock-rpc-client.md
  • Cargo.toml
  • magicblock-rpc-client/Cargo.toml
  • magicblock-rpc-client/src/lib.rs

Comment on lines +344 to +373
async fn tpu_sender(&self) -> Option<&TpuSendFn> {
self.tpu_sender
.get_or_init(|| async {
let websocket_url = self.websocket_url.as_deref()?;
match TpuClient::new(
"magicblock-rpc-client",
self.client.clone(),
websocket_url,
TpuClientConfig::default(),
)
.await
{
Ok(client) => {
let client = Arc::new(client);
Some(Arc::new(move |wire_transaction| {
let client = client.clone();
Box::pin(async move {
client.send_wire_transaction(wire_transaction).await
}) as TpuSendFuture<'static>
}) as TpuSendFn)
}
Err(err) => {
warn!(?err, "Failed to initialize leader TPU client; continuing with RPC delivery");
None
}
}
})
.await
.as_ref()
}

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

TPU sender never retries after the first initialization failure.

tpu_sender() caches the result of TpuClient::new(...) in an Arc<OnceCell<Option<TpuSendFn>>>. If the very first call fails (e.g. a transient websocket blip while MagicblockRpcClient is constructed at startup), the cell permanently stores None, and every subsequent send_transaction call silently skips TPU delivery for the entire lifetime of this client instance — with no re-attempt, backoff, or periodic refresh. Given this client is designed to be cheaply cloned and long-lived, a single transient failure disables the whole feature for the process's lifetime. The doc (.agents/context/crates/magicblock-rpc-client.md lines 158-162) says failure is "non-fatal" but doesn't mention this permanence, which could mislead operators into expecting recovery once the websocket comes back.

Consider a retry-with-backoff/TTL re-attempt strategy (e.g., store (Instant, Option<TpuSendFn>) behind a mutex and re-run the initializer after a cooldown) instead of a single-shot OnceCell, and update the doc to reflect whichever behavior is intended.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@magicblock-rpc-client/src/lib.rs` around lines 344 - 373, Replace the
single-shot OnceCell caching in tpu_sender with a synchronized retry strategy
that does not permanently retain None after TpuClient::new fails. Track the
cached result and retry eligibility using a cooldown/backoff or TTL, while
reusing a successfully initialized TpuSendFn until refresh is needed; update the
associated documentation to describe the chosen retry behavior and failure
semantics.

Comment on lines +696 to +727
let wire_transaction = bincode::serialize(tx).ok();
let rpc_send = self
.client
.send_transaction_with_config(tx, SEND_TRANSACTION_CONFIG)
.await
.map_err(|e| {
MagicBlockRpcClientError::SendTransaction(Box::new(e))
})?;
.send_transaction_with_config(tx, SEND_TRANSACTION_CONFIG);
// Type-erase this future so every generic transaction caller does not
// inherit the deeply nested TPU client future in its async state machine.
let tpu_send: TpuSendFuture<'_> = Box::pin(async {
let Some(wire_transaction) = wire_transaction else {
warn!(
"Failed to serialize transaction for leader TPU delivery"
);
return false;
};
let Some(tpu_sender) = self.tpu_sender().await else {
return false;
};
tpu_sender(wire_transaction).await
});

let (rpc_result, tpu_sent) = tokio::join!(rpc_send, tpu_send);
let sig = match rpc_result {
Ok(signature) => signature,
Err(err) if tpu_sent => {
warn!(?err, "RPC transaction delivery failed after leader TPU delivery succeeded");
*tx.get_signature()
}
Err(err) => {
return Err(MagicBlockRpcClientError::SendTransaction(
Box::new(err),
));
}
};

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.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

tokio::join! can block send_transaction on a hung/slow TPU delivery even when RPC already succeeded.

tokio::join!(rpc_send, tpu_send) only completes once both futures resolve. If tpu_sender(wire_transaction).await (i.e. TpuClient::send_wire_transaction) hangs or is slow — e.g. the current/upcoming leader is unreachable and the QUIC connection setup stalls — the whole send_transaction call is blocked on it, even though the RPC path already succeeded quickly. This is exactly the kind of "blocking call without a timeout on the request path" that can cascade into elevated tail latency/outages for a crate explicitly documented as performance-sensitive ("Changes must avoid increasing confirmation latency" — .agents/context/crates/magicblock-rpc-client.md). Wrap the TPU future in a bounded tokio::time::timeout so a stalled leader connection can't stall the whole send.

🛡️ Proposed fix: bound the TPU send with a timeout
         let tpu_send: TpuSendFuture<'_> = Box::pin(async {
             let Some(wire_transaction) = wire_transaction else {
                 warn!(
                     "Failed to serialize transaction for leader TPU delivery"
                 );
                 return false;
             };
             let Some(tpu_sender) = self.tpu_sender().await else {
                 return false;
             };
-            tpu_sender(wire_transaction).await
+            match tokio::time::timeout(
+                TPU_SEND_TIMEOUT,
+                tpu_sender(wire_transaction),
+            )
+            .await
+            {
+                Ok(sent) => sent,
+                Err(_) => {
+                    warn!("Leader TPU delivery timed out");
+                    false
+                }
+            }
         });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let wire_transaction = bincode::serialize(tx).ok();
let rpc_send = self
.client
.send_transaction_with_config(tx, SEND_TRANSACTION_CONFIG)
.await
.map_err(|e| {
MagicBlockRpcClientError::SendTransaction(Box::new(e))
})?;
.send_transaction_with_config(tx, SEND_TRANSACTION_CONFIG);
// Type-erase this future so every generic transaction caller does not
// inherit the deeply nested TPU client future in its async state machine.
let tpu_send: TpuSendFuture<'_> = Box::pin(async {
let Some(wire_transaction) = wire_transaction else {
warn!(
"Failed to serialize transaction for leader TPU delivery"
);
return false;
};
let Some(tpu_sender) = self.tpu_sender().await else {
return false;
};
tpu_sender(wire_transaction).await
});
let (rpc_result, tpu_sent) = tokio::join!(rpc_send, tpu_send);
let sig = match rpc_result {
Ok(signature) => signature,
Err(err) if tpu_sent => {
warn!(?err, "RPC transaction delivery failed after leader TPU delivery succeeded");
*tx.get_signature()
}
Err(err) => {
return Err(MagicBlockRpcClientError::SendTransaction(
Box::new(err),
));
}
};
let wire_transaction = bincode::serialize(tx).ok();
let rpc_send = self
.client
.send_transaction_with_config(tx, SEND_TRANSACTION_CONFIG);
// Type-erase this future so every generic transaction caller does not
// inherit the deeply nested TPU client future in its async state machine.
let tpu_send: TpuSendFuture<'_> = Box::pin(async {
let Some(wire_transaction) = wire_transaction else {
warn!(
"Failed to serialize transaction for leader TPU delivery"
);
return false;
};
let Some(tpu_sender) = self.tpu_sender().await else {
return false;
};
match tokio::time::timeout(
TPU_SEND_TIMEOUT,
tpu_sender(wire_transaction),
)
.await
{
Ok(sent) => sent,
Err(_) => {
warn!("Leader TPU delivery timed out");
false
}
}
});
let (rpc_result, tpu_sent) = tokio::join!(rpc_send, tpu_send);
let sig = match rpc_result {
Ok(signature) => signature,
Err(err) if tpu_sent => {
warn!(?err, "RPC transaction delivery failed after leader TPU delivery succeeded");
*tx.get_signature()
}
Err(err) => {
return Err(MagicBlockRpcClientError::SendTransaction(
Box::new(err),
));
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@magicblock-rpc-client/src/lib.rs` around lines 696 - 727, Bound the TPU
delivery future in send_transaction with a tokio::time::timeout using the
appropriate existing timeout duration or a clearly defined bounded value. Handle
timeout expiration as an unsuccessful TPU send while preserving the current
serialization, sender lookup, RPC error handling, and signature fallback
behavior; ensure a stalled tpu_sender(wire_transaction) cannot delay completion
after RPC succeeds.

@GabrielePicco
GabrielePicco requested a review from thlorenz July 20, 2026 06:30
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.

1 participant