Skip to content

Commit b56ff74

Browse files
cfflsclaude
andauthored
eth/sequencer: cap coalesced published records at the store message limit (#2400)
The send path coalesces up to 64 transactions into one published record (commit 89143d7). It bounded a record only by that count and the 32 MB pending-input limit, never by the store's Redpanda max.message.bytes, so a run of large transactions (e.g. 64 x 32 KiB) produced a >1 MiB record. That draws a permanent MESSAGE_TOO_LARGE on produce; the ingress treats a produce failure as a fence and takeover replays the same record, so one oversized record wedges the writer and nothing behind it is preconfirmed. Cap a coalesced record at maxRecordBytes (max.message.bytes less a 4 KiB reserve for per-transaction protobuf framing, the prefix commitment, and the ingress's own recordFraming allowance), so bor never builds a record the ingress would reject. A lone transaction over the cap is still emitted and left for the store to judge, not dropped. Also give the terminal MALFORMED ack path a clearer message: with the cap in place a MALFORMED means the store's max.message.bytes is below this build's record cap, not an oversized record. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1e20c79 commit b56ff74

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

eth/sequencer/stream.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ type sent struct {
6363

6464
const maxTransactionsPerPublishedRecord = 64
6565

66+
// maxMessageBytes mirrors the store's Redpanda max.message.bytes: a larger
67+
// record draws a permanent MESSAGE_TOO_LARGE on produce and fails the publisher.
68+
const maxMessageBytes = 1 << 20
69+
70+
// recordSizeReserve leaves headroom for per-transaction protobuf framing and the
71+
// prefix commitment, so a capped record's proto.Size stays under what the ingress
72+
// accepts (max.message.bytes less its own recordFraming).
73+
const recordSizeReserve = 4 * 1024
74+
75+
const maxRecordBytes = maxMessageBytes - recordSizeReserve
76+
6677
const publishedRecordBatchDelay = 5 * time.Millisecond
6778

6879
// stallTracker watches ack progress from outside the send loop: a hung
@@ -344,7 +355,9 @@ func coalescePublishedRecord(items []journalItem, acked uint64) (*pb.Entry, []jo
344355
for _, raw := range candidate.GetTransactions() {
345356
candidateBytes += uint64(len(raw))
346357
}
347-
if candidateBytes > pendingInputLimit-inputBytes {
358+
// A lone transaction over the cap still ships via the single-record
359+
// return below; the store judges it rather than losing it here.
360+
if inputBytes+candidateBytes > uint64(maxRecordBytes) {
348361
break
349362
}
350363
batch = append(batch, item)
@@ -406,7 +419,9 @@ func (p *Publisher) handleAck(ack ackResult, inflight *[]sent) (streamResult, bo
406419
// resending in order is exact.
407420
return streamResult{reason: endTransport}, true
408421
default:
409-
p.fail("store rejected entry", "status", ack.status)
422+
// Terminal but safe: no fence, no resend. A MALFORMED here means the
423+
// store's max.message.bytes is below our record cap.
424+
p.fail("store rejected entry", "status", ack.status, "seq", first.item.seq)
410425

411426
return streamResult{reason: endTerminal}, true
412427
}

eth/sequencer/stream_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"testing"
66
"time"
77

8+
"google.golang.org/protobuf/proto"
9+
810
"github.com/0xPolygon/sequence-store-proto/commitment"
911
pb "github.com/0xPolygon/sequence-store-proto/sequencestore/v1"
1012
)
@@ -182,3 +184,103 @@ func TestHandleAckRetiresPublishedRecordBatch(t *testing.T) {
182184
t.Fatalf("acked = %d, anchor = %x", p.ackedSeq, p.anchor)
183185
}
184186
}
187+
188+
func TestCoalesceRecordByteCap(t *testing.T) {
189+
// Coalescing many large txs must stay under the store's max.message.bytes.
190+
const txSize = 32 * 1024
191+
192+
items := make([]journalItem, 40)
193+
start := commitment.Head{0x01}
194+
head := start
195+
for i := range items {
196+
raw := make([]byte, txSize)
197+
raw[0] = byte(i + 1)
198+
next := commitment.FoldTx(head, raw)
199+
items[i] = journalItem{
200+
seq: uint64(i + 1),
201+
entry: recordEntry(raw, head),
202+
pre: head,
203+
post: next,
204+
kind: entryRecord,
205+
height: 9,
206+
}
207+
head = next
208+
}
209+
210+
entry, batch := coalescePublishedRecord(items, 0)
211+
212+
if len(batch) == 0 || len(batch) >= len(items) {
213+
t.Fatalf("batch length = %d, want a byte-capped prefix of %d", len(batch), len(items))
214+
}
215+
216+
got := 0
217+
for _, raw := range entry.GetRecord().GetTransactions() {
218+
got += len(raw)
219+
}
220+
if got > maxRecordBytes {
221+
t.Fatalf("coalesced tx bytes = %d, over the %d cap", got, maxRecordBytes)
222+
}
223+
if got+txSize <= maxRecordBytes {
224+
t.Fatalf("batch not maximal: %d plus one more tx still fits under %d", got, maxRecordBytes)
225+
}
226+
227+
// proto.Size must stay under what the ingress accepts (max.message.bytes
228+
// less its recordFraming allowance).
229+
if size := proto.Size(entry); size > maxMessageBytes-1024 {
230+
t.Fatalf("record proto size = %d, the ingress rejects above %d", size, maxMessageBytes-1024)
231+
}
232+
233+
if fold := commitment.FoldTxs(start, entry.GetRecord().GetTransactions()); fold != items[len(batch)-1].post {
234+
t.Fatalf("coalesced commitment = %x, want %x", fold, items[len(batch)-1].post)
235+
}
236+
}
237+
238+
func TestCoalesceRecordCountCap(t *testing.T) {
239+
// Small records cap at maxTransactionsPerPublishedRecord, well below the
240+
// byte cap.
241+
items := make([]journalItem, maxTransactionsPerPublishedRecord+20)
242+
head := commitment.Head{0x01}
243+
for i := range items {
244+
raw := []byte{byte(i%251 + 1)}
245+
next := commitment.FoldTx(head, raw)
246+
items[i] = journalItem{
247+
seq: uint64(i + 1),
248+
entry: recordEntry(raw, head),
249+
pre: head,
250+
post: next,
251+
kind: entryRecord,
252+
height: 9,
253+
}
254+
head = next
255+
}
256+
257+
if _, batch := coalescePublishedRecord(items, 0); len(batch) != maxTransactionsPerPublishedRecord {
258+
t.Fatalf("batch length = %d, want the %d count cap", len(batch), maxTransactionsPerPublishedRecord)
259+
}
260+
}
261+
262+
func TestCoalesceRecordByteCapBoundary(t *testing.T) {
263+
// Two records whose sizes sum to exactly maxRecordBytes coalesce together:
264+
// the cap boundary is inclusive (>, not >=).
265+
head := commitment.Head{0x02}
266+
sizes := []int{100_000, maxRecordBytes - 100_000}
267+
items := make([]journalItem, len(sizes))
268+
for i, sz := range sizes {
269+
raw := make([]byte, sz)
270+
raw[0] = byte(i + 1)
271+
next := commitment.FoldTx(head, raw)
272+
items[i] = journalItem{
273+
seq: uint64(i + 1),
274+
entry: recordEntry(raw, head),
275+
pre: head,
276+
post: next,
277+
kind: entryRecord,
278+
height: 3,
279+
}
280+
head = next
281+
}
282+
283+
if _, batch := coalescePublishedRecord(items, 0); len(batch) != 2 {
284+
t.Fatalf("exact-fill batch = %d, want 2 (the cap boundary is inclusive)", len(batch))
285+
}
286+
}

0 commit comments

Comments
 (0)