package main import ( "context" "encoding/json" "errors" "os" "strings" "sync" "testing" "time" "github.com/segmentio/kafka-go" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" ) func TestProcessRealtimeMessageUsesUncancelledContextForUpdateAndCommit(t *testing.T) { parent, cancel := context.WithCancel(context.Background()) cancel() env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", Phone: "13307795425", VIN: "VIN001", EventTimeMS: 1782918600000, ReceivedAtMS: 1782918600000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"}, } payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } updater := &contextCheckingRealtimeUpdater{} committer := &contextCheckingMessageCommitter{} processRealtimeMessage(parent, discardRealtimeLogger{}, nil, updater, committer, kafka.Message{Value: payload}) if updater.ctxErr != nil { t.Fatalf("updater saw cancelled context: %v", updater.ctxErr) } if committer.ctxErr != nil { t.Fatalf("committer saw cancelled context: %v", committer.ctxErr) } if updater.count != 1 || committer.count != 1 { t.Fatalf("updates=%d commits=%d", updater.count, committer.count) } } func TestProcessRealtimeMessageRecordsMetrics(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", Phone: "13307795425", VIN: "VIN001", EventTimeMS: 1782918600000, ReceivedAtMS: 1782918600000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"}, } payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } registry := metrics.NewRegistry() processRealtimeMessage( context.Background(), discardRealtimeLogger{}, registry, &contextCheckingRealtimeUpdater{}, &contextCheckingMessageCommitter{}, kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 10, HighWaterMark: 16, Value: payload}, ) text := registry.Render() for _, want := range []string{ `vehicle_realtime_kafka_messages_total{status="received",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_last_message_unix_seconds{status="received",topic="vehicle.raw.go.jt808.v1"} `, `vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_last_update_unix_seconds{status="ok",topic="vehicle.raw.go.jt808.v1"} `, `vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_last_commit_unix_seconds{status="ok",topic="vehicle.raw.go.jt808.v1"} `, `vehicle_realtime_kafka_lag{partition="2",topic="vehicle.raw.go.jt808.v1"} 5`, } { if !strings.Contains(text, want) { t.Fatalf("metrics missing %s:\n%s", want, text) } } } func TestProcessRealtimeMessageCommitsInvalidJSONAndRecordsMetrics(t *testing.T) { registry := metrics.NewRegistry() updater := &contextCheckingRealtimeUpdater{} committer := &contextCheckingMessageCommitter{} processRealtimeMessage( context.Background(), discardRealtimeLogger{}, registry, updater, committer, kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 10, HighWaterMark: 12, Value: []byte(`{bad json`)}, ) if updater.count != 0 { t.Fatalf("updates = %d, want 0 for invalid JSON", updater.count) } if committer.count != 1 { t.Fatalf("commits = %d, want 1", committer.count) } text := registry.Render() for _, want := range []string{ `vehicle_realtime_kafka_messages_total{status="received",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_kafka_messages_total{status="invalid_json",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_last_commit_unix_seconds{status="ok",topic="vehicle.raw.go.jt808.v1"} `, `vehicle_realtime_kafka_lag{partition="2",topic="vehicle.raw.go.jt808.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("invalid JSON metric missing %s:\n%s", want, text) } } } func TestProcessRealtimeMessageSkipsExplicitNonRawEventKind(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, EventKind: envelope.EventKindFields, MessageID: "0x0200", VIN: "VIN001", } payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } registry := metrics.NewRegistry() updater := &contextCheckingRealtimeUpdater{} committer := &contextCheckingMessageCommitter{} processRealtimeMessage( context.Background(), discardRealtimeLogger{}, registry, updater, committer, kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 10, HighWaterMark: 11, Value: payload}, ) if updater.count != 0 { t.Fatalf("updates = %d, want 0 for mismatched event kind", updater.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_realtime_kafka_messages_total{status="event_kind_mismatch",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("mismatch metric missing %s:\n%s", want, text) } } } func TestProcessRealtimeMessageRecordsInvalidJSONCommitError(t *testing.T) { registry := metrics.NewRegistry() committer := &contextCheckingMessageCommitter{err: errors.New("commit failed")} logger := &recordingRealtimeLogger{} processRealtimeMessage( context.Background(), logger, registry, &contextCheckingRealtimeUpdater{}, committer, kafka.Message{Topic: "vehicle.raw.go.gb32960.v1", Partition: 1, Offset: 8, HighWaterMark: 9, Value: []byte(`{bad json`)}, ) if committer.count != 1 { t.Fatalf("commits = %d, want 1", committer.count) } if !logger.containsError("kafka commit invalid envelope failed") { t.Fatalf("commit error was not logged: %#v", logger.errors) } text := registry.Render() if !strings.Contains(text, `vehicle_realtime_kafka_commits_total{status="error",topic="vehicle.raw.go.gb32960.v1"} 1`) { t.Fatalf("commit error metric missing:\n%s", text) } } func TestProcessRealtimeMessageSkipsNonRealtimeFrameBeforeUpdater(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, MessageID: "0x01", VIN: "VIN001", EventTimeMS: 1782918600000, ReceivedAtMS: 1782918600000, ParseStatus: envelope.ParseOK, Parsed: map[string]any{"platform_login": map[string]any{"username": "Hyundai"}}, } payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } registry := metrics.NewRegistry() updater := &contextCheckingRealtimeUpdater{} committer := &contextCheckingMessageCommitter{} processRealtimeMessage( context.Background(), discardRealtimeLogger{}, registry, updater, committer, kafka.Message{Topic: "vehicle.raw.go.gb32960.v1", Partition: 1, Offset: 10, HighWaterMark: 11, Value: payload}, ) if updater.count != 0 { t.Fatalf("updates = %d, want 0 for non-realtime frame", updater.count) } if committer.messageCount != 1 { t.Fatalf("committed messages = %d, want 1", committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_realtime_updates_total{status="skipped_non_realtime",topic="vehicle.raw.go.gb32960.v1"} 1`, `vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.gb32960.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("non-realtime skip metric missing %s:\n%s", want, text) } } } func TestProcessRealtimeMessageSkipsMissingVINBeforeUpdater(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", Phone: "13307795425", EventTimeMS: 1782918600000, ReceivedAtMS: 1782918600000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 30}, } payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } registry := metrics.NewRegistry() updater := &contextCheckingRealtimeUpdater{} processRealtimeMessage( context.Background(), discardRealtimeLogger{}, registry, updater, &contextCheckingMessageCommitter{}, kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 1, Offset: 10, HighWaterMark: 11, Value: payload}, ) if updater.count != 0 { t.Fatalf("updates = %d, want 0 for missing VIN", updater.count) } text := registry.Render() if !strings.Contains(text, `vehicle_realtime_updates_total{status="skipped_missing_vin",topic="vehicle.raw.go.jt808.v1"} 1`) { t.Fatalf("missing VIN skip metric missing:\n%s", text) } } func TestProcessRealtimeMessageSkipsMissingParsedFieldsBeforeUpdater(t *testing.T) { env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1782918600000, ReceivedAtMS: 1782918600000, ParseStatus: envelope.ParseOK, Parsed: map[string]any{"location": map[string]any{"speed_kmh": 30}}, } payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } registry := metrics.NewRegistry() updater := &contextCheckingRealtimeUpdater{} processRealtimeMessage( context.Background(), discardRealtimeLogger{}, registry, updater, &contextCheckingMessageCommitter{}, kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 1, Offset: 10, HighWaterMark: 11, Value: payload}, ) if updater.count != 0 { t.Fatalf("updates = %d, want 0 for missing parsed fields", updater.count) } text := registry.Render() if !strings.Contains(text, `vehicle_realtime_updates_total{status="skipped_missing_fields",topic="vehicle.raw.go.jt808.v1"} 1`) { t.Fatalf("missing parsed fields skip metric missing:\n%s", text) } } func TestCollectRealtimeBatchUsesBatchLimitAndWait(t *testing.T) { first := kafka.Message{Offset: 10} fetcher := &sliceRealtimeFetcher{messages: []kafka.Message{{Offset: 11}, {Offset: 12}, {Offset: 13}}} batch := collectRealtimeBatch(context.Background(), fetcher, first, 3, time.Second) if len(batch) != 3 { t.Fatalf("batch len = %d, want 3", len(batch)) } if batch[0].Offset != 10 || batch[1].Offset != 11 || batch[2].Offset != 12 { t.Fatalf("unexpected offsets: %#v", []int64{batch[0].Offset, batch[1].Offset, batch[2].Offset}) } } func TestProcessRealtimeBatchUsesBatchUpdaterAndCommitsAll(t *testing.T) { messages := []kafka.Message{ realtimeTestMessage(t, 10, envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, MessageID: "0x02", VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 10}}), realtimeTestMessage(t, 11, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN002", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 20}}), } registry := metrics.NewRegistry() updater := &recordingBatchRealtimeUpdater{} committer := &contextCheckingMessageCommitter{} processRealtimeBatch(context.Background(), discardRealtimeLogger{}, registry, updater, committer, messages) if updater.batchCount != 1 { t.Fatalf("batch updates = %d, want 1", updater.batchCount) } if updater.updateCount != 0 { t.Fatalf("fallback updates = %d, want 0", updater.updateCount) } if len(updater.batchEnvs) != 2 { t.Fatalf("batch envs = %d, want 2", len(updater.batchEnvs)) } if committer.count != 1 || committer.messageCount != 2 { t.Fatalf("commits=%d committed_messages=%d, want 1/2", committer.count, committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_realtime_batch_flush_total{status="ok"} 1`, `vehicle_realtime_batch_valid_messages_total{status="ok"} 2`, `vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.gb32960.v1"} 1`, `vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("batch metric missing %s:\n%s", want, text) } } } func TestProcessRealtimeBatchKeepsPendingGaugePerWorker(t *testing.T) { message := realtimeTestMessage(t, 10, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 10}, }) registry := metrics.NewRegistry() processRealtimeBatchForWorker( context.Background(), discardRealtimeLogger{}, registry, &recordingBatchRealtimeUpdater{}, &contextCheckingMessageCommitter{}, []kafka.Message{message}, metrics.Labels{"worker": "2"}, ) text := registry.Render() for _, want := range []string{ `vehicle_realtime_batch_pending_messages{worker="2"} 0`, `vehicle_realtime_batch_pending_valid_messages{worker="2"} 0`, } { if !strings.Contains(text, want) { t.Fatalf("worker pending gauge missing or not reset, want %s:\n%s", want, text) } } } func TestProcessRealtimeBatchSkipsExplicitNonRawEventKindAndUpdatesValidRows(t *testing.T) { mismatchedPayload, err := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, EventKind: envelope.EventKindFields, MessageID: "0x0200", VIN: "VIN_BAD", }.MarshalJSONBytes() if err != nil { t.Fatal(err) } valid := realtimeTestMessage(t, 11, envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, EventKind: envelope.EventKindRaw, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"}, }) messages := []kafka.Message{ {Topic: "vehicle.raw.go.jt808.v1", Partition: 0, Offset: 10, HighWaterMark: 12, Value: mismatchedPayload}, valid, } registry := metrics.NewRegistry() updater := &recordingBatchRealtimeUpdater{} committer := &contextCheckingMessageCommitter{} processRealtimeBatch(context.Background(), discardRealtimeLogger{}, registry, updater, committer, messages) if updater.batchCount != 1 || len(updater.batchEnvs) != 1 { t.Fatalf("batch updates=%d envs=%d, want only valid row", updater.batchCount, len(updater.batchEnvs)) } if committer.count != 1 || committer.messageCount != 2 { t.Fatalf("commits=%d messages=%d, want 1/2", committer.count, committer.messageCount) } text := registry.Render() for _, want := range []string{ `vehicle_realtime_kafka_messages_total{status="event_kind_mismatch",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 2`, } { if !strings.Contains(text, want) { t.Fatalf("batch mismatch metric missing %s:\n%s", want, text) } } } func TestProcessRealtimeBatchFallsBackAndCommitsProcessedPrefix(t *testing.T) { messages := []kafka.Message{ {Topic: "vehicle.raw.go.jt808.v1", Partition: 0, Offset: 10, Value: []byte(`{bad json`)}, realtimeTestMessage(t, 11, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 10}}), realtimeTestMessage(t, 12, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN002", EventTimeMS: 1200, ReceivedAtMS: 1200, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 20}}), } registry := metrics.NewRegistry() updater := &recordingBatchRealtimeUpdater{batchErr: errTestRealtimeUpdate, failUpdateAt: 2} committer := &contextCheckingMessageCommitter{} logger := &recordingRealtimeLogger{} outcome := processRealtimeBatch(context.Background(), logger, registry, updater, committer, messages) if updater.batchCount != 1 { t.Fatalf("batch updates = %d, want 1", updater.batchCount) } if updater.updateCount != 2 { t.Fatalf("fallback updates = %d, want 2", updater.updateCount) } if committer.count != 1 { t.Fatalf("commit calls = %d, want 1 processed-prefix commit", committer.count) } if len(committer.messages) != 2 || committer.messages[0].Offset != 10 || committer.messages[1].Offset != 11 { t.Fatalf("committed offsets = %#v, want [10 11]", committedOffsets(committer.messages)) } if got := committedOffsets(outcome.retryMessages); len(got) != 1 || got[0] != 12 { t.Fatalf("retry offsets = %#v, want [12]", got) } if !logger.containsError("realtime fallback update failed") { t.Fatalf("fallback error was not logged: %#v", logger.errors) } text := registry.Render() for _, want := range []string{ `vehicle_realtime_batch_flush_total{status="error"} 1`, `vehicle_realtime_updates_total{status="error",topic="vehicle.raw.go.jt808.v1"} 1`, `vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 2`, } { if !strings.Contains(text, want) { t.Fatalf("fallback metric missing %s:\n%s", want, text) } } } func TestProcessRealtimeBatchReliablyRetriesFailedSuffix(t *testing.T) { messages := []kafka.Message{ {Topic: "vehicle.raw.go.jt808.v1", Partition: 0, Offset: 10, Value: []byte(`{bad json`)}, realtimeTestMessage(t, 11, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"}}), realtimeTestMessage(t, 12, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN002", EventTimeMS: 1200, ReceivedAtMS: 1200, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "20"}}), } registry := metrics.NewRegistry() updater := &recordingBatchRealtimeUpdater{batchErr: errTestRealtimeUpdate, failUpdateAt: 2} committer := &contextCheckingMessageCommitter{} processRealtimeBatchReliably( context.Background(), discardRealtimeLogger{}, registry, updater, committer, messages, time.Nanosecond, ) if updater.batchCount != 2 { t.Fatalf("batch attempts = %d, want initial batch and failed suffix retry", updater.batchCount) } if updater.updateCount != 3 { t.Fatalf("fallback updates = %d, want 3", updater.updateCount) } 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_realtime_batch_retries_total{reason="write_error"} 1`) { t.Fatalf("write retry metric missing:\n%s", text) } } func TestProcessRealtimeBatchReliablyRetriesCommitWithoutReappending(t *testing.T) { messages := []kafka.Message{ realtimeTestMessage(t, 10, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"}}), realtimeTestMessage(t, 11, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN002", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "20"}}), } registry := metrics.NewRegistry() updater := &recordingBatchRealtimeUpdater{} committer := &contextCheckingMessageCommitter{err: errors.New("commit failed"), failOnCount: 1} processRealtimeBatchReliably( context.Background(), discardRealtimeLogger{}, registry, updater, committer, messages, time.Nanosecond, ) if updater.batchCount != 1 { t.Fatalf("batch attempts = %d, want 1 without replay after commit failure", updater.batchCount) } 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_realtime_batch_retries_total{reason="commit_error"} 1`) { t.Fatalf("commit retry metric missing:\n%s", text) } } func TestCompositeRealtimeUpdaterUpdatesBothStores(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"} redisUpdater := &contextCheckingRealtimeUpdater{} snapshotUpdater := &contextCheckingRealtimeUpdater{} updater := compositeRealtimeUpdater{primary: redisUpdater, secondary: snapshotUpdater} if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } if redisUpdater.count != 1 || snapshotUpdater.count != 1 { t.Fatalf("updates primary=%d secondary=%d", redisUpdater.count, snapshotUpdater.count) } } func TestComposeRealtimeUpdaterSkipsDisabledRedisProjector(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"} mysqlUpdater := &contextCheckingRealtimeUpdater{} updater := composeRealtimeUpdater(nil, mysqlUpdater) if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } if mysqlUpdater.count != 1 { t.Fatalf("mysql updates = %d, want 1", mysqlUpdater.count) } } func TestComposeRealtimeUpdaterReturnsPrimaryWhenMySQLSnapshotDisabled(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"} redisUpdater := &contextCheckingRealtimeUpdater{} updater := composeRealtimeUpdater(redisUpdater, nil) if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } if redisUpdater.count != 1 { t.Fatalf("redis updates = %d, want 1", redisUpdater.count) } } func TestAsyncSecondaryRealtimeUpdaterDoesNotBlockPrimaryPath(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"} primary := &contextCheckingRealtimeUpdater{} secondary := &blockingRealtimeUpdater{started: make(chan struct{}), release: make(chan struct{})} registry := metrics.NewRegistry() updater := newAsyncSecondaryRealtimeUpdater(primary, secondary, 8, 1, registry, nil) defer updater.Close() start := time.Now() if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("async updater blocked primary path for %v", elapsed) } if primary.count != 1 { t.Fatalf("primary updates = %d, want 1", primary.count) } select { case <-secondary.started: case <-time.After(time.Second): t.Fatal("secondary update was not started asynchronously") } close(secondary.release) text := registry.Render() for _, want := range []string{ `vehicle_realtime_async_queue_capacity{store="mysql"} 8`, `vehicle_realtime_async_queue_total{protocol="GB32960",status="queued",store="mysql"} 1`, `vehicle_realtime_async_queue_depth{protocol="GB32960",store="mysql"}`, } { if !strings.Contains(text, want) { t.Fatalf("async queue metric missing %s:\n%s", want, text) } } } func TestAsyncSecondaryQueueDropDoesNotFailPrimaryUpdate(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"} primary := &contextCheckingRealtimeUpdater{} registry := metrics.NewRegistry() updater := &asyncSecondaryRealtimeUpdater{ primary: primary, secondary: &contextCheckingRealtimeUpdater{}, queue: make(chan envelope.FrameEnvelope, 1), registry: registry, } updater.queue <- envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN000"} if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } if primary.count != 1 { t.Fatalf("primary updates = %d, want 1", primary.count) } text := registry.Render() for _, want := range []string{ `vehicle_realtime_async_queue_total{protocol="JT808",status="dropped",store="mysql"} 1`, `vehicle_realtime_async_queue_depth{protocol="JT808",store="mysql"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("async queue drop metric missing %s:\n%s", want, text) } } } func TestAsyncSecondaryRealtimeUpdaterCloseDrainsQueuedUpdates(t *testing.T) { secondary := &contextCheckingRealtimeUpdater{} updater := newAsyncSecondaryRealtimeUpdater(nil, secondary, 8, 1, nil, nil) for _, vin := range []string{"VIN001", "VIN002"} { if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: vin}); err != nil { t.Fatalf("Update(%s) error = %v", vin, err) } } updater.Close() if secondary.count != 2 { t.Fatalf("secondary updates = %d, want 2 after Close drains queue", secondary.count) } } func TestAsyncSecondaryRealtimeUpdaterDoesNotQueueAfterClose(t *testing.T) { secondary := &contextCheckingRealtimeUpdater{} registry := metrics.NewRegistry() updater := newAsyncSecondaryRealtimeUpdater(nil, secondary, 8, 1, registry, nil) updater.Close() if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}); err != nil { t.Fatalf("Update() error = %v", err) } if secondary.count != 0 { t.Fatalf("secondary updates = %d, want 0 after Close", secondary.count) } text := registry.Render() if !strings.Contains(text, `vehicle_realtime_async_queue_total{protocol="JT808",status="closed",store="mysql"} 1`) { t.Fatalf("closed queue metric missing:\n%s", text) } } func TestStoreMetricUpdaterRecordsStoreUpdateResults(t *testing.T) { registry := metrics.NewRegistry() env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"} updater := storeMetricUpdater{ store: "redis", delegate: &contextCheckingRealtimeUpdater{}, registry: registry, } if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } text := registry.Render() for _, want := range []string{ `vehicle_realtime_store_updates_total{protocol="JT808",status="ok",store="redis"} 1`, `vehicle_realtime_store_update_duration_ms{protocol="JT808",status="ok",store="redis"}`, `vehicle_realtime_store_update_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="ok",store="redis"} 1`, `vehicle_realtime_store_update_duration_ms_histogram_count{protocol="JT808",status="ok",store="redis"} 1`, `vehicle_realtime_store_update_duration_ms_histogram_sum{protocol="JT808",status="ok",store="redis"}`, `vehicle_realtime_last_store_update_unix_seconds{protocol="JT808",status="ok",store="redis"} `, } { if !strings.Contains(text, want) { t.Fatalf("store update metric missing %s:\n%s", want, text) } } } func TestRetryRealtimeUpdaterRetriesTransientMySQLDeadlock(t *testing.T) { delegate := &retryOnceRealtimeUpdater{err: errors.New("Error 1213 (40001): Deadlock found when trying to get lock")} updater := retryRealtimeUpdater{delegate: delegate, attempts: 3} if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}); err != nil { t.Fatalf("Update() error = %v", err) } if delegate.count != 2 { t.Fatalf("delegate updates = %d, want 2", delegate.count) } } func TestRetryRealtimeUpdaterRetriesTransientMySQLConnectionFailure(t *testing.T) { delegate := &retryOnceRealtimeUpdater{err: errors.New("driver: bad connection: write tcp: broken pipe")} updater := retryRealtimeUpdater{delegate: delegate, attempts: 3} if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}); err != nil { t.Fatalf("Update() error = %v", err) } if delegate.count != 2 { t.Fatalf("delegate updates = %d, want 2", delegate.count) } } func TestIsTransientMySQLWriteError(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 !isTransientMySQLWriteError(err) { t.Fatalf("isTransientMySQLWriteError(%q) = false, want true", err.Error()) } } for _, err := range []error{ context.Canceled, context.DeadlineExceeded, errors.New("duplicate key conflict"), nil, } { if isTransientMySQLWriteError(err) { t.Fatalf("isTransientMySQLWriteError(%v) = true, want false", err) } } } func TestThrottledRealtimeUpdaterLimitsMySQLSnapshotFrequencyByVehicle(t *testing.T) { delegate := &contextCheckingRealtimeUpdater{} registry := metrics.NewRegistry() updater := newThrottledRealtimeUpdater(delegate, 10*time.Second, "mysql", registry) base := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, Parsed: map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}}, } for _, env := range []envelope.FrameEnvelope{ base, func() envelope.FrameEnvelope { env := base env.EventTimeMS = 5000 env.ReceivedAtMS = 5100 return env }(), func() envelope.FrameEnvelope { env := base env.EventTimeMS = 11000 env.ReceivedAtMS = 11100 return env }(), } { if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } } if delegate.count != 2 { t.Fatalf("delegate updates = %d, want 2", delegate.count) } text := registry.Render() for _, want := range []string{ `vehicle_realtime_throttle_total{protocol="JT808",status="passed",store="mysql"} 2`, `vehicle_realtime_throttle_total{protocol="JT808",status="skipped_interval",store="mysql"} 1`, } { if !strings.Contains(text, want) { t.Fatalf("throttle metric missing %s:\n%s", want, text) } } } func TestThrottledRealtimeUpdaterCleansExpiredCacheEntries(t *testing.T) { delegate := &contextCheckingRealtimeUpdater{} registry := metrics.NewRegistry() updater := newThrottledRealtimeUpdaterWithCache(delegate, 10*time.Second, "mysql", registry, 48*time.Hour, 0).(*throttledRealtimeUpdater) loc := time.FixedZone("Asia/Shanghai", 8*3600) base := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", ParseStatus: envelope.ParseOK, Parsed: map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}}, } old := base old.VIN = "VIN-OLD" old.EventTimeMS = time.Date(2026, 7, 1, 12, 0, 0, 0, loc).UnixMilli() old.ReceivedAtMS = old.EventTimeMS current := base current.VIN = "VIN-CURRENT" current.EventTimeMS = time.Date(2026, 7, 4, 12, 0, 0, 0, loc).UnixMilli() current.ReceivedAtMS = current.EventTimeMS if err := updater.Update(context.Background(), old); err != nil { t.Fatalf("old Update() error = %v", err) } if err := updater.Update(context.Background(), current); err != nil { t.Fatalf("current Update() error = %v", err) } if delegate.count != 2 { t.Fatalf("delegate updates = %d, want 2", delegate.count) } if len(updater.lastMS) != 1 { t.Fatalf("throttle cache entries = %d, want 1", len(updater.lastMS)) } if _, ok := updater.lastMS[string(envelope.ProtocolJT808)+"\x00VIN-CURRENT"]; !ok { t.Fatalf("current VIN missing from throttle cache") } text := registry.Render() for _, want := range []string{ `vehicle_realtime_throttle_cache_entries{store="mysql"} 1`, `vehicle_realtime_throttle_cache_last_cleanup_deleted{store="mysql"} 1`, `vehicle_realtime_throttle_cache_last_cleanup_unix_seconds{store="mysql"} 1783137600`, } { if !strings.Contains(text, want) { t.Fatalf("throttle cache metric missing %s:\n%s", want, text) } } } func TestThrottledRealtimeUpdaterFallsBackToReceivedTimeForFarFutureEvent(t *testing.T) { delegate := &contextCheckingRealtimeUpdater{} updater := newThrottledRealtimeUpdater(delegate, 10*time.Second, "mysql", nil) received := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC).UnixMilli() future := time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC).UnixMilli() base := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: future, ReceivedAtMS: received, ParseStatus: envelope.ParseOK, Parsed: map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}}, } for _, env := range []envelope.FrameEnvelope{ base, func() envelope.FrameEnvelope { env := base env.EventTimeMS = received + 5_000 env.ReceivedAtMS = received + 5_000 return env }(), func() envelope.FrameEnvelope { env := base env.EventTimeMS = received + 11_000 env.ReceivedAtMS = received + 11_000 return env }(), } { if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } } if delegate.count != 2 { t.Fatalf("delegate updates = %d, want 2", delegate.count) } } func TestThrottledRealtimeUpdaterSkipsNonRealtimeFramesBeforeDelegate(t *testing.T) { delegate := &contextCheckingRealtimeUpdater{} registry := metrics.NewRegistry() updater := newThrottledRealtimeUpdater(delegate, 10*time.Second, "mysql", registry) if err := updater.Update(context.Background(), envelope.FrameEnvelope{ Protocol: envelope.ProtocolGB32960, MessageID: "0x01", VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, Parsed: map[string]any{"platform_login": map[string]any{"username": "Hyundai"}}, }); err != nil { t.Fatalf("Update() error = %v", err) } if delegate.count != 0 { t.Fatalf("delegate updates = %d, want 0", delegate.count) } text := registry.Render() if !strings.Contains(text, `vehicle_realtime_throttle_total{protocol="GB32960",status="skipped_non_realtime",store="mysql"} 1`) { t.Fatalf("non-realtime throttle metric missing:\n%s", text) } } func TestAsyncSecondaryRealtimeUpdaterLogsSecondaryErrors(t *testing.T) { env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventID: "event-1"} logger := &recordingRealtimeLogger{} updater := newAsyncSecondaryRealtimeUpdater(nil, failingRealtimeUpdater{}, 8, 1, nil, logger) defer updater.Close() if err := updater.Update(context.Background(), env); err != nil { t.Fatalf("Update() error = %v", err) } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if logger.containsError("async realtime secondary update failed") { return } time.Sleep(10 * time.Millisecond) } t.Fatalf("secondary error was not logged: %#v", logger.errors) } func TestKafkaTopicsFromEnvDefaultsToGoRawTopics(t *testing.T) { got := strings.Join(kafkaTopicsFromEnv(), ",") want := "vehicle.raw.go.gb32960.v1,vehicle.raw.go.jt808.v1,vehicle.raw.go.yutong-mqtt.v1" if got != want { t.Fatalf("topics = %q, want %q", got, want) } } func TestValidateRealtimeConsumerConfigRejectsFieldsTopics(t *testing.T) { err := validateRealtimeConsumerConfig([]string{"vehicle.fields.go.jt808.v1"}, 1) if err == nil || !strings.Contains(err.Error(), "raw topics only") { t.Fatalf("validateRealtimeConsumerConfig() error = %v, want raw topic rejection", err) } } func TestValidateRealtimeConsumerConfigRequiresPositiveWorkers(t *testing.T) { err := validateRealtimeConsumerConfig([]string{"vehicle.raw.go.jt808.v1"}, 0) if err == nil || !strings.Contains(err.Error(), "REALTIME_WORKERS") { t.Fatalf("validateRealtimeConsumerConfig() error = %v, want REALTIME_WORKERS hint", err) } } func TestThrottledRealtimeUpdaterIsSafeAcrossPartitionWorkers(t *testing.T) { delegate := &concurrentRealtimeUpdater{} updater := newThrottledRealtimeUpdater(delegate, time.Minute, "mysql", metrics.NewRegistry()) env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "LNBVIN00000000001", EventTimeMS: 1783947000000, ReceivedAtMS: 1783947000000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 10}, } const workers = 32 var wait sync.WaitGroup errorsCh := make(chan error, workers) for index := 0; index < workers; index++ { wait.Add(1) go func() { defer wait.Done() if err := updater.Update(context.Background(), env); err != nil { errorsCh <- err } }() } wait.Wait() close(errorsCh) for err := range errorsCh { t.Fatal(err) } if got := delegate.Count(); got != 1 { t.Fatalf("delegate updates = %d, want one throttled write for the same VIN", got) } } func TestRealtimeReaderConfigStartsAtLatestOffset(t *testing.T) { cfg := realtimeReaderConfig([]string{"127.0.0.1:9092"}, []string{"vehicle.raw.go.jt808.v1"}) if cfg.StartOffset != kafka.LastOffset { t.Fatalf("StartOffset = %d, want kafka.LastOffset %d", cfg.StartOffset, kafka.LastOffset) } if got := strings.Join(cfg.GroupTopics, ","); got != "vehicle.raw.go.jt808.v1" { t.Fatalf("GroupTopics = %q", got) } } func TestRealtimeRoleControlsQueryAndWriterResponsibilities(t *testing.T) { tests := []struct { raw string wantRole string wantAPI bool wantWriter bool }{ {raw: "", wantRole: "combined", wantAPI: true, wantWriter: true}, {raw: "combined", wantRole: "combined", wantAPI: true, wantWriter: true}, {raw: "api", wantRole: "api", wantAPI: true, wantWriter: false}, {raw: "query", wantRole: "api", wantAPI: true, wantWriter: false}, {raw: "writer", wantRole: "writer", wantAPI: false, wantWriter: true}, {raw: "projector", wantRole: "writer", wantAPI: false, wantWriter: true}, {raw: "surprise", wantRole: "combined", wantAPI: true, wantWriter: true}, } for _, tt := range tests { t.Run(tt.raw, func(t *testing.T) { if got := normalizeRealtimeRole(tt.raw); got != tt.wantRole { t.Fatalf("normalizeRealtimeRole(%q) = %q, want %q", tt.raw, got, tt.wantRole) } if got := realtimeRoleAllowsAPI(tt.raw); got != tt.wantAPI { t.Fatalf("realtimeRoleAllowsAPI(%q) = %v, want %v", tt.raw, got, tt.wantAPI) } if got := realtimeRoleAllowsWriter(tt.raw); got != tt.wantWriter { t.Fatalf("realtimeRoleAllowsWriter(%q) = %v, want %v", tt.raw, got, tt.wantWriter) } }) } } func TestRealtimeRoleFromEnvDefaultsToCombined(t *testing.T) { t.Setenv("REALTIME_ROLE", "") if got := realtimeRoleFromEnv(); got != "combined" { t.Fatalf("role = %q, want combined", got) } } func TestRealtimeRoleDependencyMatrix(t *testing.T) { tests := []struct { name string role string redisProjector bool wantRedis bool wantServiceName string }{ {name: "api queries redis", role: "api", redisProjector: false, wantRedis: true, wantServiceName: "vehicle-realtime-api"}, {name: "mysql writer skips redis", role: "writer", redisProjector: false, wantRedis: false, wantServiceName: "vehicle-realtime-writer"}, {name: "redis writer uses redis", role: "writer", redisProjector: true, wantRedis: true, wantServiceName: "vehicle-realtime-writer"}, {name: "combined queries redis", role: "combined", redisProjector: false, wantRedis: true, wantServiceName: "vehicle-realtime-api"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { apiEnabled := realtimeRoleAllowsAPI(tt.role) writerEnabled := realtimeRoleAllowsWriter(tt.role) if got := realtimeRoleNeedsRedis(apiEnabled, writerEnabled, tt.redisProjector); got != tt.wantRedis { t.Fatalf("realtimeRoleNeedsRedis() = %v, want %v", got, tt.wantRedis) } if got := realtimeServiceName(tt.role); got != tt.wantServiceName { t.Fatalf("realtimeServiceName(%q) = %q, want %q", tt.role, got, tt.wantServiceName) } }) } } func TestRealtimeAPIDoesNotExposeDuplicateMileagePointRoute(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } if strings.Contains(string(source), "/api/history/mileage-points") { t.Fatalf("realtime api should expose mileage through /api/history/locations only") } } func TestRealtimeAPICachesBindingPlateResolver(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } for _, want := range []string{"NewCachedPlateResolverWithMaxEntries", "PLATE_CACHE_TTL_SECONDS", "PLATE_CACHE_MAX_ENTRIES", "recordPlateCacheStats", "vehicle_realtime_plate_cache_entries"} { if !strings.Contains(string(source), want) { t.Fatalf("realtime api should configure cached plate resolver, missing %s", want) } } } func TestRealtimeAPIExposesPostRawFrameQueryRoute(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } if !strings.Contains(string(source), `"/api/history/raw-frames/query"`) { t.Fatalf("realtime api should expose POST raw frame query route") } } func TestRealtimeAPIExposesOperationalRealtimeRoutes(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } for _, want := range []string{`"/api/realtime/online"`, `"/api/debug/pipeline"`} { if !strings.Contains(string(source), want) { t.Fatalf("realtime api should expose operational route %s", want) } } } func TestRealtimeAPIExposesDataSourceManagementRoutes(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } for _, want := range []string{`"/api/stats/data-sources"`, `"/api/stats/data-sources/"`, "NewDataSourceHandler"} { if !strings.Contains(string(source), want) { t.Fatalf("realtime api should expose data source management route %s", want) } } } func TestRealtimeAPIExposesDailyMetricSourceRoute(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } for _, want := range []string{`"/api/stats/daily-metrics"`, `"/api/stats/daily-metrics/sources"`, `"/api/stats/daily-metrics/sources/quality"`, `"/api/stats/daily-metrics/sources/selection"`, `"/api/stats/daily-metrics/diagnostics"`, `"/api/stats/daily-metrics/diagnostics/summary"`, `"/api/stats/daily-metrics/diagnostics/reasons"`, `"/api/stats/daily-metrics/diagnostics/field-status"`, "NewMetricHandler"} { if !strings.Contains(string(source), want) { t.Fatalf("realtime api should expose metric route %s", want) } } } func TestRealtimeAPIDoesNotExposeMySQLKVRoute(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } if strings.Contains(string(source), `"/api/realtime/kv"`) || strings.Contains(string(source), "NewKVQuery") { t.Fatal("realtime api should not expose MySQL realtime kv route") } } func TestRealtimeAPIRedisProjectorHasProductionGuardMetric(t *testing.T) { source, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } for _, want := range []string{"REALTIME_ROLE", "vehicle_realtime_role_info", "vehicle_realtime_writer_enabled", "REALTIME_REDIS_PROJECTOR_ENABLED", "vehicle_realtime_redis_projector_enabled"} { if !strings.Contains(string(source), want) { t.Fatalf("realtime api should expose redis projector guard %s", want) } } } func TestRealtimeAPIClosesAsyncMySQLUpdaterBeforeStatsDB(t *testing.T) { sourceBytes, err := os.ReadFile("main.go") if err != nil { t.Fatalf("read main.go: %v", err) } source := string(sourceBytes) for _, want := range []string{"closeRealtimeUpdaters := func() {}", "closeRealtimeUpdaters = asyncUpdater.Close", "defer closeStats()", "defer closeRealtimeUpdaters()"} { if !strings.Contains(source, want) { t.Fatalf("realtime api should close async mysql updater on shutdown, missing %s", want) } } if strings.Index(source, "defer closeStats()") > strings.Index(source, "defer closeRealtimeUpdaters()") { t.Fatalf("closeRealtimeUpdaters should be deferred after closeStats so it runs first") } } type contextCheckingRealtimeUpdater struct { ctxErr error count int } type concurrentRealtimeUpdater struct { mu sync.Mutex count int } func (u *concurrentRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error { u.mu.Lock() u.count++ u.mu.Unlock() return nil } func (u *concurrentRealtimeUpdater) Count() int { u.mu.Lock() defer u.mu.Unlock() return u.count } func (u *contextCheckingRealtimeUpdater) Update(ctx context.Context, _ envelope.FrameEnvelope) error { u.ctxErr = ctx.Err() u.count++ return u.ctxErr } type contextCheckingMessageCommitter struct { ctxErr error count int messageCount int messages []kafka.Message err error failOnCount int } func (c *contextCheckingMessageCommitter) 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 discardRealtimeLogger struct{} func (discardRealtimeLogger) Info(string, ...any) {} func (discardRealtimeLogger) Error(string, ...any) {} func (discardRealtimeLogger) Warn(string, ...any) {} type recordingRealtimeLogger struct { mu sync.Mutex errors []string } func (l *recordingRealtimeLogger) Info(string, ...any) {} func (l *recordingRealtimeLogger) Warn(string, ...any) {} func (l *recordingRealtimeLogger) Error(message string, _ ...any) { l.mu.Lock() defer l.mu.Unlock() l.errors = append(l.errors, message) } func (l *recordingRealtimeLogger) containsError(message string) bool { l.mu.Lock() defer l.mu.Unlock() for _, got := range l.errors { if got == message { return true } } return false } type blockingRealtimeUpdater struct { started chan struct{} release chan struct{} once sync.Once } func (u *blockingRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error { u.once.Do(func() { close(u.started) }) <-u.release return nil } type failingRealtimeUpdater struct{} func (failingRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error { return errTestRealtimeUpdate } var errTestRealtimeUpdate = errors.New("test realtime update failed") type retryOnceRealtimeUpdater struct { err error count int } func (u *retryOnceRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error { u.count++ if u.count == 1 { return u.err } return nil } type sliceRealtimeFetcher struct { messages []kafka.Message index int } func (f *sliceRealtimeFetcher) FetchMessage(ctx context.Context) (kafka.Message, error) { if f.index >= len(f.messages) { return kafka.Message{}, ctx.Err() } message := f.messages[f.index] f.index++ return message, nil } type recordingBatchRealtimeUpdater struct { batchCount int updateCount int batchEnvs []envelope.FrameEnvelope batchErr error failUpdateAt int } func (u *recordingBatchRealtimeUpdater) UpdateBatch(_ context.Context, envs []envelope.FrameEnvelope) error { u.batchCount++ u.batchEnvs = append(u.batchEnvs, envs...) return u.batchErr } func (u *recordingBatchRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error { u.updateCount++ if u.failUpdateAt > 0 && u.updateCount == u.failUpdateAt { return errTestRealtimeUpdate } return nil } func realtimeTestMessage(t *testing.T, offset int64, env envelope.FrameEnvelope) kafka.Message { t.Helper() payload, err := json.Marshal(env) if err != nil { t.Fatal(err) } return kafka.Message{ Topic: topicForTestProtocol(env.Protocol), Partition: 0, Offset: offset, Value: payload, } } func topicForTestProtocol(protocol envelope.Protocol) string { switch protocol { case envelope.ProtocolGB32960: return "vehicle.raw.go.gb32960.v1" case envelope.ProtocolYutongMQTT: return "vehicle.raw.go.yutong_mqtt.v1" default: return "vehicle.raw.go.jt808.v1" } } func committedOffsets(messages []kafka.Message) []int64 { offsets := make([]int64, 0, len(messages)) for _, message := range messages { offsets = append(offsets, message.Offset) } return offsets }