nautechsystems/nautilus_trader · error

Replacement scan cursor regressed or changed

Error message

Replacement scan cursor regressed or changed

What it means

When an existing `execution_replacement_scan` row exists for the intent, the new cursor must strictly advance (number > stored_number) or, at the same number, carry the identical hash. Otherwise the scan is regressing or flip-flopping between headers at the same height, which breaks the monotonic progress invariant, so the update is rejected.

Source

Thrown at crates/adapters/blockchain/src/cache/database.rs:6333

            anyhow::ensure!(
                durable_hash == cursor.hash,
                "Replacement scan cursor conflicts with the finalized header ledger"
            );
            let existing = sqlx::query_as::<_, (i64, String)>(
                "
                SELECT finalized_cursor_number, finalized_cursor_hash
                FROM execution_replacement_scan
                WHERE intent_id = $1
                FOR UPDATE
                ",
            )
            .bind(scan.intent_id)
            .fetch_optional(&mut *transaction)
            .await
            .context("failed to lock replacement scan progress")?;

            if let Some((stored_number, stored_hash)) = existing {
                anyhow::ensure!(
                    number > stored_number
                        || (number == stored_number && cursor.hash == stored_hash),
                    "Replacement scan cursor regressed or changed"
                );
            }
            sqlx::query(
                "
                INSERT INTO execution_replacement_scan (
                    intent_id, chain_id, wallet_address, nonce,
                    finalized_cursor_number, finalized_cursor_hash, manifest_digest
                ) VALUES ($1, $2, $3, $4, $5, $6, $7)
                ON CONFLICT (intent_id) DO UPDATE SET
                    finalized_cursor_number = EXCLUDED.finalized_cursor_number,
                    finalized_cursor_hash = EXCLUDED.finalized_cursor_hash,
                    updated_at = NOW()
                ",
            )
            .bind(scan.intent_id)

View on GitHub (pinned to 18893faf8b)

Solutions

  1. Only submit scans whose cursor is >= the last persisted cursor; drop or skip stale scan snapshots.
  2. Serialize scan recording per intent (single worker or advisory lock) so cursors advance monotonically.
  3. If a reorg genuinely moved the head backward, clear the intent's replacement scan progress through the proper reorg path rather than re-submitting a lower cursor.

Example fix

// before: blind resubmission of an old snapshot
for snapshot in pending_snapshots { db.record_execution_replacement_scan(&snapshot).await?; }
// after: only advance
if snapshot.finalized_cursor.as_ref().unwrap().number >= last_recorded_cursor_number {
    db.record_execution_replacement_scan(&snapshot).await?;
}
Defensive patterns

Strategy: validation

Validate before calling

if let Some((stored_number, stored_hash)) = sqlx::query_as::<_, (i64, String)>("SELECT finalized_cursor_number, finalized_cursor_hash FROM execution_replacement_scan WHERE intent_id=$1").bind(scan.intent_id).fetch_optional(&pool).await? {
    let c = scan.finalized_cursor.as_ref().unwrap();
    if !(c.number as i64 > stored_number || (c.number as i64 == stored_number && c.hash == stored_hash)) {
        return Err(anyhow!("cursor would regress; skipping stale snapshot"));
    }
}

Type guard

fn cursor_advances(new: &ExecutionVerifiedHeader, stored_number: i64, stored_hash: &str) -> bool {
    new.number as i64 > stored_number || (new.number as i64 == stored_number && new.hash == stored_hash)
}

Prevention

When it happens

Trigger: Submitting a replacement scan whose `finalized_cursor.number` is lower than the stored `finalized_cursor_number`, or equal in number but with a different `hash` than the stored `finalized_cursor_hash`.

Common situations: Two scan workers processing headers out of order; a reorg caused the scanner to emit an older or alternative block; retrying an old scan snapshot after a newer one was already recorded.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of nautechsystems/nautilus_trader@18893faf8b (2026-09-08). Data as JSON: /api/errors/6fc4385249573b4b. Report an issue: GitHub.