diff --git a/cmd/soroban-rpc/internal/loadtest/config.go b/cmd/soroban-rpc/internal/loadtest/config.go new file mode 100644 index 000000000..3bacc98d3 --- /dev/null +++ b/cmd/soroban-rpc/internal/loadtest/config.go @@ -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 +} diff --git a/cmd/soroban-rpc/internal/loadtest/generate.go b/cmd/soroban-rpc/internal/loadtest/generate.go new file mode 100644 index 000000000..0d8544cd0 --- /dev/null +++ b/cmd/soroban-rpc/internal/loadtest/generate.go @@ -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 +} 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..a1f3b7d3b 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,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)