//go:build integration package pgmapbroker import ( "context" "fmt" "sync" "sync/atomic" "testing" "time" "github.com/centrifugal/centrifuge" ) // setupBench creates a broker with Persistent mode for standard benchmarks. func setupBench(b *testing.B) (*PostgresMapBroker, func()) { b.Helper() connString := getPostgresConnString(b) node, _ := centrifuge.New(centrifuge.Config{ Map: centrifuge.MapConfig{ GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions { return centrifuge.MapChannelOptions{ Mode: centrifuge.MapModePersistent, } }, }, }) broker, err := NewPostgresMapBroker(node, PostgresMapBrokerConfig{ DSN: connString, BinaryData: true, PartitionRetentionDays: 7, }) if err != nil { b.Fatal(err) } ctx := context.Background() if err := broker.EnsureSchema(ctx); err != nil { b.Fatal(err) } _ = broker.RegisterEventHandler(nil) cleanupTestTables(ctx, broker) return broker, func() { _ = broker.Close(context.Background()) _ = node.Shutdown(context.Background()) } } // setupBenchOutbox creates a broker with outbox workers running. func setupBenchOutbox(b *testing.B, handler centrifuge.BrokerEventHandler) (*PostgresMapBroker, func()) { b.Helper() connString := getPostgresConnString(b) node, _ := centrifuge.New(centrifuge.Config{ Map: centrifuge.MapConfig{ GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions { return centrifuge.MapChannelOptions{ Mode: centrifuge.MapModePersistent, } }, }, }) if err := node.Run(); err != nil { b.Fatal(err) } broker, err := NewPostgresMapBroker(node, PostgresMapBrokerConfig{ DSN: connString, BinaryData: true, PoolSize: 32, NumShards: 8, PartitionRetentionDays: 7, Outbox: OutboxConfig{ PollInterval: 5 * time.Millisecond, BatchSize: 1000, }, }) if err != nil { b.Fatal(err) } ctx := context.Background() if err := broker.EnsureSchema(ctx); err != nil { b.Fatal(err) } cleanupTestTables(ctx, broker) _ = broker.RegisterEventHandler(handler) return broker, func() { _ = broker.Close(context.Background()) _ = node.Shutdown(context.Background()) } } // ============================================================================ // Publish benchmarks // ============================================================================ // BenchmarkPostgresMapBroker_Publish measures the core map publish path: // shard lock → meta UPSERT → state UPSERT → stream INSERT → NOTIFY. // All publish benchmarks go through the same cf_map_publish SQL function. func BenchmarkPostgresMapBroker_Publish(b *testing.B) { broker, cleanup := setupBench(b) defer cleanup() ctx := context.Background() b.ReportAllocs() b.ResetTimer() var counter int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { i := atomic.AddInt64(&counter, 1) ch := fmt.Sprintf("bench_publish_%d", i%1000) key := fmt.Sprintf("key%d", i%1000) _, err := broker.Publish(ctx, ch, key, centrifuge.MapPublishOptions{ Data: []byte("x"), }) if err != nil { b.Fatal(err) } } }) } // BenchmarkPostgresMapBroker_PublishIdempotent measures publish with // idempotency key check (extra lookup + conditional insert). func BenchmarkPostgresMapBroker_PublishIdempotent(b *testing.B) { broker, cleanup := setupBench(b) defer cleanup() ctx := context.Background() b.ReportAllocs() b.ResetTimer() var counter int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { i := atomic.AddInt64(&counter, 1) ch := fmt.Sprintf("bench_idempotent_%d", i%1000) key := fmt.Sprintf("key_%d", i) _, err := broker.Publish(ctx, ch, "", centrifuge.MapPublishOptions{ Data: []byte(key), IdempotencyKey: key, IdempotentResultTTL: 60 * time.Second, }) if err != nil { b.Fatal(err) } } }) } // BenchmarkPostgresMapBroker_PublishCAS measures Compare-And-Swap: read // current state + conditional publish. Includes both the read and write // round-trips. func BenchmarkPostgresMapBroker_PublishCAS(b *testing.B) { broker, cleanup := setupBench(b) defer cleanup() ctx := context.Background() channel := "bench_cas" // Seed the key. _, err := broker.Publish(ctx, channel, "counter", centrifuge.MapPublishOptions{ Data: []byte("0"), }) if err != nil { b.Fatal(err) } b.ReportAllocs() b.ResetTimer() b.RunParallel(func(pb *testing.PB) { for pb.Next() { stateRes, err := broker.ReadState(ctx, channel, centrifuge.MapReadStateOptions{ Key: "counter", }) if err != nil || len(stateRes.Publications) == 0 { continue } pos := centrifuge.StreamPosition{ Offset: stateRes.Publications[0].Offset, Epoch: stateRes.Position.Epoch, } _, _ = broker.Publish(ctx, channel, "counter", centrifuge.MapPublishOptions{ Data: []byte("updated"), ExpectedPosition: &pos, }) } }) } // ============================================================================ // Read benchmarks // ============================================================================ // BenchmarkPostgresMapBroker_ReadStream measures reading publications from // the stream table (the change log used for recovery). func BenchmarkPostgresMapBroker_ReadStream(b *testing.B) { broker, cleanup := setupBench(b) defer cleanup() ctx := context.Background() channel := "bench_read_stream" // Seed 100 publications. for i := 0; i < 100; i++ { _, err := broker.Publish(ctx, channel, fmt.Sprintf("k%d", i), centrifuge.MapPublishOptions{ Data: []byte("data"), }) if err != nil { b.Fatal(err) } } b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { _, err := broker.ReadStream(ctx, channel, centrifuge.MapReadStreamOptions{ Filter: centrifuge.StreamFilter{Limit: 50}, }) if err != nil { b.Fatal(err) } } } // BenchmarkPostgresMapBroker_ReadState measures reading the full key-value // snapshot of a channel. func BenchmarkPostgresMapBroker_ReadState(b *testing.B) { broker, cleanup := setupBench(b) defer cleanup() ctx := context.Background() channel := "bench_read_state" for i := 0; i < 100; i++ { _, err := broker.Publish(ctx, channel, fmt.Sprintf("k%d", i), centrifuge.MapPublishOptions{ Data: []byte("data"), }) if err != nil { b.Fatal(err) } } b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { _, err := broker.ReadState(ctx, channel, centrifuge.MapReadStateOptions{ Limit: 100, }) if err != nil { b.Fatal(err) } } } // BenchmarkPostgresMapBroker_ReadStatePaginated measures paginated state // reads (typical for large channels with cursor-based iteration). func BenchmarkPostgresMapBroker_ReadStatePaginated(b *testing.B) { broker, cleanup := setupBench(b) defer cleanup() ctx := context.Background() channel := "bench_read_state_paged" for i := 0; i < 100; i++ { _, err := broker.Publish(ctx, channel, fmt.Sprintf("k%d", i), centrifuge.MapPublishOptions{ Data: []byte("data"), }) if err != nil { b.Fatal(err) } } b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { _, err := broker.ReadState(ctx, channel, centrifuge.MapReadStateOptions{ Limit: 10, }) if err != nil { b.Fatal(err) } } } // BenchmarkPostgresMapBroker_Stats measures the channel statistics query. func BenchmarkPostgresMapBroker_Stats(b *testing.B) { broker, cleanup := setupBench(b) defer cleanup() ctx := context.Background() channel := "bench_stats" for i := 0; i < 50; i++ { _, _ = broker.Publish(ctx, channel, fmt.Sprintf("k%d", i), centrifuge.MapPublishOptions{ Data: []byte("data"), }) } b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { _, err := broker.Stats(ctx, channel) if err != nil { b.Fatal(err) } } } // BenchmarkPostgresMapBroker_Remove measures key removal. func BenchmarkPostgresMapBroker_Remove(b *testing.B) { broker, cleanup := setupBench(b) defer cleanup() ctx := context.Background() b.ReportAllocs() b.ResetTimer() var counter int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { i := atomic.AddInt64(&counter, 1) ch := fmt.Sprintf("bench_remove_%d", i%1000) key := fmt.Sprintf("key%d", i%1000) // Ensure key exists, then remove it. _, _ = broker.Publish(ctx, ch, key, centrifuge.MapPublishOptions{ Data: []byte("x"), }) _, err := broker.Remove(ctx, ch, key, centrifuge.MapRemoveOptions{}) if err != nil { b.Fatal(err) } } }) } // ============================================================================ // Outbox delivery benchmarks // ============================================================================ // BenchmarkPostgresMapBroker_OutboxPublish measures publish latency with // the outbox worker running (parallel publishers across many channels). func BenchmarkPostgresMapBroker_OutboxPublish(b *testing.B) { broker, cleanup := setupBenchOutbox(b, nil) defer cleanup() ctx := context.Background() b.ReportAllocs() b.ResetTimer() var counter int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { i := atomic.AddInt64(&counter, 1) ch := fmt.Sprintf("bench_outbox_%d", i%1000) key := fmt.Sprintf("key%d", i%1000) _, err := broker.Publish(ctx, ch, key, centrifuge.MapPublishOptions{ Data: []byte("x"), }) if err != nil { b.Fatal(err) } } }) } // BenchmarkPostgresMapBroker_OutboxThroughput measures end-to-end outbox // throughput: 8 publisher goroutines + outbox workers delivering to a handler. // Verifies per-channel offset ordering as a side effect. func BenchmarkPostgresMapBroker_OutboxThroughput(b *testing.B) { ctx := context.Background() prefix := fmt.Sprintf("bench_tp_%d_", time.Now().UnixNano()) var delivered int64 broker, cleanup := setupBenchOutbox(b, &testBrokerEventHandler{ HandlePublicationFunc: func(ch string, pub *centrifuge.Publication, sp centrifuge.StreamPosition, delta bool, prevPub *centrifuge.Publication) error { // Only count publications from this bench run's channels. if len(ch) < len(prefix) || ch[:len(prefix)] != prefix { return nil } atomic.AddInt64(&delivered, 1) return nil }, }) defer cleanup() time.Sleep(100 * time.Millisecond) // let workers start b.ReportAllocs() b.ResetTimer() var published int64 var counter int64 var wg sync.WaitGroup const numPublishers = 8 perPublisher := b.N / numPublishers remainder := b.N % numPublishers for p := 0; p < numPublishers; p++ { n := perPublisher if p < remainder { n++ } if n == 0 { continue } wg.Add(1) go func(count int) { defer wg.Done() for j := 0; j < count; j++ { i := atomic.AddInt64(&counter, 1) ch := fmt.Sprintf("%sch%d", prefix, i%100) key := fmt.Sprintf("key%d", i) _, err := broker.Publish(ctx, ch, key, centrifuge.MapPublishOptions{ Data: []byte("x"), }) if err != nil { b.Error(err) return } atomic.AddInt64(&published, 1) } }(n) } wg.Wait() want := atomic.LoadInt64(&published) deadline := time.NewTimer(5 * time.Second) defer deadline.Stop() for { if atomic.LoadInt64(&delivered) >= want { break } select { case <-deadline.C: b.Fatalf("timeout: delivered %d/%d", atomic.LoadInt64(&delivered), want) default: time.Sleep(time.Millisecond) } } b.StopTimer() } // BenchmarkPostgresMapBroker_OutboxLatency measures publish-to-delivery // latency via the outbox. Reports average latency as a custom metric. func BenchmarkPostgresMapBroker_OutboxLatency(b *testing.B) { ctx := context.Background() channel := fmt.Sprintf("bench_latency_%d", time.Now().UnixNano()) publishTimes := make([]time.Time, b.N+2) delivered := make(chan time.Duration, b.N) var mu sync.Mutex broker, cleanup := setupBenchOutbox(b, &testBrokerEventHandler{ HandlePublicationFunc: func(ch string, pub *centrifuge.Publication, sp centrifuge.StreamPosition, delta bool, prevPub *centrifuge.Publication) error { if ch != channel { return nil } mu.Lock() var t time.Time if int(sp.Offset) < len(publishTimes) { t = publishTimes[sp.Offset] } mu.Unlock() if !t.IsZero() { delivered <- time.Since(t) } return nil }, }) defer cleanup() time.Sleep(100 * time.Millisecond) // let workers start b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { mu.Lock() now := time.Now() res, err := broker.Publish(ctx, channel, fmt.Sprintf("key%d", i), centrifuge.MapPublishOptions{ Data: []byte("x"), }) if err != nil { mu.Unlock() b.Fatal(err) } if int(res.Position.Offset) < len(publishTimes) { publishTimes[res.Position.Offset] = now } mu.Unlock() } deadline := time.NewTimer(30 * time.Second) defer deadline.Stop() var totalLatency time.Duration received := 0 for received < b.N { select { case d := <-delivered: totalLatency += d received++ case <-deadline.C: b.Fatalf("timeout: received %d/%d", received, b.N) } } b.StopTimer() if b.N > 0 { avg := totalLatency / time.Duration(b.N) b.ReportMetric(float64(avg.Microseconds()), "µs/delivery") } } // ============================================================================ // Cleanup benchmark // ============================================================================ // BenchmarkPostgresMapBroker_Cleanup measures TTL-based key expiration // throughput via the expire_keys SQL function. Sub-benchmarks cover // different key counts. func BenchmarkPostgresMapBroker_Cleanup(b *testing.B) { for _, numKeys := range []int{100, 1000} { b.Run(fmt.Sprintf("keys_%d", numKeys), func(b *testing.B) { connString := getPostgresConnString(b) node, _ := centrifuge.New(centrifuge.Config{ Map: centrifuge.MapConfig{ GetMapChannelOptions: func(channel string) centrifuge.MapChannelOptions { return centrifuge.MapChannelOptions{ Mode: centrifuge.MapModeRecoverable, KeyTTL: time.Millisecond, } }, }, }) broker, err := NewPostgresMapBroker(node, PostgresMapBrokerConfig{ DSN: connString, BinaryData: true, PartitionRetentionDays: 7, }) if err != nil { b.Fatal(err) } ctx := context.Background() if err := broker.EnsureSchema(ctx); err != nil { b.Fatal(err) } _ = broker.RegisterEventHandler(nil) defer func() { _ = broker.Close(context.Background()) _ = node.Shutdown(context.Background()) }() b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { b.StopTimer() channel := fmt.Sprintf("bench_cleanup_%d_%d", time.Now().UnixNano(), i) for k := 0; k < numKeys; k++ { _, err := broker.Publish(ctx, channel, fmt.Sprintf("key_%d", k), centrifuge.MapPublishOptions{ Data: []byte("data"), }) if err != nil { b.Fatal(err) } } time.Sleep(5 * time.Millisecond) b.StartTimer() numShards := broker.conf.NumShards var metaTTL *string _, err := broker.pool.Exec(ctx, fmt.Sprintf(` SELECT %s($1, $2, $3::interval, $4) `, broker.names.expireKeys), 1000, numShards, metaTTL, channel) if err != nil { b.Fatal(err) } } b.ReportMetric(float64(numKeys), "keys/op") }) } }