Skip to content

Commit 04098aa

Browse files
authored
cl, sentinel: quiet data column sidecar misses (#21686)
## Summary Fixes the data column sidecar miss path so expected misses no longer produce noisy download logs, while keeping the wire behavior compatible with peers that signal an empty multi-chunk response by closing without a response code. Closes #21670 ## Details - Return structured req/resp `resource unavailable` responses for by-root misses and valid pre-Fulu by-root requests, while validating malformed pre-Fulu by-root requests first so they return `invalid request`. - Restore by-range empty-success responses to a zero-byte close, including zero-count, all-pre-Fulu, all-future, and missing-sidecar ranges. - In the httpreqresp client bridge, synthesize success code 0 with an empty body when a negotiated multi-chunk protocol returns `io.EOF` before any response-code byte. Single-chunk EOFs, partial reads, and stream errors still surface as HTTP 400. - Keep handler `responseErr` propagation for real storage/write errors. Across the Sentinel HTTP/gRPC boundary those transport failures can still be flattened by gRPC, while structured peer response codes are surfaced as typed `PeerResponseError` values. - Bound peer error-message decoding to a 10-byte varint prefix and a 256-byte decoded message. - Downgrade expected data column sidecar misses to trace while keeping other download errors at debug. ## Validation - `go test ./cl/sentinel/httpreqresp ./cl/sentinel/service ./cl/sentinel/handlers ./cl/das -count=1` - `make lint`
1 parent cecf3ad commit 04098aa

8 files changed

Lines changed: 908 additions & 79 deletions

File tree

cl/das/peer_das.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/erigontech/erigon/cl/phase1/core/state/lru"
2020
gossipmgr "github.com/erigontech/erigon/cl/phase1/network/gossip"
2121
"github.com/erigontech/erigon/cl/rpc"
22+
"github.com/erigontech/erigon/cl/sentinel/httpreqresp"
2223
"github.com/erigontech/erigon/cl/utils/eth_clock"
2324
"github.com/erigontech/erigon/common"
2425
"github.com/erigontech/erigon/common/crypto/kzg"
@@ -822,6 +823,10 @@ mainloop:
822823
}
823824
case result := <-resultChan:
824825
if result.err != nil {
826+
if isExpectedColumnDownloadMiss(result.err) {
827+
log.Trace("column sidecars unavailable from peer", "pid", result.pid, "err", result.err)
828+
continue
829+
}
825830
log.Debug("failed to download columns from peer", "pid", result.pid, "err", result.err)
826831
//d.rpc.BanPeer(result.pid)
827832
continue
@@ -941,6 +946,17 @@ mainloop:
941946
return nil
942947
}
943948

