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
							 | 
						|
								}
							 |