851 lines
28 KiB
Go
851 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/segmentio/kafka-go"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
|
)
|
|
|
|
func TestBridgeBatchWritesKafkaThenAcks(t *testing.T) {
|
|
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
|
payload, err := json.Marshal(env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writer := &recordingBridgeWriter{}
|
|
acked := 0
|
|
|
|
err = bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
|
|
{subject: "vehicle.raw.jt808.v1", data: payload, ack: func() error {
|
|
acked++
|
|
return nil
|
|
}},
|
|
}, map[string]string{"vehicle.raw.jt808.v1": "vehicle.raw.jt808.v1"})
|
|
if err != nil {
|
|
t.Fatalf("bridgeBatch() error = %v", err)
|
|
}
|
|
if len(writer.messages) != 1 {
|
|
t.Fatalf("kafka writes = %d, want 1", len(writer.messages))
|
|
}
|
|
if got, want := writer.messages[0].Topic, "vehicle.raw.jt808.v1"; got != want {
|
|
t.Fatalf("topic = %q, want %q", got, want)
|
|
}
|
|
if got, want := string(writer.messages[0].Key), "JT808:13307795425"; got != want {
|
|
t.Fatalf("key = %q, want %q", got, want)
|
|
}
|
|
if acked != 1 {
|
|
t.Fatalf("acks = %d, want 1", acked)
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchDoesNotAckWhenKafkaFails(t *testing.T) {
|
|
writer := &recordingBridgeWriter{err: errors.New("kafka unavailable")}
|
|
acked := 0
|
|
|
|
err := bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
|
|
{subject: "vehicle.event.unified.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
|
|
acked++
|
|
return nil
|
|
}},
|
|
}, map[string]string{"vehicle.event.unified.v1": "vehicle.event.unified.v1"})
|
|
if err == nil {
|
|
t.Fatal("bridgeBatch() error is nil, want kafka error")
|
|
}
|
|
if acked != 0 {
|
|
t.Fatalf("acks = %d, want 0", acked)
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchProjectsFieldsFromCanonicalRawAndAcksOnce(t *testing.T) {
|
|
raw := envelope.FrameEnvelope{
|
|
EventID: "raw-event-1",
|
|
EventKind: envelope.EventKindRaw,
|
|
Protocol: envelope.ProtocolJT808,
|
|
MessageID: "0x0200",
|
|
VIN: "LTEST000000000001",
|
|
EventTimeMS: 1_700_000_000_000,
|
|
ReceivedAtMS: 1_700_000_000_100,
|
|
ParseStatus: envelope.ParseOK,
|
|
ParsedFields: map[string]any{
|
|
"jt808.location.speed_kmh": 52.3,
|
|
"jt808.location.total_mileage_km": 12345.6,
|
|
},
|
|
}
|
|
payload, err := raw.MarshalJSONBytes()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
acked := 0
|
|
writer := &recordingBridgeWriter{}
|
|
registry := metrics.NewRegistry()
|
|
|
|
err = bridgeBatchWithProjection(
|
|
context.Background(),
|
|
registry,
|
|
writer,
|
|
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
|
|
map[string]string{topics.RawJT808: topics.RawJT808},
|
|
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
|
|
true,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("bridgeBatchWithProjection() error = %v", err)
|
|
}
|
|
if acked != 1 {
|
|
t.Fatalf("acks = %d, want exactly one ack for raw plus derived fields", acked)
|
|
}
|
|
if len(writer.messages) != 2 {
|
|
t.Fatalf("kafka messages = %d, want raw and fields", len(writer.messages))
|
|
}
|
|
byTopic := map[string]kafka.Message{}
|
|
for _, message := range writer.messages {
|
|
byTopic[message.Topic] = message
|
|
}
|
|
if len(byTopic[topics.RawJT808].Value) == 0 || len(byTopic[topics.FieldsJT808].Value) == 0 {
|
|
t.Fatalf("projected topics = %#v", byTopic)
|
|
}
|
|
var fields envelope.FrameEnvelope
|
|
if err := json.Unmarshal(byTopic[topics.FieldsJT808].Value, &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fields.EventKind != envelope.EventKindFields || fields.SourceEventID != raw.EventID {
|
|
t.Fatalf("fields envelope = %#v", fields)
|
|
}
|
|
if got := fields.Fields["jt808.location.total_mileage_km"]; got != 12345.6 {
|
|
t.Fatalf("projected mileage = %#v", got)
|
|
}
|
|
metricText := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_fields_projection_total{protocol="JT808",status="published"} 1`,
|
|
`vehicle_bridge_fields_projection_count{protocol="JT808",status="published"} 2`,
|
|
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
|
} {
|
|
if !strings.Contains(metricText, want) {
|
|
t.Fatalf("projection metric missing %s:\n%s", want, metricText)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchDoesNotAckRawWhenDerivedFieldsWriteFails(t *testing.T) {
|
|
raw := envelope.FrameEnvelope{
|
|
EventKind: envelope.EventKindRaw,
|
|
Protocol: envelope.ProtocolJT808,
|
|
MessageID: "0x0200",
|
|
VIN: "LTEST000000000001",
|
|
EventTimeMS: 1_700_000_000_000,
|
|
ReceivedAtMS: 1_700_000_000_100,
|
|
ParseStatus: envelope.ParseOK,
|
|
ParsedFields: map[string]any{"jt808.location.total_mileage_km": 12345.6},
|
|
}
|
|
payload, err := raw.MarshalJSONBytes()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
acked := 0
|
|
writer := &recordingBridgeWriter{topicErr: map[string]error{topics.FieldsJT808: errors.New("fields unavailable")}}
|
|
|
|
err = bridgeBatchWithProjection(
|
|
context.Background(),
|
|
nil,
|
|
writer,
|
|
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
|
|
map[string]string{topics.RawJT808: topics.RawJT808},
|
|
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
|
|
true,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("bridgeBatchWithProjection() error = nil, want fields Kafka error")
|
|
}
|
|
if acked != 0 {
|
|
t.Fatalf("acks = %d, want raw left pending until both Kafka outputs succeed", acked)
|
|
}
|
|
if len(writer.messages) != 1 || writer.messages[0].Topic != topics.RawJT808 {
|
|
t.Fatalf("successful kafka messages = %#v, want only raw before replay", writer.messages)
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchSkipsFieldsProjectionForNonRealtimeRaw(t *testing.T) {
|
|
raw := envelope.FrameEnvelope{
|
|
EventKind: envelope.EventKindRaw,
|
|
Protocol: envelope.ProtocolJT808,
|
|
MessageID: "0x0100",
|
|
Phone: "13307795425",
|
|
EventTimeMS: 1_700_000_000_000,
|
|
ReceivedAtMS: 1_700_000_000_100,
|
|
ParseStatus: envelope.ParseOK,
|
|
}
|
|
payload, err := raw.MarshalJSONBytes()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
acked := 0
|
|
writer := &recordingBridgeWriter{}
|
|
registry := metrics.NewRegistry()
|
|
|
|
err = bridgeBatchWithProjection(
|
|
context.Background(),
|
|
registry,
|
|
writer,
|
|
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
|
|
map[string]string{topics.RawJT808: topics.RawJT808},
|
|
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
|
|
true,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("bridgeBatchWithProjection() error = %v", err)
|
|
}
|
|
if acked != 1 || len(writer.messages) != 1 || writer.messages[0].Topic != topics.RawJT808 {
|
|
t.Fatalf("acks=%d messages=%#v", acked, writer.messages)
|
|
}
|
|
if text := registry.Render(); !strings.Contains(text, `vehicle_bridge_fields_projection_total{protocol="JT808",status="skipped_non_realtime"} 1`) {
|
|
t.Fatalf("non-realtime projection metric missing:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchAcksSuccessfulTopicWhenAnotherTopicFails(t *testing.T) {
|
|
rawEnv := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
|
fieldsEnv := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
|
rawPayload, err := json.Marshal(rawEnv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fieldsPayload, err := json.Marshal(fieldsEnv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registry := metrics.NewRegistry()
|
|
writer := &recordingBridgeWriter{
|
|
topicErr: map[string]error{
|
|
"vehicle.fields.go.jt808.v1": errors.New("fields topic unavailable"),
|
|
},
|
|
}
|
|
acks := map[string]int{}
|
|
|
|
err = bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
|
|
{subject: "vehicle.fields.go.jt808.v1", data: fieldsPayload, ack: func() error {
|
|
acks["fields"]++
|
|
return nil
|
|
}},
|
|
{subject: "vehicle.raw.go.jt808.v1", data: rawPayload, ack: func() error {
|
|
acks["raw"]++
|
|
return nil
|
|
}},
|
|
}, map[string]string{
|
|
"vehicle.fields.go.jt808.v1": "vehicle.fields.go.jt808.v1",
|
|
"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("bridgeBatch() error = nil, want failed fields topic")
|
|
}
|
|
if len(writer.calls) != 2 {
|
|
t.Fatalf("writer calls = %d, want one call per topic", len(writer.calls))
|
|
}
|
|
if len(writer.messages) != 1 || writer.messages[0].Topic != "vehicle.raw.go.jt808.v1" {
|
|
t.Fatalf("successful kafka messages = %#v, want only raw topic", writer.messages)
|
|
}
|
|
if acks["raw"] != 1 || acks["fields"] != 0 {
|
|
t.Fatalf("acks = %#v, want raw acked and fields left unacked", acks)
|
|
}
|
|
text := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_kafka_writes_total{status="error",topic="vehicle.fields.go.jt808.v1"} 1`,
|
|
`vehicle_bridge_kafka_writes_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
|
|
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("partial bridge metric missing %s:\n%s", want, text)
|
|
}
|
|
}
|
|
if strings.Contains(text, `vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.fields.go.jt808.v1"}`) {
|
|
t.Fatalf("failed topic should not be acked:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchAcksAndDropsUnroutedSubject(t *testing.T) {
|
|
writer := &recordingBridgeWriter{}
|
|
acked := 0
|
|
registry := metrics.NewRegistry()
|
|
|
|
err := bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
|
|
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
|
|
acked++
|
|
return nil
|
|
}},
|
|
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
|
|
if err != nil {
|
|
t.Fatalf("bridgeBatch() error = %v", err)
|
|
}
|
|
if acked != 1 {
|
|
t.Fatalf("acks = %d, want unrouted message acked", acked)
|
|
}
|
|
if len(writer.messages) != 0 {
|
|
t.Fatalf("kafka writes = %d, want 0", len(writer.messages))
|
|
}
|
|
text := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_messages_total{status="route_error",subject="vehicle.unconfigured.v1"} 1`,
|
|
`vehicle_bridge_nats_acks_total{status="dropped_route_error",subject="vehicle.unconfigured.v1"} 1`,
|
|
`vehicle_bridge_batch_pending_kafka_messages 0`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("metrics missing %s:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchKeepsValidMessagesWhenUnroutedSubjectIsPresent(t *testing.T) {
|
|
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
|
payload, err := json.Marshal(env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writer := &recordingBridgeWriter{}
|
|
acked := map[string]int{}
|
|
|
|
err = bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
|
|
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
|
|
acked["unknown"]++
|
|
return nil
|
|
}},
|
|
{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error {
|
|
acked["valid"]++
|
|
return nil
|
|
}},
|
|
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
|
|
if err != nil {
|
|
t.Fatalf("bridgeBatch() error = %v", err)
|
|
}
|
|
if len(writer.messages) != 1 {
|
|
t.Fatalf("kafka writes = %d, want 1 valid message", len(writer.messages))
|
|
}
|
|
if writer.messages[0].Topic != "vehicle.raw.go.jt808.v1" {
|
|
t.Fatalf("topic = %q", writer.messages[0].Topic)
|
|
}
|
|
if acked["unknown"] != 1 || acked["valid"] != 1 {
|
|
t.Fatalf("acks = %#v, want both messages acked", acked)
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchReturnsErrorWhenUnroutedAckFails(t *testing.T) {
|
|
writer := &recordingBridgeWriter{}
|
|
|
|
err := bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
|
|
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
|
|
return errors.New("nats ack unavailable")
|
|
}},
|
|
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
|
|
if err == nil {
|
|
t.Fatal("bridgeBatch() error = nil, want ack error")
|
|
}
|
|
if len(writer.messages) != 0 {
|
|
t.Fatalf("kafka writes = %d, want 0", len(writer.messages))
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchRecordsMetrics(t *testing.T) {
|
|
env := envelope.FrameEnvelope{
|
|
Protocol: envelope.ProtocolJT808,
|
|
Phone: "13307795425",
|
|
MessageID: "0x0200",
|
|
ReceivedAtMS: time.Now().Add(-20 * time.Millisecond).UnixMilli(),
|
|
}
|
|
payload, err := json.Marshal(env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registry := metrics.NewRegistry()
|
|
writer := &recordingBridgeWriter{}
|
|
|
|
err = bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
|
|
{subject: "vehicle.raw.jt808.v1", data: payload, ack: func() error { return nil }},
|
|
}, map[string]string{"vehicle.raw.jt808.v1": "vehicle.raw.jt808.v1"})
|
|
if err != nil {
|
|
t.Fatalf("bridgeBatch() error = %v", err)
|
|
}
|
|
text := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_messages_total{status="received",subject="vehicle.raw.jt808.v1"} 1`,
|
|
`vehicle_bridge_last_message_unix_seconds{status="received",subject="vehicle.raw.jt808.v1"} `,
|
|
`vehicle_bridge_kafka_writes_total{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
|
`vehicle_bridge_last_kafka_write_unix_seconds{status="ok",topic="vehicle.raw.jt808.v1"} `,
|
|
`vehicle_bridge_kafka_write_duration_ms_histogram_count{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
|
`vehicle_bridge_kafka_e2e_duration_ms_histogram_count{topic="vehicle.raw.jt808.v1"} 1`,
|
|
`vehicle_bridge_kafka_e2e_recent_p99_ms{topic="vehicle.raw.jt808.v1"} `,
|
|
`vehicle_bridge_kafka_e2e_recent_samples{topic="vehicle.raw.jt808.v1"} `,
|
|
`vehicle_bridge_last_kafka_e2e_unix_seconds{topic="vehicle.raw.jt808.v1"} `,
|
|
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.jt808.v1"} 1`,
|
|
`vehicle_bridge_last_ack_unix_seconds{status="ok",subject="vehicle.raw.jt808.v1"} `,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("metrics missing %s:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecordBridgeKafkaE2EDurationSkipsMissingReceiveTime(t *testing.T) {
|
|
registry := metrics.NewRegistry()
|
|
|
|
recordBridgeKafkaE2EDuration(registry, "vehicle.raw.go.jt808.v1", 0)
|
|
|
|
if text := registry.Render(); strings.Contains(text, "vehicle_bridge_kafka_e2e_duration_ms_histogram") || strings.Contains(text, "vehicle_bridge_kafka_e2e_recent") {
|
|
t.Fatalf("e2e metric should be skipped when received_at_ms is missing:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchExposesPendingAndDurationMetrics(t *testing.T) {
|
|
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
|
|
payload, err := json.Marshal(env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registry := metrics.NewRegistry()
|
|
writer := &recordingBridgeWriter{
|
|
onWrite: func() {
|
|
text := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_batch_pending_messages 2`,
|
|
`vehicle_bridge_batch_pending_kafka_messages 2`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("pending bridge metric missing %s during write:\n%s", want, text)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
err = bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
|
|
{subject: "vehicle.raw.go.gb32960.v1", data: payload, ack: func() error { return nil }},
|
|
{subject: "vehicle.raw.go.gb32960.v1", data: payload, ack: func() error { return nil }},
|
|
}, map[string]string{"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1"})
|
|
if err != nil {
|
|
t.Fatalf("bridgeBatch() error = %v", err)
|
|
}
|
|
|
|
text := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_batch_pending_messages 0`,
|
|
`vehicle_bridge_batch_pending_kafka_messages 0`,
|
|
`vehicle_bridge_batch_duration_ms_histogram_bucket{le="+Inf",status="ok"} 1`,
|
|
`vehicle_bridge_batch_duration_ms_histogram_count{status="ok"} 1`,
|
|
`vehicle_bridge_batch_duration_ms_histogram_sum{status="ok"}`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("bridge batch metric missing %s:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBridgeBatchPendingAggregatesConcurrentWorkers(t *testing.T) {
|
|
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
|
|
payload, err := json.Marshal(env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registry := metrics.NewRegistry()
|
|
firstStarted := make(chan struct{})
|
|
secondStarted := make(chan struct{})
|
|
release := make(chan struct{})
|
|
writerFor := func(started chan struct{}) *recordingBridgeWriter {
|
|
return &recordingBridgeWriter{
|
|
onWrite: func() {
|
|
close(started)
|
|
<-release
|
|
},
|
|
}
|
|
}
|
|
messages := func(count int) []bridgeMessage {
|
|
out := make([]bridgeMessage, 0, count)
|
|
for i := 0; i < count; i++ {
|
|
out = append(out, bridgeMessage{subject: "vehicle.raw.go.gb32960.v1", data: payload, ack: func() error { return nil }})
|
|
}
|
|
return out
|
|
}
|
|
route := map[string]string{"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1"}
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
var firstErr error
|
|
var secondErr error
|
|
go func() {
|
|
defer wg.Done()
|
|
firstErr = bridgeBatch(context.Background(), registry, writerFor(firstStarted), messages(2), route)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
secondErr = bridgeBatch(context.Background(), registry, writerFor(secondStarted), messages(3), route)
|
|
}()
|
|
<-firstStarted
|
|
<-secondStarted
|
|
|
|
text := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_batch_pending_messages 5`,
|
|
`vehicle_bridge_batch_pending_kafka_messages 5`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("aggregate pending bridge metric missing %s during concurrent writes:\n%s", want, text)
|
|
}
|
|
}
|
|
|
|
close(release)
|
|
wg.Wait()
|
|
if firstErr != nil || secondErr != nil {
|
|
t.Fatalf("bridgeBatch errors = %v / %v", firstErr, secondErr)
|
|
}
|
|
text = registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_batch_pending_messages 0`,
|
|
`vehicle_bridge_batch_pending_kafka_messages 0`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("aggregate pending bridge metric should reset after concurrent writes, missing %s:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecordNATSConsumerInfoMetrics(t *testing.T) {
|
|
registry := metrics.NewRegistry()
|
|
cfg := config{NATSStream: "VEHICLE_INGEST", NATSDurable: "vehicle-kafka-bridge"}
|
|
|
|
recordNATSConsumerInfoMetrics(registry, cfg, &nats.ConsumerInfo{
|
|
NumPending: 17,
|
|
NumAckPending: 3,
|
|
NumWaiting: 2,
|
|
})
|
|
|
|
text := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_nats_consumer_pending{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 17`,
|
|
`vehicle_bridge_nats_consumer_ack_pending{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 3`,
|
|
`vehicle_bridge_nats_consumer_waiting{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 2`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("metrics missing %s:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsBridgeShutdownFetchError(t *testing.T) {
|
|
if isBridgeShutdownFetchError(context.Background(), errors.New("temporary nats failure")) {
|
|
t.Fatal("temporary failure should not be treated as shutdown")
|
|
}
|
|
if !isBridgeShutdownFetchError(context.Background(), nats.ErrConnectionClosed) {
|
|
t.Fatal("connection closed should stop worker without noisy error log")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if !isBridgeShutdownFetchError(ctx, errors.New("any fetch error after cancellation")) {
|
|
t.Fatal("cancelled context should stop worker without noisy error log")
|
|
}
|
|
}
|
|
|
|
func TestIsTransientBridgeFetchError(t *testing.T) {
|
|
for _, err := range []error{
|
|
errors.New("nats: disconnected during fetch"),
|
|
errors.New("nats: connection closed"),
|
|
errors.New("read tcp: connection reset by peer"),
|
|
errors.New("temporary network unavailable"),
|
|
errors.New("i/o timeout"),
|
|
} {
|
|
if !isTransientBridgeFetchError(err) {
|
|
t.Fatalf("isTransientBridgeFetchError(%v) = false, want true", err)
|
|
}
|
|
}
|
|
if isTransientBridgeFetchError(errors.New("permission denied")) {
|
|
t.Fatal("non-transient bridge fetch error should stay non-transient")
|
|
}
|
|
if isTransientBridgeFetchError(nil) {
|
|
t.Fatal("nil should not be transient")
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigDefaultsToGoSubjectRoutes(t *testing.T) {
|
|
cfg := loadConfig()
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatalf("default config Validate() error = %v", err)
|
|
}
|
|
|
|
want := map[string]string{
|
|
"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1",
|
|
"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1",
|
|
"vehicle.raw.go.yutong-mqtt.v1": "vehicle.raw.go.yutong-mqtt.v1",
|
|
"vehicle.fields.go.gb32960.v1": "vehicle.fields.go.gb32960.v1",
|
|
"vehicle.fields.go.jt808.v1": "vehicle.fields.go.jt808.v1",
|
|
"vehicle.fields.go.yutong-mqtt.v1": "vehicle.fields.go.yutong-mqtt.v1",
|
|
}
|
|
if len(cfg.Route) != len(want) {
|
|
t.Fatalf("route len = %d, want %d: %#v", len(cfg.Route), len(want), cfg.Route)
|
|
}
|
|
for subject, topic := range want {
|
|
if got := cfg.Route[subject]; got != topic {
|
|
t.Fatalf("route[%q] = %q, want %q; route=%#v", subject, got, topic, cfg.Route)
|
|
}
|
|
}
|
|
if !cfg.DeriveFieldsFromRaw {
|
|
t.Fatal("DeriveFieldsFromRaw = false, want canonical raw projection enabled by default")
|
|
}
|
|
for subject, wantTopic := range map[string]string{
|
|
topics.RawGB32960: topics.FieldsGB32960,
|
|
topics.RawJT808: topics.FieldsJT808,
|
|
topics.RawYutongMQTT: topics.FieldsYutongMQTT,
|
|
} {
|
|
if got := cfg.RawFieldRoutes[subject].Topic; got != wantTopic {
|
|
t.Fatalf("RawFieldRoutes[%q].Topic = %q, want %q", subject, got, wantTopic)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigValidateRejectsRawFieldsSubjectOverlap(t *testing.T) {
|
|
cfg := config{
|
|
RawRoutes: []subjectRoute{
|
|
{Subject: "vehicle.same.jt808", Topic: "vehicle.raw.go.jt808.v1"},
|
|
},
|
|
FieldsRoutes: []subjectRoute{
|
|
{Subject: "vehicle.same.jt808", Topic: "vehicle.fields.go.jt808.v1"},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
|
|
if err == nil {
|
|
t.Fatal("Validate() error = nil, want subject overlap rejection")
|
|
}
|
|
if !strings.Contains(err.Error(), "nats subject") {
|
|
t.Fatalf("Validate() error = %q, want nats subject hint", err)
|
|
}
|
|
}
|
|
|
|
func TestConfigValidateRejectsKnownProtocolTopicMismatch(t *testing.T) {
|
|
cfg := config{
|
|
RawRoutes: []subjectRoute{
|
|
{Protocol: envelope.ProtocolJT808, Subject: "vehicle.raw.go.jt808.v1", Topic: "vehicle.raw.go.gb32960.v1"},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
|
|
if err == nil {
|
|
t.Fatal("Validate() error = nil, want known protocol topic mismatch")
|
|
}
|
|
if !strings.Contains(err.Error(), "must match protocol") {
|
|
t.Fatalf("Validate() error = %q, want protocol mismatch hint", err)
|
|
}
|
|
}
|
|
|
|
func TestConfigValidateRejectsFieldsRouteToRawKafkaTopic(t *testing.T) {
|
|
cfg := config{
|
|
RawRoutes: []subjectRoute{
|
|
{Subject: "vehicle.raw.go.jt808.v1", Topic: "vehicle.raw.go.jt808.v1"},
|
|
},
|
|
FieldsRoutes: []subjectRoute{
|
|
{Subject: "vehicle.fields.go.jt808.v1", Topic: "vehicle.raw.go.jt808.v1"},
|
|
},
|
|
}
|
|
|
|
err := cfg.Validate()
|
|
|
|
if err == nil {
|
|
t.Fatal("Validate() error = nil, want fields topic family rejection")
|
|
}
|
|
if !strings.Contains(err.Error(), "fields kafka topic") {
|
|
t.Fatalf("Validate() error = %q, want fields kafka topic hint", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
|
cfg := loadConfig()
|
|
|
|
if got, want := cfg.StreamMaxBytes, int64(20*1024*1024*1024); got != want {
|
|
t.Fatalf("StreamMaxBytes = %d, want %d", got, want)
|
|
}
|
|
if got, want := cfg.StreamEnsureWait, 60*time.Second; got != want {
|
|
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
|
|
}
|
|
if got, want := cfg.KafkaBatchTimeout, 20*time.Millisecond; got != want {
|
|
t.Fatalf("KafkaBatchTimeout = %v, want %v", got, want)
|
|
}
|
|
if got, want := cfg.KafkaWriteConcurrency, 6; got != want {
|
|
t.Fatalf("KafkaWriteConcurrency = %d, want %d", got, want)
|
|
}
|
|
if got, want := cfg.FetchWait, 20*time.Millisecond; got != want {
|
|
t.Fatalf("FetchWait = %v, want %v", got, want)
|
|
}
|
|
if got, want := cfg.Workers, 4; got != want {
|
|
t.Fatalf("Workers = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
|
t.Setenv("NATS_STREAM_MAX_BYTES", "1073741824")
|
|
t.Setenv("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", "90")
|
|
t.Setenv("BRIDGE_KAFKA_BATCH_TIMEOUT_MS", "35")
|
|
t.Setenv("BRIDGE_KAFKA_WRITE_CONCURRENCY", "4")
|
|
t.Setenv("BRIDGE_FETCH_WAIT_MS", "45")
|
|
t.Setenv("BRIDGE_WORKERS", "6")
|
|
|
|
cfg := loadConfig()
|
|
|
|
if got, want := cfg.StreamMaxBytes, int64(1073741824); got != want {
|
|
t.Fatalf("StreamMaxBytes = %d, want %d", got, want)
|
|
}
|
|
if got, want := cfg.StreamEnsureWait, 90*time.Second; got != want {
|
|
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
|
|
}
|
|
if got, want := cfg.KafkaBatchTimeout, 35*time.Millisecond; got != want {
|
|
t.Fatalf("KafkaBatchTimeout = %v, want %v", got, want)
|
|
}
|
|
if got, want := cfg.KafkaWriteConcurrency, 4; got != want {
|
|
t.Fatalf("KafkaWriteConcurrency = %d, want %d", got, want)
|
|
}
|
|
if got, want := cfg.FetchWait, 45*time.Millisecond; got != want {
|
|
t.Fatalf("FetchWait = %v, want %v", got, want)
|
|
}
|
|
if got, want := cfg.Workers, 6; got != want {
|
|
t.Fatalf("Workers = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRecordBridgeConfigMetrics(t *testing.T) {
|
|
registry := metrics.NewRegistry()
|
|
|
|
recordBridgeConfigMetrics(registry, config{
|
|
KafkaBatchTimeout: 35 * time.Millisecond,
|
|
KafkaWriteConcurrency: 4,
|
|
BatchSize: 600,
|
|
FetchWait: 20 * time.Millisecond,
|
|
Workers: 6,
|
|
DeriveFieldsFromRaw: true,
|
|
})
|
|
|
|
text := registry.Render()
|
|
for _, want := range []string{
|
|
`vehicle_bridge_config{setting="batch_size"} 600`,
|
|
`vehicle_bridge_config{setting="fetch_wait_ms"} 20`,
|
|
`vehicle_bridge_config{setting="kafka_batch_timeout_ms"} 35`,
|
|
`vehicle_bridge_config{setting="kafka_write_concurrency"} 4`,
|
|
`vehicle_bridge_config{setting="workers"} 6`,
|
|
`vehicle_bridge_config{setting="derive_fields_from_raw_enabled"} 1`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("bridge config metric missing %s:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewKafkaWriterUsesConfiguredBatchTimeout(t *testing.T) {
|
|
writer := newKafkaWriter(config{
|
|
KafkaBrokers: []string{"127.0.0.1:9092"},
|
|
KafkaBatchTimeout: 17 * time.Millisecond,
|
|
})
|
|
defer writer.Close()
|
|
|
|
if got, want := writer.BatchTimeout, 17*time.Millisecond; got != want {
|
|
t.Fatalf("BatchTimeout = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigIncludesUnifiedOnlyWhenExplicitlyConfigured(t *testing.T) {
|
|
t.Setenv("NATS_SUBJECT_UNIFIED", "vehicle.event.go.unified.v1")
|
|
t.Setenv("KAFKA_TOPIC_UNIFIED", "vehicle.event.go.unified.v1")
|
|
|
|
cfg := loadConfig()
|
|
|
|
if got := cfg.Route["vehicle.event.go.unified.v1"]; got != "vehicle.event.go.unified.v1" {
|
|
t.Fatalf("unified route = %q, route=%#v", got, cfg.Route)
|
|
}
|
|
}
|
|
|
|
type recordingBridgeWriter struct {
|
|
mu sync.Mutex
|
|
messages []kafka.Message
|
|
calls [][]kafka.Message
|
|
err error
|
|
topicErr map[string]error
|
|
onWrite func()
|
|
}
|
|
|
|
func (w *recordingBridgeWriter) WriteMessages(_ context.Context, messages ...kafka.Message) error {
|
|
if w.onWrite != nil {
|
|
w.onWrite()
|
|
}
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
call := append([]kafka.Message(nil), messages...)
|
|
w.calls = append(w.calls, call)
|
|
if len(messages) > 0 && w.topicErr != nil {
|
|
if err := w.topicErr[messages[0].Topic]; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if w.err != nil {
|
|
return w.err
|
|
}
|
|
w.messages = append(w.messages, messages...)
|
|
return nil
|
|
}
|
|
|
|
type concurrentBridgeWriter struct {
|
|
started chan string
|
|
release chan struct{}
|
|
active atomic.Int32
|
|
max atomic.Int32
|
|
}
|
|
|
|
func (w *concurrentBridgeWriter) WriteMessages(_ context.Context, messages ...kafka.Message) error {
|
|
active := w.active.Add(1)
|
|
defer w.active.Add(-1)
|
|
for {
|
|
current := w.max.Load()
|
|
if active <= current || w.max.CompareAndSwap(current, active) {
|
|
break
|
|
}
|
|
}
|
|
w.started <- messages[0].Topic
|
|
<-w.release
|
|
return nil
|
|
}
|
|
|
|
func TestBridgeWritesIndependentKafkaTopicsConcurrently(t *testing.T) {
|
|
writer := &concurrentBridgeWriter{
|
|
started: make(chan string, 2),
|
|
release: make(chan struct{}),
|
|
}
|
|
messages := []bridgeMessage{
|
|
{subject: topics.RawJT808, data: []byte(`{"protocol":"JT808"}`), ack: func() error { return nil }},
|
|
{subject: topics.RawGB32960, data: []byte(`{"protocol":"GB32960"}`), ack: func() error { return nil }},
|
|
}
|
|
route := map[string]string{
|
|
topics.RawJT808: topics.RawJT808,
|
|
topics.RawGB32960: topics.RawGB32960,
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- bridgeBatchWithProjectionConcurrency(context.Background(), nil, writer, messages, route, nil, false, 2)
|
|
}()
|
|
for i := 0; i < 2; i++ {
|
|
select {
|
|
case <-writer.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("independent Kafka topic writes did not start concurrently")
|
|
}
|
|
}
|
|
if got := writer.max.Load(); got != 2 {
|
|
t.Fatalf("max concurrent Kafka writes = %d, want 2", got)
|
|
}
|
|
close(writer.release)
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("bridgeBatchWithProjectionConcurrency() error = %v", err)
|
|
}
|
|
}
|