package main import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "time" "github.com/segmentio/kafka-go" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" ) func statTestFieldsEnvelope(env envelope.FrameEnvelope) envelope.FrameEnvelope { if env.EventKind == "" { env.EventKind = envelope.EventKindFields } if env.FieldMapping == "" { env.FieldMapping = "test-field-mapping.v1" } if env.ParseStatus == "" { env.ParseStatus = envelope.ParseOK } if len(env.Fields) == 0 { switch env.Protocol { case envelope.ProtocolGB32960: env.Fields = map[string]any{"gb32960.vehicle.total_mileage_km": 4123.9} case envelope.ProtocolYutongMQTT: env.Fields = map[string]any{"yutong_mqtt.data.total_mileage": 4123900} default: env.Fields = map[string]any{"jt808.location.total_mileage_km": 4123.9} } } return env } func marshalStatTestFieldsEnvelope(t *testing.T, env envelope.FrameEnvelope) []byte { t.Helper() payload, err := json.Marshal(statTestFieldsEnvelope(env)) if err != nil { t.Fatal(err) } return payload } func TestProcessStatMessageUsesUncancelledContextForAppendAndCommit(t *testing.T) { parent, cancel := context.WithCancel(context.Background()) cancel() env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", Phone: "13307795425", EventTimeMS: 1782918600000, ReceivedAtMS: 1782918600000, ParseStatus: envelope.ParseOK, } payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } appender := &contextCheckingStatAppender{} committer := &contextCheckingStatCommitter{} processStatMessage(parent, discardStatLogger{}, nil, appender, committer, kafka.Message{Value: payload}) if appender.ctxErr != nil { t.Fatalf("appender saw cancelled context: %v", appender.ctxErr) } if committer.ctxErr != nil { t.Fatalf("committer saw cancelled context: %v", committer.ctxErr) } if appender.count != 1 || committer.count != 1 { t.Fatalf("appends=%d commits=%d", appender.count, committer.count) } } func TestProcessStatMessageRecordsMetrics(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200", ReceivedAtMS: time.Now().Add(-time.Second).UnixMilli()} payload := marshalStatTestFieldsEnvelope(t, env) registry := metrics.NewRegistry() processStatMessage( context.Background(), discardStatLogger{}, registry, &contextCheckingStatAppender{result: stats.AppendResult{ SamplesFound: 1, SamplesWritten: 1, SamplesSkippedMissingFields: 3, SamplesSkippedNonMileageFrame: 4, SamplesSkippedSameMileage: 2, SamplesAdjustedFutureEventTime: 1, SourceTouchesAttempted: 1, SourceTouchesWritten: 1, SourceTouchesSkippedThrottled: 2, SourceTouchesSkippedMissing: 3, ProjectionsAttempted: 1, ProjectionsWritten: 1, ProjectionsSkippedThrottled: 2, }, cacheStats: stats.CacheStats{ LastTotalMileageEntries: 300, LastSourceSeenEntries: 3, LastProjectionEntries: 280, PendingProjectionEntries: 12, BaselineEntries: 295, MaxEntries: 1000000, LastCleanupAt: time.Unix(1782918600, 0), LastCleanupTotalMileage: 11, LastCleanupSourceSeen: 1, LastCleanupProjection: 9, LastCleanupBaseline: 10, TotalMileageEvictions: 4, SourceSeenEvictions: 1, ProjectionEvictions: 2, BaselineEvictions: 3, }}, &contextCheckingStatCommitter{}, kafka.Message{Topic: "vehicle.fields.go.jt808.v1", Partition: 4, Offset: 30, HighWaterMark: 41, Value: payload}, ) text := registry.Render() for _, want := range []string{ `vehicle_stat_kafka_messages_total{status="received",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_writes_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_last_message_unix_seconds{status="received",topic="vehicle.fields.go.jt808.v1"} `, `vehicle_stat_last_write_unix_seconds{status="ok",topic="vehicle.fields.go.jt808.v1"} `, `vehicle_stat_write_e2e_duration_ms_histogram_count{topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_write_e2e_recent_p99_ms{topic="vehicle.fields.go.jt808.v1"} `, `vehicle_stat_write_e2e_recent_samples{topic="vehicle.fields.go.jt808.v1"} `, `vehicle_stat_last_write_e2e_unix_seconds{topic="vehicle.fields.go.jt808.v1"} `, `vehicle_stat_samples_total{protocol="JT808",status="found",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_samples_total{protocol="JT808",status="written",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_samples_total{protocol="JT808",status="skipped_missing_fields",topic="vehicle.fields.go.jt808.v1"} 3`, `vehicle_stat_samples_total{protocol="JT808",status="skipped_non_mileage_frame",topic="vehicle.fields.go.jt808.v1"} 4`, `vehicle_stat_samples_total{protocol="JT808",status="skipped_same_mileage",topic="vehicle.fields.go.jt808.v1"} 2`, `vehicle_stat_samples_total{protocol="JT808",status="event_time_future_adjusted",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_sources_total{protocol="JT808",status="attempted",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_sources_total{protocol="JT808",status="written",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_sources_total{protocol="JT808",status="skipped_throttled",topic="vehicle.fields.go.jt808.v1"} 2`, `vehicle_stat_sources_total{protocol="JT808",status="skipped_missing_endpoint",topic="vehicle.fields.go.jt808.v1"} 3`, `vehicle_stat_projections_total{protocol="JT808",status="attempted",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_projections_total{protocol="JT808",status="written",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_projections_total{protocol="JT808",status="skipped_throttled",topic="vehicle.fields.go.jt808.v1"} 2`, `vehicle_stat_cache_entries{cache="last_total_mileage"} 300`, `vehicle_stat_cache_entries{cache="source_seen"} 3`, `vehicle_stat_cache_entries{cache="projection"} 280`, `vehicle_stat_pending_projection_entries 12`, `vehicle_stat_cache_entries{cache="baseline"} 295`, `vehicle_stat_cache_max_entries{cache="last_total_mileage"} 1000000`, `vehicle_stat_cache_max_entries{cache="source_seen"} 1000000`, `vehicle_stat_cache_max_entries{cache="projection"} 1000000`, `vehicle_stat_cache_max_entries{cache="baseline"} 1000000`, `vehicle_stat_cache_last_cleanup_deleted{cache="last_total_mileage"} 11`, `vehicle_stat_cache_last_cleanup_deleted{cache="source_seen"} 1`, `vehicle_stat_cache_last_cleanup_deleted{cache="projection"} 9`, `vehicle_stat_cache_last_cleanup_deleted{cache="baseline"} 10`, `vehicle_stat_cache_evictions_total{cache="last_total_mileage"} 4`, `vehicle_stat_cache_evictions_total{cache="source_seen"} 1`, `vehicle_stat_cache_evictions_total{cache="projection"} 2`, `vehicle_stat_cache_evictions_total{cache="baseline"} 3`, `vehicle_stat_cache_last_cleanup_unix_seconds 1782918600`, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_last_commit_unix_seconds{status="ok",topic="vehicle.fields.go.jt808.v1"} `, `vehicle_stat_kafka_lag{partition="4",topic="vehicle.fields.go.jt808.v1"} 10`, `vehicle_stat_write_duration_ms_histogram_bucket{le="+Inf",status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_write_duration_ms_histogram_count{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_write_duration_ms_histogram_sum{status="ok",topic="vehicle.fields.go.jt808.v1"}`, } { if !strings.Contains(text, want) { t.Fatalf("metrics missing %s:\n%s", want, text) } } } func TestFlushPendingProjectionsRecordsEventualProjectionMetrics(t *testing.T) { registry := metrics.NewRegistry() flusher := &recordingPendingProjectionFlusher{ result: stats.ProjectionFlushResult{Attempted: 3, Written: 3}, } readyBefore := time.Date(2026, 7, 19, 15, 4, 45, 0, time.Local) flushPendingProjections(context.Background(), discardStatLogger{}, registry, flusher, readyBefore) if flusher.calls != 1 || !flusher.readyBefore.Equal(readyBefore) { t.Fatalf("flusher calls=%d readyBefore=%s, want one call at %s", flusher.calls, flusher.readyBefore, readyBefore) } text := registry.Render() for _, want := range []string{ `vehicle_stat_pending_projections_total{status="attempted"} 3`, `vehicle_stat_pending_projections_total{status="written"} 3`, `vehicle_stat_last_pending_projection_unix_seconds{status="written"} `, } { if !strings.Contains(text, want) { t.Fatalf("pending projection metrics missing %s:\n%s", want, text) } } } func TestFlushPendingProjectionsRetainsErrorMetricForRetry(t *testing.T) { registry := metrics.NewRegistry() flusher := &recordingPendingProjectionFlusher{ result: stats.ProjectionFlushResult{Attempted: 3, Written: 1, Failed: 2}, err: errors.New("temporary projection failure"), } flushPendingProjections(context.Background(), discardStatLogger{}, registry, flusher, time.Time{}) text := registry.Render() for _, want := range []string{ `vehicle_stat_pending_projections_total{status="attempted"} 3`, `vehicle_stat_pending_projections_total{status="written"} 1`, `vehicle_stat_pending_projections_total{status="error"} 2`, `vehicle_stat_last_pending_projection_unix_seconds{status="error"} `, } { if !strings.Contains(text, want) { t.Fatalf("pending projection error metrics missing %s:\n%s", want, text) } } } func TestRecordStatWriteE2EDurationSkipsMissingReceivedAt(t *testing.T) { registry := metrics.NewRegistry() recordStatWriteE2EDuration(registry, kafka.Message{Topic: "vehicle.fields.go.jt808.v1"}, envelope.FrameEnvelope{}) text := registry.Render() if strings.Contains(text, "vehicle_stat_write_e2e_duration_ms_histogram") || strings.Contains(text, "vehicle_stat_write_e2e_recent") { t.Fatalf("unexpected e2e metric without received_at:\n%s", text) } } func TestProcessStatBatchAppendsAllBeforeCommit(t *testing.T) { first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200", ReceivedAtMS: time.Now().Add(-time.Second).UnixMilli()} second := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02", ReceivedAtMS: time.Now().Add(-time.Second).UnixMilli()} firstPayload := marshalStatTestFieldsEnvelope(t, first) secondPayload := marshalStatTestFieldsEnvelope(t, second) appender := &contextCheckingStatAppender{} committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() processStatBatch( context.Background(), discardStatLogger{}, registry, appender, committer, []kafka.Message{ {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, HighWaterMark: 13, Value: firstPayload}, {Topic: "vehicle.fields.go.gb32960.v1", Partition: 2, Offset: 20, HighWaterMark: 21, Value: secondPayload}, }, ) if appender.count != 2 { t.Fatalf("appends = %d, want 2", appender.count) } if committer.count != 1 { t.Fatalf("commit calls = %d, want 1", committer.count) } if committer.messageCount != 2 { t.Fatalf("committed messages = %d, want 2", committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_stat_batch_pending_messages 0`, `vehicle_stat_writes_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_writes_total{status="ok",topic="vehicle.fields.go.gb32960.v1"} 1`, `vehicle_stat_write_e2e_duration_ms_histogram_count{topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_write_e2e_duration_ms_histogram_count{topic="vehicle.fields.go.gb32960.v1"} 1`, `vehicle_stat_write_e2e_recent_p99_ms{topic="vehicle.fields.go.jt808.v1"} `, `vehicle_stat_write_e2e_recent_p99_ms{topic="vehicle.fields.go.gb32960.v1"} `, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.gb32960.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("batch metric missing %s:\n%s", want, text) } } } func TestProcessStatBatchKeepsPendingGaugePerWorker(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} payload := marshalStatTestFieldsEnvelope(t, env) registry := metrics.NewRegistry() processStatBatchForWorker( context.Background(), discardStatLogger{}, registry, &contextCheckingStatAppender{}, &contextCheckingStatCommitter{}, []kafka.Message{{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}}, metrics.Labels{"worker": "2"}, ) text := registry.Render() if !strings.Contains(text, `vehicle_stat_batch_pending_messages{worker="2"} 0`) { t.Fatalf("worker pending gauge missing or not reset:\n%s", text) } } func TestProcessStatBatchSamplesCacheMetricsOncePerBatch(t *testing.T) { payload := marshalStatTestFieldsEnvelope(t, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LNBVIN00000000001", EventTimeMS: 1782918600000, ReceivedAtMS: time.Now().Add(-time.Second).UnixMilli(), Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, }) appender := &contextCheckingStatAppender{ result: stats.AppendResult{SamplesFound: 1, SamplesWritten: 1}, cacheStats: stats.CacheStats{ LastTotalMileageEntries: 12, MaxEntries: 99, }, } committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() processStatBatch( context.Background(), discardStatLogger{}, registry, appender, committer, []kafka.Message{ {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}, {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 11, Value: payload}, }, ) if appender.cacheStatsCalls != 1 { t.Fatalf("CacheStats calls = %d, want 1 per batch", appender.cacheStatsCalls) } text := registry.Render() if !strings.Contains(text, `vehicle_stat_cache_entries{cache="last_total_mileage"} 12`) { t.Fatalf("cache metric missing:\n%s", text) } } func TestProcessStatBatchCommitsInvalidJSONWithoutWriteMetric(t *testing.T) { appender := &contextCheckingStatAppender{} committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() processStatBatch( context.Background(), discardStatLogger{}, registry, appender, committer, []kafka.Message{{Topic: "vehicle.fields.go.jt808.v1", Value: []byte("{bad json")}}, ) if appender.count != 0 { t.Fatalf("appends = %d, want 0", appender.count) } if committer.count != 1 { t.Fatalf("commit calls = %d, want 1", committer.count) } if committer.messageCount != 1 { t.Fatalf("committed messages = %d, want 1", committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_stat_kafka_messages_total{status="invalid_json",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("invalid json metric missing %s:\n%s", want, text) } } if strings.Contains(text, `vehicle_stat_writes_total`) { t.Fatalf("invalid json should not record write metric:\n%s", text) } } func TestProcessStatBatchSkipsKnownFieldsTopicProtocolMismatch(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } appender := &contextCheckingStatAppender{} committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() processStatBatch( context.Background(), discardStatLogger{}, registry, appender, committer, []kafka.Message{{Topic: "vehicle.fields.go.gb32960.v1", Partition: 1, Offset: 10, Value: payload}}, ) if appender.count != 0 { t.Fatalf("appends = %d, want 0", appender.count) } if committer.count != 1 || committer.messageCount != 1 { t.Fatalf("commits=%d messages=%d, want 1/1", committer.count, committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_stat_kafka_messages_total{status="protocol_topic_mismatch",topic="vehicle.fields.go.gb32960.v1"} 1`, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.gb32960.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("protocol mismatch metric missing %s:\n%s", want, text) } } if strings.Contains(text, `vehicle_stat_writes_total`) { t.Fatalf("protocol mismatch should not record write metric:\n%s", text) } } func TestProcessStatBatchSkipsExplicitNonFieldsEventKind(t *testing.T) { env := envelope.FrameEnvelope{ EventKind: envelope.EventKindRaw, Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200", } payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } appender := &contextCheckingStatAppender{} committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() processStatBatch( context.Background(), discardStatLogger{}, registry, appender, committer, []kafka.Message{{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}}, ) if appender.count != 0 { t.Fatalf("appends = %d, want 0", appender.count) } if committer.count != 1 || committer.messageCount != 1 { t.Fatalf("commits=%d messages=%d, want 1/1", committer.count, committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_stat_kafka_messages_total{status="event_kind_mismatch",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("event kind mismatch metric missing %s:\n%s", want, text) } } if strings.Contains(text, `vehicle_stat_writes_total`) { t.Fatalf("event kind mismatch should not record write metric:\n%s", text) } } func TestProcessStatBatchSkipsFieldsContractViolation(t *testing.T) { tests := []struct { name string env envelope.FrameEnvelope status string }{ { name: "missing_field_mapping", env: envelope.FrameEnvelope{ EventKind: envelope.EventKindFields, Protocol: envelope.ProtocolJT808, Fields: map[string]any{"jt808.location.total_mileage_km": 4123.9}, }, status: "missing_field_mapping", }, { name: "missing_fields", env: envelope.FrameEnvelope{ EventKind: envelope.EventKindFields, Protocol: envelope.ProtocolJT808, FieldMapping: "test-field-mapping.v1", }, status: "missing_fields", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { payload, err := json.Marshal(tt.env) if err != nil { t.Fatal(err) } appender := &contextCheckingStatAppender{} committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() processStatBatch( context.Background(), discardStatLogger{}, registry, appender, committer, []kafka.Message{{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}}, ) if appender.count != 0 { t.Fatalf("appends = %d, want 0", appender.count) } if committer.count != 1 || committer.messageCount != 1 { t.Fatalf("commits=%d messages=%d, want 1/1", committer.count, committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_stat_kafka_messages_total{status="` + tt.status + `",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("contract violation metric missing %s:\n%s", want, text) } } if strings.Contains(text, `vehicle_stat_writes_total`) { t.Fatalf("contract violation should not record write metric:\n%s", text) } }) } } func TestProcessStatBatchExposesPendingMessagesDuringAppend(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} payload := marshalStatTestFieldsEnvelope(t, env) registry := metrics.NewRegistry() appender := &contextCheckingStatAppender{ onAppend: func() { text := registry.Render() if !strings.Contains(text, `vehicle_stat_batch_pending_messages 2`) { t.Fatalf("pending batch metric missing while appending:\n%s", text) } }, } processStatBatch( context.Background(), discardStatLogger{}, registry, appender, &contextCheckingStatCommitter{}, []kafka.Message{ {Topic: "vehicle.fields.go.jt808.v1", Value: payload}, {Topic: "vehicle.fields.go.jt808.v1", Value: payload}, }, ) text := registry.Render() if !strings.Contains(text, `vehicle_stat_batch_pending_messages 0`) { t.Fatalf("pending batch metric should reset after append:\n%s", text) } } func TestProcessStatBatchDoesNotCommitWhenAppendFails(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} payload := marshalStatTestFieldsEnvelope(t, env) appender := &contextCheckingStatAppender{err: errTestStatAppend} committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() processStatBatch( context.Background(), discardStatLogger{}, registry, appender, committer, []kafka.Message{{Topic: "vehicle.fields.go.jt808.v1", Value: payload}}, ) if committer.count != 0 { t.Fatalf("commit calls = %d, want 0", committer.count) } text := registry.Render() if !strings.Contains(text, `vehicle_stat_writes_total{status="error",topic="vehicle.fields.go.jt808.v1"} 1`) { t.Fatalf("write error metric missing:\n%s", text) } } func TestProcessStatBatchCommitsMissingTimeSample(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, VIN: "LA9GG64L7PBAF4001", Phone: "13307765812", SourceEndpoint: "115.231.168.135:20215", Fields: map[string]any{ "jt808.location.total_mileage_km": 4123.9, }, } payload := marshalStatTestFieldsEnvelope(t, env) resultAppender := &contextCheckingStatAppender{ result: stats.AppendResult{ SamplesSkippedMissingTime: 1, SourceTouchesAttempted: 1, SourceTouchesWritten: 1, }, } committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() processStatBatch( context.Background(), discardStatLogger{}, registry, resultAppender, committer, []kafka.Message{{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}}, ) if committer.messageCount != 1 { t.Fatalf("committed messages = %d, want 1", committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_stat_writes_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_samples_total{protocol="JT808",status="skipped_missing_time",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("missing time metric missing %s:\n%s", want, text) } } } func TestProcessStatBatchCommitsProcessedPrefixWhenLaterAppendFails(t *testing.T) { success := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} failed := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"} successPayload := marshalStatTestFieldsEnvelope(t, success) failedPayload := marshalStatTestFieldsEnvelope(t, failed) appender := &contextCheckingStatAppender{failOnCount: 2, err: errTestStatAppend} committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() outcome := processStatBatch( context.Background(), discardStatLogger{}, registry, appender, committer, []kafka.Message{ {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: []byte("{bad json")}, {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 11, Value: successPayload}, {Topic: "vehicle.fields.go.gb32960.v1", Partition: 2, Offset: 20, Value: failedPayload}, }, ) if appender.count != 2 { t.Fatalf("appends = %d, want 2 valid appends", appender.count) } if committer.messageCount != 2 { t.Fatalf("committed messages = %d, want processed prefix only", committer.messageCount) } if got := committedOffsets(outcome.retryMessages); len(got) != 1 || got[0] != 20 { t.Fatalf("retry offsets = %#v, want [20]", got) } text := registry.Render() for _, want := range []string{ `vehicle_stat_kafka_messages_total{status="invalid_json",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_writes_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`, `vehicle_stat_writes_total{status="error",topic="vehicle.fields.go.gb32960.v1"} 1`, `vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 2`, } { if !strings.Contains(text, want) { t.Fatalf("processed prefix metric missing %s:\n%s", want, text) } } } func TestProcessStatBatchReliablyRetriesFailedSuffix(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} payload := marshalStatTestFieldsEnvelope(t, env) appender := &contextCheckingStatAppender{failOnCount: 2, err: errTestStatAppend} committer := &contextCheckingStatCommitter{} registry := metrics.NewRegistry() messages := []kafka.Message{ {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}, {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 11, Value: payload}, {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 12, Value: payload}, } processStatBatchReliably( context.Background(), discardStatLogger{}, registry, appender, committer, messages, time.Nanosecond, ) if appender.count != 4 { t.Fatalf("append attempts = %d, want success + failed + retried suffix", appender.count) } if got := committedOffsets(committer.messages); len(got) != 3 || got[0] != 10 || got[1] != 11 || got[2] != 12 { t.Fatalf("committed offsets = %#v, want [10 11 12]", got) } text := registry.Render() if !strings.Contains(text, `vehicle_stat_batch_retries_total{reason="write_error"} 1`) { t.Fatalf("write retry metric missing:\n%s", text) } if !strings.Contains(text, `vehicle_stat_retry_pending_messages 0`) { t.Fatalf("retry pending gauge should reset:\n%s", text) } } func TestProcessStatBatchReliablyQuarantinesPermanentFailureAndContinues(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} payload := marshalStatTestFieldsEnvelope(t, env) appender := &contextCheckingStatAppender{failOnCount: 1, err: errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null")} committer := &contextCheckingStatCommitter{} quarantiner := &recordingStatQuarantiner{} registry := metrics.NewRegistry() messages := []kafka.Message{ {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}, {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 11, Value: payload}, } processStatBatchReliablyForWorker( context.Background(), discardStatLogger{}, registry, appender, committer, quarantiner, messages, time.Nanosecond, nil, ) if appender.count != 2 { t.Fatalf("append attempts = %d, want failed message once and following message once", appender.count) } if quarantiner.count != 1 || quarantiner.message.Offset != 10 { t.Fatalf("quarantine = %d/%d, want one message at offset 10", quarantiner.count, quarantiner.message.Offset) } if got := committedOffsets(committer.messages); len(got) != 2 || got[0] != 10 || got[1] != 11 { t.Fatalf("committed offsets = %#v, want [10 11]", got) } text := registry.Render() if !strings.Contains(text, `vehicle_stat_quarantine_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`) { t.Fatalf("quarantine success metric missing:\n%s", text) } } func TestFileStatMessageQuarantinerPersistsOriginalKafkaMessage(t *testing.T) { dir := t.TempDir() message := kafka.Message{ Topic: "vehicle.fields.go.jt808.v1", Partition: 2, Offset: 5094052, Key: []byte("vehicle-key"), Value: []byte(`{"event_id":"poison:fields"}`), } wantErr := errors.New("permanent mysql error") if err := (fileStatMessageQuarantiner{dir: dir}).Quarantine(context.Background(), message, wantErr); err != nil { t.Fatalf("Quarantine() error = %v", err) } payload, err := os.ReadFile(filepath.Join(dir, "vehicle.fields.go.jt808.v1-2-5094052.json")) if err != nil { t.Fatalf("read quarantine record: %v", err) } var record quarantinedStatMessage if err := json.Unmarshal(payload, &record); err != nil { t.Fatalf("unmarshal quarantine record: %v", err) } if record.Topic != message.Topic || record.Partition != message.Partition || record.Offset != message.Offset || string(record.Key) != string(message.Key) || string(record.Value) != string(message.Value) || record.Error != wantErr.Error() { t.Fatalf("quarantine record = %#v, want original message and error", record) } } func TestProcessStatBatchReliablyRetriesCommitWithoutReappending(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} payload := marshalStatTestFieldsEnvelope(t, env) appender := &contextCheckingStatAppender{} committer := &contextCheckingStatCommitter{err: errors.New("commit failed"), failOnCount: 1} registry := metrics.NewRegistry() messages := []kafka.Message{ {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload}, {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 11, Value: payload}, } processStatBatchReliably( context.Background(), discardStatLogger{}, registry, appender, committer, messages, time.Nanosecond, ) if appender.count != 2 { t.Fatalf("append attempts = %d, want 2 without replay after commit failure", appender.count) } if committer.count != 2 { t.Fatalf("commit attempts = %d, want initial failure and one retry", committer.count) } text := registry.Render() if !strings.Contains(text, `vehicle_stat_batch_retries_total{reason="commit_error"} 1`) { t.Fatalf("commit retry metric missing:\n%s", text) } } func TestProcessStatBatchDoesNotCommitAcrossOffsetGapAfterFailure(t *testing.T) { success := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} failed := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"} successPayload := marshalStatTestFieldsEnvelope(t, success) failedPayload := marshalStatTestFieldsEnvelope(t, failed) appender := &contextCheckingStatAppender{failOnCount: 1, err: errTestStatAppend} committer := &contextCheckingStatCommitter{} processStatBatch( context.Background(), discardStatLogger{}, metrics.NewRegistry(), appender, committer, []kafka.Message{ {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: successPayload}, {Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 12, Value: failedPayload}, }, ) if committer.count != 0 { t.Fatalf("commit calls = %d, want 0 because failed first processed offset must not be skipped", committer.count) } } func TestProcessedPrefixMessagesByPartitionStopsAtGap(t *testing.T) { items := []statBatchItem{ {message: kafka.Message{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10}, processed: true}, {message: kafka.Message{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 12}, processed: true}, } got := processedPrefixMessagesByPartition(items) if len(got) != 1 || got[0].Offset != 10 { t.Fatalf("processed prefix = %#v, want only offset 10", got) } } func TestRecordStatSampleMetricsLabelsUnknownProtocol(t *testing.T) { registry := metrics.NewRegistry() recordStatSampleMetrics(registry, kafka.Message{Topic: "vehicle.fields.go.unknown.v1"}, "", stats.AppendResult{ SamplesSkippedMissingVIN: 1, }) recordStatProjectionMetrics(registry, kafka.Message{Topic: "vehicle.fields.go.unknown.v1"}, "", stats.AppendResult{ ProjectionsSkippedThrottled: 1, }) text := registry.Render() for _, want := range []string{ `vehicle_stat_samples_total{protocol="UNKNOWN",status="skipped_missing_vin",topic="vehicle.fields.go.unknown.v1"} 1`, `vehicle_stat_projections_total{protocol="UNKNOWN",status="skipped_throttled",topic="vehicle.fields.go.unknown.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("metrics missing %s:\n%s", want, text) } } } func TestRetryStatAppenderRetriesTransientMySQLDeadlock(t *testing.T) { registry := metrics.NewRegistry() delegate := &contextCheckingStatAppender{ failOnCount: 1, err: errors.New("Error 1213: Deadlock found when trying to get lock"), result: stats.AppendResult{SamplesWritten: 1}, } appender := retryStatAppender{delegate: delegate, attempts: 3, registry: registry} result, err := appender.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}) if err != nil { t.Fatalf("AppendWithResult() error = %v", err) } if delegate.count != 2 { t.Fatalf("append attempts = %d, want 2", delegate.count) } if result.SamplesWritten != 1 { t.Fatalf("result = %#v, want successful result", result) } text := registry.Render() if !strings.Contains(text, `vehicle_stat_write_retries_total{operation="single",status="retry"} 1`) { t.Fatalf("retry metric missing:\n%s", text) } } func TestRetryStatAppenderRetriesTransientMySQLConnectionFailure(t *testing.T) { delegate := &contextCheckingStatAppender{ failOnCount: 1, err: errors.New("driver: bad connection: read tcp 10.0.0.1:3306: connection reset by peer"), result: stats.AppendResult{SamplesWritten: 1}, } appender := retryStatAppender{delegate: delegate, attempts: 3} result, err := appender.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}) if err != nil { t.Fatalf("AppendWithResult() error = %v", err) } if delegate.count != 2 { t.Fatalf("append attempts = %d, want 2", delegate.count) } if result.SamplesWritten != 1 { t.Fatalf("result = %#v, want successful retry result", result) } } func TestRetryStatAppenderRecordsExhaustedTransientError(t *testing.T) { registry := metrics.NewRegistry() delegate := &contextCheckingStatAppender{err: errors.New("Error 1205: Lock wait timeout exceeded")} appender := retryStatAppender{delegate: delegate, attempts: 2, registry: registry} _, err := appender.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}) if err == nil { t.Fatal("AppendWithResult() error = nil, want exhausted transient error") } if delegate.count != 2 { t.Fatalf("append attempts = %d, want 2", delegate.count) } text := registry.Render() for _, want := range []string{ `vehicle_stat_write_retries_total{operation="single",status="retry"} 1`, `vehicle_stat_write_retries_total{operation="single",status="exhausted"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("retry metric missing %s:\n%s", want, text) } } } func TestRetryStatAppenderRetriesQuarantinableDataErrorBeforeIsolation(t *testing.T) { registry := metrics.NewRegistry() delegate := &contextCheckingStatAppender{err: errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null")} appender := retryStatAppender{delegate: delegate, attempts: 3, registry: registry} _, err := appender.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}) if err == nil { t.Fatal("AppendWithResult() error = nil, want exhausted data error") } if delegate.count != 3 { t.Fatalf("append attempts = %d, want configured finite retries", delegate.count) } text := registry.Render() for _, want := range []string{ `vehicle_stat_write_retries_total{operation="single",status="retry"} 2`, `vehicle_stat_write_retries_total{operation="single",status="exhausted"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("retry metric missing %s:\n%s", want, text) } } } func TestRetryStatAppenderDoesNotRetryNonTransientError(t *testing.T) { wantErr := errors.New("validation failed") delegate := &contextCheckingStatAppender{err: wantErr} appender := retryStatAppender{delegate: delegate, attempts: 3} _, err := appender.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}) if !errors.Is(err, wantErr) { t.Fatalf("AppendWithResult() error = %v, want %v", err, wantErr) } if delegate.count != 1 { t.Fatalf("append attempts = %d, want 1", delegate.count) } } func TestIsQuarantinableStatErrorOnlyAcceptsDeterministicRowDataFailures(t *testing.T) { for _, err := range []error{ errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null"), errors.New("Error 1264: Out of range value for column 'daily_mileage_km'"), errors.New("Error 1265: Data truncated for column 'daily_mileage_km'"), errors.New("Error 1292: Incorrect datetime value"), errors.New("Error 1366: Incorrect decimal value"), errors.New("Error 1406: Data too long for column"), errors.New("Error 3819: Check constraint is violated"), } { if !isQuarantinableStatError(err) { t.Fatalf("isQuarantinableStatError(%q) = false, want true", err.Error()) } } for _, err := range []error{ errors.New("Error 1054: Unknown column 'daily_mileage_km'"), errors.New("Error 1142: INSERT command denied"), errors.New("validation failed"), context.Canceled, context.DeadlineExceeded, nil, } { if isQuarantinableStatError(err) { t.Fatalf("isQuarantinableStatError(%v) = true, want false", err) } } } func TestIsTransientMySQLStatError(t *testing.T) { for _, err := range []error{ errors.New("dial tcp 127.0.0.1:3306: connection refused"), errors.New("read tcp: connection reset by peer"), errors.New("write tcp: broken pipe"), errors.New("driver: bad connection"), errors.New("invalid connection"), errors.New("i/o timeout"), errors.New("EOF"), errors.New("server is down"), errors.New("network is unreachable"), } { if !isTransientMySQLStatError(err) { t.Fatalf("isTransientMySQLStatError(%q) = false, want true", err.Error()) } } for _, err := range []error{ context.Canceled, context.DeadlineExceeded, errors.New("duplicate key conflict"), nil, } { if isTransientMySQLStatError(err) { t.Fatalf("isTransientMySQLStatError(%v) = true, want false", err) } } } func TestRetryStatAppenderForwardsCacheStats(t *testing.T) { delegate := &contextCheckingStatAppender{cacheStats: stats.CacheStats{BaselineEntries: 12, MaxEntries: 99}} appender := retryStatAppender{delegate: delegate, attempts: 3} got := appender.CacheStats() if got.BaselineEntries != 12 || got.MaxEntries != 99 { t.Fatalf("CacheStats() = %+v, want delegated stats", got) } } func TestLoadConfigDefaultsToGoFieldsTopics(t *testing.T) { cfg := loadConfig() got := strings.Join(cfg.KafkaTopics, ",") want := "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1" if got != want { t.Fatalf("KafkaTopics = %q, want %q", got, want) } if cfg.BatchSize != 200 { t.Fatalf("BatchSize = %d, want 200", cfg.BatchSize) } if cfg.BatchWait != 20 { t.Fatalf("BatchWait = %d, want 20", cfg.BatchWait) } if cfg.CacheMaxEntries != 1000000 { t.Fatalf("CacheMaxEntries = %d, want 1000000", cfg.CacheMaxEntries) } if cfg.Workers != 3 { t.Fatalf("Workers = %d, want 3", cfg.Workers) } if cfg.BaselineMissTTL != time.Minute { t.Fatalf("BaselineMissTTL = %s, want 1m", cfg.BaselineMissTTL) } if cfg.BaselineHitTTL != 5*time.Minute { t.Fatalf("BaselineHitTTL = %s, want 5m", cfg.BaselineHitTTL) } if cfg.RetryAttempts != 3 { t.Fatalf("RetryAttempts = %d, want 3", cfg.RetryAttempts) } if cfg.RetryDelay != 20*time.Millisecond { t.Fatalf("RetryDelay = %s, want 20ms", cfg.RetryDelay) } if !cfg.NormalizePlatformSourcesOnStart { t.Fatal("NormalizePlatformSourcesOnStart = false, want default true") } if cfg.NormalizePlatformSourcesTimeout != 30*time.Second { t.Fatalf("NormalizePlatformSourcesTimeout = %s, want 30s", cfg.NormalizePlatformSourcesTimeout) } } func TestConfigValidateRejectsRawTopics(t *testing.T) { cfg := config{KafkaTopics: []string{"vehicle.raw.go.jt808.v1"}} err := cfg.Validate() if err == nil { t.Fatal("Validate() error = nil, want raw topic rejection") } if !strings.Contains(err.Error(), "fields topics only") { t.Fatalf("Validate() error = %q, want fields topic hint", err) } } func TestConfigValidateRequiresTopics(t *testing.T) { cfg := config{} err := cfg.Validate() if err == nil { t.Fatal("Validate() error = nil, want missing topic rejection") } if !strings.Contains(err.Error(), "KAFKA_TOPICS") { t.Fatalf("Validate() error = %q, want KAFKA_TOPICS hint", err) } } func TestConfigValidateRequiresPositiveWorkers(t *testing.T) { cfg := config{ KafkaTopics: []string{"vehicle.fields.go.jt808.v1"}, Workers: 0, } err := cfg.Validate() if err == nil { t.Fatal("Validate() error = nil, want worker count rejection") } if !strings.Contains(err.Error(), "STATS_WORKERS") { t.Fatalf("Validate() error = %q, want STATS_WORKERS hint", err) } } func TestLoadConfigSetsStatWriteIntervals(t *testing.T) { t.Setenv("STATS_PROJECT_INTERVAL_SECONDS", "7") t.Setenv("STATS_SOURCE_TOUCH_INTERVAL_SECONDS", "11") t.Setenv("STATS_CACHE_RETENTION_HOURS", "48") t.Setenv("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", "300") t.Setenv("STATS_BASELINE_MISS_TTL_SECONDS", "45") t.Setenv("STATS_BASELINE_HIT_TTL_SECONDS", "180") t.Setenv("STATS_CACHE_MAX_ENTRIES", "12345") cfg := loadConfig() if cfg.ProjectInterval != 7*time.Second { t.Fatalf("ProjectInterval = %s, want 7s", cfg.ProjectInterval) } if cfg.SourceTouchInterval != 11*time.Second { t.Fatalf("SourceTouchInterval = %s, want 11s", cfg.SourceTouchInterval) } if cfg.CacheRetention != 48*time.Hour { t.Fatalf("CacheRetention = %s, want 48h", cfg.CacheRetention) } if cfg.CacheCleanupInterval != 5*time.Minute { t.Fatalf("CacheCleanupInterval = %s, want 5m", cfg.CacheCleanupInterval) } if cfg.BaselineMissTTL != 45*time.Second { t.Fatalf("BaselineMissTTL = %s, want 45s", cfg.BaselineMissTTL) } if cfg.BaselineHitTTL != 3*time.Minute { t.Fatalf("BaselineHitTTL = %s, want 3m", cfg.BaselineHitTTL) } if cfg.CacheMaxEntries != 12345 { t.Fatalf("CacheMaxEntries = %d, want 12345", cfg.CacheMaxEntries) } } func TestLoadConfigReadsStatBatchSettings(t *testing.T) { t.Setenv("STATS_WORKERS", "5") t.Setenv("STATS_BATCH_SIZE", "500") t.Setenv("STATS_BATCH_WAIT_MS", "250") t.Setenv("STATS_RETRY_ATTEMPTS", "5") t.Setenv("STATS_RETRY_DELAY_MS", "33") t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "false") t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", "17") t.Setenv("STATS_QUARANTINE_DIR", "/tmp/stat-writer-quarantine-test") cfg := loadConfig() if cfg.Workers != 5 { t.Fatalf("Workers = %d, want 5", cfg.Workers) } if cfg.BatchSize != 500 { t.Fatalf("BatchSize = %d, want 500", cfg.BatchSize) } if cfg.BatchWait != 250 { t.Fatalf("BatchWait = %d, want 250", cfg.BatchWait) } if cfg.RetryAttempts != 5 { t.Fatalf("RetryAttempts = %d, want 5", cfg.RetryAttempts) } if cfg.RetryDelay != 33*time.Millisecond { t.Fatalf("RetryDelay = %s, want 33ms", cfg.RetryDelay) } if cfg.NormalizePlatformSourcesOnStart { t.Fatal("NormalizePlatformSourcesOnStart = true, want env override false") } if cfg.NormalizePlatformSourcesTimeout != 17*time.Second { t.Fatalf("NormalizePlatformSourcesTimeout = %s, want 17s", cfg.NormalizePlatformSourcesTimeout) } if cfg.QuarantineDir != "/tmp/stat-writer-quarantine-test" { t.Fatalf("QuarantineDir = %q, want env override", cfg.QuarantineDir) } } type contextCheckingStatAppender struct { ctxErr error count int result stats.AppendResult cacheStats stats.CacheStats cacheStatsCalls int err error failOnCount int onAppend func() } func (a *contextCheckingStatAppender) Append(ctx context.Context, _ envelope.FrameEnvelope) error { a.ctxErr = ctx.Err() a.count++ if a.onAppend != nil { a.onAppend() } if a.ctxErr != nil { return a.ctxErr } if a.failOnCount > 0 { if a.count == a.failOnCount { return a.err } return nil } return a.err } func (a *contextCheckingStatAppender) AppendWithResult(ctx context.Context, env envelope.FrameEnvelope) (stats.AppendResult, error) { return a.result, a.Append(ctx, env) } func (a *contextCheckingStatAppender) CacheStats() stats.CacheStats { a.cacheStatsCalls++ return a.cacheStats } type contextCheckingStatCommitter struct { ctxErr error count int messageCount int messages []kafka.Message err error failOnCount int } type recordingStatQuarantiner struct { count int message kafka.Message err error } func (q *recordingStatQuarantiner) Quarantine(_ context.Context, message kafka.Message, _ error) error { q.count++ q.message = message return q.err } func (c *contextCheckingStatCommitter) CommitMessages(ctx context.Context, messages ...kafka.Message) error { c.ctxErr = ctx.Err() c.count++ c.messageCount += len(messages) c.messages = append(c.messages, messages...) if c.ctxErr != nil { return c.ctxErr } if c.err != nil && (c.failOnCount == 0 || c.count == c.failOnCount) { return c.err } return nil } type discardStatLogger struct{} func (discardStatLogger) Error(string, ...any) {} func (discardStatLogger) Warn(string, ...any) {} type recordingPendingProjectionFlusher struct { result stats.ProjectionFlushResult err error calls int readyBefore time.Time } func (f *recordingPendingProjectionFlusher) FlushPendingProjections(_ context.Context, readyBefore time.Time) (stats.ProjectionFlushResult, error) { f.calls++ f.readyBefore = readyBefore return f.result, f.err } var errTestStatAppend = errors.New("test stat append failed") func committedOffsets(messages []kafka.Message) []int64 { offsets := make([]int64, len(messages)) for index, message := range messages { offsets[index] = message.Offset } return offsets }