You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
281 lines
8.2 KiB
281 lines
8.2 KiB
package tracker
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Record represents a tracked message
|
|
type Record struct {
|
|
Key string `json:"key"`
|
|
Topic string `json:"topic"`
|
|
Partition int32 `json:"partition"`
|
|
Offset int64 `json:"offset"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
ProducerID int `json:"producer_id,omitempty"`
|
|
ConsumerID int `json:"consumer_id,omitempty"`
|
|
}
|
|
|
|
// Tracker tracks produced and consumed records
|
|
type Tracker struct {
|
|
mu sync.Mutex
|
|
producedRecords []Record
|
|
consumedRecords []Record
|
|
producedFile string
|
|
consumedFile string
|
|
testStartTime int64 // Unix timestamp in nanoseconds - used to filter old messages
|
|
testRunPrefix string // Key prefix for this test run (e.g., "run-20251015-170150")
|
|
filteredOldCount int // Count of old messages consumed but not tracked
|
|
}
|
|
|
|
// NewTracker creates a new record tracker
|
|
func NewTracker(producedFile, consumedFile string, testStartTime int64) *Tracker {
|
|
// Generate test run prefix from start time using same format as producer
|
|
// Producer format: p.startTime.Format("20060102-150405") -> "20251015-170859"
|
|
startTime := time.Unix(0, testStartTime)
|
|
runID := startTime.Format("20060102-150405")
|
|
testRunPrefix := fmt.Sprintf("run-%s", runID)
|
|
|
|
fmt.Printf("Tracker initialized with prefix: %s (filtering messages not matching this prefix)\n", testRunPrefix)
|
|
|
|
return &Tracker{
|
|
producedRecords: make([]Record, 0, 100000),
|
|
consumedRecords: make([]Record, 0, 100000),
|
|
producedFile: producedFile,
|
|
consumedFile: consumedFile,
|
|
testStartTime: testStartTime,
|
|
testRunPrefix: testRunPrefix,
|
|
filteredOldCount: 0,
|
|
}
|
|
}
|
|
|
|
// TrackProduced records a produced message
|
|
func (t *Tracker) TrackProduced(record Record) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
t.producedRecords = append(t.producedRecords, record)
|
|
}
|
|
|
|
// TrackConsumed records a consumed message
|
|
// Only tracks messages from the current test run (filters out old messages from previous tests)
|
|
func (t *Tracker) TrackConsumed(record Record) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
// Filter: Only track messages from current test run based on key prefix
|
|
// Producer keys look like: "run-20251015-170150-key-123"
|
|
// We only want messages that match our test run prefix
|
|
if !strings.HasPrefix(record.Key, t.testRunPrefix) {
|
|
// Count old messages consumed but not tracked
|
|
t.filteredOldCount++
|
|
return
|
|
}
|
|
|
|
t.consumedRecords = append(t.consumedRecords, record)
|
|
}
|
|
|
|
// SaveProduced writes produced records to file
|
|
func (t *Tracker) SaveProduced() error {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
f, err := os.Create(t.producedFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create produced file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
encoder := json.NewEncoder(f)
|
|
for _, record := range t.producedRecords {
|
|
if err := encoder.Encode(record); err != nil {
|
|
return fmt.Errorf("failed to encode produced record: %v", err)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("Saved %d produced records to %s\n", len(t.producedRecords), t.producedFile)
|
|
return nil
|
|
}
|
|
|
|
// SaveConsumed writes consumed records to file
|
|
func (t *Tracker) SaveConsumed() error {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
f, err := os.Create(t.consumedFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create consumed file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
encoder := json.NewEncoder(f)
|
|
for _, record := range t.consumedRecords {
|
|
if err := encoder.Encode(record); err != nil {
|
|
return fmt.Errorf("failed to encode consumed record: %v", err)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("Saved %d consumed records to %s\n", len(t.consumedRecords), t.consumedFile)
|
|
return nil
|
|
}
|
|
|
|
// Compare compares produced and consumed records
|
|
func (t *Tracker) Compare() ComparisonResult {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
result := ComparisonResult{
|
|
TotalProduced: len(t.producedRecords),
|
|
TotalConsumed: len(t.consumedRecords),
|
|
FilteredOldCount: t.filteredOldCount,
|
|
}
|
|
|
|
// Build maps for efficient lookup
|
|
producedMap := make(map[string]Record)
|
|
for _, record := range t.producedRecords {
|
|
key := fmt.Sprintf("%s-%d-%d", record.Topic, record.Partition, record.Offset)
|
|
producedMap[key] = record
|
|
}
|
|
|
|
consumedMap := make(map[string]int)
|
|
duplicateKeys := make(map[string][]Record)
|
|
|
|
for _, record := range t.consumedRecords {
|
|
key := fmt.Sprintf("%s-%d-%d", record.Topic, record.Partition, record.Offset)
|
|
consumedMap[key]++
|
|
|
|
if consumedMap[key] > 1 {
|
|
duplicateKeys[key] = append(duplicateKeys[key], record)
|
|
}
|
|
}
|
|
|
|
// Find missing records (produced but not consumed)
|
|
for key, record := range producedMap {
|
|
if _, found := consumedMap[key]; !found {
|
|
result.Missing = append(result.Missing, record)
|
|
}
|
|
}
|
|
|
|
// Find duplicate records (consumed multiple times)
|
|
for key, records := range duplicateKeys {
|
|
if len(records) > 0 {
|
|
// Add first occurrence for context
|
|
result.Duplicates = append(result.Duplicates, DuplicateRecord{
|
|
Record: records[0],
|
|
Count: consumedMap[key],
|
|
})
|
|
}
|
|
}
|
|
|
|
result.MissingCount = len(result.Missing)
|
|
result.DuplicateCount = len(result.Duplicates)
|
|
result.UniqueConsumed = result.TotalConsumed - sumDuplicates(result.Duplicates)
|
|
|
|
return result
|
|
}
|
|
|
|
// ComparisonResult holds the comparison results
|
|
type ComparisonResult struct {
|
|
TotalProduced int
|
|
TotalConsumed int
|
|
UniqueConsumed int
|
|
MissingCount int
|
|
DuplicateCount int
|
|
FilteredOldCount int // Old messages consumed but filtered out
|
|
Missing []Record
|
|
Duplicates []DuplicateRecord
|
|
}
|
|
|
|
// DuplicateRecord represents a record consumed multiple times
|
|
type DuplicateRecord struct {
|
|
Record Record
|
|
Count int
|
|
}
|
|
|
|
// PrintSummary prints a summary of the comparison
|
|
func (r *ComparisonResult) PrintSummary() {
|
|
fmt.Println("\n" + strings.Repeat("=", 70))
|
|
fmt.Println(" MESSAGE VERIFICATION RESULTS")
|
|
fmt.Println(strings.Repeat("=", 70))
|
|
|
|
fmt.Printf("\nProduction Summary:\n")
|
|
fmt.Printf(" Total Produced: %d messages\n", r.TotalProduced)
|
|
|
|
fmt.Printf("\nConsumption Summary:\n")
|
|
fmt.Printf(" Total Consumed: %d messages (from current test)\n", r.TotalConsumed)
|
|
fmt.Printf(" Unique Consumed: %d messages\n", r.UniqueConsumed)
|
|
fmt.Printf(" Duplicate Reads: %d messages\n", r.TotalConsumed-r.UniqueConsumed)
|
|
if r.FilteredOldCount > 0 {
|
|
fmt.Printf(" Filtered Old: %d messages (from previous tests, not tracked)\n", r.FilteredOldCount)
|
|
}
|
|
|
|
fmt.Printf("\nVerification Results:\n")
|
|
if r.MissingCount == 0 {
|
|
fmt.Printf(" ✅ Missing Records: 0 (all messages delivered)\n")
|
|
} else {
|
|
fmt.Printf(" ❌ Missing Records: %d (data loss detected!)\n", r.MissingCount)
|
|
}
|
|
|
|
if r.DuplicateCount == 0 {
|
|
fmt.Printf(" ✅ Duplicate Records: 0 (no duplicates)\n")
|
|
} else {
|
|
duplicatePercent := float64(r.TotalConsumed-r.UniqueConsumed) * 100.0 / float64(r.TotalProduced)
|
|
fmt.Printf(" ⚠️ Duplicate Records: %d unique messages read multiple times (%.1f%%)\n",
|
|
r.DuplicateCount, duplicatePercent)
|
|
}
|
|
|
|
fmt.Printf("\nDelivery Guarantee:\n")
|
|
if r.MissingCount == 0 && r.DuplicateCount == 0 {
|
|
fmt.Printf(" ✅ EXACTLY-ONCE: All messages delivered exactly once\n")
|
|
} else if r.MissingCount == 0 {
|
|
fmt.Printf(" ✅ AT-LEAST-ONCE: All messages delivered (some duplicates)\n")
|
|
} else {
|
|
fmt.Printf(" ❌ AT-MOST-ONCE: Some messages lost\n")
|
|
}
|
|
|
|
// Print sample of missing records (up to 10)
|
|
if len(r.Missing) > 0 {
|
|
fmt.Printf("\nSample Missing Records (first 10 of %d):\n", len(r.Missing))
|
|
for i, record := range r.Missing {
|
|
if i >= 10 {
|
|
break
|
|
}
|
|
fmt.Printf(" - %s[%d]@%d (key=%s)\n",
|
|
record.Topic, record.Partition, record.Offset, record.Key)
|
|
}
|
|
}
|
|
|
|
// Print sample of duplicate records (up to 10)
|
|
if len(r.Duplicates) > 0 {
|
|
fmt.Printf("\nSample Duplicate Records (first 10 of %d):\n", len(r.Duplicates))
|
|
// Sort by count descending
|
|
sorted := make([]DuplicateRecord, len(r.Duplicates))
|
|
copy(sorted, r.Duplicates)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
return sorted[i].Count > sorted[j].Count
|
|
})
|
|
|
|
for i, dup := range sorted {
|
|
if i >= 10 {
|
|
break
|
|
}
|
|
fmt.Printf(" - %s[%d]@%d (key=%s, read %d times)\n",
|
|
dup.Record.Topic, dup.Record.Partition, dup.Record.Offset,
|
|
dup.Record.Key, dup.Count)
|
|
}
|
|
}
|
|
|
|
fmt.Println(strings.Repeat("=", 70))
|
|
}
|
|
|
|
func sumDuplicates(duplicates []DuplicateRecord) int {
|
|
sum := 0
|
|
for _, dup := range duplicates {
|
|
sum += dup.Count - 1 // Don't count the first occurrence
|
|
}
|
|
return sum
|
|
}
|