Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions db/kv/membatchwithdb/memory_mutation.go
Original file line number Diff line number Diff line change
Expand Up @@ -953,8 +953,12 @@ func (m *MemoryMutation) GetLatest(name kv.Domain, k []byte) (v []byte, step kv.

func (m *MemoryMutation) GetAsOf(name kv.Domain, k []byte, ts uint64) (v []byte, ok bool, err error) {
if m.DomainReader != nil {
if val, ok, err := m.DomainReader.GetAsOf(name, k, ts); err == nil && ok {
return val, ok, nil
val, ok, err := m.DomainReader.GetAsOf(name, k, ts)
if err != nil {
return nil, false, err
}
if ok {
return val, true, nil
}
}
if m.db == nil {
Expand Down Expand Up @@ -986,8 +990,12 @@ func (m *MemoryMutation) RangeAsOf(name kv.Domain, fromKey, toKey []byte, ts uin

func (m *MemoryMutation) HistorySeek(name kv.Domain, k []byte, ts uint64) (v []byte, ok bool, err error) {
if m.DomainReader != nil {
if val, ok, err := m.DomainReader.HistorySeek(name, k, ts); err == nil && ok {
return val, ok, nil
val, ok, err := m.DomainReader.HistorySeek(name, k, ts)
if err != nil {
return nil, false, err
}
if ok {
return val, true, nil
}
}
if m.db == nil {
Expand Down Expand Up @@ -1152,8 +1160,12 @@ func (v *OverlayTemporalReadView) GetAsOf(name kv.Domain, k []byte, ts uint64) (
// Check DomainReader independently — this method shadows MemoryMutation.GetAsOf
// and falls through to v.temporalTx (not m.db), so the embedded check never fires.
if v.MemoryMutation != nil && v.MemoryMutation.DomainReader != nil {
if val, ok, err := v.MemoryMutation.DomainReader.GetAsOf(name, k, ts); err == nil && ok {
return val, ok, nil
val, ok, err := v.MemoryMutation.DomainReader.GetAsOf(name, k, ts)
if err != nil {
return nil, false, err
}
if ok {
return val, true, nil
}
}
return v.temporalTx.GetAsOf(name, k, ts)
Expand All @@ -1171,8 +1183,12 @@ func (v *OverlayTemporalReadView) HistorySeek(name kv.Domain, k []byte, ts uint6
// Check DomainReader independently — this method shadows MemoryMutation.HistorySeek
// and falls through to v.temporalTx (not m.db), so the embedded check never fires.
if v.MemoryMutation != nil && v.MemoryMutation.DomainReader != nil {
if val, ok, err := v.MemoryMutation.DomainReader.HistorySeek(name, k, ts); err == nil && ok {
return val, ok, nil
val, ok, err := v.MemoryMutation.DomainReader.HistorySeek(name, k, ts)
if err != nil {
return nil, false, err
}
if ok {
return val, true, nil
}
}
return v.temporalTx.HistorySeek(name, k, ts)
Expand Down
42 changes: 42 additions & 0 deletions db/kv/membatchwithdb/memory_mutation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package membatchwithdb_test

import (
"errors"
"fmt"
"sync"
"testing"
Expand Down Expand Up @@ -851,3 +852,44 @@ func TestMemoryMutationConcurrentDeleteAndRead(t *testing.T) {

wg.Wait()
}

// erroringDomainReader fails every domain read, so a caller that swallows the
// error is indistinguishable from a caller that saw no value at all.
type erroringDomainReader struct{ err error }

func (r erroringDomainReader) GetAsOf(kv.Domain, []byte, uint64) ([]byte, bool, error) {
return nil, false, r.err
}

func (r erroringDomainReader) HistorySeek(kv.Domain, []byte, uint64) ([]byte, bool, error) {
return nil, false, r.err
}

// TestDomainReadErrorsPropagate covers both overlay read views: a DomainReader
// error must reach the caller rather than fall through to the committed tx,
// which would silently answer with stale data.
func TestDomainReadErrorsPropagate(t *testing.T) {
t.Parallel()

_, rwTx := newTestTx(t)
batch, err := membatchwithdb.NewMemoryBatch(rwTx, "", log.Root())
require.NoError(t, err)
defer batch.Close()

wantErr := errors.New("domain reader unavailable")
batch.DomainReader = erroringDomainReader{err: wantErr}

key := []byte{0x2}
for name, tx := range map[string]kv.TemporalTx{
"MemoryMutation": batch,
"OverlayTemporalReadView": batch.NewTemporalReadView(rwTx),
} {
t.Run(name, func(t *testing.T) {
_, _, err := tx.GetAsOf(kv.ReceiptDomain, key, 1)
require.ErrorIs(t, err, wantErr, "GetAsOf must propagate the DomainReader error")

_, _, err = tx.HistorySeek(kv.ReceiptDomain, key, 1)
require.ErrorIs(t, err, wantErr, "HistorySeek must propagate the DomainReader error")
})
}
}
46 changes: 46 additions & 0 deletions db/state/execctx/domain_shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"github.com/erigontech/erigon/db/kv/rawdbv3"
"github.com/erigontech/erigon/db/kv/stream"
"github.com/erigontech/erigon/db/kv/temporal"
"github.com/erigontech/erigon/db/rawdb/rawtemporaldb"
"github.com/erigontech/erigon/db/state"
"github.com/erigontech/erigon/db/state/changeset"
"github.com/erigontech/erigon/db/state/execctx"
Expand Down Expand Up @@ -1994,3 +1995,48 @@ func TestBlockOverlay_DomainReadsRegression(t *testing.T) {
require.True(t, ok, "NewTemporalReadView HistorySeek must find in-memory receipt data")
require.Equal(t, value, gotValHist2)
}

// TestReceiptAsOf_InFlightBlockLogIndex pins the read that seeds per-transaction log
// indexes. A block whose commit is in flight has its receipt metadata only in
// SharedDomains, and on a history miss DomainRoTx.GetAsOf falls back to GetLatest — so
// a bare read answers with the last committed block's value. The overlay read view must
// see the in-flight value instead.
func TestReceiptAsOf_InFlightBlockLogIndex(t *testing.T) {
t.Parallel()

ctx := t.Context()
logger := log.New()
db := newTestDb(t, 10)

tx, err := db.BeginTemporalRw(ctx)
require.NoError(t, err)
defer tx.Rollback()

const (
committedTxNum = uint64(5)
committedLogIdx = uint32(7)
inFlightTxNum = uint64(9)
inFlightLogIdx = uint32(3)
)

committed, err := execctx.NewSharedDomains(ctx, tx, logger)
require.NoError(t, err)
defer committed.Close()
require.NoError(t, rawtemporaldb.AppendReceipt(committed.AsPutDel(tx), committedLogIdx, 0, 0, committedTxNum))
require.NoError(t, committed.Flush(ctx, tx))
committed.Close()

_, _, stale, err := rawtemporaldb.ReceiptAsOf(tx, inFlightTxNum+1)
require.NoError(t, err)
require.Equal(t, committedLogIdx, stale, "precondition: a bare read must return the stale committed value")

sd, err := execctx.NewSharedDomains(ctx, tx, logger)
require.NoError(t, err)
defer sd.Close()
require.NoError(t, sd.InitBlockOverlay(tx, t.TempDir()))
require.NoError(t, rawtemporaldb.AppendReceipt(sd.AsPutDel(tx), inFlightLogIdx, 0, 0, inFlightTxNum))

_, _, got, err := rawtemporaldb.ReceiptAsOf(sd.BlockOverlay().NewReadView(tx), inFlightTxNum+1)
require.NoError(t, err)
require.Equal(t, inFlightLogIdx, got, "must serve the in-flight block's log index, not the last committed one")
}
81 changes: 81 additions & 0 deletions rpc/jsonrpc/receipts/receipts_generator_overlay_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package receipts_test

import (
"testing"
"time"

"github.com/holiman/uint256"
"github.com/stretchr/testify/require"

"github.com/erigontech/erigon/db/rawdb/rawtemporaldb"
"github.com/erigontech/erigon/db/state/execctx"
"github.com/erigontech/erigon/execution/protocol/params"
"github.com/erigontech/erigon/execution/tests/blockgen"
"github.com/erigontech/erigon/execution/types"
"github.com/erigontech/erigon/node/shards"
"github.com/erigontech/erigon/rpc/jsonrpc/receipts"
"github.com/erigontech/erigon/rpc/rpchelper"
)

// TestGetReceiptLogIndexThroughOverlay pins the wiring that lets GetReceipt see a
// block whose commit is in flight: the log index must be resolved through
// Filters.WithTemporalOverlay, not read from the committed tx. The overlay is
// seeded with a value the committed tx does not hold, so only a routed read can
// produce it.
func TestGetReceiptLogIndexThroughOverlay(t *testing.T) {
signer := types.LatestSignerForChainID(nil)
m := mockWithGenerator(t, 2, func(i int, block *blockgen.BlockGen) {
txn, err := types.SignTx(
types.NewTransaction(block.TxNonce(testAddr), testAddr, uint256.NewInt(1), params.TxGas, nil, nil),
*signer, testKey)
require.NoError(t, err)
block.AddTx(txn)
})

tx, err := m.DB.BeginTemporalRw(m.Ctx)
require.NoError(t, err)
defer tx.Rollback()

const blockNum = uint64(2)
block, err := m.BlockReader.BlockByNumber(m.Ctx, tx, blockNum)
require.NoError(t, err)
require.Len(t, block.Transactions(), 1)

minTxNum, err := m.BlockReader.TxnumReader().Min(m.Ctx, tx, blockNum)
require.NoError(t, err)
txNum := minTxNum + 1 // txIndex 0, past the block's system tx

const overlayLogIdx = uint32(41)

sd, err := execctx.NewSharedDomains(m.Ctx, tx, m.Log)
require.NoError(t, err)
defer sd.Close()
require.NoError(t, sd.InitBlockOverlay(tx, t.TempDir()))
require.NoError(t, rawtemporaldb.AppendReceipt(sd.AsPutDel(tx), overlayLogIdx, 0, 0, txNum))

events := shards.NewEvents()
events.PublishOverlay(sd)
ff := rpchelper.New(m.Ctx, rpchelper.DefaultFiltersConfig, nil, nil, nil, func() {}, m.Log, events)

gen := receipts.NewGenerator(m.Dirs, m.BlockReader, m.Engine, nil, time.Minute, ff)
receipt, err := gen.GetReceipt(m.Ctx, m.ChainConfig, tx, block.HeaderNoCopy(), block.Transactions()[0], 0, txNum, nil)
require.NoError(t, err)
require.Equal(t, overlayLogIdx, receipt.FirstLogIndexWithinBlock,
"GetReceipt must resolve the log index through the block overlay")
}
Loading