{"record":{"id":"6fc4385249573b4b","repo":"nautechsystems/nautilus_trader","slug":"replacement-scan-cursor-regressed-or-changed","errorCode":null,"errorMessage":"Replacement scan cursor regressed or changed","messagePattern":"Replacement scan cursor regressed or changed","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/adapters/blockchain/src/cache/database.rs","lineNumber":6333,"sourceCode":"            anyhow::ensure!(\n                durable_hash == cursor.hash,\n                \"Replacement scan cursor conflicts with the finalized header ledger\"\n            );\n            let existing = sqlx::query_as::<_, (i64, String)>(\n                \"\n                SELECT finalized_cursor_number, finalized_cursor_hash\n                FROM execution_replacement_scan\n                WHERE intent_id = $1\n                FOR UPDATE\n                \",\n            )\n            .bind(scan.intent_id)\n            .fetch_optional(&mut *transaction)\n            .await\n            .context(\"failed to lock replacement scan progress\")?;\n\n            if let Some((stored_number, stored_hash)) = existing {\n                anyhow::ensure!(\n                    number > stored_number\n                        || (number == stored_number && cursor.hash == stored_hash),\n                    \"Replacement scan cursor regressed or changed\"\n                );\n            }\n            sqlx::query(\n                \"\n                INSERT INTO execution_replacement_scan (\n                    intent_id, chain_id, wallet_address, nonce,\n                    finalized_cursor_number, finalized_cursor_hash, manifest_digest\n                ) VALUES ($1, $2, $3, $4, $5, $6, $7)\n                ON CONFLICT (intent_id) DO UPDATE SET\n                    finalized_cursor_number = EXCLUDED.finalized_cursor_number,\n                    finalized_cursor_hash = EXCLUDED.finalized_cursor_hash,\n                    updated_at = NOW()\n                \",\n            )\n            .bind(scan.intent_id)","sourceCodeStart":6315,"sourceCodeEnd":6351,"githubUrl":"https://github.com/nautechsystems/nautilus_trader/blob/18893faf8b356be3320add8de2f861b0b647cf06/crates/adapters/blockchain/src/cache/database.rs#L6315-L6351","documentation":"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.","triggerScenarios":"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`.","commonSituations":"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.","solutions":["Only submit scans whose cursor is >= the last persisted cursor; drop or skip stale scan snapshots.","Serialize scan recording per intent (single worker or advisory lock) so cursors advance monotonically.","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."],"exampleFix":"// before: blind resubmission of an old snapshot\nfor snapshot in pending_snapshots { db.record_execution_replacement_scan(&snapshot).await?; }\n// after: only advance\nif snapshot.finalized_cursor.as_ref().unwrap().number >= last_recorded_cursor_number {\n    db.record_execution_replacement_scan(&snapshot).await?;\n}","handlingStrategy":"validation","validationCode":"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? {\n    let c = scan.finalized_cursor.as_ref().unwrap();\n    if !(c.number as i64 > stored_number || (c.number as i64 == stored_number && c.hash == stored_hash)) {\n        return Err(anyhow!(\"cursor would regress; skipping stale snapshot\"));\n    }\n}","typeGuard":"fn cursor_advances(new: &ExecutionVerifiedHeader, stored_number: i64, stored_hash: &str) -> bool {\n    new.number as i64 > stored_number || (new.number as i64 == stored_number && new.hash == stored_hash)\n}","tryCatchPattern":null,"preventionTips":["Queue scan snapshots per intent and drop any whose cursor is older than the last recorded one.","Use one scanner worker per intent (or an advisory lock) to keep cursor progress monotonic.","After a reorg, clear progress through the reorg path instead of re-submitting lower cursors."],"tags":["database","consistency","state-machine"],"backgroundTag":"invalid-state-transition","analyzedSha":"18893faf8b356be3320add8de2f861b0b647cf06","analyzedAt":"2026-09-08T20:49:34.690Z","contentChangedAt":"2026-09-08T20:49:34.690Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}