Skip to content

fix(sandbox): prevent state patch loss during block processing#15536

Closed
r-near wants to merge 4 commits into
masterfrom
fix/sandbox-patch-state-generation-tracking
Closed

fix(sandbox): prevent state patch loss during block processing#15536
r-near wants to merge 4 commits into
masterfrom
fix/sandbox-patch-state-generation-tracking

Conversation

@r-near

@r-near r-near commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Summary

sandbox_patch_state sometimes permanently loses patches — the patched account never appears, even on retry. This has been a known pain point in near-workspaces-rs and near-sandbox-rs (both work around it by sending the patch twice).

Three interrelated bugs:

  • Patch lost on block failure: pending_state_patch.take() drains the queue at the start of block processing. If preprocessing fails (orphan, missing chunks, optimistic deferral), the patch is gone forever.
  • RPC returns too early: patch_state_in_progress() checks is_empty(), which is true right after take() — before the patch is actually committed to state. The RPC reports "done" while the patch is still in-flight.
  • Startup race: Block 1 after genesis has no new chunks. The runtime's apply() early-returns for old chunks and silently drops the state_patch passed to it.

Fix

  • Clone a backup of the patch before preprocessing; restore on any error path
  • Track patch completion with a generation counter (sandbox_patch_generation / sandbox_patch_committed_gen) that only advances after chain_update.commit(), so the RPC actually waits for the data to hit the DB
  • Skip take() when the block has no new chunks; defense-in-depth check in apply_chunks_preprocessing only assigns the patch to shards with a new chunk

Testing

Tested with near-sandbox-rs#51 regression test (3 sandboxes × 50 patch+query cycles). Before this fix, ~6% failure rate. After: 0 failures across 3000+ iterations.

Also tested with a targeted startup-race diagnostic (30 sequential sandbox starts, single patch each). Before: ~23% permanent loss. After: 0.

Related

sandbox_patch_state sometimes permanently drops patches. The patched
account never appears, even on retry. This affects ~30% of
first-patch-after-startup calls and occasionally hits mid-lifecycle
patches too.

Three interrelated bugs:

1. pending_state_patch.take() eagerly drains the patch queue when a
   block starts processing. If that block fails preprocessing (orphan,
   missing chunks, optimistic deferral), the patch is gone. Fix: clone
   a backup before preprocessing and restore it on any error.

2. patch_state_in_progress() checks is_empty(), which returns true
   immediately after take() — long before the patch is committed to
   state. The RPC returns "done" while the patch is still in-flight.
   Fix: generation counter (sandbox_patch_generation vs
   sandbox_patch_committed_gen) that only advances after DB commit.

3. At startup, block 1 has no new chunks. The runtime's apply()
   early-returns for old chunks and silently drops the state_patch.
   Fix: skip take() when the block has no new chunks, and only assign
   the patch to shards that actually have a new chunk.
@codecov

codecov Bot commented Apr 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.66667% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.25%. Comparing base (85847bb) to head (104063c).
⚠️ Report is 41 commits behind head on master.

Files with missing lines Patch % Lines
chain/chain/src/chain.rs 81.66% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #15536      +/-   ##
==========================================
- Coverage   69.27%   69.25%   -0.03%     
==========================================
  Files         937      937              
  Lines      210882   211094     +212     
  Branches   210882   211094     +212     
==========================================
+ Hits       146093   146196     +103     
- Misses      58983    59091     +108     
- Partials     5806     5807       +1     
Flag Coverage Δ
pytests-nightly 1.15% <0.00%> (-0.01%) ⬇️
unittests 68.63% <81.66%> (-0.03%) ⬇️
unittests-nightly 68.77% <81.66%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@r-near r-near marked this pull request as ready for review April 8, 2026 17:45
@r-near r-near requested a review from a team as a code owner April 8, 2026 17:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41d243287b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread chain/chain/src/chain.rs Outdated

Copilot AI 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.

Pull request overview

Fixes sandbox patch_state reliability by ensuring state patches aren’t dropped during block processing and by making the sandbox RPC status reflect DB-committed completion rather than “queue drained”.