949+
func isExpectedColumnDownloadMiss(err error) bool {
950+
if err == nil {
951+
return false
952+
}
953+
var peerErr *httpreqresp.PeerResponseError
954+
if errors.As(err, &peerErr) {
955+
return peerErr.Code == httpreqresp.ResponseCodeResourceUnavailable
956+
}
957+
return false
958+
}
959+
944960
type downloadTableEntry struct {
945961
blockRoot common.Hash
946962
slot uint64

cl/das/peer_das_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2026 The Erigon Authors
2+
// This file is part of Erigon.
3+
//
4+
// Erigon is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Lesser General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Erigon is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Lesser General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Lesser General Public License
15+
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package das
18+
19+
import (
20+
"errors"
21+
"fmt"
22+
"testing"
23+
24+
"github.com/stretchr/testify/require"
25+
26+
"github.com/erigontech/erigon/cl/sentinel/httpreqresp"
27+
)
28+
29+
func TestIsExpectedColumnDownloadMiss(t *testing.T) {
30+
require.False(t, isExpectedColumnDownloadMiss(nil))
31+
require.True(t, isExpectedColumnDownloadMiss(&httpreqresp.PeerResponseError{
32+
Code: httpreqresp.ResponseCodeResourceUnavailable,
33+
}))
34+
require.True(t, isExpectedColumnDownloadMiss(fmt.Errorf("column miss: %w", &httpreqresp.PeerResponseError{
35+
Code: httpreqresp.ResponseCodeResourceUnavailable,
36+
})))
37+
require.False(t, isExpectedColumnDownloadMiss(&httpreqresp.PeerResponseError{
38+
Code: httpreqresp.ResponseCodeServerError,
39+
Message: "broken",
40+
}))
41+
require.False(t, isExpectedColumnDownloadMiss(&httpreqresp.HTTPError{
42+
StatusCode: 400,
43+
Body: "Read Code: EOF",
44+
}))
45+
require.False(t, isExpectedColumnDownloadMiss(errors.New("peer error code: 2 (server error). Error message: broken")))
46+
}

cl/sentinel/handlers/data_cloumn_sidecar.go

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,40 +13,61 @@ import (
1313
"github.com/libp2p/go-libp2p/core/network"
1414
)
1515

16+
var errInvalidDataColumnIndex = errors.New("invalid column index")
17+
18+
func writeDataColumnSidecarsEmptySuccess(s network.Stream) error {
19+
return nil
20+
}
21+
1622
func (c *ConsensusHandlers) dataColumnSidecarsByRangeHandler(s network.Stream) error {
1723
curEpoch := c.ethClock.GetCurrentEpoch()
18-
if curEpoch < c.beaconConfig.FuluForkEpoch {
19-
return nil
20-
}
2124

2225
// Use current epoch's version for decoding (supports Fulu and GLOAS)
2326
version := c.beaconConfig.GetCurrentStateVersion(curEpoch)
2427
req := &cltypes.ColumnSidecarsByRangeRequest{}
2528
if err := ssz_snappy.DecodeAndReadNoForkDigest(s, req, version); err != nil {
26-
return err
29+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
30+
}
31+
if curEpoch < c.beaconConfig.FuluForkEpoch {
32+
return writeDataColumnSidecarsEmptySuccess(s)
2733
}
2834

2935
// check params.
3036
var (
31-
endSlot = req.StartSlot + req.Count
32-
startSlot = max(req.StartSlot, c.beaconConfig.FuluForkEpoch*c.beaconConfig.SlotsPerEpoch)
37+
fuluStartSlot = c.beaconConfig.FuluForkEpoch * c.beaconConfig.SlotsPerEpoch
38+
endSlot = req.StartSlot + req.Count
3339
)
34-
if endSlot-startSlot > c.beaconConfig.MinEpochsForDataColumnSidecarsRequests*c.beaconConfig.SlotsPerEpoch {
35-
return errors.New("request range is too large")
40+
if endSlot < req.StartSlot {
41+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
42+
}
43+
if req.Columns.Length() > int(c.beaconConfig.NumberOfColumns) {
44+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
3645
}
37-
solid.RangeErr(req.Columns, func(index int, columnIndex uint64, length int) error {
46+
if err := solid.RangeErr(req.Columns, func(index int, columnIndex uint64, length int) error {
3847
if columnIndex >= c.beaconConfig.NumberOfColumns {
39-
return errors.New("invalid column index")
48+
return errInvalidDataColumnIndex
4049
}
4150
return nil
42-
})
51+
}); err != nil {
52+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
53+
}
54+
if req.Count == 0 || req.Columns.Length() == 0 || endSlot <= fuluStartSlot {
55+
return writeDataColumnSidecarsEmptySuccess(s)
56+
}
57+
startSlot := max(req.StartSlot, fuluStartSlot)
58+
if endSlot-startSlot > c.beaconConfig.MinEpochsForDataColumnSidecarsRequests*c.beaconConfig.SlotsPerEpoch {
59+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
60+
}
4361

4462
// Consume additional rate-limit tokens: slots × columns per slot, capped at config max.
45-
if cost := min(int(req.Count)*req.Columns.Length(), int(c.beaconConfig.MaxRequestDataColumnSidecars)) - 1; !c.consumeRateLimit(s, cost) {
63+
if cost := dataColumnSidecarsRequestCost(endSlot-startSlot, uint64(req.Columns.Length()), c.beaconConfig.MaxRequestDataColumnSidecars); !c.consumeRateLimit(s, cost) {
4664
return nil
4765
}
4866

4967
curSlot := c.ethClock.GetCurrentSlot()
68+
if startSlot > curSlot {
69+
return writeDataColumnSidecarsEmptySuccess(s)
70+
}
5071

5172
tx, err := c.indiciesDB.BeginRo(c.ctx)
5273
if err != nil {
@@ -55,6 +76,7 @@ func (c *ConsensusHandlers) dataColumnSidecarsByRangeHandler(s network.Stream) e
5576
defer tx.Rollback()
5677

5778
count := 0
79+
var responseErr error
5880
for slot := startSlot; slot < endSlot; slot++ {
5981
if slot > curSlot {
6082
// slot is in the future
@@ -84,6 +106,7 @@ func (c *ConsensusHandlers) dataColumnSidecarsByRangeHandler(s network.Stream) e
84106
exists, err := c.dataColumnStorage.ColumnSidecarExists(c.ctx, slot, blockRoot, int64(columnIndex))
85107
if err != nil {
86108
log.Debug("failed to check if data column sidecar exists", "error", err)
109+
responseErr = err
87110
return false
88111
}
89112
if !exists {
@@ -94,56 +117,83 @@ func (c *ConsensusHandlers) dataColumnSidecarsByRangeHandler(s network.Stream) e
94117
forkDigest, err := c.ethClock.ComputeForkDigest(slot / c.beaconConfig.SlotsPerEpoch)
95118
if err != nil {
96119
log.Debug("failed to compute fork digest", "error", err)
120+
responseErr = err
97121
return false
98122
}
99123
if _, err := s.Write([]byte{SuccessfulResponsePrefix}); err != nil {
100124
log.Debug("failed to write success byte", "error", err)
125+
responseErr = err
101126
return false
102127
}
103128

104129
if _, err := s.Write(forkDigest[:]); err != nil {
105130
log.Debug("failed to write fork digest", "error", err)
131+
responseErr = err
106132
return false
107133
}
108134

109135
if err := c.dataColumnStorage.WriteStream(s, slot, blockRoot, columnIndex); err != nil {
110136
log.Debug("failed to write stream data column sidecar", "error", err)
137+
responseErr = err
111138
return false
112139
}
113140
count++
114141
return true
115142
})
143+
if responseErr != nil {
144+
break
145+
}
116146
if count >= int(c.beaconConfig.MaxRequestDataColumnSidecars) {
117147
// max number of sidecars reached
118148
break
119149
}
120150
}
121-
151+
if responseErr != nil {
152+
return responseErr
153+
}
154+
if count == 0 {
155+
return writeDataColumnSidecarsEmptySuccess(s)
156+
}
122157
return nil
123158
}
124159

125160
func (c *ConsensusHandlers) dataColumnSidecarsByRootHandler(s network.Stream) error {
126161
curEpoch := c.ethClock.GetCurrentEpoch()
127-
if curEpoch < c.beaconConfig.FuluForkEpoch {
128-
return nil
129-
}
130162

131163
// Use current epoch's version for decoding (supports Fulu and GLOAS)
132164
version := c.beaconConfig.GetCurrentStateVersion(curEpoch)
133165
req := solid.NewDynamicListSSZ[*cltypes.DataColumnsByRootIdentifier](int(c.beaconConfig.MaxRequestBlocksDeneb))
134166
if err := ssz_snappy.DecodeAndReadNoForkDigest(s, req, version); err != nil {
135-
return err
167+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
136168
}
137169
if req.Len() > int(c.beaconConfig.MaxRequestBlocksDeneb) {
138-
return errors.New("request is too large")
170+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
139171
}
140172

141173
// Consume additional rate-limit tokens: sum of column counts across all roots.
142174
totalColumns := 0
143175
for i := 0; i < req.Len(); i++ {
144-
totalColumns += req.Get(i).Columns.Length()
176+
columns := req.Get(i).Columns
177+
if columns.Length() > int(c.beaconConfig.NumberOfColumns) {
178+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
179+
}
180+
if err := solid.RangeErr(columns, func(index int, columnIndex uint64, length int) error {
181+
if columnIndex >= c.beaconConfig.NumberOfColumns {
182+
return errInvalidDataColumnIndex
183+
}
184+
return nil
185+
}); err != nil {
186+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, InvalidRequestPrefix)
187+
}
188+
totalColumns += columns.Length()
189+
}
190+
if curEpoch < c.beaconConfig.FuluForkEpoch {
191+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, ResourceUnavailablePrefix)
145192
}
146-
if cost := min(totalColumns, int(c.beaconConfig.MaxRequestDataColumnSidecars)) - 1; !c.consumeRateLimit(s, cost) {
193+
if totalColumns == 0 {
194+
return writeDataColumnSidecarsEmptySuccess(s)
195+
}
196+
if cost := dataColumnSidecarsRequestCost(1, uint64(totalColumns), c.beaconConfig.MaxRequestDataColumnSidecars); !c.consumeRateLimit(s, cost) {
147197
return nil
148198
}
149199

@@ -157,6 +207,7 @@ func (c *ConsensusHandlers) dataColumnSidecarsByRootHandler(s network.Stream) er
157207
defer tx.Rollback()
158208

159209
count := 0
210+
var responseErr error
160211
for i := 0; i < req.Len(); i++ {
161212
id := req.Get(i)
162213
blockRoot := id.BlockRoot
@@ -188,14 +239,11 @@ func (c *ConsensusHandlers) dataColumnSidecarsByRootHandler(s network.Stream) er
188239
// max number of sidecars reached
189240
return false
190241
}
191-
if columnIndex >= c.beaconConfig.NumberOfColumns {
192-
// skip invalid column index
193-
return true
194-
}
195242

196243
exists, err := c.dataColumnStorage.ColumnSidecarExists(c.ctx, *slot, blockRoot, int64(columnIndex))
197244
if err != nil {
198245
log.Debug("failed to check if data column sidecar exists", "error", err)
246+
responseErr = err
199247
return false
200248
}
201249
if !exists {
@@ -206,25 +254,52 @@ func (c *ConsensusHandlers) dataColumnSidecarsByRootHandler(s network.Stream) er
206254
forkDigest, err := c.ethClock.ComputeForkDigest(*slot / c.beaconConfig.SlotsPerEpoch)
207255
if err != nil {
208256
log.Debug("failed to compute fork digest", "error", err)
257+
responseErr = err
209258
return false
210259
}
211260
if _, err := s.Write([]byte{SuccessfulResponsePrefix}); err != nil {
212261
log.Debug("failed to write success byte", "error", err)
262+
responseErr = err
213263
return false
214264
}
215265

216266
if _, err := s.Write(forkDigest[:]); err != nil {
217267
log.Debug("failed to write fork digest", "error", err)
268+
responseErr = err
218269
return false
219270
}
220271

221272
if err := c.dataColumnStorage.WriteStream(s, *slot, blockRoot, columnIndex); err != nil {
222273
log.Debug("failed to write stream data column sidecar", "error", err)
274+
responseErr = err
223275
return false
224276
}
225277
count++
226278
return true
227279
})
280+
if responseErr != nil {
281+
break
282+
}
283+
}
284+
if responseErr != nil {
285+
return responseErr
286+
}
287+
if count == 0 {
288+
return ssz_snappy.EncodeAndWrite(s, &emptyString{}, ResourceUnavailablePrefix)
228289
}
229290
return nil
230291
}
292+
293+
func dataColumnSidecarsRequestCost(slots, columns, maxSidecars uint64) int {
294+
if slots == 0 || columns == 0 || maxSidecars == 0 {
295+
return 0
296+
}
297+
if slots > maxSidecars/columns {
298+
return int(maxSidecars) - 1
299+
}
300+
sidecars := slots * columns
301+
if sidecars > maxSidecars {
302+
sidecars = maxSidecars
303+
}
304+
return int(sidecars) - 1
305+
}

0 commit comments

Comments
 (0)