package main import ( "context" "encoding/json" "errors" "fmt" "log/slog" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" "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("vehicle-fields-projector") ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() cfg := loadConfig() if err := cfg.Validate(); err != nil { logger.Error("invalid fields projector config", "error", err) os.Exit(1) } if err := pingKafka(ctx, cfg.KafkaBrokers); err != nil { logger.Error("kafka connectivity check failed", "error", err) os.Exit(1) } registry := metrics.NewRegistry() recordConfigMetrics(registry, cfg) for _, route := range cfg.Routes { metrics.RegisterKafkaConsumerInfo(registry, "vehicle-fields-projector", route.GroupID, []string{route.RawTopic}) } health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-fields-projector", nil, registry)) logger.Info("fields projector started", "kafka_brokers", strings.Join(cfg.KafkaBrokers, ","), "group_prefix", cfg.GroupPrefix, "workers_per_protocol", cfg.WorkersPerProtocol, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait.Milliseconds(), "operation_timeout_ms", cfg.OperationTimeout.Milliseconds(), "retry_delay_ms", cfg.RetryDelay.Milliseconds(), "start_offset", cfg.StartOffsetName) var workers sync.WaitGroup for _, route := range cfg.Routes { for workerID := 1; workerID <= cfg.WorkersPerProtocol; workerID++ { workers.Add(1) go func(route projectionRoute, workerID int) { defer workers.Done() runProjector(ctx, logger.With("protocol", route.Protocol, "worker", workerID), registry, cfg, route, workerID) }(route, workerID) } } workers.Wait() } type config struct { KafkaBrokers []string GroupPrefix string Routes []projectionRoute WorkersPerProtocol int BatchSize int BatchWait time.Duration OperationTimeout time.Duration RetryDelay time.Duration StartOffset int64 StartOffsetName string } type projectionRoute struct { Protocol envelope.Protocol RawTopic string FieldsTopic string GroupID string } func loadConfig() config { groupPrefix := env("FIELDS_PROJECTOR_GROUP_PREFIX", "vehicle-fields-projector-v1") startOffsetName := strings.ToLower(env("KAFKA_START_OFFSET", "last")) startOffset := int64(kafka.LastOffset) if startOffsetName == "first" { startOffset = kafka.FirstOffset } else { startOffsetName = "last" } routes := []projectionRoute{ {Protocol: envelope.ProtocolGB32960, RawTopic: env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960), FieldsTopic: env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)}, {Protocol: envelope.ProtocolJT808, RawTopic: env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808), FieldsTopic: env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)}, {Protocol: envelope.ProtocolYutongMQTT, RawTopic: env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT), FieldsTopic: env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)}, } for index := range routes { routes[index].GroupID = groupPrefix + "-" + strings.ToLower(strings.ReplaceAll(string(routes[index].Protocol), "_", "-")) } return config{ KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")), GroupPrefix: groupPrefix, Routes: routes, WorkersPerProtocol: envInt("FIELDS_PROJECTOR_WORKERS_PER_PROTOCOL", 1), BatchSize: envInt("FIELDS_PROJECTOR_BATCH_SIZE", 500), BatchWait: time.Duration(envInt("FIELDS_PROJECTOR_BATCH_WAIT_MS", 20)) * time.Millisecond, OperationTimeout: time.Duration(envInt("FIELDS_PROJECTOR_OPERATION_TIMEOUT_MS", 30000)) * time.Millisecond, RetryDelay: time.Duration(envInt("FIELDS_PROJECTOR_RETRY_DELAY_MS", 500)) * time.Millisecond, StartOffset: startOffset, StartOffsetName: startOffsetName, } } func (c config) Validate() error { if len(c.KafkaBrokers) == 0 { return errors.New("kafka brokers are required") } if strings.TrimSpace(c.GroupPrefix) == "" { return errors.New("fields projector group prefix is required") } if c.WorkersPerProtocol < 1 || c.BatchSize < 1 || c.BatchWait <= 0 || c.OperationTimeout <= 0 || c.RetryDelay <= 0 { return errors.New("fields projector worker, batch and timeout settings must be positive") } raw := make(map[string]string, len(c.Routes)) fields := make(map[string]string, len(c.Routes)) groups := map[string]struct{}{} for _, route := range c.Routes { protocol := string(route.Protocol) raw[protocol] = route.RawTopic fields[protocol] = route.FieldsTopic if strings.TrimSpace(route.GroupID) == "" { return fmt.Errorf("consumer group is required for protocol %s", route.Protocol) } if _, exists := groups[route.GroupID]; exists { return fmt.Errorf("duplicate consumer group %q", route.GroupID) } groups[route.GroupID] = struct{}{} } return topics.ValidateKafkaRawFields(raw, fields) } type kafkaBatchWriter interface { WriteMessages(context.Context, ...kafka.Message) error } type kafkaMessageFetcher interface { FetchMessage(context.Context) (kafka.Message, error) } type kafkaMessageCommitter interface { CommitMessages(context.Context, ...kafka.Message) error } func runProjector(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, cfg config, route projectionRoute, workerID int) { reader := kafka.NewReader(kafka.ReaderConfig{ Brokers: cfg.KafkaBrokers, GroupID: route.GroupID, GroupTopics: []string{route.RawTopic}, StartOffset: cfg.StartOffset, MinBytes: 1, MaxBytes: 10e6, }) defer reader.Close() writer := &kafka.Writer{ Addr: kafka.TCP(cfg.KafkaBrokers...), Balancer: &kafka.Hash{}, RequiredAcks: kafka.RequireAll, AllowAutoTopicCreation: false, BatchTimeout: cfg.BatchWait, Async: false, } defer writer.Close() labels := metrics.Labels{"protocol": string(route.Protocol), "worker": strconv.Itoa(workerID)} registry.SetGauge("vehicle_fields_projector_worker_active", labels, 1) defer registry.SetGauge("vehicle_fields_projector_worker_active", labels, 0) for { first, err := reader.FetchMessage(ctx) if err != nil { if ctx.Err() != nil { return } logger.Error("kafka fetch failed", "error", err) continue } batch := collectBatch(ctx, reader, first, cfg.BatchSize, cfg.BatchWait) processBatchReliably(ctx, logger, registry, writer, reader, route, batch, cfg.OperationTimeout, cfg.RetryDelay) } } func collectBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message { if maxSize <= 1 { return []kafka.Message{first} } batch := []kafka.Message{first} deadline := time.Now().Add(maxWait) for len(batch) < maxSize { remaining := time.Until(deadline) if remaining <= 0 { break } fetchCtx, cancel := context.WithTimeout(ctx, remaining) message, err := fetcher.FetchMessage(fetchCtx) cancel() if err != nil { break } batch = append(batch, message) } return batch } func processBatchReliably(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, writer kafkaBatchWriter, committer kafkaMessageCommitter, route projectionRoute, messages []kafka.Message, operationTimeout time.Duration, retryDelay time.Duration) { labels := metrics.Labels{"protocol": string(route.Protocol)} defer registry.SetGauge("vehicle_fields_projector_retry_pending_messages", labels, 0) for len(messages) > 0 { operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), operationTimeout) outputs, err := projectBatch(operationCtx, logger, registry, writer, route, messages) cancel() if err != nil { registry.SetGauge("vehicle_fields_projector_retry_pending_messages", labels, float64(len(messages))) registry.IncCounter("vehicle_fields_projector_batch_retries_total", metrics.Labels{"protocol": string(route.Protocol), "reason": "write_error"}) if !waitForRetry(ctx, retryDelay) { return } continue } commitCtx, commitCancel := context.WithTimeout(context.WithoutCancel(ctx), operationTimeout) err = committer.CommitMessages(commitCtx, messages...) commitCancel() if err == nil { for _, message := range messages { recordMessageMetric(registry, "vehicle_fields_projector_kafka_commits_total", message.Topic, "ok") } _ = outputs return } for _, message := range messages { recordMessageMetric(registry, "vehicle_fields_projector_kafka_commits_total", message.Topic, "error") } registry.SetGauge("vehicle_fields_projector_retry_pending_messages", labels, float64(len(messages))) registry.IncCounter("vehicle_fields_projector_batch_retries_total", metrics.Labels{"protocol": string(route.Protocol), "reason": "commit_error"}) logger.Error("kafka source offset commit failed", "topic", route.RawTopic, "messages", len(messages), "error", err) if !retryCommit(ctx, logger, registry, committer, messages, route.Protocol, operationTimeout, retryDelay) { return } return } } func projectBatch(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, writer kafkaBatchWriter, route projectionRoute, messages []kafka.Message) (int, error) { if len(messages) == 0 { return 0, nil } outputs := make([]kafka.Message, 0, len(messages)) setBatchPending(registry, route.Protocol, len(messages), 0) defer setBatchPending(registry, route.Protocol, -len(messages), -len(outputs)) for _, message := range messages { recordMessageMetric(registry, "vehicle_fields_projector_kafka_messages_total", message.Topic, "received") recordKafkaLag(registry, message) var raw envelope.FrameEnvelope if err := json.Unmarshal(message.Value, &raw); err != nil { recordMessageMetric(registry, "vehicle_fields_projector_kafka_messages_total", message.Topic, "invalid_json") logger.Warn("skip invalid raw envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err) continue } if status, err := topics.ValidateRawEnvelope(route.RawTopic, raw); err != nil { recordMessageMetric(registry, "vehicle_fields_projector_kafka_messages_total", message.Topic, status) logger.Warn("skip mismatched raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", raw.Protocol, "event_id", raw.StableEventID(), "error", err) continue } fields, ok := realtime.BuildFieldsEnvelope(raw) if !ok { status := "skipped_missing_fields" if !envelope.IsRealtimeTelemetryFrame(raw) { status = "skipped_non_realtime" } recordProjectionMetric(registry, route.Protocol, status, 0) continue } if status, err := topics.ValidateFieldsEnvelope(route.FieldsTopic, fields); err != nil { recordProjectionMetric(registry, route.Protocol, status, len(fields.Fields)) logger.Warn("skip invalid projected fields envelope", "topic", route.FieldsTopic, "protocol", fields.Protocol, "event_id", fields.StableEventID(), "error", err) continue } payload, err := fields.MarshalJSONBytes() if err != nil { recordProjectionMetric(registry, route.Protocol, "marshal_error", len(fields.Fields)) logger.Warn("skip fields envelope marshal error", "topic", route.FieldsTopic, "event_id", fields.StableEventID(), "error", err) continue } outputs = append(outputs, kafka.Message{ Topic: route.FieldsTopic, Key: fields.KafkaKey(), Value: payload, Time: message.Time, }) recordProjectionMetric(registry, route.Protocol, "projected", len(fields.Fields)) } setBatchPending(registry, route.Protocol, 0, len(outputs)) defer setBatchPending(registry, route.Protocol, 0, -len(outputs)) if len(outputs) == 0 { return 0, nil } started := time.Now() err := writer.WriteMessages(ctx, outputs...) status := "ok" if err != nil { status = "error" } recordWriteDuration(registry, route.FieldsTopic, status, time.Since(started)) for range outputs { recordMessageMetric(registry, "vehicle_fields_projector_kafka_writes_total", route.FieldsTopic, status) } if err != nil { logger.Error("fields kafka write failed", "topic", route.FieldsTopic, "messages", len(outputs), "error", err) return len(outputs), err } return len(outputs), nil } func retryCommit(ctx context.Context, logger interface { Error(string, ...any) }, registry *metrics.Registry, committer kafkaMessageCommitter, messages []kafka.Message, protocol envelope.Protocol, operationTimeout, retryDelay time.Duration) bool { for { if !waitForRetry(ctx, retryDelay) { return false } operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), operationTimeout) err := committer.CommitMessages(operationCtx, messages...) cancel() if err == nil { for _, message := range messages { recordMessageMetric(registry, "vehicle_fields_projector_kafka_commits_total", message.Topic, "ok") } return true } for _, message := range messages { recordMessageMetric(registry, "vehicle_fields_projector_kafka_commits_total", message.Topic, "error") } registry.IncCounter("vehicle_fields_projector_batch_retries_total", metrics.Labels{"protocol": string(protocol), "reason": "commit_error"}) logger.Error("kafka source offset commit retry failed", "messages", len(messages), "error", err) } } func waitForRetry(ctx context.Context, delay time.Duration) bool { timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } func pingKafka(ctx context.Context, brokers []string) error { if len(brokers) == 0 { return errors.New("kafka broker is required") } checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() conn, err := kafka.DialContext(checkCtx, "tcp", brokers[0]) if err != nil { return err } return conn.Close() } func recordConfigMetrics(registry *metrics.Registry, cfg config) { registry.SetGauge("vehicle_fields_projector_config", metrics.Labels{"setting": "workers_per_protocol"}, float64(cfg.WorkersPerProtocol)) registry.SetGauge("vehicle_fields_projector_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize)) registry.SetGauge("vehicle_fields_projector_config", metrics.Labels{"setting": "batch_wait_ms"}, float64(cfg.BatchWait.Milliseconds())) registry.SetGauge("vehicle_fields_projector_config", metrics.Labels{"setting": "operation_timeout_ms"}, float64(cfg.OperationTimeout.Milliseconds())) } func recordMessageMetric(registry *metrics.Registry, name, topic, status string) { if registry == nil { return } labels := metrics.Labels{"topic": topic, "status": status} registry.IncCounter(name, labels) switch name { case "vehicle_fields_projector_kafka_messages_total": metrics.RecordLastActivity(registry, "vehicle_fields_projector_last_message_unix_seconds", labels) case "vehicle_fields_projector_kafka_writes_total": metrics.RecordLastActivity(registry, "vehicle_fields_projector_last_write_unix_seconds", labels) case "vehicle_fields_projector_kafka_commits_total": metrics.RecordLastActivity(registry, "vehicle_fields_projector_last_commit_unix_seconds", labels) } } func recordProjectionMetric(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) { if registry == nil { return } labels := metrics.Labels{"protocol": string(protocol), "status": status} registry.IncCounter("vehicle_fields_projector_projections_total", labels) if fieldCount > 0 { registry.SetGauge("vehicle_fields_projector_field_count", labels, float64(fieldCount)) } } func recordKafkaLag(registry *metrics.Registry, message kafka.Message) { if registry == nil { return } lag := message.HighWaterMark - message.Offset - 1 if lag < 0 { lag = 0 } registry.SetGauge("vehicle_fields_projector_kafka_lag", metrics.Labels{ "topic": message.Topic, "partition": strconv.Itoa(message.Partition), }, float64(lag)) } var projectorPendingMu sync.Mutex var projectorPendingMessages = map[envelope.Protocol]int{} var projectorPendingFields = map[envelope.Protocol]int{} func setBatchPending(registry *metrics.Registry, protocol envelope.Protocol, messagesDelta, fieldsDelta int) { if registry == nil { return } projectorPendingMu.Lock() projectorPendingMessages[protocol] += messagesDelta projectorPendingFields[protocol] += fieldsDelta messages := projectorPendingMessages[protocol] fields := projectorPendingFields[protocol] projectorPendingMu.Unlock() labels := metrics.Labels{"protocol": string(protocol)} registry.SetGauge("vehicle_fields_projector_batch_pending_messages", labels, float64(messages)) registry.SetGauge("vehicle_fields_projector_batch_pending_fields", labels, float64(fields)) } var projectorWriteBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000} func recordWriteDuration(registry *metrics.Registry, topic, status string, elapsed time.Duration) { if registry == nil { return } registry.ObserveHistogram("vehicle_fields_projector_write_duration_ms_histogram", metrics.Labels{ "topic": topic, "status": status, }, projectorWriteBucketsMS, float64(elapsed.Milliseconds())) } func env(key, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value } 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 { 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 }