package main import ( "context" "database/sql" "encoding/json" "errors" "fmt" "os" "os/signal" "sort" "strconv" "strings" "sync" "syscall" "time" "github.com/segmentio/kafka-go" _ "github.com/taosdata/driver-go/v3/taosWS" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/health" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/history" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics" ) func main() { logger := observability.NewLogger("vehicle-history-writer") ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() cfg := loadConfig() if err := cfg.Validate(); err != nil { logger.Error("invalid history writer config", "error", err) os.Exit(1) } db, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN) if err != nil { logger.Error("tdengine open failed", "error", err) os.Exit(1) } defer db.Close() if err := db.PingContext(ctx); err != nil { logger.Error("tdengine ping failed", "error", err) os.Exit(1) } registry := metrics.NewRegistry() metrics.RegisterKafkaConsumerInfo(registry, "vehicle-history-writer", cfg.KafkaGroup, cfg.KafkaTopics) registry.SetGauge("vehicle_history_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers)) health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-history-writer", []health.Check{ {Name: "tdengine", Check: db.PingContext}, }, registry)) writer := history.NewWriterWithDatabase(db, cfg.TDengineDatabase) if cfg.EnsureSchema { if err := writer.EnsureSchema(ctx, cfg.TDengineDatabase); err != nil { logger.Error("tdengine schema bootstrap failed", "error", err) os.Exit(1) } } var appender historyAppender = retryHistoryAppender{ delegate: writer, attempts: cfg.RetryAttempts, delay: cfg.RetryDelay, registry: registry, } logger.Info("history writer started", "driver", cfg.TDengineDriver, "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds()) var workers sync.WaitGroup for workerID := 1; workerID <= cfg.Workers; workerID++ { workers.Add(1) go func(id int) { defer workers.Done() runHistoryConsumer(ctx, logger, registry, appender, cfg, id) }(workerID) } workers.Wait() } func runHistoryConsumer(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, appender historyAppender, cfg config, workerID int) { reader := kafka.NewReader(kafka.ReaderConfig{ Brokers: cfg.KafkaBrokers, GroupID: cfg.KafkaGroup, GroupTopics: cfg.KafkaTopics, MinBytes: 1, MaxBytes: 10e6, }) defer reader.Close() workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)} registry.SetGauge("vehicle_history_worker_active", workerLabels, 1) defer registry.SetGauge("vehicle_history_worker_active", workerLabels, 0) for { message, err := reader.FetchMessage(ctx) if err != nil { if ctx.Err() != nil { return } logger.Error("kafka fetch failed", "error", err) continue } batch := collectHistoryBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond) processHistoryBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels) } } const kafkaMessageOperationTimeout = 30 * time.Second type historyAppender interface { AppendAll(context.Context, envelope.FrameEnvelope) error AppendAllBatch(context.Context, []envelope.FrameEnvelope) error } type historyResultAppender interface { AppendAllWithResult(context.Context, envelope.FrameEnvelope) (history.AppendResult, error) AppendAllBatchWithResult(context.Context, []envelope.FrameEnvelope) (history.AppendResult, error) } type kafkaMessageCommitter interface { CommitMessages(context.Context, ...kafka.Message) error } type kafkaMessageFetcher interface { FetchMessage(context.Context) (kafka.Message, error) } type historyBatchItem struct { message kafka.Message valid bool processed bool } type historyBatchOutcome struct { commitMessages []kafka.Message retryMessages []kafka.Message } func collectHistoryBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message { if maxSize <= 1 { return []kafka.Message{first} } if maxWait <= 0 { maxWait = 100 * time.Millisecond } 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 processHistoryMessage(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, message kafka.Message) { messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout) defer cancel() addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "received") addWriterLagMetric(registry, message) var env envelope.FrameEnvelope if err := json.Unmarshal(message.Value, &env); err != nil { addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "invalid_json") logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err) if err := committer.CommitMessages(messageCtx, message); err != nil { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error") logger.Error("kafka commit invalid envelope failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err) return } addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok") return } if status, err := topics.ValidateRawEnvelope(message.Topic, env); err != nil { addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, status) logger.Warn("skip mismatched history raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "event_kind", env.EventKind, "error", err) if err := committer.CommitMessages(messageCtx, message); err != nil { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error") logger.Error("kafka commit mismatched raw envelope failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "status", status, "error", err) return } addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok") return } recordHistoryParsedFieldMetrics(registry, message, env) result, err := appendHistoryEnvelope(messageCtx, appender, env) if err != nil { addWriterMetric(registry, "vehicle_history_writes_total", message, "error") logger.Error("tdengine raw append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err) return } recordHistoryDerivedMetrics(registry, []kafka.Message{message}, []envelope.FrameEnvelope{env}, result) if result.LocationError != nil { logger.Warn("tdengine location append failed after raw append", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", result.LocationError) } addWriterMetric(registry, "vehicle_history_writes_total", message, "ok") recordHistoryWriteE2EDuration(registry, message, env) if err := committer.CommitMessages(messageCtx, message); err != nil { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error") logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err) return } addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok") } func processHistoryBatch(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message) historyBatchOutcome { return processHistoryBatchForWorker(ctx, logger, registry, appender, committer, messages, nil) } func processHistoryBatchForWorker(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message, workerLabels metrics.Labels) historyBatchOutcome { if len(messages) == 0 { return historyBatchOutcome{} } messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout) defer cancel() envelopes := make([]envelope.FrameEnvelope, 0, len(messages)) validMessages := make([]kafka.Message, 0, len(messages)) validItemIndexes := make([]int, 0, len(messages)) items := make([]historyBatchItem, 0, len(messages)) for _, message := range messages { itemIndex := len(items) addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "received") addWriterLagMetric(registry, message) var env envelope.FrameEnvelope if err := json.Unmarshal(message.Value, &env); err != nil { addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "invalid_json") logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err) items = append(items, historyBatchItem{message: message, processed: true}) continue } if status, err := topics.ValidateRawEnvelope(message.Topic, env); err != nil { addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, status) logger.Warn("skip mismatched history raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "event_kind", env.EventKind, "error", err) items = append(items, historyBatchItem{message: message, processed: true}) continue } recordHistoryParsedFieldMetrics(registry, message, env) envelopes = append(envelopes, env) validMessages = append(validMessages, message) validItemIndexes = append(validItemIndexes, itemIndex) items = append(items, historyBatchItem{message: message, valid: true}) } if len(envelopes) > 0 { setBatchPendingForWorker(registry, workerLabels, len(messages), len(envelopes)) defer setBatchPendingForWorker(registry, workerLabels, 0, 0) started := time.Now() result, err := appendHistoryBatch(messageCtx, appender, envelopes) elapsed := time.Since(started) status := "ok" if err != nil { status = "error" } addBatchMetric(registry, "vehicle_history_batch_flush_total", status, 1) addBatchMetric(registry, "vehicle_history_batch_rows_total", status, float64(len(envelopes))) setBatchDuration(registry, status, elapsed) if err != nil { first := messages[0] logger.Error("tdengine raw batch append failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "rows", len(envelopes), "error", err) if isTransientTDengineHistoryError(err) { for _, message := range validMessages { addWriterMetric(registry, "vehicle_history_writes_total", message, "error") } addBatchMetric(registry, "vehicle_history_batch_fallback_total", "skipped_transient", 1) committed, commitErr := commitHistoryProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items) return historyFailureOutcome(messages, committed, commitErr) } if fallbackErr := fallbackHistoryBatchToSingles(messageCtx, logger, registry, appender, validMessages, envelopes, validItemIndexes, items); fallbackErr != nil { addBatchMetric(registry, "vehicle_history_batch_fallback_total", "error", 1) committed, commitErr := commitHistoryProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items) return historyFailureOutcome(messages, committed, commitErr) } addBatchMetric(registry, "vehicle_history_batch_fallback_total", "ok", 1) committed, commitErr := commitHistoryProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items) return historyFailureOutcome(messages, committed, commitErr) } recordHistoryDerivedMetrics(registry, validMessages, envelopes, result) if result.LocationError != nil { first := messages[0] logger.Warn("tdengine location batch append failed after raw append", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "rows", len(envelopes), "location_rows", result.LocationRows, "error", result.LocationError) } for index, message := range validMessages { addWriterMetric(registry, "vehicle_history_writes_total", message, "ok") recordHistoryWriteE2EDuration(registry, message, envelopes[index]) } } if err := committer.CommitMessages(messageCtx, messages...); err != nil { for _, message := range messages { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error") } first := messages[0] logger.Error("kafka batch commit failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err) return historyBatchOutcome{commitMessages: messages} } for _, message := range messages { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok") } return historyBatchOutcome{} } func historyFailureOutcome(messages []kafka.Message, committed []kafka.Message, commitErr error) historyBatchOutcome { outcome := historyBatchOutcome{ retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed), } if commitErr != nil { outcome.commitMessages = committed } return outcome } func processHistoryBatchReliably(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) { processHistoryBatchReliablyForWorker(ctx, logger, registry, appender, committer, messages, retryDelay, nil) } func processHistoryBatchReliablyForWorker(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) { defer registry.SetGauge("vehicle_history_retry_pending_messages", workerLabels, 0) pending := messages for len(pending) > 0 { outcome := processHistoryBatchForWorker(ctx, logger, registry, appender, committer, pending, workerLabels) if len(outcome.commitMessages) > 0 { registry.IncCounter("vehicle_history_batch_retries_total", metrics.Labels{"reason": "commit_error"}) if !retryHistoryCommit(ctx, logger, registry, committer, outcome.commitMessages, retryDelay) { return } } pending = outcome.retryMessages registry.SetGauge("vehicle_history_retry_pending_messages", workerLabels, float64(len(pending))) if len(pending) == 0 { return } registry.IncCounter("vehicle_history_batch_retries_total", metrics.Labels{"reason": "write_error"}) if !waitForHistoryRetry(ctx, retryDelay) { return } } } func retryHistoryCommit(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) bool { for len(messages) > 0 { operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout) err := committer.CommitMessages(operationCtx, messages...) cancel() if err == nil { for _, message := range messages { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok") } return true } for _, message := range messages { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error") } first := messages[0] logger.Error("kafka commit retry failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err) registry.IncCounter("vehicle_history_batch_retries_total", metrics.Labels{"reason": "commit_error"}) if !waitForHistoryRetry(ctx, retryDelay) { return false } } return true } func waitForHistoryRetry(ctx context.Context, retryDelay time.Duration) bool { if retryDelay <= 0 { retryDelay = 100 * time.Millisecond } timer := time.NewTimer(retryDelay) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } func fallbackHistoryBatchToSingles(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, appender historyAppender, messages []kafka.Message, envelopes []envelope.FrameEnvelope, itemIndexes []int, items []historyBatchItem) error { for index, env := range envelopes { message := messages[index] started := time.Now() result, err := appendHistoryEnvelope(ctx, appender, env) setFallbackDuration(registry, statusFromError(err), time.Since(started)) if err != nil { addWriterMetric(registry, "vehicle_history_writes_total", message, "error") logger.Error("tdengine raw single append failed after batch fallback", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err) return err } recordHistoryDerivedMetrics(registry, []kafka.Message{message}, []envelope.FrameEnvelope{env}, result) if result.LocationError != nil { logger.Warn("tdengine location append failed after raw single fallback", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", result.LocationError) } addWriterMetric(registry, "vehicle_history_writes_total", message, "ok") recordHistoryWriteE2EDuration(registry, message, env) items[itemIndexes[index]].processed = true } return nil } func commitHistoryProcessedPrefixAfterFailure(ctx context.Context, logger interface { Error(string, ...any) Warn(string, ...any) }, registry *metrics.Registry, committer kafkaMessageCommitter, items []historyBatchItem) ([]kafka.Message, error) { committable := processedHistoryPrefixMessagesByPartition(items) if len(committable) == 0 { return nil, nil } if err := committer.CommitMessages(ctx, committable...); err != nil { for _, message := range committable { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error") } first := committable[0] logger.Error("kafka processed-prefix commit failed after raw batch append failure", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(committable), "error", err) return committable, err } for _, message := range committable { addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok") } return committable, nil } func processedHistoryPrefixMessagesByPartition(items []historyBatchItem) []kafka.Message { type partitionKey struct { topic string partition int } groups := map[partitionKey][]historyBatchItem{} for _, item := range items { key := partitionKey{topic: item.message.Topic, partition: item.message.Partition} groups[key] = append(groups[key], item) } var keys []partitionKey for key := range groups { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { if keys[i].topic != keys[j].topic { return keys[i].topic < keys[j].topic } return keys[i].partition < keys[j].partition }) var out []kafka.Message for _, key := range keys { group := groups[key] sort.Slice(group, func(i, j int) bool { return group[i].message.Offset < group[j].message.Offset }) var previousOffset int64 for index, item := range group { if index > 0 && item.message.Offset != previousOffset+1 { break } if !item.processed { break } out = append(out, item.message) previousOffset = item.message.Offset } } return out } func appendHistoryEnvelope(ctx context.Context, appender historyAppender, env envelope.FrameEnvelope) (history.AppendResult, error) { if resultAppender, ok := appender.(historyResultAppender); ok { return resultAppender.AppendAllWithResult(ctx, env) } if err := appender.AppendAll(ctx, env); err != nil { return history.AppendResult{}, err } return history.AppendResult{RawRows: 1}, nil } func appendHistoryBatch(ctx context.Context, appender historyAppender, envelopes []envelope.FrameEnvelope) (history.AppendResult, error) { if resultAppender, ok := appender.(historyResultAppender); ok { return resultAppender.AppendAllBatchWithResult(ctx, envelopes) } if err := appender.AppendAllBatch(ctx, envelopes); err != nil { return history.AppendResult{}, err } return history.AppendResult{RawRows: len(envelopes)}, nil } type retryHistoryAppender struct { delegate historyAppender attempts int delay time.Duration registry *metrics.Registry } func (a retryHistoryAppender) AppendAll(ctx context.Context, env envelope.FrameEnvelope) error { _, err := a.AppendAllWithResult(ctx, env) return err } func (a retryHistoryAppender) AppendAllWithResult(ctx context.Context, env envelope.FrameEnvelope) (history.AppendResult, error) { if a.delegate == nil { return history.AppendResult{}, nil } attempts := a.attempts if attempts <= 0 { attempts = 1 } var result history.AppendResult var err error for attempt := 1; attempt <= attempts; attempt++ { result, err = appendHistoryEnvelope(ctx, a.delegate, env) if err == nil || !isTransientTDengineHistoryError(err) { return result, err } if attempt == attempts { a.recordRetry("single", "exhausted") return result, err } a.recordRetry("single", "retry") if waitErr := sleepBeforeRetry(ctx, a.delay); waitErr != nil { return result, waitErr } } return result, err } func (a retryHistoryAppender) AppendAllBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error { _, err := a.AppendAllBatchWithResult(ctx, envelopes) return err } func (a retryHistoryAppender) AppendAllBatchWithResult(ctx context.Context, envelopes []envelope.FrameEnvelope) (history.AppendResult, error) { if a.delegate == nil { return history.AppendResult{}, nil } attempts := a.attempts if attempts <= 0 { attempts = 1 } var result history.AppendResult var err error for attempt := 1; attempt <= attempts; attempt++ { result, err = appendHistoryBatch(ctx, a.delegate, envelopes) if err == nil || !isTransientTDengineHistoryError(err) { return result, err } if attempt == attempts { a.recordRetry("batch", "exhausted") return result, err } a.recordRetry("batch", "retry") if waitErr := sleepBeforeRetry(ctx, a.delay); waitErr != nil { return result, waitErr } } return result, err } func (a retryHistoryAppender) recordRetry(operation string, status string) { if a.registry == nil { return } labels := metrics.Labels{"operation": operation, "status": status} a.registry.IncCounter("vehicle_history_write_retries_total", labels) metrics.RecordLastActivity(a.registry, "vehicle_history_last_write_retry_unix_seconds", labels) } func sleepBeforeRetry(ctx context.Context, delay time.Duration) error { if delay <= 0 { return nil } timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func isTransientTDengineHistoryError(err error) bool { if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } text := strings.ToLower(strings.TrimSpace(err.Error())) return strings.Contains(text, "timeout") || strings.Contains(text, "temporary") || strings.Contains(text, "temporarily") || strings.Contains(text, "connection refused") || strings.Contains(text, "connection reset") || strings.Contains(text, "connection closed") || strings.Contains(text, "broken pipe") || strings.Contains(text, "bad connection") || strings.Contains(text, "i/o timeout") || text == "eof" || strings.Contains(text, "unexpected eof") || strings.Contains(text, "server is down") || strings.Contains(text, "network is unreachable") || strings.Contains(text, "no route to host") } func addWriterMetric(registry *metrics.Registry, name string, message kafka.Message, status string) { if registry == nil { return } labels := metrics.Labels{"topic": message.Topic, "status": status} registry.IncCounter(name, labels) switch name { case "vehicle_history_kafka_messages_total": metrics.RecordLastActivity(registry, "vehicle_history_last_message_unix_seconds", labels) case "vehicle_history_writes_total": metrics.RecordLastActivity(registry, "vehicle_history_last_write_unix_seconds", labels) case "vehicle_history_kafka_commits_total": metrics.RecordLastActivity(registry, "vehicle_history_last_commit_unix_seconds", labels) case "vehicle_history_location_writes_total": metrics.RecordLastActivity(registry, "vehicle_history_last_location_write_unix_seconds", labels) } } func addBatchMetric(registry *metrics.Registry, name string, status string, value float64) { if registry == nil { return } registry.AddCounter(name, metrics.Labels{"status": status}, value) } func recordHistoryParsedFieldMetrics(registry *metrics.Registry, message kafka.Message, env envelope.FrameEnvelope) { if registry == nil || !envelope.IsRealtimeTelemetryFrame(env) { return } status := "present" if len(env.ParsedFields) == 0 { status = "missing" } registry.IncCounter("vehicle_history_parsed_fields_total", metrics.Labels{ "topic": message.Topic, "protocol": historyProtocolLabel(env.Protocol), "status": status, }) } func recordHistoryDerivedMetrics(registry *metrics.Registry, messages []kafka.Message, envelopes []envelope.FrameEnvelope, result history.AppendResult) { if registry == nil || len(messages) == 0 { return } batchStatus := "skipped" if result.LocationError != nil { batchStatus = "error" } else if result.LocationRows > 0 { batchStatus = "ok" } for index, message := range messages { status := batchStatus if index < len(envelopes) { status = history.LocationStatus(envelopes[index]) if status == history.LocationStatusOK && result.LocationError != nil { status = "error" } } addWriterMetric(registry, "vehicle_history_location_writes_total", message, status) } addBatchMetric(registry, "vehicle_history_location_batch_flush_total", batchStatus, 1) addBatchMetric(registry, "vehicle_history_location_rows_total", batchStatus, float64(result.LocationRows)) } func historyProtocolLabel(protocol envelope.Protocol) string { protocolLabel := strings.TrimSpace(string(protocol)) if protocolLabel == "" { return "UNKNOWN" } return protocolLabel } func setBatchDuration(registry *metrics.Registry, status string, elapsed time.Duration) { if registry == nil { return } elapsedMS := float64(elapsed.Milliseconds()) labels := metrics.Labels{"status": status} registry.SetGauge("vehicle_history_batch_flush_duration_ms", labels, elapsedMS) registry.ObserveHistogram("vehicle_history_batch_flush_duration_ms_histogram", labels, historyBatchFlushDurationBucketsMS, elapsedMS) } var historyBatchFlushDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000} func setFallbackDuration(registry *metrics.Registry, status string, elapsed time.Duration) { if registry == nil { return } elapsedMS := float64(elapsed.Milliseconds()) labels := metrics.Labels{"status": status} registry.SetGauge("vehicle_history_batch_fallback_duration_ms", labels, elapsedMS) registry.ObserveHistogram("vehicle_history_batch_fallback_duration_ms_histogram", labels, historyBatchFallbackDurationBucketsMS, elapsedMS) } var historyBatchFallbackDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000} var historyWriteE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000} var historyWriteE2ERecent = metrics.NewRecentLatencyByKey(512) func recordHistoryWriteE2EDuration(registry *metrics.Registry, message kafka.Message, env envelope.FrameEnvelope) { if registry == nil || env.ReceivedAtMS <= 0 { return } elapsed := time.Since(time.UnixMilli(env.ReceivedAtMS)).Milliseconds() if elapsed < 0 { elapsed = 0 } labels := metrics.Labels{"topic": message.Topic} registry.ObserveHistogram("vehicle_history_write_e2e_duration_ms_histogram", labels, historyWriteE2EDurationBucketsMS, float64(elapsed)) p99, samples := historyWriteE2ERecent.Observe(message.Topic, float64(elapsed)) registry.SetGauge("vehicle_history_write_e2e_recent_p99_ms", labels, p99) registry.SetGauge("vehicle_history_write_e2e_recent_samples", labels, float64(samples)) metrics.RecordLastActivity(registry, "vehicle_history_last_write_e2e_unix_seconds", labels) } func statusFromError(err error) string { if err != nil { return "error" } return "ok" } func setBatchPending(registry *metrics.Registry, messages int, rows int) { setBatchPendingForWorker(registry, nil, messages, rows) } func setBatchPendingForWorker(registry *metrics.Registry, workerLabels metrics.Labels, messages int, rows int) { if registry == nil { return } registry.SetGauge("vehicle_history_batch_pending_messages", workerLabels, float64(messages)) registry.SetGauge("vehicle_history_batch_pending_rows", workerLabels, float64(rows)) } func addWriterLagMetric(registry *metrics.Registry, message kafka.Message) { if registry == nil { return } registry.SetKafkaLag("vehicle_history_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark) } type config struct { KafkaBrokers []string KafkaTopics []string KafkaGroup string TDengineDriver string TDengineDSN string TDengineDatabase string EnsureSchema bool Workers int BatchSize int BatchWait int RetryAttempts int RetryDelay time.Duration } func (c config) Validate() error { if len(c.KafkaTopics) == 0 { return errors.New("KAFKA_TOPICS must include raw topics") } for _, topic := range c.KafkaTopics { if _, ok := topics.ProtocolForKnownRawTopic(topic); !ok { return fmt.Errorf("history-writer consumes known raw topics only, got %q", topic) } } if c.Workers <= 0 { return errors.New("HISTORY_WORKERS must be greater than zero") } return nil } func loadConfig() config { return config{ KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")), KafkaTopics: splitCSV(env("KAFKA_TOPICS", strings.Join([]string{topics.RawGB32960, topics.RawJT808, topics.RawYutongMQTT}, ","))), KafkaGroup: env("KAFKA_GROUP", "go-history-writer"), TDengineDriver: env("TDENGINE_DRIVER", "taosWS"), TDengineDSN: env("TDENGINE_DSN", ""), TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase), EnsureSchema: env("TDENGINE_ENSURE_SCHEMA", "true") != "false", Workers: envInt("HISTORY_WORKERS", 3), BatchSize: envInt("HISTORY_BATCH_SIZE", 200), BatchWait: envInt("HISTORY_BATCH_WAIT_MS", 20), RetryAttempts: envInt("HISTORY_RETRY_ATTEMPTS", 3), RetryDelay: time.Duration(envInt("HISTORY_RETRY_DELAY_MS", 20)) * time.Millisecond, } } func env(key string, 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 }