Skip to content

Commit e7b3578

Browse files
committed
review and fix callbacks during undelegation recovery
Report failure callbacks before discarding an undelegation and its following actions. Extract callbacks through the existing authorization checks, retain the failure signature, and leave callbacks on retained actions for the eventual retry result. Move the sole transaction loop onto TransactionExecutor. Remove the borrowed ExecutionState adapter and closure parameters while preserving pending-signature resolution, outbox-before-send ordering, cancellation state, cleanup ownership, and retry limits. Remove the unused InternalError transaction-size wrapper and replace four small predicate tests with one table-driven test of the surviving send error mapper. Extend the existing recovery matrix to check discarded and retained callback identities, outcomes, and failure signatures. Validation: cargo fmt; cargo check -p magicblock-committor-service --tests; cargo test -p magicblock-committor-service recovery_preserves_progress_and_resources -- --nocapture; git diff --check.
1 parent 508b95a commit e7b3578

7 files changed

Lines changed: 284 additions & 310 deletions

File tree

‎committor/src/intent_executor/error.rs‎

Lines changed: 11 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,6 @@ impl InternalError {
4343
Self::MagicBlockRpcClientError(err) => err.is_transient(),
4444
}
4545
}
46-
47-
pub fn is_transaction_too_large(&self) -> bool {
48-
match self {
49-
Self::MagicBlockRpcClientError(err) => err.is_transaction_too_large(),
50-
Self::SignerError(_) => false,
51-
}
52-
}
5346
}
5447

5548
#[derive(thiserror::Error, Debug)]
@@ -143,53 +136,20 @@ mod tests {
143136
use rpc_client::MagicBlockRpcClientError;
144137
use solana_rpc_client_api::{
145138
client_error::{Error as RpcClientError, ErrorKind as RpcClientErrorKind},
146-
request::{RpcError, RpcRequest, RpcResponseErrorData},
139+
request::RpcRequest,
147140
};
148141

149142
use super::InternalError;
150-
use crate::intent_executor::strategy_executor::error::TransactionStrategyExecutionError;
151-
152-
const TX_TOO_LARGE_SOLANA: &str = "base64 encoded too large";
153-
const TX_TOO_LARGE_MAGICBLOCK: &str = "base64 encoded solana_transaction::versioned::VersionedTransaction too large: 1684 bytes (max: encoded/raw 1644/1232)";
154-
155-
fn make_send_transaction_error(message: &str) -> InternalError {
156-
let rpc_error = RpcClientError {
157-
request: Some(RpcRequest::SendTransaction),
158-
kind: Box::new(RpcClientErrorKind::RpcError(RpcError::RpcResponseError {
159-
code: -32602,
160-
message: message.to_string(),
161-
data: RpcResponseErrorData::Empty,
162-
})),
163-
};
164-
InternalError::MagicBlockRpcClientError(Box::new(
165-
MagicBlockRpcClientError::SendTransaction(Box::new(rpc_error)),
166-
))
167-
}
168-
169-
#[test]
170-
fn is_transaction_too_large_matches_solana_format() {
171-
let err = make_send_transaction_error(TX_TOO_LARGE_SOLANA);
172-
assert!(err.is_transaction_too_large());
173-
}
174-
175-
#[test]
176-
fn is_transaction_too_large_matches_magicblock_format() {
177-
let err = make_send_transaction_error(TX_TOO_LARGE_MAGICBLOCK);
178-
assert!(err.is_transaction_too_large());
179-
}
180-
181-
#[test]
182-
fn transaction_too_large_error_is_recoverable_by_splitting() {
183-
let inner = make_send_transaction_error(TX_TOO_LARGE_MAGICBLOCK);
184-
let err = TransactionStrategyExecutionError::TransactionTooLargeError(inner);
185-
assert!(err.is_recoverable_by_splitting());
186-
}
143+
use crate::intent_executor::{
144+
strategy_executor::error::TransactionStrategyExecutionError,
145+
test_utils::send_transaction_error,
146+
};
187147

188148
#[test]
189149
fn transport_errors_are_transient() {
190-
let err = TransactionStrategyExecutionError::InternalError(make_send_transaction_error(
191-
"connection reset",
192-
));
150+
let err = TransactionStrategyExecutionError::InternalError(
151+
send_transaction_error("connection reset").into(),
152+
);
193153
assert!(err.is_transient());
194154

195155
let err = TransactionStrategyExecutionError::InternalError(
@@ -309,9 +269,9 @@ mod tests {
309269
use solana_signature::Signature;
310270

311271
let transient_err = || {
312-
TransactionStrategyExecutionError::InternalError(make_send_transaction_error(
313-
"connection reset",
314-
))
272+
TransactionStrategyExecutionError::InternalError(
273+
send_transaction_error("connection reset").into(),
274+
)
315275
};
316276

317277
let signature = Signature::new_unique();
@@ -336,16 +296,4 @@ mod tests {
336296
);
337297
}
338298
}
339-
340-
#[test]
341-
fn unrelated_internal_errors_do_not_trigger_splitting() {
342-
let err = TransactionStrategyExecutionError::InternalError(
343-
InternalError::MagicBlockRpcClientError(Box::new(
344-
MagicBlockRpcClientError::LookupTableDeserialize(
345-
solana_instruction::error::InstructionError::GenericError,
346-
),
347-
)),
348-
);
349-
assert!(!err.is_recoverable_by_splitting());
350-
}
351299
}

‎committor/src/intent_executor/intent_execution_client.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,37 @@ impl SendErrorMapper<MagicBlockRpcClientError> for DefaultGetErrorMapper {
276276
decide_rpc_error_flow(mapped_error)
277277
}
278278
}
279+
280+
#[cfg(test)]
281+
mod tests {
282+
use super::*;
283+
use crate::intent_executor::test_utils::send_transaction_error;
284+
285+
#[test]
286+
fn send_errors_preserve_split_recovery() {
287+
let mapper = IntentErrorMapper {
288+
transaction_error_mapper: IntentTransactionErrorMapper {
289+
tasks: &[],
290+
task_instruction_offset: 0,
291+
},
292+
};
293+
for (error, splits) in [
294+
(send_transaction_error("base64 encoded too large"), true),
295+
(
296+
send_transaction_error(
297+
"base64 encoded solana_transaction::versioned::VersionedTransaction too large: 1684 bytes (max: encoded/raw 1644/1232)",
298+
),
299+
true,
300+
),
301+
(send_transaction_error("connection reset"), false),
302+
(
303+
MagicBlockRpcClientError::LookupTableDeserialize(
304+
solana_instruction::error::InstructionError::GenericError,
305+
),
306+
false,
307+
),
308+
] {
309+
assert_eq!(mapper.map(error).is_recoverable_by_splitting(), splits);
310+
}
311+
}
312+
}

