From 067841471f22739d384e23265af6dba98142b6d3 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 23 May 2025 12:28:16 +0100 Subject: [PATCH] General library improvements. --- buffer_pool_bench_test.go | 181 +++++++++++++++++++++++++++++ cache/cache.go | 149 +++++++++++++++++++----- cache/cache_bench_test.go | 18 +-- cache/cache_test.go | 23 ++-- cache_compression_bench_test.go | 172 ++++++++++++++++++++++++++++ execute_query.go | 2 +- hash_bench_test.go | 68 +++++++++++ hash_optimization_bench_test.go | 78 +++++++++++++ helpers.go | 7 +- http.go | 25 ++-- performance_regression_test.go | 158 +++++++++++++++++++++++++ pools.go | 56 ++++++++- query.go | 79 ++++++++----- query_optimization_test.go | 196 ++++++++++++++++++++++++++++++++ suite_test.go | 3 + test_helpers.go | 49 ++++++++ 16 files changed, 1175 insertions(+), 89 deletions(-) create mode 100644 buffer_pool_bench_test.go create mode 100644 cache_compression_bench_test.go create mode 100644 hash_bench_test.go create mode 100644 hash_optimization_bench_test.go create mode 100644 performance_regression_test.go create mode 100644 query_optimization_test.go create mode 100644 test_helpers.go diff --git a/buffer_pool_bench_test.go b/buffer_pool_bench_test.go new file mode 100644 index 0000000..0540d6d --- /dev/null +++ b/buffer_pool_bench_test.go @@ -0,0 +1,181 @@ +package gql + +import ( + "bytes" + "testing" +) + +// Benchmark tiered buffer pools vs single pool +func BenchmarkBufferPools(b *testing.B) { + // Test data of different sizes + smallData := make([]byte, 512) + mediumData := make([]byte, 2048) + largeData := make([]byte, 8192) + + for i := range smallData { + smallData[i] = byte(i % 256) + } + for i := range mediumData { + mediumData[i] = byte(i % 256) + } + for i := range largeData { + largeData[i] = byte(i % 256) + } + + b.Run("SmallBuffer_GetPut", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf := getBuffer(len(smallData)) + buf.Write(smallData) + putBuffer(buf) + } + }) + + b.Run("MediumBuffer_GetPut", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf := getBuffer(len(mediumData)) + buf.Write(mediumData) + putBuffer(buf) + } + }) + + b.Run("LargeBuffer_GetPut", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf := getBuffer(len(largeData)) + buf.Write(largeData) + putBuffer(buf) + } + }) + + // Compare with old single pool approach + b.Run("SinglePool_Small", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() + buf.Write(smallData) + bufferPool.Put(buf) + } + }) + + b.Run("SinglePool_Medium", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() + buf.Write(mediumData) + bufferPool.Put(buf) + } + }) + + b.Run("SinglePool_Large", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf := bufferPool.Get().(*bytes.Buffer) + buf.Reset() + buf.Write(largeData) + bufferPool.Put(buf) + } + }) +} + +// Benchmark buffer allocation patterns +func BenchmarkBufferAllocation(b *testing.B) { + b.Run("TieredPools_Mixed", func(b *testing.B) { + sizes := []int{256, 512, 1024, 2048, 4096, 8192, 16384} + b.ResetTimer() + for i := 0; i < b.N; i++ { + size := sizes[i%len(sizes)] + buf := getBuffer(size) + data := make([]byte, size) + buf.Write(data) + putBuffer(buf) + } + }) + + b.Run("DirectAllocation_Mixed", func(b *testing.B) { + sizes := []int{256, 512, 1024, 2048, 4096, 8192, 16384} + b.ResetTimer() + for i := 0; i < b.N; i++ { + size := sizes[i%len(sizes)] + buf := bytes.NewBuffer(make([]byte, 0, size)) + data := make([]byte, size) + buf.Write(data) + } + }) +} + +// Test buffer pool correctness +func TestBufferPoolCorrectness(t *testing.T) { + testData := []byte("Hello, World! This is test data for buffer pool testing.") + + // Test small buffer + buf := getBuffer(len(testData)) + buf.Write(testData) + if buf.Len() != len(testData) { + t.Errorf("Buffer length mismatch: expected %d, got %d", len(testData), buf.Len()) + } + if !bytes.Equal(buf.Bytes(), testData) { + t.Error("Buffer content mismatch") + } + putBuffer(buf) + + // Test that buffer is properly reset when returned to pool + buf2 := getBuffer(10) + if buf2.Len() != 0 { + t.Error("Buffer not properly reset") + } + putBuffer(buf2) + + // Test large buffer + largeData := make([]byte, 10000) + for i := range largeData { + largeData[i] = byte(i % 256) + } + + largeBuf := getBuffer(len(largeData)) + largeBuf.Write(largeData) + if largeBuf.Len() != len(largeData) { + t.Errorf("Large buffer length mismatch: expected %d, got %d", len(largeData), largeBuf.Len()) + } + putBuffer(largeBuf) +} + +// Test buffer pool size selection +func TestBufferPoolSizeSelection(t *testing.T) { + testCases := []struct { + size int + expectedPool string + }{ + {100, "small"}, + {1024, "small"}, + {1025, "medium"}, + {4096, "medium"}, + {4097, "large"}, + {16384, "large"}, + {20000, "large"}, + } + + for _, tc := range testCases { + buf := getBuffer(tc.size) + capacity := buf.Cap() + + var poolType string + switch { + case capacity <= 1024: + poolType = "small" + case capacity <= 4096: + poolType = "medium" + default: + poolType = "large" + } + + if poolType != tc.expectedPool { + t.Errorf("Size %d: expected %s pool, got %s pool (capacity: %d)", + tc.size, tc.expectedPool, poolType, capacity) + } + putBuffer(buf) + } +} diff --git a/cache/cache.go b/cache/cache.go index afdf163..8214d16 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -10,22 +10,27 @@ import ( ) type CacheEntry struct { - ExpiresAt time.Time - Value []byte + ExpiresAt time.Time + Value []byte + IsCompressed bool } const shardCount = 256 // Must be power of 2 type shard struct { - entries map[string]CacheEntry + entries map[string]CacheEntry sync.RWMutex } type Cache struct { compressPool sync.Pool decompressPool sync.Pool - shards [shardCount]*shard - globalTTL time.Duration + shards [shardCount]*shard + globalTTL time.Duration + cleanupChan chan struct{} + stopChan chan struct{} + stopped bool + mu sync.RWMutex } // getShard returns the appropriate shard for a given key @@ -37,7 +42,9 @@ func (c *Cache) getShard(key string) *shard { func New(globalTTL time.Duration) *Cache { cache := &Cache{ - globalTTL: globalTTL, + globalTTL: globalTTL, + cleanupChan: make(chan struct{}, 1), + stopChan: make(chan struct{}), compressPool: sync.Pool{ New: func() interface{} { w := gzip.NewWriter(nil) @@ -59,31 +66,116 @@ func New(globalTTL time.Duration) *Cache { } } - go cache.cleanupRoutine(globalTTL) + go cache.lazyCleanupWorker() + go cache.periodicCleanupRoutine(globalTTL) return cache } -func (c *Cache) cleanupRoutine(globalTTL time.Duration) { - ticker := time.NewTicker(globalTTL / 2) - defer ticker.Stop() - - for range ticker.C { - c.CleanExpiredEntries() +func (c *Cache) lazyCleanupWorker() { + for { + select { + case <-c.cleanupChan: + c.mu.RLock() + if c.stopped { + c.mu.RUnlock() + return + } + c.mu.RUnlock() + c.CleanExpiredEntries() + case <-c.stopChan: + return + } } } + +func (c *Cache) periodicCleanupRoutine(globalTTL time.Duration) { + // Use a shorter cleanup interval for better responsiveness, but not less than 1 second + cleanupInterval := globalTTL / 4 + if cleanupInterval < time.Second { + cleanupInterval = time.Second + } + + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + c.mu.RLock() + if c.stopped { + c.mu.RUnlock() + return + } + c.mu.RUnlock() + c.CleanExpiredEntries() + case <-c.stopChan: + return + } + } +} + +func (c *Cache) triggerLazyCleanup() { + c.mu.RLock() + if c.stopped { + c.mu.RUnlock() + return + } + c.mu.RUnlock() + + select { + case c.cleanupChan <- struct{}{}: + // Cleanup triggered + default: + // Cleanup already pending, skip + } +} + +func (c *Cache) Stop() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.stopped { + return // Already stopped + } + + c.stopped = true + close(c.stopChan) +} + func (c *Cache) Set(key string, value []byte, ttl time.Duration) { shard := c.getShard(key) shard.Lock() defer shard.Unlock() - compressedValue, err := c.compress(value) - if err != nil { - return + const compressionThreshold = 1024 // 1KB threshold + var finalValue []byte + var isCompressed bool + + if len(value) >= compressionThreshold { + compressedValue, err := c.compress(value) + if err != nil { + // If compression fails, store uncompressed + finalValue = value + isCompressed = false + } else { + // Only use compression if it actually reduces size + if len(compressedValue) < len(value) { + finalValue = compressedValue + isCompressed = true + } else { + finalValue = value + isCompressed = false + } + } + } else { + finalValue = value + isCompressed = false } shard.entries[key] = CacheEntry{ - Value: compressedValue, - ExpiresAt: time.Now().Add(ttl), + Value: finalValue, + ExpiresAt: time.Now().Add(ttl), + IsCompressed: isCompressed, } } @@ -98,19 +190,22 @@ func (c *Cache) Get(key string) ([]byte, bool) { if entry.ExpiresAt.Before(time.Now()) { shard.RUnlock() - // Clean up expired entry in background - go func() { - shard.Lock() - delete(shard.entries, key) - shard.Unlock() - }() + // Trigger lazy cleanup instead of immediate deletion + c.triggerLazyCleanup() return nil, false } shard.RUnlock() - value, err := c.decompress(entry.Value) - if err != nil { - return nil, false + var value []byte + var err error + + if entry.IsCompressed { + value, err = c.decompress(entry.Value) + if err != nil { + return nil, false + } + } else { + value = entry.Value } return value, true diff --git a/cache/cache_bench_test.go b/cache/cache_bench_test.go index 753e4e9..27053f7 100644 --- a/cache/cache_bench_test.go +++ b/cache/cache_bench_test.go @@ -8,22 +8,24 @@ import ( // Assume that New function initializes the cache and it is defined somewhere in the libpack_cache package. func BenchmarkCacheSet(b *testing.B) { - cache := New(30 * time.Second) // Initializing the cache with a TTL of 30 seconds + cache := New(5 * time.Second) // Use shorter TTL for tests + defer cache.Stop() key := "benchmark-key" value := []byte("benchmark-value") b.ResetTimer() // Reset the timer to exclude the setup time from the benchmark for i := 0; i < b.N; i++ { - cache.Set(key, value, 5*time.Second) + cache.Set(key, value, 2*time.Second) } } func BenchmarkCacheGet(b *testing.B) { - cache := New(30 * time.Second) // Initializing the cache + cache := New(5 * time.Second) // Use shorter TTL for tests + defer cache.Stop() key := "benchmark-key" value := []byte("benchmark-value") - cache.Set(key, value, 5*time.Second) // Pre-set a value to retrieve + cache.Set(key, value, 2*time.Second) // Pre-set a value to retrieve b.ResetTimer() // Start timing @@ -33,14 +35,16 @@ func BenchmarkCacheGet(b *testing.B) { } func BenchmarkCacheExpire(b *testing.B) { - key := "benchmark-expire-key" + cache := New(5 * time.Second) // Use shorter TTL for tests and reuse cache + defer cache.Stop() value := []byte("benchmark-value") ttl := 5 * time.Millisecond // Setting a short TTL for quick expiration + b.ResetTimer() for i := 0; i < b.N; i++ { - cache := New(30 * time.Second) + key := "benchmark-expire-key-" + string(rune(i)) // Use unique keys cache.Set(key, value, ttl) - time.Sleep(ttl) // Wait for the key to expire + time.Sleep(ttl + time.Millisecond) // Wait for the key to expire with buffer _, _ = cache.Get(key) } } diff --git a/cache/cache_test.go b/cache/cache_test.go index b0e2cc7..c1c47cc 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -21,12 +21,15 @@ func TestCachingTestSuite(t *testing.T) { func (suite *CacheTestSuite) Test_New() { suite.T().Run("should return a new cache", func(t *testing.T) { cache := New(2 * time.Second) + defer cache.Stop() suite.NotNil(cache) }) } func (suite *CacheTestSuite) Test_CacheUse() { - cache := New(30 * time.Second) + cache := New(5 * time.Second) + defer cache.Stop() + tests := []struct { name string cache_value string @@ -42,7 +45,7 @@ func (suite *CacheTestSuite) Test_CacheUse() { } for _, tt := range tests { suite.T().Run(tt.name, func(t *testing.T) { - cache.Set(tt.name, []byte(tt.name), 5*time.Second) + cache.Set(tt.name, []byte(tt.name), 2*time.Second) c, ok := cache.Get(tt.name) suite.Equal(true, ok) suite.Equal(tt.name, string(c)) @@ -51,7 +54,9 @@ func (suite *CacheTestSuite) Test_CacheUse() { } func (suite *CacheTestSuite) Test_CacheDelete() { - cache := New(30 * time.Second) + cache := New(5 * time.Second) + defer cache.Stop() + tests := []struct { name string cache_value string @@ -67,7 +72,7 @@ func (suite *CacheTestSuite) Test_CacheDelete() { } for _, tt := range tests { suite.T().Run(tt.name, func(t *testing.T) { - cache.Set(tt.name, []byte(tt.name), 5*time.Second) + cache.Set(tt.name, []byte(tt.name), 2*time.Second) c, ok := cache.Get(tt.name) suite.Equal(true, ok) suite.Equal(tt.name, string(c)) @@ -80,7 +85,9 @@ func (suite *CacheTestSuite) Test_CacheDelete() { } func (suite *CacheTestSuite) Test_CacheExpire() { - cache := New(30 * time.Second) + cache := New(5 * time.Second) + defer cache.Stop() + tests := []struct { name string cache_value string @@ -89,12 +96,12 @@ func (suite *CacheTestSuite) Test_CacheExpire() { { name: "test1", cache_value: "test1-123", - ttl: 2 * time.Second, + ttl: 100 * time.Millisecond, }, { name: "test2", cache_value: "test2-123", - ttl: 5 * time.Second, + ttl: 200 * time.Millisecond, }, } for _, tt := range tests { @@ -103,7 +110,7 @@ func (suite *CacheTestSuite) Test_CacheExpire() { c, ok := cache.Get(tt.name) suite.Equal(true, ok) suite.Equal(tt.name, string(c)) - time.Sleep(tt.ttl) + time.Sleep(tt.ttl + 50*time.Millisecond) // Add small buffer c, ok = cache.Get(tt.name) suite.Equal(false, ok) suite.Equal("", string(c)) diff --git a/cache_compression_bench_test.go b/cache_compression_bench_test.go new file mode 100644 index 0000000..6ca6b7f --- /dev/null +++ b/cache_compression_bench_test.go @@ -0,0 +1,172 @@ +package gql + +import ( + "testing" + "time" + + cache "github.com/lukaszraczylo/go-simple-graphql/cache" +) + +// Benchmark cache compression with different data sizes +func BenchmarkCacheCompression(b *testing.B) { + c := cache.New(5 * time.Second) // Use shorter TTL for tests + defer c.Stop() + + // Small data (under 1KB threshold) + smallData := make([]byte, 512) + for i := range smallData { + smallData[i] = byte(i % 256) + } + + // Medium data (over 1KB threshold) + mediumData := make([]byte, 2048) + for i := range mediumData { + mediumData[i] = byte(i % 256) + } + + // Large data (much larger than threshold) + largeData := make([]byte, 8192) + for i := range largeData { + largeData[i] = byte(i % 256) + } + + // Highly compressible data + compressibleData := make([]byte, 4096) + for i := range compressibleData { + compressibleData[i] = 'A' // Repeating character compresses well + } + + b.Run("SmallData_Set", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Set("small", smallData, time.Minute) + } + }) + + b.Run("SmallData_Get", func(b *testing.B) { + c.Set("small", smallData, time.Minute) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = c.Get("small") + } + }) + + b.Run("MediumData_Set", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Set("medium", mediumData, time.Minute) + } + }) + + b.Run("MediumData_Get", func(b *testing.B) { + c.Set("medium", mediumData, time.Minute) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = c.Get("medium") + } + }) + + b.Run("LargeData_Set", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Set("large", largeData, time.Minute) + } + }) + + b.Run("LargeData_Get", func(b *testing.B) { + c.Set("large", largeData, time.Minute) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = c.Get("large") + } + }) + + b.Run("CompressibleData_Set", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.Set("compressible", compressibleData, time.Minute) + } + }) + + b.Run("CompressibleData_Get", func(b *testing.B) { + c.Set("compressible", compressibleData, time.Minute) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = c.Get("compressible") + } + }) +} + +// Benchmark cache memory usage with compression +func BenchmarkCacheMemoryUsage(b *testing.B) { + c := cache.New(5 * time.Second) // Use shorter TTL for tests + defer c.Stop() + + // Test data that compresses well + testData := make([]byte, 4096) + for i := range testData { + testData[i] = byte(i%10 + '0') // Repeating pattern + } + + b.Run("WithCompression", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + key := "key" + string(rune(i%1000)) + c.Set(key, testData, time.Minute) + } + }) +} + +// Test compression effectiveness +func TestCompressionEffectiveness(t *testing.T) { + c := cache.New(5 * time.Second) // Use shorter TTL for tests + defer c.Stop() + + // Highly compressible data + compressibleData := make([]byte, 4096) + for i := range compressibleData { + compressibleData[i] = 'A' + } + + // Random data (less compressible) + randomData := make([]byte, 4096) + for i := range randomData { + randomData[i] = byte(i % 256) + } + + // Small data (under threshold) + smallData := make([]byte, 512) + for i := range smallData { + smallData[i] = byte(i % 256) + } + + // Test compressible data + c.Set("compressible", compressibleData, time.Minute) + retrieved, ok := c.Get("compressible") + if !ok { + t.Error("Failed to retrieve compressible data") + } + if len(retrieved) != len(compressibleData) { + t.Errorf("Data length mismatch: expected %d, got %d", len(compressibleData), len(retrieved)) + } + + // Test random data + c.Set("random", randomData, time.Minute) + retrieved, ok = c.Get("random") + if !ok { + t.Error("Failed to retrieve random data") + } + if len(retrieved) != len(randomData) { + t.Errorf("Data length mismatch: expected %d, got %d", len(randomData), len(retrieved)) + } + + // Test small data (should not be compressed) + c.Set("small", smallData, time.Minute) + retrieved, ok = c.Get("small") + if !ok { + t.Error("Failed to retrieve small data") + } + if len(retrieved) != len(smallData) { + t.Errorf("Data length mismatch: expected %d, got %d", len(smallData), len(retrieved)) + } +} diff --git a/execute_query.go b/execute_query.go index 676c5ae..20b63dd 100644 --- a/execute_query.go +++ b/execute_query.go @@ -130,7 +130,7 @@ func (qe *QueryExecutor) executeQuery() ([]byte, error) { retry.Attempts(uint(retriesMax)), retry.DelayType(retry.BackOffDelay), retry.Delay(time.Duration(qe.retries_delay)), - retry.MaxDelay(10 * time.Second), + retry.MaxDelay(10*time.Second), retry.LastErrorOnly(true), ) if err != nil { diff --git a/hash_bench_test.go b/hash_bench_test.go new file mode 100644 index 0000000..cf75853 --- /dev/null +++ b/hash_bench_test.go @@ -0,0 +1,68 @@ +package gql + +import ( + "fmt" + "testing" +) + +func Benchmark_calculateHash(b *testing.B) { + client := NewConnection() + query := &Query{ + Query: "query { viewer { login } }", + Variables: map[string]interface{}{"var1": "value1"}, + } + query.JsonQuery = client.convertToJSON(query) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = calculateHash(query) + } +} + +func Benchmark_calculateHashLargeQuery(b *testing.B) { + client := NewConnection() + // Create a larger query to test hash performance with bigger payloads + largeQuery := `query { + viewer { + login + name + email + bio + company + location + websiteUrl + twitterUsername + repositories(first: 100) { + nodes { + name + description + url + stargazerCount + forkCount + primaryLanguage { + name + color + } + createdAt + updatedAt + } + } + } + }` + + variables := make(map[string]interface{}) + for i := 0; i < 50; i++ { + variables[fmt.Sprintf("var%d", i)] = fmt.Sprintf("value%d", i) + } + + query := &Query{ + Query: largeQuery, + Variables: variables, + } + query.JsonQuery = client.convertToJSON(query) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = calculateHash(query) + } +} diff --git a/hash_optimization_bench_test.go b/hash_optimization_bench_test.go new file mode 100644 index 0000000..2a9a84c --- /dev/null +++ b/hash_optimization_bench_test.go @@ -0,0 +1,78 @@ +package gql + +import ( + "crypto/md5" + "encoding/hex" + "hash/fnv" + "testing" +) + +// Benchmark comparing MD5 vs FNV hash performance +func BenchmarkHashAlgorithms(b *testing.B) { + testData := []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } posts { id title content createdAt } } }","variables":{"id":"12345"}}`) + + b.Run("MD5", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + hash := md5.Sum(testData) + _ = hex.EncodeToString(hash[:]) + } + }) + + b.Run("FNV64a", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + hash := fnv.New64a() + hash.Write(testData) + _ = hex.EncodeToString(hash.Sum(nil)) + } + }) +} + +// Benchmark the new calculateHash function +func BenchmarkCalculateHash(b *testing.B) { + query := &Query{ + Query: "query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } posts { id title content createdAt } } }", + Variables: map[string]interface{}{ + "id": "12345", + }, + JsonQuery: []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } posts { id title content createdAt } } }","variables":{"id":"12345"}}`), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = calculateHash(query) + } +} + +// Benchmark hash collision rate (quality test) +func BenchmarkHashCollisions(b *testing.B) { + queries := make([]*Query, 1000) + for i := 0; i < 1000; i++ { + queries[i] = &Query{ + Query: "query GetUser($id: ID!) { user(id: $id) { id name email } }", + Variables: map[string]interface{}{ + "id": i, + }, + JsonQuery: []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":` + string(rune(i)) + `}}`), + } + } + + b.Run("FNV_Collision_Test", func(b *testing.B) { + hashes := make(map[string]bool) + collisions := 0 + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, query := range queries { + hash := calculateHash(query) + if hashes[hash] { + collisions++ + } else { + hashes[hash] = true + } + } + } + b.ReportMetric(float64(collisions), "collisions") + }) +} diff --git a/helpers.go b/helpers.go index 9e121ff..45dc500 100644 --- a/helpers.go +++ b/helpers.go @@ -1,9 +1,9 @@ package gql import ( - "crypto/md5" "encoding/hex" "fmt" + "hash/fnv" "github.com/goccy/go-json" libpack_logger "github.com/lukaszraczylo/go-simple-graphql/logging" @@ -17,8 +17,9 @@ func searchForKeysInMapStringInterface(msi map[string]interface{}, key string) i } func calculateHash(query *Query) string { - hash := md5.Sum(query.JsonQuery) - return hex.EncodeToString(hash[:]) + hash := fnv.New64a() + hash.Write(query.JsonQuery) + return hex.EncodeToString(hash.Sum(nil)) } func (b *BaseClient) cacheLookup(hash string) []byte { diff --git a/http.go b/http.go index ea71830..741dd5d 100644 --- a/http.go +++ b/http.go @@ -11,19 +11,22 @@ import ( ) func (b *BaseClient) createHttpClient() (http_client *http.Client) { + // Optimized connection pool settings for better performance httpTransport := &http.Transport{ - MaxIdleConns: 512, - MaxConnsPerHost: 512, - MaxIdleConnsPerHost: 512, - IdleConnTimeout: 15 * time.Second, - ResponseHeaderTimeout: 15 * time.Second, + MaxIdleConns: 100, // Reduced from 512 + MaxConnsPerHost: 50, // Reduced from 512 + MaxIdleConnsPerHost: 10, // Reduced from 512 + IdleConnTimeout: 30 * time.Second, // Increased for better reuse + ResponseHeaderTimeout: 10 * time.Second, // Reduced for faster timeouts DisableKeepAlives: false, DisableCompression: false, + WriteBufferSize: 4096, // Optimize buffer size + ReadBufferSize: 4096, // Optimize buffer size } if strings.HasPrefix(b.endpoint, "http://") { http_client = &http.Client{ - Timeout: 15 * time.Second, + Timeout: 30 * time.Second, // Increased for better reliability Transport: httpTransport, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -39,12 +42,14 @@ func (b *BaseClient) createHttpClient() (http_client *http.Client) { tlsClientConfig.InsecureSkipVerify = true } http2Transport := &http2.Transport{ - AllowHTTP: true, - TLSClientConfig: tlsClientConfig, - ReadIdleTimeout: 15 * time.Second, - PingTimeout: 15 * time.Second, + AllowHTTP: true, + TLSClientConfig: tlsClientConfig, + ReadIdleTimeout: 30 * time.Second, // Increased for better reuse + PingTimeout: 10 * time.Second, // Reduced for faster detection + WriteByteTimeout: 10 * time.Second, // Add write timeout } http_client = &http.Client{ + Timeout: 30 * time.Second, // Increased for better reliability Transport: http2Transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse diff --git a/performance_regression_test.go b/performance_regression_test.go new file mode 100644 index 0000000..875b8b4 --- /dev/null +++ b/performance_regression_test.go @@ -0,0 +1,158 @@ +package gql + +import ( + "testing" + "time" + + cache "github.com/lukaszraczylo/go-simple-graphql/cache" +) + +// Performance regression tests to ensure optimizations maintain their benefits +func TestPerformanceRegression(t *testing.T) { + // Test hash performance + t.Run("HashPerformance", func(t *testing.T) { + query := &Query{ + Query: "query GetUser($id: ID!) { user(id: $id) { id name email } }", + Variables: map[string]interface{}{ + "id": "12345", + }, + JsonQuery: []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":"12345"}}`), + } + + start := time.Now() + for i := 0; i < 10000; i++ { + _ = calculateHash(query) + } + duration := time.Since(start) + + // Should complete 10k hashes in under 25ms (accounts for race detection + coverage overhead) + if duration > 25*time.Millisecond { + t.Errorf("Hash performance regression: 10k hashes took %v, expected < 25ms", duration) + } + }) + + // Test buffer pool performance + t.Run("BufferPoolPerformance", func(t *testing.T) { + start := time.Now() + for i := 0; i < 10000; i++ { + buf := getBuffer(1024) + buf.WriteString("test data") + putBuffer(buf) + } + duration := time.Since(start) + + // Should complete 10k buffer operations in under 25ms (accounts for race detection + coverage overhead) + if duration > 25*time.Millisecond { + t.Errorf("Buffer pool performance regression: 10k operations took %v, expected < 25ms", duration) + } + }) + + // Test cache performance + t.Run("CachePerformance", func(t *testing.T) { + c := cache.New(5 * time.Second) // Use shorter TTL for tests + defer c.Stop() + + testData := make([]byte, 512) // Small data that won't be compressed + for i := range testData { + testData[i] = byte(i % 256) + } + + start := time.Now() + for i := 0; i < 10000; i++ { + key := "key" + string(rune(i%100)) + c.Set(key, testData, time.Minute) + _, _ = c.Get(key) + } + duration := time.Since(start) + + // Should complete 10k cache operations in under 50ms + if duration > 50*time.Millisecond { + t.Errorf("Cache performance regression: 10k operations took %v, expected < 50ms", duration) + } + }) + + // Test processFlags performance + t.Run("ProcessFlagsPerformance", func(t *testing.T) { + variables := map[string]interface{}{ + "id": "123", + "gqlcache": true, + "gqlretries": true, + } + headers := map[string]interface{}{} + + start := time.Now() + for i := 0; i < 10000; i++ { + _, _, _ = processFlags(variables, headers) + } + duration := time.Since(start) + + // Should complete 10k flag processing operations in under 30ms (accounts for race detection + coverage overhead) + if duration > 30*time.Millisecond { + t.Errorf("ProcessFlags performance regression: 10k operations took %v, expected < 30ms", duration) + } + }) + + // Test query compilation performance + t.Run("QueryCompilationPerformance", func(t *testing.T) { + client := CreateTestClient() + + query := "query GetUser($id: ID!) { user(id: $id) { id name email } }" + variables := map[string]interface{}{"id": "123"} + + start := time.Now() + for i := 0; i < 1000; i++ { + _ = client.compileQuery(query, variables) + } + duration := time.Since(start) + + // Should complete 1k query compilations in under 50ms + if duration > 50*time.Millisecond { + t.Errorf("Query compilation performance regression: 1k operations took %v, expected < 50ms", duration) + } + }) +} + +// Benchmark comprehensive workflow to ensure no regression +func BenchmarkComprehensiveWorkflow(b *testing.B) { + client := CreateTestClient() + + query := "query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } } }" + variables := map[string]interface{}{ + "id": "123", + "gqlcache": true, + "gqlretries": true, + } + headers := map[string]interface{}{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Process flags + enableCache, enableRetries, cleanedVariables := processFlags(variables, headers) + + // Compile query + compiledQuery := client.compileQuery(query, cleanedVariables) + + // Calculate hash + if enableCache { + _ = calculateHash(compiledQuery) + } + + // Use variables to avoid optimization + _ = enableRetries + } +} + +// Memory allocation benchmark +func BenchmarkMemoryAllocations(b *testing.B) { + client := CreateTestClient() + + query := "query GetUser($id: ID!) { user(id: $id) { id name } }" + variables := map[string]interface{}{"id": "123"} + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + compiledQuery := client.compileQuery(query, variables) + _ = calculateHash(compiledQuery) + } +} diff --git a/pools.go b/pools.go index d9804d5..002f0d9 100644 --- a/pools.go +++ b/pools.go @@ -6,15 +6,34 @@ import ( ) var ( - // Pre-sized buffer pool for better performance - bufferPool = sync.Pool{ + // Tiered buffer pools for different size requirements + smallBufferPool = sync.Pool{ New: func() interface{} { - // Pre-allocate buffer with 4KB capacity + // Small buffers for typical GraphQL queries (1KB) + b := bytes.NewBuffer(make([]byte, 0, 1024)) + return b + }, + } + + mediumBufferPool = sync.Pool{ + New: func() interface{} { + // Medium buffers for larger queries (4KB) b := bytes.NewBuffer(make([]byte, 0, 4096)) return b }, } + largeBufferPool = sync.Pool{ + New: func() interface{} { + // Large buffers for very large queries (16KB) + b := bytes.NewBuffer(make([]byte, 0, 16384)) + return b + }, + } + + // Backward compatibility - use medium pool as default + bufferPool *sync.Pool + // Pre-allocate error maps to reduce allocations errPairsPool = sync.Pool{ New: func() interface{} { @@ -22,3 +41,34 @@ var ( }, } ) + +func init() { + bufferPool = &mediumBufferPool +} + +// getBuffer returns an appropriately sized buffer based on estimated size +func getBuffer(estimatedSize int) *bytes.Buffer { + switch { + case estimatedSize <= 1024: + return smallBufferPool.Get().(*bytes.Buffer) + case estimatedSize <= 4096: + return mediumBufferPool.Get().(*bytes.Buffer) + default: + return largeBufferPool.Get().(*bytes.Buffer) + } +} + +// putBuffer returns a buffer to the appropriate pool based on its capacity +func putBuffer(buf *bytes.Buffer) { + buf.Reset() + capacity := buf.Cap() + + switch { + case capacity <= 1024: + smallBufferPool.Put(buf) + case capacity <= 4096: + mediumBufferPool.Put(buf) + default: + largeBufferPool.Put(buf) + } +} diff --git a/query.go b/query.go index 8d470cb..68d5c71 100644 --- a/query.go +++ b/query.go @@ -1,7 +1,6 @@ package gql import ( - "bytes" "fmt" "github.com/goccy/go-json" @@ -11,9 +10,17 @@ import ( ) func (b *BaseClient) convertToJSON(v any) []byte { - buf := bufferPool.Get().(*bytes.Buffer) - defer bufferPool.Put(buf) - buf.Reset() + // Estimate size based on query structure for better buffer selection + estimatedSize := 512 // Base size + if query, ok := v.(*Query); ok { + estimatedSize += len(query.Query) + if query.Variables != nil { + estimatedSize += len(query.Variables) * 50 // Rough estimate per variable + } + } + + buf := getBuffer(estimatedSize) + defer putBuffer(buf) // Use json.NewEncoder for better performance with buffers enc := json.NewEncoder(buf) @@ -42,6 +49,39 @@ func (b *BaseClient) convertToJSON(v any) []byte { return result } +func processFlags(variables map[string]interface{}, headers map[string]interface{}) (enableCache, enableRetries bool, cleanedVariables map[string]interface{}) { + // Start with original variables + cleanedVariables = variables + + // Check headers first + enableCache, _ = goutil.ToBool(searchForKeysInMapStringInterface(headers, "gqlcache")) + enableRetries, _ = goutil.ToBool(searchForKeysInMapStringInterface(headers, "gqlretries")) + + // Check variables and clean them if needed + if variables != nil { + varEnableCache, _ := goutil.ToBool(searchForKeysInMapStringInterface(variables, "gqlcache")) + varEnableRetries, _ := goutil.ToBool(searchForKeysInMapStringInterface(variables, "gqlretries")) + + enableCache = enableCache || varEnableCache + enableRetries = enableRetries || varEnableRetries + + // Clean variables if flags are present (regardless of their value) + _, hasCacheFlag := variables["gqlcache"] + _, hasRetriesFlag := variables["gqlretries"] + + if hasCacheFlag || hasRetriesFlag { + cleanedVariables = make(map[string]interface{}) + for k, v := range variables { + if k != "gqlcache" && k != "gqlretries" { + cleanedVariables[k] = v + } + } + } + } + + return enableCache, enableRetries, cleanedVariables +} + func (b *BaseClient) compileQuery(queryPartials ...any) *Query { var query string var variables map[string]interface{} @@ -81,7 +121,11 @@ func (b *BaseClient) compileQuery(queryPartials ...any) *Query { } func (b *BaseClient) Query(query string, variables map[string]interface{}, headers map[string]interface{}) (any, error) { - compiledQuery := b.compileQuery(query, variables) + // Process flags before compilation to avoid recompilation + enableCache, enableRetries, cleanedVariables := processFlags(variables, headers) + + // Compile query once with cleaned variables + compiledQuery := b.compileQuery(query, cleanedVariables) if compiledQuery.JsonQuery == nil { b.Logger.Error(&libpack_logger.LogMessage{ Message: "Can't compile query", @@ -94,11 +138,6 @@ func (b *BaseClient) Query(query string, variables map[string]interface{}, heade Pairs: map[string]interface{}{"query": compiledQuery}, }) - enableCache, enableRetries, recompileRequired := compiledQuery.parseHeadersAndVariables(headers) - if recompileRequired { - compiledQuery = b.compileQuery(query, variables) - } - var queryHash string if (enableCache || b.cache_global) && strutil.HasPrefix(compiledQuery.Query, "query") { b.Logger.Debug(&libpack_logger.LogMessage{ @@ -143,23 +182,3 @@ func (b *BaseClient) Query(query string, variables map[string]interface{}, heade return b.decodeResponse(rv) } - -func (q *Query) parseHeadersAndVariables(headers map[string]interface{}) (enableCache, enableRetries, recompileRequired bool) { - enableCache, _ = goutil.ToBool(searchForKeysInMapStringInterface(headers, "gqlcache")) - enableRetries, _ = goutil.ToBool(searchForKeysInMapStringInterface(headers, "gqlretries")) - - if q.Variables != nil { - varEnableCache, _ := goutil.ToBool(searchForKeysInMapStringInterface(q.Variables, "gqlcache")) - varEnableRetries, _ := goutil.ToBool(searchForKeysInMapStringInterface(q.Variables, "gqlretries")) - enableCache = enableCache || varEnableCache - enableRetries = enableRetries || varEnableRetries - - if varEnableCache || varEnableRetries { - delete(q.Variables, "gqlcache") - delete(q.Variables, "gqlretries") - recompileRequired = true - } - } - - return enableCache, enableRetries, recompileRequired -} diff --git a/query_optimization_test.go b/query_optimization_test.go new file mode 100644 index 0000000..bdb30e3 --- /dev/null +++ b/query_optimization_test.go @@ -0,0 +1,196 @@ +package gql + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type QueryOptimizationTestSuite struct { + suite.Suite + client *BaseClient +} + +func (suite *QueryOptimizationTestSuite) SetupTest() { + suite.client = CreateTestClient() +} + +func (suite *QueryOptimizationTestSuite) TearDownTest() { + // No need to stop shared cache - it will be cleaned up globally +} + +func (suite *QueryOptimizationTestSuite) TestProcessFlags() { + tests := []struct { + name string + variables map[string]interface{} + headers map[string]interface{} + expectedCache bool + expectedRetries bool + expectedVarCount int + }{ + { + name: "No flags", + variables: map[string]interface{}{"id": "123"}, + headers: map[string]interface{}{}, + expectedCache: false, + expectedRetries: false, + expectedVarCount: 1, + }, + { + name: "Cache flag in headers", + variables: map[string]interface{}{"id": "123"}, + headers: map[string]interface{}{"gqlcache": true}, + expectedCache: true, + expectedRetries: false, + expectedVarCount: 1, + }, + { + name: "Retries flag in headers", + variables: map[string]interface{}{"id": "123"}, + headers: map[string]interface{}{"gqlretries": true}, + expectedCache: false, + expectedRetries: true, + expectedVarCount: 1, + }, + { + name: "Cache flag in variables", + variables: map[string]interface{}{"id": "123", "gqlcache": true}, + headers: map[string]interface{}{}, + expectedCache: true, + expectedRetries: false, + expectedVarCount: 1, // gqlcache should be removed + }, + { + name: "Retries flag in variables", + variables: map[string]interface{}{"id": "123", "gqlretries": true}, + headers: map[string]interface{}{}, + expectedCache: false, + expectedRetries: true, + expectedVarCount: 1, // gqlretries should be removed + }, + { + name: "Both flags in variables", + variables: map[string]interface{}{"id": "123", "gqlcache": true, "gqlretries": true}, + headers: map[string]interface{}{}, + expectedCache: true, + expectedRetries: true, + expectedVarCount: 1, // both flags should be removed + }, + { + name: "Flags in both headers and variables", + variables: map[string]interface{}{"id": "123", "gqlcache": false}, + headers: map[string]interface{}{"gqlcache": true, "gqlretries": true}, + expectedCache: true, // header value OR variable value + expectedRetries: true, + expectedVarCount: 1, // gqlcache should be removed from variables + }, + { + name: "Nil variables", + variables: nil, + headers: map[string]interface{}{"gqlcache": true}, + expectedCache: true, + expectedRetries: false, + expectedVarCount: 0, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + enableCache, enableRetries, cleanedVariables := processFlags(tt.variables, tt.headers) + + suite.Equal(tt.expectedCache, enableCache, "Cache flag mismatch") + suite.Equal(tt.expectedRetries, enableRetries, "Retries flag mismatch") + + if tt.expectedVarCount == 0 { + suite.Nil(cleanedVariables, "Variables should be nil") + } else { + suite.Len(cleanedVariables, tt.expectedVarCount, "Variable count mismatch") + suite.NotContains(cleanedVariables, "gqlcache", "gqlcache should be removed") + suite.NotContains(cleanedVariables, "gqlretries", "gqlretries should be removed") + } + }) + } +} + +func (suite *QueryOptimizationTestSuite) TestDoubleCompilationFix() { + // Test that query is only compiled once even with flags in variables + query := "query GetUser($id: ID!) { user(id: $id) { id name } }" + variables := map[string]interface{}{ + "id": "123", + "gqlcache": true, + "gqlretries": true, + } + + // This should not cause double compilation + compiledQuery := suite.client.compileQuery(query, variables) + suite.NotNil(compiledQuery) + suite.Equal(query, compiledQuery.Query) + + // Variables should still contain the flags at this point (they're cleaned in Query method) + suite.Contains(compiledQuery.Variables, "gqlcache") + suite.Contains(compiledQuery.Variables, "gqlretries") +} + +func (suite *QueryOptimizationTestSuite) TestQueryMethodOptimization() { + // Test that processFlags works correctly without making HTTP requests + variables := map[string]interface{}{ + "id": "123", + "gqlcache": true, + "gqlretries": true, + } + headers := map[string]interface{}{} + + // Test processFlags directly + enableCache, enableRetries, cleanedVariables := processFlags(variables, headers) + + suite.True(enableCache, "Cache should be enabled") + suite.True(enableRetries, "Retries should be enabled") + suite.Len(cleanedVariables, 1, "Should have only id variable") + suite.Equal("123", cleanedVariables["id"], "ID should be preserved") + suite.NotContains(cleanedVariables, "gqlcache", "gqlcache should be removed") + suite.NotContains(cleanedVariables, "gqlretries", "gqlretries should be removed") +} + +// Benchmark the optimization impact +func BenchmarkQueryCompilation(b *testing.B) { + client := CreateTestClient() + + query := "query GetUser($id: ID!) { user(id: $id) { id name email profile { avatar bio } } }" + + b.Run("WithoutFlags", func(b *testing.B) { + variables := map[string]interface{}{"id": "123"} + headers := map[string]interface{}{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + processFlags(variables, headers) + } + }) + + b.Run("WithFlags", func(b *testing.B) { + variables := map[string]interface{}{ + "id": "123", + "gqlcache": true, + "gqlretries": true, + } + headers := map[string]interface{}{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + processFlags(variables, headers) + } + }) + + b.Run("CompileQuery", func(b *testing.B) { + variables := map[string]interface{}{"id": "123"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + client.compileQuery(query, variables) + } + }) +} + +func TestQueryOptimizationSuite(t *testing.T) { + suite.Run(t, new(QueryOptimizationTestSuite)) +} diff --git a/suite_test.go b/suite_test.go index 623132e..2433a4d 100644 --- a/suite_test.go +++ b/suite_test.go @@ -27,6 +27,9 @@ func (suite *Tests) TearDownSuite() { if mockServer != nil { mockServer.Close() } + + // Cleanup shared test cache + CleanupTestCache() } func (suite *Tests) SetupTest() { diff --git a/test_helpers.go b/test_helpers.go new file mode 100644 index 0000000..5883ea1 --- /dev/null +++ b/test_helpers.go @@ -0,0 +1,49 @@ +package gql + +import ( + "sync" + "time" + + cache "github.com/lukaszraczylo/go-simple-graphql/cache" + logging "github.com/lukaszraczylo/go-simple-graphql/logging" +) + +var ( + testCacheOnce sync.Once + testCache *cache.Cache + testLogger *logging.Logger +) + +// GetTestCache returns a shared cache instance for tests to reduce resource contention +func GetTestCache() *cache.Cache { + testCacheOnce.Do(func() { + testCache = cache.New(5 * time.Second) // Short TTL for tests + }) + return testCache +} + +// GetTestLogger returns a shared logger instance for tests +func GetTestLogger() *logging.Logger { + if testLogger == nil { + testLogger = logging.New() + testLogger.SetMinLogLevel(logging.LEVEL_ERROR) + } + return testLogger +} + +// CleanupTestCache stops the shared test cache - call this in TestMain if needed +func CleanupTestCache() { + if testCache != nil { + testCache.Stop() + } +} + +// CreateTestClient creates a test client with shared resources +func CreateTestClient() *BaseClient { + return &BaseClient{ + Logger: GetTestLogger(), + cache: GetTestCache(), + endpoint: "https://example.com/graphql", + responseType: "mapstring", + } +}