From e17f9bf4d80a565a32f95ffa51a5c9b5c57f1581 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 2 Jul 2026 21:22:58 +0200 Subject: [PATCH 01/10] db/etl, db/recsplit, db/seg, db/datastruct: share bufio pools Four byte-identical copies of the pooled 512KB bufio reader/writer helpers lived in db/etl, db/recsplit, db/seg and db/datastruct/btindex. Consolidate them into a new db/bufiopool package, keeping the buffer size and the Reset(nil)-before-Put semantics unchanged. --- db/bufiopool/bufiopool.go | 53 +++++++++++++++++++++++ db/datastruct/btindex/btree_index.go | 24 ++-------- db/datastruct/btindex/btree_index_test.go | 5 ++- db/etl/dataprovider.go | 21 ++------- db/recsplit/recsplit.go | 45 ++++--------------- db/seg/compress.go | 39 +++-------------- db/seg/parallel_compress.go | 25 ++++++----- 7 files changed, 90 insertions(+), 122 deletions(-) create mode 100644 db/bufiopool/bufiopool.go diff --git a/db/bufiopool/bufiopool.go b/db/bufiopool/bufiopool.go new file mode 100644 index 00000000000..26adabed769 --- /dev/null +++ b/db/bufiopool/bufiopool.go @@ -0,0 +1,53 @@ +// 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 . + +// Package bufiopool pools 512KB bufio readers and writers. Erigon doesn't +// create tons of bufio readers/writers, but it has tons of parallel small +// unit-tests which each create many small files and bufio readers/writers — +// pooling avoids the allocation pressure in that scenario. +package bufiopool + +import ( + "bufio" + "io" + "sync" + + "github.com/c2h5oh/datasize" +) + +var ( + writers = sync.Pool{New: func() any { return bufio.NewWriterSize(nil, int(512*datasize.KB)) }} + readers = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, int(512*datasize.KB)) }} +) + +func Writer(w io.Writer) *bufio.Writer { + bw := writers.Get().(*bufio.Writer) + bw.Reset(w) + return bw +} + +// Reset(nil) before Put is required: without it the pool entry retains a +// reference to the underlying io.Writer/io.Reader, keeping it alive until the +// next GC cycle or until the entry is reused — whichever comes first. +func PutWriter(w *bufio.Writer) { w.Reset(nil); writers.Put(w) } + +func Reader(r io.Reader) *bufio.Reader { + br := readers.Get().(*bufio.Reader) + br.Reset(r) + return br +} + +func PutReader(r *bufio.Reader) { r.Reset(nil); readers.Put(r) } diff --git a/db/datastruct/btindex/btree_index.go b/db/datastruct/btindex/btree_index.go index 800af99f8d4..8f150d620bd 100644 --- a/db/datastruct/btindex/btree_index.go +++ b/db/datastruct/btindex/btree_index.go @@ -17,10 +17,8 @@ package btindex import ( - "bufio" "errors" "fmt" - "io" "os" "path" "path/filepath" @@ -28,7 +26,6 @@ import ( "time" "unsafe" - "github.com/c2h5oh/datasize" "github.com/edsrzf/mmap-go" "github.com/erigontech/erigon/common/background" @@ -36,6 +33,7 @@ import ( "github.com/erigontech/erigon/common/dir" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/common/murmur3" + "github.com/erigontech/erigon/db/bufiopool" "github.com/erigontech/erigon/db/datastruct/existence" "github.com/erigontech/erigon/db/recsplit/eliasfano32" "github.com/erigontech/erigon/db/seg" @@ -226,7 +224,7 @@ func NewBtIndexWriter(args BtIndexWriterArgs, logger log.Logger) (_ *BtIndexWrit if btw.indexF, err = dir.CreateTemp(args.IndexFile); err != nil { return nil, fmt.Errorf("create temp index file for %s: %w", args.IndexFile, err) } - btw.writer = &countingWriter{w: getBufioWriter(btw.indexF)} + btw.writer = &countingWriter{w: bufiopool.Writer(btw.indexF)} if args.KeyCount > 0 { if args.M == 0 { @@ -352,7 +350,7 @@ func (btw *BtIndexWriter) closeTemps() { func (btw *BtIndexWriter) Close() { if btw.writer != nil { - putBufioWriter(btw.writer.w) + bufiopool.PutWriter(btw.writer.w) btw.writer = nil } if btw.indexF != nil { // non-nil means Build didn't rename it: drop the partial .tmp @@ -724,19 +722,3 @@ func (b *BtIndex) OrdinalLookup(getter *seg.Reader, i uint64) *Cursor { func (b *BtIndex) Offsets() *eliasfano32.EliasFano { return b.bplus.Offsets() } func (b *BtIndex) Distances() (map[int]int, error) { return b.bplus.Distances() } - -// Erigon doesn't create tons of bufio readers/writers, but it has tons of -// parallel small unit-tests which each create many small files and bufio -// readers/writers — pooling avoids the allocation pressure in that scenario. -var bufioWriterPool = sync.Pool{New: func() any { return bufio.NewWriterSize(nil, int(512*datasize.KB)) }} - -func getBufioWriter(w io.Writer) *bufio.Writer { - bw := bufioWriterPool.Get().(*bufio.Writer) - bw.Reset(w) - return bw -} - -// Reset(nil) before Put is required: without it the pool entry retains a -// reference to the underlying io.Writer/io.Reader, keeping it alive until the -// next GC cycle or until the entry is reused — whichever comes first. -func putBufioWriter(w *bufio.Writer) { w.Reset(nil); bufioWriterPool.Put(w) } diff --git a/db/datastruct/btindex/btree_index_test.go b/db/datastruct/btindex/btree_index_test.go index 60c893c61b1..f402a446289 100644 --- a/db/datastruct/btindex/btree_index_test.go +++ b/db/datastruct/btindex/btree_index_test.go @@ -31,6 +31,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/background" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/bufiopool" "github.com/erigontech/erigon/db/recsplit/eliasfano32" "github.com/erigontech/erigon/db/seg" "github.com/erigontech/erigon/db/state/statecfg" @@ -205,8 +206,8 @@ func writeV0Index(tb testing.TB, dataPath, indexPath string, compressed seg.File f, err := os.Create(indexPath) require.NoError(tb, err) defer f.Close() - w := getBufioWriter(f) - defer putBufioWriter(w) + w := bufiopool.Writer(f) + defer bufiopool.PutWriter(w) if count > 0 { ef := eliasfano32.NewEliasFano(count, uint64(r.Size())) diff --git a/db/etl/dataprovider.go b/db/etl/dataprovider.go index 30e7a73eac7..d6582864a22 100644 --- a/db/etl/dataprovider.go +++ b/db/etl/dataprovider.go @@ -17,21 +17,19 @@ package etl import ( - "bufio" "encoding/binary" "fmt" "io" "os" "path/filepath" - "sync" "sync/atomic" - "github.com/c2h5oh/datasize" "golang.org/x/sync/errgroup" "github.com/erigontech/erigon/common/dir" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/common/mmap" + "github.com/erigontech/erigon/db/bufiopool" ) type dataProvider interface { @@ -101,19 +99,6 @@ func FlushToDisk(logPrefix string, b Buffer, tmpdir string, lvl log.Lvl) (dataPr return provider, nil } -var bufioWriterPool = sync.Pool{New: func() any { return bufio.NewWriterSize(nil, int(512*datasize.KB)) }} - -func getBufioWriter(w io.Writer) *bufio.Writer { - bw := bufioWriterPool.Get().(*bufio.Writer) - bw.Reset(w) - return bw -} - -// Reset(nil) before Put is required: without it the pool entry retains a -// reference to the underlying io.Writer/io.Reader, keeping it alive until the -// next GC cycle or until the entry is reused — whichever comes first. -func putBufioWriter(w *bufio.Writer) { w.Reset(nil); bufioWriterPool.Put(w) } - func sortAndFlush(b Buffer, tmpdir string) (*os.File, error) { b.Sort() @@ -130,8 +115,8 @@ func sortAndFlush(b Buffer, tmpdir string) (*os.File, error) { return nil, err } - w := getBufioWriter(bufferFile) - defer putBufioWriter(w) + w := bufiopool.Writer(bufferFile) + defer bufiopool.PutWriter(w) if err = b.Write(w); err != nil { return bufferFile, fmt.Errorf("error writing entries to disk: %w", err) diff --git a/db/recsplit/recsplit.go b/db/recsplit/recsplit.go index 0be13d255c4..f6aaa5bfb30 100644 --- a/db/recsplit/recsplit.go +++ b/db/recsplit/recsplit.go @@ -32,8 +32,6 @@ import ( "sync" "time" - "github.com/c2h5oh/datasize" - "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/background" "github.com/erigontech/erigon/common/dbg" @@ -41,6 +39,7 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/common/mmap" "github.com/erigontech/erigon/common/murmur3" + "github.com/erigontech/erigon/db/bufiopool" "github.com/erigontech/erigon/db/datastruct/fusefilter" "github.com/erigontech/erigon/db/etl" "github.com/erigontech/erigon/db/recsplit/eliasfano16" @@ -288,7 +287,7 @@ func NewRecSplit(args RecSplitArgs, logger log.Logger) (*RecSplit, error) { if err != nil { return nil, err } - rs.offsetWriter = getBufioWriter(rs.offsetFile) + rs.offsetWriter = bufiopool.Writer(rs.offsetFile) } if rs.enums && args.KeyCount > 0 && rs.lessFalsePositives { if rs.dataStructureVersion == 0 { @@ -296,7 +295,7 @@ func NewRecSplit(args RecSplitArgs, logger log.Logger) (*RecSplit, error) { if err != nil { return nil, err } - rs.existenceWV0 = getBufioWriter(rs.existenceFV0) + rs.existenceWV0 = bufiopool.Writer(rs.existenceFV0) } } @@ -382,7 +381,7 @@ func (rs *RecSplit) Close() { rs.existenceFV0 = nil } if rs.existenceWV0 != nil { - putBufioWriter(rs.existenceWV0) + bufiopool.PutWriter(rs.existenceWV0) rs.existenceWV0 = nil } if rs.existenceFV1 != nil { @@ -402,7 +401,7 @@ func (rs *RecSplit) Close() { rs.offsetFile = nil } if rs.offsetWriter != nil { - putBufioWriter(rs.offsetWriter) + bufiopool.PutWriter(rs.offsetWriter) rs.offsetWriter = nil } if rs.offsetEf != nil { @@ -940,8 +939,8 @@ func (rs *RecSplit) Build(ctx context.Context) error { } defer rs.indexF.Close() - rs.indexW = getBufioWriter(rs.indexF) - defer putBufioWriter(rs.indexW) + rs.indexW = bufiopool.Writer(rs.indexF) + defer bufiopool.PutWriter(rs.indexW) // 1 byte: dataStructureVersion, 7 bytes: app-specific minimal dataID (of current shard) binary.BigEndian.PutUint64(rs.numBuf[:], rs.baseDataID) rs.numBuf[0] = uint8(rs.dataStructureVersion) @@ -1113,9 +1112,9 @@ func (rs *RecSplit) flushExistenceFilter() error { if _, err := rs.existenceFV0.Seek(0, io.SeekStart); err != nil { return err } - r := getBufioReader(rs.existenceFV0) + r := bufiopool.Reader(rs.existenceFV0) _, copyErr := io.CopyN(rs.indexW, r, int64(rs.keysAdded)) - putBufioReader(r) + bufiopool.PutReader(r) if copyErr != nil { return copyErr } @@ -1472,29 +1471,3 @@ func (rs *RecSplit) buildWithWorkers(ctx context.Context) error { } return producerErr } - -// Erigon doesn't create tons of bufio readers/writers, but it has tons of -// parallel small unit-tests which each create many small files and bufio -// readers/writers — pooling avoids the allocation pressure in that scenario. -var ( - bufioWriterPool = sync.Pool{New: func() any { return bufio.NewWriterSize(nil, int(512*datasize.KB)) }} - bufioReaderPool = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, int(512*datasize.KB)) }} -) - -func getBufioWriter(w io.Writer) *bufio.Writer { - bw := bufioWriterPool.Get().(*bufio.Writer) - bw.Reset(w) - return bw -} - -// Reset(nil) before Put is required: without it the pool entry retains a -// reference to the underlying io.Writer/io.Reader, keeping it alive until the -// next GC cycle or until the entry is reused — whichever comes first. -func putBufioWriter(w *bufio.Writer) { w.Reset(nil); bufioWriterPool.Put(w) } - -func getBufioReader(r io.Reader) *bufio.Reader { - br := bufioReaderPool.Get().(*bufio.Reader) - br.Reset(r) - return br -} -func putBufioReader(r *bufio.Reader) { r.Reset(nil); bufioReaderPool.Put(r) } diff --git a/db/seg/compress.go b/db/seg/compress.go index 52ff684a5c0..308ab3d0714 100644 --- a/db/seg/compress.go +++ b/db/seg/compress.go @@ -33,12 +33,11 @@ import ( "sync" "time" - "github.com/c2h5oh/datasize" - "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dir" dir2 "github.com/erigontech/erigon/common/dir" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/bufiopool" "github.com/erigontech/erigon/db/etl" ) @@ -221,32 +220,6 @@ func (c *Compressor) SetMetadata(metadata []byte) { func (c *Compressor) Count() int { return int(c.wordsCount) } -// Erigon doesn't create tons of bufio readers/writers, but it has tons of -// parallel small unit-tests which each create many small files and bufio -// readers/writers — pooling avoids the allocation pressure in that scenario. -var ( - bufioWriterPool = sync.Pool{New: func() any { return bufio.NewWriterSize(nil, int(512*datasize.KB)) }} - bufioReaderPool = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, int(512*datasize.KB)) }} -) - -func getBufioWriter(w io.Writer) *bufio.Writer { - bw := bufioWriterPool.Get().(*bufio.Writer) - bw.Reset(w) - return bw -} - -// Reset(nil) before Put is required: without it the pool entry retains a -// reference to the underlying io.Writer/io.Reader, keeping it alive until the -// next GC cycle or until the entry is reused — whichever comes first. -func putBufioWriter(w *bufio.Writer) { w.Reset(nil); bufioWriterPool.Put(w) } - -func getBufioReader(r io.Reader) *bufio.Reader { - br := bufioReaderPool.Get().(*bufio.Reader) - br.Reset(r) - return br -} -func putBufioReader(r *bufio.Reader) { r.Reset(nil); bufioReaderPool.Put(r) } - func (c *Compressor) ReadFrom(g *Getter) error { var v []byte for g.HasNext() { @@ -973,14 +946,14 @@ func NewRawWordsFile(filePath string) (*RawWordsFile, error) { if err != nil { return nil, err } - return &RawWordsFile{filePath: filePath, f: f, w: getBufioWriter(f), buf: make([]byte, 128)}, nil + return &RawWordsFile{filePath: filePath, f: f, w: bufiopool.Writer(f), buf: make([]byte, 128)}, nil } func OpenRawWordsFile(filePath string) (*RawWordsFile, error) { f, err := os.Open(filePath) if err != nil { return nil, err } - return &RawWordsFile{filePath: filePath, f: f, w: getBufioWriter(f), buf: make([]byte, 128)}, nil + return &RawWordsFile{filePath: filePath, f: f, w: bufiopool.Writer(f), buf: make([]byte, 128)}, nil } func (f *RawWordsFile) Flush() error { return f.w.Flush() @@ -989,7 +962,7 @@ func (f *RawWordsFile) Close() { if f.w != nil { f.w.Flush() f.f.Close() - putBufioWriter(f.w) + bufiopool.PutWriter(f.w) f.w = nil f.f = nil } @@ -1033,8 +1006,8 @@ func (f *RawWordsFile) ForEach(walker func(v []byte, compressed bool) error) err if err != nil { return err } - r := getBufioReader(f.f) - defer putBufioReader(r) + r := bufiopool.Reader(f.f) + defer bufiopool.PutReader(r) buf := make([]byte, 16*1024) l, e := binary.ReadUvarint(r) for ; e == nil; l, e = binary.ReadUvarint(r) { diff --git a/db/seg/parallel_compress.go b/db/seg/parallel_compress.go index 2eb29cfcbca..6bdd6418f32 100644 --- a/db/seg/parallel_compress.go +++ b/db/seg/parallel_compress.go @@ -35,6 +35,7 @@ import ( "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/dir" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/bufiopool" "github.com/erigontech/erigon/db/etl" "github.com/erigontech/erigon/db/seg/patricia" "github.com/erigontech/erigon/db/seg/sais" @@ -395,8 +396,8 @@ func compressWithPatternCandidates(ctx context.Context, trace bool, cfg Cfg, log intermediatePath := intermediateFile.Name() defer dir.RemoveFile(intermediatePath) defer intermediateFile.Close() - intermediateW := getBufioWriter(intermediateFile) - defer putBufioWriter(intermediateW) + intermediateW := bufiopool.Writer(intermediateFile) + defer bufiopool.PutWriter(intermediateW) var inCount, outCount, emptyWordsCount uint64 // Counters words sent to compression and returned for compression var numBuf [binary.MaxVarintLen64]byte @@ -634,8 +635,8 @@ func compressWithPatternCandidates(ctx context.Context, trace bool, cfg Cfg, log if lvl < log.LvlTrace { logger.Log(lvl, fmt.Sprintf("[%s] Effective dictionary", logPrefix), logCtx...) } - cw := getBufioWriter(cf) - defer putBufioWriter(cw) + cw := bufiopool.Writer(cf) + defer bufiopool.PutWriter(cw) // 1-st, output amount of words - just a useful metadata binary.BigEndian.PutUint64(numBuf[:], inCount) // Dictionary size if _, err = cw.Write(numBuf[:8]); err != nil { @@ -693,8 +694,8 @@ func compressWithPatternCandidates(ctx context.Context, trace bool, cfg Cfg, log wc := 0 var hc BitWriter hc.w = cw - r := getBufioReader(intermediateFile) - defer putBufioReader(r) + r := bufiopool.Reader(intermediateFile) + defer bufiopool.PutReader(r) copyNBuf := make([]byte, 32*1024) var l uint64 @@ -810,8 +811,8 @@ func compressNoWordPatterns(logPrefix string, cf *os.File, uncompressedFile *Raw return err } - cw := getBufioWriter(cf) - defer putBufioWriter(cw) + cw := bufiopool.Writer(cf) + defer bufiopool.PutWriter(cw) // Write data header: word count, empty word count, patternsSize=0, then position dict. binary.BigEndian.PutUint64(numBuf[:], inCount) @@ -1184,8 +1185,8 @@ func PersistDictionary(fileName string, db *DictionaryBuilder) error { if err != nil { return err } - w := getBufioWriter(df) - defer putBufioWriter(w) + w := bufiopool.Writer(df) + defer bufiopool.PutWriter(w) db.ForEach(func(score uint64, word []byte) { fmt.Fprintf(w, "%d %x\n", score, word) }) if err = w.Flush(); err != nil { return err @@ -1204,8 +1205,8 @@ func ReadSimpleFile(fileName string, walker func(v []byte) error) error { return err } defer f.Close() - r := getBufioReader(f) - defer putBufioReader(r) + r := bufiopool.Reader(f) + defer bufiopool.PutReader(r) buf := make([]byte, 4096) for l, e := binary.ReadUvarint(r); ; l, e = binary.ReadUvarint(r) { if e != nil { From 6d2ba7ddc4c885e7f15f251760b8def1af9313f5 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 2 Jul 2026 21:24:53 +0200 Subject: [PATCH 02/10] common, common/crypto: share keccak hasher pool common/hasher.go and common/crypto/crypto.go each kept their own sync.Pool of fastkeccak states. Keep a single pool of raw KeccakState in the common package (common/crypto imports common, so the pool must live there) and delegate crypto's NewKeccakState/ReturnToPool to it. Get/Reset/Put semantics are unchanged: both implementations reset on Get and put back without reset. The pool now holds raw KeccakStates instead of Hasher wrappers, so common.NewHasher allocates a 16-byte wrapper per call; the hotter crypto path (Keccak256, Keccak256Hash, RlpHash) stays allocation-free. --- common/crypto/crypto.go | 17 +++-------------- common/hasher.go | 27 ++++++++++++++++----------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/common/crypto/crypto.go b/common/crypto/crypto.go index 9c363d94705..d23cdc40a0e 100644 --- a/common/crypto/crypto.go +++ b/common/crypto/crypto.go @@ -30,7 +30,6 @@ import ( "io" "math/big" "os" - "sync" keccak "github.com/erigontech/fastkeccak" "github.com/holiman/uint256" @@ -303,20 +302,10 @@ func PubkeyToAddress(p ecdsa.PublicKey) common.Address { return common.BytesToAddress(Keccak256(pubBytes)[12:]) } -// hasherPool holds LegacyKeccak hashers. -var hasherPool = sync.Pool{ - New: func() any { - return keccak.NewFastKeccak() - }, -} +// NewKeccakState returns a reset KeccakState from the pool shared with the common package. +func NewKeccakState() keccak.KeccakState { return common.NewKeccakState() } -// NewKeccakState creates a new KeccakState -func NewKeccakState() keccak.KeccakState { - h := hasherPool.Get().(keccak.KeccakState) - h.Reset() - return h -} -func ReturnToPool(h keccak.KeccakState) { hasherPool.Put(h) } +func ReturnToPool(h keccak.KeccakState) { common.ReturnKeccakState(h) } // FinalizeHash finalizes sha and returns a Keccak-256 digest as a value type, // avoiding the heap escape that occurs when passing h[:] to an interface Read method. diff --git a/common/hasher.go b/common/hasher.go index 2940a6f437d..7f46e6ad943 100644 --- a/common/hasher.go +++ b/common/hasher.go @@ -26,30 +26,35 @@ type Hasher struct { Sha keccak.KeccakState } -var hashersPool = sync.Pool{ +var keccakStatePool = sync.Pool{ New: func() any { - return &Hasher{Sha: keccak.NewFastKeccak()} + return keccak.NewFastKeccak() }, } -func NewHasher() *Hasher { - h := hashersPool.Get().(*Hasher) - h.Sha.Reset() - return h +// NewKeccakState returns a reset KeccakState from a shared pool. +func NewKeccakState() keccak.KeccakState { + sha := keccakStatePool.Get().(keccak.KeccakState) + sha.Reset() + return sha } -func ReturnHasherToPool(h *Hasher) { hashersPool.Put(h) } + +func ReturnKeccakState(sha keccak.KeccakState) { keccakStatePool.Put(sha) } + +func NewHasher() *Hasher { return &Hasher{Sha: NewKeccakState()} } +func ReturnHasherToPool(h *Hasher) { ReturnKeccakState(h.Sha) } func HashData(data []byte) (Hash, error) { - h := NewHasher() - defer ReturnHasherToPool(h) + sha := NewKeccakState() + defer ReturnKeccakState(sha) - _, err := h.Sha.Write(data) + _, err := sha.Write(data) if err != nil { return Hash{}, err } var buf Hash - _, err = h.Sha.Read(buf[:]) + _, err = sha.Read(buf[:]) if err != nil { return Hash{}, err } From 6bb4042f7bfa1ba238f8b1d114bc38f59afc58e9 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 2 Jul 2026 21:29:10 +0200 Subject: [PATCH 03/10] common, cl, db/snapshotsync, execution/types: share bytes.Buffer pool Seven packages each declared their own sync.Pool of bytes.Buffer with the same get/reset/put pattern. Consolidate them into common/pool (GetBuffer resets on get, matching every existing call site) and add a 1MB capacity cap on put so one oversized use cannot pin memory now that unrelated subsystems share the pool. The sibling pools in cl/persistence/base_encoding (plain uint64/byte/ pattern slices) and the zstd encoder/decoder pools are left as-is. --- cl/antiquary/beacon_states_collector.go | 6 +-- cl/antiquary/state_antiquary.go | 7 ---- cl/persistence/base_encoding/uint64_diff.go | 18 +++----- cl/persistence/beacon_indicies/indicies.go | 13 ++---- .../format/snapshot_format/blocks.go | 22 ++++------ .../historical_states_reader.go | 30 +++++--------- common/pool/pool.go | 41 +++++++++++++++++++ .../freezeblocks/beacon_block_reader.go | 21 ++++------ .../freezeblocks/caplin_snapshots.go | 7 ++-- execution/types/hashing.go | 6 --- execution/types/receipt.go | 6 +-- 11 files changed, 84 insertions(+), 93 deletions(-) create mode 100644 common/pool/pool.go diff --git a/cl/antiquary/beacon_states_collector.go b/cl/antiquary/beacon_states_collector.go index 374032fb2a4..135379e8a46 100644 --- a/cl/antiquary/beacon_states_collector.go +++ b/cl/antiquary/beacon_states_collector.go @@ -32,6 +32,7 @@ import ( "github.com/erigontech/erigon/cl/transition/impl/eth2" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/etl" "github.com/erigontech/erigon/db/kv" ) @@ -719,9 +720,8 @@ func antiquateListSSZ[T solid.EncodableHashableSSZ](ctx context.Context, slot ui func antiquateBytesListDiff(ctx context.Context, key []byte, old, new []byte, collector *etl.Collector, diffFn func(w io.Writer, old, new []byte) error) error { // create a diff - diffBuffer := bufferPool.Get().(*bytes.Buffer) - defer bufferPool.Put(diffBuffer) - diffBuffer.Reset() + diffBuffer := pool.GetBuffer() + defer pool.PutBuffer(diffBuffer) if err := diffFn(diffBuffer, old, new); err != nil { return err diff --git a/cl/antiquary/state_antiquary.go b/cl/antiquary/state_antiquary.go index f88b3ff27fe..ecad88dde2c 100644 --- a/cl/antiquary/state_antiquary.go +++ b/cl/antiquary/state_antiquary.go @@ -43,13 +43,6 @@ import ( "github.com/erigontech/erigon/db/snaptype" ) -// pool for buffers -var bufferPool = sync.Pool{ - New: func() any { - return &bytes.Buffer{} - }, -} - func (s *Antiquary) loopStates(ctx context.Context) { // Execute this each second reqRetryTimer := time.NewTicker(100 * time.Millisecond) diff --git a/cl/persistence/base_encoding/uint64_diff.go b/cl/persistence/base_encoding/uint64_diff.go index 9e07d8e35a8..9c43180a137 100644 --- a/cl/persistence/base_encoding/uint64_diff.go +++ b/cl/persistence/base_encoding/uint64_diff.go @@ -25,6 +25,8 @@ import ( "sync" "github.com/klauspost/compress/zstd" + + "github.com/erigontech/erigon/common/pool" ) // make a sync.pool of compressors (zstd) @@ -43,12 +45,6 @@ func putComp(v *zstd.Encoder) { compressorPool.Put(v) } -var bufferPool = sync.Pool{ - New: func() any { - return &bytes.Buffer{} - }, -} - var plainUint64BufferPool = sync.Pool{ New: func() any { b := make([]uint64, 1028) @@ -208,9 +204,8 @@ func ComputeCompressedSerializedEffectiveBalancesDiff(w io.Writer, old, new []by func ApplyCompressedSerializedUint64ListDiff(in, out []byte, diff []byte, reverse bool) ([]byte, error) { out = out[:0] - buffer := bufferPool.Get().(*bytes.Buffer) - defer bufferPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) if _, err := buffer.Write(diff); err != nil { return nil, err @@ -307,9 +302,8 @@ func ApplyCompressedSerializedValidatorListDiff(in, out []byte, diff []byte, rev } out = out[:len(in)] - buffer := bufferPool.Get().(*bytes.Buffer) - defer bufferPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) if _, err := buffer.Write(diff); err != nil { return nil, err diff --git a/cl/persistence/beacon_indicies/indicies.go b/cl/persistence/beacon_indicies/indicies.go index 67aa6ed1411..2c59323b361 100644 --- a/cl/persistence/beacon_indicies/indicies.go +++ b/cl/persistence/beacon_indicies/indicies.go @@ -29,17 +29,11 @@ import ( "github.com/erigontech/erigon/cl/persistence/base_encoding" "github.com/erigontech/erigon/cl/persistence/format/snapshot_format" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbutils" ) -// make a buffer pool -var bufferPool = &sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - // make a zstd writer pool var zstdWriterPool = &sync.Pool{ New: func() any { @@ -350,11 +344,10 @@ func WriteBeaconBlock(ctx context.Context, tx kv.RwTx, block *cltypes.SignedBeac return err } // take a buffer and encoder - buf := bufferPool.Get().(*bytes.Buffer) - defer bufferPool.Put(buf) + buf := pool.GetBuffer() + defer pool.PutBuffer(buf) encoder := zstdWriterPool.Get().(*zstd.Encoder) defer putWriter(encoder) - buf.Reset() encoder.Reset(buf) _, err = snapshot_format.WriteBlockForSnapshot(encoder, block, nil) if err != nil { diff --git a/cl/persistence/format/snapshot_format/blocks.go b/cl/persistence/format/snapshot_format/blocks.go index d5ade0af4fb..66e3c839916 100644 --- a/cl/persistence/format/snapshot_format/blocks.go +++ b/cl/persistence/format/snapshot_format/blocks.go @@ -17,15 +17,14 @@ package snapshot_format import ( - "bytes" "encoding/binary" "io" - "sync" "github.com/erigontech/erigon/cl/clparams" "github.com/erigontech/erigon/cl/cltypes" "github.com/erigontech/erigon/cl/cltypes/solid" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/execution/types" ) @@ -38,10 +37,6 @@ type ExecutionBlockReaderByNumber interface { CacheBody(blockNumber uint64, transactions [][]byte, withdrawals []*types.Withdrawal) } -var buffersPool = sync.Pool{ - New: func() any { return &bytes.Buffer{} }, -} - // WriteBlockForSnapshot writes a block to the given writer in the format expected by the snapshot. // buf is just a reusable buffer. if it had to grow it will be returned back as grown. // @@ -103,9 +98,8 @@ func readMetadataForBlock(r io.Reader, b []byte) (clparams.StateVersion, common. } func ReadBlockFromSnapshot(r io.Reader, executionReader ExecutionBlockReaderByNumber, cfg *clparams.BeaconChainConfig) (*cltypes.SignedBeaconBlock, error) { - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) // Read the metadata metadataSlab := make([]byte, 33) @@ -160,9 +154,8 @@ func ReadBlockFromSnapshot(r io.Reader, executionReader ExecutionBlockReaderByNu // ReadBlockHeaderFromSnapshotWithExecutionData reads the beacon block header and the EL block number and block hash. func ReadBlockHeaderFromSnapshotWithExecutionData(r io.Reader, cfg *clparams.BeaconChainConfig) (*cltypes.SignedBeaconBlockHeader, uint64, common.Hash, error) { - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) // Read the metadata metadataSlab := make([]byte, 33) @@ -232,9 +225,8 @@ func ReadBlockHeaderFromSnapshotWithExecutionData(r io.Reader, cfg *clparams.Bea // Callers that need full EL data should use ReadBlockFromSnapshot instead. // - GLOAS: stored as full SignedBeaconBlock (no payload to blind); decoded directly. func ReadBeaconBlockBodyFromSnapshot(r io.Reader, cfg *clparams.BeaconChainConfig) (*cltypes.SignedBeaconBlock, error) { - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) // Read the metadata metadataSlab := make([]byte, 33) diff --git a/cl/persistence/state/historical_states_reader/historical_states_reader.go b/cl/persistence/state/historical_states_reader/historical_states_reader.go index abbadfd12d1..a4b29355210 100644 --- a/cl/persistence/state/historical_states_reader/historical_states_reader.go +++ b/cl/persistence/state/historical_states_reader/historical_states_reader.go @@ -37,15 +37,12 @@ import ( "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/snapshotsync" "github.com/erigontech/erigon/db/snapshotsync/freezeblocks" ) -var buffersPool = sync.Pool{ - New: func() any { return &bytes.Buffer{} }, -} - type HistoricalStatesReader struct { cfg *clparams.BeaconChainConfig validatorTable *state_accessors.StaticValidatorTable // We can save 80% of the I/O by caching the validator table @@ -572,9 +569,8 @@ func (r *HistoricalStatesReader) reconstructDiffedUint64List(tx kv.Tx, kvGetter return nil, fmt.Errorf("dump not found for slot %d", freshDumpSlot) } - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) if _, err := buffer.Write(compressed); err != nil { return nil, err @@ -644,9 +640,8 @@ func (r *HistoricalStatesReader) reconstructBalances(tx kv.Tx, kvGetter state_ac remainder := slot % clparams.SlotsPerDump freshDumpSlot := slot - remainder - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) var compressed []byte currentStageProgress, err := state_accessors.GetStateProcessingProgress(tx) @@ -1057,9 +1052,8 @@ func ReadQueueSSZ[T solid.EncodableHashableSSZ](kvGetter state_accessors.GetValF remainder := slot % clparams.SlotsPerDump freshDumpSlot := slot - remainder - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) var compressed []byte @@ -1128,9 +1122,8 @@ func ReadRequiredQueueSSZ[T solid.EncodableHashableSSZ](kvGetter state_accessors } // Decompress and decode the dump (reuse buffer pool). - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) if _, err := buffer.Write(compressed); err != nil { return err @@ -1192,9 +1185,8 @@ func readCompressedSSZ[T interface { return fmt.Errorf("%w: table %s, slot %d", ErrMissingGloasData, table, slot) } - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) - buffer.Reset() + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) if _, err := buffer.Write(compressed); err != nil { return err diff --git a/common/pool/pool.go b/common/pool/pool.go new file mode 100644 index 00000000000..e114a921bf6 --- /dev/null +++ b/common/pool/pool.go @@ -0,0 +1,41 @@ +// 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 . + +package pool + +import ( + "bytes" + "sync" +) + +// Buffers that grew beyond maxBufferCap are dropped on Put so that one oversized use cannot pin memory in the pool. +const maxBufferCap = 1 << 20 + +var buffers = sync.Pool{New: func() any { return new(bytes.Buffer) }} + +// GetBuffer returns an empty buffer from the shared pool. +func GetBuffer() *bytes.Buffer { + b := buffers.Get().(*bytes.Buffer) + b.Reset() + return b +} + +func PutBuffer(b *bytes.Buffer) { + if b.Cap() > maxBufferCap { + return + } + buffers.Put(b) +} diff --git a/db/snapshotsync/freezeblocks/beacon_block_reader.go b/db/snapshotsync/freezeblocks/beacon_block_reader.go index 9a25d5ecb3c..f21c3316691 100644 --- a/db/snapshotsync/freezeblocks/beacon_block_reader.go +++ b/db/snapshotsync/freezeblocks/beacon_block_reader.go @@ -17,7 +17,6 @@ package freezeblocks import ( - "bytes" "context" "fmt" "sync" @@ -29,15 +28,12 @@ import ( "github.com/erigontech/erigon/cl/persistence/beacon_indicies" "github.com/erigontech/erigon/cl/persistence/format/snapshot_format" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbutils" "github.com/erigontech/erigon/execution/types" ) -var buffersPool = sync.Pool{ - New: func() any { return &bytes.Buffer{} }, -} - var decompressorPool = sync.Pool{ New: func() any { r, err := zstd.NewReader(nil) @@ -135,10 +131,9 @@ func (r *beaconSnapshotReader) ReadBlockBySlot(ctx context.Context, tx kv.Tx, sl } // Decompress this thing - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) - buffer.Reset() buffer.Write(buf) reader := decompressorPool.Get().(*zstd.Decoder) defer decompressorPool.Put(reader) @@ -197,10 +192,9 @@ func (r *beaconSnapshotReader) ReadBeaconBlockBodyBySlot(ctx context.Context, tx } // Decompress this thing - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) - buffer.Reset() buffer.Write(buf) reader := decompressorPool.Get().(*zstd.Decoder) defer decompressorPool.Put(reader) @@ -275,10 +269,9 @@ func (r *beaconSnapshotReader) ReadBlockByRoot(ctx context.Context, tx kv.Tx, ro return nil, nil } // Decompress this thing - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) - buffer.Reset() buffer.Write(buf) reader := decompressorPool.Get().(*zstd.Decoder) defer decompressorPool.Put(reader) diff --git a/db/snapshotsync/freezeblocks/caplin_snapshots.go b/db/snapshotsync/freezeblocks/caplin_snapshots.go index cda59ff0df2..7b6b2258c01 100644 --- a/db/snapshotsync/freezeblocks/caplin_snapshots.go +++ b/db/snapshotsync/freezeblocks/caplin_snapshots.go @@ -17,7 +17,6 @@ package freezeblocks import ( - "bytes" "context" "errors" "fmt" @@ -40,6 +39,7 @@ import ( "github.com/erigontech/erigon/common/background" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/datadir" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbutils" @@ -713,10 +713,9 @@ func (s *CaplinSnapshots) ReadHeader(slot uint64, tx kv.Tx) (*cltypes.SignedBeac return nil, 0, common.Hash{}, nil } // Decompress this thing - buffer := buffersPool.Get().(*bytes.Buffer) - defer buffersPool.Put(buffer) + buffer := pool.GetBuffer() + defer pool.PutBuffer(buffer) - buffer.Reset() buffer.Write(buf) reader := decompressorPool.Get().(*zstd.Decoder) defer decompressorPool.Put(reader) diff --git a/execution/types/hashing.go b/execution/types/hashing.go index 7653fdfa7d1..83eaedca38a 100644 --- a/execution/types/hashing.go +++ b/execution/types/hashing.go @@ -23,7 +23,6 @@ import ( "bytes" "fmt" "io" - "sync" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/crypto" @@ -31,11 +30,6 @@ import ( "github.com/erigontech/erigon/execution/rlp" ) -// encodeBufferPool holds temporary encoder buffers for DeriveSha and TX encoding. -var encodeBufferPool = sync.Pool{ - New: func() any { return new(bytes.Buffer) }, -} - type DerivableList interface { Len() int EncodeIndex(i int, w *bytes.Buffer) diff --git a/execution/types/receipt.go b/execution/types/receipt.go index 2e2630378cb..87e0b0c476e 100644 --- a/execution/types/receipt.go +++ b/execution/types/receipt.go @@ -32,6 +32,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/execution/rlp" ) @@ -145,9 +146,8 @@ func (r Receipt) EncodeRLP(w io.Writer) error { if r.Type == LegacyTxType { return rlp.Encode(w, data) } - buf := encodeBufferPool.Get().(*bytes.Buffer) - defer encodeBufferPool.Put(buf) - buf.Reset() + buf := pool.GetBuffer() + defer pool.PutBuffer(buf) if err := r.encodeTyped(data, buf); err != nil { return err } From c73af18a60b09c7dfef1d58740e372ea535cfa9d Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 2 Jul 2026 21:31:36 +0200 Subject: [PATCH 04/10] rpc/requests: use backoff for connect retries Replace the hand-rolled recursive retry helper in retryConnects with cenkalti/backoff/v4 (constant 1s backoff, context-bound), preserving the existing semantics exactly: 500ms per-attempt timeout within a 20s overall budget, retry on dial errors and deadline expiries, immediate return on any other error, a per-attempt timeout following a dial error reports that dial error, and overall-deadline expiry reports the last dial error (nil when attempts only ever timed out). The new retry_test.go pins those semantics; it was written against the old implementation and passes unchanged against the new one. --- rpc/requests/request_generator.go | 52 ++++++------- rpc/requests/retry_test.go | 117 ++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 rpc/requests/retry_test.go diff --git a/rpc/requests/request_generator.go b/rpc/requests/request_generator.go index a2841b0e171..efc271f7074 100644 --- a/rpc/requests/request_generator.go +++ b/rpc/requests/request_generator.go @@ -29,6 +29,7 @@ import ( "sync" "time" + "github.com/cenkalti/backoff/v4" "github.com/valyala/fastjson" "github.com/erigontech/erigon/common" @@ -234,41 +235,36 @@ func isConnectionError(err error) bool { func retryConnects(ctx context.Context, op func(context.Context) error) error { ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() - return retry(ctx, op, isConnectionError, time.Second*1, nil) -} - -func retry(ctx context.Context, op func(context.Context) error, isRecoverableError func(error) bool, delay time.Duration, lastErr error) error { - opctx, cancel := context.WithTimeout(ctx, connectionTimeout) - defer cancel() - - err := op(opctx) - if err == nil { - return nil - } + var lastDialErr error + attempt := func() error { + opctx, opCancel := context.WithTimeout(ctx, connectionTimeout) + defer opCancel() - if !isRecoverableError(err) { + err := op(opctx) + if err == nil { + return nil + } + if !isConnectionError(err) { + return backoff.Permanent(err) + } + if errors.Is(err, context.DeadlineExceeded) { + if lastDialErr != nil { + return backoff.Permanent(lastDialErr) + } + return err + } + lastDialErr = err return err } + err := backoff.Retry(attempt, backoff.WithContext(backoff.NewConstantBackOff(time.Second), ctx)) + // On overall-deadline expiry, report the last dial error rather than the + // context error (nil if attempts only ever timed out). if errors.Is(err, context.DeadlineExceeded) { - if lastErr != nil { - return lastErr - } - - err = nil - } - - delayTimer := time.NewTimer(delay) - select { - case <-delayTimer.C: - return retry(ctx, op, isRecoverableError, delay, err) - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return err - } - return ctx.Err() + return lastDialErr } + return err } type PingResult callResult diff --git a/rpc/requests/retry_test.go b/rpc/requests/retry_test.go new file mode 100644 index 00000000000..83ab8780b43 --- /dev/null +++ b/rpc/requests/retry_test.go @@ -0,0 +1,117 @@ +// 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 . + +package requests + +import ( + "context" + "errors" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func newDialError() *net.OpError { + return &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")} +} + +func sequenceOp(attempts *atomic.Int64, errs ...error) func(context.Context) error { + return func(context.Context) error { + n := attempts.Add(1) + if int(n) > len(errs) { + return errs[len(errs)-1] + } + return errs[n-1] + } +} + +func TestRetryConnectsSuccess(t *testing.T) { + t.Parallel() + var attempts atomic.Int64 + err := retryConnects(t.Context(), sequenceOp(&attempts, nil)) + require.NoError(t, err) + require.EqualValues(t, 1, attempts.Load()) +} + +func TestRetryConnectsNonRecoverableErrorNotRetried(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + var attempts atomic.Int64 + err := retryConnects(t.Context(), sequenceOp(&attempts, boom)) + require.ErrorIs(t, err, boom) + require.EqualValues(t, 1, attempts.Load()) +} + +func TestRetryConnectsDialErrorRetriedUntilSuccess(t *testing.T) { + t.Parallel() + var attempts atomic.Int64 + start := time.Now() + err := retryConnects(t.Context(), sequenceOp(&attempts, newDialError(), nil)) + require.NoError(t, err) + require.EqualValues(t, 2, attempts.Load()) + require.GreaterOrEqual(t, time.Since(start), 900*time.Millisecond) +} + +func TestRetryConnectsTimeoutAfterDialErrorReturnsDialError(t *testing.T) { + t.Parallel() + dialErr := newDialError() + var attempts atomic.Int64 + err := retryConnects(t.Context(), sequenceOp(&attempts, dialErr, context.DeadlineExceeded)) + require.ErrorIs(t, err, dialErr) + require.EqualValues(t, 2, attempts.Load()) +} + +func TestRetryConnectsTimeoutRetriedUntilSuccess(t *testing.T) { + t.Parallel() + var attempts atomic.Int64 + err := retryConnects(t.Context(), sequenceOp(&attempts, context.DeadlineExceeded, nil)) + require.NoError(t, err) + require.EqualValues(t, 2, attempts.Load()) +} + +func TestRetryConnectsParentDeadlineReturnsLastDialError(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 1200*time.Millisecond) + defer cancel() + dialErr := newDialError() + var attempts atomic.Int64 + err := retryConnects(ctx, sequenceOp(&attempts, dialErr)) + require.ErrorIs(t, err, dialErr) +} + +func TestRetryConnectsParentDeadlineAfterOnlyTimeoutsReturnsNil(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 1200*time.Millisecond) + defer cancel() + var attempts atomic.Int64 + err := retryConnects(ctx, sequenceOp(&attempts, context.DeadlineExceeded)) + require.NoError(t, err) +} + +func TestRetryConnectsParentCancel(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + var attempts atomic.Int64 + err := retryConnects(ctx, sequenceOp(&attempts, newDialError())) + require.ErrorIs(t, err, context.Canceled) +} From 3b7f9a9cffd63757b2ad6a3b41815d885b3b2200 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:43:00 +0200 Subject: [PATCH 05/10] cl, db/snapshotsync: read compressed blobs directly instead of pooled buffer copies The CL read paths (historical-states dumps/diffs, beacon-block decompression) only wrapped an in-memory slice in a pooled buffer to read it back; feed bytes.NewReader to the zstd reader instead, dropping both the copy and the pool round-trip. These blobs routinely exceed common/pool's 1MB Put cap on mainnet (a full balances dump is ~16MB raw), so pooling bought nothing there anyway. The antiquary's multi-MB diff writer now reuses the collector-owned buffer like the sibling helpers in the same file. Also remove the dead plainBytesBufferPool in base_encoding. --- cl/antiquary/antiquary_test.go | 4 +- cl/antiquary/beacon_states_collector.go | 16 +++-- cl/persistence/base_encoding/uint64_diff.go | 33 +++-------- .../historical_states_reader.go | 59 +++---------------- .../freezeblocks/beacon_block_reader.go | 27 +++------ .../freezeblocks/caplin_snapshots.go | 10 +--- 6 files changed, 34 insertions(+), 115 deletions(-) diff --git a/cl/antiquary/antiquary_test.go b/cl/antiquary/antiquary_test.go index 1a43fdb675a..3d6910f4cd0 100644 --- a/cl/antiquary/antiquary_test.go +++ b/cl/antiquary/antiquary_test.go @@ -266,7 +266,7 @@ func TestAntiquateBytesListDiff(t *testing.T) { return err } - require.NoError(t, antiquateBytesListDiff(context.Background(), key, old, newData, collector, simpleDiff)) + require.NoError(t, antiquateBytesListDiff(context.Background(), key, old, newData, &bytes.Buffer{}, collector, simpleDiff)) collected := collectAll(t, collector) result, ok := collected[string(key)] @@ -291,7 +291,7 @@ func TestAntiquateBytesListDiff_WithRealDiffFn(t *testing.T) { key := base_encoding.Encode64ToBytes4(99) require.NoError(t, antiquateBytesListDiff( - context.Background(), key, old, newData, collector, + context.Background(), key, old, newData, &bytes.Buffer{}, collector, base_encoding.ComputeCompressedSerializedUint64ListDiff, )) diff --git a/cl/antiquary/beacon_states_collector.go b/cl/antiquary/beacon_states_collector.go index 135379e8a46..db3f8336639 100644 --- a/cl/antiquary/beacon_states_collector.go +++ b/cl/antiquary/beacon_states_collector.go @@ -32,7 +32,6 @@ import ( "github.com/erigontech/erigon/cl/transition/impl/eth2" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" - "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/etl" "github.com/erigontech/erigon/db/kv" ) @@ -520,11 +519,11 @@ func (i *beaconStatesCollector) collectStateEvents(slot uint64, events *state_ac } func (i *beaconStatesCollector) collectBalancesDiffs(ctx context.Context, slot uint64, old, new []byte) error { - return antiquateBytesListDiff(ctx, base_encoding.Encode64ToBytes4(slot), old, new, i.balancesCollector, base_encoding.ComputeCompressedSerializedUint64ListDiff) + return antiquateBytesListDiff(ctx, base_encoding.Encode64ToBytes4(slot), old, new, i.buf, i.balancesCollector, base_encoding.ComputeCompressedSerializedUint64ListDiff) } func (i *beaconStatesCollector) collectEffectiveBalancesDiffs(ctx context.Context, slot uint64, oldValidatorSetSSZ, newValidatorSetSSZ []byte) error { - return antiquateBytesListDiff(ctx, base_encoding.Encode64ToBytes4(slot), oldValidatorSetSSZ, newValidatorSetSSZ, i.effectiveBalanceCollector, base_encoding.ComputeCompressedSerializedEffectiveBalancesDiff) + return antiquateBytesListDiff(ctx, base_encoding.Encode64ToBytes4(slot), oldValidatorSetSSZ, newValidatorSetSSZ, i.buf, i.effectiveBalanceCollector, base_encoding.ComputeCompressedSerializedEffectiveBalancesDiff) } func (i *beaconStatesCollector) collectInactivityScores(slot uint64, inactivityScores []byte) error { @@ -718,14 +717,13 @@ func antiquateListSSZ[T solid.EncodableHashableSSZ](ctx context.Context, slot ui return collector.Collect(base_encoding.Encode64ToBytes4(roundedSlot), buffer.Bytes()) } -func antiquateBytesListDiff(ctx context.Context, key []byte, old, new []byte, collector *etl.Collector, diffFn func(w io.Writer, old, new []byte) error) error { - // create a diff - diffBuffer := pool.GetBuffer() - defer pool.PutBuffer(diffBuffer) +func antiquateBytesListDiff(ctx context.Context, key []byte, old, new []byte, buffer *bytes.Buffer, collector *etl.Collector, diffFn func(w io.Writer, old, new []byte) error) error { + buffer.Reset() - if err := diffFn(diffBuffer, old, new); err != nil { + // create a diff + if err := diffFn(buffer, old, new); err != nil { return err } - return collector.Collect(key, diffBuffer.Bytes()) + return collector.Collect(key, buffer.Bytes()) } diff --git a/cl/persistence/base_encoding/uint64_diff.go b/cl/persistence/base_encoding/uint64_diff.go index 9c43180a137..53a5769d2a0 100644 --- a/cl/persistence/base_encoding/uint64_diff.go +++ b/cl/persistence/base_encoding/uint64_diff.go @@ -25,8 +25,6 @@ import ( "sync" "github.com/klauspost/compress/zstd" - - "github.com/erigontech/erigon/common/pool" ) // make a sync.pool of compressors (zstd) @@ -52,13 +50,6 @@ var plainUint64BufferPool = sync.Pool{ }, } -var plainBytesBufferPool = sync.Pool{ - New: func() any { - b := make([]byte, 1028) - return &b - }, -} - var repeatedPatternBufferPool = sync.Pool{ New: func() any { b := make([]repeatedPatternEntry, 1028) @@ -204,20 +195,15 @@ func ComputeCompressedSerializedEffectiveBalancesDiff(w io.Writer, old, new []by func ApplyCompressedSerializedUint64ListDiff(in, out []byte, diff []byte, reverse bool) ([]byte, error) { out = out[:0] - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - if _, err := buffer.Write(diff); err != nil { - return nil, err - } + reader := bytes.NewReader(diff) var length uint32 - if err := binary.Read(buffer, binary.BigEndian, &length); err != nil { + if err := binary.Read(reader, binary.BigEndian, &length); err != nil { return nil, err } var entry repeatedPatternEntry - decompressor, err := zstd.NewReader(buffer) + decompressor, err := zstd.NewReader(reader) if err != nil { return nil, err } @@ -302,18 +288,13 @@ func ApplyCompressedSerializedValidatorListDiff(in, out []byte, diff []byte, rev } out = out[:len(in)] - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - if _, err := buffer.Write(diff); err != nil { - return nil, err - } + reader := bytes.NewReader(diff) currValidator := make([]byte, 121) for { var index uint32 - if err := binary.Read(buffer, binary.BigEndian, &index); err != nil { + if err := binary.Read(reader, binary.BigEndian, &index); err != nil { if errors.Is(err, io.EOF) { break } @@ -322,7 +303,7 @@ func ApplyCompressedSerializedValidatorListDiff(in, out []byte, diff []byte, rev if index == 1<<31 { break } - n, err := io.ReadFull(buffer, currValidator) + n, err := io.ReadFull(reader, currValidator) if err != nil && !errors.Is(err, io.EOF) { return nil, err } @@ -336,7 +317,7 @@ func ApplyCompressedSerializedValidatorListDiff(in, out []byte, diff []byte, rev copy(out[index*121:], currValidator) } for { - n, err := io.ReadFull(buffer, currValidator) + n, err := io.ReadFull(reader, currValidator) if err != nil && !errors.Is(err, io.EOF) { return nil, err } diff --git a/cl/persistence/state/historical_states_reader/historical_states_reader.go b/cl/persistence/state/historical_states_reader/historical_states_reader.go index a4b29355210..2b45cc7cd99 100644 --- a/cl/persistence/state/historical_states_reader/historical_states_reader.go +++ b/cl/persistence/state/historical_states_reader/historical_states_reader.go @@ -37,7 +37,6 @@ import ( "github.com/erigontech/erigon/cl/phase1/core/state/lru" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" - "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/snapshotsync" "github.com/erigontech/erigon/db/snapshotsync/freezeblocks" @@ -569,15 +568,8 @@ func (r *HistoricalStatesReader) reconstructDiffedUint64List(tx kv.Tx, kvGetter return nil, fmt.Errorf("dump not found for slot %d", freshDumpSlot) } - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - if _, err := buffer.Write(compressed); err != nil { - return nil, err - } - // Read the diff file - zstdReader, err := zstd.NewReader(buffer) + zstdReader, err := zstd.NewReader(bytes.NewReader(compressed)) if err != nil { return nil, err } @@ -640,9 +632,6 @@ func (r *HistoricalStatesReader) reconstructBalances(tx kv.Tx, kvGetter state_ac remainder := slot % clparams.SlotsPerDump freshDumpSlot := slot - remainder - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - var compressed []byte currentStageProgress, err := state_accessors.GetStateProcessingProgress(tx) if err != nil { @@ -665,10 +654,7 @@ func (r *HistoricalStatesReader) reconstructBalances(tx kv.Tx, kvGetter state_ac if len(compressed) == 0 { return nil, fmt.Errorf("dump not found for slot %d", freshDumpSlot) } - if _, err := buffer.Write(compressed); err != nil { - return nil, err - } - zstdReader, err := zstd.NewReader(buffer) + zstdReader, err := zstd.NewReader(bytes.NewReader(compressed)) if err != nil { return nil, err } @@ -1052,21 +1038,13 @@ func ReadQueueSSZ[T solid.EncodableHashableSSZ](kvGetter state_accessors.GetValF remainder := slot % clparams.SlotsPerDump freshDumpSlot := slot - remainder - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - var compressed []byte - compressed, err := kvGetter(dumpTable, base_encoding.Encode64ToBytes4(freshDumpSlot)) if err != nil { return err } if len(compressed) != 0 { - if _, err := buffer.Write(compressed); err != nil { - return err - } - zstdReader, err := zstd.NewReader(buffer) + zstdReader, err := zstd.NewReader(bytes.NewReader(compressed)) if err != nil { return err } @@ -1083,7 +1061,6 @@ func ReadQueueSSZ[T solid.EncodableHashableSSZ](kvGetter state_accessors.GetValF } for currSlot := freshDumpSlot + 1; currSlot <= slot; currSlot++ { - buffer.Reset() key := base_encoding.Encode64ToBytes4(currSlot) v, err := kvGetter(diffsTable, key) if err != nil { @@ -1093,11 +1070,7 @@ func ReadQueueSSZ[T solid.EncodableHashableSSZ](kvGetter state_accessors.GetValF continue } - if _, err := buffer.Write(v); err != nil { - return err - } - - if err := base_encoding.ApplySSZQueueDiff(buffer, out, 0); err != nil { + if err := base_encoding.ApplySSZQueueDiff(bytes.NewReader(v), out, 0); err != nil { return err } } @@ -1121,14 +1094,8 @@ func ReadRequiredQueueSSZ[T solid.EncodableHashableSSZ](kvGetter state_accessors return fmt.Errorf("%w: table %s, slot %d", ErrMissingGloasData, dumpTable, slot) } - // Decompress and decode the dump (reuse buffer pool). - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - if _, err := buffer.Write(compressed); err != nil { - return err - } - zstdReader, err := zstd.NewReader(buffer) + // Decompress and decode the dump. + zstdReader, err := zstd.NewReader(bytes.NewReader(compressed)) if err != nil { return err } @@ -1143,7 +1110,6 @@ func ReadRequiredQueueSSZ[T solid.EncodableHashableSSZ](kvGetter state_accessors // Apply per-slot diffs from dump+1 to target slot. for currSlot := freshDumpSlot + 1; currSlot <= slot; currSlot++ { - buffer.Reset() key := base_encoding.Encode64ToBytes4(currSlot) v, err := kvGetter(diffsTable, key) if err != nil { @@ -1152,10 +1118,7 @@ func ReadRequiredQueueSSZ[T solid.EncodableHashableSSZ](kvGetter state_accessors if len(v) == 0 { continue } - if _, err := buffer.Write(v); err != nil { - return err - } - if err := base_encoding.ApplySSZQueueDiff(buffer, out, 0); err != nil { + if err := base_encoding.ApplySSZQueueDiff(bytes.NewReader(v), out, 0); err != nil { return err } } @@ -1185,13 +1148,7 @@ func readCompressedSSZ[T interface { return fmt.Errorf("%w: table %s, slot %d", ErrMissingGloasData, table, slot) } - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - if _, err := buffer.Write(compressed); err != nil { - return err - } - zstdReader, err := zstd.NewReader(buffer) + zstdReader, err := zstd.NewReader(bytes.NewReader(compressed)) if err != nil { return err } diff --git a/db/snapshotsync/freezeblocks/beacon_block_reader.go b/db/snapshotsync/freezeblocks/beacon_block_reader.go index f21c3316691..35b562cb941 100644 --- a/db/snapshotsync/freezeblocks/beacon_block_reader.go +++ b/db/snapshotsync/freezeblocks/beacon_block_reader.go @@ -17,6 +17,7 @@ package freezeblocks import ( + "bytes" "context" "fmt" "sync" @@ -28,7 +29,6 @@ import ( "github.com/erigontech/erigon/cl/persistence/beacon_indicies" "github.com/erigontech/erigon/cl/persistence/format/snapshot_format" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbutils" "github.com/erigontech/erigon/execution/types" @@ -131,15 +131,11 @@ func (r *beaconSnapshotReader) ReadBlockBySlot(ctx context.Context, tx kv.Tx, sl } // Decompress this thing - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - buffer.Write(buf) reader := decompressorPool.Get().(*zstd.Decoder) defer decompressorPool.Put(reader) - reader.Reset(buffer) + reader.Reset(bytes.NewReader(buf)) - // Use pooled buffers and readers to avoid allocations. + // Use pooled readers to avoid allocations. return snapshot_format.ReadBlockFromSnapshot(reader, r.eth1Getter, r.cfg) } @@ -192,15 +188,11 @@ func (r *beaconSnapshotReader) ReadBeaconBlockBodyBySlot(ctx context.Context, tx } // Decompress this thing - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - buffer.Write(buf) reader := decompressorPool.Get().(*zstd.Decoder) defer decompressorPool.Put(reader) - reader.Reset(buffer) + reader.Reset(bytes.NewReader(buf)) - // Use pooled buffers and readers to avoid allocations. + // Use pooled readers to avoid allocations. return snapshot_format.ReadBeaconBlockBodyFromSnapshot(reader, r.cfg) } @@ -269,15 +261,11 @@ func (r *beaconSnapshotReader) ReadBlockByRoot(ctx context.Context, tx kv.Tx, ro return nil, nil } // Decompress this thing - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - buffer.Write(buf) reader := decompressorPool.Get().(*zstd.Decoder) defer decompressorPool.Put(reader) - reader.Reset(buffer) + reader.Reset(bytes.NewReader(buf)) - // Use pooled buffers and readers to avoid allocations. + // Use pooled readers to avoid allocations. return snapshot_format.ReadBlockFromSnapshot(reader, r.eth1Getter, r.cfg) } @@ -308,6 +296,5 @@ func (r *beaconSnapshotReader) ReadHeaderByRoot(ctx context.Context, tx kv.Tx, r } h, _, _, err := r.sn.ReadHeader(*slot, tx) - // Use pooled buffers and readers to avoid allocations. return h, err } diff --git a/db/snapshotsync/freezeblocks/caplin_snapshots.go b/db/snapshotsync/freezeblocks/caplin_snapshots.go index 7b6b2258c01..86341830772 100644 --- a/db/snapshotsync/freezeblocks/caplin_snapshots.go +++ b/db/snapshotsync/freezeblocks/caplin_snapshots.go @@ -17,6 +17,7 @@ package freezeblocks import ( + "bytes" "context" "errors" "fmt" @@ -39,7 +40,6 @@ import ( "github.com/erigontech/erigon/common/background" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" - "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/datadir" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbutils" @@ -713,15 +713,11 @@ func (s *CaplinSnapshots) ReadHeader(slot uint64, tx kv.Tx) (*cltypes.SignedBeac return nil, 0, common.Hash{}, nil } // Decompress this thing - buffer := pool.GetBuffer() - defer pool.PutBuffer(buffer) - - buffer.Write(buf) reader := decompressorPool.Get().(*zstd.Decoder) defer decompressorPool.Put(reader) - reader.Reset(buffer) + reader.Reset(bytes.NewReader(buf)) - // Use pooled buffers and readers to avoid allocations. + // Use pooled readers to avoid allocations. header, elBlockNumber, elBlockHash, err := snapshot_format.ReadBlockHeaderFromSnapshotWithExecutionData(reader, s.beaconCfg) if err != nil { return nil, 0, common.Hash{}, err From f79ce705bf8b4d64fb7f587385f757a48dadd924 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:43:10 +0200 Subject: [PATCH 06/10] rpc/requests: don't swallow errors on retryConnects deadline expiry Two error-swallowing fixes, each pinned by a test written red-first: - Overall-deadline expiry after only per-attempt timeouts returned nil, so callers saw success with an unpopulated result. Return context.DeadlineExceeded instead. - The final deadline-to-dial-error remap matched any error wrapping DeadlineExceeded, including a permanent one (e.g. a non-dial net.OpError joined with the deadline sentinel), replacing it with the stale dial error or nil. Require the overall context to have actually expired. --- rpc/requests/request_generator.go | 7 ++++--- rpc/requests/retry_test.go | 13 +++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/rpc/requests/request_generator.go b/rpc/requests/request_generator.go index efc271f7074..f87bf0f3e16 100644 --- a/rpc/requests/request_generator.go +++ b/rpc/requests/request_generator.go @@ -259,9 +259,10 @@ func retryConnects(ctx context.Context, op func(context.Context) error) error { } err := backoff.Retry(attempt, backoff.WithContext(backoff.NewConstantBackOff(time.Second), ctx)) - // On overall-deadline expiry, report the last dial error rather than the - // context error (nil if attempts only ever timed out). - if errors.Is(err, context.DeadlineExceeded) { + // On overall-deadline expiry, prefer reporting the last dial error. The + // ctx.Err() conjunct keeps a permanent error that merely wraps + // DeadlineExceeded intact. + if lastDialErr != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) && errors.Is(err, context.DeadlineExceeded) { return lastDialErr } return err diff --git a/rpc/requests/retry_test.go b/rpc/requests/retry_test.go index 83ab8780b43..11a1e51545c 100644 --- a/rpc/requests/retry_test.go +++ b/rpc/requests/retry_test.go @@ -95,13 +95,22 @@ func TestRetryConnectsParentDeadlineReturnsLastDialError(t *testing.T) { require.ErrorIs(t, err, dialErr) } -func TestRetryConnectsParentDeadlineAfterOnlyTimeoutsReturnsNil(t *testing.T) { +func TestRetryConnectsParentDeadlineAfterOnlyTimeoutsReturnsDeadlineExceeded(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), 1200*time.Millisecond) defer cancel() var attempts atomic.Int64 err := retryConnects(ctx, sequenceOp(&attempts, context.DeadlineExceeded)) - require.NoError(t, err) + require.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestRetryConnectsPermanentErrorWrappingDeadlineExceededNotSwallowed(t *testing.T) { + t.Parallel() + readErr := &net.OpError{Op: "read", Net: "tcp", Err: errors.New("connection reset")} + var attempts atomic.Int64 + err := retryConnects(t.Context(), sequenceOp(&attempts, errors.Join(readErr, context.DeadlineExceeded))) + require.ErrorIs(t, err, readErr) + require.EqualValues(t, 1, attempts.Load()) } func TestRetryConnectsParentCancel(t *testing.T) { From 03b879771d47b86ef3da0b75fcbbc23f9dd76000 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:43:19 +0200 Subject: [PATCH 07/10] node, common/pool: use shared buffer pool for gzip responses node/rpcstack's gzBufPool duplicated common/pool exactly, down to the 1MB drop cap. Its cap-threshold test moves to common/pool as unit tests of the shared pool's reset-on-get and drop-oversized semantics. gzDstPool stays: it pools []byte for libdeflate, not bytes.Buffer. --- common/pool/pool_test.go | 45 ++++++++++++++++++++++++++++++ node/rpcstack.go | 23 +++------------ node/rpcstack_gzip_handler_test.go | 26 +++-------------- 3 files changed, 53 insertions(+), 41 deletions(-) create mode 100644 common/pool/pool_test.go diff --git a/common/pool/pool_test.go b/common/pool/pool_test.go new file mode 100644 index 00000000000..16e6086b29d --- /dev/null +++ b/common/pool/pool_test.go @@ -0,0 +1,45 @@ +// 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 . + +package pool + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetBufferReturnsEmptyBuffer(t *testing.T) { + buf := GetBuffer() + buf.WriteString("dirty") + PutBuffer(buf) + + fresh := GetBuffer() + defer PutBuffer(fresh) + require.Zero(t, fresh.Len()) +} + +func TestPutBufferDropsOversized(t *testing.T) { + buf := GetBuffer() + buf.Grow(maxBufferCap + 1) + require.Greater(t, buf.Cap(), maxBufferCap) + PutBuffer(buf) + + // Any buffer obtained from the pool now must be within the cap limit. + fresh := GetBuffer() + defer PutBuffer(fresh) + require.LessOrEqual(t, fresh.Cap(), maxBufferCap) +} diff --git a/node/rpcstack.go b/node/rpcstack.go index 8aaa6858bbf..a90258f1f39 100644 --- a/node/rpcstack.go +++ b/node/rpcstack.go @@ -36,6 +36,7 @@ import ( "github.com/rs/cors" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/common/pool" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/diagnostics/metrics" "github.com/erigontech/erigon/rpc" @@ -182,7 +183,7 @@ func (h *virtualHostHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid host specified", http.StatusForbidden) } -// gzPoolBufCap is the maximum buffer capacity retained in pools to bound RSS growth. +// gzPoolBufCap is the maximum dst capacity retained in the pool to bound RSS growth. const gzPoolBufCap = 1 << 20 // minGzipBodySize is the minimum response body size to compress. Responses @@ -211,26 +212,10 @@ var gzCompressorPool = sync.Pool{ }, } -var gzBufPool = sync.Pool{ - New: func() any { return new(bytes.Buffer) }, -} - var gzDstPool = sync.Pool{ New: func() any { return make([]byte, 0, 64*1024) }, } -func getBuf() *bytes.Buffer { - buf := gzBufPool.Get().(*bytes.Buffer) - buf.Reset() - return buf -} - -func putBuf(buf *bytes.Buffer) { - if buf.Cap() <= gzPoolBufCap { - gzBufPool.Put(buf) - } -} - func putDst(dst []byte) { if cap(dst) <= gzPoolBufCap { gzDstPool.Put(dst) @@ -330,7 +315,7 @@ func compressLibdeflate(w http.ResponseWriter, src []byte, status int) bool { } func sendGzipResponse(w http.ResponseWriter, grw *gzipResponseWriter) { - defer putBuf(grw.buf) + defer pool.PutBuffer(grw.buf) if grw.gzw != nil { defer gzPool.Put(grw.gzw) @@ -360,7 +345,7 @@ func newGzipHandler(next http.Handler) http.Handler { return } - grw := &gzipResponseWriter{buf: getBuf(), ResponseWriter: w} + grw := &gzipResponseWriter{buf: pool.GetBuffer(), ResponseWriter: w} // The hook activates streaming mode before the first write; absent when gzip // is off so it cannot prematurely commit HTTP headers (e.g. 200 before 503). r = r.WithContext(rpc.WithGzipStreamingHook(r.Context(), grw.Flush)) diff --git a/node/rpcstack_gzip_handler_test.go b/node/rpcstack_gzip_handler_test.go index 10365a3dae7..78e40bdbb24 100644 --- a/node/rpcstack_gzip_handler_test.go +++ b/node/rpcstack_gzip_handler_test.go @@ -27,6 +27,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/pool" ) // decompressGzip reads a gzip-compressed body and returns the raw bytes. @@ -155,7 +157,7 @@ func TestGzipHandlerStatusStreaming(t *testing.T) { // TestGzipHandlerLargeBody verifies that a response body larger than // gzPoolBufCap (1 MiB) is compressed correctly. This exercises the pool-cap -// path: the oversized buffer must not be returned to gzBufPool. +// path: the oversized buffer must not be returned to the shared buffer pool. func TestGzipHandlerLargeBody(t *testing.T) { body := bytes.Repeat([]byte("x"), gzPoolBufCap+1) handler := newGzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -173,8 +175,7 @@ func TestGzipHandlerLargeBody(t *testing.T) { // mode and that buffered bytes are drained into the gzip writer. func TestGzipResponseWriterFlushActivatesStreaming(t *testing.T) { rec := httptest.NewRecorder() - buf := gzBufPool.Get().(*bytes.Buffer) - buf.Reset() + buf := pool.GetBuffer() grw := &gzipResponseWriter{buf: buf, ResponseWriter: rec} // Write into the buffer before activating streaming. @@ -193,22 +194,3 @@ func TestGzipResponseWriterFlushActivatesStreaming(t *testing.T) { got := decompressGzip(t, rec.Body) assert.Equal(t, []byte("pre-flush post-flush"), got) } - -// TestGzipBufPoolCapThreshold verifies that the cap guard does not return -// an oversized buffer to gzBufPool. -func TestGzipBufPoolCapThreshold(t *testing.T) { - buf := gzBufPool.Get().(*bytes.Buffer) - buf.Grow(gzPoolBufCap + 1) - require.Greater(t, buf.Cap(), gzPoolBufCap) - - // Simulate the handler's cap check: large buffer is NOT returned. - if buf.Cap() <= gzPoolBufCap { - gzBufPool.Put(buf) - } - - // Any buffer obtained from the pool now must be within the cap limit. - fresh := gzBufPool.Get().(*bytes.Buffer) - defer gzBufPool.Put(fresh) - assert.LessOrEqual(t, fresh.Cap(), gzPoolBufCap, - "pool must not contain a buffer larger than gzPoolBufCap") -} From bc91a3143ba59a777ac065e19b54d51c0fb5e699 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:47:26 +0200 Subject: [PATCH 08/10] rpc/requests: convert test attempt counter to int once --- rpc/requests/retry_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rpc/requests/retry_test.go b/rpc/requests/retry_test.go index 11a1e51545c..ff93849ff5d 100644 --- a/rpc/requests/retry_test.go +++ b/rpc/requests/retry_test.go @@ -33,8 +33,8 @@ func newDialError() *net.OpError { func sequenceOp(attempts *atomic.Int64, errs ...error) func(context.Context) error { return func(context.Context) error { - n := attempts.Add(1) - if int(n) > len(errs) { + n := int(attempts.Add(1)) + if n > len(errs) { return errs[len(errs)-1] } return errs[n-1] From 6dc18bb4a7eeae1b47b5a7f26c70be54c9318397 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 7 Jul 2026 20:45:17 +0200 Subject: [PATCH 09/10] node: use a plain buffer in the gzip Flush unit test This unit test exercises gzipResponseWriter.Flush(); the buffer's origin is irrelevant to it, so borrowing from the shared common/pool (without returning it) added coupling for no benefit. Use a plain bytes.Buffer. --- node/rpcstack_gzip_handler_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/node/rpcstack_gzip_handler_test.go b/node/rpcstack_gzip_handler_test.go index 78e40bdbb24..fdb2678e787 100644 --- a/node/rpcstack_gzip_handler_test.go +++ b/node/rpcstack_gzip_handler_test.go @@ -27,8 +27,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/erigontech/erigon/common/pool" ) // decompressGzip reads a gzip-compressed body and returns the raw bytes. @@ -175,7 +173,7 @@ func TestGzipHandlerLargeBody(t *testing.T) { // mode and that buffered bytes are drained into the gzip writer. func TestGzipResponseWriterFlushActivatesStreaming(t *testing.T) { rec := httptest.NewRecorder() - buf := pool.GetBuffer() + buf := &bytes.Buffer{} grw := &gzipResponseWriter{buf: buf, ResponseWriter: rec} // Write into the buffer before activating streaming. From e08b9b20c929c6a601382c9b03284114bd315674 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 7 Jul 2026 21:03:00 +0200 Subject: [PATCH 10/10] rpc/requests: don't let last dial error clobber a permanent deadline-wrapped error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On overall-deadline expiry retryConnects reports the last dial error that backoff.Retry discarded. The guard used errors.Is(err, DeadlineExceeded), which also matches a permanent (non-connection) error that merely wraps DeadlineExceeded — so once a dial error had been seen and the overall deadline had expired, such a permanent error was swallowed and the older dial error returned instead. backoff.Retry surfaces the context's error verbatim (the bare sentinel) only when it gives up on the overall deadline; a permanent error comes back as its own wrapped value. Compare by identity to tell the two apart, which also makes the now-redundant ctx.Err() conjunct unnecessary. Pinned by a red-first test. --- rpc/requests/request_generator.go | 9 +++++---- rpc/requests/retry_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/rpc/requests/request_generator.go b/rpc/requests/request_generator.go index f87bf0f3e16..8eb5f8391c0 100644 --- a/rpc/requests/request_generator.go +++ b/rpc/requests/request_generator.go @@ -259,10 +259,11 @@ func retryConnects(ctx context.Context, op func(context.Context) error) error { } err := backoff.Retry(attempt, backoff.WithContext(backoff.NewConstantBackOff(time.Second), ctx)) - // On overall-deadline expiry, prefer reporting the last dial error. The - // ctx.Err() conjunct keeps a permanent error that merely wraps - // DeadlineExceeded intact. - if lastDialErr != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) && errors.Is(err, context.DeadlineExceeded) { + // backoff.Retry surfaces the overall context's error verbatim when it gives + // up; on deadline expiry, report the last dial error it discarded. Compared + // by identity so a permanent error that merely wraps DeadlineExceeded (from + // backoff.Permanent) is left intact. + if lastDialErr != nil && err == context.DeadlineExceeded { return lastDialErr } return err diff --git a/rpc/requests/retry_test.go b/rpc/requests/retry_test.go index ff93849ff5d..4ab4dfdd7ff 100644 --- a/rpc/requests/retry_test.go +++ b/rpc/requests/retry_test.go @@ -124,3 +124,31 @@ func TestRetryConnectsParentCancel(t *testing.T) { err := retryConnects(ctx, sequenceOp(&attempts, newDialError())) require.ErrorIs(t, err, context.Canceled) } + +// TestRetryConnectsOverallDeadlineDoesNotClobberPermanentError pins that on +// overall-deadline expiry the last dial error replaces only the context's own +// deadline error, never a permanent error that merely wraps DeadlineExceeded. +func TestRetryConnectsOverallDeadlineDoesNotClobberPermanentError(t *testing.T) { + t.Parallel() + // Deadline lands after the first 1s backoff (so a second attempt runs) but + // within the per-attempt window (so the second op observes it via its ctx). + ctx, cancel := context.WithTimeout(t.Context(), 1400*time.Millisecond) + defer cancel() + + dialErr := newDialError() + readErr := &net.OpError{Op: "read", Net: "tcp", Err: errors.New("connection reset")} + permanentErr := errors.Join(readErr, context.DeadlineExceeded) + var attempts atomic.Int64 + op := func(opctx context.Context) error { + if attempts.Add(1) == 1 { + return dialErr // recoverable → recorded as lastDialErr + } + <-opctx.Done() // let the overall deadline expire before returning + return permanentErr + } + + err := retryConnects(ctx, op) + require.EqualValues(t, 2, attempts.Load()) + require.ErrorIs(t, err, readErr) + require.NotErrorIs(t, err, dialErr) +}