Repository navigation
fix(consensus): finalize transaction-local accepted state - #1796
MuncleUscles wants to merge 8 commits into
Conversation
Persist each successful acceptance's post-state alongside its rollback snapshot, then reload only that post-state during finalization. Verify receipt hashes and preserve agreed decisions when legacy state cannot be recovered safely, rather than promoting a younger accepted head or canceling the older transaction. Cover overlap, deployments, failed and successful appeals, recomputation, empty state, execution errors, and legacy rows through the real PostgreSQL worker path. No schema migration is required. Rollout must drain or explicitly recover pre-fix in-flight decisions that lack verifiable state. Validation: 1690 unit tests passed (repository-recommended RPC manager exclusion); 244 database tests passed, 1 existing xfail; changed-file pre-commit checks passed. Full stack E2E not run. Local branch only pending release-plan confirmation.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
Reviewed the upgrade caveat. The fix itself looks correct: I found no path that writes a wrong finalized state, and the new unit tests pass ( Finding: one unrecoverable legacy row can stall finalization on every contractA transaction accepted by the old code has no
No wrong state is written (fail-closed), but on stg/prd one busy contract with two writes near the deploy is enough, and recovery needs manual DB work. Suggested fixes1. Successor pre-state fallback (exact, cheap). When B executed on top of A, B's rollback pre-state equals A's post-state. Before raising, look for a younger transaction on the same contract whose saved pre-state hashes to the receipt hash: # transactions_processor.py
def find_successor_pre_state(
self, to_address: str, queue_order: int, expected_hash: str
) -> dict | None:
rows = self.session.execute(
text(
"SELECT contract_snapshot->'states'->'accepted' FROM transactions "
"WHERE to_address = :addr AND queue_order > :q "
"AND contract_snapshot IS NOT NULL ORDER BY queue_order"
),
{"addr": to_address, "q": queue_order},
).scalars()
for state in rows:
if state is not None and _compute_contract_state_hash(state) == expected_hash:
return state
return Noneand in state = context.transactions_processor.find_successor_pre_state(
context.transaction.to_address,
context.transaction.queue_order,
expected_hash,
)
if state is not None:
return stateThe hash check means any match is exactly A's result, so this cannot introduce wrong state. A DB test with legacy A + accepted B (no 2. Bound whatever is still unrecoverable. Pick one:
Also log the missing-state case once (or rate-limited) instead of a stack trace roughly every 40s per worker. 3. Deploy plan, needed with or without the code changes.
Minor: every successful accept now stores both the pre- and post-state in With (1) plus either (2) or the deploy plan in (3), I'm happy for this to go into the v0.123 release candidate. |
… contracts A finalization head in retry backoff was claimed and released every poll, so the oldest stuck head starved every other contract's finalization. claim_next_finalization now takes the worker's backed-off hashes and skips contracts whose head (or an older non-terminal tx) is backing off, while the existing ordering guard still blocks younger same-contract txs. Retry tracking is cleared once a finalization succeeds.
…states Rows accepted before transaction-local finalization state was persisted can only finalize when the moving contract head still hashes to their receipt. Once a successor is accepted the head moves and the row is stuck. A successor's saved pre-state is exactly the predecessor's post-state when nothing intervened, so after the existing sources fail the resolver now tries the transaction's own pre-state (unchanged-state writes) and up to three successor pre-states, accepting only one whose hash matches the receipt's contract_state_hash. Anything else still raises FinalizationStateError without canceling. Adds a read-only finalization_legacy_report CLI that classifies pending legacy rows with the same resolver before deploy.
…pplies A type 3 upgrade used to swap the code slot while earlier decided transactions on the same contract were still inside their appeal window. Those rows then finalized, or were re-executed by an appeal, against code they were never decided on, and their paid appeals stayed open across the code change. The upgrade now closes the contract's earlier lifecycle before touching code: - It defers while any other row on the contract holds a lease or is mid-consensus, so it never races live work (UPGRADE_DEFER_SECONDS). - Every ACCEPTED+SUCCESS predecessor must resolve a verified finalization state up front. If one cannot, the upgrade is canceled with predecessor_finalization_state and nothing is changed. - Each decided predecessor (ACCEPTED, UNDETERMINED, *_TIMEOUT) is force-finalized through the normal path: acceptance delivery repair, messages, fee settlement and value refunds. A pending paid appeal is refunded first via abort_pending_appeal_admission, which only aborts an admission with no recorded outcome. - Progress (closed rows, refunds) is persisted on the upgrade row in the same commit as each refund, and that write also renews the lease. A retry skips rows that are already final and can never refund twice. - The code is applied last, in one commit with the FINALIZED status, after rechecking ownership and that no predecessor reopened. - Never-started PENDING rows stay queued and run on the new code. Validation and contract errors cancel the upgrade as before. Transient failures go through the generic retry path, and rows that are already finalized stay final. Co-Authored-By: Claude <noreply@anthropic.com>
…cycle Force-finalizing predecessors only holds if nothing can reopen them afterwards. Before this change, an appeal could still be admitted while the upgrade was queued. A successful appeal's rewind also reset every newer row on the contract to PENDING, including FINALIZED and CANCELED rows and the upgrade itself. That replayed an applied upgrade and resurrected terminal transactions. - Appeal admission is rejected with CanNotAppeal when a PENDING or FINALIZED upgrade is queued after the decision. The check runs in the RPC precheck and again under the row lock in admit_transaction_appeal. - Rewinds only reset rows that can still be replayed (get_rewindable_newer_transactions). Terminal rows and upgrades are never reset, and rollback_transactions guards this again in memory. Upgrades applied before this change could overtake decided rows that are still open. When such an older row finalizes, or a validator appeal on it restores its pre-state, the code of the latest FINALIZED upgrade after it is kept (with_later_upgrade_code). Without this, the old code would come back. The legacy finalization report now also lists these rows as "interleaved upgrade (<hash>)" so operators can see them before deploying. Co-Authored-By: Claude <noreply@anthropic.com>
`type != UPGRADE_CONTRACT` evaluates to NULL for rows with no type, so the rewind query silently dropped them. Treat a NULL type as rewindable. Validation: unit 1731 passed (known env-only test_studio_fees failure), PostgreSQL suite 268 passed, 1 xfailed. Co-Authored-By: Claude <noreply@anthropic.com>
Problem and outcome
Finalizing transaction A currently copies the contract's latest accepted head. If B has already been accepted on the same contract while its appeal window is still open, finalizing A incorrectly publishes B's state as finalized.
Persist each successful decision's own post-state and finalize only that state, independently of younger accepted transactions.
Follow-up commits implement the maintainer plan from review: contract upgrades close the earlier lifecycle, legacy rows recover from successor pre-states, and backed-off finalization heads no longer starve other contracts.
Delivery context
v0.123-dev; merged withv0.123-devat9e907cd2(includes fix(genvm): serve latest-finalized storage reads from finalized state 🐛 #1792 and the chore(release): v0.123 RC fixes (#1765, #1715, #1773, #1789) #1797 RC fixes) ind3e52332, no conflicts.Implementation
Transaction-local finalization state (original change)
finalization_statetogether in the existing transaction snapshot JSON.Backoff without starvation (
84dea930)claim_next_finalizationskips contracts whose head is inside a retry backoff window instead of re-claiming the same blocked oldest candidate. Same-contract order is preserved: a backed-off row holds back itself and every younger row on its contract.Legacy recovery from successor pre-states (
7a394045)resolve_finalization_statekeeps the existing hash-verified fallbacks and additionally tries the pre-state snapshots of up to 3 younger same-contract transactions, accepting one only when its hash matches the receipt'scontract_state_hash. No unchecked latest-head fallback and no automatic cancellation.python -m backend.database_handler.finalization_legacy_report [--limit N]lists decided rows and how each would recover, or why it is UNRECOVERABLE (no hash, no matching snapshot, contract missing, interleaved upgrade).Upgrades close the earlier lifecycle (
4713ec3b,b2fc9eea)Scheduling is unchanged (upgrades keep priority and wait for current same-contract work). Processing an
UPGRADE_CONTRACTtransaction now:predecessor_finalization_state) before anything is touched.abort_pending_appeal_admission, guarded so resolved bonds are never refunded), atomically with a progress record and lease renewal on the upgrade row, so retries never refund or record twice.closed_transactions,refunded_appealsandapplied_at.Never-started transactions stay queued and run on the new code. Failures after the preflight are transient and retried with backoff; predecessors already finalized stay final.
Appeals and rollback:
CanNotAppealfor a decision with a PENDING or FINALIZED upgrade queued after it (RPC precheck and again under the row lock).get_rewindable_newer_transactions,rollback_transactions) never reset FINALIZED, CANCELED or upgrade rows, so an older appeal can no longer restore old code or reopen an upgrade.Validation
Executed locally on the review head:
test_studio_fees.py::test_leader_timeout_appeal_uses_next_normal_fee_schedule) that also fails on the base in this environment and passes in a clean export.New regression coverage includes: backed-off heads not starving other contracts while preserving same-contract order; successor pre-state recovery and the legacy report; upgrade force-finalizing ACCEPTED, UNDETERMINED and timeout predecessors with fee settlement; never-started rows running on the new code; paid appeal refunded (nothing forfeited); appeal admission blocked by queued and applied upgrades; validator appeal after an upgrade not restoring old code; CANCELED rows not resurrected; unresolvable predecessor canceling the upgrade untouched; crash and retry without double refund or settlement; deferral while a stale-leased row exists; legacy interleaving; upgrade claim priority.
Full E2E on this head is requested separately.
Upgrade and rollback caveats
No schema migration, SDK interface change or dependency pin change. Additional state lives in existing transaction JSON.
Rollout:
finalization_legacy_reportagainst the target database. Resolve any UNRECOVERABLE rows explicitly: they deliberately remain blocked, with no finalization side effects.Known limits:
User-facing release note
Finalized contract reads no longer expose the state of a newer transaction whose finalization/appeal period has not completed. Contract upgrades now finalize earlier decided transactions (refunding pending appeals) before the new code applies, and can no longer be undone by an older appeal.