package main import ( "context" "fmt" "math/rand" "runtime" "strconv" "strings" "sync" "sync/atomic" "time" "gitlab.michelsen.id/phillmichelsen/tessera/pkg/data" "gitlab.michelsen.id/phillmichelsen/tessera/pkg/data/routing" ) /* Realistic-ish market-data style test. Model: - 1 publisher per topic (instrument / feed partition) - Each message carries a strictly increasing sequence number (per topic) - Subscribers validate in-order, gap-free delivery - Publishers send with bursty pacing to approximate L3-ish behavior: send BurstSize messages back-to-back, then sleep to maintain AvgRate. Defaults are intentionally moderate. Increase topics/rates to stress. */ const ( NumTopics = 8 // topics/instruments/partitions SubsPerTopic = 6 // fan-out per topic RingCapacity = 1 << 14 TestDuration = 60 * time.Second AvgRatePerTopic = 500_000 // msgs/sec per topic (average) BurstSize = 512 // burst messages then sleep to preserve avg // If true, subscribers spin-poll (TryReceive). If false, blocking Receive. UseTryReceive = false ) type topicStats struct { published atomic.Uint64 } type subStats struct { received atomic.Uint64 errors atomic.Uint64 } func main() { fmt.Printf("Market-Data Routing Test\n") fmt.Printf("Topics: %d | Subs/Topic: %d | Duration: %v\n", NumTopics, SubsPerTopic, TestDuration) fmt.Printf("AvgRate/Topic: %d msg/s | BurstSize: %d | Mode: %s\n\n", AvgRatePerTopic, BurstSize, modeName()) broker := routing.NewBroker() topics := make([]string, NumTopics) for i := 0; i < NumTopics; i++ { topics[i] = fmt.Sprintf("FUT_L3_%02d", i) } // Create publishers first to size the rings. pubs := make([]routing.Publisher, NumTopics) for i := 0; i < NumTopics; i++ { pubs[i] = broker.RegisterPublisher(topics[i], RingCapacity) } // Per-topic published counters (ground truth). tStats := make([]*topicStats, NumTopics) for i := range tStats { tStats[i] = &topicStats{} } // Subscribers: attach evenly, validate ordering. var subsWG sync.WaitGroup sStats := make([][]*subStats, NumTopics) // [topic][sub] for ti := 0; ti < NumTopics; ti++ { sStats[ti] = make([]*subStats, SubsPerTopic) for si := 0; si < SubsPerTopic; si++ { sStats[ti][si] = &subStats{} } } ctx, cancel := context.WithTimeout(context.Background(), TestDuration) defer cancel() start := time.Now() for ti := 0; ti < NumTopics; ti++ { topic := topics[ti] for si := 0; si < SubsPerTopic; si++ { sub := broker.RegisterSubscriber(topic) stats := sStats[ti][si] subsWG.Add(1) go func(topicIndex int, subIndex int, subscriber routing.Subscriber, st *subStats) { defer subsWG.Done() var expected uint64 = 0 for { if ctx.Err() != nil { return } var ( env data.Envelope ok bool err error ) if UseTryReceive { env, ok, err = subscriber.TryReceive() if err != nil { st.errors.Add(1) cancel() return } if !ok { runtime.Gosched() continue } } else { env, err = subscriber.Receive(ctx) if err != nil { // Context cancellation is normal at end of test. if ctx.Err() != nil { return } st.errors.Add(1) cancel() return } } seq, parseOK := parseSeq(env) if !parseOK { st.errors.Add(1) cancel() return } if seq != expected { // Out-of-order or gap detected. st.errors.Add(1) cancel() return } expected++ st.received.Add(1) } }(ti, si, sub, stats) } } // Publishers: bursty pacing to approximate “average rate with bursts”. var pubsWG sync.WaitGroup for ti := 0; ti < NumTopics; ti++ { pub := pubs[ti] stats := tStats[ti] pubsWG.Add(1) go func(topicIndex int, p routing.Publisher, st *topicStats) { defer pubsWG.Done() // Maintain AvgRatePerTopic as an average by sleeping after bursts. // burstDuration = BurstSize / AvgRatePerTopic seconds burstNs := int64(0) if AvgRatePerTopic > 0 { burstNs = int64(time.Second) * int64(BurstSize) / int64(AvgRatePerTopic) } if burstNs <= 0 { burstNs = 1 } var seq uint64 = 0 // Optional small jitter to avoid perfect lockstep across topics. jitter := time.Duration(rand.Intn(200)) * time.Microsecond nextBurstAt := time.Now().Add(jitter) for { if ctx.Err() != nil { return } now := time.Now() if now.Before(nextBurstAt) { time.Sleep(nextBurstAt.Sub(now)) } // Send BurstSize messages back-to-back. sendTime := time.Now() for i := 0; i < BurstSize; i++ { if ctx.Err() != nil { return } env := data.Envelope{ SendTime: sendTime, Descriptor: data.Descriptor{Key: "SEQ"}, // keep your existing descriptor usage Payload: formatSeq(seq), // Any other fields you use can be set here. } p.Publish(env) seq++ } st.published.Add(uint64(BurstSize)) // Schedule next burst to maintain average rate. nextBurstAt = nextBurstAt.Add(time.Duration(burstNs)) } }(ti, pub, stats) } // Wait for timeout, then stop and drain. <-ctx.Done() // Ensure publishers exit. pubsWG.Wait() // Subscribers may still be blocked; cancel again and wait. cancel() subsWG.Wait() totalTime := time.Since(start) // Report. var totalPublished uint64 var totalReceived uint64 var totalErrors uint64 for ti := 0; ti < NumTopics; ti++ { pub := tStats[ti].published.Load() totalPublished += pub var topicRecv uint64 var topicErr uint64 for si := 0; si < SubsPerTopic; si++ { topicRecv += sStats[ti][si].received.Load() topicErr += sStats[ti][si].errors.Load() } totalReceived += topicRecv totalErrors += topicErr // Each subscriber should have received ~published for that topic. avgPerSub := uint64(0) if SubsPerTopic > 0 { avgPerSub = topicRecv / uint64(SubsPerTopic) } fmt.Printf("Topic %s: published=%d | avg_received_per_sub=%d | sub_errors=%d\n", topics[ti], pub, avgPerSub, topicErr) } pubRate := float64(totalPublished) / totalTime.Seconds() deliveriesRate := float64(totalReceived) / totalTime.Seconds() fmt.Printf("\nTotal Time: %v\n", totalTime) fmt.Printf("Total Published: %d msgs\n", totalPublished) fmt.Printf("Total Deliveries: %d (published * subs/topic, minus cancellations)\n", totalReceived) fmt.Printf("Publish Rate: %.2f msgs/sec (aggregate)\n", pubRate) fmt.Printf("Delivery Rate: %.2f deliveries/sec (aggregate)\n", deliveriesRate) fmt.Printf("Validation Errors: %d\n", totalErrors) if totalErrors == 0 { fmt.Printf("Result: PASS (in-order, gap-free until cancellation)\n") } else { fmt.Printf("Result: FAIL (see errors above; test cancels on first detected issue)\n") } } func modeName() string { if UseTryReceive { return "TryReceive (spin)" } return "Receive (blocking)" } // formatSeq encodes the per-topic sequence into a string payload. // This compiles whether Envelope.Payload is string or interface{} accepting string. func formatSeq(seq uint64) string { // Keep it cheap to parse: decimal only. return strconv.FormatUint(seq, 10) } func parseSeq(env data.Envelope) (uint64, bool) { // If you later switch Payload to a structured type, change this accordingly. s, ok := env.Payload.(string) if !ok { // If Payload is defined as string (not interface{}), remove this type assert and just use env.Payload. // This branch is for interface{} payloads where non-string could appear. return 0, false } // Fast path: no extra fields. // If you later include pubID:seq, you can parse with strings.Cut. if strings.IndexByte(s, ':') >= 0 { _, right, ok := strings.Cut(s, ":") if !ok { return 0, false } s = right } v, err := strconv.ParseUint(s, 10, 64) return v, err == nil }