erigontech / erigontech/erigon
execution/state: collapse BlockStateCache write-buffer into direct SharedDomains writes
- Dominant language
- Go
- Stars
- 3.6k
- Forks
- 1.5k
- Avg merge
- 1d 16h
- Merged PRs (30d)
- 465
Description
## Context
`BlockStateCache` (in [`execution/state/rw_v3.go`](execution/state/rw_v3.go#L1045)) buffers per-tx writes into a `writeLog` and replays them into `SharedDomains` at block-end via `Flush()`. The current `applyVersionedWrites` ([rw_v3.go:82](execution/state/rw_v3.go#L82)) routes per-tx writes into the cache when `blockCache != nil`, otherwise calls `DomainPut`/`DomainDel` directly.
## Observation
The write-side buffering appears redundant given the parallel-exec model:
- `applyVersionedWrites` is invoked **per tx-commit**, single-threaded — parallel-exec workers execute speculatively but commits are serialized in tx-order. So when `applyVersionedWrites` fires for tx N, tx N-1 has already finished applying. No concurrency hazard on `sd.mem`.
- Each write already carries its per-tx `txNum`, and `DomainPut(domain, tx, k, v, txNum, nil)` accepts `txNum` as a parameter. Domain history would be stamped at the same `txNum`s whether writes are direct or replayed from the writeLog.
The justification at [rw_v3.go:154](execution/state/rw_v3.go#L154) — "a direct domain delete (applied immediately, before the block-end Flush replays the earlier put) would be overwritten by that replay" — is the cache solving a problem the cache itself creates. Without the cache, three direct `DomainDel` / `DomainPut` calls in ascending `txNum` order produce the same final state and same domain history as the writeLog replay.
The system-call write path tested by `TestSystemCallStoragePropagation_BlockStateCache` ([execution/state/system_call_storage_test.go:78](execution/state/system_call_storage_test.go#L78)) similarly carries its own `txNum`; direct `DomainPut` should be equivalent.
## Proposed cleanup
1. In `applyVersionedWrites`, drop the `if blockCache != nil` branch that routes writes into the cache. Always call `DomainPut` / `DomainDel` / `DomainDelPrefix` directly with the per-tx `txNum`.
2. Audit `BlockStateCache.Write*` / `DeleteAccount` / `Flush` for any remaining callers; route them similarly.
3. Drop `BlockStateCache.currentAccounts` / `currentStorage` / `currentCode` / `writeLog` once unused.
4. Read-side (`committedAccounts`/`committedStorage`) is a separate concern — leave for a follow-up that also drops the read shadow (since the SD-getter at [`db/state/execctx/domain_shared.go:884`](db/state/execctx/domain_shared.go#L884) already consults `cache.StateCache.Get`, the per-block read shadow duplicates the global cache).
## Test gate
Must pass:
- `TestDeleteRecreateSlotsAcrossManyBlocks` (destruct → resurrect → destruct in one block) — the case the existing comment cites.
- `TestSystemCallStoragePropagation_BlockStateCache` — system-call writes visible to subsequent txs.
- `TestLightCollectorNoncePreservation*` — per-tx field-overlay semantics.
- Full `execution/state`, `execution/execmodule`, `execution/engineapi`, `execution/stagedsync` suites.
If any of those break, the failure mode reveals what invariant the writeLog deferral was actually preserving (e.g. parallel-exec snapshotting / cross-tx visibility ordering not visible from the current code shape).
## Why parked
This is a cleanup, not a perf change. The current shape works; collapsing it has potential for non-obvious side effects (parallel-exec read consistency, cross-block flush ordering interacting with commitment / BAL building, etc.) that need dedicated investigation. Pick up alongside the broader BlockStateCache decommission and the versionMap-as-single-write-store unification (cf. [project_unify_versionmap_sd_storage](https://github.com/erigontech/erigon-documents/blob/main/erigon3/perf/) memory notes in the perf working stream).
## Related
- Reads-side equivalent: `BlockStateCache.committedAccounts` / `committedStorage` shadow `cache.StateCache.AccountsDomain` / `StorageDomain` — same architectural concern; can be removed once `CachedReaderV3` is reduced to passing through to `ReaderV3` (which already hits `cache.StateCache` via the SD-getter).
- Frame-local opcode-level caches (slot cache, code cache) are separate: per-`CallContext`, inside the EVM dispatch loop. They stay.
Contributor guide
Research direction
Start in execution/state/rw_v3.go at applyVersionedWrites and audit BlockStateCache.Write*, DeleteAccount, and Flush callers. Run TestDeleteRecreateSlotsAcrossManyBlocks, TestSystemCallStoragePropagation_BlockStateCache, and the TestLightCollectorNoncePreservation* tests first. Done means the write buffer and related write-side state are removed without changing the listed behavior, followed by the execution/state, execution/execmodule, execution/engineapi, and execution/stagedsync suites passing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend-api-design, databases
- Issue type
- Refactor
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100