1369 lines
47 KiB
Go
1369 lines
47 KiB
Go
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
|
|
}
|