Files
insights-go/benchmark_test.go
T
Eli ReismanandGitHub 8a6e9ae51e feat: refactor internal event submission pipeline and test suites (#151)
* intitial refactor:

- Add Context-based graceful shutdown and drain phase for enqueued requests
- Use buffered channels vs. mutex for concurrency mgmt.
- Use atomic.Pointer vs. locks for FF mgmt
- Use Callback ifc vs. lossy internal tracking for unit test result validation
- Add new APIs mirroring current that accept caller's Context to control
  operation timeouts

* expose batch submission timeout with default equiv to old hardcoded timeout

* restructure event submission and batching pipeline

* overhaul tests; implement new stress tests and address coverage gaps

* parallelize tests where safe to do so (3x CI time savings!)

* introduce faster pure Go JSON seralization library

* more refactors: unit tests & benchmarks

* DRY up shutdown req drain and prepareForSend serializer

* use go-json in CLI utils too; remove redundant helpers

* move error type to proper pkg

* DRY up loop() and fix deadlock in old test case

* normalize CI and Makefile test and benchmark target behavviors

* more realistic feature flags benchmark

* replace leaky time.After pattern with idiomatic safer Timers

* internal queue size minimums decoupled from batch and worker configs

* remove dead comments

* fix test bug

* update default http.Transport cfg to avoid conn pool as bottleneck; update batch enqueue limiting strategy and tests/benches

* adjust stress test defaults to match prod

* update flaky tests and rm stale file

* make graceful shutdown timeout opt-in for now

* fix tests that reverted to hold helper utils during rebase

* fix flaky test due to overhead from -race detector now default in test flags

* add more graceful shutdown test cases
2026-01-13 12:22:44 -08:00

447 lines
12 KiB
Go

package posthog
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// BenchmarkCallback is a lightweight callback for benchmarks
// Only tracks failures to avoid overhead on the success path
type BenchmarkCallback struct {
failureCount atomic.Int64
}
func (c *BenchmarkCallback) Success(msg APIMessage) {
// No-op - tracking successes would add overhead to every message
}
func (c *BenchmarkCallback) Failure(msg APIMessage, err error) {
c.failureCount.Add(1)
}
func (c *BenchmarkCallback) FailureCount() int64 {
return c.failureCount.Load()
}
// BenchmarkConcurrentEnqueue measures throughput at different concurrency levels
// Tests: 1, 10, 100, 500, 1000 concurrent goroutines
// Uses NoOpTransport to measure pure enqueue throughput without HTTP overhead
// Note: Under extreme load, some backpressure (delivery failures) is expected
func BenchmarkConcurrentEnqueue(b *testing.B) {
concurrencyLevels := []int{1, 10, 100, 500, 1000}
for _, concurrency := range concurrencyLevels {
b.Run(fmt.Sprintf("goroutines_%d", concurrency), func(b *testing.B) {
// Pre-generate events BEFORE benchmark
pool := NewEventPool(b.N + 1000) // Extra events to avoid index issues
callback := &BenchmarkCallback{}
var enqueueErrors atomic.Int64
client, _ := NewWithConfig("test-key", Config{
Transport: NoOpTransport(),
Callback: callback,
// Uses production defaults for BatchSize, MaxEnqueuedRequests
})
defer client.Close()
b.ResetTimer()
b.SetParallelism(concurrency)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if err := client.Enqueue(pool.Next()); err != nil {
enqueueErrors.Add(1)
}
}
})
b.StopTimer()
// Enqueue errors indicate channel full - fail immediately
if enqueueErrors.Load() > 0 {
b.Fatalf("Benchmark invalid: %d enqueue errors (msgs channel full)", enqueueErrors.Load())
}
// Delivery failures indicate batches channel backpressure - report but don't fail
if failures := callback.FailureCount(); failures > 0 {
b.Logf("Note: %d delivery failures (backpressure at %d goroutines)", failures, concurrency)
}
})
}
}
// BenchmarkConcurrentEnqueueWithCardinality measures throughput with different property cardinalities
// Uses NoOpTransport to measure pure enqueue throughput without HTTP overhead
// Note: Under extreme load, some backpressure (delivery failures) is expected
func BenchmarkConcurrentEnqueueWithCardinality(b *testing.B) {
cardinalities := []struct {
name string
cardinality PropertyCardinality
}{
{"low_10-100_props", CardinalityLow},
{"medium_500-2000_props", CardinalityMedium},
{"high_2000-10000_props", CardinalityHigh},
}
concurrency := 100
for _, tc := range cardinalities {
b.Run(tc.name, func(b *testing.B) {
// Use cardinality-appropriate pool size with pre-cloned unique maps
pool := NewEventPoolWithDefaultSize(tc.cardinality)
callback := &BenchmarkCallback{}
var enqueueErrors atomic.Int64
client, _ := NewWithConfig("test-key", Config{
Transport: NoOpTransport(),
Callback: callback,
// Uses production defaults for BatchSize, MaxEnqueuedRequests
})
defer client.Close()
b.ResetTimer()
b.SetParallelism(concurrency)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if err := client.Enqueue(pool.Next()); err != nil {
enqueueErrors.Add(1)
}
}
})
b.StopTimer()
// Enqueue errors indicate channel full - fail immediately
if enqueueErrors.Load() > 0 {
b.Fatalf("Benchmark invalid: %d enqueue errors (msgs channel full)", enqueueErrors.Load())
}
// Delivery failures indicate batches channel backpressure - report but don't fail
if failures := callback.FailureCount(); failures > 0 {
b.Logf("Note: %d delivery failures (backpressure at %d goroutines)", failures, concurrency)
}
})
}
}
// BenchmarkEnqueueThroughput measures raw enqueue speed (no HTTP)
// Uses production defaults for BatchSize, MaxEnqueuedRequests
func BenchmarkEnqueueThroughput(b *testing.B) {
pool := NewEventPool(b.N + 1000)
callback := &BenchmarkCallback{}
var enqueueErrors atomic.Int64
// Use a transport that returns immediately (no actual HTTP)
client, _ := NewWithConfig("test-key", Config{
Transport: NoOpTransport(),
Callback: callback,
// Uses production defaults for BatchSize, MaxEnqueuedRequests
})
defer client.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := client.Enqueue(pool.Next()); err != nil {
enqueueErrors.Add(1)
}
}
b.StopTimer()
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
enqueueErrors.Load(), failures)
}
}
// BenchmarkEnqueueThroughputWithCardinality measures enqueue speed at different cardinalities
// Uses production defaults for BatchSize, MaxEnqueuedRequests
func BenchmarkEnqueueThroughputWithCardinality(b *testing.B) {
cardinalities := []struct {
name string
cardinality PropertyCardinality
}{
{"low", CardinalityLow},
{"medium", CardinalityMedium},
{"high", CardinalityHigh},
}
for _, tc := range cardinalities {
b.Run(tc.name, func(b *testing.B) {
// Use cardinality-appropriate pool size with pre-cloned unique maps
pool := NewEventPoolWithDefaultSize(tc.cardinality)
callback := &BenchmarkCallback{}
var enqueueErrors atomic.Int64
client, _ := NewWithConfig("test-key", Config{
Transport: NoOpTransport(),
Callback: callback,
// Uses production defaults for BatchSize, MaxEnqueuedRequests
})
defer client.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := client.Enqueue(pool.Next()); err != nil {
enqueueErrors.Add(1)
}
}
b.StopTimer()
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
enqueueErrors.Load(), failures)
}
})
}
}
// BenchmarkFeatureFlagLocalEvaluation measures flag evaluation performance
func BenchmarkFeatureFlagLocalEvaluation(b *testing.B) {
// Setup server that returns actual flag definitions
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation"):
w.Write([]byte(fixture("test-api-feature-flag.json")))
case strings.HasPrefix(r.URL.Path, "/batch"):
io.Copy(io.Discard, r.Body)
w.WriteHeader(200)
default:
w.WriteHeader(404)
}
}))
defer server.Close()
client, _ := NewWithConfig("test-key", Config{
PersonalApiKey: "test",
Endpoint: server.URL,
})
defer client.Close()
// Pre-warm cache - wait for flags to load
for i := 0; i < 10; i++ {
result, _ := client.GetFeatureFlag(FeatureFlagPayload{Key: "simpleFlag", DistinctId: "warmup"})
if result != nil {
break
}
}
distinctIds := make([]string, 1000)
for i := range distinctIds {
distinctIds[i] = fmt.Sprintf("user_%d", i)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
client.GetFeatureFlag(FeatureFlagPayload{
Key: "simpleFlag",
DistinctId: distinctIds[i%len(distinctIds)],
})
}
}
// BenchmarkBatchSizes measures throughput at various batch sizes
// Note: BatchSize=1 is excluded as it's pathological - creates one batch per event,
// overwhelming the worker pool with backpressure. Production min should be 10+.
func BenchmarkBatchSizes(b *testing.B) {
batchSizes := []int{10, 50, 100, 250, 500}
for _, batchSize := range batchSizes {
b.Run(fmt.Sprintf("batch_%d", batchSize), func(b *testing.B) {
pool := NewEventPool(b.N + 1000)
callback := &BenchmarkCallback{}
var enqueueErrors atomic.Int64
client, _ := NewWithConfig("test-key", Config{
Transport: NoOpTransport(),
Callback: callback,
BatchSize: batchSize,
})
defer client.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := client.Enqueue(pool.Next()); err != nil {
enqueueErrors.Add(1)
}
}
b.StopTimer()
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
enqueueErrors.Load(), failures)
}
})
}
}
// BenchmarkEndToEndWithServer measures end-to-end throughput including HTTP
// Uses production defaults for BatchSize, MaxEnqueuedRequests
func BenchmarkEndToEndWithServer(b *testing.B) {
pool := NewEventPool(b.N + 1000)
server := httptest.NewServer(NoOpHandler())
defer server.Close()
callback := &BenchmarkCallback{}
var enqueueErrors atomic.Int64
client, _ := NewWithConfig("test-key", Config{
Endpoint: server.URL,
Callback: callback,
// Uses production defaults for BatchSize, MaxEnqueuedRequests
})
defer client.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := client.Enqueue(pool.Next()); err != nil {
enqueueErrors.Add(1)
}
}
b.StopTimer()
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
enqueueErrors.Load(), failures)
}
}
// NoOpHandler returns an HTTP handler that discards the body and returns 200
func NoOpHandler() noOpHandler {
return noOpHandler{}
}
type noOpHandler struct{}
func (h noOpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body)
w.WriteHeader(200)
}
// BenchmarkEventPoolGeneration measures event pool creation time
func BenchmarkEventPoolGeneration(b *testing.B) {
sizes := []int{100, 1000, 10000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
NewEventPool(size)
}
})
}
}
// BenchmarkEventPoolGenerationWithCardinality measures pool creation with cardinality
func BenchmarkEventPoolGenerationWithCardinality(b *testing.B) {
cardinalities := []struct {
name string
cardinality PropertyCardinality
}{
{"low", CardinalityLow},
{"medium", CardinalityMedium},
{"high", CardinalityHigh},
}
size := 100 // Keep small for high cardinality
for _, tc := range cardinalities {
b.Run(tc.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
NewEventPoolWithCardinality(size, tc.cardinality)
}
})
}
}
// BenchmarkValidation measures message validation overhead
func BenchmarkValidation(b *testing.B) {
capture := Capture{
DistinctId: "user_1",
Event: "test_event",
Properties: generateVariedProperties(42),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
capture.Validate()
}
}
// BenchmarkAPIfy measures APIfy conversion overhead
func BenchmarkAPIfy(b *testing.B) {
cardinalities := []struct {
name string
cardinality PropertyCardinality
}{
{"low", CardinalityLow},
{"medium", CardinalityMedium},
{"high", CardinalityHigh},
}
for _, tc := range cardinalities {
b.Run(tc.name, func(b *testing.B) {
capture := Capture{
DistinctId: "user_1",
Event: "test_event",
Properties: generatePropertiesWithCardinality(42, tc.cardinality),
Groups: generateGroupsWithCardinality(42, tc.cardinality),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
capture.APIfy()
}
})
}
}
// BenchmarkMessageEnqueueOverhead measures the overhead of the Enqueue path
func BenchmarkMessageEnqueueOverhead(b *testing.B) {
callback := &BenchmarkCallback{}
var enqueueErrors atomic.Int64
// Create a client with a very fast transport
client, _ := NewWithConfig("test-key", Config{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
io.Copy(io.Discard, r.Body)
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader("")),
}, nil
}),
Callback: callback,
BatchSize: 10000, // Large batch to avoid flushing during benchmark
})
defer client.Close()
// Pre-generate captures to avoid map race conditions
// Each iteration needs its own Properties map since APIfy iterates over it
captures := make([]Capture, b.N)
for i := range captures {
captures[i] = Capture{
DistinctId: "user_1",
Event: "test_event",
Properties: generateVariedProperties(i),
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := client.Enqueue(captures[i]); err != nil {
enqueueErrors.Add(1)
}
}
b.StopTimer()
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
enqueueErrors.Load(), failures)
}
}