diff --git a/token/services/storage/db/sql/common/tokens.go b/token/services/storage/db/sql/common/tokens.go index cbce8c7e1c..fbffdaf664 100644 --- a/token/services/storage/db/sql/common/tokens.go +++ b/token/services/storage/db/sql/common/tokens.go @@ -190,7 +190,12 @@ func (db *TokenStore) UnspentTokensIterator(ctx context.Context) (tdriver.Unspen // append. UNION ALL is used (not UNION) to skip the per-row sort/hash // dedup pass; duplicates between the two branches (and within branch 1 when // a token has multiple ownership rows) are filtered at the iterator layer. -func (db *TokenStore) UnspentTokensIteratorBy(ctx context.Context, walletID string, tokenType token.Type) (tdriver.UnspentTokensIterator, error) { +// buildUnspentTokensIteratorByQuery builds the SQL query and args for +// UnspentTokensIteratorBy without executing it. Extracted so benchmarks can +// compare executing this exact query dynamically (query built and run fresh +// each call, the production path) against running it via a statement +// prepared once ahead of time, using identical SQL in both cases. +func buildUnspentTokensIteratorByQuery(db *TokenStore, walletID string, tokenType token.Type) (string, []any) { tokenTable := q.Table(db.table.Tokens) ownershipTable := q.Table(db.table.Ownership) joinCond := cond.And( @@ -258,7 +263,12 @@ func (db *TokenStore) UnspentTokensIteratorBy(ctx context.Context, walletID stri branch1.FormatTo(db.ci, sb) sb.WriteString(" UNION ALL ") branch2.FormatTo(db.ci, sb) - query, args := sb.Build() + + return sb.Build() +} + +func (db *TokenStore) UnspentTokensIteratorBy(ctx context.Context, walletID string, tokenType token.Type) (tdriver.UnspentTokensIterator, error) { + query, args := buildUnspentTokensIteratorByQuery(db, walletID, tokenType) logging.Debug(logger, query, args) rows, err := db.readDB.QueryContext(ctx, query, args...) diff --git a/token/services/storage/db/sql/common/tokens_prepared_bench_util.go b/token/services/storage/db/sql/common/tokens_prepared_bench_util.go new file mode 100644 index 0000000000..ec73824325 --- /dev/null +++ b/token/services/storage/db/sql/common/tokens_prepared_bench_util.go @@ -0,0 +1,99 @@ +/* +Copyright IBM Corp. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package common + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/LFDT-Panurus/panurus/token/services/benchmark" + tokentype "github.com/LFDT-Panurus/panurus/token/token" +) + +// RunUnspentTokensIteratorByPreparedComparison benchmarks UnspentTokensIteratorBy +// against a version of the same query executed via a statement prepared once +// outside the timed loop, using the same seed data, worker count, and duration +// as RunTokenStoreBenchmarks so the numbers are directly comparable. +// +// This exists to answer: "how much would moving to prepared statements help +// for our primary token-selection query?" (see #1183). The query built by +// UnspentTokensIteratorBy is dynamic (its shape depends on whether walletID / +// tokenType are empty), so this comparison fixes both parameters to non-empty +// values matching the seeded data, which is the common case in production. +func RunUnspentTokensIteratorByPreparedComparison(b *testing.B, store *TokenStore) { + b.Helper() + + const walletID = "wallet0" + tokenType := tokentype.Type("GOLD") + + b.Run("UnspentTokensIteratorBy_Dynamic", func(b *testing.B) { + SeedBenchTokens(b, store, 1000) + cfg := benchmark.NewConfig(4, 5*time.Second, 500*time.Millisecond) + result := benchmark.RunBenchmark( + cfg, + func() *TokenStore { return store }, + func(s *TokenStore) error { + it, err := s.UnspentTokensIteratorBy(context.Background(), walletID, tokenType) + if err != nil { + return err + } + defer it.Close() + for { + tok, err := it.Next() + if err != nil { + return err + } + if tok == nil { + break + } + } + return nil + }, + ) + result.Print() + }) + + b.Run("UnspentTokensIteratorBy_Prepared", func(b *testing.B) { + SeedBenchTokens(b, store, 1000) + + query, args := buildUnspentTokensIteratorByQuery(store, walletID, tokenType) + + stmt, err := store.readDB.PrepareContext(context.Background(), query) + if err != nil { + b.Fatalf("failed preparing statement: %v", err) + } + defer func() { _ = stmt.Close() }() + + cfg := benchmark.NewConfig(4, 5*time.Second, 500*time.Millisecond) + result := benchmark.RunBenchmark( + cfg, + func() *sql.Stmt { return stmt }, + func(s *sql.Stmt) error { + rows, err := s.QueryContext(context.Background(), args...) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + it := &dedupedTokenRowsIterator{rows: rows, seen: make(map[string]struct{})} + for { + tok, err := it.Next() + if err != nil { + return err + } + if tok == nil { + break + } + } + return nil + }, + ) + result.Print() + }) +} diff --git a/token/services/storage/db/sql/postgres/tokens_bench_test.go b/token/services/storage/db/sql/postgres/tokens_bench_test.go index 6cfe281d81..ba359eaaa9 100644 --- a/token/services/storage/db/sql/postgres/tokens_bench_test.go +++ b/token/services/storage/db/sql/postgres/tokens_bench_test.go @@ -48,3 +48,9 @@ func BenchmarkTokenStore(b *testing.B) { defer cleanup() sqlcommon.RunTokenStoreBenchmarks(b, store) } + +func BenchmarkTokenStorePreparedComparison(b *testing.B) { + store, cleanup := openBenchTokenStore(b) + defer cleanup() + sqlcommon.RunUnspentTokensIteratorByPreparedComparison(b, store) +}