From 30baa0de198078971023bc5b3306cac2eeb10bff Mon Sep 17 00:00:00 2001 From: "Tyler.S" Date: Wed, 18 Oct 2023 14:41:05 -0700 Subject: [PATCH 1/3] Introduce loadtest command --- cmd/soroban-rpc/internal/loadtest/config.go | 30 +++++++ cmd/soroban-rpc/internal/loadtest/generate.go | 90 +++++++++++++++++++ .../internal/loadtest/get_events_generator.go | 21 +++++ .../internal/loadtest/get_health_generator.go | 10 +++ .../simulate_transaction_generator.go | 61 +++++++++++++ .../internal/loadtest/spec_generator.go | 8 ++ cmd/soroban-rpc/main.go | 18 ++++ 7 files changed, 238 insertions(+) create mode 100644 cmd/soroban-rpc/internal/loadtest/config.go create mode 100644 cmd/soroban-rpc/internal/loadtest/generate.go create mode 100644 cmd/soroban-rpc/internal/loadtest/get_events_generator.go create mode 100644 cmd/soroban-rpc/internal/loadtest/get_health_generator.go create mode 100644 cmd/soroban-rpc/internal/loadtest/simulate_transaction_generator.go create mode 100644 cmd/soroban-rpc/internal/loadtest/spec_generator.go diff --git a/cmd/soroban-rpc/internal/loadtest/config.go b/cmd/soroban-rpc/internal/loadtest/config.go new file mode 100644 index 000000000..950829e01 --- /dev/null +++ b/cmd/soroban-rpc/internal/loadtest/config.go @@ -0,0 +1,30 @@ +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) { + cmd.Flags().StringVarP(&cfg.SorobanRPCURL, "soroban-rpc-url", "u", "", "Endpoint to send JSON RPC requests to") + cmd.MarkFlagRequired("soroban-rpc-url") + + 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") +} diff --git a/cmd/soroban-rpc/internal/loadtest/generate.go b/cmd/soroban-rpc/internal/loadtest/generate.go new file mode 100644 index 000000000..daa25ee24 --- /dev/null +++ b/cmd/soroban-rpc/internal/loadtest/generate.go @@ -0,0 +1,90 @@ +package loadtest + +import ( + "context" + "fmt" + "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 < int(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 + 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. + _, err := client.Batch(context.Background(), requestBatches[currentBatchI]) + if err != nil { + fmt.Printf("Batch call failed: %v\n", err) + return + } + }() + numRequestsSent += len(requestBatches[currentBatchI]) + lastBatchSentTime = now + currentBatchI += 1 + fmt.Printf("Sent batch %d / %d\n", currentBatchI, len(requestBatches)) + } + } + fmt.Printf("Successfully sent %d requests\n", numRequestsSent) + return nil +} diff --git a/cmd/soroban-rpc/internal/loadtest/get_events_generator.go b/cmd/soroban-rpc/internal/loadtest/get_events_generator.go new file mode 100644 index 000000000..d1c7d328d --- /dev/null +++ b/cmd/soroban-rpc/internal/loadtest/get_events_generator.go @@ -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 +} diff --git a/cmd/soroban-rpc/internal/loadtest/get_health_generator.go b/cmd/soroban-rpc/internal/loadtest/get_health_generator.go new file mode 100644 index 000000000..20004bac2 --- /dev/null +++ b/cmd/soroban-rpc/internal/loadtest/get_health_generator.go @@ -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 +} diff --git a/cmd/soroban-rpc/internal/loadtest/simulate_transaction_generator.go b/cmd/soroban-rpc/internal/loadtest/simulate_transaction_generator.go new file mode 100644 index 000000000..9e5437f3a --- /dev/null +++ b/cmd/soroban-rpc/internal/loadtest/simulate_transaction_generator.go @@ -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 +} diff --git a/cmd/soroban-rpc/internal/loadtest/spec_generator.go b/cmd/soroban-rpc/internal/loadtest/spec_generator.go new file mode 100644 index 000000000..a43beb3ba --- /dev/null +++ b/cmd/soroban-rpc/internal/loadtest/spec_generator.go @@ -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) +} diff --git a/cmd/soroban-rpc/main.go b/cmd/soroban-rpc/main.go index 130ea78d7..5a31e1496 100644 --- a/cmd/soroban-rpc/main.go +++ b/cmd/soroban-rpc/main.go @@ -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", @@ -70,8 +72,24 @@ 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. + loadTestCfg.AddFlags(loadTestCmd) if err := cfg.AddFlags(rootCmd); err != nil { fmt.Fprintf(os.Stderr, "could not parse config options: %v\n", err) From 12a090e13fc39b7768b9285a3ba761dadccd1046 Mon Sep 17 00:00:00 2001 From: "Tyler.S" Date: Wed, 18 Oct 2023 15:04:00 -0700 Subject: [PATCH 2/3] Added mutex to avoid batch index race condition --- cmd/soroban-rpc/internal/loadtest/generate.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-rpc/internal/loadtest/generate.go b/cmd/soroban-rpc/internal/loadtest/generate.go index daa25ee24..33801b2e8 100644 --- a/cmd/soroban-rpc/internal/loadtest/generate.go +++ b/cmd/soroban-rpc/internal/loadtest/generate.go @@ -3,6 +3,7 @@ package loadtest import ( "context" "fmt" + "sync" "time" "github.com/creachadair/jrpc2" @@ -68,20 +69,32 @@ func GenerateLoad(cfg *Config) error { 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. - _, err := client.Batch(context.Background(), requestBatches[currentBatchI]) + 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 } }() - numRequestsSent += len(requestBatches[currentBatchI]) lastBatchSentTime = now + numRequestsSent += batchSize + + batchMu.Lock() currentBatchI += 1 + batchMu.Unlock() + fmt.Printf("Sent batch %d / %d\n", currentBatchI, len(requestBatches)) } } From 24346c8bcfd29e48b31e792cfac647b7b39c9044 Mon Sep 17 00:00:00 2001 From: "Tyler.S" Date: Wed, 18 Oct 2023 15:30:09 -0700 Subject: [PATCH 3/3] Style fixes --- cmd/soroban-rpc/internal/loadtest/config.go | 7 +++++-- cmd/soroban-rpc/internal/loadtest/generate.go | 3 +-- cmd/soroban-rpc/main.go | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/soroban-rpc/internal/loadtest/config.go b/cmd/soroban-rpc/internal/loadtest/config.go index 950829e01..3bacc98d3 100644 --- a/cmd/soroban-rpc/internal/loadtest/config.go +++ b/cmd/soroban-rpc/internal/loadtest/config.go @@ -16,9 +16,11 @@ type Config struct { HelloWorldContractPath string } -func (cfg *Config) AddFlags(cmd *cobra.Command) { +func (cfg *Config) AddFlags(cmd *cobra.Command) error { cmd.Flags().StringVarP(&cfg.SorobanRPCURL, "soroban-rpc-url", "u", "", "Endpoint to send JSON RPC requests to") - cmd.MarkFlagRequired("soroban-rpc-url") + 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") @@ -27,4 +29,5 @@ func (cfg *Config) AddFlags(cmd *cobra.Command) { 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 } diff --git a/cmd/soroban-rpc/internal/loadtest/generate.go b/cmd/soroban-rpc/internal/loadtest/generate.go index 33801b2e8..0d8544cd0 100644 --- a/cmd/soroban-rpc/internal/loadtest/generate.go +++ b/cmd/soroban-rpc/internal/loadtest/generate.go @@ -13,7 +13,6 @@ import ( // 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) @@ -43,7 +42,7 @@ func GenerateLoad(cfg *Config) error { } var requestBatches [][]jrpc2.Spec batchSize := int(float64(cfg.RequestsPerSecond) * batchIntervalDur.Seconds()) - for i := 0; i < int(numBatches); i++ { + for i := 0; i < numBatches; i++ { var currentBatch []jrpc2.Spec for i := 0; i < batchSize; i++ { spec, err := generator.GenerateSpec() diff --git a/cmd/soroban-rpc/main.go b/cmd/soroban-rpc/main.go index 5a31e1496..a1f3b7d3b 100644 --- a/cmd/soroban-rpc/main.go +++ b/cmd/soroban-rpc/main.go @@ -89,7 +89,10 @@ func main() { // Load testing flags. // TODO: Load these from a configuration file like RPC server configs. - loadTestCfg.AddFlags(loadTestCmd) + 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)