From 26825b266de67c0d315e600388a30dcae583cf80 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 12 Jun 2026 15:14:48 +0200 Subject: [PATCH 01/21] WIP --- sender/sharded_sender.go | 2 - sender/worker.go | 106 +++++++++++++++------------------------ sender/worker_test.go | 34 +++++++++++++ 3 files changed, 75 insertions(+), 67 deletions(-) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 8bc7c8d..a285106 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -23,7 +23,6 @@ type ShardedSender struct { mu sync.RWMutex collector *stats.Collector logger *stats.Logger - limiter *rate.Limiter // Shared rate limiter for all workers } // NewShardedSender creates a new sharded sender with workers for each endpoint @@ -41,7 +40,6 @@ func NewShardedSender(cfg *config.LoadConfig, bufferSize int, workers int, limit workers: workerList, numShards: len(cfg.Endpoints), bufferSize: bufferSize, - limiter: limiter, }, nil } diff --git a/sender/worker.go b/sender/worker.go index 9b48a8a..75aedf7 100644 --- a/sender/worker.go +++ b/sender/worker.go @@ -1,18 +1,18 @@ package sender import ( - "bytes" "context" "errors" "fmt" - "io" "log" "net" "net/http" + "net/url" "time" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -90,6 +90,29 @@ func newHttpClient(opts ...HttpClientOption) *http.Client { } } +// newRPCClient returns a go-ethereum client configured for the endpoint scheme. +// HTTP(S) endpoints reuse the tuned otelhttp-backed transport; WS(S) endpoints +// use the default go-ethereum WebSocket transport. +func newRPCClient(ctx context.Context, endpoint string, opts ...HttpClientOption) (*ethclient.Client, error) { + u, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("parse endpoint %q: %w", endpoint, err) + } + + switch u.Scheme { + case "http", "https": + rpcClient, err := rpc.DialOptions(ctx, endpoint, rpc.WithHTTPClient(newHttpClient(opts...))) + if err != nil { + return nil, err + } + return ethclient.NewClient(rpcClient), nil + case "ws", "wss", "": + return ethclient.DialContext(ctx, endpoint) + default: + return nil, fmt.Errorf("unsupported RPC scheme %q for endpoint %s", u.Scheme, endpoint) + } +} + // NewWorker creates a new worker for a specific endpoint func NewWorker(id int, seiChainID string, endpoint string, bufferSize int, workers int, limiter *rate.Limiter) *Worker { w := &Worker{ @@ -115,12 +138,17 @@ func (w *Worker) SetStatsCollector(collector *stats.Collector, logger *stats.Log // Start begins the worker's processing loop func (w *Worker) Run(ctx context.Context) error { return service.Run(ctx, func(ctx context.Context, s service.Scope) error { - // Start multiple worker goroutines that share the same channel - client := newHttpClient() + client, err := newRPCClient(ctx, w.endpoint) + if err != nil { + return fmt.Errorf("dial %s: %w", w.endpoint, err) + } + defer client.Close() + + // Start multiple worker goroutines that share the same channel and RPC client. for range w.workers { s.Spawn(func() error { return w.processTransactions(ctx, client) }) } - return w.watchTransactions(ctx) + return w.watchTransactions(ctx, client) }) } @@ -144,22 +172,10 @@ func (w *Worker) SetTrackReceipts(trackReceipts bool) { w.trackReceipts = trackReceipts } -func (w *Worker) watchTransactions(ctx context.Context) error { +func (w *Worker) watchTransactions(ctx context.Context, eth *ethclient.Client) error { if w.dryRun || !w.trackReceipts { return nil } - dialCtx, dialSpan := tracer.Start(ctx, "sender.dial_endpoint", trace.WithAttributes( - attribute.String("seiload.endpoint", w.endpoint), - attribute.String("seiload.chain_id", w.seiChainID), - attribute.Int("seiload.worker_id", w.id), - )) - eth, err := ethclient.DialContext(dialCtx, w.endpoint) - if err != nil { - dialSpan.RecordError(err) - dialSpan.End() - return fmt.Errorf("ethclient.Dial(%q): %w", w.endpoint, err) - } - dialSpan.End() for ctx.Err() == nil { tx, err := utils.Recv(ctx, w.sentTxs) if err != nil { @@ -224,13 +240,11 @@ func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx * } // processTransactions is the main worker loop that processes transactions -func (w *Worker) processTransactions(ctx context.Context, client *http.Client) error { +func (w *Worker) processTransactions(ctx context.Context, client *ethclient.Client) error { for ctx.Err() == nil { // Apply rate limiting before getting the next transaction - if w.limiter != nil { - if !w.limiter.Allow() { - continue - } + if err:=w.limiter.Wait(ctx); err!=nil { + return err } tx, err := utils.Recv(ctx, w.txChan) @@ -255,7 +269,7 @@ func (w *Worker) processTransactions(ctx context.Context, client *http.Client) e } // sendTransaction sends a single transaction to the endpoint -func (w *Worker) sendTransaction(ctx context.Context, client *http.Client, tx *types.LoadTx) (_err error) { +func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, tx *types.LoadTx) (_err error) { ctx, span := tracer.Start(ctx, "sender.send_tx", trace.WithAttributes( attribute.String("seiload.scenario", tx.Scenario.Name), attribute.String("seiload.endpoint", w.endpoint), @@ -282,53 +296,15 @@ func (w *Worker) sendTransaction(ctx context.Context, client *http.Client, tx *t return utils.Sleep(ctx, 10*time.Microsecond) // Much faster simulation } - // Create HTTP request with JSON-RPC payload - req, err := http.NewRequestWithContext(ctx, "POST", w.endpoint, bytes.NewReader(tx.JSONRPCPayload)) - if err != nil { - return fmt.Errorf("Worker %d: Failed to create request: %w", w.id, err) - } - - // Set headers for JSON-RPC - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - // Send the request - resp, err := client.Do(req) - if err != nil { + // Send through go-ethereum so the same code path supports both HTTP(S) and WS(S) RPC. + if err := client.SendTransaction(ctx, tx.EthTx); err != nil { txsRejected.Add(ctx, 1, metric.WithAttributes( attribute.String("endpoint", w.endpoint), attribute.String("scenario", tx.Scenario.Name), - attribute.String("reason", "transport"), + attribute.String("reason", "rpc"), )) return fmt.Errorf("Worker %d: Failed to send transaction: %w", w.id, err) } - defer func() { - // Limit read to prevent memory issues with large responses - _, err = io.CopyN(io.Discard, resp.Body, 64*1024) // Read up to 64KB - if err != nil && err != io.EOF { - log.Printf("Worker %d: Failed to read response body: %v", w.id, err) - // Log but don't fail - this is just for connection reuse - } - - // Close response body and handle error - if closeErr := resp.Body.Close(); closeErr != nil { - log.Printf("Worker %d: Failed to close response body: %v", w.id, closeErr) - } - }() - - // Check response status - if resp.StatusCode != http.StatusOK { - httpErrors.Add(ctx, 1, metric.WithAttributes( - attribute.Int("status_code", resp.StatusCode), - attribute.String("endpoint", w.endpoint), - )) - txsRejected.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", w.endpoint), - attribute.String("scenario", tx.Scenario.Name), - attribute.String("reason", "http_status"), - )) - return fmt.Errorf("Worker %d: HTTP error %d for transaction to %s", w.id, resp.StatusCode, w.endpoint) - } txsAccepted.Add(ctx, 1, metric.WithAttributes( attribute.String("endpoint", w.endpoint), diff --git a/sender/worker_test.go b/sender/worker_test.go index cc55ec3..c9f30c9 100644 --- a/sender/worker_test.go +++ b/sender/worker_test.go @@ -1,10 +1,14 @@ package sender import ( + "context" "net/http" + "net/http/httptest" + "strings" "testing" "time" + "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/require" ) @@ -48,3 +52,33 @@ func TestNewHttpClient_Smoke(t *testing.T) { _, isBareTransport := c.Transport.(*http.Transport) require.False(t, isBareTransport, "Transport should be wrapped by otelhttp, not bare *http.Transport") } + +func TestNewRPCClient_HTTP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client, err := newRPCClient(context.Background(), srv.URL) + require.NoError(t, err) + require.NotNil(t, client) + client.Close() +} + +func TestNewRPCClient_WS(t *testing.T) { + srv := rpc.NewServer() + ts := httptest.NewServer(srv.WebsocketHandler([]string{"*"})) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + client, err := newRPCClient(context.Background(), wsURL) + require.NoError(t, err) + require.NotNil(t, client) + client.Close() +} + +func TestNewRPCClient_UnsupportedScheme(t *testing.T) { + client, err := newRPCClient(context.Background(), "ftp://example.com") + require.Error(t, err) + require.Nil(t, client) +} From 03d7f02e3aafcd6a028978306e303040c9f2d214 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 12 Jun 2026 15:41:02 +0200 Subject: [PATCH 02/21] WIP --- main.go | 19 ++++--- sender/metrics.go | 17 +++---- sender/sharded_sender.go | 88 +++++++------------------------- sender/worker.go | 106 ++++++++++++++++----------------------- 4 files changed, 77 insertions(+), 153 deletions(-) diff --git a/main.go b/main.go index e89257e..a9e1d89 100644 --- a/main.go +++ b/main.go @@ -256,23 +256,22 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { } // Create the sender from the config struct - snd, err := sender.NewShardedSender(cfg, settings.BufferSize, settings.Workers, sharedLimiter) - if err != nil { - return fmt.Errorf("failed to create sender: %w", err) - } - // Enable dry-run mode in sender if specified if settings.DryRun { - snd.SetDryRun(true) + cfg.Settings.DryRun = true } if settings.Debug { - snd.SetDebug(true) + cfg.Settings.Debug = true } if settings.TrackReceipts { - snd.SetTrackReceipts(true) + cfg.Settings.TrackReceipts = true } if settings.TrackBlocks { - snd.SetTrackBlocks(true) + cfg.Settings.TrackBlocks = true + } + snd, err := sender.NewShardedSender(cfg, settings.BufferSize, settings.Workers, sharedLimiter) + if err != nil { + return fmt.Errorf("failed to create sender: %w", err) } // Set statistics collector for sender and its workers @@ -322,7 +321,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { if settings.TxsDir == "" { // Start the sender (starts all workers) s.SpawnBgNamed("sender", func() error { return snd.Run(ctx) }) - log.Printf("✅ Connected to %d endpoints", snd.GetNumShards()) + log.Printf("✅ Connected to %d endpoints", snd.NumShards()) } // Perform prewarming if enabled (before starting logger to avoid logging prewarm transactions) if settings.Prewarm { diff --git a/sender/metrics.go b/sender/metrics.go index ab9d39d..93888fe 100644 --- a/sender/metrics.go +++ b/sender/metrics.go @@ -29,11 +29,6 @@ var ( metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0))) - httpErrors = must(meter.Int64Counter( - "http_errors", - metric.WithDescription("HTTP error responses from the target endpoint, by status code"), - metric.WithUnit("{errors}"))) - txsAccepted = must(meter.Int64Counter( "txs_accepted", metric.WithDescription("Transactions successfully submitted to an endpoint"), @@ -57,10 +52,10 @@ func init() { meteredChainWorkers.lock.RLock() defer meteredChainWorkers.lock.RUnlock() for _, worker := range meteredChainWorkers.workers { - observer.Observe(int64(worker.GetChannelLength()), metric.WithAttributes( - attribute.String("endpoint", worker.GetEndpoint()), - attribute.Int("worker_id", worker.id), - attribute.String("chain_id", worker.seiChainID), + observer.Observe(int64(worker.ChannelLength()), metric.WithAttributes( + attribute.String("endpoint", worker.Endpoint()), + attribute.Int("worker_id", worker.cfg.ID), + attribute.String("chain_id", worker.cfg.SeiChainID), )) } return nil @@ -91,8 +86,8 @@ func meterWorkerQueueLength(worker *Worker) { meteredChainWorkers.lock.Lock() defer meteredChainWorkers.lock.Unlock() id := chainWorkerID{ - workerID: worker.id, - chainID: worker.seiChainID, + workerID: worker.cfg.ID, + chainID: worker.cfg.SeiChainID, } if _, exists := meteredChainWorkers.workers[id]; !exists { meteredChainWorkers.workers[id] = worker diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index a285106..b67c057 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -16,41 +16,36 @@ import ( // ShardedSender implements TxSender with multiple workers, one per endpoint type ShardedSender struct { workers []*Worker - numShards int - bufferSize int - dryRun bool - debug bool mu sync.RWMutex collector *stats.Collector logger *stats.Logger } // NewShardedSender creates a new sharded sender with workers for each endpoint -func NewShardedSender(cfg *config.LoadConfig, bufferSize int, workers int, limiter *rate.Limiter) (*ShardedSender, error) { +func NewShardedSender(cfg *config.LoadConfig, bufferSize int, tasksPerWorker int, limiter *rate.Limiter) (*ShardedSender, error) { if len(cfg.Endpoints) == 0 { return nil, fmt.Errorf("no endpoints configured") } - workerList := make([]*Worker, len(cfg.Endpoints)) + workers := make([]*Worker, len(cfg.Endpoints)) for i, endpoint := range cfg.Endpoints { - workerList[i] = NewWorker(i, cfg.SeiChainID, endpoint, bufferSize, workers, limiter) + workers[i] = NewWorker(&WorkerConfig { + ID: i, + SeiChainID: cfg.SeiChainID, + Endpoint: endpoint, + BufferSize: bufferSize, + Tasks: tasksPerWorker, + }, limiter) } - return &ShardedSender{ - workers: workerList, - numShards: len(cfg.Endpoints), - bufferSize: bufferSize, - }, nil + return &ShardedSender{workers: workers}, nil } // Start initializes and starts all workers func (s *ShardedSender) Run(ctx context.Context) error { - s.mu.Lock() - workers := s.workers - s.mu.Unlock() - return service.Run(ctx, func(ctx context.Context, s service.Scope) error { - for _, worker := range workers { - s.Spawn(func() error { return worker.Run(ctx) }) + return service.Run(ctx, func(ctx context.Context, scope service.Scope) error { + for _, worker := range s.workers { + scope.Spawn(func() error { return worker.Run(ctx) }) } return nil }) @@ -59,27 +54,19 @@ func (s *ShardedSender) Run(ctx context.Context) error { // Send implements TxSender interface - calculates shard ID and routes to appropriate worker func (s *ShardedSender) Send(ctx context.Context, tx *types.LoadTx) error { // Calculate shard ID based on the transaction - shardID := tx.ShardID(s.numShards) - + shardID := tx.ShardID(len(s.workers)) // Send to the appropriate worker - s.mu.RLock() - worker := s.workers[shardID] - s.mu.RUnlock() - - return worker.Send(ctx, tx) + return s.workers[shardID].Send(ctx, tx) } // GetWorkerStats returns statistics for all workers func (s *ShardedSender) GetWorkerStats() []WorkerStats { - s.mu.RLock() - defer s.mu.RUnlock() - stats := make([]WorkerStats, len(s.workers)) for i, worker := range s.workers { stats[i] = WorkerStats{ WorkerID: i, - Endpoint: worker.GetEndpoint(), - ChannelLength: worker.GetChannelLength(), + Endpoint: worker.Endpoint(), + ChannelLength: worker.ChannelLength(), } } return stats @@ -93,46 +80,7 @@ type WorkerStats struct { } // GetNumShards returns the number of shards (workers) -func (s *ShardedSender) GetNumShards() int { - return s.numShards -} - -// SetDryRun sets the dry-run flag for the sender and its workers -func (s *ShardedSender) SetDryRun(dryRun bool) { - s.mu.Lock() - defer s.mu.Unlock() - - s.dryRun = dryRun - for _, worker := range s.workers { - worker.SetDryRun(dryRun) - } -} - -func (s *ShardedSender) SetDebug(debug bool) { - s.mu.Lock() - defer s.mu.Unlock() - - s.debug = debug - for _, worker := range s.workers { - worker.SetDebug(debug) - } -} - -// SetTrackReceipts sets the track-receipts flag for the sender and its workers -func (s *ShardedSender) SetTrackReceipts(trackReceipts bool) { - s.mu.Lock() - defer s.mu.Unlock() - - for _, worker := range s.workers { - worker.SetTrackReceipts(trackReceipts) - } -} - -// SetTrackBlocks sets the track-blocks flag (placeholder - blocks are tracked separately) -func (s *ShardedSender) SetTrackBlocks(trackBlocks bool) { - // Block tracking is handled by the BlockCollector, not the sender - // This method exists for consistency with the CLI interface -} +func (s *ShardedSender) NumShards() int { return len(s.workers) } // SetStatsCollector sets the statistics collector for all workers func (s *ShardedSender) SetStatsCollector(collector *stats.Collector, logger *stats.Logger) { diff --git a/sender/worker.go b/sender/worker.go index 75aedf7..5e43f0a 100644 --- a/sender/worker.go +++ b/sender/worker.go @@ -28,19 +28,24 @@ import ( var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") +type WorkerConfig struct { + ID int + SeiChainID string + Endpoint string + BufferSize int + Tasks int + DryRun bool + Debug bool + TrackReceipts bool +} + // Worker handles sending transactions to a specific endpoint type Worker struct { - id int - seiChainID string - endpoint string + cfg *WorkerConfig txChan chan *types.LoadTx sentTxs chan *types.LoadTx - dryRun bool - debug bool collector *stats.Collector logger *stats.Logger - workers int - trackReceipts bool limiter *rate.Limiter // Shared rate limiter for transaction sending } @@ -114,15 +119,11 @@ func newRPCClient(ctx context.Context, endpoint string, opts ...HttpClientOption } // NewWorker creates a new worker for a specific endpoint -func NewWorker(id int, seiChainID string, endpoint string, bufferSize int, workers int, limiter *rate.Limiter) *Worker { +func NewWorker(cfg *WorkerConfig, limiter *rate.Limiter) *Worker { w := &Worker{ - id: id, - seiChainID: seiChainID, - endpoint: endpoint, - txChan: make(chan *types.LoadTx, bufferSize), - sentTxs: make(chan *types.LoadTx, bufferSize), - workers: workers, - trackReceipts: false, + cfg: cfg, + txChan: make(chan *types.LoadTx, cfg.BufferSize), + sentTxs: make(chan *types.LoadTx, cfg.BufferSize), limiter: limiter, } meterWorkerQueueLength(w) @@ -138,15 +139,15 @@ func (w *Worker) SetStatsCollector(collector *stats.Collector, logger *stats.Log // Start begins the worker's processing loop func (w *Worker) Run(ctx context.Context) error { return service.Run(ctx, func(ctx context.Context, s service.Scope) error { - client, err := newRPCClient(ctx, w.endpoint) + client, err := newRPCClient(ctx, w.cfg.Endpoint) if err != nil { - return fmt.Errorf("dial %s: %w", w.endpoint, err) + return fmt.Errorf("dial %s: %w", w.cfg.Endpoint, err) } defer client.Close() - // Start multiple worker goroutines that share the same channel and RPC client. - for range w.workers { - s.Spawn(func() error { return w.processTransactions(ctx, client) }) + // Start multiple goroutines that share the same channel and RPC client. + for range w.cfg.Tasks { + s.Spawn(func() error { return w.runTxSender(ctx, client) }) } return w.watchTransactions(ctx, client) }) @@ -157,23 +158,8 @@ func (w *Worker) Send(ctx context.Context, tx *types.LoadTx) error { return utils.Send(ctx, w.txChan, tx) } -// SetDebug sets the dry-run mode for the worker -func (w *Worker) SetDebug(debug bool) { - w.debug = debug -} - -// SetDryRun sets the dry-run mode for the worker -func (w *Worker) SetDryRun(dryRun bool) { - w.dryRun = dryRun -} - -// SetTrackReceipts sets the track-receipts mode for the worker -func (w *Worker) SetTrackReceipts(trackReceipts bool) { - w.trackReceipts = trackReceipts -} - func (w *Worker) watchTransactions(ctx context.Context, eth *ethclient.Client) error { - if w.dryRun || !w.trackReceipts { + if w.cfg.DryRun || !w.cfg.TrackReceipts { return nil } for ctx.Err() == nil { @@ -194,9 +180,9 @@ func (w *Worker) watchTransactions(ctx context.Context, eth *ethclient.Client) e func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx *types.LoadTx) (_err error) { ctx, span := tracer.Start(ctx, "sender.check_receipt", trace.WithAttributes( attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", w.endpoint), - attribute.Int("seiload.worker_id", w.id), - attribute.String("seiload.chain_id", w.seiChainID), + attribute.String("seiload.endpoint", w.cfg.Endpoint), + attribute.Int("seiload.worker_id", w.cfg.ID), + attribute.String("seiload.chain_id", w.cfg.SeiChainID), )) defer func(start time.Time) { if _err != nil { @@ -208,8 +194,8 @@ func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx * receiptLatency.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes( attribute.String("scenario", tx.Scenario.Name), - attribute.String("endpoint", w.endpoint), - attribute.String("chain_id", w.seiChainID), + attribute.String("endpoint", w.cfg.Endpoint), + attribute.String("chain_id", w.cfg.SeiChainID), statusAttrFromError(_err)), ) }(time.Now()) @@ -231,7 +217,7 @@ func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx * if receipt.Status != 1 { return fmt.Errorf("tx %s failed", tx.EthTx.Hash().Hex()) } - if w.debug { + if w.cfg.Debug { log.Printf("✅ tx %s, %s, gas=%d succeeded\n", tx.Scenario.Name, tx.EthTx.Hash().Hex(), receipt.GasUsed) } return nil @@ -239,8 +225,8 @@ func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx * return ctx.Err() } -// processTransactions is the main worker loop that processes transactions -func (w *Worker) processTransactions(ctx context.Context, client *ethclient.Client) error { +// runTxSender is the main worker loop that processes transactions +func (w *Worker) runTxSender(ctx context.Context, client *ethclient.Client) error { for ctx.Err() == nil { // Apply rate limiting before getting the next transaction if err:=w.limiter.Wait(ctx); err!=nil { @@ -259,7 +245,7 @@ func (w *Worker) processTransactions(ctx context.Context, client *ethclient.Clie err = w.sendTransaction(ctx, client, tx) // Record statistics if collector is available if w.collector != nil { - w.collector.RecordTransaction(tx.Scenario.Name, w.endpoint, time.Since(startTime), err == nil) + w.collector.RecordTransaction(tx.Scenario.Name, w.cfg.Endpoint, time.Since(startTime), err == nil) } if err != nil { log.Printf("%v", err) @@ -272,9 +258,9 @@ func (w *Worker) processTransactions(ctx context.Context, client *ethclient.Clie func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, tx *types.LoadTx) (_err error) { ctx, span := tracer.Start(ctx, "sender.send_tx", trace.WithAttributes( attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", w.endpoint), - attribute.Int("seiload.worker_id", w.id), - attribute.String("seiload.chain_id", w.seiChainID), + attribute.String("seiload.endpoint", w.cfg.Endpoint), + attribute.Int("seiload.worker_id", w.cfg.ID), + attribute.String("seiload.chain_id", w.cfg.SeiChainID), )) defer func(start time.Time) { if _err != nil { @@ -285,12 +271,12 @@ func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, sendLatency.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes( attribute.String("scenario", tx.Scenario.Name), - attribute.String("endpoint", w.endpoint), - attribute.String("chain_id", w.seiChainID), + attribute.String("endpoint", w.cfg.Endpoint), + attribute.String("chain_id", w.cfg.SeiChainID), statusAttrFromError(_err)), ) }(time.Now()) - if w.dryRun { + if w.cfg.DryRun { // In dry-run mode, simulate processing time and mark as successful // Use very minimal delay to avoid channel overflow return utils.Sleep(ctx, 10*time.Microsecond) // Much faster simulation @@ -299,15 +285,15 @@ func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, // Send through go-ethereum so the same code path supports both HTTP(S) and WS(S) RPC. if err := client.SendTransaction(ctx, tx.EthTx); err != nil { txsRejected.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", w.endpoint), + attribute.String("endpoint", w.cfg.Endpoint), attribute.String("scenario", tx.Scenario.Name), attribute.String("reason", "rpc"), )) - return fmt.Errorf("Worker %d: Failed to send transaction: %w", w.id, err) + return fmt.Errorf("Worker %d: Failed to send transaction: %w", w.cfg.ID, err) } txsAccepted.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", w.endpoint), + attribute.String("endpoint", w.cfg.Endpoint), attribute.String("scenario", tx.Scenario.Name), )) @@ -319,13 +305,9 @@ func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, return nil } -// GetChannelLength returns the current length of the worker's channel (for monitoring). +// ChannelLength returns the current length of the worker's channel (for monitoring). // This function is safe for concurrent calls. -func (w *Worker) GetChannelLength() int { - return len(w.txChan) -} +func (w *Worker) ChannelLength() int { return len(w.txChan) } -// GetEndpoint returns the worker's endpoint -func (w *Worker) GetEndpoint() string { - return w.endpoint -} +// Endpoint returns the worker's endpoint +func (w *Worker) Endpoint() string { return w.cfg.Endpoint } From 6c14f3c3ce22eab41573276824ba795d8bed77b0 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 12 Jun 2026 16:37:58 +0200 Subject: [PATCH 03/21] WIP --- config/settings.go | 110 ++++++++++++++++++------------------ config/settings_test.go | 26 ++++----- main.go | 105 ++++++++++++++-------------------- observability/setup_test.go | 14 ++--- profiles/profiles_test.go | 2 +- sender/sharded_sender.go | 36 ++++-------- sender/worker.go | 51 +++++++---------- utils/channels.go | 7 +-- 8 files changed, 150 insertions(+), 201 deletions(-) diff --git a/config/settings.go b/config/settings.go index 5f31dcd..2c4e2bb 100644 --- a/config/settings.go +++ b/config/settings.go @@ -12,19 +12,19 @@ import ( // Settings holds all CLI-configurable parameters type Settings struct { - Workers int `json:"workers,omitempty"` - TPS float64 `json:"tps,omitempty"` - StatsInterval Duration `json:"statsInterval,omitempty"` - BufferSize int `json:"bufferSize,omitempty"` - DryRun bool `json:"dryRun,omitempty"` - Debug bool `json:"debug,omitempty"` - TrackReceipts bool `json:"trackReceipts,omitempty"` - TrackBlocks bool `json:"trackBlocks,omitempty"` - TrackUserLatency bool `json:"trackUserLatency,omitempty"` - Prewarm bool `json:"prewarm,omitempty"` - RampUp bool `json:"rampUp,omitempty"` - ReportPath string `json:"reportPath,omitempty"` - TxsDir string `json:"txsDir,omitempty"` + TasksPerEndpoint int `json:"workers,omitempty"` + TPS float64 `json:"tps,omitempty"` + StatsInterval Duration `json:"statsInterval,omitempty"` + BufferSize int `json:"bufferSize,omitempty"` + DryRun bool `json:"dryRun,omitempty"` + Debug bool `json:"debug,omitempty"` + TrackReceipts bool `json:"trackReceipts,omitempty"` + TrackBlocks bool `json:"trackBlocks,omitempty"` + TrackUserLatency bool `json:"trackUserLatency,omitempty"` + Prewarm bool `json:"prewarm,omitempty"` + RampUp bool `json:"rampUp,omitempty"` + ReportPath string `json:"reportPath,omitempty"` + TxsDir string `json:"txsDir,omitempty"` TargetGas uint64 `json:"targetGas,omitempty"` NumBlocksToWrite int `json:"numBlocksToWrite,omitempty"` PostSummaryFlushDelay Duration `json:"postSummaryFlushDelay,omitempty"` @@ -33,19 +33,19 @@ type Settings struct { // DefaultSettings returns the default configuration values func DefaultSettings() Settings { return Settings{ - Workers: 1, - TPS: 0.0, - StatsInterval: Duration(10 * time.Second), - BufferSize: 1000, - DryRun: false, - Debug: false, - TrackReceipts: false, - TrackBlocks: false, - TrackUserLatency: false, - Prewarm: false, - RampUp: false, - ReportPath: "", - TxsDir: "", + TasksPerEndpoint: 1, + TPS: 0.0, + StatsInterval: Duration(10 * time.Second), + BufferSize: 1000, + DryRun: false, + Debug: false, + TrackReceipts: false, + TrackBlocks: false, + TrackUserLatency: false, + Prewarm: false, + RampUp: false, + ReportPath: "", + TxsDir: "", TargetGas: 10_000_000, NumBlocksToWrite: 100, PostSummaryFlushDelay: Duration(25 * time.Second), @@ -56,19 +56,19 @@ func DefaultSettings() Settings { func InitializeViper(cmd *cobra.Command) error { // Bind flags to viper with error checking flagBindings := map[string]string{ - "statsInterval": "stats-interval", - "bufferSize": "buffer-size", - "tps": "tps", - "dryRun": "dry-run", - "debug": "debug", - "trackReceipts": "track-receipts", - "trackBlocks": "track-blocks", - "prewarm": "prewarm", - "trackUserLatency": "track-user-latency", - "workers": "workers", - "rampUp": "ramp-up", - "reportPath": "report-path", - "txsDir": "txs-dir", + "statsInterval": "stats-interval", + "bufferSize": "buffer-size", + "tps": "tps", + "dryRun": "dry-run", + "debug": "debug", + "trackReceipts": "track-receipts", + "trackBlocks": "track-blocks", + "prewarm": "prewarm", + "trackUserLatency": "track-user-latency", + "workers": "workers", + "rampUp": "ramp-up", + "reportPath": "report-path", + "txsDir": "txs-dir", "targetGas": "target-gas", "numBlocksToWrite": "num-blocks-to-write", "postSummaryFlushDelay": "post-summary-flush-delay", @@ -91,7 +91,7 @@ func InitializeViper(cmd *cobra.Command) error { viper.SetDefault("trackBlocks", defaults.TrackBlocks) viper.SetDefault("prewarm", defaults.Prewarm) viper.SetDefault("trackUserLatency", defaults.TrackUserLatency) - viper.SetDefault("workers", defaults.Workers) + viper.SetDefault("workers", defaults.TasksPerEndpoint) viper.SetDefault("rampUp", defaults.RampUp) viper.SetDefault("reportPath", defaults.ReportPath) viper.SetDefault("txsDir", defaults.TxsDir) @@ -122,21 +122,21 @@ func LoadSettings(settings *Settings) error { } // ResolveSettings gets the final resolved settings from Viper -func ResolveSettings() Settings { - return Settings{ - Workers: viper.GetInt("workers"), - TPS: viper.GetFloat64("tps"), - StatsInterval: Duration(viper.GetDuration("statsInterval")), - BufferSize: viper.GetInt("bufferSize"), - DryRun: viper.GetBool("dryRun"), - Debug: viper.GetBool("debug"), - TrackReceipts: viper.GetBool("trackReceipts"), - TrackBlocks: viper.GetBool("trackBlocks"), - TrackUserLatency: viper.GetBool("trackUserLatency"), - Prewarm: viper.GetBool("prewarm"), - RampUp: viper.GetBool("rampUp"), - ReportPath: viper.GetString("reportPath"), - TxsDir: viper.GetString("txsDir"), +func ResolveSettings() *Settings { + return &Settings{ + TasksPerEndpoint: viper.GetInt("workers"), + TPS: viper.GetFloat64("tps"), + StatsInterval: Duration(viper.GetDuration("statsInterval")), + BufferSize: viper.GetInt("bufferSize"), + DryRun: viper.GetBool("dryRun"), + Debug: viper.GetBool("debug"), + TrackReceipts: viper.GetBool("trackReceipts"), + TrackBlocks: viper.GetBool("trackBlocks"), + TrackUserLatency: viper.GetBool("trackUserLatency"), + Prewarm: viper.GetBool("prewarm"), + RampUp: viper.GetBool("rampUp"), + ReportPath: viper.GetString("reportPath"), + TxsDir: viper.GetString("txsDir"), TargetGas: viper.GetUint64("targetGas"), NumBlocksToWrite: viper.GetInt("numBlocksToWrite"), PostSummaryFlushDelay: Duration(viper.GetDuration("postSummaryFlushDelay")), diff --git a/config/settings_test.go b/config/settings_test.go index 8773159..4495c8c 100644 --- a/config/settings_test.go +++ b/config/settings_test.go @@ -125,19 +125,19 @@ func TestDefaultSettings(t *testing.T) { defaults := DefaultSettings() expected := Settings{ - Workers: 1, - TPS: 0.0, - StatsInterval: Duration(10 * time.Second), - BufferSize: 1000, - DryRun: false, - Debug: false, - TrackReceipts: false, - TrackBlocks: false, - TrackUserLatency: false, - Prewarm: false, - RampUp: false, - ReportPath: "", - TxsDir: "", + Workers: 1, + TPS: 0.0, + StatsInterval: Duration(10 * time.Second), + BufferSize: 1000, + DryRun: false, + Debug: false, + TrackReceipts: false, + TrackBlocks: false, + TrackUserLatency: false, + Prewarm: false, + RampUp: false, + ReportPath: "", + TxsDir: "", TargetGas: 10_000_000, NumBlocksToWrite: 100, PostSummaryFlushDelay: Duration(25 * time.Second), diff --git a/main.go b/main.go index a9e1d89..8502aea 100644 --- a/main.go +++ b/main.go @@ -45,10 +45,8 @@ to multiple endpoints with account pooling management. Use --dry-run to test configuration and view transaction details without actually sending requests or deploying contracts.`, - Run: func(cmd *cobra.Command, args []string) { - if err := runLoadTest(context.Background(), cmd, args); err != nil { - log.Fatal(err) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runLoadTest(cmd.Context(), cmd) }, } @@ -94,7 +92,7 @@ func main() { } } -func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { +func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Parse the config file into a config.LoadConfig struct cfg, err := loadConfig(configFile) if err != nil { @@ -107,7 +105,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { } // Get resolved settings from the config package - settings := config.ResolveSettings() + cfg.Settings = config.ResolveSettings() // Handle --nodes flag to limit number of endpoints nodes, _ := cmd.Flags().GetInt("nodes") @@ -115,39 +113,38 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { log.Printf("🔧 Limiting endpoints from %d to %d nodes", len(cfg.Endpoints), nodes) cfg.Endpoints = cfg.Endpoints[:nodes] } + // Enable mock deployment in dry-run mode + if cfg.Settings.DryRun { + cfg.MockDeploy = true + } log.Printf("🚀 Starting Sei Chain Load Test v2") log.Printf("📁 Config file: %s", configFile) log.Printf("🎯 Endpoints: %d", len(cfg.Endpoints)) - log.Printf("👥 Workers per endpoint: %d", settings.Workers) - log.Printf("🔧 Total workers: %d", len(cfg.Endpoints)*settings.Workers) + log.Printf("👥 Tasks per endpoint: %d", cfg.Settings.TasksPerEndpoint) + log.Printf("🔧 Total tasks: %d", len(cfg.Endpoints)*cfg.Settings.TasksPerEndpoint) log.Printf("📊 Scenarios: %d", len(cfg.Scenarios)) - log.Printf("⏱️ Stats interval: %v", settings.StatsInterval.ToDuration()) - log.Printf("📦 Buffer size per worker: %d", settings.BufferSize) - if settings.TPS > 0 { - log.Printf("📈 Transactions per second: %.2f", settings.TPS) + log.Printf("⏱️ Stats interval: %v", cfg.Settings.StatsInterval.ToDuration()) + log.Printf("📦 Buffer size per worker: %d", cfg.Settings.BufferSize) + if cfg.Settings.TPS > 0 { + log.Printf("📈 Transactions per second: %.2f", cfg.Settings.TPS) } - if settings.DryRun { + if cfg.Settings.DryRun { log.Printf("📝 Dry run: enabled") } - if settings.TrackReceipts { + if cfg.Settings.TrackReceipts { log.Printf("📝 Track receipts: enabled") } - if settings.TrackBlocks { + if cfg.Settings.TrackBlocks { log.Printf("📝 Track blocks: enabled") } - if settings.Prewarm { + if cfg.Settings.Prewarm { log.Printf("📝 Prewarm: enabled") } - if settings.TrackUserLatency { + if cfg.Settings.TrackUserLatency { log.Printf("📝 Track user latency: enabled") } - // Enable mock deployment in dry-run mode - if settings.DryRun { - cfg.MockDeploy = true - } - listenAddr := cmd.Flag("metricsListenAddr").Value.String() log.Printf("serving metrics at %s/metrics", listenAddr) @@ -203,7 +200,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { // Create statistics collector and logger collector := stats.NewCollector() - logger := stats.NewLogger(collector, settings.StatsInterval.ToDuration(), settings.ReportPath, settings.Debug) + logger := stats.NewLogger(collector, cfg.Settings.StatsInterval.ToDuration(), cfg.Settings.ReportPath, cfg.Settings.Debug) var ramper *sender.Ramper err = service.Run(ctx, func(ctx context.Context, s service.Scope) error { @@ -215,9 +212,9 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { // Create shared rate limiter for all workers if TPS is specified var sharedLimiter *rate.Limiter - if settings.TPS > 0 { - sharedLimiter = rate.NewLimiter(rate.Limit(settings.TPS), 1) - log.Printf("📈 Rate limiting enabled: %.2f TPS shared across all workers", settings.TPS) + if cfg.Settings.TPS > 0 { + sharedLimiter = rate.NewLimiter(rate.Limit(cfg.Settings.TPS), 1) + log.Printf("📈 Rate limiting enabled: %.2f TPS shared across all workers", cfg.Settings.TPS) } else { // No rate limiting sharedLimiter = rate.NewLimiter(rate.Inf, 1) @@ -225,7 +222,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { // Create and start block collector if endpoints are available var blockCollector *stats.BlockCollector - if len(cfg.Endpoints) > 0 && settings.TrackBlocks { + if len(cfg.Endpoints) > 0 && cfg.Settings.TrackBlocks { blockCollector = stats.NewBlockCollector(cfg.SeiChainID) collector.SetBlockCollector(blockCollector) s.SpawnBgNamed("block collector", func() error { @@ -233,7 +230,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { }) } - if settings.RampUp { + if cfg.Settings.RampUp { ramperBlockCollector := stats.NewBlockCollector(cfg.SeiChainID) s.SpawnBgNamed("ramper block collector", func() error { return ramperBlockCollector.Run(ctx, cfg.Endpoints[0]) @@ -248,38 +245,22 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { } // Create and start user latency tracker if endpoints are available - if len(cfg.Endpoints) > 0 && settings.TrackUserLatency { - userLatencyTracker := stats.NewUserLatencyTracker(settings.StatsInterval.ToDuration()) + if len(cfg.Endpoints) > 0 && cfg.Settings.TrackUserLatency { + userLatencyTracker := stats.NewUserLatencyTracker(cfg.Settings.StatsInterval.ToDuration()) s.SpawnBgNamed("user latency tracker", func() error { return userLatencyTracker.Run(ctx, cfg.Endpoints[0]) }) } // Create the sender from the config struct - // Enable dry-run mode in sender if specified - if settings.DryRun { - cfg.Settings.DryRun = true - } - if settings.Debug { - cfg.Settings.Debug = true - } - if settings.TrackReceipts { - cfg.Settings.TrackReceipts = true - } - if settings.TrackBlocks { - cfg.Settings.TrackBlocks = true - } - snd, err := sender.NewShardedSender(cfg, settings.BufferSize, settings.Workers, sharedLimiter) + snd, err := sender.NewShardedSender(cfg, sharedLimiter, collector) if err != nil { return fmt.Errorf("failed to create sender: %w", err) } - // Set statistics collector for sender and its workers - snd.SetStatsCollector(collector, logger) - // Fund the pool before prewarm/dispatch — both spend gas the accounts // don't have until funded. - if cfg.Funding != nil && !settings.DryRun { + if cfg.Funding != nil && !cfg.Settings.DryRun { if err := funder.FundAccounts(ctx, cfg, gen.GetAccountPools()); err != nil { return fmt.Errorf("failed to fund accounts: %w", err) } @@ -287,7 +268,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { // Create dispatcher var dispatcher *sender.Dispatcher - if settings.TxsDir != "" { + if cfg.Settings.TxsDir != "" { // get latest height ethclient, err := ethclient.Dial(cfg.Endpoints[0]) if err != nil { @@ -297,10 +278,10 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to get latest height: %w", err) } - numBlocksToWrite := settings.NumBlocksToWrite + numBlocksToWrite := cfg.Settings.NumBlocksToWrite writerHeight := latestHeight + 10 // some buffer log.Printf("🔍 Latest height: %d, writer start height: %d", latestHeight, writerHeight) - writer := sender.NewTxsWriter(settings.TargetGas, settings.TxsDir, writerHeight, uint64(numBlocksToWrite)) + writer := sender.NewTxsWriter(cfg.Settings.TargetGas, cfg.Settings.TxsDir, writerHeight, uint64(numBlocksToWrite)) dispatcher = sender.NewDispatcher(gen, writer) } else { dispatcher = sender.NewDispatcher(gen, snd) @@ -310,7 +291,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { dispatcher.SetStatsCollector(collector) // Set up prewarming if enabled - if settings.Prewarm { + if cfg.Settings.Prewarm { log.Printf("🔥 Creating prewarm generator...") prewarmGen := generator.NewPrewarmGenerator(cfg, gen) dispatcher.SetPrewarmGenerator(prewarmGen) @@ -318,13 +299,13 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { log.Printf("📝 Prewarm mode: Accounts will be prewarmed") } - if settings.TxsDir == "" { + if cfg.Settings.TxsDir == "" { // Start the sender (starts all workers) s.SpawnBgNamed("sender", func() error { return snd.Run(ctx) }) log.Printf("✅ Connected to %d endpoints", snd.NumShards()) } // Perform prewarming if enabled (before starting logger to avoid logging prewarm transactions) - if settings.Prewarm { + if cfg.Settings.Prewarm { if err := dispatcher.Prewarm(ctx); err != nil { return fmt.Errorf("failed to prewarm accounts: %w", err) } @@ -342,20 +323,20 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - log.Printf("📈 Logging statistics every %v (Press Ctrl+C to stop)", settings.StatsInterval.ToDuration()) - if settings.DryRun { + log.Printf("📈 Logging statistics every %v (Press Ctrl+C to stop)", cfg.Settings.StatsInterval.ToDuration()) + if cfg.Settings.DryRun { log.Printf("📝 Dry-run mode: Simulating requests without sending") } - if settings.Debug { + if cfg.Settings.Debug { log.Printf("🐛 Debug mode: Each transaction will be logged") } - if settings.TrackReceipts { + if cfg.Settings.TrackReceipts { log.Printf("📝 Track receipts mode: Receipts will be tracked") } - if settings.TrackBlocks { + if cfg.Settings.TrackBlocks { log.Printf("📝 Track blocks mode: Block data will be collected") } - if settings.TrackUserLatency { + if cfg.Settings.TrackUserLatency { log.Printf("📝 Track user latency mode: User latency will be tracked") } log.Print(strings.Repeat("=", 60)) @@ -369,11 +350,11 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { }) // Print final statistics logger.LogFinalStats() - if settings.RampUp && ramper != nil { + if cfg.Settings.RampUp && ramper != nil { ramper.LogFinalStats() } collector.EmitRunSummary(ctx) - if d := settings.PostSummaryFlushDelay.ToDuration(); d > 0 { + if d := cfg.Settings.PostSummaryFlushDelay.ToDuration(); d > 0 { log.Printf("⏳ Holding pod for post-summary scrape window (%s)...", d) time.Sleep(d) } diff --git a/observability/setup_test.go b/observability/setup_test.go index 1c4b768..f157ad0 100644 --- a/observability/setup_test.go +++ b/observability/setup_test.go @@ -47,14 +47,14 @@ func TestBuildResource_FullScope(t *testing.T) { require.NoError(t, err) want := map[string]string{ - "service.name": "seiload", - "service.version": "v1.2.3", - "service.instance.id": "seiload-abc-0", - "seiload.run_id": "run-42", - "seiload.chain_id": "autobake-42-1", - "seiload.commit_id": "deadbeefcafef00d", + "service.name": "seiload", + "service.version": "v1.2.3", + "service.instance.id": "seiload-abc-0", + "seiload.run_id": "run-42", + "seiload.chain_id": "autobake-42-1", + "seiload.commit_id": "deadbeefcafef00d", "seiload.commit_id_short": "deadbeef", - "seiload.workload": "autobake", + "seiload.workload": "autobake", } got := resourceAttrs(res.Attributes()) for k, v := range want { diff --git a/profiles/profiles_test.go b/profiles/profiles_test.go index d97cd83..aff0eb3 100644 --- a/profiles/profiles_test.go +++ b/profiles/profiles_test.go @@ -80,7 +80,7 @@ func TestProfilesAlignment(t *testing.T) { // Use a decoder with DisallowUnknownFields to catch any extra fields decoder := json.NewDecoder(strings.NewReader(string(data))) decoder.DisallowUnknownFields() - + var strictConfig config.LoadConfig if err := decoder.Decode(&strictConfig); err != nil { t.Errorf("Profile %s contains unexpected/unaligned fields: %v", file.Name(), err) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index b67c057..e9e992e 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -3,7 +3,6 @@ package sender import ( "context" "fmt" - "sync" "golang.org/x/time/rate" @@ -15,27 +14,26 @@ import ( // ShardedSender implements TxSender with multiple workers, one per endpoint type ShardedSender struct { - workers []*Worker - mu sync.RWMutex - collector *stats.Collector - logger *stats.Logger + workers []*Worker } // NewShardedSender creates a new sharded sender with workers for each endpoint -func NewShardedSender(cfg *config.LoadConfig, bufferSize int, tasksPerWorker int, limiter *rate.Limiter) (*ShardedSender, error) { +func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector) (*ShardedSender, error) { if len(cfg.Endpoints) == 0 { return nil, fmt.Errorf("no endpoints configured") } workers := make([]*Worker, len(cfg.Endpoints)) for i, endpoint := range cfg.Endpoints { - workers[i] = NewWorker(&WorkerConfig { - ID: i, + workers[i] = NewWorker(&WorkerConfig{ + ID: i, SeiChainID: cfg.SeiChainID, - Endpoint: endpoint, - BufferSize: bufferSize, - Tasks: tasksPerWorker, - }, limiter) + Endpoint: endpoint, + BufferSize: cfg.Settings.BufferSize, + Tasks: cfg.Settings.TasksPerEndpoint, + Collector: collector, + Limiter: limiter, + }) } return &ShardedSender{workers: workers}, nil @@ -81,17 +79,3 @@ type WorkerStats struct { // GetNumShards returns the number of shards (workers) func (s *ShardedSender) NumShards() int { return len(s.workers) } - -// SetStatsCollector sets the statistics collector for all workers -func (s *ShardedSender) SetStatsCollector(collector *stats.Collector, logger *stats.Logger) { - s.mu.Lock() - defer s.mu.Unlock() - - s.collector = collector - s.logger = logger - - // Pass to all workers - for _, worker := range s.workers { - worker.SetStatsCollector(collector, logger) - } -} diff --git a/sender/worker.go b/sender/worker.go index 5e43f0a..1359ece 100644 --- a/sender/worker.go +++ b/sender/worker.go @@ -29,24 +29,23 @@ import ( var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") type WorkerConfig struct { - ID int - SeiChainID string - Endpoint string - BufferSize int - Tasks int - DryRun bool - Debug bool + ID int + SeiChainID string + Endpoint string + BufferSize int + Tasks int + DryRun bool + Debug bool TrackReceipts bool + Collector *stats.Collector + Limiter *rate.Limiter // Shared rate limiter for transaction sending } // Worker handles sending transactions to a specific endpoint type Worker struct { - cfg *WorkerConfig - txChan chan *types.LoadTx - sentTxs chan *types.LoadTx - collector *stats.Collector - logger *stats.Logger - limiter *rate.Limiter // Shared rate limiter for transaction sending + cfg *WorkerConfig + txChan chan *types.LoadTx + sentTxs chan *types.LoadTx } // HttpClientOption configures the Transport used by newHttpClient. @@ -119,23 +118,16 @@ func newRPCClient(ctx context.Context, endpoint string, opts ...HttpClientOption } // NewWorker creates a new worker for a specific endpoint -func NewWorker(cfg *WorkerConfig, limiter *rate.Limiter) *Worker { +func NewWorker(cfg *WorkerConfig) *Worker { w := &Worker{ - cfg: cfg, - txChan: make(chan *types.LoadTx, cfg.BufferSize), - sentTxs: make(chan *types.LoadTx, cfg.BufferSize), - limiter: limiter, + cfg: cfg, + txChan: make(chan *types.LoadTx, cfg.BufferSize), + sentTxs: make(chan *types.LoadTx, cfg.BufferSize), } meterWorkerQueueLength(w) return w } -// SetStatsCollector sets the statistics collector for this worker -func (w *Worker) SetStatsCollector(collector *stats.Collector, logger *stats.Logger) { - w.collector = collector - w.logger = logger -} - // Start begins the worker's processing loop func (w *Worker) Run(ctx context.Context) error { return service.Run(ctx, func(ctx context.Context, s service.Scope) error { @@ -229,7 +221,7 @@ func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx * func (w *Worker) runTxSender(ctx context.Context, client *ethclient.Client) error { for ctx.Err() == nil { // Apply rate limiting before getting the next transaction - if err:=w.limiter.Wait(ctx); err!=nil { + if err := w.cfg.Limiter.Wait(ctx); err != nil { return err } @@ -244,9 +236,7 @@ func (w *Worker) runTxSender(ctx context.Context, client *ethclient.Client) erro tx.AttemptedSendTime = startTime err = w.sendTransaction(ctx, client, tx) // Record statistics if collector is available - if w.collector != nil { - w.collector.RecordTransaction(tx.Scenario.Name, w.cfg.Endpoint, time.Since(startTime), err == nil) - } + w.cfg.Collector.RecordTransaction(tx.Scenario.Name, w.cfg.Endpoint, time.Since(startTime), err == nil) if err != nil { log.Printf("%v", err) } @@ -298,10 +288,7 @@ func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, )) // Write to sentTxs channel without blocking - select { - case w.sentTxs <- tx: - default: - } + utils.SendOrDrop(w.sentTxs, tx) return nil } diff --git a/utils/channels.go b/utils/channels.go index 9eed500..1e11b90 100644 --- a/utils/channels.go +++ b/utils/channels.go @@ -45,13 +45,10 @@ func Send[T any](ctx context.Context, ch chan<- T, v T) error { } // SendOrDrop send a value to channel if not full or drop the item if the channel is full. -func SendOrDrop[T any](ch chan<- T, v T) error { +func SendOrDrop[T any](ch chan<- T, v T) { select { case ch <- v: - return nil - default: - // drop the item - return nil + default: // drop the item } } From 8be54222e87d90241e2b880500f7ea55b80dce43 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 12 Jun 2026 16:55:41 +0200 Subject: [PATCH 04/21] unrelated --- generator/scenario.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/generator/scenario.go b/generator/scenario.go index d286118..03b3b2f 100644 --- a/generator/scenario.go +++ b/generator/scenario.go @@ -13,8 +13,7 @@ type scenarioGenerator struct { mu sync.RWMutex } -func NewScenarioGenerator(accounts types.AccountPool, - txg scenarios.TxGenerator) Generator { +func NewScenarioGenerator(accounts types.AccountPool, txg scenarios.TxGenerator) Generator { return &scenarioGenerator{ scenario: txg, accountPool: accounts, @@ -23,7 +22,7 @@ func NewScenarioGenerator(accounts types.AccountPool, func (g *scenarioGenerator) GenerateN(n int) []*types.LoadTx { result := make([]*types.LoadTx, 0, n) - for i := 0; i < n; i++ { + for range n { if tx, ok := g.Generate(); ok { result = append(result, tx) } else { From 1680c9934c78aecb91e07e0060a48909035f8f89 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 12 Jun 2026 17:03:18 +0200 Subject: [PATCH 05/21] applied comments --- config/settings_test.go | 4 ++-- profiles/profiles_test.go | 2 +- sender/sharded_sender.go | 17 ++++++++++------- sender/worker.go | 11 +++++------ 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/config/settings_test.go b/config/settings_test.go index 4495c8c..ae4d797 100644 --- a/config/settings_test.go +++ b/config/settings_test.go @@ -115,7 +115,7 @@ func TestArgumentPrecedence(t *testing.T) { // Verify expectations require.Equal(t, tt.expectedStats, settings.StatsInterval.ToDuration(), "StatsInterval: expected %v, got %v", tt.expectedStats, settings.StatsInterval.ToDuration()) - require.Equal(t, tt.expectedWorkers, settings.Workers, "Workers: expected %d, got %d", tt.expectedWorkers, settings.Workers) + require.Equal(t, tt.expectedWorkers, settings.TasksPerEndpoint, "TasksPerEndpoint: expected %d, got %d", tt.expectedWorkers, settings.TasksPerEndpoint) require.Equal(t, tt.expectedTPS, settings.TPS, "TPS: expected %f, got %f", tt.expectedTPS, settings.TPS) }) } @@ -125,7 +125,7 @@ func TestDefaultSettings(t *testing.T) { defaults := DefaultSettings() expected := Settings{ - Workers: 1, + TasksPerEndpoint: 1, TPS: 0.0, StatsInterval: Duration(10 * time.Second), BufferSize: 1000, diff --git a/profiles/profiles_test.go b/profiles/profiles_test.go index aff0eb3..cd5cec4 100644 --- a/profiles/profiles_test.go +++ b/profiles/profiles_test.go @@ -72,7 +72,7 @@ func TestProfilesAlignment(t *testing.T) { // Test 4: Validate that all expected settings fields are present settings := loadConfig.Settings - if settings.Workers == 0 && settings.TPS == 0 && settings.BufferSize == 0 { + if settings.TasksPerEndpoint == 0 && settings.TPS == 0 && settings.BufferSize == 0 { t.Errorf("Profile %s appears to have zero values for critical settings fields", file.Name()) } diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index e9e992e..31b0505 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -26,13 +26,16 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * workers := make([]*Worker, len(cfg.Endpoints)) for i, endpoint := range cfg.Endpoints { workers[i] = NewWorker(&WorkerConfig{ - ID: i, - SeiChainID: cfg.SeiChainID, - Endpoint: endpoint, - BufferSize: cfg.Settings.BufferSize, - Tasks: cfg.Settings.TasksPerEndpoint, - Collector: collector, - Limiter: limiter, + ID: i, + SeiChainID: cfg.SeiChainID, + Endpoint: endpoint, + BufferSize: cfg.Settings.BufferSize, + Tasks: cfg.Settings.TasksPerEndpoint, + DryRun: cfg.Settings.DryRun, + TrackReceipts: cfg.Settings.TrackReceipts, + Debug: cfg.Settings.Debug, + Collector: collector, + Limiter: limiter, }) } diff --git a/sender/worker.go b/sender/worker.go index 1359ece..aff47fb 100644 --- a/sender/worker.go +++ b/sender/worker.go @@ -130,13 +130,12 @@ func NewWorker(cfg *WorkerConfig) *Worker { // Start begins the worker's processing loop func (w *Worker) Run(ctx context.Context) error { + client, err := newRPCClient(ctx, w.cfg.Endpoint) + if err != nil { + return fmt.Errorf("dial %s: %w", w.cfg.Endpoint, err) + } + defer client.Close() return service.Run(ctx, func(ctx context.Context, s service.Scope) error { - client, err := newRPCClient(ctx, w.cfg.Endpoint) - if err != nil { - return fmt.Errorf("dial %s: %w", w.cfg.Endpoint, err) - } - defer client.Close() - // Start multiple goroutines that share the same channel and RPC client. for range w.cfg.Tasks { s.Spawn(func() error { return w.runTxSender(ctx, client) }) From d9b468298255cfada135623d34b59dc6a44d3761 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Fri, 12 Jun 2026 18:55:19 +0200 Subject: [PATCH 06/21] queue pool --- sender/queue.go | 88 ++++++++++++++++++++++++++++++++++++++++ sender/sharded_sender.go | 2 + sender/worker.go | 9 ++-- 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 sender/queue.go diff --git a/sender/queue.go b/sender/queue.go new file mode 100644 index 0000000..33c1f2f --- /dev/null +++ b/sender/queue.go @@ -0,0 +1,88 @@ +package sender + +import ( + "context" + + "github.com/sei-protocol/sei-load/utils" +) + +type queueSlot struct { + id queueID + slot int +} + +type queueID int + +type queueState struct { + first int + next int +} + +type queuePoolState[T any] struct { + mem map[queueSlot]T + queues []queueState +} + +type QueuePool[T any] struct { + state utils.Mutex[*queuePoolState[T]] + size chan struct{} +} + +type Queue[T any] struct { + id queueID + pool *QueuePool[T] + size chan struct{} +} + +func (q *Queue[T]) Len() int { return len(q.size) } + +func NewQueuePool[T any](capacity int) *QueuePool[T] { + return &QueuePool[T]{ + state: utils.NewMutex(&queuePoolState[T]{ + mem: make(map[queueSlot]T, capacity), + }), + size: make(chan struct{}, capacity), + } +} + +func (p *QueuePool[T]) NewQueue() *Queue[T] { + for state := range p.state.Lock() { + id := queueID(len(state.queues)) + state.queues = append(state.queues, queueState{}) + return &Queue[T]{ + id: id, + pool: p, + size: make(chan struct{}, cap(p.size)), + } + } + panic("unreachable") +} + +func (q *Queue[T]) Send(ctx context.Context, v T) error { + if err := utils.Send(ctx, q.pool.size, struct{}{}); err != nil { + return err + } + for state := range q.pool.state.Lock() { + s := &state.queues[q.id] + state.mem[queueSlot{q.id, s.next}] = v + s.next += 1 + } + q.size <- struct{}{} + return nil +} + +func (q *Queue[T]) Recv(ctx context.Context) (T, error) { + if _, err := utils.Recv(ctx, q.size); err != nil { + return utils.Zero[T](), err + } + var res T + for state := range q.pool.state.Lock() { + s := &state.queues[q.id] + slot := queueSlot{q.id, s.first} + s.first += 1 + res = state.mem[slot] + delete(state.mem, slot) + } + <-q.pool.size + return res, nil +} diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 31b0505..25bd069 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -19,6 +19,7 @@ type ShardedSender struct { // NewShardedSender creates a new sharded sender with workers for each endpoint func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector) (*ShardedSender, error) { + pool := NewQueuePool[*types.LoadTx](len(cfg.Endpoints) * cfg.Settings.BufferSize) if len(cfg.Endpoints) == 0 { return nil, fmt.Errorf("no endpoints configured") } @@ -35,6 +36,7 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * TrackReceipts: cfg.Settings.TrackReceipts, Debug: cfg.Settings.Debug, Collector: collector, + Queue: pool.NewQueue(), Limiter: limiter, }) } diff --git a/sender/worker.go b/sender/worker.go index aff47fb..2058892 100644 --- a/sender/worker.go +++ b/sender/worker.go @@ -37,6 +37,7 @@ type WorkerConfig struct { DryRun bool Debug bool TrackReceipts bool + Queue *Queue[*types.LoadTx] Collector *stats.Collector Limiter *rate.Limiter // Shared rate limiter for transaction sending } @@ -44,7 +45,6 @@ type WorkerConfig struct { // Worker handles sending transactions to a specific endpoint type Worker struct { cfg *WorkerConfig - txChan chan *types.LoadTx sentTxs chan *types.LoadTx } @@ -121,7 +121,6 @@ func newRPCClient(ctx context.Context, endpoint string, opts ...HttpClientOption func NewWorker(cfg *WorkerConfig) *Worker { w := &Worker{ cfg: cfg, - txChan: make(chan *types.LoadTx, cfg.BufferSize), sentTxs: make(chan *types.LoadTx, cfg.BufferSize), } meterWorkerQueueLength(w) @@ -146,7 +145,7 @@ func (w *Worker) Run(ctx context.Context) error { // Send queues a transaction for this worker to process func (w *Worker) Send(ctx context.Context, tx *types.LoadTx) error { - return utils.Send(ctx, w.txChan, tx) + return w.cfg.Queue.Send(ctx, tx) } func (w *Worker) watchTransactions(ctx context.Context, eth *ethclient.Client) error { @@ -224,7 +223,7 @@ func (w *Worker) runTxSender(ctx context.Context, client *ethclient.Client) erro return err } - tx, err := utils.Recv(ctx, w.txChan) + tx, err := w.cfg.Queue.Recv(ctx) if err != nil { return err } @@ -293,7 +292,7 @@ func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, // ChannelLength returns the current length of the worker's channel (for monitoring). // This function is safe for concurrent calls. -func (w *Worker) ChannelLength() int { return len(w.txChan) } +func (w *Worker) ChannelLength() int { return w.cfg.Queue.Len() } // Endpoint returns the worker's endpoint func (w *Worker) Endpoint() string { return w.cfg.Endpoint } From 17cdc2025efa4cde93c79b7439ed841de8561041 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 12:56:07 +0200 Subject: [PATCH 07/21] WIP --- sender/metrics.go | 71 ++++----------- sender/sharded_sender.go | 100 ++++++++++----------- sender/worker.go | 190 ++++++++++++++++----------------------- 3 files changed, 143 insertions(+), 218 deletions(-) diff --git a/sender/metrics.go b/sender/metrics.go index 93888fe..3d8e128 100644 --- a/sender/metrics.go +++ b/sender/metrics.go @@ -2,11 +2,11 @@ package sender import ( "context" - "sync" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" + "github.com/sei-protocol/sei-load/utils" ) // Acquired at package init, before observability.Setup installs the real @@ -17,24 +17,24 @@ var meter = otel.Meter("github.com/sei-protocol/sei-load/sender") // Synchronous instruments — read by Record/Add call sites. var ( - sendLatency = must(meter.Float64Histogram( + sendLatency = utils.OrPanic1(meter.Float64Histogram( "send_latency", metric.WithDescription("Latency of sending transactions in seconds"), metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0))) - receiptLatency = must(meter.Float64Histogram( + receiptLatency = utils.OrPanic1(meter.Float64Histogram( "receipt_latency", metric.WithDescription("Latency from transaction submission to receipt confirmation in seconds"), metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0))) - txsAccepted = must(meter.Int64Counter( + txsAccepted = utils.OrPanic1(meter.Int64Counter( "txs_accepted", metric.WithDescription("Transactions successfully submitted to an endpoint"), metric.WithUnit("{transactions}"))) - txsRejected = must(meter.Int64Counter( + txsRejected = utils.OrPanic1(meter.Int64Counter( "txs_rejected", metric.WithDescription("Transactions rejected by the target or local client, by reason"), metric.WithUnit("{transactions}"))) @@ -44,24 +44,26 @@ var ( // Return values are discarded because OTel invokes the callbacks on each // collection; we never read the instrument handles. func init() { - must(meter.Int64ObservableGauge( + utils.OrPanic1(meter.Int64ObservableGauge( "worker_queue_length", metric.WithDescription("Length of the worker's queue"), metric.WithUnit("{count}"), metric.WithInt64Callback(func(ctx context.Context, observer metric.Int64Observer) error { - meteredChainWorkers.lock.RLock() - defer meteredChainWorkers.lock.RUnlock() - for _, worker := range meteredChainWorkers.workers { - observer.Observe(int64(worker.ChannelLength()), metric.WithAttributes( - attribute.String("endpoint", worker.Endpoint()), - attribute.Int("worker_id", worker.cfg.ID), - attribute.String("chain_id", worker.cfg.SeiChainID), - )) + for _,senders := range meteredSenders.RLock() { + for _,ss := range senders { + for _, shard := range ss.shards { + observer.Observe(int64(worker.ChannelLength()), metric.WithAttributes( + attribute.String("endpoint", worker.Endpoint()), + attribute.Int("worker_id", worker.cfg.ID), + attribute.String("chain_id", worker.cfg.SeiChainID), + )) + } + } } return nil }))) - must(meter.Float64ObservableGauge( + utils.OrPanic1(meter.Float64ObservableGauge( "tps_achieved", metric.WithDescription("Most recent TPS sample observed by the sender, per endpoint/scenario"), metric.WithUnit("{transactions}/s"), @@ -69,37 +71,9 @@ func init() { } // meteredChainWorkers is the registry the worker_queue_length callback reads. -var meteredChainWorkers = &chainWorkerObserver{ - workers: make(map[chainWorkerID]*Worker), -} +var meteredSenders = utils.NewRWMutex(map[*ShardedSender]struct{}{}) -type chainWorkerObserver struct { - lock sync.RWMutex - workers map[chainWorkerID]*Worker -} -type chainWorkerID struct { - workerID int - chainID string -} - -func meterWorkerQueueLength(worker *Worker) { - meteredChainWorkers.lock.Lock() - defer meteredChainWorkers.lock.Unlock() - id := chainWorkerID{ - workerID: worker.cfg.ID, - chainID: worker.cfg.SeiChainID, - } - if _, exists := meteredChainWorkers.workers[id]; !exists { - meteredChainWorkers.workers[id] = worker - } -} - -var tpsObserverRegistry = struct { - lock sync.RWMutex - samples map[tpsSampleKey]float64 -}{ - samples: make(map[tpsSampleKey]float64), -} +var tpsObserverRegistry = utils.NewRWMutex(map[tpsSampleKey]float64{}) type tpsSampleKey struct { endpoint string @@ -134,10 +108,3 @@ func statusAttrFromError(err error) attribute.KeyValue { } return attribute.String(key, "failure") } - -func must[V any](v V, err error) V { - if err != nil { - panic(err) - } - return v -} diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 25bd069..90cdbaa 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -14,41 +14,65 @@ import ( // ShardedSender implements TxSender with multiple workers, one per endpoint type ShardedSender struct { - workers []*Worker + cfg *config.LoadConfig + collector *stats.Collector + limiter *rate.Limiter // Shared rate limiter for transaction sending + clients []*ethClient + shards []*Queue[*types.LoadTx] } // NewShardedSender creates a new sharded sender with workers for each endpoint -func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector) (*ShardedSender, error) { - pool := NewQueuePool[*types.LoadTx](len(cfg.Endpoints) * cfg.Settings.BufferSize) +func NewShardedSender(ctx context.Context, cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector) (*ShardedSender, error) { if len(cfg.Endpoints) == 0 { return nil, fmt.Errorf("no endpoints configured") } - - workers := make([]*Worker, len(cfg.Endpoints)) - for i, endpoint := range cfg.Endpoints { - workers[i] = NewWorker(&WorkerConfig{ - ID: i, - SeiChainID: cfg.SeiChainID, - Endpoint: endpoint, - BufferSize: cfg.Settings.BufferSize, - Tasks: cfg.Settings.TasksPerEndpoint, - DryRun: cfg.Settings.DryRun, + var clients []*ethClient + for id,endpoint := range cfg.Endpoints { + clients = append(clients, newEthClient(ðClientConfig { + ChainID: cfg.SeiChainID, + ID: id, + Endpoint: endpoint, + Tasks: cfg.Settings.TasksPerEndpoint, + Debug: cfg.Settings.Debug, TrackReceipts: cfg.Settings.TrackReceipts, - Debug: cfg.Settings.Debug, - Collector: collector, - Queue: pool.NewQueue(), - Limiter: limiter, - }) + ReceiptsBuf: cfg.Settings.BufferSize, + })) } - - return &ShardedSender{workers: workers}, nil + numShards := len(cfg.Endpoints) + poolSize := numShards * cfg.Settings.BufferSize + pool := NewQueuePool[*types.LoadTx](poolSize) + var shards []*Queue[*types.LoadTx] + for range shards { + q := pool.NewQueue() + shards = append(shards,q) + meterWorkerQueueLength(q) + } + return &ShardedSender{ + cfg:cfg, + collector:collector, + limiter:limiter, + clients:clients, + shards:shards, + }, nil } // Start initializes and starts all workers -func (s *ShardedSender) Run(ctx context.Context) error { - return service.Run(ctx, func(ctx context.Context, scope service.Scope) error { - for _, worker := range s.workers { - scope.Spawn(func() error { return worker.Run(ctx) }) +func (ss *ShardedSender) Run(ctx context.Context) error { + return service.Run(ctx, func(ctx context.Context, s service.Scope) error { + for _,client := range ss.clients { + s.Spawn(func() error { return client.Run(ctx) }) + } + for i, shard := range ss.shards { + s.Spawn(func() error { + for ctx.Err()==nil { + // Apply rate limiting before getting the next transaction + if err := ss.limiter.Wait(ctx); err != nil { + return err + } + return w.runTxSender(ctx, client) + } + return ctx.Err() + }) } return nil }) @@ -56,31 +80,5 @@ func (s *ShardedSender) Run(ctx context.Context) error { // Send implements TxSender interface - calculates shard ID and routes to appropriate worker func (s *ShardedSender) Send(ctx context.Context, tx *types.LoadTx) error { - // Calculate shard ID based on the transaction - shardID := tx.ShardID(len(s.workers)) - // Send to the appropriate worker - return s.workers[shardID].Send(ctx, tx) + return s.shards[tx.ShardID(len(s.shards))].Send(ctx, tx) } - -// GetWorkerStats returns statistics for all workers -func (s *ShardedSender) GetWorkerStats() []WorkerStats { - stats := make([]WorkerStats, len(s.workers)) - for i, worker := range s.workers { - stats[i] = WorkerStats{ - WorkerID: i, - Endpoint: worker.Endpoint(), - ChannelLength: worker.ChannelLength(), - } - } - return stats -} - -// WorkerStats contains statistics for a single worker -type WorkerStats struct { - WorkerID int - Endpoint string - ChannelLength int -} - -// GetNumShards returns the number of shards (workers) -func (s *ShardedSender) NumShards() int { return len(s.workers) } diff --git a/sender/worker.go b/sender/worker.go index 2058892..87bf638 100644 --- a/sender/worker.go +++ b/sender/worker.go @@ -18,9 +18,6 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" - "golang.org/x/time/rate" - - "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils" "github.com/sei-protocol/sei-load/utils/service" @@ -28,44 +25,58 @@ import ( var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") -type WorkerConfig struct { - ID int - SeiChainID string - Endpoint string - BufferSize int - Tasks int - DryRun bool - Debug bool - TrackReceipts bool - Queue *Queue[*types.LoadTx] - Collector *stats.Collector - Limiter *rate.Limiter // Shared rate limiter for transaction sending -} - -// Worker handles sending transactions to a specific endpoint -type Worker struct { - cfg *WorkerConfig - sentTxs chan *types.LoadTx +type sendReq struct { + tx *types.LoadTx + done chan struct{} } -// HttpClientOption configures the Transport used by newHttpClient. -type HttpClientOption func(*http.Transport) +type ethClientConfig struct { + ChainID string + ID int + Endpoint string + Tasks int + Debug bool + TrackReceipts bool + ReceiptsBuf int +} -// WithMaxIdleConns overrides the global idle-connection pool size. -func WithMaxIdleConns(n int) HttpClientOption { - return func(t *http.Transport) { t.MaxIdleConns = n } +type ethClient struct { + cfg ethClientConfig + reqs chan sendReq } -// WithMaxIdleConnsPerHost overrides the per-host idle-connection pool size. -// Scale with goroutine count to avoid TCP re-dial on each completion. -func WithMaxIdleConnsPerHost(n int) HttpClientOption { - return func(t *http.Transport) { t.MaxIdleConnsPerHost = n } +func (c *ethClient) Run(ctx context.Context) error { + return service.Run(ctx, func(ctx context.Context, s service.Scope) error { + u, err := url.Parse(c.cfg.Endpoint) + if err != nil { + return fmt.Errorf("parse endpoint %q: %w", c.cfg.Endpoint, err) + } + var opts []rpc.ClientOption + switch u.Scheme { + case "http", "https": + opts = append(opts, rpc.WithHTTPClient(newHttpClient())) + } + rpcClient, err := rpc.DialOptions(ctx, c.cfg.Endpoint, opts...) + if err != nil { + return fmt.Errorf("rpc.Dial(%q): %w", c.cfg.Endpoint, err) + } + client := ethclient.NewClient(rpcClient) + defer client.Close() + receiptsChan := make(chan *types.LoadTx, c.cfg.ReceiptsBuf) + for range c.cfg.Tasks { + s.Spawn(func() error { return c.runSender(ctx, client, receiptsChan) }) + } + if c.cfg.TrackReceipts { + s.Spawn(func() error { return c.watchTransactions(ctx, client, receiptsChan) }) + } + return nil + }) } -// newHttpTransport is the base transport factory. Exists separately so tests -// can inspect the unwrapped *http.Transport; newHttpClient returns it wrapped -// in otelhttp, whose inner transport isn't publicly accessible. -func newHttpTransport(opts ...HttpClientOption) *http.Transport { +// newHttpClient returns an otelhttp-wrapped client: injects traceparent on +// outbound, emits http.client.* metrics. Requires observability.Setup to have +// installed the global TextMapPropagator. +func newHttpClient() *http.Client { t := &http.Transport{ DialContext: (&net.Dialer{ Timeout: 10 * time.Second, @@ -78,88 +89,42 @@ func newHttpTransport(opts ...HttpClientOption) *http.Transport { ExpectContinueTimeout: 1 * time.Second, DisableKeepAlives: false, } - for _, opt := range opts { - opt(t) - } - return t -} - -// newHttpClient returns an otelhttp-wrapped client: injects traceparent on -// outbound, emits http.client.* metrics. Requires observability.Setup to have -// installed the global TextMapPropagator. -func newHttpClient(opts ...HttpClientOption) *http.Client { return &http.Client{ Timeout: 30 * time.Second, - Transport: otelhttp.NewTransport(newHttpTransport(opts...)), + Transport: otelhttp.NewTransport(t), } } // newRPCClient returns a go-ethereum client configured for the endpoint scheme. // HTTP(S) endpoints reuse the tuned otelhttp-backed transport; WS(S) endpoints // use the default go-ethereum WebSocket transport. -func newRPCClient(ctx context.Context, endpoint string, opts ...HttpClientOption) (*ethclient.Client, error) { - u, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("parse endpoint %q: %w", endpoint, err) - } - - switch u.Scheme { - case "http", "https": - rpcClient, err := rpc.DialOptions(ctx, endpoint, rpc.WithHTTPClient(newHttpClient(opts...))) - if err != nil { - return nil, err - } - return ethclient.NewClient(rpcClient), nil - case "ws", "wss", "": - return ethclient.DialContext(ctx, endpoint) - default: - return nil, fmt.Errorf("unsupported RPC scheme %q for endpoint %s", u.Scheme, endpoint) - } -} - -// NewWorker creates a new worker for a specific endpoint -func NewWorker(cfg *WorkerConfig) *Worker { - w := &Worker{ - cfg: cfg, - sentTxs: make(chan *types.LoadTx, cfg.BufferSize), - } - meterWorkerQueueLength(w) - return w -} - -// Start begins the worker's processing loop -func (w *Worker) Run(ctx context.Context) error { - client, err := newRPCClient(ctx, w.cfg.Endpoint) - if err != nil { - return fmt.Errorf("dial %s: %w", w.cfg.Endpoint, err) +func newEthClient(ctx context.Context, id int, endpoint string) *ethClient { + return ðClient { + id: id, + endpoint: endpoint, + reqs: make(chan sendReq), } - defer client.Close() - return service.Run(ctx, func(ctx context.Context, s service.Scope) error { - // Start multiple goroutines that share the same channel and RPC client. - for range w.cfg.Tasks { - s.Spawn(func() error { return w.runTxSender(ctx, client) }) - } - return w.watchTransactions(ctx, client) - }) } // Send queues a transaction for this worker to process -func (w *Worker) Send(ctx context.Context, tx *types.LoadTx) error { - return w.cfg.Queue.Send(ctx, tx) +func (c *ethClient) Send(ctx context.Context, tx *types.LoadTx) error { + done := make(chan struct{}) + if err:=utils.Send(ctx,c.reqs,sendReq{tx,done}); err!=nil { + return err + } + _,_,err := utils.RecvOrClosed(ctx,done) + return err } -func (w *Worker) watchTransactions(ctx context.Context, eth *ethclient.Client) error { - if w.cfg.DryRun || !w.cfg.TrackReceipts { - return nil - } +func (c *ethClient) watchTransactions(ctx context.Context, eth *ethclient.Client, sentTxs <-chan *types.LoadTx) error { for ctx.Err() == nil { - tx, err := utils.Recv(ctx, w.sentTxs) + tx, err := utils.Recv(ctx, sentTxs) if err != nil { return err } // Cancel per-iteration; defer would leak contexts under sustained load. waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - if err := w.waitForReceipt(waitCtx, eth, tx); err != nil { + if err := c.waitForReceipt(waitCtx, eth, tx); err != nil { log.Printf("❌ %v", err) } cancel() @@ -167,12 +132,12 @@ func (w *Worker) watchTransactions(ctx context.Context, eth *ethclient.Client) e return ctx.Err() } -func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx *types.LoadTx) (_err error) { +func (c *ethClient) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx *types.LoadTx) (_err error) { ctx, span := tracer.Start(ctx, "sender.check_receipt", trace.WithAttributes( attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", w.cfg.Endpoint), - attribute.Int("seiload.worker_id", w.cfg.ID), - attribute.String("seiload.chain_id", w.cfg.SeiChainID), + attribute.String("seiload.endpoint", c.endpoint), + attribute.Int("seiload.worker_id", c.id), + attribute.String("seiload.chain_id", c.chainID), )) defer func(start time.Time) { if _err != nil { @@ -184,8 +149,8 @@ func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx * receiptLatency.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes( attribute.String("scenario", tx.Scenario.Name), - attribute.String("endpoint", w.cfg.Endpoint), - attribute.String("chain_id", w.cfg.SeiChainID), + attribute.String("endpoint", c.endpoint), + attribute.String("chain_id", c.chainID), statusAttrFromError(_err)), ) }(time.Now()) @@ -207,7 +172,7 @@ func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx * if receipt.Status != 1 { return fmt.Errorf("tx %s failed", tx.EthTx.Hash().Hex()) } - if w.cfg.Debug { + if c.cfg.Debug { log.Printf("✅ tx %s, %s, gas=%d succeeded\n", tx.Scenario.Name, tx.EthTx.Hash().Hex(), receipt.GasUsed) } return nil @@ -215,15 +180,10 @@ func (w *Worker) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx * return ctx.Err() } -// runTxSender is the main worker loop that processes transactions -func (w *Worker) runTxSender(ctx context.Context, client *ethclient.Client) error { +// runSender handles the tx send requests. +func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client, receiptsChan chan<- *types.LoadTx) error { for ctx.Err() == nil { - // Apply rate limiting before getting the next transaction - if err := w.cfg.Limiter.Wait(ctx); err != nil { - return err - } - - tx, err := w.cfg.Queue.Recv(ctx) + tx, err := utils.Recv(ctx,c.reqs) if err != nil { return err } @@ -243,12 +203,12 @@ func (w *Worker) runTxSender(ctx context.Context, client *ethclient.Client) erro } // sendTransaction sends a single transaction to the endpoint -func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, tx *types.LoadTx) (_err error) { +func (c *ethClient) sendTransaction(ctx context.Context, tx *types.LoadTx) (_err error) { ctx, span := tracer.Start(ctx, "sender.send_tx", trace.WithAttributes( attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", w.cfg.Endpoint), - attribute.Int("seiload.worker_id", w.cfg.ID), - attribute.String("seiload.chain_id", w.cfg.SeiChainID), + attribute.String("seiload.endpoint", c.endpoint), + attribute.Int("seiload.worker_id", c.id), + attribute.String("seiload.chain_id", c.chainID), )) defer func(start time.Time) { if _err != nil { From 25d6c2711db75b2b97412aae85556aef80a870e6 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 13:06:25 +0200 Subject: [PATCH 08/21] updated utils --- go.mod | 1 + go.sum | 29 +++++ main.go | 4 +- sender/ramper.go | 4 +- sender/sharded_sender.go | 4 +- sender/worker.go | 4 +- stats/block_collector.go | 4 +- utils/channels.go | 7 +- utils/mutex.go | 112 +++++++++---------- utils/option.go | 9 +- utils/option_test.go | 32 ++++++ utils/panic.go | 17 +++ utils/proto.go | 31 +----- utils/require/require.go | 113 +++++++++++++++++++ utils/scope/global.go | 80 +++++++++++++ utils/{service => scope}/parallel.go | 2 +- utils/{service => scope}/parallel_test.go | 23 ++-- utils/{service => scope}/start.go | 63 +++++++---- utils/semaphore.go | 24 ---- utils/testonly.go | 130 ++++++++++++++++++---- utils/wait.go | 53 +++++++++ utils/wait_test.go | 14 ++- 22 files changed, 583 insertions(+), 177 deletions(-) create mode 100644 utils/option_test.go create mode 100644 utils/panic.go create mode 100644 utils/require/require.go create mode 100644 utils/scope/global.go rename utils/{service => scope}/parallel.go (98%) rename utils/{service => scope}/parallel_test.go (62%) rename utils/{service => scope}/start.go (81%) delete mode 100644 utils/semaphore.go diff --git a/go.mod b/go.mod index 1678f0c..8aad25a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.1 require ( github.com/ethereum/go-ethereum v1.16.1 + github.com/gogo/protobuf v1.3.2 github.com/google/go-cmp v0.7.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.22.0 diff --git a/go.sum b/go.sum index 528de49..6457faa 100644 --- a/go.sum +++ b/go.sum @@ -119,6 +119,8 @@ github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7 github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= @@ -220,6 +222,8 @@ github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= @@ -250,24 +254,49 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= diff --git a/main.go b/main.go index 8502aea..e92f5f5 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,7 @@ import ( "github.com/sei-protocol/sei-load/sender" "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/service" + "github.com/sei-protocol/sei-load/utils/scope" ) var ( @@ -203,7 +203,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { logger := stats.NewLogger(collector, cfg.Settings.StatsInterval.ToDuration(), cfg.Settings.ReportPath, cfg.Settings.Debug) var ramper *sender.Ramper - err = service.Run(ctx, func(ctx context.Context, s service.Scope) error { + err = scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Create the generator from the config struct gen, err := generator.NewConfigBasedGenerator(cfg) if err != nil { diff --git a/sender/ramper.go b/sender/ramper.go index 209384c..9bf10fc 100644 --- a/sender/ramper.go +++ b/sender/ramper.go @@ -8,7 +8,7 @@ import ( "time" "github.com/sei-protocol/sei-load/stats" - "github.com/sei-protocol/sei-load/utils/service" + "github.com/sei-protocol/sei-load/utils/scope" "golang.org/x/time/rate" ) @@ -114,7 +114,7 @@ func (r *Ramper) WatchSLO(ctx context.Context) <-chan struct{} { // Start initializes and starts all workers func (r *Ramper) Run(ctx context.Context) error { - return service.Run(ctx, func(ctx context.Context, s service.Scope) error { + return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // TODO: Implement ramping logic r.startTime = time.Now() sloChan := r.WatchSLO(ctx) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 90cdbaa..5933ce1 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -9,7 +9,7 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils/service" + "github.com/sei-protocol/sei-load/utils/scope" ) // ShardedSender implements TxSender with multiple workers, one per endpoint @@ -58,7 +58,7 @@ func NewShardedSender(ctx context.Context, cfg *config.LoadConfig, limiter *rate // Start initializes and starts all workers func (ss *ShardedSender) Run(ctx context.Context) error { - return service.Run(ctx, func(ctx context.Context, s service.Scope) error { + return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { for _,client := range ss.clients { s.Spawn(func() error { return client.Run(ctx) }) } diff --git a/sender/worker.go b/sender/worker.go index 87bf638..84e9303 100644 --- a/sender/worker.go +++ b/sender/worker.go @@ -20,7 +20,7 @@ import ( "go.opentelemetry.io/otel/trace" "github.com/sei-protocol/sei-load/types" "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/service" + "github.com/sei-protocol/sei-load/utils/scope" ) var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") @@ -46,7 +46,7 @@ type ethClient struct { } func (c *ethClient) Run(ctx context.Context) error { - return service.Run(ctx, func(ctx context.Context, s service.Scope) error { + return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { u, err := url.Parse(c.cfg.Endpoint) if err != nil { return fmt.Errorf("parse endpoint %q: %w", c.cfg.Endpoint, err) diff --git a/stats/block_collector.go b/stats/block_collector.go index 6a1794c..82d22fc 100644 --- a/stats/block_collector.go +++ b/stats/block_collector.go @@ -10,7 +10,7 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/service" + "github.com/sei-protocol/sei-load/utils/scope" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" ) @@ -57,7 +57,7 @@ func NewBlockCollector(seiChainID string) *BlockCollector { // Start begins block subscription and data collection func (bc *BlockCollector) Run(ctx context.Context, firstEndpoint string) error { wsEndpoint := utils.GetWSEndpoint(firstEndpoint) - return service.Run(ctx, func(ctx context.Context, s service.Scope) error { + return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Connect to WebSocket endpoint client, err := ethclient.Dial(wsEndpoint) if err != nil { diff --git a/utils/channels.go b/utils/channels.go index 1e11b90..9eed500 100644 --- a/utils/channels.go +++ b/utils/channels.go @@ -45,10 +45,13 @@ func Send[T any](ctx context.Context, ch chan<- T, v T) error { } // SendOrDrop send a value to channel if not full or drop the item if the channel is full. -func SendOrDrop[T any](ch chan<- T, v T) { +func SendOrDrop[T any](ch chan<- T, v T) error { select { case ch <- v: - default: // drop the item + return nil + default: + // drop the item + return nil } } diff --git a/utils/mutex.go b/utils/mutex.go index beb1154..ffd5952 100644 --- a/utils/mutex.go +++ b/utils/mutex.go @@ -33,6 +33,41 @@ func (m *Mutex[T]) Lock() iter.Seq[T] { } } +// Mutex guards access to object of type T. +type RWMutex[T any] struct { + mu sync.RWMutex + value T +} + +// NewMutex creates a new Mutex with given object. +func NewRWMutex[T any](value T) (m RWMutex[T]) { + m.value = value + // nolint:nakedret + return +} + +// Lock returns an iterator which locks the mutex and yields the guarded object. +// The mutex is unlocked when the iterator is done. +// If the mutex is nil, the iterator is a no-op. +func (m *RWMutex[T]) Lock() iter.Seq[T] { + return func(yield func(val T) bool) { + m.mu.Lock() + defer m.mu.Unlock() + _ = yield(m.value) + } +} + +// RLock returns an iterator which locks the mutex FOR READ and yields the guarded object. +// The mutex is unlocked when the iterator is done. +// If the mutex is nil, the iterator is a no-op. +func (m *RWMutex[T]) RLock() iter.Seq[T] { + return func(yield func(val T) bool) { + m.mu.RLock() + defer m.mu.RUnlock() + _ = yield(m.value) + } +} + // version of the value stored in an atomic watch. type version[T any] struct { updated chan struct{} @@ -48,80 +83,31 @@ type atomicWatch[T any] struct { ptr atomic.Pointer[version[T]] } -type AtomicSend[T any] struct { - atomicWatch[T] -} - -// Store updates the value of the atomic watch. -func (w *AtomicSend[T]) Send(value T) { - close(w.ptr.Swap(newVersion(value)).updated) -} +type AtomicSend[T any] struct{ atomicWatch[T] } -// Update conditionally updates the value of the atomic watch. -func (w *AtomicSend[T]) Update(f func(T) (T, bool)) { - old := w.ptr.Load() - if value, ok := f(old.value); ok { - w.ptr.Store(newVersion(value)) - close(old.updated) - } +func (w *AtomicSend[T]) Subscribe() AtomicRecv[T] { + return AtomicRecv[T]{&w.atomicWatch} } +// NewAtomicWatch creates a new AtomicWatch with the given initial value. func NewAtomicSend[T any](value T) (w AtomicSend[T]) { w.ptr.Store(newVersion(value)) // nolint:nakedret return } -func (w *AtomicSend[T]) Subscribe() AtomicRecv[T] { - return AtomicRecv[T]{&w.atomicWatch} -} - -// AtomicWatch stores a pointer to an IMMUTABLE value. -// Loading and waiting for updates do NOT require locking. -// TODO(gprusak): remove mutex and rename to AtomicSend, -// this will allow for sharing a mutex across multiple AtomicSenders. -type AtomicWatch[T any] struct { - atomicWatch[T] - mu sync.Mutex +// Store updates the value of the atomic watch. +func (w *AtomicSend[T]) Store(value T) { + close(w.ptr.Swap(newVersion(value)).updated) } // AtomicRecv is a read-only reference to AtomicWatch. type AtomicRecv[T any] struct{ *atomicWatch[T] } -// NewAtomicWatch creates a new AtomicWatch with the given initial value. -func NewAtomicWatch[T any](value T) (w AtomicWatch[T]) { - w.ptr.Store(newVersion(value)) - // nolint:nakedret - return -} - -// Subscribe returns a view-only API of the atomic watch. -func (w *AtomicWatch[T]) Subscribe() AtomicRecv[T] { - return AtomicRecv[T]{&w.atomicWatch} -} - // Load returns the current value of the atomic watch. // Does not do any locking. func (w *atomicWatch[T]) Load() T { return w.ptr.Load().value } -// Store updates the value of the atomic watch. -func (w *AtomicWatch[T]) Store(value T) { - w.mu.Lock() - defer w.mu.Unlock() - close(w.ptr.Swap(newVersion(value)).updated) -} - -// Update conditionally updates the value of the atomic watch. -func (w *AtomicWatch[T]) Update(f func(T) (T, bool)) { - w.mu.Lock() - defer w.mu.Unlock() - old := w.ptr.Load() - if value, ok := f(old.value); ok { - w.ptr.Store(newVersion(value)) - close(old.updated) - } -} - // Wait waits for the value of the atomic watch to satisfy the predicate. // Does not do any locking. func (w *atomicWatch[T]) Wait(ctx context.Context, pred func(T) bool) (T, error) { @@ -232,3 +218,17 @@ func (w *Watch[T]) Lock() iter.Seq2[T, *WatchCtrl] { _ = yield(w.val, &w.ctrl) } } + +// MonitorWatchUpdates calls f and checks if it has updated the watch. +func MonitorWatchUpdates[T any](w *Watch[T], f func()) bool { + w.ctrl.mu.Lock() + updated := w.ctrl.updated + w.ctrl.mu.Unlock() + f() + select { + case <-updated: + return true + default: + return false + } +} diff --git a/utils/option.go b/utils/option.go index 85fd6a4..2dd26f2 100644 --- a/utils/option.go +++ b/utils/option.go @@ -33,13 +33,20 @@ func (o Option[T]) IsPresent() bool { } // Or returns the value if present, otherwise returns the default value. -func (o *Option[T]) Or(def T) T { +func (o Option[T]) Or(def T) T { if o.isPresent { return o.value } return def } +func (o Option[T]) OrPanic(msg string) T { + if o.isPresent { + return o.value + } + panic(msg) +} + // MapOpt applies a function to the value if present, returning a new Option. func MapOpt[T, R any](o Option[T], f func(T) R) Option[R] { if o.isPresent { diff --git a/utils/option_test.go b/utils/option_test.go new file mode 100644 index 0000000..03fb54f --- /dev/null +++ b/utils/option_test.go @@ -0,0 +1,32 @@ +package utils + +import ( + "encoding/json" + "testing" + + "github.com/sei-protocol/sei-load/utils/require" +) + +func testJSON[T any](t *testing.T, want T) { + enc, err := json.Marshal(want) + require.NoError(t, err) + t.Logf("%s", enc) + var got T + require.NoError(t, json.Unmarshal(enc, &got)) + require.NoError(t, TestDiff(want, got)) +} + +func TestOptionJSON(t *testing.T) { + type a struct { + X Option[int] + Y Option[string] + } + type b struct { + X Option[int] `json:"X,omitzero"` + Y Option[string] `json:"Y,omitzero"` + } + testJSON(t, &a{}) + testJSON(t, &a{Some(1), Some("a")}) + testJSON(t, &b{}) + testJSON(t, &b{Some(1), Some("a")}) +} diff --git a/utils/panic.go b/utils/panic.go new file mode 100644 index 0000000..960f787 --- /dev/null +++ b/utils/panic.go @@ -0,0 +1,17 @@ +package utils + +// OrPanic panics if err is non-nil. Use for initialization-time or otherwise +// unrecoverable failures where returning an error is not an option (e.g. var +// initializers, metric instrument creation). +func OrPanic(err error) { + if err != nil { + panic(err) + } +} + +// OrPanic1 returns v, panicking if err is non-nil. Convenience for wrapping a +// (value, error) call in a var initializer that cannot fail at runtime. +func OrPanic1[T any](v T, err error) T { + OrPanic(err) + return v +} diff --git a/utils/proto.go b/utils/proto.go index 5f5ad7a..0842c98 100644 --- a/utils/proto.go +++ b/utils/proto.go @@ -1,28 +1,19 @@ package utils import ( - "crypto/sha256" "errors" "fmt" "sync" - "google.golang.org/protobuf/proto" + "github.com/gogo/protobuf/proto" ) -// Hash is a SHA-256 hash. -type Hash [sha256.Size]byte - -// GetHash computes a hash of the given data. -func GetHash(data []byte) Hash { - return sha256.Sum256(data) -} - -// ParseHash parses a Hash from bytes. -func ParseHash(raw []byte) (Hash, error) { - if got, want := len(raw), sha256.Size; got != want { - return Hash{}, fmt.Errorf("hash size = %v, want %v", got, want) +func ErrorAs[T error](err error) Option[T] { + var target T + if errors.As(err, &target) { + return Some(target) } - return Hash(raw), nil + return None[T]() } // ProtoClone clones a proto.Message object. @@ -35,16 +26,6 @@ func ProtoEqual[T proto.Message](a, b T) bool { return proto.Equal(a, b) } -// ProtoHash hashes a proto.Message object. -// TODO(gprusak): make it deterministic. -func ProtoHash(a proto.Message) Hash { - raw, err := proto.Marshal(a) - if err != nil { - panic(err) - } - return sha256.Sum256(raw) -} - // ProtoMessage is comparable proto.Message. type ProtoMessage interface { comparable diff --git a/utils/require/require.go b/utils/require/require.go new file mode 100644 index 0000000..14d011d --- /dev/null +++ b/utils/require/require.go @@ -0,0 +1,113 @@ +// Package require reexports strongly typed `testify/require` API. +// We don't reexport `New`, because methods cannot be generic. +package require + +import ( + "cmp" + + "github.com/stretchr/testify/require" +) + +// TestingT . +type TestingT = require.TestingT + +// False . +var False = require.False + +// True . +var True = require.True + +// Zero . +var Zero = require.Zero + +// NotZero . +var NotZero = require.NotZero + +// Contains . +var Contains = require.Contains + +func ElementsMatch[T any](t TestingT, a []T, b []T, msgAndArgs ...any) { + require.ElementsMatch(t, a, b, msgAndArgs...) +} + +// Eventually . +var Eventually = require.Eventually + +// EqualError . +// TODO: get rid of comparing errors by strings, +// use concrete error types instead. +var EqualError = require.EqualError + +// Error . +var Error = require.Error + +// ErrorIs . +var ErrorIs = require.ErrorIs + +// NoError . +var NoError = require.NoError + +// Empty . +var Empty = require.Empty + +// NotEmpty . +var NotEmpty = require.NotEmpty + +// Len . +var Len = require.Len + +// Nil . +var Nil = require.Nil + +// NotNil . +var NotNil = require.NotNil + +// Panics . +var Panics = require.Panics + +// Fail . +var Fail = require.Fail + +// FailNow . +var FailNow = require.FailNow + +// NoFileExists . +var NoFileExists = require.NoFileExists + +// FileExists . +var FileExists = require.FileExists + +// Positive . +func Positive[T cmp.Ordered](t TestingT, e T, msgAndArgs ...any) { + require.Positive(t, e, msgAndArgs...) +} + +// Less . +func Less[T cmp.Ordered](t TestingT, e1, e2 T, msgAndArgs ...any) { + require.Less(t, e1, e2, msgAndArgs...) +} + +// LessOrEqual . +func LessOrEqual[T cmp.Ordered](t TestingT, e1, e2 T, msgAndArgs ...any) { + require.LessOrEqual(t, e1, e2, msgAndArgs...) +} + +// Greater . +func Greater[T cmp.Ordered](t TestingT, e1, e2 T, msgAndArgs ...any) { + require.Greater(t, e1, e2, msgAndArgs...) +} + +// GreaterOrEqual . +func GreaterOrEqual[T cmp.Ordered](t TestingT, e1, e2 T, msgAndArgs ...any) { + require.GreaterOrEqual(t, e1, e2, msgAndArgs...) +} + +// Equal . +func Equal[T any](t TestingT, expected, actual T, msgAndArgs ...any) { + require.Equal(t, expected, actual, msgAndArgs...) +} + +// NotEqual . +func NotEqual[T any](t TestingT, expected, actual T, msgAndArgs ...any) { + require.NotEqual(t, expected, actual, msgAndArgs...) +} diff --git a/utils/scope/global.go b/utils/scope/global.go new file mode 100644 index 0000000..91758a0 --- /dev/null +++ b/utils/scope/global.go @@ -0,0 +1,80 @@ +package scope + +import ( + "context" + + "github.com/sei-protocol/sei-load/utils" +) + +// GlobalHandle is a handle to a task spawned via SpawnGlobal. +type GlobalHandle[T any] struct { + cancel context.CancelFunc + done chan struct{} + res T +} + +// SpawnGlobal spawns a task in a global context. +// Use with care, as it is not tied to any scope and must be terminated manually by calling Terminate(). +// The task does not return an error, because there is no canonical way to handle it. +// Can be used as an intermediate step when migrating code to use scopes. +func SpawnGlobal[T any](task func(ctx context.Context) T) *GlobalHandle[T] { + ctx, cancel := context.WithCancel(context.Background()) + h := &GlobalHandle[T]{ + cancel: cancel, + done: make(chan struct{}), + } + go func() { + h.res = task(ctx) + close(h.done) + }() + return h +} + +// WhileRunning restricts ctx to the lifetime of the task. +// WARNING: If the task is already finished, it SKIPs running f and returns context.Canceled. +func (h *GlobalHandle[T]) WhileRunning(ctx context.Context, f func(ctx context.Context) error) error { + select { + case <-h.done: + return context.Canceled + default: + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + select { + case <-ctx.Done(): + case <-h.done: + cancel() + } + }() + return f(ctx) +} + +// WhileRunning1 is like WhileRunning but for functions returning a value. +func WhileRunning1[R any, T any](ctx context.Context, h *GlobalHandle[T], f func(ctx context.Context) (R, error)) (res R, err error) { + // We need to set the error outside the closure, because + // h.WhileRunning() may return context.Canceled if the task is already finished. + err = h.WhileRunning(ctx, func(ctx context.Context) error { + res, err = f(ctx) + return err + }) + return +} + +// Join awaits tasks completion. +func (h *GlobalHandle[T]) Join(ctx context.Context) (T, error) { + select { + case <-ctx.Done(): + return utils.Zero[T](), ctx.Err() + case <-h.done: + return h.res, nil + } +} + +// Terminate cancels the task and waits for it to finish. +// Returns the task's result. +func (h *GlobalHandle[T]) Terminate() T { + h.cancel() + <-h.done + return h.res +} diff --git a/utils/service/parallel.go b/utils/scope/parallel.go similarity index 98% rename from utils/service/parallel.go rename to utils/scope/parallel.go index f2bcea9..1377184 100644 --- a/utils/service/parallel.go +++ b/utils/scope/parallel.go @@ -1,4 +1,4 @@ -package service +package scope import ( "sync" diff --git a/utils/service/parallel_test.go b/utils/scope/parallel_test.go similarity index 62% rename from utils/service/parallel_test.go rename to utils/scope/parallel_test.go index 57f7ba2..7f98872 100644 --- a/utils/service/parallel_test.go +++ b/utils/scope/parallel_test.go @@ -1,15 +1,13 @@ -package service +package scope import ( "errors" "testing" - - "github.com/stretchr/testify/require" ) func TestParallelOk(t *testing.T) { x := [10]int{} - err := Parallel(func(s ParallelScope) error { + if err := Parallel(func(s ParallelScope) error { for i := range x { s.Spawn(func() error { x[i] = i @@ -17,10 +15,13 @@ func TestParallelOk(t *testing.T) { }) } return nil - }) - require.NoError(t, err) + }); err != nil { + t.Fatal(err) + } for want, got := range x { - require.Equal(t, want, got, "x[%d] = %d, want %d", want, got, want) + if want != got { + t.Fatalf("x[%d] = %d, want %d", want, got, want) + } } } @@ -39,11 +40,15 @@ func TestParallelFail(t *testing.T) { } return nil }) - require.ErrorIs(t, wantErr, err, "err = %v, want %v", err, wantErr) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } for want, got := range x { if want%2 == 0 { want = 0 } - require.Equal(t, want, got, "x[%d] = %d, want %d", want, got, want) + if want != got { + t.Fatalf("x[%d] = %d, want %d", want, got, want) + } } } diff --git a/utils/service/start.go b/utils/scope/start.go similarity index 81% rename from utils/service/start.go rename to utils/scope/start.go index a077f95..e6e80f3 100644 --- a/utils/service/start.go +++ b/utils/scope/start.go @@ -1,4 +1,4 @@ -package service +package scope import ( "context" @@ -7,30 +7,57 @@ import ( "sync" "time" - "golang.org/x/sync/errgroup" - "github.com/sei-protocol/sei-load/utils" ) -// Scope of concurrenct tasks. -type Scope struct { +type scope struct { // scope is a concurrecy primitive, so no-ctx-in-struct rule does not apply // nolint:containedctx - ctx context.Context - all *errgroup.Group - main *sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + all sync.WaitGroup + main sync.WaitGroup + errOnce sync.Once + err error +} + +// Scope of concurrenct tasks. +type Scope struct{ *scope } + +// SpawnBg spawns a background task. +// Background tasks get canceled when all the main tasks return. +func (s Scope) SpawnBg(t func() error) { + s.all.Add(1) + go func() { + defer s.all.Done() + if err := t(); err != nil { + s.Cancel(err) + } + }() } // Spawn spawns a main task. // Scope gets automatically canceled when all the main tasks return. func (s Scope) Spawn(t func() error) { s.main.Add(1) - s.all.Go(func() error { + s.SpawnBg(func() error { defer s.main.Done() return t() }) } +// Cancels the scope. +// If err is not nil and no error was set before, +// sets err as the scope error. +func (s Scope) Cancel(err error) { + if err != nil { + s.errOnce.Do(func() { + s.err = err + }) + } + s.cancel() +} + // JoinHandle is a handle to an awaitable task. type JoinHandle[R any] struct { result utils.AtomicRecv[*R] @@ -38,16 +65,16 @@ type JoinHandle[R any] struct { // Spawn1 is the same as Scope.Spawn, but allows awaiting completion of a task and getting its result. func Spawn1[R any](s Scope, t func() (R, error)) JoinHandle[R] { - send := utils.NewAtomicSend[*R](nil) + result := utils.NewAtomicSend[*R](nil) s.Spawn(func() error { v, err := t() if err != nil { return err } - send.Send(&v) + result.Store(&v) return nil }) - return JoinHandle[R]{send.Subscribe()} + return JoinHandle[R]{result.Subscribe()} } // Join awaits completion of a task and returns its result. @@ -112,10 +139,6 @@ func (s Scope) SpawnBgNamed(name string, t func() error) { } } -// SpawnBg spawns a background task. -// Background tasks get canceled when all the main tasks return. -func (s Scope) SpawnBg(t func() error) { s.all.Go(t) } - // Run runs a scope capable of spawning tasks. // It is guaranteed that all the spawned tasks will be executed (even if spawned after the context is cancelled), // and that `Run` will return only after all the tasks have completed. @@ -123,12 +146,12 @@ func (s Scope) SpawnBg(t func() error) { s.all.Go(t) } // Returns the first error returned by any task (main or background). func Run(ctx context.Context, main func(context.Context, Scope) error) error { ctx, cancel := context.WithCancel(ctx) - all, ctx := errgroup.WithContext(ctx) - s := Scope{ctx, all, &sync.WaitGroup{}} + s := Scope{&scope{ctx: ctx, cancel: cancel}} s.Spawn(func() error { return main(ctx, s) }) s.main.Wait() - cancel() - return s.all.Wait() + s.cancel() + s.all.Wait() + return s.err } // Run1 is the same as Run, but returns the result of the main task. diff --git a/utils/semaphore.go b/utils/semaphore.go deleted file mode 100644 index 728c12a..0000000 --- a/utils/semaphore.go +++ /dev/null @@ -1,24 +0,0 @@ -package utils - -import ( - "context" -) - -// Semaphore provides a way to bound concurrenct access to a resource. -type Semaphore struct { - ch chan struct{} -} - -// NewSemaphore constructs a new semaphore with n permits. -func NewSemaphore(n int) *Semaphore { - return &Semaphore{ch: make(chan struct{}, n)} -} - -// Acquire acquires a permit from the semaphore. -// Blocks until a permit is available. -func (s *Semaphore) Acquire(ctx context.Context) (relase func(), err error) { - if err := Send(ctx, s.ch, struct{}{}); err != nil { - return nil, err - } - return func() { <-s.ch }, nil -} diff --git a/utils/testonly.go b/utils/testonly.go index 7c0ddf0..fc5b77d 100644 --- a/utils/testonly.go +++ b/utils/testonly.go @@ -1,15 +1,17 @@ package utils import ( + "bytes" + "context" "fmt" "math/big" "math/rand" "reflect" "time" + "github.com/gogo/protobuf/proto" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" ) @@ -19,7 +21,7 @@ type ReadOnly struct{} // isReadOnly returns true if t embeds ReadOnly. func isReadOnly(t reflect.Type) bool { - want := reflect.TypeOf(ReadOnly{}) + want := reflect.TypeFor[ReadOnly]() if t.Kind() != reflect.Struct { return false } @@ -45,6 +47,9 @@ var cmpOpts = []cmp.Option{ protocmp.Transform(), cmp.Exporter(isReadOnly), cmpopts.EquateEmpty(), + // Optimization for comparing slices of bytes. + // Applies iff any of the slices is non-empty to avoid collision with EquateEmpty. + cmp.FilterValues(func(x, y []byte) bool { return len(x) > 0 || len(y) > 0 }, cmp.Comparer(bytes.Equal)), cmp.Comparer(cmpComparer[big.Int]), } @@ -61,22 +66,85 @@ func TestEqual[T any](a, b T) bool { return cmp.Equal(a, b, cmpOpts...) } -// TestRngSplit returns a new random number splitted from the given one. +// Thread-safe wrapper of rand.Rand. +type Rng struct{ inner *Mutex[*rand.Rand] } + +func (rng Rng) Read(p []byte) (int, error) { + for inner := range rng.inner.Lock() { + return inner.Read(p) + } + panic("unreachable") +} + +func (rng Rng) Int63() int64 { + for inner := range rng.inner.Lock() { + return inner.Int63() + } + panic("unreachable") +} + +func (rng Rng) Uint64() uint64 { + for inner := range rng.inner.Lock() { + return inner.Uint64() + } + panic("unreachable") +} + +func (rng Rng) Int() int { + for inner := range rng.inner.Lock() { + return inner.Int() + } + panic("unreachable") +} + +func (rng Rng) Intn(n int) int { + for inner := range rng.inner.Lock() { + return inner.Intn(n) + } + panic("unreachable") +} + +func (rng Rng) Int63n(n int64) int64 { + for inner := range rng.inner.Lock() { + return inner.Int63n(n) + } + panic("unreachable") +} + +func (rng Rng) Shuffle(n int, swap func(i, j int)) { + for inner := range rng.inner.Lock() { + inner.Shuffle(n, swap) + } +} + +// Split returns a new random number splitted from the given one. +// It should be used to provide deterministic rngs to independent goroutines. // This is a very primitive splitting, known to result with dependent randomness. // If that ever causes a problem, we can switch to SplitMix. -func TestRngSplit(rng *rand.Rand) *rand.Rand { - return rand.New(rand.NewSource(rng.Int63())) +func (rng Rng) Split() Rng { + for inner := range rng.inner.Lock() { + return TestRngFromSeed(inner.Int63()) + } + panic("unreachable") } // TestRng returns a deterministic random number generator. -func TestRng() *rand.Rand { - return rand.New(rand.NewSource(789345342)) +func TestRng() Rng { + return TestRngFromSeed(789345342) +} + +func TestRngFromSeed(seed int64) Rng { + return Rng{Alloc(NewMutex(rand.New(rand.NewSource(seed))))} +} + +func GenBool(rng Rng) bool { + return rng.Intn(2) == 0 } var alphanum = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") // GenString generates a random string of length n. -func GenString(rng *rand.Rand, n int) string { +func GenString(rng Rng, n int) string { s := make([]rune, n) for i := range n { s[i] = alphanum[rand.Intn(len(alphanum))] @@ -84,23 +152,33 @@ func GenString(rng *rand.Rand, n int) string { return string(s) } +// Shuffle reorders the elements of s uniformly at random. +func Shuffle[T any](rng Rng, s []T) { + for i := 1; i < len(s); i += 1 { + j := rng.Intn(i) + s[i], s[j] = s[j], s[i] + } +} + // GenBytes generates a random byte slice. -func GenBytes(rng *rand.Rand, n int) []byte { +func GenBytes(rng Rng, n int) []byte { s := make([]byte, n) - _, _ = rng.Read(s) + for inner := range rng.inner.Lock() { + _, _ = inner.Read(s) + } return s } // GenF is a function which generates T. -type GenF[T any] = func(rng *rand.Rand) T +type GenF[T any] = func(rng Rng) T // GenSlice generates a slice of small random length. -func GenSlice[T any](rng *rand.Rand, gen GenF[T]) []T { +func GenSlice[T any](rng Rng, gen GenF[T]) []T { return GenSliceN(rng, 2+rng.Intn(3), gen) } // GenSliceN generates a slice of n elements. -func GenSliceN[T any](rng *rand.Rand, n int, gen GenF[T]) []T { +func GenSliceN[T any](rng Rng, n int, gen GenF[T]) []T { s := make([]T, n) for i := range s { s[i] = gen(rng) @@ -109,12 +187,12 @@ func GenSliceN[T any](rng *rand.Rand, n int, gen GenF[T]) []T { } // GenMap generates a map of small random length. -func GenMap[K comparable, V any](rng *rand.Rand, genK GenF[K], genV GenF[V]) map[K]V { +func GenMap[K comparable, V any](rng Rng, genK GenF[K], genV GenF[V]) map[K]V { return GenMapN(rng, 2+rng.Intn(3), genK, genV) } // GenMapN generates a map of n elements. -func GenMapN[K comparable, V any](rng *rand.Rand, n int, genK GenF[K], genV GenF[V]) map[K]V { +func GenMapN[K comparable, V any](rng Rng, n int, genK GenF[K], genV GenF[V]) map[K]V { m := make(map[K]V, n) for len(m) < n { m[genK(rng)] = genV(rng) @@ -123,19 +201,12 @@ func GenMapN[K comparable, V any](rng *rand.Rand, n int, genK GenF[K], genV GenF } // GenTimestamp generates a random timestamp. -func GenTimestamp(rng *rand.Rand) time.Time { +func GenTimestamp(rng Rng) time.Time { return time.Unix(0, rng.Int63()) } -// GenHash generates a random Hash. -func GenHash(rng *rand.Rand) Hash { - var h Hash - _, _ = rng.Read(h[:]) - return h -} - // Test tests whether reencoding a value is an identity operation. -func (c ProtoConv[T, P]) Test(want T) error { +func (c *ProtoConv[T, P]) Test(want T) error { p := c.Encode(want) raw, err := proto.Marshal(p) if err != nil { @@ -150,3 +221,14 @@ func (c ProtoConv[T, P]) Test(want T) error { } return TestDiff(want, got) } + +// IgnoreAfterCancel silently drops the error if the context is already canceled. +// Should be used for background tasks in tests, which cannot be guaranteed to exit gracefully. +// For example - if you have a tcp connection, then during cleanup one end will disconnect faster than the other, +// causing a race condition between context cancellation and disconnection error. +func IgnoreAfterCancel(ctx context.Context, err error) error { + if ctx.Err() != nil { + return nil + } + return err +} diff --git a/utils/wait.go b/utils/wait.go index 4c8c663..d1875be 100644 --- a/utils/wait.go +++ b/utils/wait.go @@ -4,6 +4,7 @@ import ( "context" "encoding" "errors" + "sync/atomic" "time" ) @@ -15,6 +16,18 @@ func IgnoreCancel(err error) error { return err } +// WithDeadline executes a function with a deadline. +// If deadline is none, it executes the function without a deadline. +func WithDeadline(ctx context.Context, md Option[time.Time], f func(ctx context.Context) error) error { + d, ok := md.Get() + if !ok { + return f(ctx) + } + ctx, cancel := context.WithDeadline(ctx, d) + defer cancel() + return f(ctx) +} + // WithTimeout executes a function with a timeout. func WithTimeout(ctx context.Context, d time.Duration, f func(ctx context.Context) error) error { ctx, cancel := context.WithTimeout(ctx, d) @@ -22,6 +35,14 @@ func WithTimeout(ctx context.Context, d time.Duration, f func(ctx context.Contex return f(ctx) } +// WithOptTimeout executes a function with a timeout. +func WithOptTimeout(ctx context.Context, d Option[time.Duration], f func(ctx context.Context) error) error { + if d, ok := d.Get(); ok { + return WithTimeout(ctx, d, f) + } + return f(ctx) +} + // WithTimeout1 executes a function with a timeout. func WithTimeout1[R any](ctx context.Context, d time.Duration, f func(ctx context.Context) (R, error)) (R, error) { ctx, cancel := context.WithTimeout(ctx, d) @@ -29,6 +50,14 @@ func WithTimeout1[R any](ctx context.Context, d time.Duration, f func(ctx contex return f(ctx) } +// WithOptTimeout1 executes a function with a timeout. +func WithOptTimeout1[R any](ctx context.Context, d Option[time.Duration], f func(ctx context.Context) (R, error)) (R, error) { + if d, ok := d.Get(); ok { + return WithTimeout1(ctx, d, f) + } + return f(ctx) +} + // Sleep sleeps for a duration or until the context is canceled. func Sleep(ctx context.Context, d time.Duration) error { select { @@ -117,3 +146,27 @@ func (d Duration) Duration() time.Duration { func (d Duration) Seconds() float64 { return time.Duration(d).Seconds() } + +// Once is an idempotent signal. +type Once struct { + _ NoCopy + ch chan struct{} + done atomic.Bool +} + +func NewOnce() (o Once) { + o.ch = make(chan struct{}) + return +} + +func (o *Once) Send() { + if o.done.Swap(true) { + return + } + close(o.ch) +} + +func (o *Once) Recv(ctx context.Context) error { + _, _, err := RecvOrClosed(ctx, o.ch) + return err +} diff --git a/utils/wait_test.go b/utils/wait_test.go index 0331ae9..91edc12 100644 --- a/utils/wait_test.go +++ b/utils/wait_test.go @@ -4,16 +4,20 @@ import ( "encoding/json" "testing" "time" - - "github.com/stretchr/testify/require" ) func TestJSON(t *testing.T) { var got, want struct{ X Duration } want.X = Duration(100 * time.Millisecond) j, err := json.Marshal(want) - require.NoError(t, err) + if err != nil { + t.Fatal(err) + } t.Logf("%s", j) - require.NoError(t, json.Unmarshal(j, &got)) - require.NoError(t, TestDiff(want, got)) + if err := json.Unmarshal(j, &got); err != nil { + t.Fatal(err) + } + if err := TestDiff(want, got); err != nil { + t.Fatal(err) + } } From 1aa4c0f047d8dc9023d4a53df57b7ab444d3034d Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 13:55:44 +0200 Subject: [PATCH 09/21] sender rewritten --- config/config.go | 26 +++- main.go | 2 +- sender/{worker.go => eth_client.go} | 121 +++++++++--------- sender/{worker_test.go => eth_client_test.go} | 0 sender/metrics.go | 75 +++++++---- sender/sharded_sender.go | 91 ++++++++----- 6 files changed, 190 insertions(+), 125 deletions(-) rename sender/{worker.go => eth_client.go} (69%) rename sender/{worker_test.go => eth_client_test.go} (100%) diff --git a/config/config.go b/config/config.go index 938a8e4..8ca851a 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "github.com/sei-protocol/sei-load/utils" "math/big" "time" ) @@ -11,12 +12,16 @@ import ( type LoadConfig struct { ChainID int64 `json:"chainId,omitempty"` // SeiChainID is the textual chain ID used for tagging metric collection. - SeiChainID string `json:"seiChainID,omitempty"` - Endpoints []string `json:"endpoints"` - Accounts *AccountConfig `json:"accounts,omitempty"` - Scenarios []Scenario `json:"scenarios,omitempty"` - MockDeploy bool `json:"mockDeploy,omitempty"` - Settings *Settings `json:"settings,omitempty"` + SeiChainID string `json:"seiChainID,omitempty"` + Endpoints []string `json:"endpoints"` + // Number of shards to divide the senders into. + // Txs within each shard are sent sequentially. + // Defaults to Endpoints * Settings.TasksPerEndpoint. + NumShards utils.Option[int] `json:"numShards,omitzero"` + Accounts *AccountConfig `json:"accounts,omitempty"` + Scenarios []Scenario `json:"scenarios,omitempty"` + MockDeploy bool `json:"mockDeploy,omitempty"` + Settings *Settings `json:"settings,omitempty"` // Funding, when set, funds the generated account pool from a root key at // startup so the run works against a real chain. See funding.go. Funding *FundingConfig `json:"funding,omitempty"` @@ -33,6 +38,15 @@ type LoadConfig struct { Seed *uint64 `json:"seed,omitempty"` } +func (c *LoadConfig) GetNumShards() int { + return c.NumShards.Or(len(c.Endpoints) * c.Settings.TasksPerEndpoint) +} + +func (c *LoadConfig) TotalQueueSize() int { + // Backward compatible formula, consider making it a config value. + return len(c.Endpoints) * c.Settings.BufferSize +} + // Duration wraps time.Duration to provide JSON unmarshaling support type Duration time.Duration diff --git a/main.go b/main.go index e92f5f5..1a48343 100644 --- a/main.go +++ b/main.go @@ -302,7 +302,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { if cfg.Settings.TxsDir == "" { // Start the sender (starts all workers) s.SpawnBgNamed("sender", func() error { return snd.Run(ctx) }) - log.Printf("✅ Connected to %d endpoints", snd.NumShards()) + log.Printf("✅ Connected to %d endpoints", len(cfg.Endpoints)) } // Perform prewarming if enabled (before starting logger to avoid logging prewarm transactions) if cfg.Settings.Prewarm { diff --git a/sender/worker.go b/sender/eth_client.go similarity index 69% rename from sender/worker.go rename to sender/eth_client.go index 84e9303..c66f486 100644 --- a/sender/worker.go +++ b/sender/eth_client.go @@ -13,36 +13,40 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/sei-load/stats" + "github.com/sei-protocol/sei-load/types" + "github.com/sei-protocol/sei-load/utils" + "github.com/sei-protocol/sei-load/utils/scope" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" - "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/scope" ) var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") type sendReq struct { - tx *types.LoadTx + tx *types.LoadTx done chan struct{} } type ethClientConfig struct { - ChainID string - ID int - Endpoint string - Tasks int - Debug bool + ChainID string + ID int + Endpoint string + Tasks int + Debug bool + DryRun bool TrackReceipts bool - ReceiptsBuf int -} + ReceiptsBuf int + Collector *stats.Collector +} type ethClient struct { - cfg ethClientConfig - reqs chan sendReq + cfg *ethClientConfig + reqs chan sendReq + receipts chan *types.LoadTx } func (c *ethClient) Run(ctx context.Context) error { @@ -62,12 +66,11 @@ func (c *ethClient) Run(ctx context.Context) error { } client := ethclient.NewClient(rpcClient) defer client.Close() - receiptsChan := make(chan *types.LoadTx, c.cfg.ReceiptsBuf) for range c.cfg.Tasks { - s.Spawn(func() error { return c.runSender(ctx, client, receiptsChan) }) + s.Spawn(func() error { return c.runSender(ctx, client) }) } if c.cfg.TrackReceipts { - s.Spawn(func() error { return c.watchTransactions(ctx, client, receiptsChan) }) + s.Spawn(func() error { return c.watchTransactions(ctx, client) }) } return nil }) @@ -98,46 +101,49 @@ func newHttpClient() *http.Client { // newRPCClient returns a go-ethereum client configured for the endpoint scheme. // HTTP(S) endpoints reuse the tuned otelhttp-backed transport; WS(S) endpoints // use the default go-ethereum WebSocket transport. -func newEthClient(ctx context.Context, id int, endpoint string) *ethClient { - return ðClient { - id: id, - endpoint: endpoint, - reqs: make(chan sendReq), +func newEthClient(cfg *ethClientConfig) *ethClient { + receiptsBuf := 0 + if cfg.TrackReceipts { + receiptsBuf = cfg.ReceiptsBuf + } + return ðClient{ + cfg: cfg, + reqs: make(chan sendReq), + receipts: make(chan *types.LoadTx, receiptsBuf), } } // Send queues a transaction for this worker to process func (c *ethClient) Send(ctx context.Context, tx *types.LoadTx) error { done := make(chan struct{}) - if err:=utils.Send(ctx,c.reqs,sendReq{tx,done}); err!=nil { + if err := utils.Send(ctx, c.reqs, sendReq{tx, done}); err != nil { return err } - _,_,err := utils.RecvOrClosed(ctx,done) + _, _, err := utils.RecvOrClosed(ctx, done) return err } -func (c *ethClient) watchTransactions(ctx context.Context, eth *ethclient.Client, sentTxs <-chan *types.LoadTx) error { +func (c *ethClient) watchTransactions(ctx context.Context, eth *ethclient.Client) error { for ctx.Err() == nil { - tx, err := utils.Recv(ctx, sentTxs) + tx, err := utils.Recv(ctx, c.receipts) if err != nil { return err } - // Cancel per-iteration; defer would leak contexts under sustained load. - waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - if err := c.waitForReceipt(waitCtx, eth, tx); err != nil { + if err := c.waitForReceipt(ctx, eth, tx); err != nil { log.Printf("❌ %v", err) } - cancel() } return ctx.Err() } func (c *ethClient) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx *types.LoadTx) (_err error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() ctx, span := tracer.Start(ctx, "sender.check_receipt", trace.WithAttributes( attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", c.endpoint), - attribute.Int("seiload.worker_id", c.id), - attribute.String("seiload.chain_id", c.chainID), + attribute.String("seiload.endpoint", c.cfg.Endpoint), + attribute.Int("seiload.worker_id", c.cfg.ID), + attribute.String("seiload.chain_id", c.cfg.ChainID), )) defer func(start time.Time) { if _err != nil { @@ -149,8 +155,8 @@ func (c *ethClient) waitForReceipt(ctx context.Context, eth *ethclient.Client, t receiptLatency.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes( attribute.String("scenario", tx.Scenario.Name), - attribute.String("endpoint", c.endpoint), - attribute.String("chain_id", c.chainID), + attribute.String("endpoint", c.cfg.Endpoint), + attribute.String("chain_id", c.cfg.ChainID), statusAttrFromError(_err)), ) }(time.Now()) @@ -180,10 +186,10 @@ func (c *ethClient) waitForReceipt(ctx context.Context, eth *ethclient.Client, t return ctx.Err() } -// runSender handles the tx send requests. -func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client, receiptsChan chan<- *types.LoadTx) error { +// runSender handles the tx send requests. +func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client) error { for ctx.Err() == nil { - tx, err := utils.Recv(ctx,c.reqs) + req, err := utils.Recv(ctx, c.reqs) if err != nil { return err } @@ -191,10 +197,9 @@ func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client, rec startTime := time.Now() // This goroutine solely owns tx between dequeue and the sentTxs hand-off, // so stamping the actual send-attempt time here is race-free (see LoadTx). - tx.AttemptedSendTime = startTime - err = w.sendTransaction(ctx, client, tx) - // Record statistics if collector is available - w.cfg.Collector.RecordTransaction(tx.Scenario.Name, w.cfg.Endpoint, time.Since(startTime), err == nil) + req.tx.AttemptedSendTime = startTime + err = c.sendTx(ctx, client, req.tx) + c.cfg.Collector.RecordTransaction(req.tx.Scenario.Name, c.cfg.Endpoint, time.Since(startTime), err == nil) if err != nil { log.Printf("%v", err) } @@ -202,13 +207,12 @@ func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client, rec return ctx.Err() } -// sendTransaction sends a single transaction to the endpoint -func (c *ethClient) sendTransaction(ctx context.Context, tx *types.LoadTx) (_err error) { +func (c *ethClient) sendTx(ctx context.Context, eth *ethclient.Client, tx *types.LoadTx) (_err error) { ctx, span := tracer.Start(ctx, "sender.send_tx", trace.WithAttributes( attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", c.endpoint), - attribute.Int("seiload.worker_id", c.id), - attribute.String("seiload.chain_id", c.chainID), + attribute.String("seiload.endpoint", c.cfg.Endpoint), + attribute.Int("seiload.worker_id", c.cfg.ID), + attribute.String("seiload.chain_id", c.cfg.ChainID), )) defer func(start time.Time) { if _err != nil { @@ -219,40 +223,31 @@ func (c *ethClient) sendTransaction(ctx context.Context, tx *types.LoadTx) (_err sendLatency.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes( attribute.String("scenario", tx.Scenario.Name), - attribute.String("endpoint", w.cfg.Endpoint), - attribute.String("chain_id", w.cfg.SeiChainID), + attribute.String("endpoint", c.cfg.Endpoint), + attribute.String("chain_id", c.cfg.ChainID), statusAttrFromError(_err)), ) }(time.Now()) - if w.cfg.DryRun { + if c.cfg.DryRun { // In dry-run mode, simulate processing time and mark as successful // Use very minimal delay to avoid channel overflow return utils.Sleep(ctx, 10*time.Microsecond) // Much faster simulation } // Send through go-ethereum so the same code path supports both HTTP(S) and WS(S) RPC. - if err := client.SendTransaction(ctx, tx.EthTx); err != nil { + if err := eth.SendTransaction(ctx, tx.EthTx); err != nil { txsRejected.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", w.cfg.Endpoint), + attribute.String("endpoint", c.cfg.Endpoint), attribute.String("scenario", tx.Scenario.Name), attribute.String("reason", "rpc"), )) - return fmt.Errorf("Worker %d: Failed to send transaction: %w", w.cfg.ID, err) + return fmt.Errorf("Worker %d: Failed to send transaction: %w", c.cfg.ID, err) } txsAccepted.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", w.cfg.Endpoint), + attribute.String("endpoint", c.cfg.Endpoint), attribute.String("scenario", tx.Scenario.Name), )) - - // Write to sentTxs channel without blocking - utils.SendOrDrop(w.sentTxs, tx) + utils.SendOrDrop(c.receipts, tx) return nil } - -// ChannelLength returns the current length of the worker's channel (for monitoring). -// This function is safe for concurrent calls. -func (w *Worker) ChannelLength() int { return w.cfg.Queue.Len() } - -// Endpoint returns the worker's endpoint -func (w *Worker) Endpoint() string { return w.cfg.Endpoint } diff --git a/sender/worker_test.go b/sender/eth_client_test.go similarity index 100% rename from sender/worker_test.go rename to sender/eth_client_test.go diff --git a/sender/metrics.go b/sender/metrics.go index 3d8e128..16f9383 100644 --- a/sender/metrics.go +++ b/sender/metrics.go @@ -3,10 +3,10 @@ package sender import ( "context" + "github.com/sei-protocol/sei-load/utils" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" - "github.com/sei-protocol/sei-load/utils" ) // Acquired at package init, before observability.Setup installs the real @@ -49,15 +49,13 @@ func init() { metric.WithDescription("Length of the worker's queue"), metric.WithUnit("{count}"), metric.WithInt64Callback(func(ctx context.Context, observer metric.Int64Observer) error { - for _,senders := range meteredSenders.RLock() { - for _,ss := range senders { - for _, shard := range ss.shards { - observer.Observe(int64(worker.ChannelLength()), metric.WithAttributes( - attribute.String("endpoint", worker.Endpoint()), - attribute.Int("worker_id", worker.cfg.ID), - attribute.String("chain_id", worker.cfg.SeiChainID), - )) - } + for _, ss := range meteredSenders.Get() { + for _, stats := range ss.ShardStats() { + observer.Observe(int64(stats.TxsQueued), metric.WithAttributes( + attribute.String("endpoint", stats.Endpoint), + attribute.Int("worker_id", stats.ID), + attribute.String("chain_id", stats.ChainID), + )) } } return nil @@ -70,8 +68,41 @@ func init() { metric.WithFloat64Callback(observeTPS))) } +type Registry[T comparable] struct { + r utils.RWMutex[map[T]struct{}] +} + +func (r *Registry[T]) Get() []T { + for r := range r.r.RLock() { + var vs []T + for v := range r { + vs = append(vs, v) + } + return vs + } + panic("unreachable") +} + +func NewRegistry[T comparable]() *Registry[T] { + return &Registry[T]{r: utils.NewRWMutex(map[T]struct{}{})} +} + +func (r *Registry[T]) MustRegister(val T) (cancel func()) { + for r := range r.r.Lock() { + if _, ok := r[val]; ok { + panic("already registered") + } + r[val] = struct{}{} + } + return func() { + for r := range r.r.Lock() { + delete(r, val) + } + } +} + // meteredChainWorkers is the registry the worker_queue_length callback reads. -var meteredSenders = utils.NewRWMutex(map[*ShardedSender]struct{}{}) +var meteredSenders = NewRegistry[*ShardedSender]() var tpsObserverRegistry = utils.NewRWMutex(map[tpsSampleKey]float64{}) @@ -83,20 +114,20 @@ type tpsSampleKey struct { // RecordTPSSample publishes the latest TPS sample read by the tps_achieved gauge. func RecordTPSSample(endpoint, chainID, scenario string, tps float64) { - tpsObserverRegistry.lock.Lock() - defer tpsObserverRegistry.lock.Unlock() - tpsObserverRegistry.samples[tpsSampleKey{endpoint, chainID, scenario}] = tps + for r := range tpsObserverRegistry.Lock() { + r[tpsSampleKey{endpoint, chainID, scenario}] = tps + } } func observeTPS(_ context.Context, observer metric.Float64Observer) error { - tpsObserverRegistry.lock.RLock() - defer tpsObserverRegistry.lock.RUnlock() - for k, v := range tpsObserverRegistry.samples { - observer.Observe(v, metric.WithAttributes( - attribute.String("endpoint", k.endpoint), - attribute.String("chain_id", k.chainID), - attribute.String("scenario", k.scenario), - )) + for r := range tpsObserverRegistry.RLock() { + for k, v := range r { + observer.Observe(v, metric.WithAttributes( + attribute.String("endpoint", k.endpoint), + attribute.String("chain_id", k.chainID), + attribute.String("scenario", k.scenario), + )) + } } return nil } diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 5933ce1..2b0a731 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -14,62 +14,72 @@ import ( // ShardedSender implements TxSender with multiple workers, one per endpoint type ShardedSender struct { - cfg *config.LoadConfig - collector *stats.Collector - limiter *rate.Limiter // Shared rate limiter for transaction sending - clients []*ethClient - shards []*Queue[*types.LoadTx] + cfg *config.LoadConfig + limiter *rate.Limiter // Shared rate limiter for transaction sending + clients []*ethClient + shards []*Queue[*types.LoadTx] } -// NewShardedSender creates a new sharded sender with workers for each endpoint -func NewShardedSender(ctx context.Context, cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector) (*ShardedSender, error) { +// NewShardedSender creates a new sharded sender. +// Txs of each shard are sent sequentially, using a single eth client. +func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector) (*ShardedSender, error) { if len(cfg.Endpoints) == 0 { return nil, fmt.Errorf("no endpoints configured") } var clients []*ethClient - for id,endpoint := range cfg.Endpoints { - clients = append(clients, newEthClient(ðClientConfig { - ChainID: cfg.SeiChainID, - ID: id, - Endpoint: endpoint, - Tasks: cfg.Settings.TasksPerEndpoint, - Debug: cfg.Settings.Debug, + for id, endpoint := range cfg.Endpoints { + clients = append(clients, newEthClient(ðClientConfig{ + ChainID: cfg.SeiChainID, + ID: id, + Endpoint: endpoint, + Tasks: cfg.Settings.TasksPerEndpoint, + Debug: cfg.Settings.Debug, TrackReceipts: cfg.Settings.TrackReceipts, - ReceiptsBuf: cfg.Settings.BufferSize, + ReceiptsBuf: cfg.Settings.BufferSize, + Collector: collector, })) } - numShards := len(cfg.Endpoints) - poolSize := numShards * cfg.Settings.BufferSize + poolSize := len(cfg.Endpoints) * cfg.Settings.BufferSize pool := NewQueuePool[*types.LoadTx](poolSize) var shards []*Queue[*types.LoadTx] - for range shards { - q := pool.NewQueue() - shards = append(shards,q) - meterWorkerQueueLength(q) + for range cfg.GetNumShards() { + shards = append(shards, pool.NewQueue()) } return &ShardedSender{ - cfg:cfg, - collector:collector, - limiter:limiter, - clients:clients, - shards:shards, + cfg: cfg, + limiter: limiter, + clients: clients, + shards: shards, }, nil } +// Send implements TxSender interface - calculates shard ID and routes to appropriate worker +func (s *ShardedSender) Send(ctx context.Context, tx *types.LoadTx) error { + return s.shards[tx.ShardID(len(s.shards))].Send(ctx, tx) +} + // Start initializes and starts all workers func (ss *ShardedSender) Run(ctx context.Context) error { + cancel := meteredSenders.MustRegister(ss) + defer cancel() return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - for _,client := range ss.clients { + for _, client := range ss.clients { s.Spawn(func() error { return client.Run(ctx) }) } for i, shard := range ss.shards { s.Spawn(func() error { - for ctx.Err()==nil { - // Apply rate limiting before getting the next transaction + client := ss.clients[i%len(ss.clients)] + for ctx.Err() == nil { if err := ss.limiter.Wait(ctx); err != nil { return err } - return w.runTxSender(ctx, client) + tx, err := shard.Recv(ctx) + if err != nil { + return err + } + if err := client.Send(ctx, tx); err != nil { + return err + } } return ctx.Err() }) @@ -78,7 +88,22 @@ func (ss *ShardedSender) Run(ctx context.Context) error { }) } -// Send implements TxSender interface - calculates shard ID and routes to appropriate worker -func (s *ShardedSender) Send(ctx context.Context, tx *types.LoadTx) error { - return s.shards[tx.ShardID(len(s.shards))].Send(ctx, tx) +type ShardStats struct { + ChainID string + ID int + Endpoint string + TxsQueued int +} + +func (ss *ShardedSender) ShardStats() []ShardStats { + var stats []ShardStats + for i, shard := range ss.shards { + stats = append(stats, ShardStats{ + ChainID: ss.cfg.SeiChainID, + ID: i, + Endpoint: ss.clients[i%len(ss.clients)].cfg.Endpoint, + TxsQueued: shard.Len(), + }) + } + return stats } From b94776f70d0c51240e5a271a27dab40b2fc049af Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 14:18:20 +0200 Subject: [PATCH 10/21] fixed bugs --- sender/eth_client.go | 31 +++-- sender/eth_client_test.go | 284 +++++++++++++++++++++++++++++++------- 2 files changed, 253 insertions(+), 62 deletions(-) diff --git a/sender/eth_client.go b/sender/eth_client.go index c66f486..fec9097 100644 --- a/sender/eth_client.go +++ b/sender/eth_client.go @@ -50,22 +50,22 @@ type ethClient struct { } func (c *ethClient) Run(ctx context.Context) error { + u, err := url.Parse(c.cfg.Endpoint) + if err != nil { + return fmt.Errorf("parse endpoint %q: %w", c.cfg.Endpoint, err) + } + var opts []rpc.ClientOption + switch u.Scheme { + case "http", "https": + opts = append(opts, rpc.WithHTTPClient(newHttpClient())) + } + rpcClient, err := rpc.DialOptions(ctx, c.cfg.Endpoint, opts...) + if err != nil { + return fmt.Errorf("rpc.Dial(%q): %w", c.cfg.Endpoint, err) + } + client := ethclient.NewClient(rpcClient) + defer client.Close() return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - u, err := url.Parse(c.cfg.Endpoint) - if err != nil { - return fmt.Errorf("parse endpoint %q: %w", c.cfg.Endpoint, err) - } - var opts []rpc.ClientOption - switch u.Scheme { - case "http", "https": - opts = append(opts, rpc.WithHTTPClient(newHttpClient())) - } - rpcClient, err := rpc.DialOptions(ctx, c.cfg.Endpoint, opts...) - if err != nil { - return fmt.Errorf("rpc.Dial(%q): %w", c.cfg.Endpoint, err) - } - client := ethclient.NewClient(rpcClient) - defer client.Close() for range c.cfg.Tasks { s.Spawn(func() error { return c.runSender(ctx, client) }) } @@ -199,6 +199,7 @@ func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client) err // so stamping the actual send-attempt time here is race-free (see LoadTx). req.tx.AttemptedSendTime = startTime err = c.sendTx(ctx, client, req.tx) + close(req.done) c.cfg.Collector.RecordTransaction(req.tx.Scenario.Name, c.cfg.Endpoint, time.Since(startTime), err == nil) if err != nil { log.Printf("%v", err) diff --git a/sender/eth_client_test.go b/sender/eth_client_test.go index c9f30c9..40af218 100644 --- a/sender/eth_client_test.go +++ b/sender/eth_client_test.go @@ -2,83 +2,273 @@ package sender import ( "context" + "math/big" "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/sei-load/stats" + "github.com/sei-protocol/sei-load/types" + "github.com/sei-protocol/sei-load/utils" + "github.com/sei-protocol/sei-load/utils/scope" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" ) -func TestNewHttpTransport_Defaults(t *testing.T) { - tr := newHttpTransport() +func TestEthClientSendTx_HTTP(t *testing.T) { + tel := setupTelemetry(t) + api := &mockEthAPI{} + srv := rpc.NewServer() + require.NoError(t, srv.RegisterName("eth", api)) + + var traceparent string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + traceparent = r.Header.Get("traceparent") + srv.ServeHTTP(w, r) + })) + defer ts.Close() + + tx := testLoadTx(t) + client := newEthClient(ðClientConfig{ + ChainID: "test-chain", + ID: 7, + Endpoint: ts.URL, + Tasks: 1, + Collector: stats.NewCollector(), + }) - require.Equal(t, 500, tr.MaxIdleConns) - require.Equal(t, 50, tr.MaxIdleConnsPerHost) - require.Equal(t, 90*time.Second, tr.IdleConnTimeout) - require.False(t, tr.DisableKeepAlives) + err := exerciseClientViaSend(t, client, tx, func() bool { + return api.CallCount() == 1 + }) + require.NoError(t, err) + require.NotEmpty(t, traceparent, "otelhttp transport should inject traceparent") + require.Equal(t, 1, api.CallCount()) + require.NotEmpty(t, api.RawTransactions()) + + rm := tel.Collect(t) + requireHistogramCount(t, rm, "send_latency", map[string]string{ + "scenario": "test-scenario", + "endpoint": ts.URL, + "chain_id": "test-chain", + "status": "success", + }, 1) + requireSumValue(t, rm, "txs_accepted", map[string]string{ + "endpoint": ts.URL, + "scenario": "test-scenario", + }, 1) } -func TestNewHttpTransport_WithMaxIdleConns(t *testing.T) { - tr := newHttpTransport(WithMaxIdleConns(2048)) +func TestEthClientSendTx_WS(t *testing.T) { + tel := setupTelemetry(t) + api := &mockEthAPI{} + srv := rpc.NewServer() + require.NoError(t, srv.RegisterName("eth", api)) + + ts := httptest.NewServer(srv.WebsocketHandler([]string{"*"})) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + tx := testLoadTx(t) + client := newEthClient(ðClientConfig{ + ChainID: "test-chain", + ID: 8, + Endpoint: wsURL, + Tasks: 1, + Collector: stats.NewCollector(), + }) + + err := exerciseClientViaSend(t, client, tx, func() bool { + return api.CallCount() == 1 + }) + require.NoError(t, err) + require.Equal(t, 1, api.CallCount()) + require.NotEmpty(t, api.RawTransactions()) + + rm := tel.Collect(t) + requireHistogramCount(t, rm, "send_latency", map[string]string{ + "scenario": "test-scenario", + "endpoint": wsURL, + "chain_id": "test-chain", + "status": "success", + }, 1) + requireSumValue(t, rm, "txs_accepted", map[string]string{ + "endpoint": wsURL, + "scenario": "test-scenario", + }, 1) +} - require.Equal(t, 2048, tr.MaxIdleConns) - require.Equal(t, 50, tr.MaxIdleConnsPerHost, "per-host default preserved") +type mockEthAPI struct { + mu sync.Mutex + rawTxs [][]byte } -func TestNewHttpTransport_WithMaxIdleConnsPerHost(t *testing.T) { - tr := newHttpTransport(WithMaxIdleConnsPerHost(1024)) +func (m *mockEthAPI) SendRawTransaction(_ context.Context, rawTx hexutil.Bytes) (common.Hash, error) { + m.mu.Lock() + defer m.mu.Unlock() - require.Equal(t, 1024, tr.MaxIdleConnsPerHost) - require.Equal(t, 500, tr.MaxIdleConns, "global default preserved") + cp := make([]byte, len(rawTx)) + copy(cp, rawTx) + m.rawTxs = append(m.rawTxs, cp) + return common.HexToHash("0x1234"), nil } -func TestNewHttpTransport_MultipleOptions(t *testing.T) { - tr := newHttpTransport( - WithMaxIdleConns(4096), - WithMaxIdleConnsPerHost(1024), - ) +func (m *mockEthAPI) CallCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.rawTxs) +} + +func (m *mockEthAPI) RawTransactions() [][]byte { + m.mu.Lock() + defer m.mu.Unlock() - require.Equal(t, 4096, tr.MaxIdleConns) - require.Equal(t, 1024, tr.MaxIdleConnsPerHost) + out := make([][]byte, len(m.rawTxs)) + for i, rawTx := range m.rawTxs { + cp := make([]byte, len(rawTx)) + copy(cp, rawTx) + out[i] = cp + } + return out } -func TestNewHttpClient_Smoke(t *testing.T) { - c := newHttpClient() - require.Equal(t, 30*time.Second, c.Timeout) - require.NotNil(t, c.Transport, "Transport must be set") - _, isBareTransport := c.Transport.(*http.Transport) - require.False(t, isBareTransport, "Transport should be wrapped by otelhttp, not bare *http.Transport") +type testTelemetry struct { + reader *sdkmetric.ManualReader } -func TestNewRPCClient_HTTP(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() +var ( + testTelemetryOnce sync.Once + sharedTelemetry testTelemetry +) + +func setupTelemetry(t *testing.T) testTelemetry { + t.Helper() - client, err := newRPCClient(context.Background(), srv.URL) + testTelemetryOnce.Do(func() { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + tp := sdktrace.NewTracerProvider() + otel.SetMeterProvider(mp) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + sharedTelemetry = testTelemetry{reader: reader} + }) + return sharedTelemetry +} + +func (tt testTelemetry) Collect(t *testing.T) metricdata.ResourceMetrics { + t.Helper() + + var rm metricdata.ResourceMetrics + require.NoError(t, tt.reader.Collect(t.Context(), &rm)) + return rm +} + +func exerciseClientViaSend(t *testing.T, client *ethClient, tx *types.LoadTx, sent func() bool) error { + t.Helper() + + var sendErr error + err := scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { + s.SpawnBg(func() error { + return utils.IgnoreAfterCancel(ctx, client.Run(ctx)) + }) + + sendCtx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + sendErr = client.Send(sendCtx, tx) + + require.Eventually(t, func() bool { + return client.cfg.Collector.GetStats().TotalTxs == 1 && sent() + }, time.Second, 10*time.Millisecond) + return nil + }) require.NoError(t, err) - require.NotNil(t, client) - client.Close() + return sendErr } -func TestNewRPCClient_WS(t *testing.T) { - srv := rpc.NewServer() - ts := httptest.NewServer(srv.WebsocketHandler([]string{"*"})) - defer ts.Close() +func testLoadTx(t *testing.T) *types.LoadTx { + t.Helper() - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") - client, err := newRPCClient(context.Background(), wsURL) + account, err := types.NewAccount() + require.NoError(t, err) + + to := common.HexToAddress("0x0000000000000000000000000000000000000001") + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 1, + To: &to, + Value: big.NewInt(1), + Gas: 21_000, + GasPrice: big.NewInt(1), + }) + signedTx, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(big.NewInt(1)), account.PrivKey) require.NoError(t, err) - require.NotNil(t, client) - client.Close() + + return types.CreateTxFromEthTx(signedTx, &types.TxScenario{ + Name: "test-scenario", + Sender: account, + }) +} + +func requireHistogramCount(t *testing.T, rm metricdata.ResourceMetrics, name string, attrs map[string]string, minCount uint64) { + t.Helper() + + for _, scopeMetric := range rm.ScopeMetrics { + for _, metric := range scopeMetric.Metrics { + if metric.Name != name { + continue + } + hist, ok := metric.Data.(metricdata.Histogram[float64]) + require.True(t, ok, "metric %q should be a float64 histogram", name) + for _, point := range hist.DataPoints { + if attrsMatch(point.Attributes, attrs) && point.Count >= minCount { + return + } + } + } + } + t.Fatalf("metric %q did not contain attrs=%v with count >= %d", name, attrs, minCount) +} + +func requireSumValue(t *testing.T, rm metricdata.ResourceMetrics, name string, attrs map[string]string, minValue int64) { + t.Helper() + + for _, scopeMetric := range rm.ScopeMetrics { + for _, metric := range scopeMetric.Metrics { + if metric.Name != name { + continue + } + sum, ok := metric.Data.(metricdata.Sum[int64]) + require.True(t, ok, "metric %q should be an int64 sum", name) + for _, point := range sum.DataPoints { + if attrsMatch(point.Attributes, attrs) && point.Value >= minValue { + return + } + } + } + } + t.Fatalf("metric %q did not contain attrs=%v with value >= %d", name, attrs, minValue) } -func TestNewRPCClient_UnsupportedScheme(t *testing.T) { - client, err := newRPCClient(context.Background(), "ftp://example.com") - require.Error(t, err) - require.Nil(t, client) +func attrsMatch(set attribute.Set, want map[string]string) bool { + for k, v := range want { + got, ok := (&set).Value(attribute.Key(k)) + if !ok || got.Emit() != v { + return false + } + } + return true } From de4e2d928204c0c09765494ece4dcaca7c3c6ce4 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 14:34:08 +0200 Subject: [PATCH 11/21] WIP --- sender/eth_client_test.go | 207 +++++--------------------------------- 1 file changed, 26 insertions(+), 181 deletions(-) diff --git a/sender/eth_client_test.go b/sender/eth_client_test.go index 40af218..4cba42e 100644 --- a/sender/eth_client_test.go +++ b/sender/eth_client_test.go @@ -2,13 +2,12 @@ package sender import ( "context" + "crypto/sha256" "math/big" - "net/http" "net/http/httptest" + "slices" "strings" - "sync" "testing" - "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -19,25 +18,14 @@ import ( "github.com/sei-protocol/sei-load/utils" "github.com/sei-protocol/sei-load/utils/scope" "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/propagation" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/metric/metricdata" - sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func TestEthClientSendTx_HTTP(t *testing.T) { - tel := setupTelemetry(t) - api := &mockEthAPI{} + api := newMockEthAPI() srv := rpc.NewServer() require.NoError(t, srv.RegisterName("eth", api)) - var traceparent string - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - traceparent = r.Header.Get("traceparent") - srv.ServeHTTP(w, r) - })) + ts := httptest.NewServer(srv) defer ts.Close() tx := testLoadTx(t) @@ -49,30 +37,16 @@ func TestEthClientSendTx_HTTP(t *testing.T) { Collector: stats.NewCollector(), }) - err := exerciseClientViaSend(t, client, tx, func() bool { - return api.CallCount() == 1 + err := scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { + s.SpawnBg(func() error { return utils.IgnoreCancel(client.Run(ctx)) }) + return client.Send(ctx, tx) }) require.NoError(t, err) - require.NotEmpty(t, traceparent, "otelhttp transport should inject traceparent") - require.Equal(t, 1, api.CallCount()) - require.NotEmpty(t, api.RawTransactions()) - - rm := tel.Collect(t) - requireHistogramCount(t, rm, "send_latency", map[string]string{ - "scenario": "test-scenario", - "endpoint": ts.URL, - "chain_id": "test-chain", - "status": "success", - }, 1) - requireSumValue(t, rm, "txs_accepted", map[string]string{ - "endpoint": ts.URL, - "scenario": "test-scenario", - }, 1) + require.Equal(t, [][]byte{tx.Payload}, api.RawTransactions()) } func TestEthClientSendTx_WS(t *testing.T) { - tel := setupTelemetry(t) - api := &mockEthAPI{} + api := newMockEthAPI() srv := rpc.NewServer() require.NoError(t, srv.RegisterName("eth", api)) @@ -89,115 +63,36 @@ func TestEthClientSendTx_WS(t *testing.T) { Collector: stats.NewCollector(), }) - err := exerciseClientViaSend(t, client, tx, func() bool { - return api.CallCount() == 1 + err := scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { + s.SpawnBg(func() error { return utils.IgnoreCancel(client.Run(ctx)) }) + return client.Send(ctx, tx) }) require.NoError(t, err) - require.Equal(t, 1, api.CallCount()) - require.NotEmpty(t, api.RawTransactions()) - - rm := tel.Collect(t) - requireHistogramCount(t, rm, "send_latency", map[string]string{ - "scenario": "test-scenario", - "endpoint": wsURL, - "chain_id": "test-chain", - "status": "success", - }, 1) - requireSumValue(t, rm, "txs_accepted", map[string]string{ - "endpoint": wsURL, - "scenario": "test-scenario", - }, 1) + require.Equal(t, [][]byte{tx.Payload}, api.RawTransactions()) } type mockEthAPI struct { - mu sync.Mutex - rawTxs [][]byte + rawTxs utils.Mutex[*[][]byte] } -func (m *mockEthAPI) SendRawTransaction(_ context.Context, rawTx hexutil.Bytes) (common.Hash, error) { - m.mu.Lock() - defer m.mu.Unlock() - - cp := make([]byte, len(rawTx)) - copy(cp, rawTx) - m.rawTxs = append(m.rawTxs, cp) - return common.HexToHash("0x1234"), nil +func newMockEthAPI() *mockEthAPI { + rawTxs := [][]byte{} + return &mockEthAPI{rawTxs: utils.NewMutex(&rawTxs)} } -func (m *mockEthAPI) CallCount() int { - m.mu.Lock() - defer m.mu.Unlock() - return len(m.rawTxs) +func (m *mockEthAPI) SendRawTransaction(_ context.Context, rawTx hexutil.Bytes) (common.Hash, error) { + for rawTxs := range m.rawTxs.Lock() { + *rawTxs = append(*rawTxs, slices.Clone(rawTx)) + } + sum := sha256.Sum256(rawTx) + return common.BytesToHash(sum[:]), nil } func (m *mockEthAPI) RawTransactions() [][]byte { - m.mu.Lock() - defer m.mu.Unlock() - - out := make([][]byte, len(m.rawTxs)) - for i, rawTx := range m.rawTxs { - cp := make([]byte, len(rawTx)) - copy(cp, rawTx) - out[i] = cp + for rawTxs := range m.rawTxs.Lock() { + return slices.Clone(*rawTxs) } - return out -} - -type testTelemetry struct { - reader *sdkmetric.ManualReader -} - -var ( - testTelemetryOnce sync.Once - sharedTelemetry testTelemetry -) - -func setupTelemetry(t *testing.T) testTelemetry { - t.Helper() - - testTelemetryOnce.Do(func() { - reader := sdkmetric.NewManualReader() - mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) - tp := sdktrace.NewTracerProvider() - otel.SetMeterProvider(mp) - otel.SetTracerProvider(tp) - otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( - propagation.TraceContext{}, - propagation.Baggage{}, - )) - sharedTelemetry = testTelemetry{reader: reader} - }) - return sharedTelemetry -} - -func (tt testTelemetry) Collect(t *testing.T) metricdata.ResourceMetrics { - t.Helper() - - var rm metricdata.ResourceMetrics - require.NoError(t, tt.reader.Collect(t.Context(), &rm)) - return rm -} - -func exerciseClientViaSend(t *testing.T, client *ethClient, tx *types.LoadTx, sent func() bool) error { - t.Helper() - - var sendErr error - err := scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { - s.SpawnBg(func() error { - return utils.IgnoreAfterCancel(ctx, client.Run(ctx)) - }) - - sendCtx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - sendErr = client.Send(sendCtx, tx) - - require.Eventually(t, func() bool { - return client.cfg.Collector.GetStats().TotalTxs == 1 && sent() - }, time.Second, 10*time.Millisecond) - return nil - }) - require.NoError(t, err) - return sendErr + panic("unreachable") } func testLoadTx(t *testing.T) *types.LoadTx { @@ -222,53 +117,3 @@ func testLoadTx(t *testing.T) *types.LoadTx { Sender: account, }) } - -func requireHistogramCount(t *testing.T, rm metricdata.ResourceMetrics, name string, attrs map[string]string, minCount uint64) { - t.Helper() - - for _, scopeMetric := range rm.ScopeMetrics { - for _, metric := range scopeMetric.Metrics { - if metric.Name != name { - continue - } - hist, ok := metric.Data.(metricdata.Histogram[float64]) - require.True(t, ok, "metric %q should be a float64 histogram", name) - for _, point := range hist.DataPoints { - if attrsMatch(point.Attributes, attrs) && point.Count >= minCount { - return - } - } - } - } - t.Fatalf("metric %q did not contain attrs=%v with count >= %d", name, attrs, minCount) -} - -func requireSumValue(t *testing.T, rm metricdata.ResourceMetrics, name string, attrs map[string]string, minValue int64) { - t.Helper() - - for _, scopeMetric := range rm.ScopeMetrics { - for _, metric := range scopeMetric.Metrics { - if metric.Name != name { - continue - } - sum, ok := metric.Data.(metricdata.Sum[int64]) - require.True(t, ok, "metric %q should be an int64 sum", name) - for _, point := range sum.DataPoints { - if attrsMatch(point.Attributes, attrs) && point.Value >= minValue { - return - } - } - } - } - t.Fatalf("metric %q did not contain attrs=%v with value >= %d", name, attrs, minValue) -} - -func attrsMatch(set attribute.Set, want map[string]string) bool { - for k, v := range want { - got, ok := (&set).Value(attribute.Key(k)) - if !ok || got.Emit() != v { - return false - } - } - return true -} From d337faed8b6e692f339aabb59b72913e9ef80ce3 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 14:43:33 +0200 Subject: [PATCH 12/21] a better test --- sender/eth_client_test.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/sender/eth_client_test.go b/sender/eth_client_test.go index 4cba42e..63909d7 100644 --- a/sender/eth_client_test.go +++ b/sender/eth_client_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "math/big" + "net/http" "net/http/httptest" "slices" "strings" @@ -18,6 +19,9 @@ import ( "github.com/sei-protocol/sei-load/utils" "github.com/sei-protocol/sei-load/utils/scope" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func TestEthClientSendTx_HTTP(t *testing.T) { @@ -25,8 +29,20 @@ func TestEthClientSendTx_HTTP(t *testing.T) { srv := rpc.NewServer() require.NoError(t, srv.RegisterName("eth", api)) - ts := httptest.NewServer(srv) + // We check the TraceID as a proof that otel Transport was used. + var traceparent string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + traceparent = r.Header.Get("traceparent") + srv.ServeHTTP(w, r) + })) defer ts.Close() + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + otel.SetTracerProvider(sdktrace.NewTracerProvider()) + ctx, span := otel.Tracer("sender-test").Start(t.Context(), "parent") + defer span.End() tx := testLoadTx(t) client := newEthClient(ðClientConfig{ @@ -37,12 +53,13 @@ func TestEthClientSendTx_HTTP(t *testing.T) { Collector: stats.NewCollector(), }) - err := scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { + err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBg(func() error { return utils.IgnoreCancel(client.Run(ctx)) }) return client.Send(ctx, tx) }) require.NoError(t, err) require.Equal(t, [][]byte{tx.Payload}, api.RawTransactions()) + require.Contains(t, traceparent, span.SpanContext().TraceID().String()) } func TestEthClientSendTx_WS(t *testing.T) { From 207f05c28f3651ac6eebfb4581721a7afa9b5d35 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 14:52:59 +0200 Subject: [PATCH 13/21] missing dry run --- sender/sharded_sender.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 2b0a731..2b6ba4f 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -33,6 +33,7 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * ID: id, Endpoint: endpoint, Tasks: cfg.Settings.TasksPerEndpoint, + DryRun: cfg.Settings.DryRun, Debug: cfg.Settings.Debug, TrackReceipts: cfg.Settings.TrackReceipts, ReceiptsBuf: cfg.Settings.BufferSize, From e1bc51e35b2d5d5d52f69f760ce79be70dd8d5eb Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 15:00:57 +0200 Subject: [PATCH 14/21] applied comments --- sender/eth_client.go | 14 +++++++------- sender/sharded_sender.go | 6 +++++- utils/channels.go | 27 ++------------------------- utils/testonly.go | 2 +- 4 files changed, 15 insertions(+), 34 deletions(-) diff --git a/sender/eth_client.go b/sender/eth_client.go index fec9097..91f8b60 100644 --- a/sender/eth_client.go +++ b/sender/eth_client.go @@ -28,7 +28,7 @@ var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") type sendReq struct { tx *types.LoadTx - done chan struct{} + done chan error } type ethClientConfig struct { @@ -115,11 +115,14 @@ func newEthClient(cfg *ethClientConfig) *ethClient { // Send queues a transaction for this worker to process func (c *ethClient) Send(ctx context.Context, tx *types.LoadTx) error { - done := make(chan struct{}) + done := make(chan error, 1) if err := utils.Send(ctx, c.reqs, sendReq{tx, done}); err != nil { return err } - _, _, err := utils.RecvOrClosed(ctx, done) + err, recvErr := utils.Recv(ctx, done) + if recvErr != nil { + return recvErr + } return err } @@ -199,11 +202,8 @@ func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client) err // so stamping the actual send-attempt time here is race-free (see LoadTx). req.tx.AttemptedSendTime = startTime err = c.sendTx(ctx, client, req.tx) - close(req.done) + req.done <- err c.cfg.Collector.RecordTransaction(req.tx.Scenario.Name, c.cfg.Endpoint, time.Since(startTime), err == nil) - if err != nil { - log.Printf("%v", err) - } } return ctx.Err() } diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 2b6ba4f..8ed31e5 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -3,6 +3,7 @@ package sender import ( "context" "fmt" + "log" "golang.org/x/time/rate" @@ -26,6 +27,9 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * if len(cfg.Endpoints) == 0 { return nil, fmt.Errorf("no endpoints configured") } + if cfg.GetNumShards() <= 0 { + return nil, fmt.Errorf("no shards configured") + } var clients []*ethClient for id, endpoint := range cfg.Endpoints { clients = append(clients, newEthClient(ðClientConfig{ @@ -79,7 +83,7 @@ func (ss *ShardedSender) Run(ctx context.Context) error { return err } if err := client.Send(ctx, tx); err != nil { - return err + log.Printf("%v", err) } } return ctx.Err() diff --git a/utils/channels.go b/utils/channels.go index 9eed500..a8bafee 100644 --- a/utils/channels.go +++ b/utils/channels.go @@ -2,8 +2,6 @@ package utils import ( "context" - - "github.com/pkg/errors" ) // Recv receives a value from a channel or returns an error if the context is canceled. @@ -45,30 +43,9 @@ func Send[T any](ctx context.Context, ch chan<- T, v T) error { } // SendOrDrop send a value to channel if not full or drop the item if the channel is full. -func SendOrDrop[T any](ch chan<- T, v T) error { +func SendOrDrop[T any](ch chan<- T, v T) { select { case ch <- v: - return nil - default: - // drop the item - return nil - } -} - -// ForEach is a helper function that reads from a channel and calls a handler for each item. -// this avoids needing a lot of for/select boilerplate everywhere. -func ForEach[T any](ctx context.Context, ch <-chan T, handler func(T) error) error { - for { - select { - case <-ctx.Done(): - return errors.WithStack(ctx.Err()) - case item, ok := <-ch: - if !ok { - return nil // Channel closed - } - if err := handler(item); err != nil { - return err // Stop on error - } - } + default: // drop the item } } diff --git a/utils/testonly.go b/utils/testonly.go index fc5b77d..91e76ad 100644 --- a/utils/testonly.go +++ b/utils/testonly.go @@ -147,7 +147,7 @@ var alphanum = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234 func GenString(rng Rng, n int) string { s := make([]rune, n) for i := range n { - s[i] = alphanum[rand.Intn(len(alphanum))] + s[i] = alphanum[rng.Intn(len(alphanum))] } return string(s) } From 13a1ac41d3fbc176c9f59f14a6dc84dec9f77006 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 15:04:35 +0200 Subject: [PATCH 15/21] lint --- sender/eth_client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sender/eth_client.go b/sender/eth_client.go index 91f8b60..44a5489 100644 --- a/sender/eth_client.go +++ b/sender/eth_client.go @@ -242,7 +242,7 @@ func (c *ethClient) sendTx(ctx context.Context, eth *ethclient.Client, tx *types attribute.String("scenario", tx.Scenario.Name), attribute.String("reason", "rpc"), )) - return fmt.Errorf("Worker %d: Failed to send transaction: %w", c.cfg.ID, err) + return fmt.Errorf("eth.SendTransaction(): %w", err) } txsAccepted.Add(ctx, 1, metric.WithAttributes( From 2aaac16ed35f49fb8c594de22331ff51e8a8b897 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 15:31:01 +0200 Subject: [PATCH 16/21] used TotalQueueSize --- sender/sharded_sender.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index 8ed31e5..c0afa94 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -44,8 +44,7 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * Collector: collector, })) } - poolSize := len(cfg.Endpoints) * cfg.Settings.BufferSize - pool := NewQueuePool[*types.LoadTx](poolSize) + pool := NewQueuePool[*types.LoadTx](cfg.TotalQueueSize()) var shards []*Queue[*types.LoadTx] for range cfg.GetNumShards() { shards = append(shards, pool.NewQueue()) From 531507c5cf014fd9816b50b650e1384286a765fd Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Mon, 15 Jun 2026 15:51:44 +0200 Subject: [PATCH 17/21] verifying that queue has positive size --- sender/sharded_sender.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index c0afa94..b3db566 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -27,9 +27,14 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * if len(cfg.Endpoints) == 0 { return nil, fmt.Errorf("no endpoints configured") } - if cfg.GetNumShards() <= 0 { + numShards := cfg.GetNumShards() + if numShards <= 0 { return nil, fmt.Errorf("no shards configured") } + totalQueueSize := cfg.TotalQueueSize() + if totalQueueSize <= 0 { + return nil, fmt.Errorf("queue size has to be positive") + } var clients []*ethClient for id, endpoint := range cfg.Endpoints { clients = append(clients, newEthClient(ðClientConfig{ @@ -44,9 +49,9 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * Collector: collector, })) } - pool := NewQueuePool[*types.LoadTx](cfg.TotalQueueSize()) + pool := NewQueuePool[*types.LoadTx](totalQueueSize) var shards []*Queue[*types.LoadTx] - for range cfg.GetNumShards() { + for range numShards { shards = append(shards, pool.NewQueue()) } return &ShardedSender{ From e62d0aba6ce2a7530efc48a51c97a0e2a22af1fc Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Tue, 16 Jun 2026 13:52:39 +0200 Subject: [PATCH 18/21] applied comments --- config/config.go | 3 +++ sender/sharded_sender.go | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/config/config.go b/config/config.go index 8ca851a..158b2fa 100644 --- a/config/config.go +++ b/config/config.go @@ -17,6 +17,9 @@ type LoadConfig struct { // Number of shards to divide the senders into. // Txs within each shard are sent sequentially. // Defaults to Endpoints * Settings.TasksPerEndpoint. + // WARNING: this is unrelated to the server-side autobahn sharding + // (which assigns tx sender addrs to lanes). It is solely used to maximize + // txs/s throughput of the load generator. NumShards utils.Option[int] `json:"numShards,omitzero"` Accounts *AccountConfig `json:"accounts,omitempty"` Scenarios []Scenario `json:"scenarios,omitempty"` diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index b3db566..52b9fbc 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -79,13 +79,13 @@ func (ss *ShardedSender) Run(ctx context.Context) error { s.Spawn(func() error { client := ss.clients[i%len(ss.clients)] for ctx.Err() == nil { - if err := ss.limiter.Wait(ctx); err != nil { - return err - } tx, err := shard.Recv(ctx) if err != nil { return err } + if err := ss.limiter.Wait(ctx); err != nil { + return err + } if err := client.Send(ctx, tx); err != nil { log.Printf("%v", err) } From 6abf71840a2d50265fee751c926416be89dee9af Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Tue, 16 Jun 2026 14:04:34 +0200 Subject: [PATCH 19/21] Resolve sender merge conflicts for gprusak-queue --- sender/eth_client.go | 116 ++++--------------- sender/metrics.go | 10 -- sender/sharded_sender.go | 72 +++--------- sender/worker.go | 242 --------------------------------------- sender/worker_test.go | 223 ------------------------------------ 5 files changed, 37 insertions(+), 626 deletions(-) delete mode 100644 sender/worker.go delete mode 100644 sender/worker_test.go diff --git a/sender/eth_client.go b/sender/eth_client.go index 44a5489..796b071 100644 --- a/sender/eth_client.go +++ b/sender/eth_client.go @@ -2,15 +2,12 @@ package sender import ( "context" - "errors" "fmt" - "log" "net" "net/http" "net/url" "time" - "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" "github.com/sei-protocol/sei-load/stats" @@ -32,21 +29,19 @@ type sendReq struct { } type ethClientConfig struct { - ChainID string - ID int - Endpoint string - Tasks int - Debug bool - DryRun bool - TrackReceipts bool - ReceiptsBuf int - Collector *stats.Collector + ChainID string + ID int + Endpoint string + Tasks int + Debug bool + DryRun bool + Collector *stats.Collector + Inclusion utils.Option[*stats.InclusionTracker] } type ethClient struct { - cfg *ethClientConfig - reqs chan sendReq - receipts chan *types.LoadTx + cfg *ethClientConfig + reqs chan sendReq } func (c *ethClient) Run(ctx context.Context) error { @@ -69,9 +64,6 @@ func (c *ethClient) Run(ctx context.Context) error { for range c.cfg.Tasks { s.Spawn(func() error { return c.runSender(ctx, client) }) } - if c.cfg.TrackReceipts { - s.Spawn(func() error { return c.watchTransactions(ctx, client) }) - } return nil }) } @@ -98,22 +90,14 @@ func newHttpClient() *http.Client { } } -// newRPCClient returns a go-ethereum client configured for the endpoint scheme. -// HTTP(S) endpoints reuse the tuned otelhttp-backed transport; WS(S) endpoints -// use the default go-ethereum WebSocket transport. func newEthClient(cfg *ethClientConfig) *ethClient { - receiptsBuf := 0 - if cfg.TrackReceipts { - receiptsBuf = cfg.ReceiptsBuf - } return ðClient{ - cfg: cfg, - reqs: make(chan sendReq), - receipts: make(chan *types.LoadTx, receiptsBuf), + cfg: cfg, + reqs: make(chan sendReq), } } -// Send queues a transaction for this worker to process +// Send queues a transaction for this endpoint client to process. func (c *ethClient) Send(ctx context.Context, tx *types.LoadTx) error { done := make(chan error, 1) if err := utils.Send(ctx, c.reqs, sendReq{tx, done}); err != nil { @@ -126,69 +110,6 @@ func (c *ethClient) Send(ctx context.Context, tx *types.LoadTx) error { return err } -func (c *ethClient) watchTransactions(ctx context.Context, eth *ethclient.Client) error { - for ctx.Err() == nil { - tx, err := utils.Recv(ctx, c.receipts) - if err != nil { - return err - } - if err := c.waitForReceipt(ctx, eth, tx); err != nil { - log.Printf("❌ %v", err) - } - } - return ctx.Err() -} - -func (c *ethClient) waitForReceipt(ctx context.Context, eth *ethclient.Client, tx *types.LoadTx) (_err error) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - ctx, span := tracer.Start(ctx, "sender.check_receipt", trace.WithAttributes( - attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", c.cfg.Endpoint), - attribute.Int("seiload.worker_id", c.cfg.ID), - attribute.String("seiload.chain_id", c.cfg.ChainID), - )) - defer func(start time.Time) { - if _err != nil { - span.RecordError(_err) - } - span.End() - // Record inside the span ctx so exemplars link to the trace. - // worker_id stays off the histogram (cardinality); available via span. - receiptLatency.Record(ctx, time.Since(start).Seconds(), - metric.WithAttributes( - attribute.String("scenario", tx.Scenario.Name), - attribute.String("endpoint", c.cfg.Endpoint), - attribute.String("chain_id", c.cfg.ChainID), - statusAttrFromError(_err)), - ) - }(time.Now()) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for ctx.Err() == nil { - if _, err := utils.Recv(ctx, ticker.C); err != nil { - return fmt.Errorf("timeout waiting for receipt for tx %s", tx.EthTx.Hash().Hex()) - } - receipt, err := eth.TransactionReceipt(ctx, tx.EthTx.Hash()) - if err != nil { - if errors.Is(err, ethereum.NotFound) { - continue - } - log.Printf("❌ error getting receipt for tx %s: %v", tx.EthTx.Hash().Hex(), err) - continue - } - // Receipt found - log status and return - if receipt.Status != 1 { - return fmt.Errorf("tx %s failed", tx.EthTx.Hash().Hex()) - } - if c.cfg.Debug { - log.Printf("✅ tx %s, %s, gas=%d succeeded\n", tx.Scenario.Name, tx.EthTx.Hash().Hex(), receipt.GasUsed) - } - return nil - } - return ctx.Err() -} - // runSender handles the tx send requests. func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client) error { for ctx.Err() == nil { @@ -202,8 +123,16 @@ func (c *ethClient) runSender(ctx context.Context, client *ethclient.Client) err // so stamping the actual send-attempt time here is race-free (see LoadTx). req.tx.AttemptedSendTime = startTime err = c.sendTx(ctx, client, req.tx) + if req.tx.OnComplete != nil { + req.tx.OnComplete(err) + } req.done <- err c.cfg.Collector.RecordTransaction(req.tx.Scenario.Name, c.cfg.Endpoint, time.Since(startTime), err == nil) + if err == nil { + if t, ok := c.cfg.Inclusion.Get(); ok { + t.Register(req.tx) + } + } } return ctx.Err() } @@ -220,7 +149,7 @@ func (c *ethClient) sendTx(ctx context.Context, eth *ethclient.Client, tx *types span.RecordError(_err) } span.End() - // See receiptLatency above re: span-context recording + no worker_id. + // Record inside the span ctx so exemplars link to the trace. sendLatency.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes( attribute.String("scenario", tx.Scenario.Name), @@ -249,6 +178,5 @@ func (c *ethClient) sendTx(ctx context.Context, eth *ethclient.Client, tx *types attribute.String("endpoint", c.cfg.Endpoint), attribute.String("scenario", tx.Scenario.Name), )) - utils.SendOrDrop(c.receipts, tx) return nil } diff --git a/sender/metrics.go b/sender/metrics.go index e5a7ba8..e433290 100644 --- a/sender/metrics.go +++ b/sender/metrics.go @@ -23,17 +23,7 @@ var ( metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0))) -<<<<<<< HEAD - receiptLatency = utils.OrPanic1(meter.Float64Histogram( - "receipt_latency", - metric.WithDescription("Latency from transaction submission to receipt confirmation in seconds"), - metric.WithUnit("s"), - metric.WithExplicitBucketBoundaries(0.1, 0.2, 0.3, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0))) - txsAccepted = utils.OrPanic1(meter.Int64Counter( -======= - txsAccepted = must(meter.Int64Counter( ->>>>>>> origin/main "txs_accepted", metric.WithDescription("Transactions successfully submitted to an endpoint"), metric.WithUnit("{transactions}"))) diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index bb21269..f83771b 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -10,12 +10,8 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/stats" "github.com/sei-protocol/sei-load/types" -<<<<<<< HEAD - "github.com/sei-protocol/sei-load/utils/scope" -======= "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/service" ->>>>>>> origin/main + "github.com/sei-protocol/sei-load/utils/scope" ) // ShardedSender implements TxSender with multiple workers, one per endpoint @@ -26,10 +22,9 @@ type ShardedSender struct { shards []*Queue[*types.LoadTx] } -<<<<<<< HEAD // NewShardedSender creates a new sharded sender. // Txs of each shard are sent sequentially, using a single eth client. -func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector) (*ShardedSender, error) { +func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector, inclusion utils.Option[*stats.InclusionTracker]) (*ShardedSender, error) { if len(cfg.Endpoints) == 0 { return nil, fmt.Errorf("no endpoints configured") } @@ -44,42 +39,15 @@ func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector * var clients []*ethClient for id, endpoint := range cfg.Endpoints { clients = append(clients, newEthClient(ðClientConfig{ - ChainID: cfg.SeiChainID, - ID: id, -======= -// NewShardedSender creates a new sharded sender with workers for each endpoint. -// inclusion, when present, is shared across all workers so each routes its -// successful sends to the one tracker. -func NewShardedSender(cfg *config.LoadConfig, limiter *rate.Limiter, collector *stats.Collector, inclusion utils.Option[*stats.InclusionTracker]) (*ShardedSender, error) { - if len(cfg.Endpoints) == 0 { - return nil, fmt.Errorf("no endpoints configured") - } - - // Open-loop lets the scheduler own the arrival clock (see doc.go), so the - // worker skips gating to avoid double-throttling; closed-loop keeps it. - skipRateLimit := cfg.Settings.ArrivalModel == config.ArrivalModelOpenLoop - - workers := make([]*Worker, len(cfg.Endpoints)) - for i, endpoint := range cfg.Endpoints { - workers[i] = NewWorker(&WorkerConfig{ - ID: i, - SeiChainID: cfg.SeiChainID, ->>>>>>> origin/main - Endpoint: endpoint, - Tasks: cfg.Settings.TasksPerEndpoint, - DryRun: cfg.Settings.DryRun, - Debug: cfg.Settings.Debug, - TrackReceipts: cfg.Settings.TrackReceipts, - ReceiptsBuf: cfg.Settings.BufferSize, - Collector: collector, -<<<<<<< HEAD + ChainID: cfg.SeiChainID, + ID: id, + Endpoint: endpoint, + Tasks: cfg.Settings.TasksPerEndpoint, + DryRun: cfg.Settings.DryRun, + Debug: cfg.Settings.Debug, + Collector: collector, + Inclusion: inclusion, })) -======= - Limiter: limiter, - SkipRateLimit: skipRateLimit, - Inclusion: inclusion, - }) ->>>>>>> origin/main } pool := NewQueuePool[*types.LoadTx](totalQueueSize) var shards []*Queue[*types.LoadTx] @@ -110,13 +78,16 @@ func (ss *ShardedSender) Run(ctx context.Context) error { for i, shard := range ss.shards { s.Spawn(func() error { client := ss.clients[i%len(ss.clients)] + skipRateLimit := ss.cfg.Settings.ArrivalModel == config.ArrivalModelOpenLoop for ctx.Err() == nil { tx, err := shard.Recv(ctx) if err != nil { return err } - if err := ss.limiter.Wait(ctx); err != nil { - return err + if !skipRateLimit && ss.limiter != nil { + if err := ss.limiter.Wait(ctx); err != nil { + return err + } } if err := client.Send(ctx, tx); err != nil { log.Printf("%v", err) @@ -148,16 +119,3 @@ func (ss *ShardedSender) ShardStats() []ShardStats { } return stats } -<<<<<<< HEAD -======= - -// WorkerStats contains statistics for a single worker -type WorkerStats struct { - WorkerID int - Endpoint string - ChannelLength int -} - -// NumShards returns the number of shards (workers) -func (s *ShardedSender) NumShards() int { return len(s.workers) } ->>>>>>> origin/main diff --git a/sender/worker.go b/sender/worker.go deleted file mode 100644 index 21d33e4..0000000 --- a/sender/worker.go +++ /dev/null @@ -1,242 +0,0 @@ -package sender - -import ( - "context" - "fmt" - "log" - "net" - "net/http" - "net/url" - "time" - - "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/rpc" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/trace" - "golang.org/x/time/rate" - - "github.com/sei-protocol/sei-load/stats" - "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils" - "github.com/sei-protocol/sei-load/utils/service" -) - -var tracer = otel.Tracer("github.com/sei-protocol/sei-load/sender") - -type WorkerConfig struct { - ID int - SeiChainID string - Endpoint string - BufferSize int - Tasks int - DryRun bool - Debug bool - Collector *stats.Collector - Limiter *rate.Limiter // Shared rate authority; nil disables gating. - // SkipRateLimit opts a worker out of limiter gating. Zero value (false) is the - // safe default (gate when Limiter is set); set true only in open-loop, where - // the scheduler owns the clock (see doc.go). - SkipRateLimit bool - // Inclusion, when present, receives each successful send at send-completion so - // the tracker can stamp InclusionTime (see doc.go). None disables tracking. - Inclusion utils.Option[*stats.InclusionTracker] -} - -// Worker handles sending transactions to a specific endpoint -type Worker struct { - cfg *WorkerConfig - txChan chan *types.LoadTx -} - -// HttpClientOption configures the Transport used by newHttpClient. -type HttpClientOption func(*http.Transport) - -// WithMaxIdleConns overrides the global idle-connection pool size. -func WithMaxIdleConns(n int) HttpClientOption { - return func(t *http.Transport) { t.MaxIdleConns = n } -} - -// WithMaxIdleConnsPerHost overrides the per-host idle-connection pool size. -// Scale with goroutine count to avoid TCP re-dial on each completion. -func WithMaxIdleConnsPerHost(n int) HttpClientOption { - return func(t *http.Transport) { t.MaxIdleConnsPerHost = n } -} - -// newHttpTransport is the base transport factory. Exists separately so tests -// can inspect the unwrapped *http.Transport; newHttpClient returns it wrapped -// in otelhttp, whose inner transport isn't publicly accessible. -func newHttpTransport(opts ...HttpClientOption) *http.Transport { - t := &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - MaxIdleConns: 500, - MaxIdleConnsPerHost: 50, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - DisableKeepAlives: false, - } - for _, opt := range opts { - opt(t) - } - return t -} - -// newHttpClient returns an otelhttp-wrapped client: injects traceparent on -// outbound, emits http.client.* metrics. Requires observability.Setup to have -// installed the global TextMapPropagator. -func newHttpClient(opts ...HttpClientOption) *http.Client { - return &http.Client{ - Timeout: 30 * time.Second, - Transport: otelhttp.NewTransport(newHttpTransport(opts...)), - } -} - -// newRPCClient returns a go-ethereum client configured for the endpoint scheme. -// HTTP(S) endpoints reuse the tuned otelhttp-backed transport; WS(S) endpoints -// use the default go-ethereum WebSocket transport. -func newRPCClient(ctx context.Context, endpoint string, opts ...HttpClientOption) (*ethclient.Client, error) { - u, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("parse endpoint %q: %w", endpoint, err) - } - - switch u.Scheme { - case "http", "https": - rpcClient, err := rpc.DialOptions(ctx, endpoint, rpc.WithHTTPClient(newHttpClient(opts...))) - if err != nil { - return nil, err - } - return ethclient.NewClient(rpcClient), nil - case "ws", "wss", "": - return ethclient.DialContext(ctx, endpoint) - default: - return nil, fmt.Errorf("unsupported RPC scheme %q for endpoint %s", u.Scheme, endpoint) - } -} - -// NewWorker creates a new worker for a specific endpoint -func NewWorker(cfg *WorkerConfig) *Worker { - w := &Worker{ - cfg: cfg, - txChan: make(chan *types.LoadTx, cfg.BufferSize), - } - meterWorkerQueueLength(w) - return w -} - -// Start begins the worker's processing loop -func (w *Worker) Run(ctx context.Context) error { - client, err := newRPCClient(ctx, w.cfg.Endpoint) - if err != nil { - return fmt.Errorf("dial %s: %w", w.cfg.Endpoint, err) - } - defer client.Close() - return service.Run(ctx, func(ctx context.Context, s service.Scope) error { - // Start multiple goroutines that share the same channel and RPC client. - for range w.cfg.Tasks { - s.Spawn(func() error { return w.runTxSender(ctx, client) }) - } - return nil - }) -} - -// Send queues a transaction for this worker to process -func (w *Worker) Send(ctx context.Context, tx *types.LoadTx) error { - return utils.Send(ctx, w.txChan, tx) -} - -// runTxSender is the main worker loop that processes transactions -func (w *Worker) runTxSender(ctx context.Context, client *ethclient.Client) error { - for ctx.Err() == nil { - // Closed-loop gates on the limiter before dequeue; open-loop skips it. - if !w.cfg.SkipRateLimit && w.cfg.Limiter != nil { - if err := w.cfg.Limiter.Wait(ctx); err != nil { - return err - } - } - - tx, err := utils.Recv(ctx, w.txChan) - if err != nil { - return err - } - - startTime := time.Now() - // Sole owner between dequeue and hand-off: stamp is race-free (see LoadTx). - tx.AttemptedSendTime = startTime - err = w.sendTransaction(ctx, client, tx) - // OnComplete must fire only after the real send returns — that is what - // bounds true unacked in-flight (see doc.go). Nil on closed-loop/batch. - if tx.OnComplete != nil { - tx.OnComplete(err) - } - w.cfg.Collector.RecordTransaction(tx.Scenario.Name, w.cfg.Endpoint, time.Since(startTime), err == nil) - // Register at send-completion, only on success: registered ⊆ succeeded. - // (The tracker is wired only for live runs — see main.go; DryRun never - // gets a tracker, so simulated sends are not inclusion-tracked.) - if err == nil { - if t, ok := w.cfg.Inclusion.Get(); ok { - t.Register(tx) - } - } - if err != nil { - log.Printf("%v", err) - } - } - return ctx.Err() -} - -// sendTransaction sends a single transaction to the endpoint -func (w *Worker) sendTransaction(ctx context.Context, client *ethclient.Client, tx *types.LoadTx) (_err error) { - ctx, span := tracer.Start(ctx, "sender.send_tx", trace.WithAttributes( - attribute.String("seiload.scenario", tx.Scenario.Name), - attribute.String("seiload.endpoint", w.cfg.Endpoint), - attribute.Int("seiload.worker_id", w.cfg.ID), - attribute.String("seiload.chain_id", w.cfg.SeiChainID), - )) - defer func(start time.Time) { - if _err != nil { - span.RecordError(_err) - } - span.End() - // Record inside the span ctx so exemplars link to the trace; worker_id - // stays off the histogram (cardinality), available via the span. - sendLatency.Record(ctx, time.Since(start).Seconds(), - metric.WithAttributes( - attribute.String("scenario", tx.Scenario.Name), - attribute.String("endpoint", w.cfg.Endpoint), - attribute.String("chain_id", w.cfg.SeiChainID), - statusAttrFromError(_err)), - ) - }(time.Now()) - if w.cfg.DryRun { - return utils.Sleep(ctx, 10*time.Microsecond) // minimal delay, no RPC - } - - if err := client.SendTransaction(ctx, tx.EthTx); err != nil { - txsRejected.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", w.cfg.Endpoint), - attribute.String("scenario", tx.Scenario.Name), - attribute.String("reason", "rpc"), - )) - return fmt.Errorf("Worker %d: Failed to send transaction: %w", w.cfg.ID, err) - } - - txsAccepted.Add(ctx, 1, metric.WithAttributes( - attribute.String("endpoint", w.cfg.Endpoint), - attribute.String("scenario", tx.Scenario.Name), - )) - return nil -} - -// ChannelLength returns the current length of the worker's channel (for monitoring). -// This function is safe for concurrent calls. -func (w *Worker) ChannelLength() int { return len(w.txChan) } - -// Endpoint returns the worker's endpoint -func (w *Worker) Endpoint() string { return w.cfg.Endpoint } diff --git a/sender/worker_test.go b/sender/worker_test.go deleted file mode 100644 index f280772..0000000 --- a/sender/worker_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package sender - -import ( - "context" - "math/big" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/rpc" - "github.com/stretchr/testify/require" - "golang.org/x/time/rate" - - "github.com/sei-protocol/sei-load/stats" - "github.com/sei-protocol/sei-load/types" - "github.com/sei-protocol/sei-load/utils" -) - -// drainWorkerWithLimiter runs runTxSender (DryRun: no RPC) over txCount queued -// txs gated by a tight limiter and returns how long the drain took. cancel fires -// once all txs are recorded, so the elapsed time reflects limiter pacing alone. -func drainWorkerWithLimiter(t *testing.T, skipRateLimit bool, txCount int, rps float64) time.Duration { - t.Helper() - collector := stats.NewCollector() - w := NewWorker(&WorkerConfig{ - ID: 0, - Endpoint: "dryrun", - BufferSize: txCount, - Tasks: 1, - DryRun: true, - Collector: collector, - Limiter: rate.NewLimiter(rate.Limit(rps), 1), - SkipRateLimit: skipRateLimit, - }) - for range txCount { - w.txChan <- &types.LoadTx{Scenario: &types.TxScenario{Name: "gate"}} - } - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - go func() { - for collector.GetStats().TotalTxs < uint64(txCount) { - time.Sleep(time.Millisecond) - } - cancel() - }() - - start := time.Now() - _ = w.runTxSender(ctx, nil) // DryRun never touches the client - return time.Since(start) -} - -// TestRunTxSender_RateLimitedByDefault is the SkipRateLimit-flip guard: the -// zero-value config (SkipRateLimit=false) with a non-nil Limiter must gate, so -// an omitted flag can never silently drop rate limiting. With burst=1 at `rps`, -// draining txCount txs cannot finish faster than (txCount-1)/rps. -func TestRunTxSender_RateLimitedByDefault(t *testing.T) { - const txCount = 10 - const rps = 50.0 // floor: (10-1)/50 = 180ms - elapsed := drainWorkerWithLimiter(t, false, txCount, rps) - require.GreaterOrEqual(t, elapsed, 150*time.Millisecond, - "default config must rate-limit (safe zero value)") -} - -// TestRunTxSender_SkipRateLimitBypassesLimiter confirms the open-loop opt-out: -// SkipRateLimit=true ignores the limiter entirely, so the same drain finishes -// far under the gated floor. -func TestRunTxSender_SkipRateLimitBypassesLimiter(t *testing.T) { - const txCount = 10 - const rps = 50.0 - elapsed := drainWorkerWithLimiter(t, true, txCount, rps) - require.Less(t, elapsed, 100*time.Millisecond, - "SkipRateLimit must bypass the limiter") -} - -// dryRunTx builds a minimal LoadTx with a real eth tx so EthTx.Hash() works. -func dryRunTx(nonce uint64) *types.LoadTx { - eth := ethtypes.NewTx(ðtypes.LegacyTx{ - Nonce: nonce, GasPrice: big.NewInt(1), Gas: 21000, - To: &common.Address{}, Value: big.NewInt(0), - }) - return &types.LoadTx{EthTx: eth, Scenario: &types.TxScenario{Name: "incl"}} -} - -// inflightCount reads the tracker's registry size via its Summary (read after a -// drain, so inflight is the registered-minus-terminal count). -func inflightCount(tr *stats.InclusionTracker) uint64 { - return tr.Summary().InflightAtShutdown -} - -// TestRunTxSender_RegistersSuccessfulSend asserts the inclusion hand-off: -// a successful (DryRun) send registers the tx with the tracker, and Register -// runs strictly AFTER OnComplete (the permit-release ordering in doc.go). -func TestRunTxSender_RegistersSuccessfulSend(t *testing.T) { - tracker := stats.NewInclusionTracker("test-chain", time.Hour, 100, true /* openLoop */) - collector := stats.NewCollector() - w := NewWorker(&WorkerConfig{ - ID: 0, Endpoint: "dryrun", BufferSize: 4, Tasks: 1, DryRun: true, - Collector: collector, SkipRateLimit: true, - Inclusion: utils.Some(tracker), - }) - - // Single tx so the registry starts empty: at OnComplete time inflight must - // still be 0, proving Register runs strictly after OnComplete. - var inflightAtComplete atomic.Int64 - tx := dryRunTx(0) - tx.OnComplete = func(error) { - inflightAtComplete.Store(int64(inflightCount(tracker))) - } - w.txChan <- tx - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - go func() { - for collector.GetStats().TotalTxs < 1 { - time.Sleep(time.Millisecond) - } - cancel() - }() - _ = w.runTxSender(ctx, nil) - - require.Equal(t, int64(0), inflightAtComplete.Load(), - "Register must fire after OnComplete (registry empty at OnComplete time)") - require.Equal(t, uint64(1), inflightCount(tracker), - "a successful send registers exactly once") -} - -// TestRunTxSender_NoInclusionTracker confirms a None tracker is a safe no-op. -func TestRunTxSender_NoInclusionTracker(t *testing.T) { - collector := stats.NewCollector() - w := NewWorker(&WorkerConfig{ - ID: 0, Endpoint: "dryrun", BufferSize: 2, Tasks: 1, DryRun: true, - Collector: collector, SkipRateLimit: true, - Inclusion: utils.None[*stats.InclusionTracker](), - }) - w.txChan <- dryRunTx(0) - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - go func() { - for collector.GetStats().TotalTxs < 1 { - time.Sleep(time.Millisecond) - } - cancel() - }() - require.NotPanics(t, func() { _ = w.runTxSender(ctx, nil) }) -} - -func TestNewHttpTransport_Defaults(t *testing.T) { - tr := newHttpTransport() - - require.Equal(t, 500, tr.MaxIdleConns) - require.Equal(t, 50, tr.MaxIdleConnsPerHost) - require.Equal(t, 90*time.Second, tr.IdleConnTimeout) - require.False(t, tr.DisableKeepAlives) -} - -func TestNewHttpTransport_WithMaxIdleConns(t *testing.T) { - tr := newHttpTransport(WithMaxIdleConns(2048)) - - require.Equal(t, 2048, tr.MaxIdleConns) - require.Equal(t, 50, tr.MaxIdleConnsPerHost, "per-host default preserved") -} - -func TestNewHttpTransport_WithMaxIdleConnsPerHost(t *testing.T) { - tr := newHttpTransport(WithMaxIdleConnsPerHost(1024)) - - require.Equal(t, 1024, tr.MaxIdleConnsPerHost) - require.Equal(t, 500, tr.MaxIdleConns, "global default preserved") -} - -func TestNewHttpTransport_MultipleOptions(t *testing.T) { - tr := newHttpTransport( - WithMaxIdleConns(4096), - WithMaxIdleConnsPerHost(1024), - ) - - require.Equal(t, 4096, tr.MaxIdleConns) - require.Equal(t, 1024, tr.MaxIdleConnsPerHost) -} - -func TestNewHttpClient_Smoke(t *testing.T) { - c := newHttpClient() - require.Equal(t, 30*time.Second, c.Timeout) - require.NotNil(t, c.Transport, "Transport must be set") - _, isBareTransport := c.Transport.(*http.Transport) - require.False(t, isBareTransport, "Transport should be wrapped by otelhttp, not bare *http.Transport") -} - -func TestNewRPCClient_HTTP(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - client, err := newRPCClient(context.Background(), srv.URL) - require.NoError(t, err) - require.NotNil(t, client) - client.Close() -} - -func TestNewRPCClient_WS(t *testing.T) { - srv := rpc.NewServer() - ts := httptest.NewServer(srv.WebsocketHandler([]string{"*"})) - defer ts.Close() - - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") - client, err := newRPCClient(context.Background(), wsURL) - require.NoError(t, err) - require.NotNil(t, client) - client.Close() -} - -func TestNewRPCClient_UnsupportedScheme(t *testing.T) { - client, err := newRPCClient(context.Background(), "ftp://example.com") - require.Error(t, err) - require.Nil(t, client) -} From ead64e65379a859fb58b458e1ea07c06f3d1da75 Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Tue, 16 Jun 2026 15:31:57 +0200 Subject: [PATCH 20/21] removed semaphore --- main.go | 17 +++++++++++------ sender/dispatcher.go | 16 +++++++--------- sender/scheduler.go | 14 +++++++++----- sender/sharded_sender.go | 7 ++----- utils/semaphore.go | 37 ------------------------------------- 5 files changed, 29 insertions(+), 62 deletions(-) delete mode 100644 utils/semaphore.go diff --git a/main.go b/main.go index 6aa59b9..db3eee4 100644 --- a/main.go +++ b/main.go @@ -219,14 +219,11 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { return fmt.Errorf("failed to create generator: %w", err) } - // Create shared rate limiter for all workers if TPS is specified - var sharedLimiter *rate.Limiter + // Create the shared rate authority for the whole run. + sharedLimiter := rate.NewLimiter(rate.Inf, 1) if cfg.Settings.TPS > 0 { sharedLimiter = rate.NewLimiter(rate.Limit(cfg.Settings.TPS), 1) log.Printf("📈 Rate limiting enabled: %.2f TPS shared across all workers", cfg.Settings.TPS) - } else { - // No rate limiting - sharedLimiter = rate.NewLimiter(rate.Inf, 1) } // Create and start block collector if endpoints are available @@ -280,8 +277,16 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { }) } + // Open-loop owns the arrival clock in the scheduler, so the sender must + // not add a second finite gate. Prewarm and the scheduler still use the + // real shared limiter. + senderLimiter := sharedLimiter + if cfg.Settings.ArrivalModel == config.ArrivalModelOpenLoop && cfg.Settings.TxsDir == "" { + senderLimiter = rate.NewLimiter(rate.Inf, 1) + } + // Create the sender from the config struct - snd, err := sender.NewShardedSender(cfg, sharedLimiter, collector, inclusion) + snd, err := sender.NewShardedSender(cfg, senderLimiter, collector, inclusion) if err != nil { return fmt.Errorf("failed to create sender: %w", err) } diff --git a/sender/dispatcher.go b/sender/dispatcher.go index 69a6d4d..f21ae14 100644 --- a/sender/dispatcher.go +++ b/sender/dispatcher.go @@ -35,8 +35,8 @@ type Dispatcher struct { prewarmGen utils.Option[generator.Generator] // Optional prewarm generator sender TxSender - // Open-loop arrival configuration. arrivalModel defaults to closed-loop; - // limiter and maxInFlight are only consulted in open-loop mode. + // Open-loop arrival configuration. arrivalModel defaults to closed-loop. + // limiter is always present; open-loop additionally consults maxInFlight. arrivalModel ArrivalModel limiter *rate.Limiter maxInFlight int @@ -56,6 +56,7 @@ func NewDispatcher(gen generator.Generator, sender TxSender) *Dispatcher { generator: gen, sender: sender, arrivalModel: ArrivalClosedLoop, + limiter: rate.NewLimiter(rate.Inf, 1), } } @@ -99,9 +100,8 @@ func (d *Dispatcher) SetPrewarmGenerator(prewarmGen generator.Generator) { func (d *Dispatcher) Prewarm(ctx context.Context) error { d.mu.RLock() prewarmGen := d.prewarmGen - // Prewarm runs over the workers before the scheduler paces anything, so in - // open-loop (ungated workers) it must self-pace off the shared limiter or it - // floods the SUT. Nil in closed-loop, where the worker gates instead. + // Prewarm runs before the scheduler paces anything, so it must self-pace off + // the shared limiter or it floods the SUT. limiter := d.limiter d.mu.RUnlock() @@ -116,10 +116,8 @@ func (d *Dispatcher) Prewarm(ctx context.Context) error { // Run prewarm generator until completion for ctx.Err() == nil { - if limiter != nil { - if err := limiter.Wait(ctx); err != nil { - return err - } + if err := limiter.Wait(ctx); err != nil { + return err } tx, ok := gen.Generate() diff --git a/sender/scheduler.go b/sender/scheduler.go index f4c1ade..0527c26 100644 --- a/sender/scheduler.go +++ b/sender/scheduler.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "time" + "golang.org/x/sync/semaphore" "golang.org/x/time/rate" "github.com/sei-protocol/sei-load/generator" @@ -24,7 +25,7 @@ type openLoopScheduler struct { generator generator.Generator sender TxSender limiter *rate.Limiter - inflight *utils.Semaphore + inflight *semaphore.Weighted onSent func(tx *types.LoadTx, err error) maxInFlight int @@ -54,11 +55,14 @@ func newOpenLoopScheduler( maxInFlight int, onSent func(tx *types.LoadTx, err error), ) *openLoopScheduler { + if maxInFlight < 1 { + maxInFlight = 1 + } return &openLoopScheduler{ generator: gen, sender: snd, limiter: limiter, - inflight: utils.NewSemaphore(maxInFlight), + inflight: semaphore.NewWeighted(int64(maxInFlight)), onSent: onSent, maxInFlight: maxInFlight, } @@ -102,7 +106,7 @@ func (s *openLoopScheduler) Run(ctx context.Context, scope service.Scope) error // Admit before generating: a dropped tick must not consume a seeded // generator draw (determinism). TryAcquire is non-blocking. - release, ok := s.inflight.TryAcquire() + ok := s.inflight.TryAcquire(1) if !ok { s.dropped.Add(1) nextSend = nextSend.Add(gap) @@ -113,7 +117,7 @@ func (s *openLoopScheduler) Run(ctx context.Context, scope service.Scope) error tx, ok := s.generator.Generate() if !ok { // Generator drained: not an arrival — release the permit and stop. - release() + s.inflight.Release(1) log.Print("Scheduler: generator returned no more transactions") return nil } @@ -131,7 +135,7 @@ func (s *openLoopScheduler) Run(ctx context.Context, scope service.Scope) error var once sync.Once complete := func(err error) { once.Do(func() { - release() + s.inflight.Release(1) if s.onSent != nil { s.onSent(tx, err) } diff --git a/sender/sharded_sender.go b/sender/sharded_sender.go index f83771b..60f903c 100644 --- a/sender/sharded_sender.go +++ b/sender/sharded_sender.go @@ -78,16 +78,13 @@ func (ss *ShardedSender) Run(ctx context.Context) error { for i, shard := range ss.shards { s.Spawn(func() error { client := ss.clients[i%len(ss.clients)] - skipRateLimit := ss.cfg.Settings.ArrivalModel == config.ArrivalModelOpenLoop for ctx.Err() == nil { tx, err := shard.Recv(ctx) if err != nil { return err } - if !skipRateLimit && ss.limiter != nil { - if err := ss.limiter.Wait(ctx); err != nil { - return err - } + if err := ss.limiter.Wait(ctx); err != nil { + return err } if err := client.Send(ctx, tx); err != nil { log.Printf("%v", err) diff --git a/utils/semaphore.go b/utils/semaphore.go deleted file mode 100644 index 941c670..0000000 --- a/utils/semaphore.go +++ /dev/null @@ -1,37 +0,0 @@ -package utils - -import ( - "context" -) - -// Semaphore provides a way to bound concurrenct access to a resource. -type Semaphore struct { - ch chan struct{} -} - -// NewSemaphore constructs a new semaphore with n permits. -func NewSemaphore(n int) *Semaphore { - return &Semaphore{ch: make(chan struct{}, n)} -} - -// Acquire acquires a permit from the semaphore. -// Blocks until a permit is available. -func (s *Semaphore) Acquire(ctx context.Context) (release func(), err error) { - if err := Send(ctx, s.ch, struct{}{}); err != nil { - return nil, err - } - return func() { <-s.ch }, nil -} - -// TryAcquire acquires a permit without blocking. It returns the release func -// and true if a permit was available, or nil and false if all permits are held. -// Used by callers that must never block waiting for capacity (e.g. an open-loop -// scheduler that drops rather than throttling its clock). -func (s *Semaphore) TryAcquire() (release func(), ok bool) { - select { - case s.ch <- struct{}{}: - return func() { <-s.ch }, true - default: - return nil, false - } -} From 25d018edd9556677cb0b97f4b9f89478a66d4aff Mon Sep 17 00:00:00 2001 From: Grzegorz Prusak Date: Tue, 16 Jun 2026 15:55:28 +0200 Subject: [PATCH 21/21] unrelated fix --- utils/testonly.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/testonly.go b/utils/testonly.go index 91e76ad..56d3ac6 100644 --- a/utils/testonly.go +++ b/utils/testonly.go @@ -155,7 +155,7 @@ func GenString(rng Rng, n int) string { // Shuffle reorders the elements of s uniformly at random. func Shuffle[T any](rng Rng, s []T) { for i := 1; i < len(s); i += 1 { - j := rng.Intn(i) + j := rng.Intn(i + 1) s[i], s[j] = s[j], s[i] } }