Changes:

  • Avoid draining pending_state_patch for blocks with no new chunks (startup race), and only pass patches to shards with a new chunk.
  • Restore drained patches back into pending_state_patch when block preprocessing fails (orphan/missing chunks/optimistic deferral).
  • Add generation-based tracking (sandbox_patch_generation / sandbox_patch_committed_gen) so patch_state_in_progress() stays true until the patch’s block is committed.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
chain/chain/src/chain.rs Implements patch backup/restore on preprocess errors, avoids patch take on no-new-chunk blocks, and adds generation-based “in progress until commit” tracking.
chain/chain/src/block_processing_utils.rs Extends BlockPreprocessInfo to carry the sandbox patch generation through preprocessing into postprocessing/commit.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread chain/chain/src/chain.rs Outdated
Comment thread chain/chain/src/block_processing_utils.rs Outdated
Comment thread chain/chain/src/chain.rs
r-near added 2 commits April 8, 2026 11:04
- Early-return in patch_state() when patch is empty to avoid bumping
  the generation counter with nothing to commit (would cause
  patch_state_in_progress() to stay true forever)
- Update doc comment on BlockPreprocessInfo::sandbox_patch_generation
  to reflect actual usage
The generation counter fields and sandbox-specific logic paths (patch
backup/restore, generation tracking, is_new_chunk guard) are only
meaningful when the sandbox feature is enabled. Without cfg-gating,
these code paths are compiled but unreachable on non-sandbox builds,
causing codecov patch coverage failures since CI runs without the
sandbox feature.
@saketh-are saketh-are removed their request for review April 8, 2026 18:42
@shreyan-gupta

Copy link
Copy Markdown
Contributor

I'll take a look soon...

github-merge-queue Bot pushed a commit that referenced this pull request Apr 16, 2026
## Background

`sandbox_patch_state` sometimes permanently loses patches — the patched
account never appears, even on retry. This was reported in the [Zulip
thread](https://near.zulipchat.com/#narrow/channel/308695-nearone.2Fprivate/topic/Sandbox.20patch_state.20issues)
and reproduced with
[near-sandbox-rs#51](near/near-sandbox-rs#51).

Three interrelated bugs:

- **Patch lost on block failure**: `pending_state_patch.take()` drains
the queue at the start of block processing. If preprocessing fails
(orphan, missing chunks, optimistic deferral), the patch is gone
forever.
- **RPC returns too early**: `patch_state_in_progress()` checks
`is_empty()`, which is true right after `take()` — before the patch is
actually committed to state.
- **Startup race**: Block 1 after genesis has no new chunks. The
runtime's `apply()` early-returns for old chunks and silently drops the
`state_patch`.

## What changed

Introduces `SandboxPatchTracker` — a type that encapsulates the pending
patch, a generation counter, and a committed-generation counter. Like
`SandboxStatePatch`, it uses the ZST pattern: real struct on sandbox
builds, zero-sized no-op on non-sandbox builds. This replaces the three
separate fields on `Chain` (`pending_state_patch`,
`sandbox_patch_generation`, `sandbox_patch_committed_gen`) with a single
`sandbox_patches: SandboxPatchTracker`.

The key design change is **taking the patch late** — at point of use
inside the per-shard loop of `apply_chunks_preprocessing`, instead of at
the top of `start_process_block_impl`. This eliminates the need for
backup/clone/restore on error paths, since any error before the take
leaves the patch in the tracker. The generation counter (`in_progress()`
checks `generation != committed_gen`, not `is_empty()`) ensures the RPC
doesn't return prematurely despite the late take.

This removes the `state_patch` parameter from `preprocess_block` and
`apply_chunks_preprocessing`, and results in zero `#[cfg(feature =
"sandbox")]` blocks in `chain.rs`.

## Related

- #15536 — alternative fix by @r-near that addresses the same three bugs
with a backup/restore approach and 22 cfg-gates. This PR takes a
different approach (take-late + tracker) to achieve the same result with
less code deviation.
- #14893 — fixed a different patch loss path (`clear()` in
`postprocess_ready_block`)
@r-near

r-near commented Apr 16, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of #15534

@r-near r-near closed this Apr 16, 2026
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.

3 participants