Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce loadtest Command to Soroban RPC #1032

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions cmd/soroban-rpc/internal/loadtest/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package loadtest

import (
"github.com/spf13/cobra"
)

// Config represents the configuration of a load test to a soroban-rpc server
type Config struct {
SorobanRPCURL string
TestDuration string
SpecGenerator string
RequestsPerSecond int
BatchInterval string
NetworkPassphrase string
GetEventsStartLedger int32
HelloWorldContractPath string
}

func (cfg *Config) AddFlags(cmd *cobra.Command) error {
cmd.Flags().StringVarP(&cfg.SorobanRPCURL, "soroban-rpc-url", "u", "", "Endpoint to send JSON RPC requests to")
if err := cmd.MarkFlagRequired("soroban-rpc-url"); err != nil {
return err
}

cmd.Flags().StringVarP(&cfg.TestDuration, "duration", "d", "60s", "How long to generate load to the RPC server")
cmd.Flags().StringVarP(&cfg.SpecGenerator, "spec-generator", "g", "getHealth", "Which spec generator to use to generate load")
cmd.Flags().IntVarP(&cfg.RequestsPerSecond, "requests-per-second", "n", 10, "How many requests per second to send to the RPC server")
cmd.Flags().StringVarP(&cfg.BatchInterval, "batch-interval", "i", "100ms", "How often to send a batch of requests")
cmd.Flags().StringVarP(&cfg.NetworkPassphrase, "network-passphrase", "p", "Test SDF Network ; September 2015", "Network passphrase to use when simulating transactions")
cmd.Flags().Int32Var(&cfg.GetEventsStartLedger, "get-events-start-ledger", 1, "Start ledger to fetch events after in GetEventsGenerator")
cmd.Flags().StringVar(&cfg.HelloWorldContractPath, "hello-world-contract-path", "", "Location of hello world contract to use when simulating transactions")
return nil
}
102 changes: 102 additions & 0 deletions cmd/soroban-rpc/internal/loadtest/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package loadtest

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

"github.com/creachadair/jrpc2"
"github.com/creachadair/jrpc2/jhttp"
"github.com/pkg/errors"
)

// Generates load to a soroban-rpc server based on configuration.
func GenerateLoad(cfg *Config) error {
ch := jhttp.NewChannel(cfg.SorobanRPCURL, nil)
client := jrpc2.NewClient(ch, nil)

batchIntervalDur, err := time.ParseDuration(cfg.BatchInterval)
if err != nil {
return errors.Wrapf(err, "invalid time format for batch interval: %s", cfg.BatchInterval)
}
loadTestDuration, err := time.ParseDuration(cfg.TestDuration)
if err != nil {
return errors.Wrapf(err, "invalid time format for test duration: %s", cfg.TestDuration)
}
numBatches := int(loadTestDuration.Seconds() / batchIntervalDur.Seconds())

// Generate request batches
nameToRegisteredSpecGenerator := make(map[string]SpecGenerator)
nameToRegisteredSpecGenerator["getHealth"] = &GetHealthGenerator{}
nameToRegisteredSpecGenerator["getEvents"] = &GetEventsGenerator{
startLedger: cfg.GetEventsStartLedger,
}
nameToRegisteredSpecGenerator["simulateTransaction"] = &SimulateTransactionGenerator{
networkPassphrase: cfg.NetworkPassphrase,
helloWorldContractPath: cfg.HelloWorldContractPath,
}
generator, ok := nameToRegisteredSpecGenerator[cfg.SpecGenerator]
if !ok {
return errors.Wrapf(err, "spec generator with name %s does not exist", cfg.SpecGenerator)
}
var requestBatches [][]jrpc2.Spec
batchSize := int(float64(cfg.RequestsPerSecond) * batchIntervalDur.Seconds())
for i := 0; i < numBatches; i++ {
var currentBatch []jrpc2.Spec
for i := 0; i < batchSize; i++ {
spec, err := generator.GenerateSpec()
if err != nil {
return errors.Wrapf(err, "could not generate spec: %v\n", err)
}
currentBatch = append(currentBatch, spec)
}
requestBatches = append(requestBatches, currentBatch)
}

// Actually generate load.
fmt.Printf("Generating approximately %d requests per second for %v\n", cfg.RequestsPerSecond, loadTestDuration)
fmt.Printf(
"Sending %d batches of %d requests each, every %v for %v\n",
numBatches,
batchSize,
batchIntervalDur,
loadTestDuration,
)
startTime := time.Now()
numRequestsSent := 0
now := time.Time{}
lastBatchSentTime := time.Time{}
currentBatchI := 0
var batchMu sync.Mutex
for now.Before(startTime.Add(loadTestDuration)) && currentBatchI < len(requestBatches) {
now = time.Now()
if now.After(lastBatchSentTime.Add(batchIntervalDur)) {
go func() {
// Ignore response content for now.
batchMu.Lock()
if currentBatchI >= len(requestBatches) {
batchMu.Unlock()
return
}
currentBatch := requestBatches[currentBatchI]
batchMu.Unlock()
_, err := client.Batch(context.Background(), currentBatch)
if err != nil {
fmt.Printf("Batch call failed: %v\n", err)
return
}
}()
lastBatchSentTime = now
numRequestsSent += batchSize

batchMu.Lock()
currentBatchI += 1
batchMu.Unlock()

fmt.Printf("Sent batch %d / %d\n", currentBatchI, len(requestBatches))
}
}
fmt.Printf("Successfully sent %d requests\n", numRequestsSent)
return nil
}
21 changes: 21 additions & 0 deletions cmd/soroban-rpc/internal/loadtest/get_events_generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package loadtest

