mirror of
https://github.com/hanzoai/insights-go.git
synced 2026-09-30 22:46:12 +00:00
The module declares go 1.27.1 and the adapter image follows. go fix applied the modernizers and left ptrString and ptrBool with no callers, so they are gone; omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
482 lines
12 KiB
Go
482 lines
12 KiB
Go
package insights
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
json "github.com/goccy/go-json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestConcurrentEnqueue tests thread-safety of the client under concurrent load
|
|
func TestConcurrentEnqueue(t *testing.T) {
|
|
t.Parallel()
|
|
goroutines := 20
|
|
eventsPerGoroutine := 50
|
|
totalEvents := goroutines * eventsPerGoroutine
|
|
|
|
pool := NewEventPool(totalEvents)
|
|
|
|
var received atomic.Int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var b batch
|
|
json.NewDecoder(r.Body).Decode(&b)
|
|
received.Add(int64(len(b.Messages)))
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewWithConfig("test-key", Config{
|
|
Endpoint: server.URL,
|
|
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var wg sync.WaitGroup
|
|
for g := range goroutines {
|
|
wg.Add(1)
|
|
go func(goroutineID int) {
|
|
defer wg.Done()
|
|
for i := range eventsPerGoroutine {
|
|
idx := goroutineID*eventsPerGoroutine + i
|
|
err := client.Enqueue(pool.Get(idx))
|
|
if err != nil {
|
|
t.Errorf("Enqueue failed: %v", err)
|
|
}
|
|
}
|
|
}(g)
|
|
}
|
|
|
|
wg.Wait()
|
|
client.Close()
|
|
|
|
require.Equal(t, int64(totalEvents), received.Load(),
|
|
"All events should be delivered")
|
|
}
|
|
|
|
// TestConcurrentEnqueueDifferentMessageTypes tests concurrent enqueueing of different message types
|
|
func TestConcurrentEnqueueDifferentMessageTypes(t *testing.T) {
|
|
t.Parallel()
|
|
goroutines := 10
|
|
messagesPerGoroutine := 20
|
|
|
|
var received atomic.Int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var b batch
|
|
json.NewDecoder(r.Body).Decode(&b)
|
|
received.Add(int64(len(b.Messages)))
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewWithConfig("test-key", Config{
|
|
Endpoint: server.URL,
|
|
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var wg sync.WaitGroup
|
|
for g := range goroutines {
|
|
wg.Add(1)
|
|
go func(goroutineID int) {
|
|
defer wg.Done()
|
|
for i := range messagesPerGoroutine {
|
|
msgType := i % 4
|
|
switch msgType {
|
|
case 0:
|
|
client.Enqueue(Capture{
|
|
DistinctId: fmt.Sprintf("user_%d", goroutineID),
|
|
Event: fmt.Sprintf("event_%d", i),
|
|
})
|
|
case 1:
|
|
client.Enqueue(Identify{
|
|
DistinctId: fmt.Sprintf("user_%d", goroutineID),
|
|
Properties: Properties{"name": fmt.Sprintf("User %d", goroutineID)},
|
|
})
|
|
case 2:
|
|
client.Enqueue(Alias{
|
|
DistinctId: fmt.Sprintf("user_%d", goroutineID),
|
|
Alias: fmt.Sprintf("alias_%d", goroutineID),
|
|
})
|
|
case 3:
|
|
client.Enqueue(GroupIdentify{
|
|
Type: "company",
|
|
Key: fmt.Sprintf("company_%d", goroutineID),
|
|
Properties: Properties{
|
|
"name": fmt.Sprintf("Company %d", goroutineID),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}(g)
|
|
}
|
|
|
|
wg.Wait()
|
|
client.Close()
|
|
|
|
expectedTotal := int64(goroutines * messagesPerGoroutine)
|
|
require.Equal(t, expectedTotal, received.Load(),
|
|
"All messages should be delivered")
|
|
}
|
|
|
|
// TestConcurrentFeatureFlagEvaluation tests concurrent flag evaluation with cache updates
|
|
func TestConcurrentFeatureFlagEvaluation(t *testing.T) {
|
|
t.Parallel()
|
|
// Setup mock server with feature flags
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
// Return proper format for both local evaluation and flags endpoints
|
|
if r.URL.Path == "/flags/definitions" {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"flags": []map[string]any{
|
|
{
|
|
"id": 1,
|
|
"key": "test-flag",
|
|
"active": true,
|
|
"filters": map[string]any{
|
|
"groups": []map[string]any{
|
|
{
|
|
"rollout_percentage": 100,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"group_type_mapping": map[string]string{},
|
|
})
|
|
} else {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"featureFlags": map[string]any{
|
|
"test-flag": true,
|
|
},
|
|
})
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewWithConfig("test-key", Config{
|
|
Endpoint: server.URL,
|
|
PersonalApiKey: "test-key",
|
|
})
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
|
|
// Wait for initial flag fetch to complete
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
goroutines := 100
|
|
evaluationsPerGoroutine := 50
|
|
|
|
var wg sync.WaitGroup
|
|
errorCount := atomic.Int64{}
|
|
|
|
for g := range goroutines {
|
|
wg.Add(1)
|
|
go func(goroutineID int) {
|
|
defer wg.Done()
|
|
for i := range evaluationsPerGoroutine {
|
|
distinctId := fmt.Sprintf("user_%d_%d", goroutineID, i)
|
|
_, err := client.GetFeatureFlag(FeatureFlagPayload{
|
|
Key: "test-flag",
|
|
DistinctId: distinctId,
|
|
})
|
|
if err != nil {
|
|
errorCount.Add(1)
|
|
}
|
|
}
|
|
}(g)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// With httptest.Server, there are no network issues - all evaluations should succeed
|
|
require.Equal(t, int64(0), errorCount.Load(),
|
|
"All feature flag evaluations should succeed - httptest.Server has no network issues")
|
|
}
|
|
|
|
// TestConcurrentClientOperations tests mixing Enqueue with Close operations
|
|
func TestConcurrentClientOperations(t *testing.T) {
|
|
t.Parallel()
|
|
// This tests that Close() properly waits for all pending operations
|
|
|
|
for iteration := range 5 {
|
|
t.Run(fmt.Sprintf("iteration_%d", iteration), func(t *testing.T) {
|
|
var received atomic.Int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var b batch
|
|
json.NewDecoder(r.Body).Decode(&b)
|
|
received.Add(int64(len(b.Messages)))
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewWithConfig("test-key", Config{
|
|
Endpoint: server.URL,
|
|
BatchSize: 50,
|
|
// Uses production defaults for MaxEnqueuedRequests
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Start enqueueing
|
|
enqueueCount := 50
|
|
var wg sync.WaitGroup
|
|
|
|
for i := range enqueueCount {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
client.Enqueue(Capture{
|
|
DistinctId: fmt.Sprintf("user_%d", idx),
|
|
Event: "test_event",
|
|
})
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all enqueues to complete
|
|
wg.Wait()
|
|
|
|
// Close should wait for all events to be sent
|
|
err = client.Close()
|
|
require.NoError(t, err)
|
|
|
|
// All events should have been delivered by the time Close returns
|
|
require.Equal(t, int64(enqueueCount), received.Load(),
|
|
"All events should be delivered before Close returns")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConcurrentEnqueueWithSlowServer tests behavior with a slow server
|
|
func TestConcurrentEnqueueWithSlowServer(t *testing.T) {
|
|
goroutines := 20
|
|
eventsPerGoroutine := 10
|
|
totalEvents := goroutines * eventsPerGoroutine
|
|
|
|
pool := NewEventPool(totalEvents)
|
|
|
|
var received atomic.Int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Simulate slow server
|
|
time.Sleep(10 * time.Millisecond)
|
|
var b batch
|
|
json.NewDecoder(r.Body).Decode(&b)
|
|
received.Add(int64(len(b.Messages)))
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewWithConfig("test-key", Config{
|
|
Endpoint: server.URL,
|
|
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var wg sync.WaitGroup
|
|
start := time.Now()
|
|
|
|
for g := range goroutines {
|
|
wg.Add(1)
|
|
go func(goroutineID int) {
|
|
defer wg.Done()
|
|
for i := range eventsPerGoroutine {
|
|
idx := goroutineID*eventsPerGoroutine + i
|
|
client.Enqueue(pool.Get(idx))
|
|
}
|
|
}(g)
|
|
}
|
|
|
|
wg.Wait()
|
|
client.Close()
|
|
|
|
elapsed := time.Since(start)
|
|
t.Logf("Slow server test: %d events in %v", totalEvents, elapsed)
|
|
|
|
require.Equal(t, int64(totalEvents), received.Load(),
|
|
"All events should be delivered even with slow server")
|
|
}
|
|
|
|
// TestConcurrentPrepareForSend tests that prepareForSend is thread-safe
|
|
func TestConcurrentPrepareForSend(t *testing.T) {
|
|
t.Parallel()
|
|
capture := Capture{
|
|
Type: "capture",
|
|
DistinctId: "test_user",
|
|
Event: "test_event",
|
|
Properties: generatePropertiesWithCardinality(42, CardinalityMedium),
|
|
Groups: generateGroupsWithCardinality(42, CardinalityMedium),
|
|
}
|
|
|
|
goroutines := 100
|
|
callsPerGoroutine := 100
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]int, goroutines)
|
|
|
|
for g := range goroutines {
|
|
wg.Add(1)
|
|
go func(goroutineID int) {
|
|
defer wg.Done()
|
|
var total int
|
|
for range callsPerGoroutine {
|
|
data, _, err := prepareForSend(capture)
|
|
if err != nil {
|
|
t.Errorf("prepareForSend error: %v", err)
|
|
return
|
|
}
|
|
total += len(data)
|
|
}
|
|
results[goroutineID] = total / callsPerGoroutine
|
|
}(g)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// All goroutines should get the same average result
|
|
first := results[0]
|
|
for i, result := range results {
|
|
if result != first {
|
|
t.Errorf("Inconsistent prepareForSend size: goroutine 0 got %d, goroutine %d got %d",
|
|
first, i, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConcurrentEventPoolAccess tests thread-safe access to EventPool
|
|
func TestConcurrentEventPoolAccess(t *testing.T) {
|
|
t.Parallel()
|
|
poolSize := 1000
|
|
pool := NewEventPool(poolSize)
|
|
|
|
goroutines := 50
|
|
accessesPerGoroutine := 200
|
|
|
|
var wg sync.WaitGroup
|
|
errorCount := atomic.Int64{}
|
|
|
|
for range goroutines {
|
|
wg.Go(func() {
|
|
for range accessesPerGoroutine {
|
|
event := pool.Next()
|
|
if event.DistinctId == "" || event.Event == "" {
|
|
errorCount.Add(1)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
require.Equal(t, int64(0), errorCount.Load(),
|
|
"No errors should occur during concurrent pool access")
|
|
}
|
|
|
|
// TestConcurrentCallbackExecution tests that callbacks are executed thread-safely
|
|
func TestConcurrentCallbackExecution(t *testing.T) {
|
|
t.Parallel()
|
|
callback := NewUnifiedCallback(t)
|
|
|
|
var received atomic.Int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var b batch
|
|
json.NewDecoder(r.Body).Decode(&b)
|
|
received.Add(int64(len(b.Messages)))
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewWithConfig("test-key", Config{
|
|
Endpoint: server.URL,
|
|
BatchSize: 50,
|
|
Callback: callback,
|
|
// Uses production defaults for MaxEnqueuedRequests
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
goroutines := 10
|
|
eventsPerGoroutine := 20
|
|
|
|
var wg sync.WaitGroup
|
|
for g := range goroutines {
|
|
wg.Add(1)
|
|
go func(goroutineID int) {
|
|
defer wg.Done()
|
|
for i := range eventsPerGoroutine {
|
|
client.Enqueue(Capture{
|
|
DistinctId: fmt.Sprintf("user_%d", goroutineID),
|
|
Event: fmt.Sprintf("event_%d", i),
|
|
})
|
|
}
|
|
}(g)
|
|
}
|
|
|
|
wg.Wait()
|
|
client.Close()
|
|
|
|
successCount, failureCount := callback.GetCounts()
|
|
t.Logf("Callback counts: success=%d, failure=%d", successCount, failureCount)
|
|
|
|
// All events should have been delivered with zero failures
|
|
totalEvents := int64(goroutines * eventsPerGoroutine)
|
|
require.Equal(t, totalEvents, received.Load(),
|
|
"All events should be delivered")
|
|
require.Equal(t, 0, failureCount,
|
|
"No callback failures should occur")
|
|
}
|
|
|
|
// TestRaceConditionDetection is specifically designed to trigger race detection
|
|
// Run with: go test -race -run TestRaceConditionDetection
|
|
func TestRaceConditionDetection(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("concurrent_enqueue_and_close", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer server.Close()
|
|
|
|
for range 10 {
|
|
client, _ := NewWithConfig("test-key", Config{
|
|
Endpoint: server.URL,
|
|
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
|
})
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
// Concurrent enqueues
|
|
for j := range 20 {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
client.Enqueue(Capture{
|
|
DistinctId: fmt.Sprintf("user_%d", idx),
|
|
Event: "test",
|
|
})
|
|
}(j)
|
|
}
|
|
|
|
wg.Wait()
|
|
client.Close()
|
|
}
|
|
})
|
|
|
|
t.Run("concurrent_pool_access", func(t *testing.T) {
|
|
pool := NewEventPool(100)
|
|
|
|
var wg sync.WaitGroup
|
|
for range 100 {
|
|
wg.Go(func() {
|
|
for range 50 {
|
|
_ = pool.Next()
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
})
|
|
}
|