feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
@@ -20,6 +21,7 @@ import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
)
@@ -29,6 +31,10 @@ func main() {
defer stop()
cfg := loadConfig()
if err := cfg.Validate(); err != nil {
logger.Error("invalid bridge config", "error", err)
os.Exit(1)
}
conn, err := nats.Connect(cfg.NATSURL, nats.Name(cfg.NATSClientName), nats.Timeout(5*time.Second))
if err != nil {
logger.Error("nats connect failed", "error", err)
@@ -36,6 +42,7 @@ func main() {
}
defer conn.Close()
registry := metrics.NewRegistry()
recordBridgeConfigMetrics(registry, cfg)
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-kafka-bridge", []health.Check{
{Name: "nats", Check: func(context.Context) error {
if conn.Status() != nats.CONNECTED {
@@ -66,71 +73,205 @@ func main() {
os.Exit(1)
}
writer := &kafka.Writer{
Addr: kafka.TCP(cfg.KafkaBrokers...),
Balancer: &kafka.Hash{},
AllowAutoTopicCreation: false,
RequiredAcks: kafka.RequireAll,
Async: false,
}
defer writer.Close()
logger.Info("nats kafka bridge started",
"nats_url", cfg.NATSURL,
"stream", cfg.NATSStream,
"durable", cfg.NATSDurable,
"filter", cfg.NATSFilter,
"kafka_brokers", strings.Join(cfg.KafkaBrokers, ","))
runBridge(ctx, logger, registry, js, sub, writer, cfg)
"kafka_brokers", strings.Join(cfg.KafkaBrokers, ","),
"kafka_batch_timeout_ms", cfg.KafkaBatchTimeout.Milliseconds(),
"kafka_write_concurrency", cfg.KafkaWriteConcurrency,
"fetch_wait_ms", cfg.FetchWait.Milliseconds(),
"batch_size", cfg.BatchSize,
"workers", cfg.Workers,
"derive_fields_from_raw_enabled", cfg.DeriveFieldsFromRaw)
for i := 0; i < cfg.Workers; i++ {
writer := newKafkaWriter(cfg)
defer writer.Close()
go runBridge(ctx, logger.With("worker", i), registry, js, sub, writer, cfg)
}
<-ctx.Done()
}
type config struct {
NATSURL string
NATSClientName string
NATSStream string
NATSDurable string
NATSFilter string
NATSSubjects []string
KafkaBrokers []string
Route map[string]string
BatchSize int
FetchWait time.Duration
OperationWait time.Duration
AckWait time.Duration
StreamMaxAge time.Duration
StreamMaxBytes int64
StreamEnsureWait time.Duration
NATSURL string
NATSClientName string
NATSStream string
NATSDurable string
NATSFilter string
NATSSubjects []string
KafkaBrokers []string
RawRoutes []subjectRoute
FieldsRoutes []subjectRoute
Route map[string]string
RawFieldRoutes map[string]fieldsProjectionRoute
DeriveFieldsFromRaw bool
KafkaBatchTimeout time.Duration
KafkaWriteConcurrency int
BatchSize int
FetchWait time.Duration
OperationWait time.Duration
AckWait time.Duration
StreamMaxAge time.Duration
StreamMaxBytes int64
StreamEnsureWait time.Duration
Workers int
}
type subjectRoute struct {
Protocol envelope.Protocol
Subject string
Topic string
}
type fieldsProjectionRoute struct {
Protocol envelope.Protocol
Topic string
}
func loadConfig() config {
route := map[string]string{
env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)): env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960),
env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)): env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808),
env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)): env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT),
env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)): env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960),
env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)): env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808),
env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)): env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT),
rawRoutes := []subjectRoute{
{Protocol: envelope.ProtocolGB32960, Subject: env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)), Topic: env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)},
{Protocol: envelope.ProtocolJT808, Subject: env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)), Topic: env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)},
{Protocol: envelope.ProtocolYutongMQTT, Subject: env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)), Topic: env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)},
}
fieldsRoutes := []subjectRoute{
{Protocol: envelope.ProtocolGB32960, Subject: env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)), Topic: env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)},
{Protocol: envelope.ProtocolJT808, Subject: env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)), Topic: env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)},
{Protocol: envelope.ProtocolYutongMQTT, Subject: env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)), Topic: env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)},
}
route := routeMap(rawRoutes, fieldsRoutes)
if unifiedSubject, unifiedTopic, ok := unifiedRouteFromEnv(); ok {
route[unifiedSubject] = unifiedTopic
}
return config{
NATSURL: env("NATS_URL", "nats://127.0.0.1:4222"),
NATSClientName: env("NATS_CLIENT_NAME", "lingniu-nats-kafka-bridge"),
NATSStream: env("NATS_STREAM", "VEHICLE_INGEST"),
NATSDurable: env("NATS_DURABLE", "vehicle-kafka-bridge"),
NATSFilter: env("NATS_FILTER", "vehicle.>"),
NATSSubjects: splitCSV(env("NATS_STREAM_SUBJECTS", strings.Join(mapKeys(route), ","))),
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
Route: route,
BatchSize: envInt("BRIDGE_BATCH_SIZE", 500),
FetchWait: time.Duration(envInt("BRIDGE_FETCH_WAIT_MS", 1000)) * time.Millisecond,
OperationWait: time.Duration(envInt("BRIDGE_OPERATION_TIMEOUT_MS", 30000)) * time.Millisecond,
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 60)) * time.Second,
StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour,
StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024),
StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second,
workers := envInt("BRIDGE_WORKERS", 4)
if workers < 1 {
workers = 1
}
return config{
NATSURL: env("NATS_URL", "nats://127.0.0.1:4222"),
NATSClientName: env("NATS_CLIENT_NAME", "lingniu-nats-kafka-bridge"),
NATSStream: env("NATS_STREAM", "VEHICLE_INGEST"),
NATSDurable: env("NATS_DURABLE", "vehicle-kafka-bridge"),
NATSFilter: env("NATS_FILTER", "vehicle.>"),
NATSSubjects: splitCSV(env("NATS_STREAM_SUBJECTS", strings.Join(mapKeys(route), ","))),
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
RawRoutes: rawRoutes,
FieldsRoutes: fieldsRoutes,
Route: route,
RawFieldRoutes: rawFieldProjectionRoutes(rawRoutes, fieldsRoutes),
DeriveFieldsFromRaw: envBool("BRIDGE_DERIVE_FIELDS_FROM_RAW_ENABLED", true),
KafkaBatchTimeout: time.Duration(envInt("BRIDGE_KAFKA_BATCH_TIMEOUT_MS", 20)) * time.Millisecond,
KafkaWriteConcurrency: envInt("BRIDGE_KAFKA_WRITE_CONCURRENCY", 6),
BatchSize: envInt("BRIDGE_BATCH_SIZE", 500),
FetchWait: time.Duration(envInt("BRIDGE_FETCH_WAIT_MS", 20)) * time.Millisecond,
OperationWait: time.Duration(envInt("BRIDGE_OPERATION_TIMEOUT_MS", 30000)) * time.Millisecond,
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 60)) * time.Second,
StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour,
StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024),
StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second,
Workers: workers,
}
}
func recordBridgeConfigMetrics(registry *metrics.Registry, cfg config) {
if registry == nil {
return
}
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize))
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "fetch_wait_ms"}, float64(cfg.FetchWait.Milliseconds()))
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "kafka_batch_timeout_ms"}, float64(cfg.KafkaBatchTimeout.Milliseconds()))
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "kafka_write_concurrency"}, float64(cfg.KafkaWriteConcurrency))
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "derive_fields_from_raw_enabled"}, boolMetric(cfg.DeriveFieldsFromRaw))
}
func newKafkaWriter(cfg config) *kafka.Writer {
return &kafka.Writer{
Addr: kafka.TCP(cfg.KafkaBrokers...),
Balancer: &kafka.Hash{},
AllowAutoTopicCreation: false,
RequiredAcks: kafka.RequireAll,
BatchTimeout: cfg.KafkaBatchTimeout,
Async: false,
}
}
func routeMap(rawRoutes []subjectRoute, fieldsRoutes []subjectRoute) map[string]string {
route := make(map[string]string, len(rawRoutes)+len(fieldsRoutes))
for _, item := range rawRoutes {
if item.Subject != "" {
route[item.Subject] = item.Topic
}
}
for _, item := range fieldsRoutes {
if item.Subject != "" {
route[item.Subject] = item.Topic
}
}
return route
}
func rawFieldProjectionRoutes(rawRoutes []subjectRoute, fieldsRoutes []subjectRoute) map[string]fieldsProjectionRoute {
fieldsByProtocol := make(map[envelope.Protocol]string, len(fieldsRoutes))
for _, route := range fieldsRoutes {
if route.Protocol != "" && strings.TrimSpace(route.Topic) != "" {
fieldsByProtocol[route.Protocol] = strings.TrimSpace(route.Topic)
}
}
out := make(map[string]fieldsProjectionRoute, len(rawRoutes))
for _, route := range rawRoutes {
subject := strings.TrimSpace(route.Subject)
topic := fieldsByProtocol[route.Protocol]
if subject == "" || topic == "" {
continue
}
out[subject] = fieldsProjectionRoute{Protocol: route.Protocol, Topic: topic}
}
return out
}
func (c config) Validate() error {
rawSubjects, rawTopics := routeSubjectsAndTopics(c.RawRoutes)
fieldsSubjects, fieldsTopics := routeSubjectsAndTopics(c.FieldsRoutes)
if err := topics.ValidateKafkaRawFields(rawTopics, fieldsTopics); err != nil {
return err
}
return topics.ValidateRawFieldsDisjoint(rawSubjects, fieldsSubjects, "nats subject")
}
func routeSubjectsAndTopics(routes []subjectRoute) (map[string]string, map[string]string) {
subjects := make(map[string]string, len(routes))
kafkaTopics := make(map[string]string, len(routes))
for _, route := range routes {
name := strings.TrimSpace(route.Subject)
if name == "" {
name = strings.TrimSpace(route.Topic)
}
if name == "" {
name = "unknown"
}
subjects[name] = strings.TrimSpace(route.Subject)
topicName := routeProtocolName(route)
if topicName == "" {
topicName = name
}
kafkaTopics[topicName] = strings.TrimSpace(route.Topic)
}
return subjects, kafkaTopics
}
func routeProtocolName(route subjectRoute) string {
if protocol := strings.TrimSpace(string(route.Protocol)); protocol != "" {
return protocol
}
if protocol, ok := topics.ProtocolForKnownRawTopic(route.Topic); ok {
return protocol
}
if protocol, ok := topics.ProtocolForKnownFieldsTopic(route.Topic); ok {
return protocol
}
return ""
}
func unifiedRouteFromEnv() (string, string, bool) {
@@ -169,6 +310,18 @@ type bridgeMessage struct {
ack func() error
}
type routedBridgeMessage struct {
sourceIndex int
kafka kafka.Message
receivedAtMS int64
}
type bridgeSourceState struct {
source bridgeMessage
routed bool
failed bool
}
func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, infoReader natsConsumerInfoReader, sub natsPullSubscription, writer kafkaBatchWriter, cfg config) {
var lastConsumerInfoAt time.Time
for {
@@ -188,9 +341,17 @@ func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
}
msgs, err := sub.Fetch(cfg.BatchSize, nats.MaxWait(cfg.FetchWait))
if err != nil {
if isBridgeShutdownFetchError(ctx, err) {
return
}
if errors.Is(err, nats.ErrTimeout) {
continue
}
if isTransientBridgeFetchError(err) {
logger.Warn("nats fetch interrupted", "error", err)
time.Sleep(time.Second)
continue
}
logger.Error("nats fetch failed", "error", err)
time.Sleep(time.Second)
continue
@@ -207,7 +368,7 @@ func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
})
}
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.OperationWait)
err = bridgeBatch(operationCtx, registry, writer, bridgeMessages, cfg.Route)
err = bridgeBatchWithProjectionConcurrency(operationCtx, registry, writer, bridgeMessages, cfg.Route, cfg.RawFieldRoutes, cfg.DeriveFieldsFromRaw, cfg.KafkaWriteConcurrency)
cancel()
if err != nil {
logger.Error("bridge batch failed", "count", len(bridgeMessages), "error", err)
@@ -216,6 +377,30 @@ func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
}
}
func isBridgeShutdownFetchError(ctx context.Context, err error) bool {
if err == nil {
return false
}
if ctx.Err() != nil {
return true
}
return errors.Is(err, nats.ErrConnectionClosed)
}
func isTransientBridgeFetchError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(text, "disconnected during fetch") ||
strings.Contains(text, "connection closed") ||
strings.Contains(text, "connection reset") ||
strings.Contains(text, "broken pipe") ||
strings.Contains(text, "temporary") ||
strings.Contains(text, "temporarily") ||
strings.Contains(text, "timeout")
}
func ensureStream(js nats.JetStreamContext, cfg config) error {
ctx, cancel := context.WithTimeout(context.Background(), cfg.StreamEnsureWait)
defer cancel()
@@ -245,6 +430,31 @@ func ensureStream(js nats.JetStreamContext, cfg config) error {
}
func bridgeBatch(ctx context.Context, registry *metrics.Registry, writer kafkaBatchWriter, messages []bridgeMessage, route map[string]string) error {
return bridgeBatchWithProjectionConcurrency(ctx, registry, writer, messages, route, nil, false, 6)
}
func bridgeBatchWithProjection(
ctx context.Context,
registry *metrics.Registry,
writer kafkaBatchWriter,
messages []bridgeMessage,
route map[string]string,
rawFieldRoutes map[string]fieldsProjectionRoute,
deriveFieldsFromRaw bool,
) error {
return bridgeBatchWithProjectionConcurrency(ctx, registry, writer, messages, route, rawFieldRoutes, deriveFieldsFromRaw, 6)
}
func bridgeBatchWithProjectionConcurrency(
ctx context.Context,
registry *metrics.Registry,
writer kafkaBatchWriter,
messages []bridgeMessage,
route map[string]string,
rawFieldRoutes map[string]fieldsProjectionRoute,
deriveFieldsFromRaw bool,
kafkaWriteConcurrency int,
) error {
if len(messages) == 0 {
return nil
}
@@ -252,66 +462,240 @@ func bridgeBatch(ctx context.Context, registry *metrics.Registry, writer kafkaBa
status := "ok"
defer func() {
recordBridgeBatchDuration(registry, status, time.Since(started))
recordBridgeBatchPending(registry, 0, 0)
}()
kafkaMessages := make([]kafka.Message, 0, len(messages))
for _, message := range messages {
routed := make([]routedBridgeMessage, 0, len(messages)*2)
sources := make([]bridgeSourceState, len(messages))
for sourceIndex, message := range messages {
sources[sourceIndex].source = message
addBridgeSubjectMetric(registry, "vehicle_bridge_messages_total", message.subject, "received")
topic, ok := route[message.subject]
if !ok || topic == "" {
addBridgeSubjectMetric(registry, "vehicle_bridge_messages_total", message.subject, "route_error")
status = "error"
return fmt.Errorf("kafka topic not configured for nats subject %q", message.subject)
}
kafkaMessages = append(kafkaMessages, kafkaMessage(topic, message.data))
}
recordBridgeBatchPending(registry, len(messages), len(kafkaMessages))
if err := writer.WriteMessages(ctx, kafkaMessages...); err != nil {
for _, message := range kafkaMessages {
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", message.Topic, "error")
}
status = "error"
return err
}
for _, message := range kafkaMessages {
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", message.Topic, "ok")
}
for _, message := range messages {
if message.ack == nil {
if message.ack != nil {
if err := message.ack(); err != nil {
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "error")
status = "error"
return fmt.Errorf("ack unrouted nats subject %q: %w", message.subject, err)
}
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "dropped_route_error")
}
continue
}
if err := message.ack(); err != nil {
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "error")
kafkaMessage, receivedAtMS, decoded, decodeErr := kafkaMessageWithEnvelope(topic, message.data)
sources[sourceIndex].routed = true
routed = append(routed, routedBridgeMessage{
sourceIndex: sourceIndex,
kafka: kafkaMessage,
receivedAtMS: receivedAtMS,
})
if !deriveFieldsFromRaw {
continue
}
projection, ok := rawFieldRoutes[message.subject]
if !ok {
continue
}
fieldsMessage, fieldsReceivedAtMS, fieldCount, projectionStatus, projected := projectRawFields(message.subject, decoded, decodeErr, projection)
recordBridgeFieldsProjection(registry, projection.Protocol, projectionStatus, fieldCount)
if !projected {
continue
}
routed = append(routed, routedBridgeMessage{
sourceIndex: sourceIndex,
kafka: fieldsMessage,
receivedAtMS: fieldsReceivedAtMS,
})
}
if len(routed) == 0 {
addBridgeBatchPending(registry, 0, 0)
return nil
}
addBridgeBatchPending(registry, len(messages), len(routed))
defer addBridgeBatchPending(registry, -len(messages), -len(routed))
groups := groupRoutedBridgeMessagesByTopic(routed)
results := writeBridgeKafkaGroups(ctx, writer, groups, kafkaWriteConcurrency)
var firstErr error
for _, result := range results {
group := result.group
err := result.err
recordBridgeKafkaWriteDuration(registry, group[0].kafka.Topic, statusFromError(err), result.elapsed)
if err != nil {
for _, item := range group {
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", item.kafka.Topic, "error")
sources[item.sourceIndex].failed = true
}
status = "error"
if firstErr == nil {
firstErr = fmt.Errorf("kafka write topic %s: %w", group[0].kafka.Topic, err)
}
continue
}
for _, item := range group {
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", item.kafka.Topic, "ok")
recordBridgeKafkaE2EDuration(registry, item.kafka.Topic, item.receivedAtMS)
}
}
for i := range sources {
source := &sources[i]
if !source.routed || source.failed || source.source.ack == nil {
continue
}
if err := source.source.ack(); err != nil {
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", source.source.subject, "error")
status = "error"
return err
}
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "ok")
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", source.source.subject, "ok")
}
return nil
return firstErr
}
type bridgeKafkaWriteResult struct {
group []routedBridgeMessage
elapsed time.Duration
err error
}
func writeBridgeKafkaGroups(ctx context.Context, writer kafkaBatchWriter, groups [][]routedBridgeMessage, concurrency int) []bridgeKafkaWriteResult {
if len(groups) == 0 {
return nil
}
if concurrency < 1 {
concurrency = 1
}
if concurrency > len(groups) {
concurrency = len(groups)
}
semaphore := make(chan struct{}, concurrency)
results := make(chan bridgeKafkaWriteResult, len(groups))
for _, group := range groups {
group := group
semaphore <- struct{}{}
go func() {
defer func() { <-semaphore }()
messages := make([]kafka.Message, 0, len(group))
for _, item := range group {
messages = append(messages, item.kafka)
}
started := time.Now()
err := writer.WriteMessages(ctx, messages...)
results <- bridgeKafkaWriteResult{group: group, elapsed: time.Since(started), err: err}
}()
}
out := make([]bridgeKafkaWriteResult, 0, len(groups))
for range groups {
out = append(out, <-results)
}
return out
}
func projectRawFields(subject string, raw envelope.FrameEnvelope, decodeErr error, projection fieldsProjectionRoute) (kafka.Message, int64, int, string, bool) {
if decodeErr != nil {
return kafka.Message{}, 0, 0, "invalid_json", false
}
if _, err := topics.ValidateRawEnvelope(subject, raw); err != nil {
return kafka.Message{}, 0, 0, "invalid_envelope", false
}
if raw.Protocol != projection.Protocol {
return kafka.Message{}, 0, 0, "protocol_mismatch", false
}
if !envelope.IsRealtimeTelemetryFrame(raw) {
return kafka.Message{}, 0, 0, "skipped_non_realtime", false
}
if len(raw.ParsedFields) == 0 {
return kafka.Message{}, 0, 0, "skipped_missing_fields", false
}
fields, ok := realtime.BuildFieldsEnvelope(raw)
if !ok || len(fields.Fields) == 0 {
return kafka.Message{}, 0, 0, "skipped_missing_fields", false
}
if _, err := topics.ValidateFieldsEnvelope(projection.Topic, fields); err != nil {
return kafka.Message{}, 0, 0, "invalid_fields_envelope", false
}
payload, err := fields.MarshalJSONBytes()
if err != nil {
return kafka.Message{}, 0, 0, "marshal_error", false
}
result := kafka.Message{Topic: projection.Topic, Key: fields.KafkaKey(), Value: payload}
return result, fields.ReceivedAtMS, len(fields.Fields), "published", true
}
func groupRoutedBridgeMessagesByTopic(messages []routedBridgeMessage) [][]routedBridgeMessage {
if len(messages) == 0 {
return nil
}
groupsByTopic := make(map[string][]routedBridgeMessage)
for _, message := range messages {
groupsByTopic[message.kafka.Topic] = append(groupsByTopic[message.kafka.Topic], message)
}
topics := make([]string, 0, len(groupsByTopic))
for topic := range groupsByTopic {
topics = append(topics, topic)
}
sort.Strings(topics)
groups := make([][]routedBridgeMessage, 0, len(topics))
for _, topic := range topics {
groups = append(groups, groupsByTopic[topic])
}
return groups
}
var bridgeBatchDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var bridgeKafkaWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var bridgeKafkaE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
var bridgeKafkaE2ERecent = metrics.NewRecentLatencyByKey(512)
var bridgeBatchPending = metrics.PendingPairGauge{}
func addBridgeSubjectMetric(registry *metrics.Registry, name string, subject string, status string) {
if registry == nil {
return
}
registry.IncCounter(name, metrics.Labels{"subject": subject, "status": status})
labels := metrics.Labels{"subject": subject, "status": status}
registry.IncCounter(name, labels)
if name == "vehicle_bridge_messages_total" {
metrics.RecordLastActivity(registry, "vehicle_bridge_last_message_unix_seconds", labels)
return
}
if name == "vehicle_bridge_nats_acks_total" {
metrics.RecordLastActivity(registry, "vehicle_bridge_last_ack_unix_seconds", labels)
}
}
func addBridgeTopicMetric(registry *metrics.Registry, name string, topic string, status string) {
if registry == nil {
return
}
registry.IncCounter(name, metrics.Labels{"topic": topic, "status": status})
labels := metrics.Labels{"topic": topic, "status": status}
registry.IncCounter(name, labels)
if name == "vehicle_bridge_kafka_writes_total" {
metrics.RecordLastActivity(registry, "vehicle_bridge_last_kafka_write_unix_seconds", labels)
}
}
func recordBridgeBatchPending(registry *metrics.Registry, messages int, kafkaMessages int) {
func recordBridgeFieldsProjection(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) {
if registry == nil {
return
}
registry.SetGauge("vehicle_bridge_batch_pending_messages", nil, float64(messages))
registry.SetGauge("vehicle_bridge_batch_pending_kafka_messages", nil, float64(kafkaMessages))
protocolLabel := strings.TrimSpace(string(protocol))
if protocolLabel == "" {
protocolLabel = "unknown"
}
if strings.TrimSpace(status) == "" {
status = "unknown"
}
labels := metrics.Labels{"protocol": protocolLabel, "status": status}
registry.IncCounter("vehicle_bridge_fields_projection_total", labels)
metrics.RecordLastActivity(registry, "vehicle_bridge_last_fields_projection_unix_seconds", labels)
if status == "published" {
registry.SetGauge("vehicle_bridge_fields_projection_count", labels, float64(fieldCount))
}
}
func addBridgeBatchPending(registry *metrics.Registry, messages int, kafkaMessages int) {
if registry == nil {
return
}
bridgeBatchPending.Add(registry, "vehicle_bridge_batch_pending_messages", "vehicle_bridge_batch_pending_kafka_messages", messages, kafkaMessages)
}
func recordBridgeBatchDuration(registry *metrics.Registry, status string, elapsed time.Duration) {
@@ -323,6 +707,41 @@ func recordBridgeBatchDuration(registry *metrics.Registry, status string, elapse
}, bridgeBatchDurationBucketsMS, float64(elapsed.Milliseconds()))
}
func recordBridgeKafkaWriteDuration(registry *metrics.Registry, topic string, status string, elapsed time.Duration) {
if registry == nil {
return
}
registry.ObserveHistogram("vehicle_bridge_kafka_write_duration_ms_histogram", metrics.Labels{
"topic": topic,
"status": status,
}, bridgeKafkaWriteDurationBucketsMS, float64(elapsed.Milliseconds()))
}
func recordBridgeKafkaE2EDuration(registry *metrics.Registry, topic string, receivedAtMS int64) {
if registry == nil || receivedAtMS <= 0 {
return
}
elapsedMS := time.Since(time.UnixMilli(receivedAtMS)).Milliseconds()
if elapsedMS < 0 {
elapsedMS = 0
}
labels := metrics.Labels{
"topic": topic,
}
registry.ObserveHistogram("vehicle_bridge_kafka_e2e_duration_ms_histogram", labels, bridgeKafkaE2EDurationBucketsMS, float64(elapsedMS))
p99, samples := bridgeKafkaE2ERecent.Observe(topic, float64(elapsedMS))
registry.SetGauge("vehicle_bridge_kafka_e2e_recent_p99_ms", labels, p99)
registry.SetGauge("vehicle_bridge_kafka_e2e_recent_samples", labels, float64(samples))
metrics.RecordLastActivity(registry, "vehicle_bridge_last_kafka_e2e_unix_seconds", labels)
}
func statusFromError(err error) string {
if err != nil {
return "error"
}
return "ok"
}
func recordNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info *nats.ConsumerInfo) {
if registry == nil || info == nil {
return
@@ -334,12 +753,23 @@ func recordNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info
}
func kafkaMessage(topic string, data []byte) kafka.Message {
message, _ := kafkaMessageWithReceivedAt(topic, data)
return message
}
func kafkaMessageWithReceivedAt(topic string, data []byte) (kafka.Message, int64) {
message, receivedAtMS, _, _ := kafkaMessageWithEnvelope(topic, data)
return message, receivedAtMS
}
func kafkaMessageWithEnvelope(topic string, data []byte) (kafka.Message, int64, envelope.FrameEnvelope, error) {
var env envelope.FrameEnvelope
message := kafka.Message{Topic: topic, Value: data}
if err := json.Unmarshal(data, &env); err == nil {
message.Key = env.KafkaKey()
if err := json.Unmarshal(data, &env); err != nil {
return message, 0, envelope.FrameEnvelope{}, err
}
return message
message.Key = env.KafkaKey()
return message, env.ReceivedAtMS, env, nil
}
func env(key string, fallback string) string {
@@ -350,6 +780,25 @@ func env(key string, fallback string) string {
return value
}
func envBool(key string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func boolMetric(value bool) float64 {
if value {
return 1
}
return 0
}
func envOptional(key string) (string, bool) {
value, ok := os.LookupEnv(key)
if !ok {

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -13,6 +15,7 @@ import (
"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) {
@@ -65,12 +68,303 @@ func TestBridgeBatchDoesNotAckWhenKafkaFails(t *testing.T) {
}
}
func TestBridgeBatchRecordsMetrics(t *testing.T) {
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{}
@@ -83,8 +377,16 @@ func TestBridgeBatchRecordsMetrics(t *testing.T) {
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)
@@ -92,6 +394,16 @@ func TestBridgeBatchRecordsMetrics(t *testing.T) {
}
}
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)
@@ -135,6 +447,73 @@ func TestBridgeBatchExposesPendingAndDurationMetrics(t *testing.T) {
}
}
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"}
@@ -157,8 +536,45 @@ func TestRecordNATSConsumerInfoMetrics(t *testing.T) {
}
}
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",
@@ -176,6 +592,75 @@ func TestLoadConfigDefaultsToGoSubjectRoutes(t *testing.T) {
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) {
@@ -187,11 +672,27 @@ func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
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()
@@ -201,6 +702,57 @@ func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
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) {
@@ -215,8 +767,11 @@ func TestLoadConfigIncludesUnifiedOnlyWhenExplicitlyConfigured(t *testing.T) {
}
type recordingBridgeWriter struct {
mu sync.Mutex
messages []kafka.Message
calls [][]kafka.Message
err error
topicErr map[string]error
onWrite func()
}
@@ -224,9 +779,72 @@ func (w *recordingBridgeWriter) WriteMessages(_ context.Context, messages ...kaf
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)
}
}