852 lines
28 KiB
Go
852 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"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/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"
|
|
)
|
|
|
|
func main() {
|
|
logger := observability.NewLogger("nats-kafka-bridge")
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
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)
|
|
os.Exit(1)
|
|
}
|
|
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 {
|
|
return fmt.Errorf("nats status is %s", conn.Status().String())
|
|
}
|
|
return nil
|
|
}},
|
|
}, registry))
|
|
js, err := conn.JetStream()
|
|
if err != nil {
|
|
logger.Error("nats jetstream init failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := ensureStream(js, cfg); err != nil {
|
|
logger.Error("nats stream ensure failed", "stream", cfg.NATSStream, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
sub, err := js.PullSubscribe(
|
|
cfg.NATSFilter,
|
|
cfg.NATSDurable,
|
|
nats.BindStream(cfg.NATSStream),
|
|
nats.ManualAck(),
|
|
nats.AckWait(cfg.AckWait),
|
|
nats.MaxDeliver(-1),
|
|
)
|
|
if err != nil {
|
|
logger.Error("nats pull consumer init failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
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, ","),
|
|
"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
|
|
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 {
|
|
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
|
|
}
|
|
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) {
|
|
subject, hasSubject := envOptional("NATS_SUBJECT_UNIFIED")
|
|
topic, hasTopic := envOptional("KAFKA_TOPIC_UNIFIED")
|
|
if !hasSubject && !hasTopic {
|
|
return "", "", false
|
|
}
|
|
if subject == "" {
|
|
subject = topic
|
|
}
|
|
if topic == "" {
|
|
topic = subject
|
|
}
|
|
if subject == "" || topic == "" {
|
|
return "", "", false
|
|
}
|
|
return subject, topic, true
|
|
}
|
|
|
|
type natsPullSubscription interface {
|
|
Fetch(int, ...nats.PullOpt) ([]*nats.Msg, error)
|
|
}
|
|
|
|
type natsConsumerInfoReader interface {
|
|
ConsumerInfo(stream string, name string, opts ...nats.JSOpt) (*nats.ConsumerInfo, error)
|
|
}
|
|
|
|
type kafkaBatchWriter interface {
|
|
WriteMessages(context.Context, ...kafka.Message) error
|
|
}
|
|
|
|
type bridgeMessage struct {
|
|
subject string
|
|
data []byte
|
|
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 {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if time.Since(lastConsumerInfoAt) >= time.Duration(envInt("NATS_CONSUMER_METRICS_INTERVAL_SECONDS", 10))*time.Second {
|
|
lastConsumerInfoAt = time.Now()
|
|
if infoReader != nil {
|
|
info, err := infoReader.ConsumerInfo(cfg.NATSStream, cfg.NATSDurable, nats.Context(ctx))
|
|
if err != nil {
|
|
logger.Warn("nats consumer info failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err)
|
|
} else {
|
|
recordNATSConsumerInfoMetrics(registry, cfg, info)
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
bridgeMessages := make([]bridgeMessage, 0, len(msgs))
|
|
for _, msg := range msgs {
|
|
msg := msg
|
|
bridgeMessages = append(bridgeMessages, bridgeMessage{
|
|
subject: msg.Subject,
|
|
data: msg.Data,
|
|
ack: func() error {
|
|
return msg.Ack()
|
|
},
|
|
})
|
|
}
|
|
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.OperationWait)
|
|
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)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
opts := []nats.JSOpt{nats.Context(ctx)}
|
|
stream := &nats.StreamConfig{
|
|
Name: cfg.NATSStream,
|
|
Subjects: cfg.NATSSubjects,
|
|
Storage: nats.FileStorage,
|
|
Retention: nats.LimitsPolicy,
|
|
MaxAge: cfg.StreamMaxAge,
|
|
MaxBytes: cfg.StreamMaxBytes,
|
|
Duplicates: 2 * time.Minute,
|
|
}
|
|
if _, err := js.StreamInfo(cfg.NATSStream, opts...); err == nil {
|
|
_, err = js.UpdateStream(stream, opts...)
|
|
return err
|
|
}
|
|
_, err := js.AddStream(stream, opts...)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
_, updateErr := js.UpdateStream(stream, opts...)
|
|
if updateErr == nil {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
started := time.Now()
|
|
status := "ok"
|
|
defer func() {
|
|
recordBridgeBatchDuration(registry, status, time.Since(started))
|
|
}()
|
|
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")
|
|
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
|
|
}
|
|
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", source.source.subject, "ok")
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
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 recordBridgeFieldsProjection(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
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) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
registry.ObserveHistogram("vehicle_bridge_batch_duration_ms_histogram", metrics.Labels{
|
|
"status": status,
|
|
}, 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
|
|
}
|
|
labels := metrics.Labels{"stream": cfg.NATSStream, "consumer": cfg.NATSDurable}
|
|
registry.SetGauge("vehicle_bridge_nats_consumer_pending", labels, float64(info.NumPending))
|
|
registry.SetGauge("vehicle_bridge_nats_consumer_ack_pending", labels, float64(info.NumAckPending))
|
|
registry.SetGauge("vehicle_bridge_nats_consumer_waiting", labels, float64(info.NumWaiting))
|
|
}
|
|
|
|
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 {
|
|
return message, 0, envelope.FrameEnvelope{}, err
|
|
}
|
|
message.Key = env.KafkaKey()
|
|
return message, env.ReceivedAtMS, env, nil
|
|
}
|
|
|
|
func env(key string, fallback string) string {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
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 {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(value), true
|
|
}
|
|
|
|
func envInt(key string, fallback int) int {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func envInt64(key string, fallback int64) int64 {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func splitCSV(value string) []string {
|
|
var out []string
|
|
for _, item := range strings.Split(value, ",") {
|
|
item = strings.TrimSpace(item)
|
|
if item != "" {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mapKeys(values map[string]string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for key := range values {
|
|
out = append(out, key)
|
|
}
|
|
return out
|
|
}
|