feat: build vehicle data platform and production pipeline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
490
go/vehicle-gateway/cmd/fields-projector/main.go
Normal file
490
go/vehicle-gateway/cmd/fields-projector/main.go
Normal file
@@ -0,0 +1,490 @@
|
||||
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
|
||||
}
|
||||
242
go/vehicle-gateway/cmd/fields-projector/main_test.go
Normal file
242
go/vehicle-gateway/cmd/fields-projector/main_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func TestLoadConfigCreatesProtocolIsolatedConsumerGroups(t *testing.T) {
|
||||
t.Setenv("FIELDS_PROJECTOR_GROUP_PREFIX", "projector-test")
|
||||
t.Setenv("KAFKA_START_OFFSET", "first")
|
||||
cfg := loadConfig()
|
||||
if cfg.StartOffset != kafka.FirstOffset || cfg.StartOffsetName != "first" {
|
||||
t.Fatalf("start offset = %d/%q", cfg.StartOffset, cfg.StartOffsetName)
|
||||
}
|
||||
wantGroups := map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: "projector-test-gb32960",
|
||||
envelope.ProtocolJT808: "projector-test-jt808",
|
||||
envelope.ProtocolYutongMQTT: "projector-test-yutong-mqtt",
|
||||
}
|
||||
for _, route := range cfg.Routes {
|
||||
if route.GroupID != wantGroups[route.Protocol] {
|
||||
t.Fatalf("group for %s = %q", route.Protocol, route.GroupID)
|
||||
}
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBatchUsesPrecomputedFieldsAndPreservesSourceMetadata(t *testing.T) {
|
||||
raw := projectorRawEnvelope()
|
||||
payload, err := raw.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal raw: %v", err)
|
||||
}
|
||||
writer := &recordingProjectorWriter{}
|
||||
registry := metrics.NewRegistry()
|
||||
route := jt808ProjectionRoute()
|
||||
count, err := projectBatch(context.Background(), discardProjectorLogger{}, registry, writer, route, []kafka.Message{{
|
||||
Topic: route.RawTopic, Key: raw.KafkaKey(), Value: payload, Partition: 2, Offset: 10, HighWaterMark: 11,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("projectBatch() error = %v", err)
|
||||
}
|
||||
if count != 1 || writer.callCount() != 1 || len(writer.messages) != 1 {
|
||||
t.Fatalf("projected=%d calls=%d messages=%d", count, writer.callCount(), len(writer.messages))
|
||||
}
|
||||
var fields envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(writer.messages[0].Value, &fields); err != nil {
|
||||
t.Fatalf("decode fields: %v", err)
|
||||
}
|
||||
if fields.EventKind != envelope.EventKindFields || fields.SourceEventID != raw.EventID || fields.EventID != raw.EventID+":fields" {
|
||||
t.Fatalf("fields identity = %#v", fields)
|
||||
}
|
||||
if fields.SourceCode != raw.SourceCode || fields.SourceKind != raw.SourceKind || fields.SourceEndpoint != raw.SourceEndpoint {
|
||||
t.Fatalf("source metadata not preserved: %#v", fields)
|
||||
}
|
||||
if got := fields.Fields["jt808.location.total_mileage_km"]; got != 1234.5 {
|
||||
t.Fatalf("total mileage = %#v", got)
|
||||
}
|
||||
if len(fields.Parsed) != 0 || len(fields.ParsedFields) != 0 {
|
||||
t.Fatalf("fields projection must not duplicate raw payload: parsed=%v parsed_fields=%v", fields.Parsed, fields.ParsedFields)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fields_projector_projections_total{protocol="JT808",status="projected"} 1`,
|
||||
`vehicle_fields_projector_kafka_writes_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`,
|
||||
`vehicle_fields_projector_kafka_lag{partition="2",topic="vehicle.raw.go.jt808.v1"} 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metric missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBatchReliablySkipsNonRealtimeAndInvalidWithoutWriting(t *testing.T) {
|
||||
route := jt808ProjectionRoute()
|
||||
nonRealtime := projectorRawEnvelope()
|
||||
nonRealtime.MessageID = "0x0002"
|
||||
nonRealtime.ParsedFields = map[string]any{"jt808.header.message_id": "0x0002"}
|
||||
payload, _ := nonRealtime.MarshalJSONBytes()
|
||||
messages := []kafka.Message{
|
||||
{Topic: route.RawTopic, Value: []byte("{bad"), Partition: 0, Offset: 1, HighWaterMark: 3},
|
||||
{Topic: route.RawTopic, Value: payload, Partition: 0, Offset: 2, HighWaterMark: 3},
|
||||
}
|
||||
writer := &recordingProjectorWriter{}
|
||||
committer := &recordingProjectorCommitter{}
|
||||
registry := metrics.NewRegistry()
|
||||
processBatchReliably(context.Background(), discardProjectorLogger{}, registry, writer, committer, route, messages, time.Second, time.Millisecond)
|
||||
if writer.callCount() != 0 {
|
||||
t.Fatalf("writer calls = %d, want 0", writer.callCount())
|
||||
}
|
||||
if committer.callCount() != 1 || committer.messageCount != 2 {
|
||||
t.Fatalf("commit calls/messages = %d/%d", committer.callCount(), committer.messageCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if !strings.Contains(text, `vehicle_fields_projector_kafka_messages_total{status="invalid_json",topic="vehicle.raw.go.jt808.v1"} 1`) ||
|
||||
!strings.Contains(text, `vehicle_fields_projector_projections_total{protocol="JT808",status="skipped_non_realtime"} 1`) {
|
||||
t.Fatalf("skip metrics missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBatchReliablyRetriesWriteBeforeCommitting(t *testing.T) {
|
||||
route := jt808ProjectionRoute()
|
||||
payload, _ := projectorRawEnvelope().MarshalJSONBytes()
|
||||
messages := []kafka.Message{{Topic: route.RawTopic, Value: payload, Partition: 1, Offset: 7, HighWaterMark: 8}}
|
||||
writer := &recordingProjectorWriter{errors: []error{errors.New("kafka unavailable"), nil}}
|
||||
committer := &recordingProjectorCommitter{}
|
||||
registry := metrics.NewRegistry()
|
||||
processBatchReliably(context.Background(), discardProjectorLogger{}, registry, writer, committer, route, messages, time.Second, time.Millisecond)
|
||||
if writer.callCount() != 2 {
|
||||
t.Fatalf("writer calls = %d, want 2", writer.callCount())
|
||||
}
|
||||
if committer.callCount() != 1 {
|
||||
t.Fatalf("commit calls = %d, want 1 after successful write", committer.callCount())
|
||||
}
|
||||
if !strings.Contains(registry.Render(), `vehicle_fields_projector_batch_retries_total{protocol="JT808",reason="write_error"} 1`) {
|
||||
t.Fatalf("write retry metric missing:\n%s", registry.Render())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBatchReliablyRetriesOnlyCommitAfterSuccessfulWrite(t *testing.T) {
|
||||
route := jt808ProjectionRoute()
|
||||
payload, _ := projectorRawEnvelope().MarshalJSONBytes()
|
||||
messages := []kafka.Message{{Topic: route.RawTopic, Value: payload, Partition: 1, Offset: 7, HighWaterMark: 8}}
|
||||
writer := &recordingProjectorWriter{}
|
||||
committer := &recordingProjectorCommitter{errors: []error{errors.New("commit timeout"), nil}}
|
||||
processBatchReliably(context.Background(), discardProjectorLogger{}, metrics.NewRegistry(), writer, committer, route, messages, time.Second, time.Millisecond)
|
||||
if writer.callCount() != 1 {
|
||||
t.Fatalf("writer calls = %d, want 1", writer.callCount())
|
||||
}
|
||||
if committer.callCount() != 2 {
|
||||
t.Fatalf("commit calls = %d, want 2", committer.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBatchWriteFailureDoesNotCommitByItself(t *testing.T) {
|
||||
route := jt808ProjectionRoute()
|
||||
payload, _ := projectorRawEnvelope().MarshalJSONBytes()
|
||||
writer := &recordingProjectorWriter{errors: []error{errors.New("write failed")}}
|
||||
count, err := projectBatch(context.Background(), discardProjectorLogger{}, nil, writer, route, []kafka.Message{{Topic: route.RawTopic, Value: payload}})
|
||||
if err == nil || count != 1 {
|
||||
t.Fatalf("projectBatch() count/error = %d/%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func projectorRawEnvelope() envelope.FrameEnvelope {
|
||||
return envelope.FrameEnvelope{
|
||||
EventID: "raw-event-1",
|
||||
EventKind: envelope.EventKindRaw,
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
VIN: "VIN001",
|
||||
Phone: "13307795425",
|
||||
SourceEndpoint: "115.231.168.135:43625",
|
||||
SourceCode: "g7s",
|
||||
SourceKind: "PLATFORM",
|
||||
PlatformName: "G7s",
|
||||
EventTimeMS: 1783960000000,
|
||||
ReceivedAtMS: 1783960000010,
|
||||
ParsedFields: map[string]any{
|
||||
"jt808.location.latitude": 30.1,
|
||||
"jt808.location.longitude": 121.2,
|
||||
"jt808.location.total_mileage_km": 1234.5,
|
||||
},
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
}
|
||||
|
||||
func jt808ProjectionRoute() projectionRoute {
|
||||
return projectionRoute{
|
||||
Protocol: envelope.ProtocolJT808, RawTopic: topics.RawJT808, FieldsTopic: topics.FieldsJT808, GroupID: "projector-jt808",
|
||||
}
|
||||
}
|
||||
|
||||
type recordingProjectorWriter struct {
|
||||
mu sync.Mutex
|
||||
errors []error
|
||||
calls int
|
||||
messages []kafka.Message
|
||||
}
|
||||
|
||||
func (w *recordingProjectorWriter) WriteMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.calls++
|
||||
w.messages = append(w.messages, messages...)
|
||||
if len(w.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := w.errors[0]
|
||||
w.errors = w.errors[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *recordingProjectorWriter) callCount() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.calls
|
||||
}
|
||||
|
||||
type recordingProjectorCommitter struct {
|
||||
mu sync.Mutex
|
||||
errors []error
|
||||
calls int
|
||||
messageCount int
|
||||
}
|
||||
|
||||
func (c *recordingProjectorCommitter) CommitMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls++
|
||||
c.messageCount += len(messages)
|
||||
if len(c.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := c.errors[0]
|
||||
c.errors = c.errors[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *recordingProjectorCommitter) callCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.calls
|
||||
}
|
||||
|
||||
type discardProjectorLogger struct{}
|
||||
|
||||
func (discardProjectorLogger) Error(string, ...any) {}
|
||||
func (discardProjectorLogger) Warn(string, ...any) {}
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/gateway"
|
||||
@@ -37,7 +40,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer sink.Close()
|
||||
resolver, closeResolver, err := buildIdentityResolver(ctx, logger)
|
||||
resolver, jt808DeviceTokens, closeResolver, err := buildIdentityResolver(ctx, logger, registry)
|
||||
if err != nil {
|
||||
logger.Error("build identity resolver failed", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -45,21 +48,38 @@ func main() {
|
||||
defer closeResolver()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-gateway", nil, registry))
|
||||
publishUnified := envBool("PUBLISH_UNIFIED_ENABLED", false)
|
||||
delegateFields := envBool("FIELDS_DERIVE_FROM_RAW_ENABLED", strings.TrimSpace(os.Getenv("NATS_URL")) != "")
|
||||
gb32960Authenticator, gb32960AuthMode, gb32960CredentialCount, err := buildGB32960Authenticator()
|
||||
if err != nil {
|
||||
logger.Error("build gb32960 authenticator failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
jt808AuthCode := env("JT808_REGISTER_AUTH_CODE", "g7gps")
|
||||
jt808Authenticator, jt808AuthMode, err := buildJT808Authenticator(jt808AuthCode, jt808DeviceTokens)
|
||||
if err != nil {
|
||||
logger.Error("build jt808 authenticator failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
recordAuthenticationConfig(registry, envelope.ProtocolGB32960, gb32960AuthMode, gb32960CredentialCount)
|
||||
recordAuthenticationConfig(registry, envelope.ProtocolJT808, jt808AuthMode, int(boolMetric(strings.TrimSpace(jt808AuthCode) != "")))
|
||||
logger.Info("protocol authentication configured", "gb32960_mode", gb32960AuthMode, "gb32960_accounts", gb32960CredentialCount, "jt808_mode", jt808AuthMode)
|
||||
|
||||
protocols := []gateway.TCPProtocol{
|
||||
{
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
Addr: env("GB32960_TCP_ADDR", ":32960"),
|
||||
Extract: gb32960.ExtractFrames,
|
||||
Parse: gb32960.ParseFrame,
|
||||
Respond: gb32960.AutoResponse,
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
Addr: env("GB32960_TCP_ADDR", ":32960"),
|
||||
Extract: gb32960.ExtractFrames,
|
||||
Parse: gb32960.ParseFrame,
|
||||
Authenticate: gb32960Authenticator,
|
||||
Respond: gb32960.AutoResponse,
|
||||
},
|
||||
{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: env("JT808_TCP_ADDR", ":808"),
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: jt808.ParseFrame,
|
||||
Respond: jt808.NewAutoResponder(env("JT808_REGISTER_AUTH_CODE", "g7gps")).Respond,
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: env("JT808_TCP_ADDR", ":808"),
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: jt808.ParseFrame,
|
||||
Authenticate: jt808Authenticator,
|
||||
Respond: jt808.NewAutoResponder(jt808AuthCode).Respond,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,6 +96,7 @@ func main() {
|
||||
IdleTimeout: time.Duration(envInt("TCP_IDLE_TIMEOUT_SECONDS", 180)) * time.Second,
|
||||
MaxConnections: envInt("TCP_MAX_CONNECTIONS", 120_000),
|
||||
PublishUnified: publishUnified,
|
||||
DelegateFields: delegateFields,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("build tcp server failed", "protocol", protocol.Protocol, "error", err)
|
||||
@@ -110,6 +131,7 @@ func main() {
|
||||
Logger: logger,
|
||||
Metrics: registry,
|
||||
PublishUnified: publishUnified,
|
||||
DelegateFields: delegateFields,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("build yutong mqtt client failed", "error", err)
|
||||
@@ -122,7 +144,7 @@ func main() {
|
||||
logger.Info("yutong mqtt client started")
|
||||
}
|
||||
|
||||
logger.Info("vehicle gateway started")
|
||||
logger.Info("vehicle gateway started", "fields_derive_from_raw_enabled", delegateFields)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-errs:
|
||||
@@ -132,63 +154,433 @@ func main() {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func buildIdentityResolver(ctx context.Context, logger *slog.Logger) (identity.Resolver, func(), error) {
|
||||
func buildGB32960Authenticator() (authentication.Authenticator, authentication.Mode, int, error) {
|
||||
mode, err := authentication.ParseMode(os.Getenv("GB32960_AUTH_MODE"), authentication.ModeObserve)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
credentials, err := loadGB32960Credentials()
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
if mode == authentication.ModeEnforce && len(credentials) == 0 {
|
||||
return nil, "", 0, fmt.Errorf("GB32960_AUTH_MODE=enforce requires configured platform credentials")
|
||||
}
|
||||
return authentication.NewGB32960PlatformAuthenticator(mode, credentials), mode, len(credentials), nil
|
||||
}
|
||||
|
||||
func buildJT808Authenticator(authCode string, deviceTokens authentication.JT808DeviceTokenProvider) (authentication.Authenticator, authentication.Mode, error) {
|
||||
mode, err := authentication.ParseMode(os.Getenv("JT808_AUTH_MODE"), authentication.ModeObserve)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if mode == authentication.ModeEnforce && strings.TrimSpace(authCode) == "" && deviceTokens == nil {
|
||||
return nil, "", fmt.Errorf("JT808_AUTH_MODE=enforce requires a configured or device token provider")
|
||||
}
|
||||
return authentication.NewJT808Authenticator(mode, authCode, deviceTokens), mode, nil
|
||||
}
|
||||
|
||||
func loadGB32960Credentials() (map[string][]string, error) {
|
||||
payload := strings.TrimSpace(os.Getenv("GB32960_PLATFORM_CREDENTIALS_JSON"))
|
||||
if path := strings.TrimSpace(os.Getenv("GB32960_PLATFORM_CREDENTIALS_FILE")); path != "" {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read gb32960 credentials file: %w", err)
|
||||
}
|
||||
payload = strings.TrimSpace(string(contents))
|
||||
}
|
||||
if payload == "" {
|
||||
return map[string][]string{}, nil
|
||||
}
|
||||
rawCredentials := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(payload), &rawCredentials); err != nil {
|
||||
return nil, fmt.Errorf("parse gb32960 platform credentials: %w", err)
|
||||
}
|
||||
credentials := make(map[string][]string, len(rawCredentials))
|
||||
for username, rawPassword := range rawCredentials {
|
||||
trimmed := strings.TrimSpace(username)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
var passwords []string
|
||||
var single string
|
||||
if err := json.Unmarshal(rawPassword, &single); err == nil {
|
||||
passwords = []string{single}
|
||||
} else if err := json.Unmarshal(rawPassword, &passwords); err != nil {
|
||||
return nil, fmt.Errorf("parse gb32960 credentials for account %q: expected string or string array", trimmed)
|
||||
}
|
||||
for _, password := range passwords {
|
||||
if password != "" {
|
||||
credentials[trimmed] = append(credentials[trimmed], password)
|
||||
}
|
||||
}
|
||||
if len(credentials[trimmed]) == 0 {
|
||||
delete(credentials, trimmed)
|
||||
}
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func recordAuthenticationConfig(registry *metrics.Registry, protocol envelope.Protocol, mode authentication.Mode, credentialCount int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_gateway_authentication_mode", metrics.Labels{
|
||||
"protocol": string(protocol),
|
||||
"mode": string(mode),
|
||||
}, 1)
|
||||
registry.SetGauge("vehicle_gateway_authentication_credentials", metrics.Labels{
|
||||
"protocol": string(protocol),
|
||||
}, float64(credentialCount))
|
||||
}
|
||||
|
||||
func buildIdentityResolver(ctx context.Context, logger *slog.Logger, registry *metrics.Registry) (identity.Resolver, authentication.JT808DeviceTokenProvider, func(), error) {
|
||||
dsn := env("IDENTITY_MYSQL_DSN", strings.TrimSpace(os.Getenv("MYSQL_DSN")))
|
||||
if strings.TrimSpace(dsn) == "" {
|
||||
logger.Warn("identity mysql dsn is empty; using noop identity resolver")
|
||||
return identity.NoopResolver{}, func() {}, nil
|
||||
return identity.NoopResolver{}, nil, func() {}, nil
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, nil, err
|
||||
logger.Error("identity mysql configuration failed; continuing with unresolved identities", "error", err)
|
||||
return identity.NoopResolver{}, nil, func() {}, nil
|
||||
}
|
||||
db.SetMaxOpenConns(envInt("IDENTITY_MYSQL_MAX_OPEN_CONNS", 16))
|
||||
db.SetMaxIdleConns(envInt("IDENTITY_MYSQL_MAX_IDLE_CONNS", 8))
|
||||
db.SetConnMaxLifetime(time.Duration(envInt("IDENTITY_MYSQL_CONN_MAX_LIFETIME_SECONDS", 300)) * time.Second)
|
||||
db.SetConnMaxIdleTime(time.Duration(envInt("IDENTITY_MYSQL_CONN_MAX_IDLE_SECONDS", 60)) * time.Second)
|
||||
table := env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding")
|
||||
cacheMaxEntries := envInt("IDENTITY_LOOKUP_CACHE_MAX_ENTRIES", 300000)
|
||||
cacheCleanupInterval := time.Duration(envInt("IDENTITY_LOOKUP_CACHE_CLEANUP_INTERVAL_SECONDS", 60)) * time.Second
|
||||
staleLookupTTLSeconds := envInt("IDENTITY_STALE_LOOKUP_TTL_SECONDS", 3600)
|
||||
registrationGatewayWritesEnabled := envBool("JT808_REGISTRATION_GATEWAY_WRITES_ENABLED", false)
|
||||
resolver := identity.NewMySQLResolverWithOptions(db, table, identity.MySQLResolverOptions{
|
||||
LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
LookupCacheTTL: time.Duration(envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600)) * time.Second,
|
||||
SnapshotOnlyLookups: envBool("IDENTITY_SNAPSHOT_ONLY_ENABLED", true),
|
||||
LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
LocationTouchRetryInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_RETRY_INTERVAL_SECONDS", 5)) * time.Second,
|
||||
RegistrationWriteAttempts: envInt("JT808_REGISTRATION_WRITE_RETRY_ATTEMPTS", 2),
|
||||
RegistrationWriteRetryDelay: time.Duration(envInt("JT808_REGISTRATION_WRITE_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
||||
LookupCacheTTL: time.Duration(envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600)) * time.Second,
|
||||
StaleLookupTTL: time.Duration(staleLookupTTLSeconds) * time.Second,
|
||||
CacheCleanupInterval: cacheCleanupInterval,
|
||||
MaxCacheEntries: cacheMaxEntries,
|
||||
SourceCodeLookup: envBool("IDENTITY_SOURCE_CODE_LOOKUP_ENABLED", true),
|
||||
AsyncRegistrationWrites: envBool("JT808_REGISTRATION_ASYNC_WRITE_ENABLED", true),
|
||||
RegistrationWriteQueueSize: envInt("JT808_REGISTRATION_WRITE_QUEUE_SIZE", 100000),
|
||||
RegistrationWriteWorkers: envInt("JT808_REGISTRATION_WRITE_WORKERS", 4),
|
||||
RegistrationWriteTimeout: time.Duration(envInt("JT808_REGISTRATION_WRITE_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
RegistrationEnqueueTimeout: time.Duration(envInt("JT808_REGISTRATION_WRITE_ENQUEUE_TIMEOUT_MS", 50)) * time.Millisecond,
|
||||
DisableRegistrationWrites: !registrationGatewayWritesEnabled,
|
||||
OnRegistrationWriteResult: func(result identity.RegistrationWriteResult) {
|
||||
recordJT808RegistrationWriteResult(registry, result)
|
||||
},
|
||||
OnRegistrationWriteError: func(err error) {
|
||||
logger.Warn("jt808 registration async write failed", "error", err)
|
||||
},
|
||||
})
|
||||
if envBool("IDENTITY_MYSQL_ENSURE_SCHEMA", true) {
|
||||
if err := resolver.EnsureSchema(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
startIdentityDatabaseMaintenance(
|
||||
ctx,
|
||||
logger,
|
||||
db,
|
||||
resolver,
|
||||
envBool("IDENTITY_MYSQL_ENSURE_SCHEMA", false),
|
||||
time.Duration(envInt("IDENTITY_MYSQL_PING_TIMEOUT_MS", 3000))*time.Millisecond,
|
||||
time.Duration(envInt("IDENTITY_MYSQL_SCHEMA_TIMEOUT_SECONDS", 10))*time.Second,
|
||||
)
|
||||
startIdentitySnapshotRefresh(
|
||||
ctx,
|
||||
logger,
|
||||
registry,
|
||||
resolver,
|
||||
time.Duration(envInt("IDENTITY_SNAPSHOT_REFRESH_INTERVAL_SECONDS", 60))*time.Second,
|
||||
time.Duration(envInt("IDENTITY_SNAPSHOT_REFRESH_TIMEOUT_SECONDS", 10))*time.Second,
|
||||
)
|
||||
startIdentityCacheMetrics(ctx, registry, resolver, time.Duration(envInt("IDENTITY_CACHE_METRICS_INTERVAL_SECONDS", 30))*time.Second)
|
||||
resolveTimeout := time.Duration(envInt("IDENTITY_RESOLVE_TIMEOUT_MS", 50)) * time.Millisecond
|
||||
registry.SetGauge("vehicle_gateway_jt808_registration_gateway_writes_enabled", nil, boolMetric(registrationGatewayWritesEnabled))
|
||||
logger.Info("identity mysql resolver enabled", "table", table, "snapshot_only_enabled", envBool("IDENTITY_SNAPSHOT_ONLY_ENABLED", true), "snapshot_refresh_interval_seconds", envInt("IDENTITY_SNAPSHOT_REFRESH_INTERVAL_SECONDS", 60), "lookup_cache_ttl_seconds", envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600), "stale_lookup_ttl_seconds", staleLookupTTLSeconds, "lookup_cache_max_entries", cacheMaxEntries, "lookup_cache_cleanup_interval_seconds", cacheCleanupInterval.Seconds(), "source_code_lookup_enabled", envBool("IDENTITY_SOURCE_CODE_LOOKUP_ENABLED", true), "resolve_timeout_ms", resolveTimeout.Milliseconds(), "jt808_registration_gateway_writes_enabled", registrationGatewayWritesEnabled, "jt808_registration_location_touch_retry_interval_seconds", envInt("JT808_REGISTRATION_LOCATION_TOUCH_RETRY_INTERVAL_SECONDS", 5), "jt808_registration_write_retry_attempts", envInt("JT808_REGISTRATION_WRITE_RETRY_ATTEMPTS", 2), "jt808_registration_write_retry_delay_ms", envInt("JT808_REGISTRATION_WRITE_RETRY_DELAY_MS", 20), "jt808_registration_async_write_enabled", envBool("JT808_REGISTRATION_ASYNC_WRITE_ENABLED", true), "jt808_registration_write_queue_size", envInt("JT808_REGISTRATION_WRITE_QUEUE_SIZE", 100000), "jt808_registration_write_workers", envInt("JT808_REGISTRATION_WRITE_WORKERS", 4), "jt808_registration_write_timeout_ms", envInt("JT808_REGISTRATION_WRITE_TIMEOUT_MS", 5000), "jt808_registration_write_enqueue_timeout_ms", envInt("JT808_REGISTRATION_WRITE_ENQUEUE_TIMEOUT_MS", 50))
|
||||
return identity.TimeoutResolver{Delegate: resolver, Timeout: resolveTimeout}, resolver, func() {
|
||||
_ = resolver.Close()
|
||||
_ = db.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
type identitySnapshotRefresher interface {
|
||||
RefreshSnapshot(context.Context) (identity.SnapshotRefreshResult, error)
|
||||
}
|
||||
|
||||
type identityDatabasePinger interface {
|
||||
PingContext(context.Context) error
|
||||
}
|
||||
|
||||
type identitySchemaEnsurer interface {
|
||||
EnsureSchema(context.Context) error
|
||||
}
|
||||
|
||||
func startIdentityDatabaseMaintenance(ctx context.Context, logger *slog.Logger, db identityDatabasePinger, schema identitySchemaEnsurer, ensureSchema bool, pingTimeout time.Duration, schemaTimeout time.Duration) {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
logger.Info("identity mysql resolver enabled", "table", table, "lookup_cache_ttl_seconds", envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600))
|
||||
return resolver, func() { _ = db.Close() }, nil
|
||||
if pingTimeout <= 0 {
|
||||
pingTimeout = 3 * time.Second
|
||||
}
|
||||
if schemaTimeout <= 0 {
|
||||
schemaTimeout = 10 * time.Second
|
||||
}
|
||||
go func() {
|
||||
pingCtx, cancelPing := context.WithTimeout(ctx, pingTimeout)
|
||||
err := db.PingContext(pingCtx)
|
||||
cancelPing()
|
||||
if err != nil {
|
||||
logger.Warn("identity mysql unavailable; ingress remains enabled and snapshot refresh will retry", "error", err)
|
||||
return
|
||||
}
|
||||
if !ensureSchema || schema == nil {
|
||||
return
|
||||
}
|
||||
schemaCtx, cancelSchema := context.WithTimeout(ctx, schemaTimeout)
|
||||
err = schema.EnsureSchema(schemaCtx)
|
||||
cancelSchema()
|
||||
if err != nil {
|
||||
logger.Warn("identity mysql schema check failed; ingress remains enabled", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startIdentitySnapshotRefresh(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, refresher identitySnapshotRefresher, interval time.Duration, timeout time.Duration) {
|
||||
if refresher == nil {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
refresh := func() {
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
result, err := refresher.RefreshSnapshot(refreshCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
recordIdentitySnapshotRefresh(registry, identity.SnapshotRefreshResult{}, "error")
|
||||
logger.Warn("identity snapshot refresh failed; keeping last known good snapshot", "error", err)
|
||||
return
|
||||
}
|
||||
recordIdentitySnapshotRefresh(registry, result, "ok")
|
||||
logger.Info("identity snapshot refreshed", "binding_entries", result.BindingEntries, "identifier_entries", result.IdentifierEntries, "registration_entries", result.RegistrationEntries, "source_entries", result.SourceEntries)
|
||||
}
|
||||
go func() {
|
||||
refresh()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func recordIdentitySnapshotRefresh(registry *metrics.Registry, result identity.SnapshotRefreshResult, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_gateway_identity_snapshot_refresh_total", metrics.Labels{"status": status})
|
||||
if status != "ok" {
|
||||
return
|
||||
}
|
||||
for _, item := range []struct {
|
||||
kind string
|
||||
value int
|
||||
}{
|
||||
{kind: "binding", value: result.BindingEntries},
|
||||
{kind: "identifier", value: result.IdentifierEntries},
|
||||
{kind: "registration", value: result.RegistrationEntries},
|
||||
{kind: "source", value: result.SourceEntries},
|
||||
} {
|
||||
registry.SetGauge("vehicle_gateway_identity_snapshot_entries", metrics.Labels{"kind": item.kind}, float64(item.value))
|
||||
}
|
||||
registry.SetGauge("vehicle_gateway_identity_snapshot_last_success_unix_seconds", nil, float64(result.RefreshedAt.Unix()))
|
||||
}
|
||||
|
||||
type identityCacheStatsReporter interface {
|
||||
CacheStats() identity.CacheStats
|
||||
}
|
||||
|
||||
func startIdentityCacheMetrics(ctx context.Context, registry *metrics.Registry, reporter identityCacheStatsReporter, interval time.Duration) {
|
||||
if registry == nil || reporter == nil {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
recordIdentityCacheStats(registry, reporter.CacheStats())
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
recordIdentityCacheStats(registry, reporter.CacheStats())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func recordIdentityCacheStats(registry *metrics.Registry, stats identity.CacheStats) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value int
|
||||
max int
|
||||
}{
|
||||
{name: "lookup", value: stats.LookupEntries, max: stats.MaxEntries},
|
||||
{name: "registration", value: stats.RegistrationEntries, max: stats.MaxEntries},
|
||||
{name: "source_code", value: stats.SourceCodeEntries, max: stats.MaxEntries},
|
||||
{name: "location_touch", value: stats.LocationTouchEntries, max: stats.MaxEntries},
|
||||
{name: "location_touch_failure", value: stats.LocationTouchFailureEntries, max: stats.MaxEntries},
|
||||
{name: "registration_write_queue", value: stats.RegistrationWriteQueueDepth, max: stats.RegistrationWriteQueueCap},
|
||||
{name: "snapshot_binding", value: stats.SnapshotBindingEntries, max: stats.MaxEntries},
|
||||
{name: "snapshot_identifier", value: stats.SnapshotIdentifierEntries, max: stats.MaxEntries},
|
||||
{name: "snapshot_registration", value: stats.SnapshotRegistrationEntries, max: stats.MaxEntries},
|
||||
{name: "snapshot_source", value: stats.SnapshotSourceEntries, max: stats.MaxEntries},
|
||||
} {
|
||||
registry.SetGauge("vehicle_gateway_identity_cache_entries", metrics.Labels{"cache": item.name}, float64(item.value))
|
||||
registry.SetGauge("vehicle_gateway_identity_cache_max_entries", metrics.Labels{"cache": item.name}, float64(item.max))
|
||||
}
|
||||
ready := 0.0
|
||||
if stats.SnapshotReady {
|
||||
ready = 1
|
||||
}
|
||||
registry.SetGauge("vehicle_gateway_identity_snapshot_ready", nil, ready)
|
||||
if !stats.SnapshotRefreshedAt.IsZero() {
|
||||
registry.SetGauge("vehicle_gateway_identity_snapshot_last_success_unix_seconds", nil, float64(stats.SnapshotRefreshedAt.Unix()))
|
||||
}
|
||||
}
|
||||
|
||||
func recordJT808RegistrationWriteResult(registry *metrics.Registry, result identity.RegistrationWriteResult) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
mode := strings.TrimSpace(result.Mode)
|
||||
if mode == "" {
|
||||
mode = "unknown"
|
||||
}
|
||||
status := strings.TrimSpace(result.Status)
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
registry.IncCounter("vehicle_gateway_jt808_registration_write_total", metrics.Labels{
|
||||
"mode": mode,
|
||||
"status": status,
|
||||
})
|
||||
metrics.RecordLastActivity(registry, "vehicle_gateway_last_jt808_registration_write_unix_seconds", metrics.Labels{
|
||||
"mode": mode,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func buildSink(ctx context.Context, logger *slog.Logger, registry *metrics.Registry) (eventbus.Sink, error) {
|
||||
if strings.TrimSpace(os.Getenv("NATS_URL")) != "" {
|
||||
sink, err := eventbus.NewNATSSink(natsSinkConfigFromEnv())
|
||||
natsConfig := natsSinkConfigFromEnv()
|
||||
sink, err := eventbus.NewNATSSink(natsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outboxRuntime := natsOutboxConfigFromEnv(registry, func(err error) {
|
||||
logger.Warn("nats durable outbox publish failed", "error", err)
|
||||
})
|
||||
if outboxRuntime.Enabled {
|
||||
outbox, err := eventbus.NewDurableOutboxSink(sink, outboxRuntime.Config)
|
||||
if err != nil {
|
||||
_ = sink.Close()
|
||||
return nil, err
|
||||
}
|
||||
go outbox.ReplayLoop(ctx, outboxRuntime.ReplayInterval)
|
||||
logger.Info("nats durable outbox enabled",
|
||||
"dir", outboxRuntime.Config.Directory,
|
||||
"fsync", outboxRuntime.Config.SyncWrites,
|
||||
"replay_interval_ms", outboxRuntime.ReplayInterval.Milliseconds(),
|
||||
"replay_batch_size", outboxRuntime.Config.ReplayBatchSize,
|
||||
"close_timeout_ms", outboxRuntime.Config.CloseTimeout.Milliseconds(),
|
||||
"wal_segment_bytes", outboxRuntime.Config.WALSegmentBytes,
|
||||
"wal_segment_age_ms", outboxRuntime.Config.WALSegmentAge.Milliseconds(),
|
||||
"wal_append_queue_size", outboxRuntime.Config.WALAppendQueue,
|
||||
"wal_commit_batch_size", outboxRuntime.Config.WALCommitBatch,
|
||||
"wal_commit_interval_ms", outboxRuntime.Config.WALCommitWait.Milliseconds(),
|
||||
"max_inflight", natsConfig.AsyncMaxPending,
|
||||
"ack_timeout_ms", natsConfig.AsyncAckTimeout.Milliseconds(),
|
||||
)
|
||||
return outbox, nil
|
||||
}
|
||||
var out eventbus.Sink = eventbus.NewRetryingSink(sink, eventbus.RetryConfig{
|
||||
Attempts: envInt("NATS_PUBLISH_ATTEMPTS", 3),
|
||||
Backoff: time.Duration(envInt("NATS_PUBLISH_BACKOFF_MS", 100)) * time.Millisecond,
|
||||
AttemptTimeout: time.Duration(envInt("NATS_PUBLISH_TIMEOUT_MS", 3000)) * time.Millisecond,
|
||||
})
|
||||
spoolDir := strings.TrimSpace(os.Getenv("NATS_SPOOL_DIR"))
|
||||
if spoolDir != "" {
|
||||
replayBatchSize := envInt("NATS_SPOOL_REPLAY_BATCH_SIZE", 200)
|
||||
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{
|
||||
Directory: spoolDir,
|
||||
ReplayBatchSize: replayBatchSize,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
})
|
||||
interval := time.Duration(envInt("NATS_SPOOL_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond
|
||||
go durable.ReplayLoop(ctx, interval, func(err error) {
|
||||
logger.Warn("nats spool replay failed", "error", err)
|
||||
})
|
||||
logger.Info("nats durable spool enabled", "dir", spoolDir, "replay_interval_ms", interval.Milliseconds(), "replay_batch_size", replayBatchSize)
|
||||
out = durable
|
||||
}
|
||||
if envBool("NATS_ASYNC_ENABLED", true) {
|
||||
queueSize := envInt("NATS_ASYNC_QUEUE_SIZE", envInt("KAFKA_ASYNC_QUEUE_SIZE", 100000))
|
||||
workers := envInt("NATS_ASYNC_WORKERS", envInt("KAFKA_ASYNC_WORKERS", 8))
|
||||
enqueueTimeout := time.Duration(envInt("NATS_ASYNC_ENQUEUE_TIMEOUT_MS", envInt("KAFKA_ASYNC_ENQUEUE_TIMEOUT_MS", 1000))) * time.Millisecond
|
||||
timeout := time.Duration(envInt("NATS_ASYNC_PUBLISH_TIMEOUT_MS", envInt("KAFKA_ASYNC_PUBLISH_TIMEOUT_MS", 30000))) * time.Millisecond
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("nats async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("nats async publish enabled", "queue_size", queueSize, "workers", workers, "publish_timeout_ms", timeout.Milliseconds())
|
||||
if envBool("NATS_PARTITIONED_ASYNC_ENABLED", true) {
|
||||
rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers := partitionedAsyncConfigFromEnv("NATS", queueSize, workers)
|
||||
rawEnqueueTimeout, derivedEnqueueTimeout := partitionedAsyncEnqueueTimeoutsFromEnv("NATS", enqueueTimeout)
|
||||
out = eventbus.NewPartitionedAsyncSink(out, eventbus.PartitionedAsyncConfig{
|
||||
RawQueueSize: rawQueueSize,
|
||||
DerivedQueueSize: derivedQueueSize,
|
||||
RawWorkers: rawWorkers,
|
||||
DerivedWorkers: derivedWorkers,
|
||||
RawEnqueueTimeout: rawEnqueueTimeout,
|
||||
DerivedEnqueueTimeout: derivedEnqueueTimeout,
|
||||
EnqueueTimeout: enqueueTimeout,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("nats partitioned async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("nats partitioned async publish enabled", "raw_queue_size", rawQueueSize, "derived_queue_size", derivedQueueSize, "raw_workers", rawWorkers, "derived_workers", derivedWorkers, "raw_enqueue_timeout_ms", rawEnqueueTimeout.Milliseconds(), "derived_enqueue_timeout_ms", derivedEnqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
|
||||
} else {
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
EnqueueTimeout: enqueueTimeout,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("nats async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("nats async publish enabled", "queue_size", queueSize, "workers", workers, "enqueue_timeout_ms", enqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
|
||||
}
|
||||
}
|
||||
logger.Info("nats jetstream sink enabled", "url", env("NATS_URL", ""), "unified_subject", natsSinkConfigFromEnv().UnifiedSubject, "publish_unified_enabled", envBool("PUBLISH_UNIFIED_ENABLED", false), "publish_fields_enabled", true)
|
||||
logger.Info("nats jetstream sink enabled", "url", env("NATS_URL", ""), "unified_subject", natsSinkConfigFromEnv().UnifiedSubject, "publish_unified_enabled", envBool("PUBLISH_UNIFIED_ENABLED", false), "publish_fields_enabled", !envBool("FIELDS_DERIVE_FROM_RAW_ENABLED", true))
|
||||
return out, nil
|
||||
}
|
||||
brokers := splitCSV(os.Getenv("KAFKA_BROKERS"))
|
||||
@@ -221,7 +613,12 @@ func buildSink(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
|
||||
spoolDir := strings.TrimSpace(os.Getenv("KAFKA_SPOOL_DIR"))
|
||||
if spoolDir != "" {
|
||||
replayBatchSize := envInt("KAFKA_SPOOL_REPLAY_BATCH_SIZE", 200)
|
||||
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{Directory: spoolDir, ReplayBatchSize: replayBatchSize})
|
||||
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{
|
||||
Directory: spoolDir,
|
||||
ReplayBatchSize: replayBatchSize,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
})
|
||||
interval := time.Duration(envInt("KAFKA_SPOOL_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond
|
||||
go durable.ReplayLoop(ctx, interval, func(err error) {
|
||||
logger.Warn("kafka spool replay failed", "error", err)
|
||||
@@ -232,26 +629,73 @@ func buildSink(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
|
||||
if envBool("KAFKA_ASYNC_ENABLED", true) {
|
||||
queueSize := envInt("KAFKA_ASYNC_QUEUE_SIZE", 100000)
|
||||
workers := envInt("KAFKA_ASYNC_WORKERS", 8)
|
||||
enqueueTimeout := time.Duration(envInt("KAFKA_ASYNC_ENQUEUE_TIMEOUT_MS", 1000)) * time.Millisecond
|
||||
timeout := time.Duration(envInt("KAFKA_ASYNC_PUBLISH_TIMEOUT_MS", 30000)) * time.Millisecond
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("kafka async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("kafka async publish enabled", "queue_size", queueSize, "workers", workers, "publish_timeout_ms", timeout.Milliseconds())
|
||||
if envBool("KAFKA_PARTITIONED_ASYNC_ENABLED", true) {
|
||||
rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers := partitionedAsyncConfigFromEnv("KAFKA", queueSize, workers)
|
||||
rawEnqueueTimeout, derivedEnqueueTimeout := partitionedAsyncEnqueueTimeoutsFromEnv("KAFKA", enqueueTimeout)
|
||||
out = eventbus.NewPartitionedAsyncSink(out, eventbus.PartitionedAsyncConfig{
|
||||
RawQueueSize: rawQueueSize,
|
||||
DerivedQueueSize: derivedQueueSize,
|
||||
RawWorkers: rawWorkers,
|
||||
DerivedWorkers: derivedWorkers,
|
||||
RawEnqueueTimeout: rawEnqueueTimeout,
|
||||
DerivedEnqueueTimeout: derivedEnqueueTimeout,
|
||||
EnqueueTimeout: enqueueTimeout,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("kafka partitioned async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("kafka partitioned async publish enabled", "raw_queue_size", rawQueueSize, "derived_queue_size", derivedQueueSize, "raw_workers", rawWorkers, "derived_workers", derivedWorkers, "raw_enqueue_timeout_ms", rawEnqueueTimeout.Milliseconds(), "derived_enqueue_timeout_ms", derivedEnqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
|
||||
} else {
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
EnqueueTimeout: enqueueTimeout,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("kafka async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("kafka async publish enabled", "queue_size", queueSize, "workers", workers, "enqueue_timeout_ms", enqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func partitionedAsyncConfigFromEnv(prefix string, queueSize int, workers int) (rawQueueSize int, derivedQueueSize int, rawWorkers int, derivedWorkers int) {
|
||||
derivedQueueDefault := queueSize / 2
|
||||
if derivedQueueDefault <= 0 {
|
||||
derivedQueueDefault = 1
|
||||
}
|
||||
derivedWorkersDefault := workers / 2
|
||||
if derivedWorkersDefault <= 0 {
|
||||
derivedWorkersDefault = 1
|
||||
}
|
||||
rawQueueSize = envInt(prefix+"_ASYNC_RAW_QUEUE_SIZE", queueSize)
|
||||
derivedQueueSize = envInt(prefix+"_ASYNC_DERIVED_QUEUE_SIZE", derivedQueueDefault)
|
||||
rawWorkers = envInt(prefix+"_ASYNC_RAW_WORKERS", workers)
|
||||
derivedWorkers = envInt(prefix+"_ASYNC_DERIVED_WORKERS", derivedWorkersDefault)
|
||||
return rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers
|
||||
}
|
||||
|
||||
func partitionedAsyncEnqueueTimeoutsFromEnv(prefix string, enqueueTimeout time.Duration) (rawEnqueueTimeout time.Duration, derivedEnqueueTimeout time.Duration) {
|
||||
rawEnqueueTimeout = time.Duration(envInt(prefix+"_ASYNC_RAW_ENQUEUE_TIMEOUT_MS", int(enqueueTimeout/time.Millisecond))) * time.Millisecond
|
||||
derivedEnqueueTimeout = time.Duration(envInt(prefix+"_ASYNC_DERIVED_ENQUEUE_TIMEOUT_MS", 50)) * time.Millisecond
|
||||
return rawEnqueueTimeout, derivedEnqueueTimeout
|
||||
}
|
||||
|
||||
func natsSinkConfigFromEnv() eventbus.NATSConfig {
|
||||
return eventbus.NATSConfig{
|
||||
URL: env("NATS_URL", ""),
|
||||
Name: env("NATS_CLIENT_NAME", "lingniu-vehicle-gateway"),
|
||||
URL: env("NATS_URL", ""),
|
||||
Name: env("NATS_CLIENT_NAME", "lingniu-vehicle-gateway"),
|
||||
AsyncMaxPending: envInt("NATS_OUTBOX_MAX_INFLIGHT", 10_000),
|
||||
AsyncAckTimeout: time.Duration(envInt("NATS_OUTBOX_ACK_TIMEOUT_MS", 3000)) * time.Millisecond,
|
||||
RawSubjects: map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)),
|
||||
envelope.ProtocolJT808: env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)),
|
||||
@@ -266,6 +710,34 @@ func natsSinkConfigFromEnv() eventbus.NATSConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type natsOutboxRuntimeConfig struct {
|
||||
Enabled bool
|
||||
Config eventbus.DurableOutboxConfig
|
||||
ReplayInterval time.Duration
|
||||
}
|
||||
|
||||
func natsOutboxConfigFromEnv(registry *metrics.Registry, onError func(error)) natsOutboxRuntimeConfig {
|
||||
directory := strings.TrimSpace(os.Getenv("NATS_OUTBOX_DIR"))
|
||||
return natsOutboxRuntimeConfig{
|
||||
Enabled: envBool("NATS_DURABLE_OUTBOX_ENABLED", false),
|
||||
Config: eventbus.DurableOutboxConfig{
|
||||
Directory: directory,
|
||||
ReplayBatchSize: envInt("NATS_OUTBOX_REPLAY_BATCH_SIZE", 1000),
|
||||
SyncWrites: envBool("NATS_OUTBOX_FSYNC", true),
|
||||
CloseTimeout: time.Duration(envInt("NATS_OUTBOX_CLOSE_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
WALSegmentBytes: int64(envInt("NATS_OUTBOX_WAL_SEGMENT_BYTES", 16<<20)),
|
||||
WALSegmentAge: time.Duration(envInt("NATS_OUTBOX_WAL_SEGMENT_AGE_MS", 5000)) * time.Millisecond,
|
||||
WALAppendQueue: envInt("NATS_OUTBOX_WAL_APPEND_QUEUE_SIZE", 100_000),
|
||||
WALCommitBatch: envInt("NATS_OUTBOX_WAL_COMMIT_BATCH_SIZE", 256),
|
||||
WALCommitWait: time.Duration(envInt("NATS_OUTBOX_WAL_COMMIT_INTERVAL_MS", 1)) * time.Millisecond,
|
||||
Metrics: registry,
|
||||
Name: "nats-outbox",
|
||||
OnError: onError,
|
||||
},
|
||||
ReplayInterval: time.Duration(envInt("NATS_OUTBOX_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
@@ -300,6 +772,13 @@ func envBool(key string, fallback bool) bool {
|
||||
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||
}
|
||||
|
||||
func boolMetric(value bool) float64 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestNATSSinkConfigFromEnvUsesExplicitSubjects(t *testing.T) {
|
||||
@@ -13,7 +21,12 @@ func TestNATSSinkConfigFromEnvUsesExplicitSubjects(t *testing.T) {
|
||||
t.Setenv("NATS_SUBJECT_GB32960_RAW", "custom.raw.gb32960")
|
||||
t.Setenv("NATS_SUBJECT_JT808_RAW", "custom.raw.jt808")
|
||||
t.Setenv("NATS_SUBJECT_YUTONG_MQTT_RAW", "custom.raw.yutong")
|
||||
t.Setenv("NATS_SUBJECT_GB32960_FIELDS", "custom.fields.gb32960")
|
||||
t.Setenv("NATS_SUBJECT_JT808_FIELDS", "custom.fields.jt808")
|
||||
t.Setenv("NATS_SUBJECT_YUTONG_MQTT_FIELDS", "custom.fields.yutong")
|
||||
t.Setenv("NATS_SUBJECT_UNIFIED", "custom.unified")
|
||||
t.Setenv("NATS_OUTBOX_MAX_INFLIGHT", "12000")
|
||||
t.Setenv("NATS_OUTBOX_ACK_TIMEOUT_MS", "4500")
|
||||
|
||||
cfg := natsSinkConfigFromEnv()
|
||||
|
||||
@@ -29,9 +42,131 @@ func TestNATSSinkConfigFromEnvUsesExplicitSubjects(t *testing.T) {
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolYutongMQTT], "custom.raw.yutong"; got != want {
|
||||
t.Fatalf("yutong subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolGB32960], "custom.fields.gb32960"; got != want {
|
||||
t.Fatalf("gb32960 fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolJT808], "custom.fields.jt808"; got != want {
|
||||
t.Fatalf("jt808 fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolYutongMQTT], "custom.fields.yutong"; got != want {
|
||||
t.Fatalf("yutong fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.UnifiedSubject, "custom.unified"; got != want {
|
||||
t.Fatalf("unified subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.AsyncMaxPending, 12000; got != want {
|
||||
t.Fatalf("async max pending = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := cfg.AsyncAckTimeout, 4500*time.Millisecond; got != want {
|
||||
t.Fatalf("async ack timeout = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATSOutboxConfigFromEnvIsExplicitAndDefaultsToSafeFsync(t *testing.T) {
|
||||
t.Setenv("NATS_DURABLE_OUTBOX_ENABLED", "true")
|
||||
t.Setenv("NATS_OUTBOX_DIR", "/var/lib/lingniu-go/nats-outbox")
|
||||
t.Setenv("NATS_OUTBOX_REPLAY_BATCH_SIZE", "750")
|
||||
t.Setenv("NATS_OUTBOX_REPLAY_INTERVAL_MS", "250")
|
||||
t.Setenv("NATS_OUTBOX_CLOSE_TIMEOUT_MS", "7000")
|
||||
t.Setenv("NATS_OUTBOX_WAL_SEGMENT_BYTES", "8388608")
|
||||
t.Setenv("NATS_OUTBOX_WAL_SEGMENT_AGE_MS", "4000")
|
||||
t.Setenv("NATS_OUTBOX_WAL_APPEND_QUEUE_SIZE", "50000")
|
||||
t.Setenv("NATS_OUTBOX_WAL_COMMIT_BATCH_SIZE", "128")
|
||||
t.Setenv("NATS_OUTBOX_WAL_COMMIT_INTERVAL_MS", "2")
|
||||
t.Setenv("NATS_OUTBOX_FSYNC", "")
|
||||
registry := metrics.NewRegistry()
|
||||
onError := func(error) {}
|
||||
|
||||
runtime := natsOutboxConfigFromEnv(registry, onError)
|
||||
|
||||
if !runtime.Enabled {
|
||||
t.Fatal("outbox should be enabled")
|
||||
}
|
||||
if got, want := runtime.Config.Directory, "/var/lib/lingniu-go/nats-outbox"; got != want {
|
||||
t.Fatalf("directory = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.ReplayBatchSize, 750; got != want {
|
||||
t.Fatalf("replay batch = %d, want %d", got, want)
|
||||
}
|
||||
if !runtime.Config.SyncWrites {
|
||||
t.Fatal("outbox fsync should default to true")
|
||||
}
|
||||
if got, want := runtime.Config.CloseTimeout, 7*time.Second; got != want {
|
||||
t.Fatalf("close timeout = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALSegmentBytes, int64(8<<20); got != want {
|
||||
t.Fatalf("WAL segment bytes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALSegmentAge, 4*time.Second; got != want {
|
||||
t.Fatalf("WAL segment age = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALAppendQueue, 50_000; got != want {
|
||||
t.Fatalf("WAL append queue = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALCommitBatch, 128; got != want {
|
||||
t.Fatalf("WAL commit batch = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALCommitWait, 2*time.Millisecond; got != want {
|
||||
t.Fatalf("WAL commit wait = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := runtime.ReplayInterval, 250*time.Millisecond; got != want {
|
||||
t.Fatalf("replay interval = %s, want %s", got, want)
|
||||
}
|
||||
if runtime.Config.Metrics != registry || runtime.Config.OnError == nil {
|
||||
t.Fatal("metrics and error callback should be wired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATSOutboxConfigDoesNotReuseLegacySpoolDirectory(t *testing.T) {
|
||||
t.Setenv("NATS_DURABLE_OUTBOX_ENABLED", "")
|
||||
t.Setenv("NATS_OUTBOX_DIR", "")
|
||||
t.Setenv("NATS_SPOOL_DIR", "/var/lib/lingniu-go/nats-spool")
|
||||
t.Setenv("NATS_OUTBOX_FSYNC", "false")
|
||||
|
||||
runtime := natsOutboxConfigFromEnv(nil, nil)
|
||||
|
||||
if runtime.Enabled {
|
||||
t.Fatal("outbox must remain opt-in")
|
||||
}
|
||||
if runtime.Config.Directory != "" {
|
||||
t.Fatalf("WAL directory must be explicit, got %q", runtime.Config.Directory)
|
||||
}
|
||||
if runtime.Config.SyncWrites {
|
||||
t.Fatal("explicit false should disable fsync")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGB32960AuthenticatorUsesConfiguredCredentials(t *testing.T) {
|
||||
t.Setenv("GB32960_AUTH_MODE", "enforce")
|
||||
t.Setenv("GB32960_PLATFORM_CREDENTIALS_FILE", "")
|
||||
t.Setenv("GB32960_PLATFORM_CREDENTIALS_JSON", `{"platform-a":"secret-a","platform-b":["secret-b-old","secret-b"]}`)
|
||||
|
||||
authenticator, mode, count, err := buildGB32960Authenticator()
|
||||
if err != nil {
|
||||
t.Fatalf("buildGB32960Authenticator() error = %v", err)
|
||||
}
|
||||
if mode != authentication.ModeEnforce || count != 2 {
|
||||
t.Fatalf("mode=%q count=%d", mode, count)
|
||||
}
|
||||
result := authenticator.Authenticate(envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
MessageID: "0x05",
|
||||
Parsed: map[string]any{
|
||||
"platform_login": map[string]any{"username": "platform-b", "password": "secret-b"},
|
||||
},
|
||||
})
|
||||
if !result.Allowed || result.Status != authentication.StatusAccepted {
|
||||
t.Fatalf("authentication result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGB32960AuthenticatorRejectsEnforceWithoutCredentials(t *testing.T) {
|
||||
t.Setenv("GB32960_AUTH_MODE", "enforce")
|
||||
t.Setenv("GB32960_PLATFORM_CREDENTIALS_FILE", "")
|
||||
t.Setenv("GB32960_PLATFORM_CREDENTIALS_JSON", "")
|
||||
if _, _, _, err := buildGB32960Authenticator(); err == nil {
|
||||
t.Fatal("expected enforce mode configuration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayConfiguresIdentityLookupCacheTTL(t *testing.T) {
|
||||
@@ -39,13 +174,158 @@ func TestGatewayConfiguresIdentityLookupCacheTTL(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{"LookupCacheTTL", "IDENTITY_LOOKUP_CACHE_TTL_SECONDS"} {
|
||||
for _, want := range []string{"LookupCacheTTL", "StaleLookupTTL", "IDENTITY_LOOKUP_CACHE_TTL_SECONDS", "IDENTITY_STALE_LOOKUP_TTL_SECONDS", "IDENTITY_LOOKUP_CACHE_MAX_ENTRIES", "IDENTITY_LOOKUP_CACHE_CLEANUP_INTERVAL_SECONDS", "vehicle_gateway_identity_cache_entries", "vehicle_gateway_jt808_registration_write_total", "OnRegistrationWriteResult", "IDENTITY_SOURCE_CODE_LOOKUP_ENABLED", "IDENTITY_RESOLVE_TIMEOUT_MS", "IDENTITY_SNAPSHOT_ONLY_ENABLED", "IDENTITY_SNAPSHOT_REFRESH_INTERVAL_SECONDS", "startIdentitySnapshotRefresh", "JT808_REGISTRATION_LOCATION_TOUCH_RETRY_INTERVAL_SECONDS", "JT808_REGISTRATION_WRITE_RETRY_ATTEMPTS", "JT808_REGISTRATION_WRITE_RETRY_DELAY_MS", "JT808_REGISTRATION_ASYNC_WRITE_ENABLED", "JT808_REGISTRATION_WRITE_QUEUE_SIZE", "JT808_REGISTRATION_WRITE_WORKERS", "JT808_REGISTRATION_WRITE_ENQUEUE_TIMEOUT_MS", "TimeoutResolver"} {
|
||||
if !strings.Contains(string(source), want) {
|
||||
t.Fatalf("gateway should expose identity lookup cache ttl, missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordIdentityCacheStatsIncludesLocationTouchFailures(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
recordIdentityCacheStats(registry, identity.CacheStats{
|
||||
LookupEntries: 1,
|
||||
RegistrationEntries: 2,
|
||||
SourceCodeEntries: 3,
|
||||
LocationTouchEntries: 4,
|
||||
LocationTouchFailureEntries: 5,
|
||||
SnapshotBindingEntries: 6,
|
||||
SnapshotIdentifierEntries: 7,
|
||||
SnapshotRegistrationEntries: 8,
|
||||
SnapshotSourceEntries: 9,
|
||||
SnapshotReady: true,
|
||||
SnapshotRefreshedAt: time.Unix(1234, 0),
|
||||
MaxEntries: 9,
|
||||
})
|
||||
|
||||
rendered := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_gateway_identity_cache_entries{cache="location_touch_failure"} 5`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="registration_write_queue"} 0`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="snapshot_binding"} 6`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="snapshot_identifier"} 7`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="snapshot_registration"} 8`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="snapshot_source"} 9`,
|
||||
`vehicle_gateway_identity_cache_max_entries{cache="location_touch_failure"} 9`,
|
||||
`vehicle_gateway_identity_snapshot_ready 1`,
|
||||
`vehicle_gateway_identity_snapshot_last_success_unix_seconds 1234`,
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("identity cache metrics missing %s in:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordIdentitySnapshotRefresh(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
recordIdentitySnapshotRefresh(registry, identity.SnapshotRefreshResult{
|
||||
BindingEntries: 10,
|
||||
IdentifierEntries: 20,
|
||||
RegistrationEntries: 30,
|
||||
SourceEntries: 40,
|
||||
RefreshedAt: time.Unix(5678, 0),
|
||||
}, "ok")
|
||||
|
||||
rendered := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_gateway_identity_snapshot_refresh_total{status="ok"} 1`,
|
||||
`vehicle_gateway_identity_snapshot_entries{kind="binding"} 10`,
|
||||
`vehicle_gateway_identity_snapshot_entries{kind="identifier"} 20`,
|
||||
`vehicle_gateway_identity_snapshot_entries{kind="registration"} 30`,
|
||||
`vehicle_gateway_identity_snapshot_entries{kind="source"} 40`,
|
||||
`vehicle_gateway_identity_snapshot_last_success_unix_seconds 5678`,
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("identity snapshot metrics missing %s in:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityDatabaseMaintenanceDoesNotBlockIngressStartup(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
db := &blockingIdentityDatabase{
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
startedAt := time.Now()
|
||||
startIdentityDatabaseMaintenance(ctx, logger, db, nil, false, time.Second, time.Second)
|
||||
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("database maintenance blocked gateway startup for %s", elapsed)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-db.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("database maintenance did not start in background")
|
||||
}
|
||||
close(db.release)
|
||||
}
|
||||
|
||||
func TestIdentitySnapshotRefreshDoesNotBlockIngressStartup(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
refresher := &blockingIdentitySnapshotRefresher{started: make(chan struct{})}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
startedAt := time.Now()
|
||||
startIdentitySnapshotRefresh(ctx, logger, metrics.NewRegistry(), refresher, time.Hour, time.Second)
|
||||
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("snapshot refresh blocked gateway startup for %s", elapsed)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-refresher.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("snapshot refresh did not start in background")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordJT808RegistrationWriteResult(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
recordJT808RegistrationWriteResult(registry, identity.RegistrationWriteResult{
|
||||
Mode: "async_background",
|
||||
Status: "error",
|
||||
})
|
||||
|
||||
rendered := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_gateway_jt808_registration_write_total{mode="async_background",status="error"} 1`,
|
||||
`vehicle_gateway_last_jt808_registration_write_unix_seconds{mode="async_background",status="error"}`,
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("registration write metric missing %s in:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type blockingIdentityDatabase struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (d *blockingIdentityDatabase) PingContext(ctx context.Context) error {
|
||||
close(d.started)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-d.release:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type blockingIdentitySnapshotRefresher struct {
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (r *blockingIdentitySnapshotRefresher) RefreshSnapshot(ctx context.Context) (identity.SnapshotRefreshResult, error) {
|
||||
close(r.started)
|
||||
<-ctx.Done()
|
||||
return identity.SnapshotRefreshResult{}, errors.New("test refresh stopped")
|
||||
}
|
||||
|
||||
func TestGatewayDefaultsTo100KConnectionCeiling(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
@@ -64,6 +344,18 @@ func TestGatewayPassesMetricsRegistryToAsyncSink(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"buildSink(ctx, logger, registry)",
|
||||
"Metrics: registry",
|
||||
"EnqueueTimeout: enqueueTimeout",
|
||||
"NewPartitionedAsyncSink",
|
||||
"NATS_PARTITIONED_ASYNC_ENABLED",
|
||||
"KAFKA_PARTITIONED_ASYNC_ENABLED",
|
||||
"_ASYNC_RAW_QUEUE_SIZE",
|
||||
"_ASYNC_DERIVED_QUEUE_SIZE",
|
||||
"_ASYNC_RAW_ENQUEUE_TIMEOUT_MS",
|
||||
"_ASYNC_DERIVED_ENQUEUE_TIMEOUT_MS",
|
||||
"RawEnqueueTimeout",
|
||||
"DerivedEnqueueTimeout",
|
||||
"NATS_ASYNC_ENQUEUE_TIMEOUT_MS",
|
||||
"KAFKA_ASYNC_ENQUEUE_TIMEOUT_MS",
|
||||
`Name: "nats"`,
|
||||
`Name: "kafka"`,
|
||||
} {
|
||||
@@ -73,6 +365,43 @@ func TestGatewayPassesMetricsRegistryToAsyncSink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayExposesNATSDurableSpoolConfig(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"NATS_DURABLE_OUTBOX_ENABLED",
|
||||
"NATS_OUTBOX_DIR",
|
||||
"NATS_OUTBOX_MAX_INFLIGHT",
|
||||
"NATS_OUTBOX_ACK_TIMEOUT_MS",
|
||||
"NATS_OUTBOX_FSYNC",
|
||||
"NATS_OUTBOX_CLOSE_TIMEOUT_MS",
|
||||
"NATS_OUTBOX_REPLAY_BATCH_SIZE",
|
||||
"NATS_OUTBOX_REPLAY_INTERVAL_MS",
|
||||
"NATS_OUTBOX_WAL_SEGMENT_BYTES",
|
||||
"NATS_OUTBOX_WAL_SEGMENT_AGE_MS",
|
||||
"NATS_OUTBOX_WAL_APPEND_QUEUE_SIZE",
|
||||
"NATS_OUTBOX_WAL_COMMIT_BATCH_SIZE",
|
||||
"NATS_OUTBOX_WAL_COMMIT_INTERVAL_MS",
|
||||
"eventbus.NewDurableOutboxSink",
|
||||
"nats durable outbox enabled",
|
||||
"NATS_SPOOL_DIR",
|
||||
"NATS_SPOOL_REPLAY_BATCH_SIZE",
|
||||
"NATS_SPOOL_REPLAY_INTERVAL_MS",
|
||||
"nats durable spool enabled",
|
||||
"nats spool replay failed",
|
||||
"eventbus.NewDurableSink",
|
||||
"Metrics: registry",
|
||||
`Name: "nats"`,
|
||||
`Name: "kafka"`,
|
||||
} {
|
||||
if !strings.Contains(string(source), want) {
|
||||
t.Fatalf("gateway nats spool wiring missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATSSinkConfigFromEnvDefaultsToGoSubjects(t *testing.T) {
|
||||
t.Setenv("NATS_URL", "nats://172.17.111.56:4222")
|
||||
|
||||
@@ -87,6 +416,15 @@ func TestNATSSinkConfigFromEnvDefaultsToGoSubjects(t *testing.T) {
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolYutongMQTT], "vehicle.raw.go.yutong-mqtt.v1"; got != want {
|
||||
t.Fatalf("yutong subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolGB32960], "vehicle.fields.go.gb32960.v1"; got != want {
|
||||
t.Fatalf("gb32960 fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolJT808], "vehicle.fields.go.jt808.v1"; got != want {
|
||||
t.Fatalf("jt808 fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolYutongMQTT], "vehicle.fields.go.yutong-mqtt.v1"; got != want {
|
||||
t.Fatalf("yutong fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.UnifiedSubject, "vehicle.event.go.unified.v1"; got != want {
|
||||
t.Fatalf("unified subject = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +19,7 @@ import (
|
||||
_ "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"
|
||||
@@ -28,6 +33,10 @@ func main() {
|
||||
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)
|
||||
@@ -39,18 +48,51 @@ func main() {
|
||||
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.NewWriter(db)
|
||||
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,
|
||||
@@ -60,12 +102,9 @@ func main() {
|
||||
})
|
||||
defer reader.Close()
|
||||
|
||||
logger.Info("history writer started",
|
||||
"driver", cfg.TDengineDriver,
|
||||
"group", cfg.KafkaGroup,
|
||||
"topics", strings.Join(cfg.KafkaTopics, ","),
|
||||
"batch_size", cfg.BatchSize,
|
||||
"batch_wait_ms", cfg.BatchWait)
|
||||
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)
|
||||
@@ -77,7 +116,7 @@ func main() {
|
||||
continue
|
||||
}
|
||||
batch := collectHistoryBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond)
|
||||
processHistoryBatch(ctx, logger, registry, writer, reader, batch)
|
||||
processHistoryBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +127,11 @@ type historyAppender interface {
|
||||
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
|
||||
}
|
||||
@@ -96,6 +140,17 @@ 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}
|
||||
@@ -134,15 +189,38 @@ func processHistoryMessage(ctx context.Context, logger interface {
|
||||
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)
|
||||
_ = committer.CommitMessages(messageCtx, message)
|
||||
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 err := appender.AppendAll(messageCtx, env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
logger.Error("tdengine append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
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)
|
||||
@@ -154,30 +232,52 @@ func processHistoryMessage(ctx context.Context, logger interface {
|
||||
func processHistoryBatch(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message) {
|
||||
}, 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
|
||||
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 {
|
||||
setBatchPending(registry, len(messages), len(envelopes))
|
||||
defer setBatchPending(registry, 0, 0)
|
||||
setBatchPendingForWorker(registry, workerLabels, len(messages), len(envelopes))
|
||||
defer setBatchPendingForWorker(registry, workerLabels, 0, 0)
|
||||
started := time.Now()
|
||||
err := appender.AppendAllBatch(messageCtx, envelopes)
|
||||
result, err := appendHistoryBatch(messageCtx, appender, envelopes)
|
||||
elapsed := time.Since(started)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
@@ -187,15 +287,33 @@ func processHistoryBatch(ctx context.Context, logger interface {
|
||||
addBatchMetric(registry, "vehicle_history_batch_rows_total", status, float64(len(envelopes)))
|
||||
setBatchDuration(registry, status, elapsed)
|
||||
if err != nil {
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("tdengine batch append failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "rows", len(envelopes), "error", err)
|
||||
return
|
||||
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)
|
||||
}
|
||||
for _, message := range messages {
|
||||
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 {
|
||||
@@ -204,18 +322,337 @@ func processHistoryBatch(ctx context.Context, logger interface {
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("kafka batch commit failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
|
||||
return
|
||||
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
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"topic": message.Topic, "status": status})
|
||||
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) {
|
||||
@@ -225,19 +662,113 @@ func addBatchMetric(registry *metrics.Registry, name string, status string, valu
|
||||
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
|
||||
}
|
||||
registry.SetGauge("vehicle_history_batch_flush_duration_ms", metrics.Labels{"status": status}, float64(elapsed.Milliseconds()))
|
||||
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)
|
||||
}
|
||||
|
||||
func setBatchPending(registry *metrics.Registry, messages int, rows int) {
|
||||
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
|
||||
}
|
||||
registry.SetGauge("vehicle_history_batch_pending_messages", nil, float64(messages))
|
||||
registry.SetGauge("vehicle_history_batch_pending_rows", nil, float64(rows))
|
||||
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) {
|
||||
@@ -255,8 +786,26 @@ type config struct {
|
||||
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 {
|
||||
@@ -268,8 +817,11 @@ func loadConfig() config {
|
||||
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", 100),
|
||||
BatchWait: envInt("HISTORY_BATCH_WAIT_MS", 20),
|
||||
RetryAttempts: envInt("HISTORY_RETRY_ATTEMPTS", 3),
|
||||
RetryDelay: time.Duration(envInt("HISTORY_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
695
go/vehicle-gateway/cmd/identity-import/main.go
Normal file
695
go/vehicle-gateway/cmd/identity-import/main.go
Normal file
@@ -0,0 +1,695 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var input string
|
||||
var dsn string
|
||||
var legacyTable string
|
||||
var apply bool
|
||||
var ensureSchema bool
|
||||
var reportLimit int
|
||||
var unresolvedOut string
|
||||
var conflictsOut string
|
||||
var syncDataSources bool
|
||||
var pruneUnmanagedDataSources bool
|
||||
var pruneDataSourceMinAge time.Duration
|
||||
var retireStaleUnmanagedDataSources bool
|
||||
var retireDataSourceMinAge time.Duration
|
||||
var timeout time.Duration
|
||||
flag.StringVar(&input, "input", env("IDENTITY_MAPPING_INPUT", ""), "directory that contains 808 plate/phone mapping workbooks")
|
||||
flag.StringVar(&dsn, "mysql-dsn", env("IDENTITY_MYSQL_DSN", env("MYSQL_DSN", "")), "MySQL DSN; empty means scan only")
|
||||
flag.StringVar(&legacyTable, "legacy-table", env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding"), "legacy VIN/plate binding table used to resolve VIN")
|
||||
flag.BoolVar(&apply, "apply", false, "write resolved mappings to vehicle and vehicle_identifier")
|
||||
flag.BoolVar(&ensureSchema, "ensure-schema", false, "create vehicle and vehicle_identifier tables before import")
|
||||
flag.IntVar(&reportLimit, "report-limit", 50, "max unresolved/conflict items embedded in JSON; use -1 for all")
|
||||
flag.StringVar(&unresolvedOut, "unresolved-out", "", "optional CSV path for all unresolved mappings")
|
||||
flag.StringVar(&conflictsOut, "conflicts-out", "", "optional CSV path for all conflict mappings")
|
||||
flag.BoolVar(&syncDataSources, "sync-data-sources", false, "infer JT808 vehicle_data_source platform names from jt808_registration and vehicle_identifier")
|
||||
flag.BoolVar(&pruneUnmanagedDataSources, "prune-unmanaged-data-sources", false, "delete stale unclassified vehicle_data_source rows that have no registration evidence and are not referenced by final mileage")
|
||||
flag.DurationVar(&pruneDataSourceMinAge, "prune-data-source-min-age", time.Hour, "minimum latest_seen/updated age before -prune-unmanaged-data-sources can delete rows")
|
||||
flag.BoolVar(&retireStaleUnmanagedDataSources, "retire-stale-unmanaged-data-sources", false, "disable stale unclassified vehicle_data_source rows that have no registration evidence but may be referenced by historical mileage")
|
||||
flag.DurationVar(&retireDataSourceMinAge, "retire-data-source-min-age", 24*time.Hour, "minimum latest_seen/updated age before -retire-stale-unmanaged-data-sources can disable rows")
|
||||
flag.DurationVar(&timeout, "timeout", 2*time.Minute, "import timeout")
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
output := map[string]any{}
|
||||
var records []identity.MappingRecord
|
||||
var scan identity.MappingScanReport
|
||||
if input != "" {
|
||||
var err error
|
||||
records, scan, err = identity.ReadMappingDirectory(input)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
output["input"] = input
|
||||
output["scan"] = scan
|
||||
} else if !syncDataSources && !pruneUnmanagedDataSources && !retireStaleUnmanagedDataSources {
|
||||
fail(errors.New("-input is required unless -sync-data-sources, -prune-unmanaged-data-sources or -retire-stale-unmanaged-data-sources is set"))
|
||||
}
|
||||
if err := validateMappingApplyInput(apply, scan); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if strings.TrimSpace(dsn) == "" {
|
||||
if syncDataSources || pruneUnmanagedDataSources || retireStaleUnmanagedDataSources {
|
||||
fail(errors.New("-mysql-dsn is required when -sync-data-sources, -prune-unmanaged-data-sources or -retire-stale-unmanaged-data-sources is set"))
|
||||
}
|
||||
output["mode"] = "scan_only"
|
||||
writeJSON(output)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if input != "" && (ensureSchema || apply) {
|
||||
if err := identity.EnsureVehicleIdentifierSchema(ctx, db); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
if input != "" {
|
||||
report, err := identity.ImportMappingRecords(ctx, db, records, scan, identity.MappingImportOptions{
|
||||
Apply: apply,
|
||||
LegacyTable: legacyTable,
|
||||
ReportItemLimit: reportItemLimit(reportLimit, unresolvedOut, conflictsOut),
|
||||
})
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if strings.TrimSpace(unresolvedOut) != "" {
|
||||
if err := writeUnresolvedCSV(unresolvedOut, report.UnresolvedItems); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(conflictsOut) != "" {
|
||||
if err := writeConflictsCSV(conflictsOut, report.ConflictItems); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
output["report"] = report
|
||||
output["mode"] = map[bool]string{true: "apply", false: "dry_run"}[apply]
|
||||
}
|
||||
if syncDataSources {
|
||||
syncReport, err := syncJT808DataSourcesFromIdentifiers(ctx, db, apply)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
output["data_source_sync"] = syncReport
|
||||
}
|
||||
if pruneUnmanagedDataSources {
|
||||
pruneReport, err := pruneUnmanagedDataSourcesWithoutEvidence(ctx, db, apply, pruneDataSourceMinAge)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
output["data_source_prune"] = pruneReport
|
||||
}
|
||||
if retireStaleUnmanagedDataSources {
|
||||
retireReport, err := retireStaleUnmanagedDataSourcesWithoutEvidence(ctx, db, apply, retireDataSourceMinAge)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
output["data_source_retire"] = retireReport
|
||||
}
|
||||
if input == "" {
|
||||
output["mode"] = maintenanceMode(apply, syncDataSources, pruneUnmanagedDataSources, retireStaleUnmanagedDataSources)
|
||||
}
|
||||
writeJSON(output)
|
||||
}
|
||||
|
||||
func maintenanceMode(apply bool, syncDataSources bool, pruneUnmanagedDataSources bool, retireStaleUnmanagedDataSources bool) string {
|
||||
var parts []string
|
||||
if syncDataSources {
|
||||
parts = append(parts, "sync")
|
||||
}
|
||||
if pruneUnmanagedDataSources {
|
||||
parts = append(parts, "prune")
|
||||
}
|
||||
if retireStaleUnmanagedDataSources {
|
||||
parts = append(parts, "retire")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return map[bool]string{true: "apply", false: "dry_run"}[apply]
|
||||
}
|
||||
parts = append(parts, map[bool]string{true: "apply", false: "dry_run"}[apply])
|
||||
return strings.Join(parts, "_")
|
||||
}
|
||||
|
||||
func reportItemLimit(limit int, unresolvedOut string, conflictsOut string) int {
|
||||
if strings.TrimSpace(unresolvedOut) != "" || strings.TrimSpace(conflictsOut) != "" {
|
||||
return -1
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func validateMappingApplyInput(apply bool, scan identity.MappingScanReport) error {
|
||||
if !apply {
|
||||
return nil
|
||||
}
|
||||
return unsupportedMappingFilesError(scan)
|
||||
}
|
||||
|
||||
func unsupportedMappingFilesError(scan identity.MappingScanReport) error {
|
||||
if scan.UnsupportedFiles <= 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]string, 0, len(scan.UnsupportedItems))
|
||||
for _, item := range scan.UnsupportedItems {
|
||||
path := strings.TrimSpace(item.File)
|
||||
if path == "" {
|
||||
path = strings.TrimSpace(item.Ext)
|
||||
}
|
||||
if path != "" {
|
||||
items = append(items, path)
|
||||
}
|
||||
if len(items) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
suffix := ""
|
||||
if len(items) > 0 {
|
||||
suffix = ": " + strings.Join(items, ", ")
|
||||
}
|
||||
return fmt.Errorf("identity mapping import has %d unsupported workbook(s); convert .xls/.xlsb to .xlsx before -apply%s", scan.UnsupportedFiles, suffix)
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func writeJSON(value any) {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeUnresolvedCSV(path string, items []identity.MappingRecord) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
if err := writer.Write([]string{
|
||||
"file", "sheet", "row", "source_code", "source_name", "protocol",
|
||||
"identifier_type", "identifier_value", "raw_value", "plate", "oem", "reason",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := writer.Write([]string{
|
||||
item.File,
|
||||
item.Sheet,
|
||||
fmt.Sprint(item.Row),
|
||||
item.SourceCode,
|
||||
item.SourceName,
|
||||
item.Protocol,
|
||||
item.IdentifierType,
|
||||
item.IdentifierValue,
|
||||
item.RawValue,
|
||||
item.Plate,
|
||||
item.OEM,
|
||||
"vin_not_found_in_legacy_binding",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writer.Error()
|
||||
}
|
||||
|
||||
func writeConflictsCSV(path string, items []identity.MappingConflict) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
if err := writer.Write([]string{
|
||||
"file", "sheet", "row", "source_code", "source_name", "protocol",
|
||||
"identifier_type", "identifier_value", "raw_value", "plate", "oem",
|
||||
"existing_vin", "new_vin", "reason",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
record := item.Record
|
||||
if err := writer.Write([]string{
|
||||
record.File,
|
||||
record.Sheet,
|
||||
fmt.Sprint(record.Row),
|
||||
record.SourceCode,
|
||||
record.SourceName,
|
||||
record.Protocol,
|
||||
record.IdentifierType,
|
||||
record.IdentifierValue,
|
||||
record.RawValue,
|
||||
record.Plate,
|
||||
record.OEM,
|
||||
item.ExistingVIN,
|
||||
item.NewVIN,
|
||||
item.Reason,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writer.Error()
|
||||
}
|
||||
|
||||
type dataSourceSyncReport struct {
|
||||
Apply bool `json:"apply"`
|
||||
CandidateSources int64 `json:"candidate_sources"`
|
||||
SkippedSources int64 `json:"skipped_sources"`
|
||||
ConflictingSources int64 `json:"conflicting_sources"`
|
||||
PlatformKindCandidates int64 `json:"platform_kind_candidates"`
|
||||
PlatformKindClassified int64 `json:"platform_kind_classified,omitempty"`
|
||||
ReprojectDailyMileageTargets int64 `json:"reproject_daily_mileage_targets,omitempty"`
|
||||
ReprojectedDailyMileageTargets int64 `json:"reprojected_daily_mileage_targets,omitempty"`
|
||||
Synced int64 `json:"synced,omitempty"`
|
||||
}
|
||||
|
||||
type dailyMileageProjectionTarget struct {
|
||||
VIN string
|
||||
StatDate string
|
||||
Protocol envelope.Protocol
|
||||
}
|
||||
|
||||
type dataSourcePruneReport struct {
|
||||
Apply bool `json:"apply"`
|
||||
MinAgeSeconds int64 `json:"min_age_seconds"`
|
||||
CandidateSources int64 `json:"candidate_sources"`
|
||||
Pruned int64 `json:"pruned,omitempty"`
|
||||
}
|
||||
|
||||
type dataSourceRetireReport struct {
|
||||
Apply bool `json:"apply"`
|
||||
MinAgeSeconds int64 `json:"min_age_seconds"`
|
||||
CandidateSources int64 `json:"candidate_sources"`
|
||||
Retired int64 `json:"retired,omitempty"`
|
||||
}
|
||||
|
||||
func syncJT808DataSourcesFromIdentifiers(ctx context.Context, db *sql.DB, apply bool) (dataSourceSyncReport, error) {
|
||||
report := dataSourceSyncReport{Apply: apply}
|
||||
var err error
|
||||
report.CandidateSources, err = queryInt64(ctx, db, syncJT808DataSourcesCandidateCountSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.SkippedSources, err = queryInt64(ctx, db, syncJT808DataSourcesSkippedCountSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.ConflictingSources, err = queryInt64(ctx, db, syncJT808DataSourcesConflictCountSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if apply {
|
||||
result, err := db.ExecContext(ctx, syncJT808DataSourcesSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil {
|
||||
report.Synced = rowsAffected
|
||||
}
|
||||
}
|
||||
report.PlatformKindCandidates, err = queryInt64(ctx, db, classifyConfiguredDataSourcesCountSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if apply {
|
||||
result, err := db.ExecContext(ctx, classifyConfiguredDataSourcesSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil {
|
||||
report.PlatformKindClassified = rowsAffected
|
||||
}
|
||||
}
|
||||
targets, err := queryDailyMileageProjectionTargets(ctx, db)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.ReprojectDailyMileageTargets = int64(len(targets))
|
||||
if apply {
|
||||
for _, target := range targets {
|
||||
if err := stats.ProjectDailyMileage(ctx, db, target.VIN, target.StatDate, target.Protocol); err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.ReprojectedDailyMileageTargets++
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func queryDailyMileageProjectionTargets(ctx context.Context, db *sql.DB) ([]dailyMileageProjectionTarget, error) {
|
||||
rows, err := db.QueryContext(ctx, reprojectSelectableDailyMileageSourcesSQL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var targets []dailyMileageProjectionTarget
|
||||
for rows.Next() {
|
||||
var target dailyMileageProjectionTarget
|
||||
var protocol string
|
||||
if err := rows.Scan(&target.VIN, &target.StatDate, &protocol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target.Protocol = envelope.Protocol(strings.TrimSpace(protocol))
|
||||
if strings.TrimSpace(target.VIN) != "" && strings.TrimSpace(target.StatDate) != "" && strings.TrimSpace(string(target.Protocol)) != "" {
|
||||
targets = append(targets, target)
|
||||
}
|
||||
}
|
||||
return targets, rows.Err()
|
||||
}
|
||||
|
||||
func pruneUnmanagedDataSourcesWithoutEvidence(ctx context.Context, db *sql.DB, apply bool, minAge time.Duration) (dataSourcePruneReport, error) {
|
||||
if minAge < 0 {
|
||||
minAge = 0
|
||||
}
|
||||
minAgeSeconds := int64(minAge.Seconds())
|
||||
report := dataSourcePruneReport{Apply: apply, MinAgeSeconds: minAgeSeconds}
|
||||
var err error
|
||||
report.CandidateSources, err = queryInt64WithArgs(ctx, db, pruneUnmanagedDataSourcesCountSQL, minAgeSeconds)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if !apply {
|
||||
return report, nil
|
||||
}
|
||||
result, err := db.ExecContext(ctx, pruneUnmanagedDataSourcesSQL, minAgeSeconds)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil {
|
||||
report.Pruned = rowsAffected
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func retireStaleUnmanagedDataSourcesWithoutEvidence(ctx context.Context, db *sql.DB, apply bool, minAge time.Duration) (dataSourceRetireReport, error) {
|
||||
if minAge < 0 {
|
||||
minAge = 0
|
||||
}
|
||||
minAgeSeconds := int64(minAge.Seconds())
|
||||
report := dataSourceRetireReport{Apply: apply, MinAgeSeconds: minAgeSeconds}
|
||||
var err error
|
||||
report.CandidateSources, err = queryInt64WithArgs(ctx, db, retireStaleUnmanagedDataSourcesCountSQL, minAgeSeconds)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if !apply {
|
||||
return report, nil
|
||||
}
|
||||
result, err := db.ExecContext(ctx, retireStaleUnmanagedDataSourcesSQL, minAgeSeconds)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil {
|
||||
report.Retired = rowsAffected
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func queryInt64(ctx context.Context, db *sql.DB, query string) (int64, error) {
|
||||
return queryInt64WithArgs(ctx, db, query)
|
||||
}
|
||||
|
||||
func queryInt64WithArgs(ctx context.Context, db *sql.DB, query string, args ...any) (int64, error) {
|
||||
var count int64
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const jt808DataSourceInferenceSQL = `
|
||||
SELECT
|
||||
'JT808' AS protocol,
|
||||
TRIM(r.source_ip) AS source_ip,
|
||||
MAX(TRIM(r.source_endpoint)) AS latest_source_endpoint,
|
||||
MIN(COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), ''))) AS platform_name,
|
||||
MIN(NULLIF(TRIM(vi.source_code), '')) AS source_code,
|
||||
MIN(COALESCE(r.first_registered_at, r.latest_registered_at, r.latest_authenticated_at, r.latest_seen_at, r.updated_at, CURRENT_TIMESTAMP)) AS first_seen_at,
|
||||
MAX(COALESCE(r.latest_seen_at, r.latest_authenticated_at, r.latest_registered_at, r.updated_at, CURRENT_TIMESTAMP)) AS latest_seen_at,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), ''))) AS platform_count,
|
||||
COUNT(DISTINCT NULLIF(TRIM(vi.source_code), '')) AS source_code_count
|
||||
FROM jt808_registration r
|
||||
JOIN vehicle_identifier vi
|
||||
ON vi.protocol = 'JT808'
|
||||
AND vi.identifier_type = 'JT808_PHONE'
|
||||
AND vi.identifier_value = r.phone
|
||||
AND vi.enabled = 1
|
||||
WHERE r.source_endpoint IS NOT NULL
|
||||
AND TRIM(r.source_endpoint) <> ''
|
||||
AND r.source_ip IS NOT NULL
|
||||
AND TRIM(r.source_ip) <> ''
|
||||
GROUP BY TRIM(r.source_ip)
|
||||
`
|
||||
|
||||
const jt808DataSourceCandidateWhereSQL = `inferred.source_ip IS NOT NULL
|
||||
AND inferred.source_ip <> ''
|
||||
AND inferred.platform_name IS NOT NULL
|
||||
AND inferred.platform_name <> ''
|
||||
AND inferred.platform_count = 1
|
||||
AND inferred.source_code_count = 1`
|
||||
|
||||
const jt808DataSourceNeedsSyncWhereSQL = `(ds.id IS NULL
|
||||
OR ds.platform_name IS NULL OR TRIM(ds.platform_name) = ''
|
||||
OR ds.source_code IS NULL OR TRIM(ds.source_code) = ''
|
||||
OR ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'
|
||||
OR (ds.enabled = 0 AND (ds.remark LIKE 'auto-retired:%' OR ds.remark = 'auto-reenabled: source evidence restored')))`
|
||||
|
||||
const syncJT808DataSourcesSQL = `
|
||||
INSERT INTO vehicle_data_source
|
||||
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, first_seen_at, latest_seen_at)
|
||||
SELECT
|
||||
inferred.protocol,
|
||||
inferred.source_ip,
|
||||
inferred.latest_source_endpoint,
|
||||
inferred.platform_name,
|
||||
inferred.source_code,
|
||||
'PLATFORM',
|
||||
inferred.first_seen_at,
|
||||
inferred.latest_seen_at
|
||||
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
ON ds.protocol = inferred.protocol
|
||||
AND ds.source_ip = inferred.source_ip
|
||||
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
||||
AND ` + jt808DataSourceNeedsSyncWhereSQL + `
|
||||
ON DUPLICATE KEY UPDATE
|
||||
latest_source_endpoint = VALUES(latest_source_endpoint),
|
||||
platform_name = CASE
|
||||
WHEN vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''
|
||||
THEN VALUES(platform_name)
|
||||
ELSE vehicle_data_source.platform_name
|
||||
END,
|
||||
source_code = CASE
|
||||
WHEN vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''
|
||||
THEN VALUES(source_code)
|
||||
ELSE vehicle_data_source.source_code
|
||||
END,
|
||||
source_kind = CASE
|
||||
WHEN vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'
|
||||
THEN VALUES(source_kind)
|
||||
ELSE vehicle_data_source.source_kind
|
||||
END,
|
||||
enabled = CASE
|
||||
WHEN vehicle_data_source.enabled = 0
|
||||
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
|
||||
AND (
|
||||
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
|
||||
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
|
||||
OR VALUES(source_kind) <> 'UNKNOWN'
|
||||
)
|
||||
THEN 1
|
||||
ELSE vehicle_data_source.enabled
|
||||
END,
|
||||
remark = CASE
|
||||
WHEN vehicle_data_source.enabled = 0
|
||||
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
|
||||
AND (
|
||||
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
|
||||
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
|
||||
OR VALUES(source_kind) <> 'UNKNOWN'
|
||||
)
|
||||
THEN 'auto-reenabled: source evidence restored'
|
||||
ELSE vehicle_data_source.remark
|
||||
END,
|
||||
first_seen_at = COALESCE(vehicle_data_source.first_seen_at, VALUES(first_seen_at)),
|
||||
latest_seen_at = GREATEST(COALESCE(vehicle_data_source.latest_seen_at, VALUES(latest_seen_at)), VALUES(latest_seen_at)),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
const reprojectSelectableDailyMileageSourcesSQL = `
|
||||
SELECT DISTINCT s.vin, s.stat_date, s.protocol
|
||||
FROM vehicle_daily_mileage_source s
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
|
||||
LEFT JOIN vehicle_daily_mileage m
|
||||
ON m.vin = s.vin AND m.stat_date = s.stat_date AND m.protocol = s.protocol
|
||||
WHERE s.protocol = 'JT808'
|
||||
AND s.quality_status = 'OK'
|
||||
AND (ds.id IS NULL OR ds.enabled = 1 OR (
|
||||
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
|
||||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||||
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
||||
))
|
||||
AND (
|
||||
m.vin IS NULL
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM vehicle_daily_mileage_source selected
|
||||
WHERE selected.vin = s.vin
|
||||
AND selected.stat_date = s.stat_date
|
||||
AND selected.protocol = s.protocol
|
||||
AND selected.is_selected = 1
|
||||
)
|
||||
)
|
||||
ORDER BY s.stat_date DESC, s.vin ASC
|
||||
`
|
||||
|
||||
const syncJT808DataSourcesCandidateCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
ON ds.protocol = inferred.protocol
|
||||
AND ds.source_ip = inferred.source_ip
|
||||
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
||||
AND ` + jt808DataSourceNeedsSyncWhereSQL
|
||||
|
||||
const syncJT808DataSourcesSkippedCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
||||
WHERE inferred.source_ip IS NOT NULL
|
||||
AND inferred.source_ip <> ''
|
||||
AND inferred.platform_name IS NOT NULL
|
||||
AND inferred.platform_name <> ''
|
||||
AND NOT (` + jt808DataSourceCandidateWhereSQL + `)`
|
||||
|
||||
const syncJT808DataSourcesConflictCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
||||
JOIN vehicle_data_source ds
|
||||
ON ds.protocol = inferred.protocol
|
||||
AND ds.source_ip = inferred.source_ip
|
||||
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
||||
AND ds.source_code IS NOT NULL
|
||||
AND TRIM(ds.source_code) <> ''
|
||||
AND ds.source_code <> inferred.source_code`
|
||||
|
||||
const classifyConfiguredDataSourcesWhereSQL = `
|
||||
(source_kind IS NULL OR TRIM(source_kind) = '' OR source_kind = 'UNKNOWN')
|
||||
AND (
|
||||
(source_code IS NOT NULL AND TRIM(source_code) <> '')
|
||||
OR (platform_name IS NOT NULL AND TRIM(platform_name) <> '')
|
||||
)`
|
||||
|
||||
const classifyConfiguredDataSourcesCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM vehicle_data_source
|
||||
WHERE ` + classifyConfiguredDataSourcesWhereSQL
|
||||
|
||||
const classifyConfiguredDataSourcesSQL = `
|
||||
UPDATE vehicle_data_source
|
||||
SET source_kind = 'PLATFORM',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE ` + classifyConfiguredDataSourcesWhereSQL
|
||||
|
||||
const pruneUnmanagedDataSourcesWhereSQL = `
|
||||
(ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
||||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||||
AND (ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN')
|
||||
AND TIMESTAMPDIFF(SECOND, COALESCE(ds.latest_seen_at, ds.updated_at, ds.created_at), CURRENT_TIMESTAMP) >= ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM vehicle_daily_mileage m
|
||||
WHERE m.source_id = ds.id
|
||||
LIMIT 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jt808_registration r
|
||||
WHERE ds.protocol = 'JT808'
|
||||
AND r.source_ip = ds.source_ip
|
||||
LIMIT 1
|
||||
)`
|
||||
|
||||
const pruneUnmanagedDataSourcesCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM vehicle_data_source ds
|
||||
WHERE ` + pruneUnmanagedDataSourcesWhereSQL
|
||||
|
||||
const pruneUnmanagedDataSourcesSQL = `
|
||||
DELETE ds
|
||||
FROM vehicle_data_source ds
|
||||
WHERE ` + pruneUnmanagedDataSourcesWhereSQL
|
||||
|
||||
const retireStaleUnmanagedDataSourcesWhereSQL = `
|
||||
ds.enabled = 1
|
||||
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
||||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||||
AND (ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN')
|
||||
AND TIMESTAMPDIFF(SECOND, COALESCE(ds.latest_seen_at, ds.updated_at, ds.created_at), CURRENT_TIMESTAMP) >= ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jt808_registration r
|
||||
WHERE ds.protocol = 'JT808'
|
||||
AND r.source_ip = ds.source_ip
|
||||
LIMIT 1
|
||||
)`
|
||||
|
||||
const retireStaleUnmanagedDataSourcesCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM vehicle_data_source ds
|
||||
WHERE ` + retireStaleUnmanagedDataSourcesWhereSQL
|
||||
|
||||
const retireStaleUnmanagedDataSourcesSQL = `
|
||||
UPDATE vehicle_data_source ds
|
||||
SET enabled = 0,
|
||||
remark = CASE
|
||||
WHEN remark IS NULL OR TRIM(remark) = ''
|
||||
THEN 'auto-retired: stale unmanaged source without registration evidence'
|
||||
ELSE remark
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE ` + retireStaleUnmanagedDataSourcesWhereSQL
|
||||
|
||||
func fail(err error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
426
go/vehicle-gateway/cmd/identity-import/main_test.go
Normal file
426
go/vehicle-gateway/cmd/identity-import/main_test.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
)
|
||||
|
||||
func TestReportItemLimitExpandsWhenCSVOutputIsRequested(t *testing.T) {
|
||||
if got := reportItemLimit(50, "", ""); got != 50 {
|
||||
t.Fatalf("reportItemLimit without csv = %d, want 50", got)
|
||||
}
|
||||
if got := reportItemLimit(50, "unresolved.csv", ""); got != -1 {
|
||||
t.Fatalf("reportItemLimit with unresolved csv = %d, want -1", got)
|
||||
}
|
||||
if got := reportItemLimit(50, "", "conflicts.csv"); got != -1 {
|
||||
t.Fatalf("reportItemLimit with conflicts csv = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedMappingFilesError(t *testing.T) {
|
||||
err := unsupportedMappingFilesError(identity.MappingScanReport{})
|
||||
if err != nil {
|
||||
t.Fatalf("unsupportedMappingFilesError(empty) = %v", err)
|
||||
}
|
||||
err = unsupportedMappingFilesError(identity.MappingScanReport{
|
||||
UnsupportedFiles: 2,
|
||||
UnsupportedItems: []identity.MappingUnsupportedFileReport{
|
||||
{File: "G7s/legacy.xls", Ext: ".xls"},
|
||||
{File: "信达/legacy.xlsb", Ext: ".xlsb"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("unsupportedMappingFilesError() nil, want error")
|
||||
}
|
||||
text := err.Error()
|
||||
for _, want := range []string{"2 unsupported workbook", "G7s/legacy.xls", "信达/legacy.xlsb", "before -apply"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("error missing %q: %s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMappingApplyInputAllowsDryRunButBlocksApply(t *testing.T) {
|
||||
scan := identity.MappingScanReport{
|
||||
UnsupportedFiles: 1,
|
||||
UnsupportedItems: []identity.MappingUnsupportedFileReport{
|
||||
{File: "G7s/legacy.xls", Ext: ".xls"},
|
||||
},
|
||||
}
|
||||
if err := validateMappingApplyInput(false, scan); err != nil {
|
||||
t.Fatalf("validateMappingApplyInput(dry-run) = %v", err)
|
||||
}
|
||||
if err := validateMappingApplyInput(true, scan); err == nil {
|
||||
t.Fatal("validateMappingApplyInput(apply) nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteUnresolvedCSV(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "unresolved.csv")
|
||||
err := writeUnresolvedCSV(path, []identity.MappingRecord{
|
||||
{
|
||||
File: "G7s/example.xlsx",
|
||||
Sheet: "Sheet1",
|
||||
Row: 2,
|
||||
SourceCode: "g7s",
|
||||
SourceName: "G7s",
|
||||
Protocol: "JT808",
|
||||
IdentifierType: identity.IdentifierTypeJT808Phone,
|
||||
IdentifierValue: "13307795425",
|
||||
RawValue: "013307795425",
|
||||
Plate: "粤AG18312",
|
||||
OEM: "G7s",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeUnresolvedCSV() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{"identifier_type", "JT808_PHONE", "13307795425", "vin_not_found_in_legacy_binding"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("csv missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConflictsCSV(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "conflicts.csv")
|
||||
err := writeConflictsCSV(path, []identity.MappingConflict{
|
||||
{
|
||||
Record: identity.MappingRecord{
|
||||
File: "source.xlsx",
|
||||
Sheet: "Sheet1",
|
||||
Row: 3,
|
||||
SourceCode: "xinda",
|
||||
Protocol: "JT808",
|
||||
IdentifierType: identity.IdentifierTypePlate,
|
||||
IdentifierValue: "粤AG18312",
|
||||
Plate: "粤AG18312",
|
||||
},
|
||||
ExistingVIN: "VIN001",
|
||||
NewVIN: "VIN002",
|
||||
Reason: "identifier already points to another vin",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeConflictsCSV() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{"existing_vin", "VIN001", "VIN002", "identifier already points to another vin"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("csv missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncJT808DataSourcesFromIdentifiersPreservesManualPlatformNames(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectExec("INSERT INTO vehicle_data_source").
|
||||
WillReturnResult(driver.RowsAffected(3))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(5))
|
||||
mock.ExpectExec("UPDATE vehicle_data_source").
|
||||
WillReturnResult(driver.RowsAffected(5))
|
||||
mock.ExpectQuery("SELECT DISTINCT s.vin, s.stat_date, s.protocol").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol"}).
|
||||
AddRow("LNXNEGRRXSR319449", "2026-07-13", "JT808"))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").
|
||||
WillReturnResult(driver.RowsAffected(1))
|
||||
mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").
|
||||
WillReturnResult(driver.RowsAffected(1))
|
||||
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").
|
||||
WillReturnResult(driver.RowsAffected(0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
report, err := syncJT808DataSourcesFromIdentifiers(context.Background(), db, true)
|
||||
if err != nil {
|
||||
t.Fatalf("syncJT808DataSourcesFromIdentifiers() error = %v", err)
|
||||
}
|
||||
if !report.Apply || report.CandidateSources != 3 || report.SkippedSources != 1 || report.ConflictingSources != 2 || report.Synced != 3 || report.PlatformKindCandidates != 5 || report.PlatformKindClassified != 5 || report.ReprojectDailyMileageTargets != 1 || report.ReprojectedDailyMileageTargets != 1 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"COUNT(DISTINCT",
|
||||
"vehicle_identifier vi",
|
||||
"TRIM(r.source_ip) AS source_ip",
|
||||
"GROUP BY TRIM(r.source_ip)",
|
||||
"inferred.source_code",
|
||||
"'PLATFORM'",
|
||||
"inferred.source_code_count = 1",
|
||||
"vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''",
|
||||
"ELSE vehicle_data_source.source_code",
|
||||
"vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'",
|
||||
"ELSE vehicle_data_source.source_kind",
|
||||
"vehicle_data_source.enabled = 0",
|
||||
"vehicle_data_source.remark LIKE 'auto-retired:%'",
|
||||
"vehicle_data_source.remark = 'auto-reenabled: source evidence restored'",
|
||||
"THEN 'auto-reenabled: source evidence restored'",
|
||||
"THEN 1",
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"ds.id IS NULL",
|
||||
} {
|
||||
if !strings.Contains(syncJT808DataSourcesSQL, want) {
|
||||
t.Fatalf("sync sql missing %q:\n%s", want, syncJT808DataSourcesSQL)
|
||||
}
|
||||
}
|
||||
if strings.Index(syncJT808DataSourcesSQL, "enabled = CASE") < 0 ||
|
||||
strings.Index(syncJT808DataSourcesSQL, "remark = CASE") < 0 ||
|
||||
strings.Index(syncJT808DataSourcesSQL, "enabled = CASE") > strings.Index(syncJT808DataSourcesSQL, "remark = CASE") {
|
||||
t.Fatalf("sync sql must restore enabled before updating remark because MySQL evaluates assignments in order:\n%s", syncJT808DataSourcesSQL)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"ds.id IS NULL",
|
||||
"ds.source_code IS NULL OR TRIM(ds.source_code) = ''",
|
||||
"ds.enabled = 0 AND (ds.remark LIKE 'auto-retired:%'",
|
||||
"ds.remark = 'auto-reenabled: source evidence restored'",
|
||||
} {
|
||||
if !strings.Contains(syncJT808DataSourcesCandidateCountSQL, want) {
|
||||
t.Fatalf("candidate count sql missing %q:\n%s", want, syncJT808DataSourcesCandidateCountSQL)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"JOIN vehicle_data_source ds",
|
||||
"ds.source_code <> inferred.source_code",
|
||||
} {
|
||||
if !strings.Contains(syncJT808DataSourcesConflictCountSQL, want) {
|
||||
t.Fatalf("conflict count sql missing %q:\n%s", want, syncJT808DataSourcesConflictCountSQL)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''",
|
||||
"ELSE vehicle_data_source.platform_name",
|
||||
} {
|
||||
if !strings.Contains(syncJT808DataSourcesSQL, want) {
|
||||
t.Fatalf("sync sql missing %q:\n%s", want, syncJT808DataSourcesSQL)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"source_kind IS NULL OR TRIM(source_kind) = '' OR source_kind = 'UNKNOWN'",
|
||||
"source_code IS NOT NULL AND TRIM(source_code) <> ''",
|
||||
"platform_name IS NOT NULL AND TRIM(platform_name) <> ''",
|
||||
"SET source_kind = 'PLATFORM'",
|
||||
} {
|
||||
if !strings.Contains(classifyConfiguredDataSourcesSQL, want) && !strings.Contains(classifyConfiguredDataSourcesCountSQL, want) {
|
||||
t.Fatalf("configured-source classification sql missing %q:\n%s\n%s", want, classifyConfiguredDataSourcesSQL, classifyConfiguredDataSourcesCountSQL)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"SELECT DISTINCT s.vin, s.stat_date, s.protocol",
|
||||
"vehicle_daily_mileage_source s",
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"LEFT JOIN vehicle_daily_mileage m",
|
||||
"s.protocol = 'JT808'",
|
||||
"s.quality_status = 'OK'",
|
||||
"ds.enabled = 1",
|
||||
"m.vin IS NULL",
|
||||
"selected.is_selected = 1",
|
||||
} {
|
||||
if !strings.Contains(reprojectSelectableDailyMileageSourcesSQL, want) {
|
||||
t.Fatalf("reproject sql missing %q:\n%s", want, reprojectSelectableDailyMileageSourcesSQL)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncJT808DataSourcesDryRunDoesNotWrite(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(4))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(6))
|
||||
mock.ExpectQuery("SELECT DISTINCT s.vin, s.stat_date, s.protocol").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol"}).
|
||||
AddRow("LNXNEGRRXSR319449", "2026-07-13", "JT808"))
|
||||
|
||||
report, err := syncJT808DataSourcesFromIdentifiers(context.Background(), db, false)
|
||||
if err != nil {
|
||||
t.Fatalf("syncJT808DataSourcesFromIdentifiers() error = %v", err)
|
||||
}
|
||||
if report.Apply || report.CandidateSources != 4 || report.SkippedSources != 2 || report.ConflictingSources != 1 || report.PlatformKindCandidates != 6 || report.PlatformKindClassified != 0 || report.Synced != 0 || report.ReprojectDailyMileageTargets != 1 || report.ReprojectedDailyMileageTargets != 0 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceModeNamesAllSourceMaintenanceSteps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apply bool
|
||||
sync bool
|
||||
prune bool
|
||||
retire bool
|
||||
want string
|
||||
}{
|
||||
{name: "dry run", want: "dry_run"},
|
||||
{name: "apply", apply: true, want: "apply"},
|
||||
{name: "sync dry run", sync: true, want: "sync_dry_run"},
|
||||
{name: "sync prune retire apply", apply: true, sync: true, prune: true, retire: true, want: "sync_prune_retire_apply"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := maintenanceMode(tt.apply, tt.sync, tt.prune, tt.retire); got != tt.want {
|
||||
t.Fatalf("maintenanceMode() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneUnmanagedDataSourcesDryRunDoesNotDelete(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WithArgs(int64(3600)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(339))
|
||||
|
||||
report, err := pruneUnmanagedDataSourcesWithoutEvidence(context.Background(), db, false, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("pruneUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
||||
}
|
||||
if report.Apply || report.MinAgeSeconds != 3600 || report.CandidateSources != 339 || report.Pruned != 0 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vehicle_data_source ds",
|
||||
"vehicle_daily_mileage m",
|
||||
"m.source_id = ds.id",
|
||||
"jt808_registration r",
|
||||
"r.source_ip = ds.source_ip",
|
||||
"ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'",
|
||||
"TIMESTAMPDIFF(SECOND",
|
||||
} {
|
||||
if !strings.Contains(pruneUnmanagedDataSourcesCountSQL, want) {
|
||||
t.Fatalf("prune count sql missing %q:\n%s", want, pruneUnmanagedDataSourcesCountSQL)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireStaleUnmanagedDataSourcesDryRunDoesNotDisable(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WithArgs(int64(86400)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(83))
|
||||
|
||||
report, err := retireStaleUnmanagedDataSourcesWithoutEvidence(context.Background(), db, false, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("retireStaleUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
||||
}
|
||||
if report.Apply || report.MinAgeSeconds != 86400 || report.CandidateSources != 83 || report.Retired != 0 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vehicle_data_source ds",
|
||||
"ds.enabled = 1",
|
||||
"jt808_registration r",
|
||||
"r.source_ip = ds.source_ip",
|
||||
"ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'",
|
||||
"TIMESTAMPDIFF(SECOND",
|
||||
} {
|
||||
if !strings.Contains(retireStaleUnmanagedDataSourcesCountSQL, want) {
|
||||
t.Fatalf("retire count sql missing %q:\n%s", want, retireStaleUnmanagedDataSourcesCountSQL)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireStaleUnmanagedDataSourcesApplyDisablesOnlyCandidates(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WithArgs(int64(7200)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(7))
|
||||
mock.ExpectExec("UPDATE vehicle_data_source ds").
|
||||
WithArgs(int64(7200)).
|
||||
WillReturnResult(driver.RowsAffected(7))
|
||||
|
||||
report, err := retireStaleUnmanagedDataSourcesWithoutEvidence(context.Background(), db, true, 2*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("retireStaleUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
||||
}
|
||||
if !report.Apply || report.MinAgeSeconds != 7200 || report.CandidateSources != 7 || report.Retired != 7 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneUnmanagedDataSourcesApplyDeletesOnlyCandidates(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WithArgs(int64(1800)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(12))
|
||||
mock.ExpectExec("DELETE ds\\s+FROM vehicle_data_source ds").
|
||||
WithArgs(int64(1800)).
|
||||
WillReturnResult(driver.RowsAffected(12))
|
||||
|
||||
report, err := pruneUnmanagedDataSourcesWithoutEvidence(context.Background(), db, true, 30*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("pruneUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
||||
}
|
||||
if !report.Apply || report.MinAgeSeconds != 1800 || report.CandidateSources != 12 || report.Pruned != 12 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
512
go/vehicle-gateway/cmd/identity-writer/main.go
Normal file
512
go/vehicle-gateway/cmd/identity-writer/main.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"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/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
const identityBatchOperationTimeout = 30 * time.Second
|
||||
|
||||
func main() {
|
||||
logger := observability.NewLogger("vehicle-identity-writer")
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Error("invalid identity writer config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := sql.Open("mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
logger.Error("mysql open failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(cfg.MySQLMaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MySQLMaxIdleConns)
|
||||
db.SetConnMaxLifetime(cfg.MySQLConnMaxLifetime)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
logger.Error("mysql ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if cfg.EnsureSchema {
|
||||
if err := identity.EnsureJT808RegistrationSchema(ctx, db); err != nil {
|
||||
logger.Error("jt808 registration schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
registry := metrics.NewRegistry()
|
||||
metrics.RegisterKafkaConsumerInfo(registry, "vehicle-identity-writer", cfg.KafkaGroup, []string{cfg.KafkaTopic})
|
||||
registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize))
|
||||
registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "batch_wait_ms"}, float64(cfg.BatchWait.Milliseconds()))
|
||||
registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "location_touch_interval_seconds"}, cfg.LocationTouchInterval.Seconds())
|
||||
registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
|
||||
health.Start(ctx, logger, health.NewServer(cfg.HealthAddr, "vehicle-identity-writer", []health.Check{
|
||||
{Name: "mysql", Check: db.PingContext},
|
||||
}, registry))
|
||||
|
||||
store := identity.NewJT808RegistrationStore(db)
|
||||
|
||||
logger.Info("identity writer started",
|
||||
"group", cfg.KafkaGroup,
|
||||
"topic", cfg.KafkaTopic,
|
||||
"workers", cfg.Workers,
|
||||
"batch_size", cfg.BatchSize,
|
||||
"batch_wait_ms", cfg.BatchWait.Milliseconds(),
|
||||
"location_touch_interval_seconds", cfg.LocationTouchInterval.Seconds())
|
||||
var workers sync.WaitGroup
|
||||
for workerID := 1; workerID <= cfg.Workers; workerID++ {
|
||||
workers.Add(1)
|
||||
go func(id int) {
|
||||
defer workers.Done()
|
||||
runIdentityConsumer(ctx, logger, registry, store, cfg, id)
|
||||
}(workerID)
|
||||
}
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func runIdentityConsumer(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
store registrationBatchStore,
|
||||
cfg config,
|
||||
workerID int,
|
||||
) {
|
||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: cfg.KafkaBrokers,
|
||||
GroupID: cfg.KafkaGroup,
|
||||
GroupTopics: []string{cfg.KafkaTopic},
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
StartOffset: cfg.StartOffset,
|
||||
})
|
||||
defer reader.Close()
|
||||
|
||||
// Kafka keeps one phone on one partition. Per-worker throttling avoids a
|
||||
// global hot lock; a rebalance can only cause one harmless idempotent touch.
|
||||
projector := identity.NewJT808RegistrationProjector(cfg.Location, cfg.LocationTouchInterval)
|
||||
workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)}
|
||||
registry.SetGauge("vehicle_identity_writer_worker_active", workerLabels, 1)
|
||||
defer registry.SetGauge("vehicle_identity_writer_worker_active", workerLabels, 0)
|
||||
|
||||
logger.Info("identity kafka consumer started", "worker", workerID, "topic", cfg.KafkaTopic)
|
||||
for {
|
||||
first, err := reader.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.Error("kafka fetch failed", "error", err)
|
||||
if !waitForRetry(ctx, cfg.RetryDelay) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
batch := collectIdentityBatch(ctx, reader, first, cfg.BatchSize, cfg.BatchWait)
|
||||
if !processIdentityBatchReliablyForWorker(ctx, logger, registry, projector, store, reader, batch, cfg.RetryDelay, workerLabels) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type registrationBatchStore interface {
|
||||
UpsertBatch(context.Context, []identity.JT808RegistrationFact) error
|
||||
}
|
||||
|
||||
type registrationProjector interface {
|
||||
ProjectBatch([]envelope.FrameEnvelope) []identity.JT808RegistrationFact
|
||||
MarkPersisted([]identity.JT808RegistrationFact)
|
||||
}
|
||||
|
||||
type kafkaMessageFetcher interface {
|
||||
FetchMessage(context.Context) (kafka.Message, error)
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
const (
|
||||
identityFailureWrite = "write_error"
|
||||
identityFailureCommit = "commit_error"
|
||||
)
|
||||
|
||||
type identityBatchFailure struct {
|
||||
reason string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *identityBatchFailure) Error() string { return e.err.Error() }
|
||||
func (e *identityBatchFailure) Unwrap() error { return e.err }
|
||||
|
||||
func collectIdentityBatch(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 = 20 * 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 processIdentityBatch(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
) error {
|
||||
return processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, true, nil)
|
||||
}
|
||||
|
||||
func processIdentityBatchAttempt(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
recordReceived bool,
|
||||
) error {
|
||||
return processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, recordReceived, nil)
|
||||
}
|
||||
|
||||
func processIdentityBatchAttemptForWorker(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
recordReceived bool,
|
||||
workerLabels metrics.Labels,
|
||||
) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), identityBatchOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
registry.SetGauge("vehicle_identity_writer_batch_pending_messages", workerLabels, float64(len(messages)))
|
||||
defer registry.SetGauge("vehicle_identity_writer_batch_pending_messages", workerLabels, 0)
|
||||
envelopes := make([]envelope.FrameEnvelope, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
if recordReceived {
|
||||
recordIdentityMessage(registry, message, "received")
|
||||
registry.SetKafkaLag("vehicle_identity_writer_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark)
|
||||
}
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
if recordReceived {
|
||||
recordIdentityMessage(registry, message, "invalid_json")
|
||||
logger.Warn("skip invalid identity raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status, err := topics.ValidateRawEnvelope(message.Topic, env); err != nil {
|
||||
if recordReceived {
|
||||
recordIdentityMessage(registry, message, status)
|
||||
logger.Warn("skip mismatched identity raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
envelopes = append(envelopes, env)
|
||||
}
|
||||
facts := projector.ProjectBatch(envelopes)
|
||||
registry.SetGauge("vehicle_identity_writer_batch_pending_facts", workerLabels, float64(len(facts)))
|
||||
defer registry.SetGauge("vehicle_identity_writer_batch_pending_facts", workerLabels, 0)
|
||||
if len(facts) > 0 {
|
||||
started := time.Now()
|
||||
if err := store.UpsertBatch(operationCtx, facts); err != nil {
|
||||
recordIdentityWrite(registry, "error", len(facts), time.Since(started))
|
||||
return &identityBatchFailure{reason: identityFailureWrite, err: fmt.Errorf("upsert jt808 registration facts: %w", err)}
|
||||
}
|
||||
projector.MarkPersisted(facts)
|
||||
recordIdentityWrite(registry, "ok", len(facts), time.Since(started))
|
||||
}
|
||||
if err := committer.CommitMessages(operationCtx, messages...); err != nil {
|
||||
for _, message := range messages {
|
||||
recordIdentityCommit(registry, message, "error")
|
||||
}
|
||||
return &identityBatchFailure{reason: identityFailureCommit, err: fmt.Errorf("commit identity kafka batch: %w", err)}
|
||||
}
|
||||
for _, message := range messages {
|
||||
recordIdentityCommit(registry, message, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processIdentityBatchReliably(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
retryDelay time.Duration,
|
||||
) bool {
|
||||
return processIdentityBatchReliablyForWorker(ctx, logger, registry, projector, store, committer, messages, retryDelay, nil)
|
||||
}
|
||||
|
||||
func processIdentityBatchReliablyForWorker(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
retryDelay time.Duration,
|
||||
workerLabels metrics.Labels,
|
||||
) bool {
|
||||
defer registry.SetGauge("vehicle_identity_writer_retry_pending_messages", workerLabels, 0)
|
||||
recordReceived := true
|
||||
for {
|
||||
err := processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, recordReceived, workerLabels)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
reason := identityFailureReason(err)
|
||||
registry.SetGauge("vehicle_identity_writer_retry_pending_messages", workerLabels, float64(len(messages)))
|
||||
registry.IncCounter("vehicle_identity_writer_batch_retries_total", metrics.Labels{"reason": reason})
|
||||
logger.Error("identity batch failed; retrying without fetching newer offsets", "messages", len(messages), "reason", reason, "error", err)
|
||||
if reason == identityFailureCommit {
|
||||
return retryIdentityCommit(ctx, logger, registry, committer, messages, retryDelay)
|
||||
}
|
||||
if !waitForRetry(ctx, retryDelay) {
|
||||
return false
|
||||
}
|
||||
recordReceived = false
|
||||
}
|
||||
}
|
||||
|
||||
func retryIdentityCommit(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
retryDelay time.Duration,
|
||||
) bool {
|
||||
for {
|
||||
if !waitForRetry(ctx, retryDelay) {
|
||||
return false
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), identityBatchOperationTimeout)
|
||||
err := committer.CommitMessages(operationCtx, messages...)
|
||||
cancel()
|
||||
if err == nil {
|
||||
for _, message := range messages {
|
||||
recordIdentityCommit(registry, message, "ok")
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, message := range messages {
|
||||
recordIdentityCommit(registry, message, "error")
|
||||
}
|
||||
registry.IncCounter("vehicle_identity_writer_batch_retries_total", metrics.Labels{"reason": identityFailureCommit})
|
||||
logger.Error("identity kafka commit retry failed", "messages", len(messages), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func identityFailureReason(err error) string {
|
||||
var failure *identityBatchFailure
|
||||
if errors.As(err, &failure) && failure.reason != "" {
|
||||
return failure.reason
|
||||
}
|
||||
return identityFailureWrite
|
||||
}
|
||||
|
||||
var identityWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func recordIdentityMessage(registry *metrics.Registry, message kafka.Message, status string) {
|
||||
labels := metrics.Labels{"topic": message.Topic, "status": status}
|
||||
registry.IncCounter("vehicle_identity_writer_kafka_messages_total", labels)
|
||||
metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_message_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func recordIdentityWrite(registry *metrics.Registry, status string, facts int, elapsed time.Duration) {
|
||||
labels := metrics.Labels{"status": status}
|
||||
registry.IncCounter("vehicle_identity_writer_batches_total", labels)
|
||||
registry.AddCounter("vehicle_identity_writer_facts_total", labels, float64(facts))
|
||||
registry.ObserveHistogram("vehicle_identity_writer_write_duration_ms_histogram", labels, identityWriteDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_write_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func recordIdentityCommit(registry *metrics.Registry, message kafka.Message, status string) {
|
||||
labels := metrics.Labels{"topic": message.Topic, "status": status}
|
||||
registry.IncCounter("vehicle_identity_writer_kafka_commits_total", labels)
|
||||
metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_commit_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) bool {
|
||||
if delay <= 0 {
|
||||
delay = time.Second
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type config struct {
|
||||
KafkaBrokers []string
|
||||
KafkaTopic string
|
||||
KafkaGroup string
|
||||
MySQLDSN string
|
||||
MySQLMaxOpenConns int
|
||||
MySQLMaxIdleConns int
|
||||
MySQLConnMaxLifetime time.Duration
|
||||
EnsureSchema bool
|
||||
HealthAddr string
|
||||
Location *time.Location
|
||||
LocationTouchInterval time.Duration
|
||||
BatchSize int
|
||||
BatchWait time.Duration
|
||||
RetryDelay time.Duration
|
||||
StartOffset int64
|
||||
Workers int
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
location, err := time.LoadLocation(env("LOCAL_TZ", "Asia/Shanghai"))
|
||||
if err != nil {
|
||||
location = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
}
|
||||
startOffset := kafka.LastOffset
|
||||
if strings.EqualFold(env("KAFKA_START_OFFSET", "last"), "first") {
|
||||
startOffset = kafka.FirstOffset
|
||||
}
|
||||
return config{
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
KafkaTopic: env("KAFKA_TOPIC", topics.RawJT808),
|
||||
KafkaGroup: env("KAFKA_GROUP", "go-identity-writer"),
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
MySQLMaxOpenConns: envInt("MYSQL_MAX_OPEN_CONNS", 8),
|
||||
MySQLMaxIdleConns: envInt("MYSQL_MAX_IDLE_CONNS", 4),
|
||||
MySQLConnMaxLifetime: time.Duration(envInt("MYSQL_CONN_MAX_LIFETIME_SECONDS", 300)) * time.Second,
|
||||
EnsureSchema: envBool("MYSQL_ENSURE_SCHEMA", true),
|
||||
HealthAddr: env("HEALTH_ADDR", "127.0.0.1:20217"),
|
||||
Location: location,
|
||||
LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
BatchSize: envInt("IDENTITY_WRITER_BATCH_SIZE", 500),
|
||||
BatchWait: time.Duration(envInt("IDENTITY_WRITER_BATCH_WAIT_MS", 20)) * time.Millisecond,
|
||||
RetryDelay: time.Duration(envInt("IDENTITY_WRITER_RETRY_DELAY_MS", 1000)) * time.Millisecond,
|
||||
StartOffset: startOffset,
|
||||
Workers: envInt("IDENTITY_WRITER_WORKERS", 3),
|
||||
}
|
||||
}
|
||||
|
||||
func (c config) Validate() error {
|
||||
if len(c.KafkaBrokers) == 0 {
|
||||
return fmt.Errorf("KAFKA_BROKERS is required")
|
||||
}
|
||||
if strings.TrimSpace(c.MySQLDSN) == "" {
|
||||
return fmt.Errorf("MYSQL_DSN is required")
|
||||
}
|
||||
protocol, ok := topics.ProtocolForKnownRawTopic(c.KafkaTopic)
|
||||
if !ok || protocol != string(envelope.ProtocolJT808) {
|
||||
return fmt.Errorf("identity writer consumes JT808 raw topic only, got %q", c.KafkaTopic)
|
||||
}
|
||||
if c.BatchSize <= 0 {
|
||||
return fmt.Errorf("IDENTITY_WRITER_BATCH_SIZE must be positive")
|
||||
}
|
||||
if c.Workers <= 0 {
|
||||
return fmt.Errorf("IDENTITY_WRITER_WORKERS must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 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 splitCSV(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, item := range parts {
|
||||
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
314
go/vehicle-gateway/cmd/identity-writer/main_test.go
Normal file
314
go/vehicle-gateway/cmd/identity-writer/main_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
type fakeRegistrationProjector struct {
|
||||
facts []identity.JT808RegistrationFact
|
||||
envelopes []envelope.FrameEnvelope
|
||||
markedFacts []identity.JT808RegistrationFact
|
||||
}
|
||||
|
||||
func (p *fakeRegistrationProjector) ProjectBatch(envs []envelope.FrameEnvelope) []identity.JT808RegistrationFact {
|
||||
p.envelopes = append(p.envelopes, envs...)
|
||||
return p.facts
|
||||
}
|
||||
|
||||
func (p *fakeRegistrationProjector) MarkPersisted(facts []identity.JT808RegistrationFact) {
|
||||
p.markedFacts = append(p.markedFacts, facts...)
|
||||
}
|
||||
|
||||
type fakeRegistrationStore struct {
|
||||
facts []identity.JT808RegistrationFact
|
||||
err error
|
||||
count int
|
||||
failOnCount int
|
||||
}
|
||||
|
||||
func (s *fakeRegistrationStore) UpsertBatch(_ context.Context, facts []identity.JT808RegistrationFact) error {
|
||||
s.count++
|
||||
s.facts = append(s.facts, facts...)
|
||||
if s.err != nil && (s.failOnCount == 0 || s.count == s.failOnCount) {
|
||||
return s.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeCommitter struct {
|
||||
messages []kafka.Message
|
||||
err error
|
||||
count int
|
||||
failOnCount int
|
||||
}
|
||||
|
||||
func (c *fakeCommitter) CommitMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
c.count++
|
||||
c.messages = append(c.messages, messages...)
|
||||
if c.err != nil && (c.failOnCount == 0 || c.count == c.failOnCount) {
|
||||
return c.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchDoesNotCommitOrThrottleOnStoreFailure(t *testing.T) {
|
||||
wantErr := errors.New("mysql unavailable")
|
||||
projector := &fakeRegistrationProjector{facts: []identity.JT808RegistrationFact{{
|
||||
Phone: "13307795425",
|
||||
SeenAt: time.Now(),
|
||||
}}}
|
||||
store := &fakeRegistrationStore{err: wantErr}
|
||||
committer := &fakeCommitter{}
|
||||
|
||||
err := processIdentityBatch(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
metrics.NewRegistry(),
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
[]kafka.Message{validIdentityMessage(t, 1)},
|
||||
)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("processIdentityBatch() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if len(committer.messages) != 0 {
|
||||
t.Fatalf("committed messages = %d, want 0", len(committer.messages))
|
||||
}
|
||||
if len(projector.markedFacts) != 0 {
|
||||
t.Fatalf("marked facts = %d, want 0", len(projector.markedFacts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchMarksOnlyAfterStoreAndCommits(t *testing.T) {
|
||||
fact := identity.JT808RegistrationFact{Phone: "13307795425", SeenAt: time.Now()}
|
||||
projector := &fakeRegistrationProjector{facts: []identity.JT808RegistrationFact{fact}}
|
||||
store := &fakeRegistrationStore{}
|
||||
committer := &fakeCommitter{}
|
||||
messages := []kafka.Message{validIdentityMessage(t, 1), validIdentityMessage(t, 2)}
|
||||
|
||||
err := processIdentityBatch(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
metrics.NewRegistry(),
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
messages,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("processIdentityBatch() error = %v", err)
|
||||
}
|
||||
if len(store.facts) != 1 || len(projector.markedFacts) != 1 {
|
||||
t.Fatalf("store facts = %d marked facts = %d, want 1/1", len(store.facts), len(projector.markedFacts))
|
||||
}
|
||||
if len(committer.messages) != len(messages) {
|
||||
t.Fatalf("committed messages = %d, want %d", len(committer.messages), len(messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchCommitsPoisonEnvelopeWithoutProjection(t *testing.T) {
|
||||
projector := &fakeRegistrationProjector{}
|
||||
store := &fakeRegistrationStore{}
|
||||
committer := &fakeCommitter{}
|
||||
message := kafka.Message{Topic: topics.RawJT808, Partition: 1, Offset: 7, Value: []byte("not-json")}
|
||||
|
||||
err := processIdentityBatch(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
metrics.NewRegistry(),
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
[]kafka.Message{message},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("processIdentityBatch() error = %v", err)
|
||||
}
|
||||
if len(store.facts) != 0 || len(projector.envelopes) != 0 {
|
||||
t.Fatalf("poison message reached projector/store: envs=%d facts=%d", len(projector.envelopes), len(store.facts))
|
||||
}
|
||||
if len(committer.messages) != 1 {
|
||||
t.Fatalf("committed messages = %d, want 1", len(committer.messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchReliablyRetriesCommitWithoutRewritingMySQL(t *testing.T) {
|
||||
fact := identity.JT808RegistrationFact{Phone: "13307795425", SeenAt: time.Now()}
|
||||
projector := &fakeRegistrationProjector{facts: []identity.JT808RegistrationFact{fact}}
|
||||
store := &fakeRegistrationStore{}
|
||||
committer := &fakeCommitter{err: errors.New("commit failed"), failOnCount: 1}
|
||||
registry := metrics.NewRegistry()
|
||||
messages := []kafka.Message{validIdentityMessage(t, 1), validIdentityMessage(t, 2)}
|
||||
|
||||
ok := processIdentityBatchReliably(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
registry,
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
messages,
|
||||
time.Nanosecond,
|
||||
)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("reliable identity batch returned false")
|
||||
}
|
||||
if store.count != 1 {
|
||||
t.Fatalf("mysql writes = %d, want 1 after commit-only retry", store.count)
|
||||
}
|
||||
if committer.count != 2 {
|
||||
t.Fatalf("commit attempts = %d, want initial failure and one retry", committer.count)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_identity_writer_batch_retries_total{reason="commit_error"} 1`,
|
||||
`vehicle_identity_writer_retry_pending_messages 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("missing metric %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchReliablyRetriesStoreBeforeCommit(t *testing.T) {
|
||||
fact := identity.JT808RegistrationFact{Phone: "13307795425", SeenAt: time.Now()}
|
||||
projector := &fakeRegistrationProjector{facts: []identity.JT808RegistrationFact{fact}}
|
||||
store := &fakeRegistrationStore{err: errors.New("mysql unavailable"), failOnCount: 1}
|
||||
committer := &fakeCommitter{}
|
||||
registry := metrics.NewRegistry()
|
||||
messages := []kafka.Message{validIdentityMessage(t, 1)}
|
||||
|
||||
ok := processIdentityBatchReliably(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
registry,
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
messages,
|
||||
time.Nanosecond,
|
||||
)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("reliable identity batch returned false")
|
||||
}
|
||||
if store.count != 2 || committer.count != 1 {
|
||||
t.Fatalf("mysql writes=%d commits=%d, want 2/1", store.count, committer.count)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_identity_writer_batch_retries_total{reason="write_error"} 1`,
|
||||
`vehicle_identity_writer_kafka_messages_total{status="received",topic="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("missing metric %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsNonJT808Topic(t *testing.T) {
|
||||
cfg := config{
|
||||
KafkaBrokers: []string{"127.0.0.1:9092"},
|
||||
KafkaTopic: topics.RawGB32960,
|
||||
MySQLDSN: "user:pass@tcp(localhost:3306)/db",
|
||||
BatchSize: 100,
|
||||
Workers: 3,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() error = nil, want non-JT808 topic error")
|
||||
}
|
||||
cfg.KafkaTopic = topics.RawJT808
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsAndOverridesWorkers(t *testing.T) {
|
||||
t.Setenv("IDENTITY_WRITER_WORKERS", "")
|
||||
if got := loadConfig().Workers; got != 3 {
|
||||
t.Fatalf("default workers = %d, want 3", got)
|
||||
}
|
||||
t.Setenv("IDENTITY_WRITER_WORKERS", "5")
|
||||
if got := loadConfig().Workers; got != 5 {
|
||||
t.Fatalf("configured workers = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsNonPositiveWorkers(t *testing.T) {
|
||||
cfg := config{
|
||||
KafkaBrokers: []string{"127.0.0.1:9092"},
|
||||
KafkaTopic: topics.RawJT808,
|
||||
MySQLDSN: "user:pass@tcp(localhost:3306)/db",
|
||||
BatchSize: 100,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "IDENTITY_WRITER_WORKERS") {
|
||||
t.Fatalf("Validate() error = %v, want workers error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchForWorkerLabelsPendingMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
workerLabels := metrics.Labels{"worker": "2"}
|
||||
err := processIdentityBatchAttemptForWorker(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
registry,
|
||||
&fakeRegistrationProjector{},
|
||||
&fakeRegistrationStore{},
|
||||
&fakeCommitter{},
|
||||
[]kafka.Message{validIdentityMessage(t, 1)},
|
||||
true,
|
||||
workerLabels,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("processIdentityBatchAttemptForWorker() error = %v", err)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_identity_writer_batch_pending_messages{worker="2"} 0`,
|
||||
`vehicle_identity_writer_batch_pending_facts{worker="2"} 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("missing metric %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validIdentityMessage(t *testing.T, offset int64) kafka.Message {
|
||||
t.Helper()
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: identity.JT808LocationMessageID,
|
||||
EventKind: envelope.EventKindRaw,
|
||||
Phone: "13307795425",
|
||||
VIN: "LTESTVIN000000001",
|
||||
ReceivedAtMS: time.Now().UnixMilli(),
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kafka.Message{
|
||||
Topic: topics.RawJT808,
|
||||
Partition: 1,
|
||||
Offset: offset,
|
||||
HighWaterMark: offset + 1,
|
||||
Value: payload,
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,26 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
flagCfg := loadsim.RegisterFlags(flag.CommandLine)
|
||||
cleanupRegistration := flag.Bool("cleanup-registration", false, "delete this run's loopback JT808 registration rows after the simulation")
|
||||
cleanupOnly := flag.Bool("cleanup-only", false, "skip the simulation and only clean the configured JT808 phone range")
|
||||
mysqlDSN := flag.String("mysql-dsn", strings.TrimSpace(os.Getenv("MYSQL_DSN")), "MySQL DSN used only by JT808 registration cleanup")
|
||||
if err := flag.CommandLine.Parse(os.Args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -25,6 +32,14 @@ func main() {
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if *cleanupOnly {
|
||||
deleted, err := cleanupJT808Registrations(ctx, cfg, *mysqlDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("jt808 synthetic registration cleanup completed rows_deleted=%d", deleted)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("load simulation started protocol=%s addr=%s connections=%d connect_rate=%d send_interval=%s duration=%s template=%s",
|
||||
cfg.Protocol, cfg.Addr, cfg.Connections, cfg.ConnectRatePerSecond, cfg.SendInterval, cfg.Duration, cfg.Template)
|
||||
@@ -33,13 +48,57 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Print(formatStats(stats))
|
||||
if *cleanupRegistration {
|
||||
deleted, err := cleanupJT808Registrations(ctx, cfg, *mysqlDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("jt808 synthetic registration cleanup completed rows_deleted=%d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupJT808Registrations(ctx context.Context, cfg loadsim.Config, dsn string) (int64, error) {
|
||||
if cfg.Protocol != loadsim.ProtocolJT808 {
|
||||
return 0, fmt.Errorf("registration cleanup only supports jt808")
|
||||
}
|
||||
if strings.TrimSpace(dsn) == "" {
|
||||
return 0, fmt.Errorf("mysql-dsn or MYSQL_DSN is required for registration cleanup")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open mysql for registration cleanup: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
return cleanupJT808RegistrationsWithDB(ctx, db, cfg)
|
||||
}
|
||||
|
||||
type cleanupExecer interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func cleanupJT808RegistrationsWithDB(ctx context.Context, exec cleanupExecer, cfg loadsim.Config) (int64, error) {
|
||||
firstPhone := fmt.Sprintf("%012d", cfg.JT808PhoneBase)
|
||||
lastPhone := fmt.Sprintf("%012d", cfg.JT808PhoneBase+int64(cfg.Connections)-1)
|
||||
result, err := exec.ExecContext(ctx, `DELETE FROM jt808_registration
|
||||
WHERE phone BETWEEN ? AND ?
|
||||
AND source_ip IN ('127.0.0.1', '::1')`, firstPhone, lastPhone)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete loopback jt808 registrations: %w", err)
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read registration cleanup result: %w", err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func formatStats(stats loadsim.Stats) string {
|
||||
return fmt.Sprintf("connections_opened=%d connections_failed=%d frames_written=%d write_errors=%d",
|
||||
return fmt.Sprintf("connections_opened=%d connections_failed=%d frames_written=%d write_errors=%d response_bytes=%d read_errors=%d",
|
||||
stats.ConnectionsOpened,
|
||||
stats.ConnectionsFailed,
|
||||
stats.FramesWritten,
|
||||
stats.WriteErrors,
|
||||
stats.ResponseBytes,
|
||||
stats.ReadErrors,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim"
|
||||
)
|
||||
|
||||
@@ -13,6 +16,8 @@ func TestFormatStatsIncludesCapacityCounters(t *testing.T) {
|
||||
ConnectionsFailed: 2,
|
||||
FramesWritten: 300,
|
||||
WriteErrors: 1,
|
||||
ResponseBytes: 2048,
|
||||
ReadErrors: 0,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
@@ -20,9 +25,37 @@ func TestFormatStatsIncludesCapacityCounters(t *testing.T) {
|
||||
"connections_failed=2",
|
||||
"frames_written=300",
|
||||
"write_errors=1",
|
||||
"response_bytes=2048",
|
||||
"read_errors=0",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("formatStats() = %q, missing %q", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupJT808RegistrationsUsesBoundedLoopbackDelete(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectExec(`DELETE FROM jt808_registration`).
|
||||
WithArgs("139000000000", "139000000999").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1000))
|
||||
|
||||
deleted, err := cleanupJT808RegistrationsWithDB(context.Background(), db, loadsim.Config{
|
||||
Protocol: loadsim.ProtocolJT808,
|
||||
Connections: 1000,
|
||||
JT808PhoneBase: loadsim.DefaultJT808PhoneBase,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cleanupJT808RegistrationsWithDB() error = %v", err)
|
||||
}
|
||||
if deleted != 1000 {
|
||||
t.Fatalf("deleted = %d, want 1000", deleted)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,24 +60,32 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tdDB, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if err != nil {
|
||||
logger.Error("tdengine open failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
tdDB.SetMaxOpenConns(cfg.TDengineMaxOpenConns)
|
||||
tdDB.SetMaxIdleConns(cfg.TDengineMaxIdleConns)
|
||||
if err := tdDB.PingContext(ctx); err != nil {
|
||||
logger.Error("tdengine ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
historyWriter := history.NewWriterWithDatabase(tdDB, cfg.TDengineDatabase)
|
||||
if cfg.TDengineEnsureSchema {
|
||||
if err := historyWriter.EnsureSchema(ctx, cfg.TDengineDatabase); err != nil {
|
||||
logger.Error("tdengine schema bootstrap failed", "error", err)
|
||||
var historyWriter fastAppender
|
||||
var tdCheck health.Check
|
||||
if cfg.TDengineEnabled {
|
||||
tdDB, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if err != nil {
|
||||
logger.Error("tdengine open failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
tdDB.SetMaxOpenConns(cfg.TDengineMaxOpenConns)
|
||||
tdDB.SetMaxIdleConns(cfg.TDengineMaxIdleConns)
|
||||
if err := tdDB.PingContext(ctx); err != nil {
|
||||
logger.Error("tdengine ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
writer := history.NewWriterWithDatabase(tdDB, cfg.TDengineDatabase)
|
||||
if cfg.TDengineEnsureSchema {
|
||||
if err := writer.EnsureSchema(ctx, cfg.TDengineDatabase); err != nil {
|
||||
logger.Error("tdengine schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
historyWriter = writer
|
||||
tdCheck = health.Check{Name: "tdengine", Check: tdDB.PingContext}
|
||||
} else {
|
||||
logger.Info("nats fast writer tdengine stage disabled")
|
||||
}
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
@@ -94,23 +102,30 @@ func main() {
|
||||
realtimeRepo := realtime.NewRepository(redisClient, realtime.Config{OnlineTTL: cfg.OnlineTTL})
|
||||
|
||||
registry := metrics.NewRegistry()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-fast-writer", []health.Check{
|
||||
recordFastWriterConfigMetrics(registry, cfg)
|
||||
registry.SetGauge("vehicle_fast_writer_tdengine_enabled", nil, boolGauge(cfg.TDengineEnabled))
|
||||
healthChecks := []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
|
||||
}},
|
||||
{Name: "tdengine", Check: tdDB.PingContext},
|
||||
{Name: "redis", Check: func(ctx context.Context) error { return redisClient.Ping(ctx).Err() }},
|
||||
}, registry))
|
||||
}
|
||||
if tdCheck.Name != "" {
|
||||
healthChecks = append(healthChecks, tdCheck)
|
||||
}
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-fast-writer", healthChecks, registry))
|
||||
|
||||
logger.Info("nats fast writer started",
|
||||
"stream", cfg.NATSStream,
|
||||
"durable", cfg.NATSDurable,
|
||||
"filter", cfg.NATSFilter,
|
||||
"batch_size", cfg.BatchSize,
|
||||
"fetch_wait_ms", cfg.FetchWait.Milliseconds(),
|
||||
"workers", cfg.Workers,
|
||||
"tdengine_enabled", cfg.TDengineEnabled,
|
||||
"tdengine_max_open_conns", cfg.TDengineMaxOpenConns,
|
||||
"tdengine_max_idle_conns", cfg.TDengineMaxIdleConns,
|
||||
"operation_timeout_ms", cfg.OperationWait.Milliseconds())
|
||||
@@ -135,6 +150,7 @@ type config struct {
|
||||
StreamMaxBytes int64
|
||||
StreamEnsureWait time.Duration
|
||||
Workers int
|
||||
TDengineEnabled bool
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
@@ -168,13 +184,14 @@ func loadConfig() config {
|
||||
NATSFilter: env("NATS_FILTER", "vehicle.raw.go.>"),
|
||||
NATSSubjects: subjects,
|
||||
BatchSize: envInt("FAST_WRITER_BATCH_SIZE", 100),
|
||||
FetchWait: time.Duration(envInt("FAST_WRITER_FETCH_WAIT_MS", 100)) * time.Millisecond,
|
||||
FetchWait: time.Duration(envInt("FAST_WRITER_FETCH_WAIT_MS", 20)) * time.Millisecond,
|
||||
OperationWait: time.Duration(envInt("FAST_WRITER_OPERATION_TIMEOUT_MS", 1000)) * time.Millisecond,
|
||||
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 30)) * 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,
|
||||
TDengineEnabled: envBool("FAST_WRITER_TDENGINE_ENABLED", false),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
@@ -198,10 +215,18 @@ type fastUpdater interface {
|
||||
FastUpdate(context.Context, envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type fastResultUpdater interface {
|
||||
FastUpdateWithResult(context.Context, envelope.FrameEnvelope) (realtime.FastUpdateResult, error)
|
||||
}
|
||||
|
||||
type fastBatchUpdater interface {
|
||||
FastUpdateBatch(context.Context, []envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type fastBatchResultUpdater interface {
|
||||
FastUpdateBatchWithResult(context.Context, []envelope.FrameEnvelope) (realtime.FastUpdateResult, error)
|
||||
}
|
||||
|
||||
type fastMessage struct {
|
||||
subject string
|
||||
data []byte
|
||||
@@ -227,9 +252,17 @@ func runFastWorker(ctx context.Context, logger *slog.Logger, registry *metrics.R
|
||||
}
|
||||
msgs, err := sub.Fetch(cfg.BatchSize, nats.MaxWait(cfg.FetchWait))
|
||||
if err != nil {
|
||||
if isFastWorkerShutdownFetchError(ctx, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, nats.ErrTimeout) {
|
||||
continue
|
||||
}
|
||||
if isTransientFastFetchError(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
|
||||
@@ -253,12 +286,33 @@ func runFastWorker(ctx context.Context, logger *slog.Logger, registry *metrics.R
|
||||
logger.Error("fast write batch failed", "messages", len(fastMessages), "error", err)
|
||||
continue
|
||||
}
|
||||
for _, msg := range fastMessages {
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isFastWorkerShutdownFetchError(ctx context.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, nats.ErrConnectionClosed)
|
||||
}
|
||||
|
||||
func isTransientFastFetchError(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")
|
||||
}
|
||||
|
||||
type natsPullSubscription interface {
|
||||
Fetch(int, ...nats.PullOpt) ([]*nats.Msg, error)
|
||||
}
|
||||
@@ -268,18 +322,44 @@ type natsConsumerInfoReader interface {
|
||||
}
|
||||
|
||||
var fastWriterStageDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
var fastWriterRedisE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
|
||||
var fastWriterRedisE2ERecent = metrics.NewRecentLatencyByKey(512)
|
||||
var fastBatchPending = metrics.PendingPairGauge{}
|
||||
|
||||
func processFastBatch(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, messages []*fastMessage) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, msg := range messages {
|
||||
addFastMetric(registry, msg.subject, "received")
|
||||
}
|
||||
envelopes := make([]envelope.FrameEnvelope, 0, len(messages))
|
||||
validMessages := make([]*fastMessage, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(msg.data, &env); err != nil {
|
||||
addFastMetric(registry, msg.subject, "invalid_json")
|
||||
if msg.ack != nil {
|
||||
_ = msg.ack()
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack invalid json: %w", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status, err := topics.ValidateRawEnvelope(msg.subject, env); err != nil {
|
||||
addFastMetric(registry, msg.subject, status)
|
||||
if msg.ack != nil {
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack mismatched raw envelope: %w", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -289,85 +369,358 @@ func processFastBatch(ctx context.Context, registry *metrics.Registry, appender
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
setFastBatchPending(registry, len(messages), len(envelopes))
|
||||
defer setFastBatchPending(registry, 0, 0)
|
||||
addFastBatchPending(registry, len(messages), len(envelopes))
|
||||
defer addFastBatchPending(registry, -len(messages), -len(envelopes))
|
||||
subject := fastBatchSubject(validMessages)
|
||||
started := time.Now()
|
||||
err := appender.AppendAllBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("tdengine batch append: %w", err)
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
|
||||
started = time.Now()
|
||||
err = batchUpdater.FastUpdateBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if appender != nil {
|
||||
started := time.Now()
|
||||
err := appender.AppendAllBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast batch update: %w", err)
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
if fallbackErr := processFastMessagesIndividually(ctx, registry, appender, updater, validMessages); fallbackErr != nil {
|
||||
addFastBatchFallbackMetric(registry, "tdengine", "error")
|
||||
return fmt.Errorf("tdengine batch fallback: %w", fallbackErr)
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "tdengine", "ok")
|
||||
return nil
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "tdengine", "skipped_transient")
|
||||
if catchupErr := updateFastRedisBatchWithoutAck(ctx, registry, updater, validMessages, envelopes); catchupErr != nil {
|
||||
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "error")
|
||||
return fmt.Errorf("tdengine batch append: %w; redis catchup: %v", err, catchupErr)
|
||||
}
|
||||
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "ok")
|
||||
return fmt.Errorf("tdengine batch append: %w", err)
|
||||
}
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchResultUpdater); ok {
|
||||
for _, group := range fastSubjectGroups(validMessages, envelopes) {
|
||||
started := time.Now()
|
||||
result, err := batchUpdater.FastUpdateBatchWithResult(ctx, group.envelopes)
|
||||
recordFastWriterStageDuration(registry, group.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
if fallbackErr := processFastMessagesIndividually(ctx, registry, nil, updater, validMessages); fallbackErr != nil {
|
||||
addFastBatchFallbackMetric(registry, "redis", "error")
|
||||
return fmt.Errorf("redis fast batch fallback: %w", fallbackErr)
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "redis", "ok")
|
||||
return nil
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "redis", "skipped_transient")
|
||||
return fmt.Errorf("redis fast batch update: %w", err)
|
||||
}
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, group.subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, group.subject, result)
|
||||
recordFastWriterRedisE2EDurationMessages(registry, group.messages, group.envelopes, group.subject)
|
||||
}
|
||||
for _, msg := range validMessages {
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
|
||||
for _, group := range fastSubjectGroups(validMessages, envelopes) {
|
||||
started := time.Now()
|
||||
err := batchUpdater.FastUpdateBatch(ctx, group.envelopes)
|
||||
recordFastWriterStageDuration(registry, group.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
if fallbackErr := processFastMessagesIndividually(ctx, registry, nil, updater, validMessages); fallbackErr != nil {
|
||||
addFastBatchFallbackMetric(registry, "redis", "error")
|
||||
return fmt.Errorf("redis fast batch fallback: %w", fallbackErr)
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "redis", "ok")
|
||||
return nil
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "redis", "skipped_transient")
|
||||
return fmt.Errorf("redis fast batch update: %w", err)
|
||||
}
|
||||
recordFastWriterRedisE2EDurationMessages(registry, group.messages, group.envelopes, group.subject)
|
||||
}
|
||||
for _, msg := range validMessages {
|
||||
if msg.ack != nil {
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, env := range envelopes {
|
||||
msg := validMessages[i]
|
||||
started = time.Now()
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
started := time.Now()
|
||||
var result realtime.FastUpdateResult
|
||||
var err error
|
||||
if resultUpdater, ok := updater.(fastResultUpdater); ok {
|
||||
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
|
||||
} else {
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
}
|
||||
recordFastWriterStageDuration(registry, msg.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast update: %w", err)
|
||||
}
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, msg.subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, msg.subject, result)
|
||||
recordFastWriterRedisE2EDuration(registry, msg.subject, env)
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processFastMessagesIndividually(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, messages []*fastMessage) error {
|
||||
for _, msg := range messages {
|
||||
if err := processFastMessageWithReceived(ctx, registry, appender, updater, msg, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processFastMessage(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, msg *fastMessage) error {
|
||||
return processFastMessageWithReceived(ctx, registry, appender, updater, msg, true)
|
||||
}
|
||||
|
||||
func processFastMessageWithReceived(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, msg *fastMessage, recordReceived bool) error {
|
||||
if recordReceived {
|
||||
addFastMetric(registry, msg.subject, "received")
|
||||
}
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(msg.data, &env); err != nil {
|
||||
addFastMetric(registry, msg.subject, "invalid_json")
|
||||
if msg.ack != nil {
|
||||
_ = msg.ack()
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack invalid json: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
started := time.Now()
|
||||
err := appender.AppendAll(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("tdengine append: %w", err)
|
||||
if status, err := topics.ValidateRawEnvelope(msg.subject, env); err != nil {
|
||||
addFastMetric(registry, msg.subject, status)
|
||||
if msg.ack != nil {
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack mismatched raw envelope: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if appender != nil {
|
||||
started := time.Now()
|
||||
err := appender.AppendAll(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
if isTransientFastBatchError(err) {
|
||||
if catchupErr := updateFastRedisSingleWithoutAck(ctx, registry, updater, msg.subject, env); catchupErr != nil {
|
||||
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "error")
|
||||
return fmt.Errorf("tdengine append: %w; redis catchup: %v", err, catchupErr)
|
||||
}
|
||||
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "ok")
|
||||
}
|
||||
return fmt.Errorf("tdengine append: %w", err)
|
||||
}
|
||||
}
|
||||
started := time.Now()
|
||||
var result realtime.FastUpdateResult
|
||||
var err error
|
||||
if resultUpdater, ok := updater.(fastResultUpdater); ok {
|
||||
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
|
||||
} else {
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
}
|
||||
started = time.Now()
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast update: %w", err)
|
||||
}
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, msg.subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, msg.subject, result)
|
||||
recordFastWriterRedisE2EDuration(registry, msg.subject, env)
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateFastRedisBatchWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, messages []*fastMessage, envelopes []envelope.FrameEnvelope) error {
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
subject := fastBatchSubject(messages)
|
||||
if batchUpdater, ok := updater.(fastBatchResultUpdater); ok {
|
||||
started := time.Now()
|
||||
result, err := batchUpdater.FastUpdateBatchWithResult(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err == nil {
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, subject, result)
|
||||
recordFastWriterRedisE2EDurationMessages(registry, messages, envelopes, subject)
|
||||
return nil
|
||||
}
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
addFastBatchFallbackMetric(registry, "redis_catchup", "attempted")
|
||||
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
|
||||
started := time.Now()
|
||||
err := batchUpdater.FastUpdateBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err == nil {
|
||||
recordFastWriterRedisE2EDurationMessages(registry, messages, envelopes, subject)
|
||||
return nil
|
||||
}
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
addFastBatchFallbackMetric(registry, "redis_catchup", "attempted")
|
||||
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
|
||||
}
|
||||
|
||||
func updateFastRedisSinglesWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, messages []*fastMessage, envelopes []envelope.FrameEnvelope) error {
|
||||
for index, env := range envelopes {
|
||||
subject := fastMessageSubject(messages, index, fastBatchSubject(messages))
|
||||
if err := updateFastRedisSingleWithoutAck(ctx, registry, updater, subject, env); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateFastRedisSingleWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, subject string, env envelope.FrameEnvelope) error {
|
||||
started := time.Now()
|
||||
var result realtime.FastUpdateResult
|
||||
var err error
|
||||
if resultUpdater, ok := updater.(fastResultUpdater); ok {
|
||||
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
|
||||
} else {
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
}
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, subject, result)
|
||||
recordFastWriterRedisE2EDuration(registry, subject, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldFallbackFastBatchError(err error) bool {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
return !isTransientFastBatchError(err)
|
||||
}
|
||||
|
||||
func isTransientFastBatchError(err error) bool {
|
||||
if err == nil {
|
||||
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 recordFastWriterConfigMetrics(registry *metrics.Registry, cfg config) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
|
||||
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize))
|
||||
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "fetch_wait_ms"}, float64(cfg.FetchWait.Milliseconds()))
|
||||
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "operation_timeout_ms"}, float64(cfg.OperationWait.Milliseconds()))
|
||||
}
|
||||
|
||||
func boolGauge(value bool) float64 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type fastSubjectGroup struct {
|
||||
subject string
|
||||
messages []*fastMessage
|
||||
envelopes []envelope.FrameEnvelope
|
||||
}
|
||||
|
||||
func fastSubjectGroups(messages []*fastMessage, envelopes []envelope.FrameEnvelope) []fastSubjectGroup {
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
fallback := fastBatchSubject(messages)
|
||||
groups := make([]fastSubjectGroup, 0, len(envelopes))
|
||||
indexBySubject := make(map[string]int, len(envelopes))
|
||||
for index, env := range envelopes {
|
||||
subject := fastMessageSubject(messages, index, fallback)
|
||||
groupIndex, exists := indexBySubject[subject]
|
||||
if !exists {
|
||||
groupIndex = len(groups)
|
||||
indexBySubject[subject] = groupIndex
|
||||
groups = append(groups, fastSubjectGroup{subject: subject})
|
||||
}
|
||||
groups[groupIndex].envelopes = append(groups[groupIndex].envelopes, env)
|
||||
if index >= 0 && index < len(messages) {
|
||||
groups[groupIndex].messages = append(groups[groupIndex].messages, messages[index])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func fastBatchSubject(messages []*fastMessage) string {
|
||||
if len(messages) == 0 {
|
||||
return "unknown"
|
||||
@@ -384,6 +737,16 @@ func fastBatchSubject(messages []*fastMessage) string {
|
||||
return subject
|
||||
}
|
||||
|
||||
func fastMessageSubject(messages []*fastMessage, index int, fallback string) string {
|
||||
if index >= 0 && index < len(messages) && strings.TrimSpace(messages[index].subject) != "" {
|
||||
return messages[index].subject
|
||||
}
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func ensureStream(js nats.JetStreamContext, cfg config) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.StreamEnsureWait)
|
||||
defer cancel()
|
||||
@@ -416,15 +779,36 @@ func addFastMetric(registry *metrics.Registry, subject string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_fast_writer_messages_total", metrics.Labels{"subject": subject, "status": status})
|
||||
labels := metrics.Labels{"subject": subject, "status": status}
|
||||
registry.IncCounter("vehicle_fast_writer_messages_total", labels)
|
||||
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_message_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func setFastBatchPending(registry *metrics.Registry, messages int, envelopes int) {
|
||||
func addFastBatchFallbackMetric(registry *metrics.Registry, stage string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_fast_writer_batch_pending_messages", nil, float64(messages))
|
||||
registry.SetGauge("vehicle_fast_writer_batch_pending_envelopes", nil, float64(envelopes))
|
||||
registry.IncCounter("vehicle_fast_writer_batch_fallback_total", metrics.Labels{
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func addFastDecoupledUpdateMetric(registry *metrics.Registry, reason string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_fast_writer_decoupled_updates_total", metrics.Labels{
|
||||
"reason": reason,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func addFastBatchPending(registry *metrics.Registry, messages int, envelopes int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
fastBatchPending.Add(registry, "vehicle_fast_writer_batch_pending_messages", "vehicle_fast_writer_batch_pending_envelopes", messages, envelopes)
|
||||
}
|
||||
|
||||
func recordFastNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info *nats.ConsumerInfo) {
|
||||
@@ -441,11 +825,78 @@ func recordFastWriterStageDuration(registry *metrics.Registry, subject string, s
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_fast_writer_stage_duration_ms_histogram", metrics.Labels{
|
||||
labels := metrics.Labels{
|
||||
"subject": subject,
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
}, fastWriterStageDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_fast_writer_stage_duration_ms_histogram", labels, fastWriterStageDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_stage_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func recordFastWriterRedisE2EDurationMessages(registry *metrics.Registry, messages []*fastMessage, envelopes []envelope.FrameEnvelope, fallback string) {
|
||||
for index, env := range envelopes {
|
||||
recordFastWriterRedisE2EDuration(registry, fastMessageSubject(messages, index, fallback), env)
|
||||
}
|
||||
}
|
||||
|
||||
func recordFastWriterRedisE2EDuration(registry *metrics.Registry, subject string, env envelope.FrameEnvelope) {
|
||||
if registry == nil || env.ReceivedAtMS <= 0 {
|
||||
return
|
||||
}
|
||||
elapsedMS := time.Since(time.UnixMilli(env.ReceivedAtMS)).Milliseconds()
|
||||
if elapsedMS < 0 {
|
||||
elapsedMS = 0
|
||||
}
|
||||
labels := metrics.Labels{
|
||||
"subject": subject,
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_fast_writer_redis_e2e_duration_ms_histogram", labels, fastWriterRedisE2EDurationBucketsMS, float64(elapsedMS))
|
||||
p99, samples := fastWriterRedisE2ERecent.Observe(subject, float64(elapsedMS))
|
||||
registry.SetGauge("vehicle_fast_writer_redis_e2e_recent_p99_ms", labels, p99)
|
||||
registry.SetGauge("vehicle_fast_writer_redis_e2e_recent_samples", labels, float64(samples))
|
||||
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_redis_e2e_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func recordFastWriterRedisFieldMetrics(registry *metrics.Registry, subject string, result realtime.FastUpdateResult) {
|
||||
if registry == nil || result.FieldsSeen == 0 {
|
||||
return
|
||||
}
|
||||
addFastWriterRedisFieldMetric(registry, subject, "seen", result.FieldsSeen)
|
||||
addFastWriterRedisFieldMetric(registry, subject, "written", result.FieldsWritten)
|
||||
addFastWriterRedisFieldMetric(registry, subject, "skipped_stale", result.FieldsSkippedStale)
|
||||
}
|
||||
|
||||
func recordFastWriterRedisEnvelopeMetrics(registry *metrics.Registry, subject string, result realtime.FastUpdateResult) {
|
||||
if registry == nil || result.EnvelopesSeen == 0 {
|
||||
return
|
||||
}
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "seen", result.EnvelopesSeen)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "updated", result.EnvelopesUpdated)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_non_realtime", result.EnvelopesSkippedNonRealtime)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_vin", result.EnvelopesSkippedMissingVIN)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_vehicle_key", result.EnvelopesSkippedMissingVehicleKey)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_fields", result.EnvelopesSkippedMissingFields)
|
||||
}
|
||||
|
||||
func addFastWriterRedisEnvelopeMetric(registry *metrics.Registry, subject string, status string, value int) {
|
||||
if value <= 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_fast_writer_redis_envelopes_total", metrics.Labels{
|
||||
"subject": subject,
|
||||
"status": status,
|
||||
}, float64(value))
|
||||
}
|
||||
|
||||
func addFastWriterRedisFieldMetric(registry *metrics.Registry, subject string, status string, value int) {
|
||||
if value <= 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_fast_writer_redis_fields_total", metrics.Labels{
|
||||
"subject": subject,
|
||||
"status": status,
|
||||
}, float64(value))
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
@@ -487,6 +938,20 @@ func envInt64(key string, fallback int64) int64 {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
switch value {
|
||||
case "":
|
||||
return fallback
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
case "0", "false", "no", "n", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
|
||||
@@ -12,6 +12,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/realtime"
|
||||
)
|
||||
|
||||
func TestProcessFastMessageWritesTDengineAndRedisBeforeAck(t *testing.T) {
|
||||
@@ -58,8 +59,45 @@ func TestProcessFastMessageDoesNotAckWhenRedisFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageUpdatesRedisButDoesNotAckOnTransientTDengineFailure(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-td-transient-single"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{err: errors.New("connection reset by peer")}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error {
|
||||
ackCount++
|
||||
return nil
|
||||
}}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, appender, updater, msg); err == nil {
|
||||
t.Fatal("processFastMessage() error = nil, want tdengine transient failure")
|
||||
}
|
||||
if updater.count != 1 || ackCount != 0 {
|
||||
t.Fatalf("updates=%d acks=%d, want 1/0", updater.count, ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="received",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_last_message_unix_seconds{status="received",subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_decoupled_updates_total{reason="tdengine_transient",status="ok"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("tdengine transient redis catchup metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"}`) {
|
||||
t.Fatalf("message should not be counted ok because it was not acked:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageRecordsStageDurationMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-3"}
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-3", ReceivedAtMS: time.Now().Add(-20 * time.Millisecond).UnixMilli()}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -77,6 +115,14 @@ func TestProcessFastMessageRecordsStageDurationMetrics(t *testing.T) {
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_count{stage="tdengine",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_last_stage_unix_seconds{stage="tdengine",status="ok",subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_last_stage_unix_seconds{stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_last_stage_unix_seconds{stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_redis_e2e_duration_ms_histogram_bucket{le="+Inf",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_e2e_duration_ms_histogram_count{subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_e2e_recent_p99_ms{subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_redis_e2e_recent_samples{subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_last_redis_e2e_unix_seconds{subject="vehicle.raw.go.jt808.v1"} `,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fast writer stage metric missing %s:\n%s", want, text)
|
||||
@@ -84,6 +130,151 @@ func TestProcessFastMessageRecordsStageDurationMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFastWriterRedisE2EDurationSkipsMissingReceiveTime(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
recordFastWriterRedisE2EDuration(registry, "vehicle.raw.go.jt808.v1", envelope.FrameEnvelope{})
|
||||
|
||||
if text := registry.Render(); strings.Contains(text, "vehicle_fast_writer_redis_e2e_duration_ms_histogram") || strings.Contains(text, "vehicle_fast_writer_redis_e2e_recent") {
|
||||
t.Fatalf("e2e metric should be skipped when received_at_ms is missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageAcksInvalidJSONAndRecordsMetric(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
ackCount := 0
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: []byte(`{bad json`), ack: func() error {
|
||||
ackCount++
|
||||
return nil
|
||||
}}
|
||||
updater := &recordingFastUpdater{}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, nil, updater, msg); err != nil {
|
||||
t.Fatalf("processFastMessage() error = %v", err)
|
||||
}
|
||||
if ackCount != 1 {
|
||||
t.Fatalf("ack count = %d, want 1", ackCount)
|
||||
}
|
||||
if updater.count != 0 || updater.batchCount != 0 {
|
||||
t.Fatalf("updater counts = single %d batch %d, want 0", updater.count, updater.batchCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="invalid_json",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("invalid json metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"}`) {
|
||||
t.Fatalf("invalid json should not be counted as ok:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageAcksExplicitNonRawEventKindWithoutWriting(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
EventKind: envelope.EventKindFields,
|
||||
MessageID: "0x0200",
|
||||
VIN: "VIN001",
|
||||
}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{}
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error {
|
||||
ackCount++
|
||||
return nil
|
||||
}}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, appender, updater, msg); err != nil {
|
||||
t.Fatalf("processFastMessage() error = %v", err)
|
||||
}
|
||||
if appender.count != 0 || updater.count != 0 || updater.batchCount != 0 {
|
||||
t.Fatalf("appends=%d updates=%d batch_updates=%d, want no writes", appender.count, updater.count, updater.batchCount)
|
||||
}
|
||||
if ackCount != 1 {
|
||||
t.Fatalf("ack count=%d, want 1", ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="event_kind_mismatch",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_count{stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("mismatch metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"}`) {
|
||||
t.Fatalf("mismatched event should not be counted ok:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageRecordsRedisFieldMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fields"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastResultUpdater{result: realtime.FastUpdateResult{
|
||||
EnvelopesSeen: 4,
|
||||
EnvelopesUpdated: 1,
|
||||
EnvelopesSkippedNonRealtime: 1,
|
||||
EnvelopesSkippedMissingVIN: 1,
|
||||
EnvelopesSkippedMissingVehicleKey: 0,
|
||||
EnvelopesSkippedMissingFields: 1,
|
||||
FieldsSeen: 3,
|
||||
FieldsWritten: 2,
|
||||
FieldsSkippedStale: 1,
|
||||
}}
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { return nil }}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, nil, updater, msg); err != nil {
|
||||
t.Fatalf("processFastMessage() error = %v", err)
|
||||
}
|
||||
if updater.resultCount != 1 {
|
||||
t.Fatalf("FastUpdateWithResult count=%d, want 1", updater.resultCount)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 4`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="updated",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="skipped_missing_vin",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="skipped_non_realtime",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="skipped_missing_fields",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 3`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="skipped_stale",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="written",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("redis field metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddFastMetricRecordsLastMessage(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
addFastMetric(registry, "vehicle.raw.go.gb32960.v1", "ok")
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
`vehicle_fast_writer_last_message_unix_seconds{status="ok",subject="vehicle.raw.go.gb32960.v1"} `,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fast writer message metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchAppendsTDengineBatchBeforeRedisAndAck(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-4"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-5"}
|
||||
@@ -149,6 +340,407 @@ func TestProcessFastBatchUsesRedisBatchUpdaterWhenAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchFallsBackToSinglesAfterNonTransientTDengineBatchFailure(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fallback-td-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-fallback-td-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{batchErr: errors.New("syntax error in batch insert")}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, appender, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if appender.batchCount != 1 || appender.count != 2 {
|
||||
t.Fatalf("batch appends=%d single appends=%d, want 1/2", appender.batchCount, appender.count)
|
||||
}
|
||||
if updater.count != 2 || ackCount != 2 {
|
||||
t.Fatalf("updates=%d acks=%d, want 2/2", updater.count, ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if !strings.Contains(text, `vehicle_fast_writer_batch_fallback_total{stage="tdengine",status="ok"} 1`) {
|
||||
t.Fatalf("tdengine fallback metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchUpdatesRedisButDoesNotAckOnTransientTDengineBatchFailure(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fallback-td-transient"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{batchErr: errors.New("i/o timeout")}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, appender, updater, msgs); err == nil {
|
||||
t.Fatal("processFastBatch() error = nil, want transient tdengine failure")
|
||||
}
|
||||
if appender.batchCount != 1 || appender.count != 0 {
|
||||
t.Fatalf("batch appends=%d single appends=%d, want 1/0", appender.batchCount, appender.count)
|
||||
}
|
||||
if updater.count != 1 || ackCount != 0 {
|
||||
t.Fatalf("updates=%d acks=%d, want 1/0", updater.count, ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="received",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_batch_fallback_total{stage="tdengine",status="skipped_transient"} 1`,
|
||||
`vehicle_fast_writer_decoupled_updates_total{reason="tdengine_transient",status="ok"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("tdengine transient catchup metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchFallsBackToRedisSinglesAfterNonTransientRedisBatchFailure(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fallback-redis-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-fallback-redis-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{}
|
||||
updater := &recordingFastUpdater{batchErr: errors.New("WRONGTYPE operation against a key holding the wrong kind of value")}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, appender, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if appender.batchCount != 1 || appender.count != 0 {
|
||||
t.Fatalf("tdengine batch appends=%d single appends=%d, want 1/0", appender.batchCount, appender.count)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.count != 2 {
|
||||
t.Fatalf("redis batch updates=%d single updates=%d, want 1/2", updater.batchCount, updater.count)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count=%d, want 2", ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if !strings.Contains(text, `vehicle_fast_writer_batch_fallback_total{stage="redis",status="ok"} 1`) {
|
||||
t.Fatalf("redis fallback metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchFallbackLeavesFailedAndRemainingMessagesUnacked(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fallback-prefix-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-fallback-prefix-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{
|
||||
batchErr: errors.New("syntax error in batch insert"),
|
||||
singleErr: errors.New("single insert failed"),
|
||||
failSingleOnCount: 2,
|
||||
}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, appender, updater, msgs); err == nil {
|
||||
t.Fatal("processFastBatch() error = nil, want fallback single write failure")
|
||||
}
|
||||
if appender.batchCount != 1 || appender.count != 2 {
|
||||
t.Fatalf("tdengine batch appends=%d single appends=%d, want 1/2", appender.batchCount, appender.count)
|
||||
}
|
||||
if updater.count != 1 || ackCount != 1 {
|
||||
t.Fatalf("updates=%d acks=%d, want successful prefix 1/1", updater.count, ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if !strings.Contains(text, `vehicle_fast_writer_batch_fallback_total{stage="tdengine",status="error"} 1`) {
|
||||
t.Fatalf("tdengine fallback error metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchRecordsRedisFieldMetrics(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-batch-fields-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-batch-fields-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastResultUpdater{result: realtime.FastUpdateResult{
|
||||
EnvelopesSeen: 2,
|
||||
EnvelopesUpdated: 2,
|
||||
FieldsSeen: 10,
|
||||
FieldsWritten: 7,
|
||||
FieldsSkippedStale: 3,
|
||||
}}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if updater.batchResultCount != 1 || updater.batchCount != 0 {
|
||||
t.Fatalf("batch result count=%d legacy batch count=%d, want 1/0", updater.batchResultCount, updater.batchCount)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count=%d, want 2", ackCount)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="updated",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 10`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="skipped_stale",subject="vehicle.raw.go.jt808.v1"} 3`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="written",subject="vehicle.raw.go.jt808.v1"} 7`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("redis field metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchRecordsRedisE2EByOriginalSubject(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
VIN: "VIN001",
|
||||
EventID: "evt-batch-e2e-1",
|
||||
ReceivedAtMS: time.Now().Add(-20 * time.Millisecond).UnixMilli(),
|
||||
}
|
||||
second := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
VIN: "VIN002",
|
||||
EventID: "evt-batch-e2e-2",
|
||||
ReceivedAtMS: time.Now().Add(-25 * time.Millisecond).UnixMilli(),
|
||||
}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastResultUpdater{result: realtime.FastUpdateResult{
|
||||
EnvelopesSeen: 2,
|
||||
EnvelopesUpdated: 2,
|
||||
}}
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload},
|
||||
{subject: "vehicle.raw.go.gb32960.v1", data: secondPayload},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_redis_e2e_duration_ms_histogram_count{subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_e2e_duration_ms_histogram_count{subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_e2e_recent_p99_ms{subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_redis_e2e_recent_p99_ms{subject="vehicle.raw.go.gb32960.v1"} `,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("redis e2e metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_fast_writer_redis_e2e_duration_ms_histogram_count{subject="mixed"}`) {
|
||||
t.Fatalf("redis e2e metric should keep original subjects, not mixed:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchRecordsRedisFieldMetricsByOriginalSubjectForMixedBatch(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-batch-mixed-fields-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN002", EventID: "evt-batch-mixed-fields-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastDynamicResultUpdater{}
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload},
|
||||
{subject: "vehicle.raw.go.gb32960.v1", data: secondPayload},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if updater.batchResultCount != 2 {
|
||||
t.Fatalf("batch result count=%d, want one batch per subject", updater.batchResultCount)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="seen",subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="seen",subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="written",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="written",subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("redis field metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `subject="mixed"`) {
|
||||
t.Fatalf("mixed subject should not be used for per-subject Redis metrics:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchAcksInvalidJSONWithoutMarkingItOK(t *testing.T) {
|
||||
valid := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-valid"}
|
||||
validPayload, err := valid.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: []byte(`{bad json`), ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: validPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count = %d, want 2", ackCount)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.batchRows != 1 {
|
||||
t.Fatalf("redis batch count=%d rows=%d, want one valid row", updater.batchCount, updater.batchRows)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="received",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
`vehicle_fast_writer_messages_total{status="invalid_json",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_batch_pending_messages 0`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("batch invalid json metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchSkipsExplicitNonRawEventKindAndWritesValidRows(t *testing.T) {
|
||||
mismatched := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, EventKind: envelope.EventKindFields, VIN: "VIN_BAD", EventID: "evt-fields-on-raw"}
|
||||
valid := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, EventKind: envelope.EventKindRaw, VIN: "VIN001", EventID: "evt-valid-raw"}
|
||||
mismatchedPayload, err := mismatched.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validPayload, err := valid.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: mismatchedPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: validPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count = %d, want 2", ackCount)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.batchRows != 1 {
|
||||
t.Fatalf("redis batch count=%d rows=%d, want only valid row", updater.batchCount, updater.batchRows)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="received",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
`vehicle_fast_writer_messages_total{status="event_kind_mismatch",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_batch_pending_messages 0`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("batch mismatch metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchCanSkipTDengineAppender(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-no-td"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.batchRows != 1 {
|
||||
t.Fatalf("redis batch count=%d rows=%d, want 1/1", updater.batchCount, updater.batchRows)
|
||||
}
|
||||
if ackCount != 1 {
|
||||
t.Fatalf("ack count=%d, want 1", ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if strings.Contains(text, `stage="tdengine"`) {
|
||||
t.Fatalf("tdengine stage should not be recorded when appender is nil:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, `stage="redis"`) || !strings.Contains(text, `stage="ack"`) {
|
||||
t.Fatalf("redis and ack stages should still be recorded:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchExposesPendingMetricsDuringAppend(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-6"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-7"}
|
||||
@@ -208,6 +800,77 @@ func TestProcessFastBatchExposesPendingMetricsDuringAppend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchPendingAggregatesConcurrentWorkers(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fast-pending-concurrent-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-fast-pending-concurrent-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
firstStarted := make(chan struct{})
|
||||
secondStarted := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
appenderFor := func(started chan struct{}) *recordingFastAppender {
|
||||
return &recordingFastAppender{
|
||||
onAppendBatch: func() {
|
||||
close(started)
|
||||
<-release
|
||||
},
|
||||
}
|
||||
}
|
||||
messages := func(payload []byte, count int) []*fastMessage {
|
||||
out := make([]*fastMessage, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
out = append(out, &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { return nil }})
|
||||
}
|
||||
return out
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var firstErr error
|
||||
var secondErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
firstErr = processFastBatch(context.Background(), registry, appenderFor(firstStarted), &recordingFastUpdater{}, messages(firstPayload, 2))
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
secondErr = processFastBatch(context.Background(), registry, appenderFor(secondStarted), &recordingFastUpdater{}, messages(secondPayload, 3))
|
||||
}()
|
||||
<-firstStarted
|
||||
<-secondStarted
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_batch_pending_messages 5`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 5`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("aggregate pending metric missing %s during concurrent append:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if firstErr != nil || secondErr != nil {
|
||||
t.Fatalf("processFastBatch errors = %v / %v", firstErr, secondErr)
|
||||
}
|
||||
text = registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_batch_pending_messages 0`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("aggregate pending metric should reset after concurrent append, missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFastNATSConsumerInfoMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
cfg := config{NATSStream: "VEHICLE_INGEST", NATSDurable: "vehicle-fast-writer"}
|
||||
@@ -230,6 +893,41 @@ func TestRecordFastNATSConsumerInfoMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFastWorkerShutdownFetchError(t *testing.T) {
|
||||
if isFastWorkerShutdownFetchError(context.Background(), errors.New("temporary nats failure")) {
|
||||
t.Fatal("temporary failure should not be treated as shutdown")
|
||||
}
|
||||
if !isFastWorkerShutdownFetchError(context.Background(), nats.ErrConnectionClosed) {
|
||||
t.Fatal("connection closed should stop worker without noisy error log")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if !isFastWorkerShutdownFetchError(ctx, errors.New("any fetch error after cancellation")) {
|
||||
t.Fatal("cancelled context should stop worker without noisy error log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransientFastFetchError(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("write tcp: broken pipe"),
|
||||
errors.New("temporary network unavailable"),
|
||||
errors.New("i/o timeout"),
|
||||
} {
|
||||
if !isTransientFastFetchError(err) {
|
||||
t.Fatalf("isTransientFastFetchError(%q) = false, want true", err.Error())
|
||||
}
|
||||
}
|
||||
if isTransientFastFetchError(errors.New("permission denied")) {
|
||||
t.Fatal("permission denied should remain an unexpected fetch error")
|
||||
}
|
||||
if isTransientFastFetchError(nil) {
|
||||
t.Fatal("nil should not be treated as transient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsTDenginePoolToSingleConnection(t *testing.T) {
|
||||
t.Setenv("FAST_WRITER_WORKERS", "12")
|
||||
t.Setenv("FAST_WRITER_TDENGINE_MAX_OPEN_CONNS", "")
|
||||
@@ -244,6 +942,28 @@ func TestLoadConfigDefaultsTDenginePoolToSingleConnection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsTDengineStageDisabled(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
if cfg.TDengineEnabled {
|
||||
t.Fatal("TDengineEnabled = true, want production default false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigCanEnableTDengineStageExplicitly(t *testing.T) {
|
||||
for _, value := range []string{"true", "1", "yes", "on"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("FAST_WRITER_TDENGINE_ENABLED", value)
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
if !cfg.TDengineEnabled {
|
||||
t.Fatalf("TDengineEnabled = false for %q, want explicit true", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -256,12 +976,16 @@ func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
||||
if got, want := cfg.OperationWait, time.Second; got != want {
|
||||
t.Fatalf("OperationWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.FetchWait, 20*time.Millisecond; got != want {
|
||||
t.Fatalf("FetchWait = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
||||
t.Setenv("NATS_STREAM_MAX_BYTES", "1073741824")
|
||||
t.Setenv("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", "90")
|
||||
t.Setenv("FAST_WRITER_OPERATION_TIMEOUT_MS", "1500")
|
||||
t.Setenv("FAST_WRITER_FETCH_WAIT_MS", "45")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -274,6 +998,32 @@ func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
||||
if got, want := cfg.OperationWait, 1500*time.Millisecond; got != want {
|
||||
t.Fatalf("OperationWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.FetchWait, 45*time.Millisecond; got != want {
|
||||
t.Fatalf("FetchWait = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFastWriterConfigMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
recordFastWriterConfigMetrics(registry, config{
|
||||
BatchSize: 120,
|
||||
FetchWait: 20 * time.Millisecond,
|
||||
OperationWait: 1500 * time.Millisecond,
|
||||
Workers: 8,
|
||||
})
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_config{setting="batch_size"} 120`,
|
||||
`vehicle_fast_writer_config{setting="fetch_wait_ms"} 20`,
|
||||
`vehicle_fast_writer_config{setting="operation_timeout_ms"} 1500`,
|
||||
`vehicle_fast_writer_config{setting="workers"} 8`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fast writer config metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsTDenginePoolOverride(t *testing.T) {
|
||||
@@ -292,15 +1042,30 @@ func TestLoadConfigReadsTDenginePoolOverride(t *testing.T) {
|
||||
}
|
||||
|
||||
type recordingFastAppender struct {
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
onAppendBatch func()
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
batchErr error
|
||||
singleErr error
|
||||
failSingleOnCount int
|
||||
onAppendBatch func()
|
||||
}
|
||||
|
||||
func (a *recordingFastAppender) AppendAll(context.Context, envelope.FrameEnvelope) error {
|
||||
a.count++
|
||||
if a.failSingleOnCount > 0 && a.count == a.failSingleOnCount {
|
||||
if a.singleErr != nil {
|
||||
return a.singleErr
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
if a.failSingleOnCount > 0 {
|
||||
return nil
|
||||
}
|
||||
if a.singleErr != nil {
|
||||
return a.singleErr
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
|
||||
@@ -310,24 +1075,45 @@ func (a *recordingFastAppender) AppendAllBatch(_ context.Context, envs []envelop
|
||||
if a.onAppendBatch != nil {
|
||||
a.onAppendBatch()
|
||||
}
|
||||
if a.batchErr != nil {
|
||||
return a.batchErr
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
|
||||
type recordingFastUpdater struct {
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
batchErr error
|
||||
singleErr error
|
||||
failSingleOnCount int
|
||||
}
|
||||
|
||||
func (u *recordingFastUpdater) FastUpdate(context.Context, envelope.FrameEnvelope) error {
|
||||
u.count++
|
||||
if u.failSingleOnCount > 0 && u.count == u.failSingleOnCount {
|
||||
if u.singleErr != nil {
|
||||
return u.singleErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
if u.failSingleOnCount > 0 {
|
||||
return nil
|
||||
}
|
||||
if u.singleErr != nil {
|
||||
return u.singleErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastUpdater) FastUpdateBatch(_ context.Context, envs []envelope.FrameEnvelope) error {
|
||||
u.batchCount++
|
||||
u.batchRows += len(envs)
|
||||
if u.batchErr != nil {
|
||||
return u.batchErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
@@ -340,3 +1126,73 @@ func (u *recordingFastSingleUpdater) FastUpdate(context.Context, envelope.FrameE
|
||||
u.count++
|
||||
return u.err
|
||||
}
|
||||
|
||||
type recordingFastResultUpdater struct {
|
||||
count int
|
||||
resultCount int
|
||||
batchCount int
|
||||
batchResultCount int
|
||||
batchRows int
|
||||
result realtime.FastUpdateResult
|
||||
err error
|
||||
batchErr error
|
||||
singleErr error
|
||||
}
|
||||
|
||||
func (u *recordingFastResultUpdater) FastUpdate(context.Context, envelope.FrameEnvelope) error {
|
||||
u.count++
|
||||
if u.singleErr != nil {
|
||||
return u.singleErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastResultUpdater) FastUpdateWithResult(context.Context, envelope.FrameEnvelope) (realtime.FastUpdateResult, error) {
|
||||
u.resultCount++
|
||||
if u.singleErr != nil {
|
||||
return u.result, u.singleErr
|
||||
}
|
||||
return u.result, u.err
|
||||
}
|
||||
|
||||
type recordingFastDynamicResultUpdater struct {
|
||||
batchResultCount int
|
||||
batchRows int
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *recordingFastDynamicResultUpdater) FastUpdate(context.Context, envelope.FrameEnvelope) error {
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastDynamicResultUpdater) FastUpdateBatchWithResult(_ context.Context, envs []envelope.FrameEnvelope) (realtime.FastUpdateResult, error) {
|
||||
u.batchResultCount++
|
||||
u.batchRows += len(envs)
|
||||
if u.err != nil {
|
||||
return realtime.FastUpdateResult{}, u.err
|
||||
}
|
||||
return realtime.FastUpdateResult{
|
||||
EnvelopesSeen: len(envs),
|
||||
EnvelopesUpdated: len(envs),
|
||||
FieldsSeen: len(envs),
|
||||
FieldsWritten: len(envs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *recordingFastResultUpdater) FastUpdateBatch(_ context.Context, envs []envelope.FrameEnvelope) error {
|
||||
u.batchCount++
|
||||
u.batchRows += len(envs)
|
||||
if u.batchErr != nil {
|
||||
return u.batchErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastResultUpdater) FastUpdateBatchWithResult(_ context.Context, envs []envelope.FrameEnvelope) (realtime.FastUpdateResult, error) {
|
||||
u.batchResultCount++
|
||||
u.batchRows += len(envs)
|
||||
if u.batchErr != nil {
|
||||
return u.result, u.batchErr
|
||||
}
|
||||
return u.result, u.err
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +19,7 @@ import (
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"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/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
@@ -27,6 +33,10 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Error("invalid stat writer config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := sql.Open("mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
logger.Error("mysql open failed", "error", err)
|
||||
@@ -38,18 +48,64 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
metrics.RegisterKafkaConsumerInfo(registry, "vehicle-stat-writer", cfg.KafkaGroup, cfg.KafkaTopics)
|
||||
registry.SetGauge("vehicle_stat_project_interval_seconds", nil, cfg.ProjectInterval.Seconds())
|
||||
registry.SetGauge("vehicle_stat_source_touch_interval_seconds", nil, cfg.SourceTouchInterval.Seconds())
|
||||
registry.SetGauge("vehicle_stat_cache_retention_seconds", nil, cfg.CacheRetention.Seconds())
|
||||
registry.SetGauge("vehicle_stat_cache_cleanup_interval_seconds", nil, cfg.CacheCleanupInterval.Seconds())
|
||||
registry.SetGauge("vehicle_stat_baseline_miss_ttl_seconds", nil, cfg.BaselineMissTTL.Seconds())
|
||||
registry.SetGauge("vehicle_stat_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{
|
||||
{Name: "mysql", Check: db.PingContext},
|
||||
}, registry))
|
||||
|
||||
writer := stats.NewWriter(db, cfg.Location)
|
||||
writer.SetProjectionInterval(cfg.ProjectInterval)
|
||||
writer.SetSourceTouchInterval(cfg.SourceTouchInterval)
|
||||
writer.SetCacheRetention(cfg.CacheRetention)
|
||||
writer.SetCacheCleanupInterval(cfg.CacheCleanupInterval)
|
||||
writer.SetBaselineMissTTL(cfg.BaselineMissTTL)
|
||||
writer.SetMaxCacheEntries(cfg.CacheMaxEntries)
|
||||
if cfg.EnsureSchema {
|
||||
if err := writer.EnsureSchema(ctx); err != nil {
|
||||
logger.Error("mysql schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if cfg.NormalizePlatformSourcesOnStart {
|
||||
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
|
||||
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
|
||||
normalized, err := stats.NormalizePlatformSourceMileageForDate(normalizeCtx, db, statDate, envelope.ProtocolJT808)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("platform source startup normalization failed", "stat_date", statDate, "protocol", envelope.ProtocolJT808, "normalized", normalized, "error", err)
|
||||
} else if normalized > 0 {
|
||||
logger.Info("platform source startup normalization finished", "stat_date", statDate, "protocol", envelope.ProtocolJT808, "normalized", normalized)
|
||||
}
|
||||
}
|
||||
var appender statAppender = retryStatAppender{
|
||||
delegate: writer,
|
||||
attempts: cfg.RetryAttempts,
|
||||
delay: cfg.RetryDelay,
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "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()
|
||||
runStatConsumer(ctx, logger, registry, appender, cfg, id)
|
||||
}(workerID)
|
||||
}
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func runStatConsumer(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, cfg config, workerID int) {
|
||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: cfg.KafkaBrokers,
|
||||
GroupID: cfg.KafkaGroup,
|
||||
@@ -59,7 +115,10 @@ func main() {
|
||||
})
|
||||
defer reader.Close()
|
||||
|
||||
logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","))
|
||||
workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)}
|
||||
registry.SetGauge("vehicle_stat_worker_active", workerLabels, 1)
|
||||
defer registry.SetGauge("vehicle_stat_worker_active", workerLabels, 0)
|
||||
|
||||
for {
|
||||
message, err := reader.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
@@ -69,7 +128,8 @@ func main() {
|
||||
logger.Error("kafka fetch failed", "error", err)
|
||||
continue
|
||||
}
|
||||
processStatMessage(ctx, logger, registry, writer, reader, message)
|
||||
batch := collectStatBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond)
|
||||
processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,50 +139,488 @@ type statAppender interface {
|
||||
Append(context.Context, envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type statResultAppender interface {
|
||||
AppendWithResult(context.Context, envelope.FrameEnvelope) (stats.AppendResult, error)
|
||||
}
|
||||
|
||||
type statCacheReporter interface {
|
||||
CacheStats() stats.CacheStats
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
type kafkaMessageFetcher interface {
|
||||
FetchMessage(context.Context) (kafka.Message, error)
|
||||
}
|
||||
|
||||
type statBatchItem struct {
|
||||
message kafka.Message
|
||||
processed bool
|
||||
}
|
||||
|
||||
type statBatchOutcome struct {
|
||||
commitMessages []kafka.Message
|
||||
retryMessages []kafka.Message
|
||||
}
|
||||
|
||||
var statWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
var statWriteE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
|
||||
var statWriteE2ERecent = metrics.NewRecentLatencyByKey(512)
|
||||
|
||||
func collectStatBatch(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 processStatMessage(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, message kafka.Message) {
|
||||
processStatBatch(ctx, logger, registry, appender, committer, []kafka.Message{message})
|
||||
}
|
||||
|
||||
func processStatBatch(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message) statBatchOutcome {
|
||||
return processStatBatchForWorker(ctx, logger, registry, appender, committer, messages, nil)
|
||||
}
|
||||
|
||||
func processStatBatchForWorker(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, workerLabels metrics.Labels) statBatchOutcome {
|
||||
if len(messages) == 0 {
|
||||
return statBatchOutcome{}
|
||||
}
|
||||
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "received")
|
||||
addStatLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
_ = committer.CommitMessages(messageCtx, message)
|
||||
setStatBatchPendingForWorker(registry, workerLabels, len(messages))
|
||||
defer setStatBatchPendingForWorker(registry, workerLabels, 0)
|
||||
defer recordStatCacheMetrics(registry, appender)
|
||||
items := make([]statBatchItem, len(messages))
|
||||
for index, message := range messages {
|
||||
items[index].message = message
|
||||
}
|
||||
for itemIndex, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "received")
|
||||
addStatLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
items[itemIndex].processed = true
|
||||
continue
|
||||
}
|
||||
if status, err := validateStatFieldsEnvelope(message.Topic, env); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, status)
|
||||
logger.Warn("skip mismatched stat fields envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "event_kind", env.EventKind, "error", err)
|
||||
items[itemIndex].processed = true
|
||||
continue
|
||||
}
|
||||
started := time.Now()
|
||||
result, err := appendStatEnvelope(messageCtx, appender, env)
|
||||
recordStatWriteDuration(registry, message, statusFromError(err), time.Since(started))
|
||||
recordStatSampleMetrics(registry, message, env.Protocol, result)
|
||||
recordStatSourceMetrics(registry, message, env.Protocol, result)
|
||||
recordStatProjectionMetrics(registry, message, env.Protocol, result)
|
||||
if err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "error")
|
||||
logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
committed, commitErr := commitStatProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
|
||||
outcome := statBatchOutcome{retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed)}
|
||||
if commitErr != nil {
|
||||
outcome.commitMessages = committed
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "ok")
|
||||
recordStatWriteE2EDuration(registry, message, env)
|
||||
items[itemIndex].processed = true
|
||||
}
|
||||
if err := committer.CommitMessages(messageCtx, messages...); err != nil {
|
||||
for _, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("kafka commit failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
|
||||
return statBatchOutcome{commitMessages: messages}
|
||||
}
|
||||
for _, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return statBatchOutcome{}
|
||||
}
|
||||
|
||||
func processStatBatchReliably(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) {
|
||||
processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, messages, retryDelay, nil)
|
||||
}
|
||||
|
||||
func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) {
|
||||
defer registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, 0)
|
||||
pending := messages
|
||||
for len(pending) > 0 {
|
||||
outcome := processStatBatchForWorker(ctx, logger, registry, appender, committer, pending, workerLabels)
|
||||
if len(outcome.commitMessages) > 0 {
|
||||
registry.IncCounter("vehicle_stat_batch_retries_total", metrics.Labels{"reason": "commit_error"})
|
||||
if !retryStatCommit(ctx, logger, registry, committer, outcome.commitMessages, retryDelay) {
|
||||
return
|
||||
}
|
||||
}
|
||||
pending = outcome.retryMessages
|
||||
registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, float64(len(pending)))
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_stat_batch_retries_total", metrics.Labels{"reason": "write_error"})
|
||||
if !waitForStatRetry(ctx, retryDelay) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retryStatCommit(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 {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_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_stat_batch_retries_total", metrics.Labels{"reason": "commit_error"})
|
||||
if !waitForStatRetry(ctx, retryDelay) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func waitForStatRetry(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 validateStatFieldsEnvelope(topic string, env envelope.FrameEnvelope) (string, error) {
|
||||
return topics.ValidateFieldsEnvelope(topic, env)
|
||||
}
|
||||
|
||||
func commitStatProcessedPrefixAfterFailure(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, committer kafkaMessageCommitter, items []statBatchItem) ([]kafka.Message, error) {
|
||||
committable := processedPrefixMessagesByPartition(items)
|
||||
if len(committable) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := committer.CommitMessages(ctx, committable...); err != nil {
|
||||
for _, message := range committable {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
|
||||
}
|
||||
first := committable[0]
|
||||
logger.Error("kafka processed-prefix commit failed after mysql append failure", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(committable), "error", err)
|
||||
return committable, err
|
||||
}
|
||||
for _, message := range committable {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return committable, nil
|
||||
}
|
||||
|
||||
func processedPrefixMessagesByPartition(items []statBatchItem) []kafka.Message {
|
||||
type partitionKey struct {
|
||||
topic string
|
||||
partition int
|
||||
}
|
||||
groups := map[partitionKey][]statBatchItem{}
|
||||
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 appendStatEnvelope(ctx context.Context, appender statAppender, env envelope.FrameEnvelope) (stats.AppendResult, error) {
|
||||
if resultAppender, ok := appender.(statResultAppender); ok {
|
||||
return resultAppender.AppendWithResult(ctx, env)
|
||||
}
|
||||
return stats.AppendResult{}, appender.Append(ctx, env)
|
||||
}
|
||||
|
||||
type retryStatAppender struct {
|
||||
delegate statAppender
|
||||
attempts int
|
||||
delay time.Duration
|
||||
registry *metrics.Registry
|
||||
}
|
||||
|
||||
func (a retryStatAppender) Append(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
_, err := a.AppendWithResult(ctx, env)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a retryStatAppender) AppendWithResult(ctx context.Context, env envelope.FrameEnvelope) (stats.AppendResult, error) {
|
||||
if a.delegate == nil {
|
||||
return stats.AppendResult{}, nil
|
||||
}
|
||||
attempts := a.attempts
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
var result stats.AppendResult
|
||||
var err error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
result, err = appendStatEnvelope(ctx, a.delegate, env)
|
||||
if err == nil || !isTransientMySQLStatError(err) {
|
||||
return result, err
|
||||
}
|
||||
if attempt == attempts {
|
||||
a.recordRetry("single", "exhausted")
|
||||
return result, err
|
||||
}
|
||||
a.recordRetry("single", "retry")
|
||||
if a.delay <= 0 {
|
||||
continue
|
||||
}
|
||||
timer := time.NewTimer(a.delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return result, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (a retryStatAppender) recordRetry(operation string, status string) {
|
||||
if a.registry == nil {
|
||||
return
|
||||
}
|
||||
started := time.Now()
|
||||
err := appender.Append(messageCtx, env)
|
||||
recordStatWriteDuration(registry, message, statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "error")
|
||||
logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
labels := metrics.Labels{"operation": operation, "status": status}
|
||||
a.registry.IncCounter("vehicle_stat_write_retries_total", labels)
|
||||
metrics.RecordLastActivity(a.registry, "vehicle_stat_last_write_retry_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func (a retryStatAppender) CacheStats() stats.CacheStats {
|
||||
if reporter, ok := a.delegate.(statCacheReporter); ok {
|
||||
return reporter.CacheStats()
|
||||
}
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "ok")
|
||||
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
|
||||
logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
return
|
||||
return stats.CacheStats{}
|
||||
}
|
||||
|
||||
func isTransientMySQLStatError(err error) bool {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(text, "deadlock") ||
|
||||
strings.Contains(text, "error 1213") ||
|
||||
strings.Contains(text, "40001") ||
|
||||
strings.Contains(text, "lock wait timeout") ||
|
||||
strings.Contains(text, "error 1205") ||
|
||||
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, "invalid 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 addStatMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"topic": message.Topic, "status": status})
|
||||
labels := metrics.Labels{"topic": message.Topic, "status": status}
|
||||
registry.IncCounter(name, labels)
|
||||
switch name {
|
||||
case "vehicle_stat_kafka_messages_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_last_message_unix_seconds", labels)
|
||||
case "vehicle_stat_writes_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_last_write_unix_seconds", labels)
|
||||
case "vehicle_stat_kafka_commits_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_last_commit_unix_seconds", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func recordStatSampleMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
|
||||
addStatSampleMetric(registry, message, protocol, "found", result.SamplesFound)
|
||||
addStatSampleMetric(registry, message, protocol, "written", result.SamplesWritten)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_fields", result.SamplesSkippedMissingFields)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_vin", result.SamplesSkippedMissingVIN)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_mileage", result.SamplesSkippedMissingMileage)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_non_mileage_frame", result.SamplesSkippedNonMileageFrame)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_non_positive_mileage", result.SamplesSkippedNonPositiveMileage)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_time", result.SamplesSkippedMissingTime)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_same_mileage", result.SamplesSkippedSameMileage)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_source", result.SamplesSkippedMissingSource)
|
||||
addStatSampleMetric(registry, message, protocol, "event_time_future_adjusted", result.SamplesAdjustedFutureEventTime)
|
||||
}
|
||||
|
||||
func addStatSampleMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
|
||||
if registry == nil || count == 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_stat_samples_total", metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"protocol": statProtocolLabel(protocol),
|
||||
"status": status,
|
||||
}, float64(count))
|
||||
}
|
||||
|
||||
func recordStatSourceMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
|
||||
addStatSourceMetric(registry, message, protocol, "attempted", result.SourceTouchesAttempted)
|
||||
addStatSourceMetric(registry, message, protocol, "written", result.SourceTouchesWritten)
|
||||
addStatSourceMetric(registry, message, protocol, "skipped_throttled", result.SourceTouchesSkippedThrottled)
|
||||
addStatSourceMetric(registry, message, protocol, "skipped_missing_endpoint", result.SourceTouchesSkippedMissing)
|
||||
addStatSourceMetric(registry, message, protocol, "skipped_unmanaged", result.SourceTouchesSkippedUnmanaged)
|
||||
}
|
||||
|
||||
func addStatSourceMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
|
||||
if registry == nil || count == 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_stat_sources_total", metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"protocol": statProtocolLabel(protocol),
|
||||
"status": status,
|
||||
}, float64(count))
|
||||
}
|
||||
|
||||
func recordStatProjectionMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
|
||||
addStatProjectionMetric(registry, message, protocol, "attempted", result.ProjectionsAttempted)
|
||||
addStatProjectionMetric(registry, message, protocol, "written", result.ProjectionsWritten)
|
||||
addStatProjectionMetric(registry, message, protocol, "skipped_throttled", result.ProjectionsSkippedThrottled)
|
||||
}
|
||||
|
||||
func addStatProjectionMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
|
||||
if registry == nil || count == 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_stat_projections_total", metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"protocol": statProtocolLabel(protocol),
|
||||
"status": status,
|
||||
}, float64(count))
|
||||
}
|
||||
|
||||
func statProtocolLabel(protocol envelope.Protocol) string {
|
||||
protocolLabel := strings.TrimSpace(string(protocol))
|
||||
if protocolLabel == "" {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return protocolLabel
|
||||
}
|
||||
|
||||
func recordStatCacheMetrics(registry *metrics.Registry, appender statAppender) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
reporter, ok := appender.(statCacheReporter)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
stats := reporter.CacheStats()
|
||||
setStatCacheGauge(registry, "last_total_mileage", stats.LastTotalMileageEntries, stats.MaxEntries, stats.LastCleanupTotalMileage, stats.TotalMileageEvictions)
|
||||
setStatCacheGauge(registry, "source_seen", stats.LastSourceSeenEntries, stats.MaxEntries, stats.LastCleanupSourceSeen, stats.SourceSeenEvictions)
|
||||
setStatCacheGauge(registry, "projection", stats.LastProjectionEntries, stats.MaxEntries, stats.LastCleanupProjection, stats.ProjectionEvictions)
|
||||
setStatCacheGauge(registry, "baseline", stats.BaselineEntries, stats.MaxEntries, stats.LastCleanupBaseline, stats.BaselineEvictions)
|
||||
if !stats.LastCleanupAt.IsZero() {
|
||||
registry.SetGauge("vehicle_stat_cache_last_cleanup_unix_seconds", nil, float64(stats.LastCleanupAt.Unix()))
|
||||
}
|
||||
}
|
||||
|
||||
func setStatCacheGauge(registry *metrics.Registry, cache string, entries int, maxEntries int, lastCleanupDeleted int, evictions int) {
|
||||
labels := metrics.Labels{"cache": cache}
|
||||
registry.SetGauge("vehicle_stat_cache_entries", labels, float64(entries))
|
||||
registry.SetGauge("vehicle_stat_cache_max_entries", labels, float64(maxEntries))
|
||||
registry.SetGauge("vehicle_stat_cache_last_cleanup_deleted", labels, float64(lastCleanupDeleted))
|
||||
registry.SetGauge("vehicle_stat_cache_evictions_total", labels, float64(evictions))
|
||||
}
|
||||
|
||||
func addStatLagMetric(registry *metrics.Registry, message kafka.Message) {
|
||||
@@ -142,6 +640,33 @@ func recordStatWriteDuration(registry *metrics.Registry, message kafka.Message,
|
||||
}, statWriteDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func recordStatWriteE2EDuration(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_stat_write_e2e_duration_ms_histogram", labels, statWriteE2EDurationBucketsMS, float64(elapsed))
|
||||
p99, samples := statWriteE2ERecent.Observe(message.Topic, float64(elapsed))
|
||||
registry.SetGauge("vehicle_stat_write_e2e_recent_p99_ms", labels, p99)
|
||||
registry.SetGauge("vehicle_stat_write_e2e_recent_samples", labels, float64(samples))
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_last_write_e2e_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func setStatBatchPending(registry *metrics.Registry, messages int) {
|
||||
setStatBatchPendingForWorker(registry, nil, messages)
|
||||
}
|
||||
|
||||
func setStatBatchPendingForWorker(registry *metrics.Registry, workerLabels metrics.Labels, messages int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_stat_batch_pending_messages", workerLabels, float64(messages))
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
if err != nil {
|
||||
return "error"
|
||||
@@ -150,12 +675,40 @@ func statusFromError(err error) string {
|
||||
}
|
||||
|
||||
type config struct {
|
||||
KafkaBrokers []string
|
||||
KafkaTopics []string
|
||||
KafkaGroup string
|
||||
MySQLDSN string
|
||||
EnsureSchema bool
|
||||
Location *time.Location
|
||||
KafkaBrokers []string
|
||||
KafkaTopics []string
|
||||
KafkaGroup string
|
||||
MySQLDSN string
|
||||
EnsureSchema bool
|
||||
Location *time.Location
|
||||
ProjectInterval time.Duration
|
||||
SourceTouchInterval time.Duration
|
||||
CacheRetention time.Duration
|
||||
CacheCleanupInterval time.Duration
|
||||
BaselineMissTTL time.Duration
|
||||
CacheMaxEntries int
|
||||
Workers int
|
||||
BatchSize int
|
||||
BatchWait int
|
||||
RetryAttempts int
|
||||
RetryDelay time.Duration
|
||||
NormalizePlatformSourcesOnStart bool
|
||||
NormalizePlatformSourcesTimeout time.Duration
|
||||
}
|
||||
|
||||
func (c config) Validate() error {
|
||||
if len(c.KafkaTopics) == 0 {
|
||||
return fmt.Errorf("KAFKA_TOPICS must include fields topics")
|
||||
}
|
||||
for _, topic := range c.KafkaTopics {
|
||||
if !strings.HasPrefix(topic, "vehicle.fields.") {
|
||||
return fmt.Errorf("stat-writer consumes fields topics only, got %q", topic)
|
||||
}
|
||||
}
|
||||
if c.Workers <= 0 {
|
||||
return fmt.Errorf("STATS_WORKERS must be greater than zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -164,12 +717,25 @@ func loadConfig() config {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
return config{
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
KafkaTopics: splitCSV(env("KAFKA_TOPICS", strings.Join([]string{topics.FieldsGB32960, topics.FieldsJT808, topics.FieldsYutongMQTT}, ","))),
|
||||
KafkaGroup: env("KAFKA_GROUP", "go-stat-writer"),
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
EnsureSchema: env("MYSQL_ENSURE_SCHEMA", "true") != "false",
|
||||
Location: loc,
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
KafkaTopics: splitCSV(env("KAFKA_TOPICS", strings.Join([]string{topics.FieldsGB32960, topics.FieldsJT808, topics.FieldsYutongMQTT}, ","))),
|
||||
KafkaGroup: env("KAFKA_GROUP", "go-stat-writer"),
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
EnsureSchema: env("MYSQL_ENSURE_SCHEMA", "true") != "false",
|
||||
Location: loc,
|
||||
ProjectInterval: time.Duration(envInt("STATS_PROJECT_INTERVAL_SECONDS", 15)) * time.Second,
|
||||
SourceTouchInterval: time.Duration(envInt("STATS_SOURCE_TOUCH_INTERVAL_SECONDS", 60)) * time.Second,
|
||||
CacheRetention: time.Duration(envInt("STATS_CACHE_RETENTION_HOURS", 72)) * time.Hour,
|
||||
CacheCleanupInterval: time.Duration(envInt("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
BaselineMissTTL: time.Duration(envInt("STATS_BASELINE_MISS_TTL_SECONDS", 60)) * time.Second,
|
||||
CacheMaxEntries: envInt("STATS_CACHE_MAX_ENTRIES", 1000000),
|
||||
Workers: envInt("STATS_WORKERS", 3),
|
||||
BatchSize: envInt("STATS_BATCH_SIZE", 200),
|
||||
BatchWait: envInt("STATS_BATCH_WAIT_MS", 20),
|
||||
RetryAttempts: envInt("STATS_RETRY_ATTEMPTS", 3),
|
||||
RetryDelay: time.Duration(envInt("STATS_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
||||
NormalizePlatformSourcesOnStart: env("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "true") != "false",
|
||||
NormalizePlatformSourcesTimeout: time.Duration(envInt("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", 30)) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +747,18 @@ func env(key string, fallback string) string {
|
||||
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, ",") {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,20 +22,21 @@ import (
|
||||
)
|
||||
|
||||
type config struct {
|
||||
MySQLDSN string
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
DateFrom string
|
||||
DateTo string
|
||||
Protocols []envelope.Protocol
|
||||
Method string
|
||||
Limit int
|
||||
DryRun bool
|
||||
Reset bool
|
||||
Debug bool
|
||||
ProgressEvery int64
|
||||
Location *time.Location
|
||||
MySQLDSN string
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
DateFrom string
|
||||
DateTo string
|
||||
Protocols []envelope.Protocol
|
||||
Method string
|
||||
Limit int
|
||||
DryRun bool
|
||||
Reset bool
|
||||
Debug bool
|
||||
EventTimeFullScan bool
|
||||
ProgressEvery int64
|
||||
Location *time.Location
|
||||
}
|
||||
|
||||
type rawFrameRow struct {
|
||||
@@ -62,6 +63,9 @@ type metricAgg struct {
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
SourceCode string
|
||||
PlatformName string
|
||||
SourceKind string
|
||||
FirstEventTime time.Time
|
||||
LatestEventTime time.Time
|
||||
QualityStatus string
|
||||
@@ -74,6 +78,9 @@ type dailySourceLast struct {
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
SourceCode string
|
||||
PlatformName string
|
||||
SourceKind string
|
||||
FirstTS time.Time
|
||||
TS time.Time
|
||||
FirstTotalKM float64
|
||||
@@ -81,8 +88,19 @@ type dailySourceLast struct {
|
||||
RawSampleCount int64
|
||||
}
|
||||
|
||||
type sourceHistoryID struct {
|
||||
VIN string
|
||||
SourceKey string
|
||||
}
|
||||
|
||||
var defaultBackfillEnvFiles = []string{
|
||||
"/opt/lingniu-go-native/env/base.env",
|
||||
"/opt/lingniu-go-native/env/stat-writer.env",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := loadEnvFiles(env("BACKFILL_ENV_FILES", os.Getenv("BACKFILL_ENV_FILE"))); err != nil {
|
||||
envFiles := backfillEnvFiles(os.Getenv("BACKFILL_ENV_FILES"), os.Getenv("BACKFILL_ENV_FILE"), defaultBackfillEnvFiles)
|
||||
if err := loadEnvFiles(envFiles); err != nil {
|
||||
fail("load env file", err)
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
@@ -121,18 +139,27 @@ func main() {
|
||||
slog.Info("reset stats rows", "deleted", deleted)
|
||||
}
|
||||
if cfg.Method == "last_diff" {
|
||||
aggregates, err := buildLastDiffAggregates(ctx, td, cfg)
|
||||
aggregates, err := buildLastDiffAggregates(ctx, mysqlDB, td, cfg)
|
||||
if err != nil {
|
||||
fail("build last-diff aggregates", err)
|
||||
}
|
||||
fallbacks, err := addRealtimeLocationFallbackAggregates(ctx, mysqlDB, td, cfg, aggregates)
|
||||
if err != nil {
|
||||
fail("build realtime-location fallback aggregates", err)
|
||||
}
|
||||
var written int64
|
||||
var normalized int
|
||||
if !cfg.DryRun {
|
||||
written, err = writeAggregates(ctx, mysqlDB, aggregates, 500)
|
||||
if err != nil {
|
||||
fail("write aggregates", err)
|
||||
}
|
||||
normalized, err = normalizeHistoricalPlatformSources(ctx, mysqlDB, cfg)
|
||||
if err != nil {
|
||||
fail("normalize historical platform sources", err)
|
||||
}
|
||||
}
|
||||
slog.Info("stats backfill complete", "method", cfg.Method, "dry_run", cfg.DryRun, "aggregates", len(aggregates), "written", written)
|
||||
slog.Info("stats backfill complete", "method", cfg.Method, "dry_run", cfg.DryRun, "aggregates", len(aggregates), "realtimeLocationFallbacks", fallbacks, "written", written, "platformSourcesNormalized", normalized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -234,6 +261,7 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
SourceEndpoint: sample.SourceEndpoint,
|
||||
PlatformName: sample.PlatformName,
|
||||
FirstEventTime: sample.EventTime,
|
||||
LatestEventTime: sample.EventTime,
|
||||
QualityStatus: stats.QualityOK,
|
||||
@@ -255,6 +283,9 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
if strings.TrimSpace(sample.DeviceID) != "" {
|
||||
agg.DeviceID = sample.DeviceID
|
||||
}
|
||||
if strings.TrimSpace(sample.PlatformName) != "" {
|
||||
agg.PlatformName = sample.PlatformName
|
||||
}
|
||||
}
|
||||
agg.Count++
|
||||
}
|
||||
@@ -271,6 +302,9 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
Protocol: agg.Protocol,
|
||||
SourceIP: stats.NormalizeSourceIP(agg.SourceEndpoint),
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
SourceCode: agg.SourceCode,
|
||||
PlatformName: agg.PlatformName,
|
||||
SourceKind: agg.SourceKind,
|
||||
}
|
||||
if strings.TrimSpace(identity.SourceIP) == "" {
|
||||
continue
|
||||
@@ -285,7 +319,7 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil {
|
||||
return written, err
|
||||
}
|
||||
dailyKM := agg.LatestKM - agg.FirstKM
|
||||
dailyKM := stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM)
|
||||
candidate := stats.SourceMileageSample{
|
||||
VIN: agg.VIN,
|
||||
StatDate: agg.Date,
|
||||
@@ -295,6 +329,7 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
Phone: agg.Phone,
|
||||
DeviceID: agg.DeviceID,
|
||||
PlatformName: agg.PlatformName,
|
||||
FirstTotalKM: agg.FirstKM,
|
||||
LatestTotalKM: agg.LatestKM,
|
||||
DailyKM: dailyKM,
|
||||
@@ -310,10 +345,7 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
if candidate.QualityReason == "" {
|
||||
candidate.QualityReason = "same_source_previous_day"
|
||||
}
|
||||
if candidate.QualityStatus == stats.QualityOK && (candidate.DailyKM < 0 || candidate.DailyKM > maxTrustedDailyMileageKM) {
|
||||
candidate.QualityStatus = stats.QualityInvalidDelta
|
||||
candidate.QualityReason = "outside_daily_range"
|
||||
}
|
||||
stats.ApplyMileageQualityRules(&candidate)
|
||||
if err := stats.UpsertSourceMileage(ctx, db, candidate); err != nil {
|
||||
return written, err
|
||||
}
|
||||
@@ -325,6 +357,27 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func normalizeHistoricalPlatformSources(ctx context.Context, db *sql.DB, cfg config) (int, error) {
|
||||
dates, err := dateRange(cfg.DateFrom, cfg.DateTo)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalized := 0
|
||||
for _, protocol := range cfg.Protocols {
|
||||
if protocol != envelope.ProtocolJT808 {
|
||||
continue
|
||||
}
|
||||
for _, date := range dates {
|
||||
count, err := stats.NormalizePlatformSourceMileageForDate(ctx, db, date, protocol)
|
||||
if err != nil {
|
||||
return normalized, err
|
||||
}
|
||||
normalized += count
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func clearBackfillTargetMileage(ctx context.Context, db *sql.DB, vin string, statDate string, protocol envelope.Protocol) error {
|
||||
if db == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(string(protocol)) == "" {
|
||||
return nil
|
||||
@@ -337,47 +390,173 @@ func clearBackfillTargetMileage(ctx context.Context, db *sql.DB, vin string, sta
|
||||
return err
|
||||
}
|
||||
|
||||
func buildLastDiffAggregates(ctx context.Context, db *sql.DB, cfg config) (map[string]*metricAgg, error) {
|
||||
func buildLastDiffAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config) (map[string]*metricAgg, error) {
|
||||
targetDates, err := dateRange(cfg.DateFrom, cfg.DateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aggregates := map[string]*metricAgg{}
|
||||
for _, protocol := range cfg.Protocols {
|
||||
latestHistory := map[sourceHistoryID]dailySourceLast{}
|
||||
preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rememberLatestSourceRows(latestHistory, preWindow)
|
||||
slog.Info("pre-window baseline loaded", "protocol", protocol, "before_date", targetDates[0], "vehicles", len(preWindow))
|
||||
for _, date := range targetDates {
|
||||
current, err := queryDailyLastSourceRows(ctx, db, cfg, protocol, date)
|
||||
current, err := queryDailyLastSourceRows(ctx, tdDB, cfg, protocol, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previous, err := queryPreviousLastSourceRows(ctx, db, cfg, protocol, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("daily last loaded", "protocol", protocol, "date", date, "vehicles", len(current), "previousVehicles", len(previous))
|
||||
slog.Info("daily last loaded", "protocol", protocol, "date", date, "vehicles", len(current), "historicalSources", len(latestHistory))
|
||||
for vin, currentSources := range current {
|
||||
previousBySource := map[string]dailySourceLast{}
|
||||
for _, previousRow := range previous[vin] {
|
||||
previousBySource[previousRow.SourceKey] = previousRow
|
||||
}
|
||||
for _, currentRow := range currentSources {
|
||||
key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey
|
||||
previousRow, hasPrevious := previousBySource[currentRow.SourceKey]
|
||||
previousRow, hasPrevious, err := resolvePreviousSourceRow(ctx, mysqlDB, date, protocol, latestHistory, currentRow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aggregates[key] = aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious)
|
||||
}
|
||||
}
|
||||
// Current-day last values become eligible only for later target dates.
|
||||
rememberLatestSourceRows(latestHistory, current)
|
||||
}
|
||||
}
|
||||
return aggregates, nil
|
||||
}
|
||||
|
||||
func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) {
|
||||
if aggregates == nil {
|
||||
return 0, fmt.Errorf("aggregates map is nil")
|
||||
}
|
||||
targetDates, err := dateRange(cfg.DateFrom, cfg.DateTo)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var added int
|
||||
for _, protocol := range cfg.Protocols {
|
||||
if protocol != envelope.ProtocolYutongMQTT {
|
||||
continue
|
||||
}
|
||||
latestHistory := map[sourceHistoryID]dailySourceLast{}
|
||||
preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0])
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
rememberLatestSourceRows(latestHistory, preWindow)
|
||||
aggregateRowsByDate := indexAggregateSourceRowsByDate(aggregates, protocol)
|
||||
for _, date := range targetDates {
|
||||
current, err := queryRealtimeLocationLastRows(ctx, mysqlDB, cfg, protocol, date)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
for vin, currentSources := range current {
|
||||
for _, currentRow := range currentSources {
|
||||
key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey
|
||||
if _, exists := aggregates[key]; exists {
|
||||
continue
|
||||
}
|
||||
previousRow, hasPrevious, err := resolvePreviousSourceRow(ctx, mysqlDB, date, protocol, latestHistory, currentRow)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious)
|
||||
if hasPrevious {
|
||||
agg.QualityReason = "realtime_location_fallback_historical_baseline"
|
||||
} else {
|
||||
agg.QualityReason = "realtime_location_fallback_current_day_first_baseline"
|
||||
}
|
||||
aggregates[key] = agg
|
||||
added++
|
||||
}
|
||||
}
|
||||
rememberLatestSourceRows(latestHistory, aggregateRowsByDate[date])
|
||||
rememberLatestSourceRows(latestHistory, current)
|
||||
if len(current) > 0 {
|
||||
slog.Info("realtime location fallback loaded", "protocol", protocol, "date", date, "vehicles", len(current), "added", added)
|
||||
}
|
||||
}
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
func indexAggregateSourceRowsByDate(aggregates map[string]*metricAgg, protocol envelope.Protocol) map[string]map[string][]dailySourceLast {
|
||||
indexed := map[string]map[string][]dailySourceLast{}
|
||||
for _, agg := range aggregates {
|
||||
if agg == nil || agg.Protocol != protocol || strings.TrimSpace(agg.Date) == "" {
|
||||
continue
|
||||
}
|
||||
rows := indexed[agg.Date]
|
||||
if rows == nil {
|
||||
rows = map[string][]dailySourceLast{}
|
||||
indexed[agg.Date] = rows
|
||||
}
|
||||
rows[agg.VIN] = append(rows[agg.VIN], dailySourceLast{
|
||||
VIN: agg.VIN,
|
||||
SourceKey: agg.SourceKey,
|
||||
Phone: agg.Phone,
|
||||
DeviceID: agg.DeviceID,
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
SourceCode: agg.SourceCode,
|
||||
PlatformName: agg.PlatformName,
|
||||
SourceKind: agg.SourceKind,
|
||||
FirstTS: agg.LatestEventTime,
|
||||
TS: agg.LatestEventTime,
|
||||
FirstTotalKM: agg.LatestKM,
|
||||
TotalKM: agg.LatestKM,
|
||||
RawSampleCount: agg.Count,
|
||||
})
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
func rememberLatestSourceRows(history map[sourceHistoryID]dailySourceLast, rows map[string][]dailySourceLast) {
|
||||
for vin, sourceRows := range rows {
|
||||
for _, row := range sourceRows {
|
||||
rowVIN := strings.TrimSpace(row.VIN)
|
||||
if rowVIN == "" {
|
||||
rowVIN = strings.TrimSpace(vin)
|
||||
row.VIN = rowVIN
|
||||
}
|
||||
id := sourceHistoryID{VIN: rowVIN, SourceKey: strings.TrimSpace(row.SourceKey)}
|
||||
if id.VIN == "" || id.SourceKey == "" || row.TS.IsZero() {
|
||||
continue
|
||||
}
|
||||
if existing, ok := history[id]; !ok || row.TS.After(existing.TS) {
|
||||
history[id] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func latestHistoricalSourceRow(history map[sourceHistoryID]dailySourceLast, current dailySourceLast) (dailySourceLast, bool) {
|
||||
id := sourceHistoryID{
|
||||
VIN: strings.TrimSpace(current.VIN),
|
||||
SourceKey: strings.TrimSpace(current.SourceKey),
|
||||
}
|
||||
previous, ok := history[id]
|
||||
return previous, ok && previous.TS.Before(current.TS)
|
||||
}
|
||||
|
||||
func resolvePreviousSourceRow(ctx context.Context, db *sql.DB, date string, protocol envelope.Protocol, history map[sourceHistoryID]dailySourceLast, current dailySourceLast) (dailySourceLast, bool, error) {
|
||||
if previous, ok := latestHistoricalSourceRow(history, current); ok {
|
||||
return previous, true, nil
|
||||
}
|
||||
return queryDurablePreviousSourceRow(ctx, db, date, protocol, current)
|
||||
}
|
||||
|
||||
func aggregateFromDailySource(date string, protocol envelope.Protocol, current dailySourceLast, previous dailySourceLast, hasPrevious bool) *metricAgg {
|
||||
firstKM := current.FirstTotalKM
|
||||
firstEventTime := current.FirstTS
|
||||
qualityReason := "current_day_first_sample"
|
||||
qualityStatus := stats.QualityOK
|
||||
qualityReason := stats.QualityReasonCurrentDayFirst
|
||||
if hasPrevious {
|
||||
firstKM = previous.TotalKM
|
||||
firstEventTime = previous.TS
|
||||
qualityReason = "historical_source_baseline"
|
||||
qualityStatus = stats.QualityOK
|
||||
qualityReason = stats.QualityReasonHistorical
|
||||
}
|
||||
if firstEventTime.IsZero() {
|
||||
firstEventTime = current.TS
|
||||
@@ -386,7 +565,7 @@ func aggregateFromDailySource(date string, protocol envelope.Protocol, current d
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
return &metricAgg{
|
||||
agg := &metricAgg{
|
||||
VIN: current.VIN,
|
||||
Date: date,
|
||||
Protocol: protocol,
|
||||
@@ -397,11 +576,43 @@ func aggregateFromDailySource(date string, protocol envelope.Protocol, current d
|
||||
Phone: current.Phone,
|
||||
DeviceID: current.DeviceID,
|
||||
SourceEndpoint: current.SourceEndpoint,
|
||||
SourceCode: current.SourceCode,
|
||||
PlatformName: current.PlatformName,
|
||||
SourceKind: current.SourceKind,
|
||||
FirstEventTime: firstEventTime,
|
||||
LatestEventTime: current.TS,
|
||||
QualityStatus: stats.QualityOK,
|
||||
QualityStatus: qualityStatus,
|
||||
QualityReason: qualityReason,
|
||||
}
|
||||
candidate := stats.SourceMileageSample{
|
||||
FirstTotalKM: agg.FirstKM,
|
||||
LatestTotalKM: agg.LatestKM,
|
||||
DailyKM: stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM),
|
||||
FirstEventTime: agg.FirstEventTime,
|
||||
LatestEventTime: agg.LatestEventTime,
|
||||
QualityStatus: agg.QualityStatus,
|
||||
QualityReason: agg.QualityReason,
|
||||
}
|
||||
stats.ApplyMileageQualityRules(&candidate)
|
||||
agg.QualityStatus = candidate.QualityStatus
|
||||
agg.QualityReason = candidate.QualityReason
|
||||
return agg
|
||||
}
|
||||
|
||||
func queryDurablePreviousSourceRow(ctx context.Context, db *sql.DB, date string, protocol envelope.Protocol, current dailySourceLast) (dailySourceLast, bool, error) {
|
||||
if db == nil {
|
||||
return dailySourceLast{}, false, nil
|
||||
}
|
||||
totalKM, eventTime, found, err := stats.LookupLatestSourceBaselineBefore(ctx, db, current.VIN, date, protocol, current.SourceKey)
|
||||
if err != nil || !found {
|
||||
return dailySourceLast{}, found, err
|
||||
}
|
||||
previous := current
|
||||
previous.FirstTS = eventTime
|
||||
previous.TS = eventTime
|
||||
previous.FirstTotalKM = totalKM
|
||||
previous.TotalKM = totalKM
|
||||
return previous, true, nil
|
||||
}
|
||||
|
||||
type trustedChoice struct {
|
||||
@@ -409,8 +620,6 @@ type trustedChoice struct {
|
||||
previous dailySourceLast
|
||||
}
|
||||
|
||||
const maxTrustedDailyMileageKM = 1000
|
||||
|
||||
func chooseTrustedSource(current []dailySourceLast, previous []dailySourceLast) (trustedChoice, bool) {
|
||||
previousBySource := map[string]dailySourceLast{}
|
||||
for _, row := range previous {
|
||||
@@ -423,8 +632,8 @@ func chooseTrustedSource(current []dailySourceLast, previous []dailySourceLast)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delta := currentRow.TotalKM - previousRow.TotalKM
|
||||
if delta < 0 || delta > maxTrustedDailyMileageKM {
|
||||
delta, ok, _ := stats.NormalizeDailyMileageDeltaForWindow(stats.DailyMileageFromDayBoundary(previousRow.TotalKM, currentRow.TotalKM), previousRow.TS, currentRow.TS)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if chosen.current.SourceKey == "" || delta < chosenDelta {
|
||||
@@ -436,19 +645,18 @@ func chooseTrustedSource(current []dailySourceLast, previous []dailySourceLast)
|
||||
}
|
||||
|
||||
func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
|
||||
where := []string{
|
||||
fmt.Sprintf("ts >= '%s 00:00:00'", quote(date)),
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(date))),
|
||||
where := backfillTimePredicates(cfg, date, nextDate(date))
|
||||
where = append(where,
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
"vin <> ''",
|
||||
fmt.Sprintf("protocol = '%s'", quote(string(protocol))),
|
||||
realtimeMileageFramePredicate(),
|
||||
}
|
||||
)
|
||||
if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(ts), FIRST(parsed_json), LAST(ts), LAST(parsed_json), COUNT(*)
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(event_time), FIRST(parsed_json), LAST(event_time), LAST(parsed_json), COUNT(*)
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
@@ -456,24 +664,103 @@ GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), s
|
||||
}
|
||||
|
||||
func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
|
||||
where := []string{
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(date)),
|
||||
// The baseline is the nearest earlier sample, not necessarily yesterday's.
|
||||
// LAST aggregates the complete pre-window history once per source so empty
|
||||
// calendar days do not make a backfill fall back to the current day's first
|
||||
// sample.
|
||||
where := backfillBeforePredicates(date)
|
||||
where = append(where,
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
"vin <> ''",
|
||||
fmt.Sprintf("protocol = '%s'", quote(string(protocol))),
|
||||
realtimeMileageFramePredicate(),
|
||||
}
|
||||
)
|
||||
if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(ts), LAST(parsed_json), COUNT(*)
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(event_time), LAST(parsed_json), COUNT(*)
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
return querySourceRows(ctx, db, cfg, protocol, sqlText, false)
|
||||
}
|
||||
|
||||
func backfillBeforePredicates(eventDateExclusive string) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateExclusive)),
|
||||
}
|
||||
}
|
||||
|
||||
func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
|
||||
if db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
loc := cfg.Location
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
sqlText := `SELECT l.vin, COALESCE(NULLIF(s.peer, ''), ''), l.total_mileage_event_time, l.total_mileage_km
|
||||
FROM vehicle_realtime_location l
|
||||
LEFT JOIN vehicle_realtime_snapshot s ON s.protocol = l.protocol AND s.vin = l.vin
|
||||
WHERE l.protocol = ?
|
||||
AND l.vin IS NOT NULL AND l.vin <> ''
|
||||
AND l.total_mileage_km IS NOT NULL AND l.total_mileage_km > 0
|
||||
AND l.total_mileage_event_time >= ?
|
||||
AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)`
|
||||
rows, err := db.QueryContext(ctx, sqlText, string(protocol), date, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string][]dailySourceLast{}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
var peer sql.NullString
|
||||
var eventTime time.Time
|
||||
var totalKM float64
|
||||
if err := rows.Scan(&vin, &peer, &eventTime, &totalKM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.TrimSpace(vin)
|
||||
if vin == "" || totalKM <= 0 {
|
||||
continue
|
||||
}
|
||||
sourceEndpoint := strings.TrimSpace(peer.String)
|
||||
if sourceEndpoint == "" && protocol == envelope.ProtocolYutongMQTT {
|
||||
sourceEndpoint = "mqtt://yutong/realtime-location"
|
||||
}
|
||||
deviceID := ""
|
||||
if protocol == envelope.ProtocolYutongMQTT {
|
||||
deviceID = vin
|
||||
}
|
||||
sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint)
|
||||
sourceKey := normalizedSourceKeyForSource(string(protocol), "", deviceID, sourceEndpoint, sourceKind, sourceCode)
|
||||
if sourceKey == "" {
|
||||
continue
|
||||
}
|
||||
row := dailySourceLast{
|
||||
VIN: vin,
|
||||
SourceKey: sourceKey,
|
||||
DeviceID: deviceID,
|
||||
SourceEndpoint: sourceEndpoint,
|
||||
SourceCode: sourceCode,
|
||||
PlatformName: platformName,
|
||||
SourceKind: sourceKind,
|
||||
FirstTS: eventTime.In(loc),
|
||||
TS: eventTime.In(loc),
|
||||
FirstTotalKM: totalKM,
|
||||
TotalKM: totalKM,
|
||||
RawSampleCount: 1,
|
||||
}
|
||||
out[vin] = append(out[vin], row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, sqlText string, includeFirst bool) (map[string][]dailySourceLast, error) {
|
||||
rows, err := db.QueryContext(ctx, sqlText)
|
||||
if err != nil {
|
||||
@@ -512,7 +799,8 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
|
||||
firstTotalKM = parsedFirst
|
||||
}
|
||||
}
|
||||
sourceKey := normalizedSourceKey(string(protocol), phone, deviceID, sourceEndpoint)
|
||||
sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint)
|
||||
sourceKey := normalizedSourceKeyForSource(string(protocol), phone, deviceID, sourceEndpoint, sourceKind, sourceCode)
|
||||
if sourceKey == "" {
|
||||
continue
|
||||
}
|
||||
@@ -522,8 +810,11 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
|
||||
Phone: strings.TrimSpace(phone),
|
||||
DeviceID: strings.TrimSpace(deviceID),
|
||||
SourceEndpoint: strings.TrimSpace(sourceEndpoint),
|
||||
SourceCode: sourceCode,
|
||||
PlatformName: platformName,
|
||||
SourceKind: sourceKind,
|
||||
FirstTS: firstTS.In(cfg.Location),
|
||||
TS: ts,
|
||||
TS: ts.In(cfg.Location),
|
||||
FirstTotalKM: firstTotalKM,
|
||||
TotalKM: latestTotalKM,
|
||||
RawSampleCount: rawSampleCount,
|
||||
@@ -562,8 +853,19 @@ func mileageFromParsed(protocol envelope.Protocol, vin string, parsedJSON string
|
||||
}
|
||||
|
||||
func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string {
|
||||
return normalizedSourceKeyForSource(protocol, phone, deviceID, endpoint, "", "")
|
||||
}
|
||||
|
||||
func normalizedSourceKeyForSource(protocol string, phone string, deviceID string, endpoint string, sourceKind string, sourceCode string) string {
|
||||
sourceIP := stats.NormalizeSourceIP(endpoint)
|
||||
return stats.SourceKey(envelope.Protocol(protocol), phone, deviceID, sourceIP)
|
||||
return stats.SourceKeyForSource(envelope.Protocol(protocol), phone, deviceID, sourceIP, sourceKind, sourceCode)
|
||||
}
|
||||
|
||||
func knownPlatformSourceMetadata(protocol envelope.Protocol, endpoint string) (sourceCode string, platformName string, sourceKind string) {
|
||||
if protocol == envelope.ProtocolYutongMQTT && stats.NormalizeSourceIP(endpoint) == "mqtt" {
|
||||
return "yutong", "宇通", "PLATFORM"
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[string]any {
|
||||
@@ -574,7 +876,7 @@ func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[str
|
||||
}
|
||||
parsed := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err == nil && len(parsed) > 0 {
|
||||
if flattened, _, ok := realtime.ParsedFieldsForEnvelope(envelope.FrameEnvelope{
|
||||
if flattened, _, ok := realtime.ComputeParsedFieldsForEnvelope(envelope.FrameEnvelope{
|
||||
Protocol: protocol,
|
||||
VIN: vin,
|
||||
Parsed: parsed,
|
||||
@@ -604,7 +906,12 @@ func mileageKeys(protocol envelope.Protocol) []string {
|
||||
case envelope.ProtocolJT808:
|
||||
return []string{"jt808.location.total_mileage_km"}
|
||||
case envelope.ProtocolYutongMQTT:
|
||||
return []string{"yutong_mqtt.data.total_mileage", "yutong_mqtt.root.data.total_mileage"}
|
||||
return []string{
|
||||
"yutong_mqtt.data.total_mileage_km",
|
||||
"yutong_mqtt.root.data.total_mileage_km",
|
||||
"yutong_mqtt.data.total_mileage",
|
||||
"yutong_mqtt.root.data.total_mileage",
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -624,38 +931,67 @@ func loadConfig() (config, error) {
|
||||
if err != nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
dateTo := env("BACKFILL_DATE_TO", time.Now().In(loc).Format("2006-01-02"))
|
||||
dateFrom := env("BACKFILL_DATE_FROM", dateTo)
|
||||
dateFrom, dateTo := resolveBackfillDateRange(time.Now(), loc)
|
||||
protocols, err := parseProtocols(env("BACKFILL_PROTOCOLS", "GB32960,JT808,YUTONG_MQTT"))
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
return config{
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
Protocols: protocols,
|
||||
Method: env("BACKFILL_METHOD", "last_diff"),
|
||||
Limit: envInt("BACKFILL_LIMIT", 0),
|
||||
DryRun: envBool("BACKFILL_DRY_RUN", true),
|
||||
Reset: envBool("BACKFILL_RESET", false),
|
||||
Debug: envBool("BACKFILL_DEBUG", false),
|
||||
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
|
||||
Location: loc,
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
Protocols: protocols,
|
||||
Method: env("BACKFILL_METHOD", "last_diff"),
|
||||
Limit: envInt("BACKFILL_LIMIT", 0),
|
||||
DryRun: envBool("BACKFILL_DRY_RUN", true),
|
||||
Reset: envBool("BACKFILL_RESET", false),
|
||||
Debug: envBool("BACKFILL_DEBUG", false),
|
||||
EventTimeFullScan: envBool("BACKFILL_EVENT_TIME_FULL_SCAN", false),
|
||||
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
|
||||
Location: loc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveBackfillDateRange(now time.Time, loc *time.Location) (string, string) {
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
explicitFrom := strings.TrimSpace(os.Getenv("BACKFILL_DATE_FROM"))
|
||||
explicitTo := strings.TrimSpace(os.Getenv("BACKFILL_DATE_TO"))
|
||||
if explicitTo != "" || explicitFrom != "" {
|
||||
dateTo := explicitTo
|
||||
if dateTo == "" {
|
||||
dateTo = now.In(loc).Format("2006-01-02")
|
||||
}
|
||||
dateFrom := explicitFrom
|
||||
if dateFrom == "" {
|
||||
dateFrom = dateTo
|
||||
}
|
||||
return dateFrom, dateTo
|
||||
}
|
||||
daysBack := envInt("BACKFILL_DAYS_BACK", 0)
|
||||
if daysBack < 0 {
|
||||
daysBack = 0
|
||||
}
|
||||
windowDays := envInt("BACKFILL_WINDOW_DAYS", 1)
|
||||
if windowDays < 1 {
|
||||
windowDays = 1
|
||||
}
|
||||
dateToTime := now.In(loc).AddDate(0, 0, -daysBack)
|
||||
dateFromTime := dateToTime.AddDate(0, 0, -(windowDays - 1))
|
||||
return dateFromTime.Format("2006-01-02"), dateToTime.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, error) {
|
||||
where := []string{
|
||||
fmt.Sprintf("ts >= '%s 00:00:00'", quote(cfg.DateFrom)),
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(cfg.DateTo))),
|
||||
where := backfillTimePredicates(cfg, cfg.DateFrom, nextDate(cfg.DateTo))
|
||||
where = append(where,
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
"vin <> ''",
|
||||
}
|
||||
)
|
||||
if len(cfg.Protocols) > 0 {
|
||||
quoted := make([]string, 0, len(cfg.Protocols))
|
||||
for _, protocol := range cfg.Protocols {
|
||||
@@ -667,7 +1003,7 @@ func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, err
|
||||
sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
ORDER BY ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
ORDER BY event_time ASC, ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
if cfg.Limit > 0 {
|
||||
sqlText += fmt.Sprintf(" LIMIT %d", cfg.Limit)
|
||||
}
|
||||
@@ -675,6 +1011,23 @@ ORDER BY ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
return db.QueryContext(ctx, sqlText)
|
||||
}
|
||||
|
||||
func backfillTimePredicates(cfg config, eventDateFrom string, eventDateToExclusive string) []string {
|
||||
where := make([]string, 0, 4)
|
||||
if !cfg.EventTimeFullScan {
|
||||
// ts is the TDengine primary timestamp and may be adjusted slightly to keep
|
||||
// rows unique. Use it only as a broad index-friendly window; event_time is
|
||||
// the protocol business boundary used for the final natural-day result.
|
||||
where = append(where,
|
||||
fmt.Sprintf("ts >= '%s 00:00:00'", quote(previousDate(eventDateFrom))),
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(eventDateToExclusive))),
|
||||
)
|
||||
}
|
||||
return append(where,
|
||||
fmt.Sprintf("event_time >= '%s 00:00:00'", quote(eventDateFrom)),
|
||||
fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateToExclusive)),
|
||||
)
|
||||
}
|
||||
|
||||
func realtimeMileageFramePredicate() string {
|
||||
return `(
|
||||
(protocol = 'GB32960' AND message_id IN (2,3))
|
||||
@@ -684,17 +1037,39 @@ func realtimeMileageFramePredicate() string {
|
||||
}
|
||||
|
||||
func mileageBearingFramePredicate(protocol envelope.Protocol) string {
|
||||
keys := mileageKeys(protocol)
|
||||
if len(keys) == 0 {
|
||||
tokens := mileageSearchTokens(protocol)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
conditions := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(key)))
|
||||
conditions := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(token)))
|
||||
}
|
||||
return "(" + strings.Join(conditions, " OR ") + ")"
|
||||
}
|
||||
|
||||
func mileageSearchTokens(protocol envelope.Protocol) []string {
|
||||
tokens := append([]string{}, mileageKeys(protocol)...)
|
||||
switch protocol {
|
||||
case envelope.ProtocolYutongMQTT:
|
||||
tokens = append(tokens, "TOTAL_MILEAGE", "totalMileage", "total_mileage_km")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(tokens))
|
||||
out := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[token]; ok {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
out = append(out, token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) {
|
||||
var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed string
|
||||
var messageID int64
|
||||
@@ -804,6 +1179,26 @@ func loadEnvFiles(paths string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillEnvFiles(explicitFiles string, explicitFile string, defaults []string) string {
|
||||
if value := strings.TrimSpace(explicitFiles); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimSpace(explicitFile); value != "" {
|
||||
return value
|
||||
}
|
||||
var existing []string
|
||||
for _, path := range defaults {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
existing = append(existing, path)
|
||||
}
|
||||
}
|
||||
return strings.Join(existing, ",")
|
||||
}
|
||||
|
||||
func loadEnvFile(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
|
||||
@@ -2,6 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +50,58 @@ func TestChooseTrustedSourceKeepsContinuingSourceAndRejectsNewJump(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseTrustedSourceAcceptsSmallNegativeMileageJitter(t *testing.T) {
|
||||
previous := []dailySourceLast{{
|
||||
VIN: "LB9A32A28R0LS1574",
|
||||
SourceKey: normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53330"),
|
||||
Phone: "64115156034",
|
||||
SourceEndpoint: "115.159.85.149:53330",
|
||||
TotalKM: 15355.4,
|
||||
}}
|
||||
current := []dailySourceLast{{
|
||||
VIN: "LB9A32A28R0LS1574",
|
||||
SourceKey: normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53338"),
|
||||
Phone: "64115156034",
|
||||
SourceEndpoint: "115.159.85.149:53338",
|
||||
TotalKM: 15355.3,
|
||||
}}
|
||||
|
||||
chosen, ok := chooseTrustedSource(current, previous)
|
||||
if !ok {
|
||||
t.Fatal("chooseTrustedSource() should accept tiny negative mileage jitter")
|
||||
}
|
||||
if chosen.current.SourceKey != current[0].SourceKey {
|
||||
t.Fatalf("chosen source = %q", chosen.current.SourceKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseTrustedSourceAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
sourceKey := normalizedSourceKey("GB32960", "", "", "8.134.95.166:37720")
|
||||
previous := []dailySourceLast{{
|
||||
VIN: "LNXNEGRR1SR319498",
|
||||
SourceKey: sourceKey,
|
||||
SourceEndpoint: "8.134.95.166:37720",
|
||||
TS: time.Date(2026, 7, 4, 11, 7, 58, 0, loc),
|
||||
TotalKM: 8832.1,
|
||||
}}
|
||||
current := []dailySourceLast{{
|
||||
VIN: "LNXNEGRR1SR319498",
|
||||
SourceKey: sourceKey,
|
||||
SourceEndpoint: "8.134.95.166:37720",
|
||||
TS: time.Date(2026, 7, 12, 2, 52, 47, 0, loc),
|
||||
TotalKM: 16665.6,
|
||||
}}
|
||||
|
||||
chosen, ok := chooseTrustedSource(current, previous)
|
||||
if !ok {
|
||||
t.Fatal("chooseTrustedSource() should accept delta within the historical baseline window")
|
||||
}
|
||||
if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 7833.4 || delta > 7833.6 {
|
||||
t.Fatalf("delta = %v, want historical gap delta", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) {
|
||||
sourceA := dailySourceLast{
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
@@ -96,7 +151,7 @@ func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
|
||||
if agg.FirstEventTime != previous.TS || agg.LatestEventTime != current.TS {
|
||||
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||
}
|
||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != "historical_source_baseline" {
|
||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonHistorical {
|
||||
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
||||
}
|
||||
if agg.Count != 15 {
|
||||
@@ -104,7 +159,40 @@ func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateFromDailySourceUsesCurrentFirstSampleWithoutHistory(t *testing.T) {
|
||||
func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
current := dailySourceLast{
|
||||
VIN: "LNXNEGRR6SR319464",
|
||||
SourceKey: normalizedSourceKey("GB32960", "", "", "8.134.95.166:49206"),
|
||||
SourceEndpoint: "8.134.95.166:49206",
|
||||
FirstTS: time.Date(2026, 7, 12, 8, 5, 42, 0, loc),
|
||||
TS: time.Date(2026, 7, 12, 10, 44, 56, 0, loc),
|
||||
FirstTotalKM: 28004.2,
|
||||
TotalKM: 40009.7,
|
||||
RawSampleCount: 1938,
|
||||
}
|
||||
previous := dailySourceLast{
|
||||
VIN: current.VIN,
|
||||
SourceKey: current.SourceKey,
|
||||
SourceEndpoint: current.SourceEndpoint,
|
||||
TS: time.Date(2026, 7, 3, 19, 0, 39, 0, loc),
|
||||
TotalKM: 10009.7,
|
||||
}
|
||||
|
||||
agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true)
|
||||
|
||||
if agg.FirstKM != previous.TotalKM || agg.LatestKM != current.TotalKM {
|
||||
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
||||
}
|
||||
if !agg.FirstEventTime.Equal(previous.TS) || !agg.LatestEventTime.Equal(current.TS) {
|
||||
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||
}
|
||||
if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" {
|
||||
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateFromDailySourceUsesCurrentDayFirstWhenHistoryIsEmpty(t *testing.T) {
|
||||
current := dailySourceLast{
|
||||
VIN: "LMRKH9AC2R1004087",
|
||||
SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
|
||||
@@ -125,11 +213,264 @@ func TestAggregateFromDailySourceUsesCurrentFirstSampleWithoutHistory(t *testing
|
||||
if agg.FirstEventTime != current.FirstTS || agg.LatestEventTime != current.TS {
|
||||
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||
}
|
||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != "current_day_first_sample" {
|
||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
|
||||
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.T) {
|
||||
tdDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
mock.MatchExpectationsInOrder(true)
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
vin := "LMRKH9AC2R1004087"
|
||||
endpoint := "mqtt://yutong/ytforward/shln/3"
|
||||
dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
|
||||
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
|
||||
currentRows := func() *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
})
|
||||
}
|
||||
previousRows := func() *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
})
|
||||
}
|
||||
|
||||
mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(previousRows())
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, int64(4)))
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows())
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, int64(6)))
|
||||
|
||||
aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
DateFrom: "2026-07-09",
|
||||
DateTo: "2026-07-11",
|
||||
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
||||
Location: loc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildLastDiffAggregates() error = %v", err)
|
||||
}
|
||||
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
|
||||
agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey]
|
||||
if agg == nil {
|
||||
t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
|
||||
}
|
||||
if agg.FirstKM != 100 || agg.LatestKM != 120 {
|
||||
t.Fatalf("km range = %v -> %v, want 100 -> 120", agg.FirstKM, agg.LatestKM)
|
||||
}
|
||||
if !agg.FirstEventTime.Equal(dayOneTS) || agg.QualityReason != stats.QualityReasonHistorical {
|
||||
t.Fatalf("baseline = %v reason=%q, want day-one historical baseline", agg.FirstEventTime, agg.QualityReason)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateKeys(aggregates map[string]*metricAgg) []string {
|
||||
keys := make([]string, 0, len(aggregates))
|
||||
for key := range aggregates {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestQueryRealtimeLocationLastRowsBuildsYutongSourceFromPeer(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 7, 12, 5, 54, 50, 0, loc)
|
||||
mock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*LEFT JOIN vehicle_realtime_snapshot s").
|
||||
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
||||
AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", eventTime, 11578.0))
|
||||
|
||||
rows, err := queryRealtimeLocationLastRows(context.Background(), db, config{Location: loc}, envelope.ProtocolYutongMQTT, "2026-07-12")
|
||||
if err != nil {
|
||||
t.Fatalf("queryRealtimeLocationLastRows() error = %v", err)
|
||||
}
|
||||
sourceRows := rows["LMRKH9AC0R1004086"]
|
||||
if len(sourceRows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
||||
}
|
||||
row := sourceRows[0]
|
||||
wantSourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong")
|
||||
if row.SourceKey != wantSourceKey {
|
||||
t.Fatalf("source key = %q", row.SourceKey)
|
||||
}
|
||||
if row.SourceCode != "yutong" || row.PlatformName != "宇通" || row.SourceKind != "PLATFORM" {
|
||||
t.Fatalf("source metadata = code:%q platform:%q kind:%q", row.SourceCode, row.PlatformName, row.SourceKind)
|
||||
}
|
||||
if row.TotalKM != 11578 || row.DeviceID != "LMRKH9AC0R1004086" || row.RawSampleCount != 1 {
|
||||
t.Fatalf("unexpected realtime fallback row: %#v", row)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.T) {
|
||||
mysqlDB, mysqlMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
tdDB, tdMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("td sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
currentTS := time.Date(2026, 7, 12, 5, 54, 50, 0, loc)
|
||||
previousTS := time.Date(2026, 7, 11, 23, 58, 0, 0, loc)
|
||||
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
||||
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
||||
AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0))
|
||||
tdMock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-12 00:00:00'.*protocol = 'YUTONG_MQTT'.*TOTAL_MILEAGE").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC0R1004086",
|
||||
"",
|
||||
"LMRKH9AC0R1004086",
|
||||
"mqtt://yutong/ytforward/shln/4",
|
||||
previousTS,
|
||||
`{"data":{"TOTAL_MILEAGE":11500000}}`,
|
||||
int64(12),
|
||||
))
|
||||
|
||||
aggregates := map[string]*metricAgg{}
|
||||
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
DateFrom: "2026-07-12",
|
||||
DateTo: "2026-07-12",
|
||||
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
||||
Location: loc,
|
||||
}, aggregates)
|
||||
if err != nil {
|
||||
t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
|
||||
}
|
||||
if added != 1 || len(aggregates) != 1 {
|
||||
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
|
||||
}
|
||||
for _, agg := range aggregates {
|
||||
if agg.FirstKM != 11500 || agg.LatestKM != 11578 {
|
||||
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
||||
}
|
||||
if agg.SourceKey != stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") {
|
||||
t.Fatalf("source key = %q", agg.SourceKey)
|
||||
}
|
||||
if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" {
|
||||
t.Fatalf("source metadata = code:%q platform:%q kind:%q", agg.SourceCode, agg.PlatformName, agg.SourceKind)
|
||||
}
|
||||
if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
|
||||
t.Fatalf("quality reason = %q", agg.QualityReason)
|
||||
}
|
||||
}
|
||||
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("mysql sql expectations: %v", err)
|
||||
}
|
||||
if err := tdMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("td sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *testing.T) {
|
||||
mysqlDB, mysqlMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
tdDB, tdMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("td sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
vin := "LMRKH9AC2R1004087"
|
||||
endpoint := "mqtt://yutong/ytforward/shln/3"
|
||||
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
|
||||
dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
|
||||
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
|
||||
|
||||
tdMock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}))
|
||||
for _, date := range []string{"2026-07-09", "2026-07-10"} {
|
||||
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
||||
WithArgs("YUTONG_MQTT", date, date).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}))
|
||||
}
|
||||
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
||||
WithArgs("YUTONG_MQTT", "2026-07-11", "2026-07-11").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
||||
AddRow(vin, endpoint, dayThreeTS, 120.0))
|
||||
|
||||
aggregates := map[string]*metricAgg{
|
||||
vin + "|2026-07-09|YUTONG_MQTT|" + sourceKey: {
|
||||
VIN: vin,
|
||||
Date: "2026-07-09",
|
||||
Protocol: envelope.ProtocolYutongMQTT,
|
||||
LatestKM: 100,
|
||||
Count: 4,
|
||||
SourceKey: sourceKey,
|
||||
DeviceID: vin,
|
||||
SourceEndpoint: endpoint,
|
||||
SourceCode: "yutong",
|
||||
PlatformName: "宇通",
|
||||
SourceKind: "PLATFORM",
|
||||
LatestEventTime: dayOneTS,
|
||||
},
|
||||
}
|
||||
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
DateFrom: "2026-07-09",
|
||||
DateTo: "2026-07-11",
|
||||
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
||||
Location: loc,
|
||||
}, aggregates)
|
||||
if err != nil {
|
||||
t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
|
||||
}
|
||||
if added != 1 || len(aggregates) != 2 {
|
||||
t.Fatalf("added=%d aggregates=%d, want 1 and 2", added, len(aggregates))
|
||||
}
|
||||
agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey]
|
||||
if agg == nil {
|
||||
t.Fatal("missing realtime-location fallback aggregate")
|
||||
}
|
||||
if agg.FirstKM != 100 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayOneTS) {
|
||||
t.Fatalf("fallback range = %v@%v -> %v, want 100@day-one -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
|
||||
}
|
||||
if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
|
||||
t.Fatalf("quality reason = %q", agg.QualityReason)
|
||||
}
|
||||
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("mysql sql expectations: %v", err)
|
||||
}
|
||||
if err := tdMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("td sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -140,9 +481,9 @@ func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
firstTS := time.Date(2026, 7, 8, 8, 0, 0, 0, loc)
|
||||
lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'").
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(ts)", "FIRST(parsed_json)", "LAST(ts)", "LAST(parsed_json)", "COUNT(*)",
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC6R1004108",
|
||||
"",
|
||||
@@ -183,9 +524,9 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'").
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(ts)", "LAST(parsed_json)", "COUNT(*)",
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC6R1004108",
|
||||
"",
|
||||
@@ -215,6 +556,89 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillBeforePredicatesSearchesAllEarlierHistory(t *testing.T) {
|
||||
where := strings.Join(backfillBeforePredicates("2026-07-08"), " AND ")
|
||||
if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") {
|
||||
t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where)
|
||||
}
|
||||
if strings.Contains(where, "event_time >=") || strings.Contains(where, "ts >=") {
|
||||
t.Fatalf("pre-window predicate must not stop at the previous day: %s", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC7R1004098",
|
||||
"",
|
||||
"LMRKH9AC7R1004098",
|
||||
"mqtt://yutong/ytforward/shln/4",
|
||||
utcInstant,
|
||||
`{"yutong_mqtt.data.total_mileage":"41249000"}`,
|
||||
int64(1),
|
||||
))
|
||||
|
||||
rows, err := queryPreviousLastSourceRows(context.Background(), db, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
Location: loc,
|
||||
}, envelope.ProtocolYutongMQTT, "2026-07-13")
|
||||
if err != nil {
|
||||
t.Fatalf("queryPreviousLastSourceRows() error = %v", err)
|
||||
}
|
||||
sourceRows := rows["LMRKH9AC7R1004098"]
|
||||
if len(sourceRows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
||||
}
|
||||
want := time.Date(2026, 7, 12, 23, 59, 59, 0, loc)
|
||||
if !sourceRows[0].TS.Equal(want) || sourceRows[0].TS.Location().String() != loc.String() {
|
||||
t.Fatalf("previous event time = %s (%s), want %s (%s)", sourceRows[0].TS, sourceRows[0].TS.Location(), want, want.Location())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
|
||||
fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{
|
||||
"data": {
|
||||
"TOTAL_MILEAGE": 65423000,
|
||||
"METER_SPEED": 12.3
|
||||
},
|
||||
"root": {
|
||||
"device": "LMRKH9AC6R1004108"
|
||||
}
|
||||
}`)
|
||||
|
||||
if got := fields["yutong_mqtt.data.total_mileage"]; got == nil {
|
||||
t.Fatalf("fields missing raw yutong total mileage: %#v", fields)
|
||||
}
|
||||
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolYutongMQTT,
|
||||
VIN: "LMRKH9AC6R1004108",
|
||||
EventTimeMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
||||
ReceivedAtMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
||||
Fields: fields,
|
||||
}
|
||||
samples, err := stats.SamplesFromEnvelope(env, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
if err != nil {
|
||||
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
||||
}
|
||||
if len(samples) != 1 {
|
||||
t.Fatalf("samples = %d, want 1", len(samples))
|
||||
}
|
||||
if samples[0].TotalMileageKM != 65423 {
|
||||
t.Fatalf("total mileage km = %v, want 65423", samples[0].TotalMileageKM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -269,7 +693,7 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_data_source`).
|
||||
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
||||
WithArgs(
|
||||
@@ -292,18 +716,16 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
|
||||
"outside_daily_range",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(1000)).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(2500)).
|
||||
WillReturnResult(sqlmock.NewResult(1, 0))
|
||||
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
||||
WithArgs(
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
int64(1000),
|
||||
int64(2500),
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
@@ -319,6 +741,7 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
|
||||
"JT808",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
written, err := writeAggregates(context.Background(), db, aggregates, 500)
|
||||
if err != nil {
|
||||
@@ -419,6 +842,8 @@ func TestAddSamplesUsesCurrentDayFirstSampleBaseline(t *testing.T) {
|
||||
|
||||
func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
|
||||
t.Setenv("BACKFILL_METHOD", "")
|
||||
t.Setenv("BACKFILL_DAYS_BACK", "")
|
||||
t.Setenv("BACKFILL_WINDOW_DAYS", "")
|
||||
t.Setenv("BACKFILL_DATE_FROM", "2026-07-08")
|
||||
t.Setenv("BACKFILL_DATE_TO", "2026-07-08")
|
||||
t.Setenv("BACKFILL_PROTOCOLS", "JT808")
|
||||
@@ -431,4 +856,101 @@ func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
|
||||
if cfg.Method != "last_diff" {
|
||||
t.Fatalf("method = %q, want last_diff", cfg.Method)
|
||||
}
|
||||
if cfg.EventTimeFullScan {
|
||||
t.Fatal("event-time full scan should be opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillTimePredicatesUsePrimaryTimeForCoarseScanAndEventTimeForBusinessDay(t *testing.T) {
|
||||
where := strings.Join(backfillTimePredicates(config{}, "2026-07-13", "2026-07-14"), " AND ")
|
||||
for _, want := range []string{
|
||||
"ts >= '2026-07-12 00:00:00'",
|
||||
"ts < '2026-07-15 00:00:00'",
|
||||
"event_time >= '2026-07-13 00:00:00'",
|
||||
"event_time < '2026-07-14 00:00:00'",
|
||||
} {
|
||||
if !strings.Contains(where, want) {
|
||||
t.Fatalf("time predicates missing %q: %s", want, where)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillTimePredicatesAllowExplicitDeepEventTimeScan(t *testing.T) {
|
||||
where := strings.Join(backfillTimePredicates(config{EventTimeFullScan: true}, "2026-07-13", "2026-07-14"), " AND ")
|
||||
if strings.Contains(where, "ts >=") || strings.Contains(where, "ts <") {
|
||||
t.Fatalf("deep event-time scan must not apply storage-time bounds: %s", where)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"event_time >= '2026-07-13 00:00:00'",
|
||||
"event_time < '2026-07-14 00:00:00'",
|
||||
} {
|
||||
if !strings.Contains(where, want) {
|
||||
t.Fatalf("deep event-time scan missing %q: %s", want, where)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBackfillDateRangeDefaultsToToday(t *testing.T) {
|
||||
t.Setenv("BACKFILL_DATE_FROM", "")
|
||||
t.Setenv("BACKFILL_DATE_TO", "")
|
||||
t.Setenv("BACKFILL_DAYS_BACK", "")
|
||||
t.Setenv("BACKFILL_WINDOW_DAYS", "")
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
||||
if dateFrom != "2026-07-12" || dateTo != "2026-07-12" {
|
||||
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBackfillDateRangeUsesRelativeWindow(t *testing.T) {
|
||||
t.Setenv("BACKFILL_DATE_FROM", "")
|
||||
t.Setenv("BACKFILL_DATE_TO", "")
|
||||
t.Setenv("BACKFILL_DAYS_BACK", "1")
|
||||
t.Setenv("BACKFILL_WINDOW_DAYS", "3")
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
||||
if dateFrom != "2026-07-09" || dateTo != "2026-07-11" {
|
||||
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBackfillDateRangePrefersExplicitDates(t *testing.T) {
|
||||
t.Setenv("BACKFILL_DATE_FROM", "2026-07-01")
|
||||
t.Setenv("BACKFILL_DATE_TO", "2026-07-03")
|
||||
t.Setenv("BACKFILL_DAYS_BACK", "1")
|
||||
t.Setenv("BACKFILL_WINDOW_DAYS", "3")
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
||||
if dateFrom != "2026-07-01" || dateTo != "2026-07-03" {
|
||||
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEnvFilesPrefersExplicitList(t *testing.T) {
|
||||
got := backfillEnvFiles("/tmp/a.env,/tmp/b.env", "/tmp/legacy.env", []string{"/tmp/default.env"})
|
||||
if got != "/tmp/a.env,/tmp/b.env" {
|
||||
t.Fatalf("env files = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEnvFilesUsesExistingDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "missing.env")
|
||||
base := filepath.Join(dir, "base.env")
|
||||
stat := filepath.Join(dir, "stat-writer.env")
|
||||
if err := os.WriteFile(base, []byte("A=1\n"), 0600); err != nil {
|
||||
t.Fatalf("write base env: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(stat, []byte("B=2\n"), 0600); err != nil {
|
||||
t.Fatalf("write stat env: %v", err)
|
||||
}
|
||||
|
||||
got := backfillEnvFiles("", "", []string{missing, base, stat})
|
||||
want := base + "," + stat
|
||||
if got != want {
|
||||
t.Fatalf("env files = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user