Skip to content

fix: retry failed intents - #1501

Open
taco-paco wants to merge 7 commits into
masterfrom
hotfix/retry-failed-intents
Open

fix: retry failed intents#1501
taco-paco wants to merge 7 commits into
masterfrom
hotfix/retry-failed-intents

Conversation

@taco-paco

@taco-paco taco-paco commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Reintroduces retrying failed intents on restart (previously reverted after a bug where a replayed intent could apply stale state if the account had been undelegated/redelegated since the intent was originally scheduled).

Before replaying a failed intent, we now verify:

  • The account is still in the same delegation session it was in when the intent was built (hasn't been undelegated/redelegated since).
  • The commit hasn't already landed on chain.

Only intents that pass both checks are retried. Pending intents (still in-flight, most recent) are recovered as before, without these checks.

To support the session check, we now persist the base chain slot observed when an intent was scheduled - previously dropped on reload, defaulting to 0. Includes a DB migration for existing rows.

Note: intent execution durability is a question of a different PR and saga — tracked separately. Here we only try to replay intents that we can and that are safe to replay; everything else is covered by that larger effort.

With this in mind, the nonce comparison was relaxed so it covers both single-stage and two-stage execution. This could lead to an intent being executed twice, but that's fine since the resulting state is the same either way.

Breaking Changes

  • None
  • Yes — migration path described below

Test Plan

Summary by CodeRabbit

  • New Features

    • Improved intent recovery by loading both pending and failed/recoverable commit intents directly from persisted state.
    • Preserved per-account commit identifiers and remote slot data during recovery and rescheduling.
    • Strengthened rescheduling safeguards with delegation-session consistency checks and commit-nonce availability validation.
  • Bug Fixes

    • Automatically migrates existing databases to store and backfill remote_slot for older commit-status rows.
    • Ensures recovered account and commit data round-trip remote_slot correctly to prevent scheduling from stale sessions or reused nonces.

@taco-paco
taco-paco requested a review from thlorenz July 28, 2026 21:05
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a387c61f-78c6-4e12-8095-b7dbd7ad8e6f

📥 Commits

Reviewing files that changed from the base of the PR and between cdfd8d0 and 12d9041.

📒 Files selected for processing (3)
  • magicblock-committor-service/src/committor_processor.rs
  • magicblock-committor-service/src/persist/commit_persister.rs
  • magicblock-committor-service/src/service.rs

📝 Walkthrough

Walkthrough

Recovery persistence now stores and migrates remote_slot, queries failed commit statuses, and reconstructs RecoveredIntent values containing bundles and per-account commit IDs. CommittorProcessor loads these intents and exposes slot retrieval and bundle refreshing. Rescheduling filters intents using delegation-session and nonce checks before refreshing and scheduling eligible bundles. Tests cover migration, failed-status queries, remote-slot round trips, and recovered commit IDs.

Suggested reviewers: gabrielepicco, thlorenz

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hotfix/retry-failed-intents

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: 6

🤖 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-committor-service/src/persist/commit_persister.rs`:
- Around line 814-838: Extend the recoverable_intents test coverage around
recoverable_intents and
test_recoverable_intents_pairs_bundle_with_its_commit_ids by inserting
FailedFinalize or FailedProcess rows, including a mixed pending-and-failed
bundle. Assert the failed row is included in the recovered bundle and that its
commit ID is present in RecoveredIntent.commit_ids alongside the pending rows.
- Line 177: Replace the expect-based lock acquisition in the recovery path with
recoverable error handling: map a poisoned `commits_db` mutex lock into the
existing `CommitPersistError` and propagate it from the enclosing method. Follow
the surrounding pre-existing mutex error-mapping pattern and preserve normal
lock usage when acquisition succeeds.
- Around line 173-184: Extract the shared “lock commits_db, load pending
statuses, extend with failed statuses, then release the lock” sequence from
recoverable_intents and pending_intent_bundles into a single helper for loading
recoverable statuses. Update both methods to use that helper while retaining
their existing distinct conversion functions, and remove
pending_rows_to_scheduled_intent_bundles if it is no longer used outside tests.

In `@magicblock-committor-service/src/persist/db.rs`:
- Around line 618-645: Update get_failed_commit_statuses to derive both
failed-status query parameters from CommitStatus::FailedFinalize(...).as_str()
and CommitStatus::FailedProcess(None).as_str() instead of hard-coded string
literals, preserving the existing query and filtering behavior.

In `@magicblock-committor-service/src/service.rs`:
- Around line 388-497: Add unit tests for the replay-safety gates
is_same_delegation_session and is_valid_nonce using stubbed chainlink and
task-info-fetcher dependencies. Cover same-session acceptance,
redelegated-session rejection, equal nonce rejection, greater nonce acceptance,
and missing current-nonce rejection, including the commit_id == 0 boundary where
applicable.
- Around line 492-496: Update the validity predicate in the recovered-intent
flow around recovered.commit_ids so commit_id == 0 is treated as valid for
pending-intent recovery instead of compared against current_nonces. Preserve the
existing commit_id > current_nonce validation for assigned nonzero commit IDs,
and keep the pubkey lookup behavior unchanged.
🪄 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 Plus

Run ID: f92bfc66-fc09-4e98-9f1b-d2e2bd9af4bf

📥 Commits

Reviewing files that changed from the base of the PR and between 26e5844 and 1206ca7.

📒 Files selected for processing (5)
  • magicblock-committor-service/src/committor_processor.rs
  • magicblock-committor-service/src/persist/commit_persister.rs
  • magicblock-committor-service/src/persist/db.rs
  • magicblock-committor-service/src/persist/mod.rs
  • magicblock-committor-service/src/service.rs

Comment thread magicblock-committor-service/src/persist/commit_persister.rs
Comment thread magicblock-committor-service/src/persist/commit_persister.rs Outdated
Comment thread magicblock-committor-service/src/persist/commit_persister.rs
Comment thread magicblock-committor-service/src/persist/db.rs
Comment on lines +388 to +497
/// Filters recovered intents down to the ones still safe to replay,
/// checking up to `JOIN_CHUNK_SIZE` at a time in parallel.
async fn retain_recoverable_intents(
&self,
bundles: &mut Vec<ScheduledIntentBundle>,
) {
let results = join_all(
bundles.iter().map(|b| b.get_all_committed_pubkeys()).map(
|pubkeys| async move {
#[allow(deprecated)]
let result = self
.chainlink
.accounts_delegated_on_base_and_er(
&pubkeys,
AccountFetchContext::internal(
AccountFetchReason::RequestedAccount,
),
)
.await;
result
},
),
)
.await;

let mut results_iter = results.into_iter();
bundles.retain(|bundle| {
let Some(result) = results_iter.next() else {
error!("Results and bundles must have equal length");
return false;
};
match result {
Ok(delegated) if delegated.iter().all(|d| *d) => true,
Ok(_) => {
warn!(
intent_id = bundle.id,
"Skipping recovered commit intent because not all accounts are delegated on base and ER"
);
false
}
Err(err) => {
error!(
intent_id = bundle.id,
error = ?err,
"Failed to verify recovered commit intent accounts"
);
false
}
}
recovered: Vec<RecoveredIntent>,
) -> Vec<ScheduledIntentBundle> {
const JOIN_CHUNK_SIZE: usize = 10;

let mut to_keep = Vec::with_capacity(recovered.len());
for chunk in recovered.chunks(JOIN_CHUNK_SIZE) {
let iter = chunk.iter().map(|recovered| async move {
self.is_intent_retriable(recovered).await
});
to_keep.extend(join_all(iter).await);
}

recovered
.into_iter()
.enumerate()
.filter(|(i, _)| to_keep[*i])
.map(|(_, el)| el.bundle)
.collect()
}

/// An intent is retriable only if its delegation session is unchanged
/// and its commit nonce hasn't already landed on chain.
async fn is_intent_retriable(&self, recovered: &RecoveredIntent) -> bool {
let pubkeys = recovered.bundle.get_all_committed_pubkeys();
let result = self
.is_same_delegation_session(&pubkeys, recovered)
.await
.inspect_err(|err| {
error!(intent_id = recovered.bundle.id, error = ?err, "Failed to check delegation session for recovery");
})
.unwrap_or(false);

if !result {
false
} else {
self.is_valid_nonce(&pubkeys, recovered)
.await
.inspect_err(|err| {
error!(intent_id = recovered.bundle.id, error = ?err, "Failed to check commit nonce for recovery");
})
.unwrap_or(false)
}
}

/// True if every committed account is still delegated (or undelegating)
/// at the same `remote_slot` recorded when the intent was scheduled.
async fn is_same_delegation_session(
&self,
pubkeys: &[Pubkey],
recovered: &RecoveredIntent,
) -> ChainlinkResult<bool> {
let recovered_accounts = recovered.bundle.get_all_committed_accounts();
let current_accounts = self
.chainlink
.fetch_accounts(
pubkeys,
AccountFetchContext::internal(
AccountFetchReason::RequestedAccount,
),
)
.await?
.into_iter()
.collect::<Option<Vec<_>>>();
let Some(current_accounts) = current_accounts else {
return Ok(false);
};
if current_accounts.len() != pubkeys.len() {
return Ok(false);
}

let same_session = recovered_accounts
.into_iter()
.zip(current_accounts)
.all(|(recovered, actual)| {
// 1. If account delegated using remote_slot we ensure
// that account is within same delegation "session" and wasn't redelegated
// 2. We handle `undelegating` state as well because undelegation could fail
// If that is the case we want to retry undelegate intent
// We need to make sure that is within same session as well
// NOTE: here we rely `validate_not_delegated` & `should_refresh_undelegating_in_bank_account`
// If account delegated - remote_slot doesn't change
// If account undelegating - until it is remote_slot doesn't change
(actual.delegated() || actual.undelegating())
&& recovered.remote_slot == actual.remote_slot()
});
Ok(same_session)
}

/// False if any committed account's persisted `commit_id` has already
/// been consumed on chain, or couldn't be verified.
async fn is_valid_nonce(
&self,
pubkeys: &[Pubkey],
recovered: &RecoveredIntent,
) -> TaskInfoFetcherResult<bool> {
let current_nonces = self
.processor
.fetch_current_commit_nonces(pubkeys, 0)
.await?;

let valid = recovered.commit_ids.iter().all(|(pubkey, commit_id)| {
current_nonces
.get(pubkey)
.is_some_and(|current_nonce| commit_id > current_nonce)
});
Ok(valid)

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No tests cover the new replay-safety filters.

is_same_delegation_session and is_valid_nonce are the gates that prevent re-committing an intent into a new delegation session or double-committing an already-landed nonce, yet neither has coverage. The nonce boundary in particular (commit_id == current_nonce, commit_id == 0, pubkey absent from current_nonces) is exactly where an off-by-one silently becomes a duplicate commit.

Worth adding unit tests with a stubbed chainlink/task-info-fetcher for: same-session accept, redelegated-session reject, nonce-equal reject, nonce-greater accept, and missing-nonce reject.

🤖 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-committor-service/src/service.rs` around lines 388 - 497, Add unit
tests for the replay-safety gates is_same_delegation_session and is_valid_nonce
using stubbed chainlink and task-info-fetcher dependencies. Cover same-session
acceptance, redelegated-session rejection, equal nonce rejection, greater nonce
acceptance, and missing current-nonce rejection, including the commit_id == 0
boundary where applicable.

Comment thread magicblock-committor-service/src/service.rs

@thlorenz thlorenz left a comment

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.

Aside from the remaining coderabbit issues I found a few issues as well.

The recovery path has two blocking safety and durability issues: it can approve stale local delegated state after an offline re-delegation, and the schema migration makes all legacy pending intents unrecoverable.

Failed-finalize rows also cannot pass the new nonce gate, and nonce validation uses an unfenced RPC read that is then cached. These issues conflict with the base-layer synchronization, restart durability, and commit lifecycle invariants.

let recovered_accounts = recovered.bundle.get_all_committed_accounts();
let current_accounts = self
.chainlink
.fetch_accounts(

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.

This does not necessarily fetch current base-layer state.
For a delegated, non-undelegating account already in the bank, chainlink treats the local copy as a hit and skips the remote fetch.
Startup also preserves delegated bank accounts.
After an offline undelegate/redelegate cycle, this can therefore compare the persisted slot with the same stale local slot and approve replay into a new delegation session.
If the nonce reset, the old account state can then be committed into that new session.
Please verify the session against current base-layer delegation metadata rather than the protected local account copy.

@taco-paco taco-paco Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For a delegated, non-undelegating account already in the bank, chainlink treats the local copy as a hit and skips the remote fetch.

That what we want. If it is in the bank and delegated it is owned by ER and remote_slot allows us to check delegation "session". If intent.remote_slot == current.remote_slot it is same session. If not(higher likely) it means account was undelegated and then delegated.

After an offline undelegate/redelegate cycle, this can therefore compare the persisted slot with the same stale local slot and approve replay into a new delegation session.

  1. If account is delegated it's remote_slot is not stale.
  2. It can't approve replay into new session as we compare remote_slots and distinguish sessions using it

For !delegated or undelegatingchainlink will fetch new data if as we want.

if has_column {
return Ok(());
}
self.conn.execute_batch(

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.

Every pre-upgrade pending row is backfilled with remote_slot = 0, but recovery later requires exact equality with the current account's normally nonzero slot.
That silently drops all pending settlement work present during an upgrade, while the previous implementation would recover it after checking delegation.
If this is not acceptable then provide a safe legacy reconciliation path, or record an explicit durable failure outcome instead of making these rows unrecoverable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We can't replay intents we're not sure about.
Atm we have:

  1. No failed intents on nodes
  2. Failed intents can be resolved via script and then ignoreing here is safe

All new failed intent until Outbox is merged will have correctly identified remote_slot

.fetch_current_commit_nonces(pubkeys, 0)
.await?;

let valid = recovered.commit_ids.iter().all(|(pubkey, commit_id)| {

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.

A FailedFinalize row is written only after the commit stage returned a commit signature, so its persisted commit ID has normally already landed and equals the current on-chain nonce.
Such a row can never pass this predicate, even though the new query explicitly loads FailedFinalize for retry.
Retrying the full commit would also be wrong after that stage landed.

We need to distinguish this state and resume only the remaining finalize work, or exclude it from the claimed recovery set with an explicit durable outcome.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note, intent execution durability is question of a different PR and saga here. Here we try to replay intents that we can and that are safe to do. Everything else will be covered in big PR mentioned above.

With this said, relaxed nonce comparison so it would cover both SingleStage and TwoStage. That could lead to intent executed twice but it is fine as states are the same

pubkeys: &[Pubkey],
recovered: &RecoveredIntent,
) -> TaskInfoFetcherResult<bool> {
let current_nonces = self

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.

Using min_context_slot = 0 allows a lagging RPC response to pass this replay gate.
The returned nonce is cached, so the later bundle refresh does not make task execution refetch it at the newer account slot.
That can seed the retry with a stale nonce and produce another avoidable failure. Please fence this read at the verified base-layer slot and preserve that freshness through execution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
magicblock-committor-service/src/service.rs (1)

431-444: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not permanently discard intents when replay validation is indeterminate.

Line 434 converts transient delegation/nonce lookup failures into false; the bundle is then dropped, and recovery runs only once before the accept loop. Preserve invalid intents as rejected, but propagate or retry indeterminate checks with backoff so RPC failures do not disable recovery until restart.

🤖 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-committor-service/src/service.rs` around lines 431 - 444, Update
the recovery validation flow around the delegation check and self.is_valid_nonce
so transient lookup errors are not converted to false and permanently discarded.
Distinguish definitively invalid intents from indeterminate RPC failures, and
propagate or retry the latter with backoff so recovery can continue after
temporary failures; keep valid intents accepted and invalid intents rejected.
🤖 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.

Outside diff comments:
In `@magicblock-committor-service/src/service.rs`:
- Around line 431-444: Update the recovery validation flow around the delegation
check and self.is_valid_nonce so transient lookup errors are not converted to
false and permanently discarded. Distinguish definitively invalid intents from
indeterminate RPC failures, and propagate or retry the latter with backoff so
recovery can continue after temporary failures; keep valid intents accepted and
invalid intents rejected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c82b94b2-fdb8-4e04-8b11-875348cf6840

📥 Commits

Reviewing files that changed from the base of the PR and between 1206ca7 and cdfd8d0.

📒 Files selected for processing (2)
  • magicblock-committor-service/src/committor_processor.rs
  • magicblock-committor-service/src/service.rs

@taco-paco

Copy link
Copy Markdown
Contributor Author

@thlorenz
Note: durability is not a purpose of this PR and is barely achievable by current Persister architecture. Durability is a separate ongoing saga that is implemented this PR. Here we just try to retry intents that we 100% sure about. Durability is desirable but is out of scope of this PR

it can approve stale local delegated state after an offline re-delegation

Delegated accounts can't be re-delegated offline as they have to be undelegated first. More detailed answer here.

and the schema migration makes all legacy pending intents unrecoverable.

Legacy intents will be addressed via script and post migration will be recovered via current flow.

nonce validation uses an unfenced RPC read that is then cached

Fixed

Failed-finalize rows also cannot pass the new nonce gate

Majority of intents are executed in 1 tx for which this isn't true. For example on devnet-asia: 143 single stage intents an 0 are executed in 2 txs. Tho I will think more on how that can be addressed

@taco-paco
taco-paco marked this pull request as ready for review July 29, 2026 16:57
@taco-paco
taco-paco requested a review from GabrielePicco July 29, 2026 16:57
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.

2 participants