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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cmd/collect/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ var cliFlags = []cli.Flag{
Usage: "ClickHouse server DSN (e.g. clickhouse://user:password@host:9440/dbname?secure=true or clickhouse://default:password@clickhouse:9000/default)",
Category: "Collector Configuration",
},
&cli.StringFlag{
Name: "redis-endpoint",
EnvVars: []string{"REDIS_ENDPOINT"},
Usage: "Redis endpoint for tx hash export (e.g. redis://localhost:6379)",
Category: "Collector Configuration",
},

// Metrics API Endpoint
&cli.StringFlag{
Expand Down Expand Up @@ -145,6 +151,7 @@ func runCollector(cCtx *cli.Context) error {
metricsListenAddr = cCtx.String("metrics-listen-addr")
enablePprof = cCtx.Bool("pprof")
clickhouseDSN = cCtx.String("clickhouse-dsn")
redisEndpoint = cCtx.String("redis-endpoint")
)

// Logger setup
Expand All @@ -159,8 +166,8 @@ func runCollector(cCtx *cli.Context) error {
log.Fatal("No nodes, bloxroute, or eden token set (use -nodes <url1>,<url2> / -blx-token <token> / -eden-token <token>)")
}

if outDir == "" && clickhouseDSN == "" {
log.Fatal("Either --out or --clickhouse-dsn must be specified")
if outDir == "" && clickhouseDSN == "" && redisEndpoint == "" {
log.Fatal("Either --out, --clickhouse-dsn, or --redis-endpoint must be specified")
}

log.Infow("Starting mempool-collector", "version", common.Version, "outDir", outDir, "uid", uid, "enablePprof", enablePprof)
Expand All @@ -178,6 +185,7 @@ func runCollector(cCtx *cli.Context) error {
OutDir: outDir,
CheckNodeURI: checkNodeURI,
ClickhouseDSN: clickhouseDSN,
RedisEndpoint: redisEndpoint,
Nodes: nodeURIs,
BloxrouteAuth: blxAuth,
EdenAuth: edenAuth,
Expand Down
2 changes: 2 additions & 0 deletions collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type CollectorOpts struct {

CheckNodeURI string
ClickhouseDSN string
RedisEndpoint string

BloxrouteAuth []string
EdenAuth []string
Expand Down Expand Up @@ -64,6 +65,7 @@ func (c *Collector) Start() {
OutDir: c.opts.OutDir,
CheckNodeURI: c.opts.CheckNodeURI,
ClickhouseDSN: c.opts.ClickhouseDSN,
RedisEndpoint: c.opts.RedisEndpoint,
HTTPReceivers: c.opts.Receivers,
ReceiversAllowedSources: c.opts.ReceiversAllowedSources,
APIServer: apiServer,
Expand Down
88 changes: 88 additions & 0 deletions collector/redis.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package collector

import (
"context"
"fmt"
"sync"
"time"

"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)

const (
redisKeyPrefix = "mempool-dumpster:"
redisTTL = 5 * time.Minute
redisPingTimeout = 30 * time.Second
redisAddTxTimeout = 10 * time.Second
redisQueueSize = 4096
redisNumWorkers = 4
)

type Redis struct {
log *zap.SugaredLogger
client *redis.Client
queue chan string
wg sync.WaitGroup
}

func NewRedis(log *zap.SugaredLogger, endpoint string) (*Redis, error) {
opts, err := redis.ParseURL(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse redis endpoint: %w", err)
}

client := redis.NewClient(opts)

ctx, cancel := context.WithTimeout(context.Background(), redisPingTimeout)
defer cancel()

if err := client.Ping(ctx).Err(); err != nil {
client.Close()
return nil, fmt.Errorf("failed to ping redis: %w", err)
}
Comment thread
shanejonas marked this conversation as resolved.

rd := &Redis{
log: log,
client: client,
queue: make(chan string, redisQueueSize),
}

rd.wg.Add(redisNumWorkers)
for range redisNumWorkers {
go rd.worker()
}

return rd, nil
}

// AddTx sends a tx hash to the background queue for async Redis write.
// Drops the hash if the queue is full to avoid blocking the caller.
func (r *Redis) AddTx(hash string) {
select {
case r.queue <- hash:
default:
r.log.Warnw("redis queue full, dropping tx", "tx", hash)
}
Comment on lines +61 to +66

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the Redis queue is full, this logs a warning per dropped tx. Under load this can generate extremely high log volume and become an operational issue (I/O + cost), especially if Redis is briefly slow/unreachable. Consider rate-limiting/sampling this log and/or tracking drops via a counter metric (and maybe logging periodic summaries).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might actually want to log each tx that gets dropped. just incase we need to search for some tx while debugging, like first time we saw the tx. if i put it as a metric we lose that.

}

func (r *Redis) worker() {
defer r.wg.Done()
for hash := range r.queue {
r.processHash(hash)
}
}

func (r *Redis) processHash(hash string) {
ctx, cancel := context.WithTimeout(context.Background(), redisAddTxTimeout)
defer cancel()
if err := r.client.Set(ctx, redisKeyPrefix+hash, "1", redisTTL).Err(); err != nil {
r.log.Errorw("failed to add tx to redis", "error", err, "tx", hash)
}
}

func (r *Redis) Close() error {
close(r.queue)
r.wg.Wait()
return r.client.Close()
}
60 changes: 60 additions & 0 deletions collector/redis_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package collector

import (
"testing"
"time"

"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)

func TestRedis_AddTx(t *testing.T) {
mr := miniredis.RunT(t)
log := zap.NewNop().Sugar()

r, err := NewRedis(log, "redis://"+mr.Addr())
require.NoError(t, err)

hash := "0xabc123"
r.AddTx(hash)

// Close flushes the queue and waits for workers to finish
require.NoError(t, r.Close())

// key exists with correct prefix
require.True(t, mr.Exists(redisKeyPrefix+hash))

// value is "1"
val, err := mr.Get(redisKeyPrefix + hash)
require.NoError(t, err)
require.Equal(t, "1", val)

// TTL is set
ttl := mr.TTL(redisKeyPrefix + hash)
require.Equal(t, redisTTL, ttl)
}

func TestRedis_TTLExpiry(t *testing.T) {
mr := miniredis.RunT(t)
log := zap.NewNop().Sugar()

r, err := NewRedis(log, "redis://"+mr.Addr())
require.NoError(t, err)

hash := "0xdef456"
r.AddTx(hash)
require.NoError(t, r.Close())

require.True(t, mr.Exists(redisKeyPrefix+hash))

// fast-forward past TTL
mr.FastForward(redisTTL + time.Second)
require.False(t, mr.Exists(redisKeyPrefix+hash))
}

func TestRedis_BadEndpoint(t *testing.T) {
log := zap.NewNop().Sugar()
_, err := NewRedis(log, "redis://localhost:1")
require.Error(t, err)
}
44 changes: 39 additions & 5 deletions collector/tx_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type TxProcessorOpts struct {
Location string // location of the collector, will be stored in sourcelogs
CheckNodeURI string
ClickhouseDSN string
RedisEndpoint string
HTTPReceivers []string
ReceiversAllowedSources []string
APIServer *api.Server
Expand All @@ -51,8 +52,9 @@ type TxProcessor struct {
uid string
location string

outDir string
txC chan common.TxIn // note: it's important that the value is sent in here instead of a pointer, otherwise there are memory race conditions
outDir string
txC chan common.TxIn // note: it's important that the value is sent in here instead of a pointer, otherwise there are memory race conditions
txCDone chan struct{} // closed when startTransactionReceiverLoop exits

outFilesLock sync.RWMutex
outFiles map[int64]OutFiles
Expand All @@ -74,6 +76,9 @@ type TxProcessor struct {

clickhouseDSN string
clickhouse *Clickhouse

redisEndpoint string
redis *Redis
}

type OutFiles struct {
Expand All @@ -95,8 +100,9 @@ func NewTxProcessor(opts TxProcessorOpts) *TxProcessor {
}

return &TxProcessor{ //nolint:exhaustruct
log: opts.Log,
txC: make(chan common.TxIn, 100),
log: opts.Log,
txC: make(chan common.TxIn, 100),
txCDone: make(chan struct{}),

uid: opts.UID,
location: opts.Location,
Expand All @@ -109,6 +115,7 @@ func NewTxProcessor(opts TxProcessorOpts) *TxProcessor {

checkNodeURI: opts.CheckNodeURI,
clickhouseDSN: opts.ClickhouseDSN,
redisEndpoint: opts.RedisEndpoint,

receivers: receivers,
receiversAllowedSources: opts.ReceiversAllowedSources,
Expand All @@ -118,6 +125,12 @@ func NewTxProcessor(opts TxProcessorOpts) *TxProcessor {

func (p *TxProcessor) Shutdown() {
p.log.Info("Shutting down TxProcessor ...")
p.stopTransactionReceiverLoop()
if p.redis != nil {
if err := p.redis.Close(); err != nil {
p.log.Errorw("failed to close Redis", "error", err)
}
}
if p.clickhouse != nil {
p.clickhouse.FlushCurrentBatches()
}
Expand All @@ -139,8 +152,17 @@ func (p *TxProcessor) Start() {
p.log.Info("Connected to Clickhouse!")
}

if p.redisEndpoint != "" {
p.log.Info("Connecting to Redis...")
p.redis, err = NewRedis(p.log, p.redisEndpoint)
if err != nil {
p.log.Fatalw("failed to connect to Redis", "error", err)
}
p.log.Info("Connected to Redis!")
}
Comment thread
shanejonas marked this conversation as resolved.

if p.checkNodeURI != "" {
p.log.Infof("Conecting to check-node at %s ...", p.checkNodeURI)
p.log.Infof("Connecting to check-node at %s ...", p.checkNodeURI)
p.ethClient, err = ethclient.Dial(p.checkNodeURI)
if err != nil {
p.log.Fatal(err)
Expand All @@ -165,7 +187,13 @@ func (p *TxProcessor) Start() {
p.log.Info("TxProcessor started successfully")
}

func (p *TxProcessor) stopTransactionReceiverLoop() {
close(p.txC)
<-p.txCDone
}
Comment on lines +190 to +193

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stopTransactionReceiverLoop closes p.txC. That channel is also written to by multiple long-lived goroutines (e.g., NodeConnection.connect() does nc.txC <- ... without a select/closed check). On shutdown, those goroutines will panic with send on closed channel as soon as they receive another tx. Consider not closing a shared producer channel; instead introduce a context/done signal that producers can observe, or encapsulate the channel behind a TxProcessor.SubmitTx method that becomes a no-op after shutdown, and only close internal channels that have no external senders.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is pointing out some real issues with the existing implementation. I'll make a new issue for this.


func (p *TxProcessor) startTransactionReceiverLoop() {
defer close(p.txCDone)
p.log.Info("Waiting for transactions...")
for txIn := range p.txC {
if txIn.Tx == nil {
Expand Down Expand Up @@ -275,6 +303,12 @@ func (p *TxProcessor) processTx(txIn common.TxIn) {
}
}

// Add tx hash to Redis asynchronously via background workers.
// Protect will check this to filter txs coming back through.
if p.redis != nil {
p.redis.AddTx(txHashLower)
}

// Add transaction to Clickhouse
if p.clickhouse != nil {
err = p.clickhouse.AddTransaction(txIn) // send to Clickhouse
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/ClickHouse/clickhouse-go/v2 v2.37.1
github.com/NYTimes/gziphandler v1.1.1
github.com/VictoriaMetrics/metrics v1.37.0
github.com/alicebob/miniredis/v2 v2.37.0
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.7
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1
Expand All @@ -23,6 +24,7 @@ require (
github.com/gorilla/websocket v1.5.0
github.com/lithammer/shortuuid v3.0.0+incompatible
github.com/olekukonko/tablewriter v0.0.5
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.10.0
github.com/tdewolff/minify v2.3.6+incompatible
github.com/urfave/cli/v2 v2.27.5
Expand Down Expand Up @@ -68,6 +70,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/emicklei/dot v1.8.0 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect
Expand Down Expand Up @@ -116,6 +119,7 @@ require (
github.com/valyala/fastrand v1.1.0 // indirect
github.com/valyala/histogram v1.2.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
Expand Down
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMG
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/VictoriaMetrics/metrics v1.37.0 h1:u5Yr+HFofQyn7kgmmkufgkX0nEA6G1oEyK2eaKsVaUM=
github.com/VictoriaMetrics/metrics v1.37.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc=
github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
Expand Down Expand Up @@ -211,6 +213,10 @@ github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoG
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bloXroute-Labs/gateway/v2 v2.127.42 h1:s9KM9oIqyH8vALK1nKtVyxLKoG/dDIZ32km0BxDuXJI=
github.com/bloXroute-Labs/gateway/v2 v2.127.42/go.mod h1:ZF7TH56npPgB7Ev4N6VUOS1MTlUCwYqwUO1ARoyOdjc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
Expand Down Expand Up @@ -270,6 +276,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/denisenkom/go-mssqldb v0.12.0/go.mod h1:iiK0YP1ZeepvmBQk/QpLEhhTNJgfzrpArPY/aFvc9yU=
github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
Expand Down Expand Up @@ -674,6 +682,8 @@ github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 h1:lC8ki
github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15/go.mod h1:8svFBIKKu31YriBG/pNizo9N0Jr9i5PQ+dFkxWg3x5k=
github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4=
github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
Expand Down Expand Up @@ -768,8 +778,12 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0=
Expand Down
Loading