‎committor/src/intent_executor/strategy_executor/recovery.rs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,17 @@ impl<F: TaskInfoFetcher, A: ActionsCallbackScheduler> TransactionRecovery<'_, F,
9696
.iter()
9797
.any(|task| matches!(task, BaseTaskImpl::Undelegate(_))) =>
9898
{
99-
handle_undelegation_error(&self.authority.pubkey(), strategy)
99+
let mut removed = handle_undelegation_error(&self.authority.pubkey(), strategy);
100+
// Report only discarded actions; retained actions still need to execute.
101+
let callbacks = removed.extract_action_callbacks();
102+
if !callbacks.is_empty() {
103+
report.add_callback_report(self.callback_scheduler.schedule(
104+
callbacks,
105+
err.signature(),
106+
Err(ActionError::from(err)),
107+
));
108+
}
109+
removed
100110
}
101111
_ => return Ok(ControlFlow::Break(())),
102112
};

‎committor/src/intent_executor/strategy_executor/transaction.rs‎

Lines changed: 154 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,32 @@
1-
use std::{mem, time::Duration};
1+
use std::{mem, ops::ControlFlow, time::Duration};
22

33
use mbv_core::traits::{ActionError, ActionsCallbackScheduler};
44
use program::outbox::PendingTransaction;
55
use solana_pubkey::Pubkey;
6+
use solana_rpc_client::rpc_client::SerializableTransaction;
67
use solana_signature::Signature;
78
use solana_signer::Signer;
89
use tokio::time::timeout;
9-
use tracing::info;
10+
use tracing::{error, info, warn};
1011