import (
"github.com/creachadair/jrpc2"
"github.com/stellar/soroban-tools/cmd/soroban-rpc/internal/methods"
)

// Generates specs for getting all events after a target start ledger.
type GetEventsGenerator struct {
startLedger int32
}

func (generator *GetEventsGenerator) GenerateSpec() (jrpc2.Spec, error) {
getEventsRequest := methods.GetEventsRequest{
StartLedger: generator.startLedger,
}
return jrpc2.Spec{
Method: "getEvents",
Params: getEventsRequest,
}, nil
}
10 changes: 10 additions & 0 deletions cmd/soroban-rpc/internal/loadtest/get_health_generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package loadtest

import "github.com/creachadair/jrpc2"

// Generates simple getHealth requests. Useful as a baseline for load testing.
type GetHealthGenerator struct{}

func (generator *GetHealthGenerator) GenerateSpec() (jrpc2.Spec, error) {
return jrpc2.Spec{Method: "getHealth"}, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package loadtest

import (
"os"

"github.com/creachadair/jrpc2"
"github.com/stellar/go/keypair"
"github.com/stellar/go/txnbuild"
"github.com/stellar/go/xdr"
"github.com/stellar/soroban-tools/cmd/soroban-rpc/internal/methods"
)

// Generates simple simulateTransaction requests to invoke a "hello world" contract.
type SimulateTransactionGenerator struct {
networkPassphrase string
helloWorldContractPath string
}

func (generator *SimulateTransactionGenerator) GenerateSpec() (jrpc2.Spec, error) {
sourceAccount := keypair.Root(generator.networkPassphrase).Address()
contractBinary, err := os.ReadFile(generator.helloWorldContractPath)
if err != nil {
return jrpc2.Spec{}, err
}
invokeHostFunction := &txnbuild.InvokeHostFunction{
HostFunction: xdr.HostFunction{
Type: xdr.HostFunctionTypeHostFunctionTypeUploadContractWasm,
Wasm: &contractBinary,
},
SourceAccount: sourceAccount,
}
params := txnbuild.TransactionParams{
SourceAccount: &txnbuild.SimpleAccount{
AccountID: sourceAccount,
Sequence: 0,
},
IncrementSequenceNum: false,
Operations: []txnbuild.Operation{
invokeHostFunction,
},
BaseFee: txnbuild.MinBaseFee,
Memo: nil,
Preconditions: txnbuild.Preconditions{
TimeBounds: txnbuild.NewInfiniteTimeout(),
},
}

params.IncrementSequenceNum = false
tx, err := txnbuild.NewTransaction(params)
if err != nil {
return jrpc2.Spec{}, err
}
txB64, err := tx.Base64()
if err != nil {
return jrpc2.Spec{}, err
}
return jrpc2.Spec{
Method: "simulateTransaction",
Params: methods.SimulateTransactionRequest{Transaction: txB64},
}, nil
}
8 changes: 8 additions & 0 deletions cmd/soroban-rpc/internal/loadtest/spec_generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package loadtest

import "github.com/creachadair/jrpc2"

// Implement SpecGenerator to test different types request load.
type SpecGenerator interface {
GenerateSpec() (jrpc2.Spec, error)
}
21 changes: 21 additions & 0 deletions cmd/soroban-rpc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import (

"github.com/stellar/soroban-tools/cmd/soroban-rpc/internal/config"
"github.com/stellar/soroban-tools/cmd/soroban-rpc/internal/daemon"
"github.com/stellar/soroban-tools/cmd/soroban-rpc/internal/loadtest"
)

func main() {
var cfg config.Config
var loadTestCfg loadtest.Config

rootCmd := &cobra.Command{
Use: "soroban-rpc",
Expand Down Expand Up @@ -70,8 +72,27 @@ func main() {
},
}

loadTestCmd := &cobra.Command{
Use: "loadtest",
Short: "Generates a configurable load to a Soroban RPC server",
Run: func(cmd *cobra.Command, _ []string) {
if err := loadtest.GenerateLoad(&loadTestCfg); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
},
}

rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(genConfigFileCmd)
rootCmd.AddCommand(loadTestCmd)

// Load testing flags.
// TODO: Load these from a configuration file like RPC server configs.
if err := loadTestCfg.AddFlags(loadTestCmd); err != nil {
fmt.Fprintf(os.Stderr, "could not parse loadtest flags: %v\n", err)
os.Exit(1)
}

if err := cfg.AddFlags(rootCmd); err != nil {
fmt.Fprintf(os.Stderr, "could not parse config options: %v\n", err)
Expand Down
Loading