Skip to content

Commit 3004c38

Browse files
authored
fix(bump-builder): chunk the bulk MINED fan-out to stay under Kafka MaxMessageBytes (#221)
markMinedAndPublish coalesced a block's MINED fan-out into a single bulk event carrying every txid. A ~27k-tx block serialized to ~1.85 MB, over the 1 MiB default Producer.MaxMessageBytes, so PublishBulk failed and the MINED event was silently dropped for large blocks — the DB status was still MINED, but SSE/webhook subscribers never saw it. Split the txid list into <=maxTxIDsPerBulkEvent (5000) chunks, one event each (~340 KB), comfortably under the limit. Subscribers already unfan per-tx from TxIDs[], so multiple events per block are additive and harmless. (WS3a/WS3b from the original PR are dropped — superseded by #207's expected-STUMP recovery model.)
1 parent efaac03 commit 3004c38

2 files changed

Lines changed: 107 additions & 16 deletions

File tree

services/bump_builder/builder.go

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -179,38 +179,56 @@ func (b *Builder) markMinedAndPublish(ctx context.Context, logger *zap.Logger, b
179179
WithLabelValues(string(prev.Status), string(models.StatusMined)).
180180
Observe(time.Since(prev.Timestamp).Seconds())
181181
}
182-
if len(mined) == 0 {
182+
if len(mined) == 0 || b.publisher == nil {
183183
return
184184
}
185-
// Coalesce the N-per-block MINED fan-out into ONE bulk event. Without
186-
// this, a single BUMP build for a 14k-tx block produced 14k individual
187-
// publish calls, which overran the webhook service's 1024-cap work
188-
// queue and triggered ~185k drops/block. Subscribers (SSE, webhook)
189-
// unfan from the bulk template in their own handlers — they own the
190-
// per-tx delivery cost, but they don't pay the channel-saturation cost.
185+
// Coalesce the N-per-block MINED fan-out into bulk events. Without this, a
186+
// single BUMP build for a 14k-tx block produced 14k individual publish
187+
// calls, which overran the webhook service's 1024-cap work queue and
188+
// triggered ~185k drops/block. Subscribers (SSE, webhook) unfan from the
189+
// bulk template in their own handlers.
190+
//
191+
// Chunk the txids so one event never exceeds the Kafka producer's max
192+
// message size: a ~27k-tx block serialized to ~1.85 MB, over the 1 MiB
193+
// default Producer.MaxMessageBytes, so PublishBulk failed and the MINED
194+
// event was silently dropped for large blocks (the DB status was still
195+
// MINED, but SSE/webhook subscribers never saw it). Each txid is ~67 bytes
196+
// of JSON, so maxTxIDsPerBulkEvent keeps every event well under the limit.
191197
publishTxIDs := make([]string, 0, len(mined))
192198
for _, st := range mined {
193199
publishTxIDs = append(publishTxIDs, st.TxID)
194200
}
195-
template := &models.TransactionStatus{
196-
Status: models.StatusMined,
197-
BlockHash: blockHash,
198-
BlockHeight: blockHeight,
199-
Timestamp: time.Now(),
200-
TxIDs: publishTxIDs,
201-
}
202-
if b.publisher != nil {
201+
for start := 0; start < len(publishTxIDs); start += maxTxIDsPerBulkEvent {
202+
end := start + maxTxIDsPerBulkEvent
203+
if end > len(publishTxIDs) {
204+
end = len(publishTxIDs)
205+
}
206+
template := &models.TransactionStatus{
207+
Status: models.StatusMined,
208+
BlockHash: blockHash,
209+
BlockHeight: blockHeight,
210+
Timestamp: time.Now(),
211+
TxIDs: publishTxIDs[start:end],
212+
}
203213
if pubErr := b.publisher.PublishBulk(ctx, template); pubErr != nil {
204214
logger.Warn(
205215
"failed to publish bulk MINED",
206216
zap.String("block_hash", blockHash),
207-
zap.Int("txid_count", len(publishTxIDs)),
217+
zap.Int("chunk_start", start),
218+
zap.Int("chunk_size", end-start),
219+
zap.Int("txid_total", len(publishTxIDs)),
208220
zap.Error(pubErr),
209221
)
210222
}
211223
}
212224
}
213225

226+
// maxTxIDsPerBulkEvent caps how many txids ride in a single bulk MINED event.
227+
// A txid is 64 hex chars (~67 bytes of JSON with quoting + comma); 5000 keeps a
228+
// bulk event around 340 KB, comfortably under the 1 MiB default Kafka
229+
// Producer.MaxMessageBytes even with the status envelope.
230+
const maxTxIDsPerBulkEvent = 5000
231+
214232
func (b *Builder) Name() string { return "bump-builder" }
215233

216234
func (b *Builder) Start(ctx context.Context) error {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package bump_builder
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"sync"
8+
"testing"
9+
10+
"go.uber.org/zap"
11+
12+
"github.com/bsv-blockchain/arcade/models"
13+
)
14+
15+
// chunkCapturingPublisher records the TxIDs of each PublishBulk call verbatim
16+
// (without unfanning) so a test can assert on chunk count and per-chunk size.
17+
type chunkCapturingPublisher struct {
18+
mu sync.Mutex
19+
chunks [][]string
20+
}
21+
22+
func (p *chunkCapturingPublisher) Publish(context.Context, *models.TransactionStatus) error {
23+
return nil
24+
}
25+
26+
func (p *chunkCapturingPublisher) PublishBulk(_ context.Context, template *models.TransactionStatus) error {
27+
p.mu.Lock()
28+
defer p.mu.Unlock()
29+
chunk := make([]string, len(template.TxIDs))
30+
copy(chunk, template.TxIDs)
31+
p.chunks = append(p.chunks, chunk)
32+
return nil
33+
}
34+
35+
func (p *chunkCapturingPublisher) Subscribe(context.Context, string) (<-chan *models.TransactionStatus, error) {
36+
return nil, errors.New("chunkCapturingPublisher: Subscribe not used in tests")
37+
}
38+
func (p *chunkCapturingPublisher) Close() error { return nil }
39+
40+
// A MINED fan-out larger than maxTxIDsPerBulkEvent is split across multiple bulk
41+
// events so no single event exceeds the Kafka producer's max message size. A
42+
// ~27k-tx block previously serialized to ~1.85 MB — over the 1 MiB default
43+
// Producer.MaxMessageBytes — so PublishBulk failed and the MINED event was
44+
// silently dropped for large blocks (the DB status was still MINED).
45+
func TestBuilder_MarkMinedAndPublish_ChunksLargeTxidList(t *testing.T) {
46+
ms := newMockStore()
47+
pub := &chunkCapturingPublisher{}
48+
b := newTestBuilder(ms, "http://unused.invalid")
49+
b.publisher = pub
50+
51+
const n = maxTxIDsPerBulkEvent*2 + 37
52+
txids := make([]string, n)
53+
for i := range txids {
54+
txids[i] = fmt.Sprintf("tx%064d", i)
55+
}
56+
57+
b.markMinedAndPublish(context.Background(), zap.NewNop(), "blkD", 4242, txids)
58+
59+
wantChunks := (n + maxTxIDsPerBulkEvent - 1) / maxTxIDsPerBulkEvent
60+
if len(pub.chunks) != wantChunks {
61+
t.Fatalf("expected %d bulk events, got %d", wantChunks, len(pub.chunks))
62+
}
63+
total := 0
64+
for i, c := range pub.chunks {
65+
if len(c) == 0 || len(c) > maxTxIDsPerBulkEvent {
66+
t.Fatalf("chunk %d has invalid size %d (cap %d)", i, len(c), maxTxIDsPerBulkEvent)
67+
}
68+
total += len(c)
69+
}
70+
if total != n {
71+
t.Fatalf("chunks must cover every txid exactly once: got %d, want %d", total, n)
72+
}
73+
}

0 commit comments

Comments
 (0)