1112
use super::{
1213
error::TransactionStrategyExecutionError,
1314
recovery::TransactionRecovery,
1415
utils::{
15-
ExecutionState, handle_actions_result, handle_cpi_limit_error, transaction_execution_loop,
16+
handle_actions_result, handle_cpi_limit_error, prepare_transaction, recovery_commit_nonces,
17+
resolve_pending_signature,
1618
},
1719
};
1820
use crate::{
1921
intent_executor::{
2022
ExecutionOutput, IntentExecutionReport, IntentExecutorCtx,
2123
error::{IntentExecutorError, IntentExecutorResult},
22-
progress::ExecutionProgress,
24+
progress::{ExecutionProgress, TransactionProgress},
25+
},
26+
outbox::{
27+
OutboxClient, outbox_intent_bundles_reader::OutboxIntentBundlesReader,
28+
progress::PendingExecution,
2329
},
24-
outbox::{OutboxClient, progress::PendingExecution},
2530
tasks::{
2631
BaseTaskImpl,
2732
task_info_fetcher::TaskInfoFetcher,
@@ -33,6 +38,8 @@ use crate::{
3338
#[cfg(test)]
3439
mod tests;
3540

41+
const TRANSACTION_LOOP_CEILING: u8 = 10;
42+
3643
/// Runs the selected transaction and any remaining actions or undelegations.
3744
/// A confirmed commit is never included in subsequent recovery attempts.
3845
pub(in crate::intent_executor) struct TransactionExecutor<'a, T, F, A, O> {
@@ -192,47 +199,149 @@ where
192199
let ctx = self.ctx;
193200
let confirmed_commit = self.report.progress.confirmed_commit;
194201
let has_follow_up = self.follow_up.is_some();
195-
let make_outbox_stage = |transaction| {
196-
PendingExecution {
197-
transaction,
198-
confirmed_commit,
199-
has_follow_up,
200-
}
201-
.into_stage()
202+
let recovery = TransactionRecovery {
203+
authority: &ctx.authority,
204+
intent_client: &ctx.intent_client,
205+
intent_id: self.intent_id,
206+
callback_scheduler: &ctx.actions_callback_executor,
207+
task_info_fetcher: &ctx.task_info_fetcher,
208+
committed_pubkeys: self.committed_pubkeys,
202209
};
203-
let state = ExecutionState {
204-
current_attempt: &mut self.current_attempt,
205-
transaction_strategy: &mut self.current,
206-
pending_transaction: &mut self.pending_transaction,
207-
execution_report: self.report,
208-
commit_signature: confirmed_commit,
209-
};
210-
transaction_execution_loop(
211-
&ctx.authority,
212-
&ctx.intent_client,
213-
ctx.outbox_client.as_ref(),
214-
&ctx.transaction_preparator,
215-
TransactionRecovery {
216-
authority: &ctx.authority,
217-
intent_client: &ctx.intent_client,
218-
intent_id: self.intent_id,
219-
callback_scheduler: &ctx.actions_callback_executor,
220-
task_info_fetcher: &ctx.task_info_fetcher,
221-
committed_pubkeys: self.committed_pubkeys,
222-
},
223-
self.intent_id,
224-
make_outbox_stage,
225-
|status| {
226-
PendingExecution::from_status(status)
227-
.filter(|pending| {
228-
pending.has_follow_up == has_follow_up
229-
&& pending.confirmed_commit.is_some() == confirmed_commit.is_some()
230-
})
231-
.map(|pending| pending.transaction)
232-
},
233-
state,
234-
)
235-
.await
210+
let mut reconciled_outbox = false;
211+
loop {
212+
if self.pending_transaction.is_none() && !reconciled_outbox {
213+
reconciled_outbox = true;
214+
self.pending_transaction = self.recover_pending_from_outbox().await;
215+
}
216+
217+
if let Some(ref pending) = self.pending_transaction {
218+
self.report.progress.transaction =
219+
TransactionProgress::Submitted(pending.signature);
220+
if resolve_pending_signature(&ctx.intent_client, pending).await? {
221+
self.report.progress.transaction =
222+
TransactionProgress::Confirmed(pending.signature);
223+
break Ok(Ok(pending.signature));
224+
}
225+
self.report.progress.transaction = TransactionProgress::Rejected(pending.signature);
226+
self.pending_transaction = None;
227+
}
228+
229+
// Resolve a previously sent transaction before skipping an empty follow-up.
230+
if let Some(commit_signature) = confirmed_commit
231+
&& self.current.optimized_tasks.is_empty()
232+
{
233+
return Ok(Ok(commit_signature));
234+
}
235+
236+
self.current_attempt += 1;
237+
238+
// Prepare message
239+
let prepared_transaction = prepare_transaction(
240+
&ctx.intent_client,
241+
&ctx.authority,
242+
&ctx.transaction_preparator,
243+
&mut self.current,
244+
)
245+
.await
246+
.map_err(IntentExecutorError::FailedPreparation)?;
247+
248+
// Record in outbox before sending
249+
let pending = PendingTransaction {
250+
signature: *prepared_transaction.get_signature(),
251+
blockhash: *prepared_transaction.get_recent_blockhash(),
252+
};
253+
self.report.progress.transaction = TransactionProgress::Prepared(pending.signature);
254+
let recovery_commit_nonces = recovery_commit_nonces(&self.current.optimized_tasks);
255+
ctx.outbox_client
256+
.set_intent_execution_stage(
257+
self.intent_id,
258+
PendingExecution {
259+
transaction: pending,
260+
confirmed_commit,
261+
has_follow_up,
262+
}
263+
.into_stage(),
264+
recovery_commit_nonces,
265+
)
266+
.await
267+
.map_err(Into::into)?;
268+
269+
// Now record locally signature of tx we about to send
270+
// Precaution for timeout in between
271+
self.pending_transaction = Some(pending);
272+
self.report.progress.transaction = TransactionProgress::Submitted(pending.signature);
273+
let signature = &pending.signature;
274+
275+
// Send signed tx
276+
let execution_result = ctx
277+
.intent_client
278+
.send_signed_tx_with_retries(&prepared_transaction, &self.current.optimized_tasks)
279+
.await;
280+
281+
// Result returned, cleanup pending signature
282+
self.pending_transaction = None;
283+
284+
let execution_err = match execution_result {
285+
Ok(()) => {
286+
self.report.progress.transaction = TransactionProgress::Confirmed(*signature);
287+
return Ok(Ok(*signature));
288+
}
289+
Err(err) => err,
290+
};
291+
if execution_err.is_rejected() {
292+
self.report.progress.transaction = TransactionProgress::Rejected(*signature);
293+
}
294+
let flow = recovery.recover(&execution_err, &mut self.current, self.report).await?;
295+
let cleanup = match flow {
296+
ControlFlow::Continue(value) => value,
297+
ControlFlow::Break(()) => return Ok(Err(execution_err)),
298+
};
299+
ctx.intent_client.invalidate_cached_blockhash().await;
300+
self.report.dispose(cleanup);
301+
302+
if let Some(commit_signature) = confirmed_commit
303+
&& self.current.optimized_tasks.is_empty()
304+
{
305+
// Failed actions can leave no work after a confirmed commit.
306+
// Keep undelegation failures visible even when patching removes all tasks.
307+
if matches!(
308+
execution_err,
309+
TransactionStrategyExecutionError::ActionsError(..)
310+
) {
311+
self.report.add_patched_error(execution_err);
312+
return Ok(Ok(commit_signature));
313+
}
314+
return Ok(Err(execution_err));
315+
}
316+
317+
if self.current_attempt >= TRANSACTION_LOOP_CEILING {
318+
error!("CRITICAL! Recursion ceiling reached");
319+
return Ok(Err(execution_err));
320+
} else {
321+
self.report.add_patched_error(execution_err);
322+
}
323+
}
324+
}
325+
326+
async fn recover_pending_from_outbox(&self) -> Option<PendingTransaction> {
327+
let reader = self.ctx.outbox_client.outbox_reader();
328+
match reader.fetch_outbox_intent(self.intent_id).await {
329+
Ok(Some(bundle)) => PendingExecution::from_status(bundle.status())
330+
.filter(|pending| {
331+
pending.has_follow_up == self.follow_up.is_some()
332+
&& pending.confirmed_commit.is_some()
333+
== self.report.progress.confirmed_commit.is_some()
334+
})
335+
.map(|pending| pending.transaction),
336+
Ok(None) => None,
337+
Err(_) => {
338+
warn!(
339+
intent_id = self.intent_id,
340+
"Failed to reconcile pending transaction from outbox"
341+
);
342+
None
343+
}
344+
}
236345
}
237346

238347
fn execute_callbacks(&mut self, signature: Option<Signature>, result: Result<(), ActionError>) {

0 commit comments

Comments
 (0)