diff --git a/sender/dispatcher.go b/sender/dispatcher.go index 3e0c5c7..b3614c9 100644 --- a/sender/dispatcher.go +++ b/sender/dispatcher.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "sync" + "time" "github.com/sei-protocol/sei-load/generator" "github.com/sei-protocol/sei-load/stats" @@ -95,6 +96,10 @@ func (d *Dispatcher) Run(ctx context.Context) error { return nil } + // Stamp before hand-off: the dispatcher is sole owner here (tx just + // returned by the generator, not yet enqueued), so this write is race-free. + tx.IntendedSendTime = time.Now() + // Send the transaction if err := d.sender.Send(ctx, tx); err != nil { return err @@ -117,6 +122,9 @@ func (d *Dispatcher) RunBatch(ctx context.Context, count int) error { if !ok { return fmt.Errorf("dispatcher: generator returned nil transaction (batch %d/%d)", i+1, count) } + // Stamp before hand-off (see Run). + tx.IntendedSendTime = time.Now() + // Send the transaction if err := d.sender.Send(ctx, tx); err != nil { log.Printf("Dispatcher: Failed to send transaction %d/%d: %v", i+1, count, err) diff --git a/sender/worker.go b/sender/worker.go index 3c15862..9b48a8a 100644 --- a/sender/worker.go +++ b/sender/worker.go @@ -239,6 +239,9 @@ func (w *Worker) processTransactions(ctx context.Context, client *http.Client) e } 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 if w.collector != nil { diff --git a/types/scenario.go b/types/scenario.go index 754e8cf..db5e6a7 100644 --- a/types/scenario.go +++ b/types/scenario.go @@ -4,17 +4,38 @@ import ( "encoding/json" "fmt" "math/big" + "time" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" ) // LoadTx is a wrapper that has pre-encoded json rpc payload and eth transaction. +// +// Lifecycle timestamp concurrency contract: a *LoadTx is passed by pointer +// through buffered channels (txChan, sentTxs). Each lifecycle timestamp is +// written at most once, by whichever goroutine owns the tx at that stage, and +// is immutable thereafter; ownership transfers with the pointer across the +// channels, so the writes need no locking. A zero timestamp means "not +// recorded" (e.g. prewarm txs, or a stage not yet reached) — consumers must +// treat it as untracked, never as the zero epoch. type LoadTx struct { EthTx *ethtypes.Transaction JSONRPCPayload []byte Payload []byte Scenario *TxScenario + + // IntendedSendTime is when the tx was scheduled to be sent, written by the + // dispatcher before the tx is enqueued. It currently holds the enqueue time, + // which is back-pressured under load; until an open-loop scheduler sets it to + // the intended schedule instant, it must not be used to derive latency. + IntendedSendTime time.Time + // AttemptedSendTime is when the send was actually attempted, written by the + // worker goroutine that owns the tx between dequeue and the sentTxs hand-off. + AttemptedSendTime time.Time + // InclusionTime is when the tx was observed included on-chain, written only + // by the inclusion tracker. + InclusionTime time.Time } // JSONRPCRequest represents json rpc request.