General library improvements.

This commit is contained in:
2025-05-23 12:28:16 +01:00
parent 4d4eac8a20
commit 067841471f
16 changed files with 1175 additions and 89 deletions
+181
View File
@@ -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)
}
}
+110 -15
View File
@@ -12,6 +12,7 @@ import (
type CacheEntry struct {
ExpiresAt time.Time
Value []byte
IsCompressed bool
}
const shardCount = 256 // Must be power of 2
@@ -26,6 +27,10 @@ type Cache struct {
decompressPool sync.Pool
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
@@ -38,6 +43,8 @@ func (c *Cache) getShard(key string) *shard {
func New(globalTTL time.Duration) *Cache {
cache := &Cache{
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 {
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()
const compressionThreshold = 1024 // 1KB threshold
var finalValue []byte
var isCompressed bool
if len(value) >= compressionThreshold {
compressedValue, err := c.compress(value)
if err != nil {
return
// 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,
Value: finalValue,
ExpiresAt: time.Now().Add(ttl),
IsCompressed: isCompressed,
}
}
@@ -98,20 +190,23 @@ 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)
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
}
+11 -7
View File
@@ -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)
}
}
+15 -8
View File
@@ -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))
+172
View File
@@ -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))
}
}
+1 -1
View File
@@ -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 {
+68
View File
@@ -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)
}
}
+78
View File
@@ -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")
})
}
+4 -3
View File
@@ -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 {
+13 -8
View File
@@ -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
@@ -41,10 +44,12 @@ func (b *BaseClient) createHttpClient() (http_client *http.Client) {
http2Transport := &http2.Transport{
AllowHTTP: true,
TLSClientConfig: tlsClientConfig,
ReadIdleTimeout: 15 * time.Second,
PingTimeout: 15 * time.Second,
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
+158
View File
@@ -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)
}
}
+53 -3
View File
@@ -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)
}
}
+49 -30
View File
@@ -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
}
+196
View File
@@ -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))
}
+3
View File
@@ -27,6 +27,9 @@ func (suite *Tests) TearDownSuite() {
if mockServer != nil {
mockServer.Close()
}
// Cleanup shared test cache
CleanupTestCache()
}
func (suite *Tests) SetupTest() {
+49
View File
@@ -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",
}
}