feat: build vehicle data platform and production pipeline

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}

View 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) {}

View File

@@ -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, ",") {

View File

@@ -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)
}

View File

@@ -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

View 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)
}

View 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)
}
}

View 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
}

View 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,
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}

View File

@@ -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, ",") {

View File

@@ -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
}

View File

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

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -13,6 +15,7 @@ import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
)
func TestBridgeBatchWritesKafkaThenAcks(t *testing.T) {
@@ -65,12 +68,303 @@ func TestBridgeBatchDoesNotAckWhenKafkaFails(t *testing.T) {
}
}
func TestBridgeBatchRecordsMetrics(t *testing.T) {
func TestBridgeBatchProjectsFieldsFromCanonicalRawAndAcksOnce(t *testing.T) {
raw := envelope.FrameEnvelope{
EventID: "raw-event-1",
EventKind: envelope.EventKindRaw,
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "LTEST000000000001",
EventTimeMS: 1_700_000_000_000,
ReceivedAtMS: 1_700_000_000_100,
ParseStatus: envelope.ParseOK,
ParsedFields: map[string]any{
"jt808.location.speed_kmh": 52.3,
"jt808.location.total_mileage_km": 12345.6,
},
}
payload, err := raw.MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
acked := 0
writer := &recordingBridgeWriter{}
registry := metrics.NewRegistry()
err = bridgeBatchWithProjection(
context.Background(),
registry,
writer,
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
map[string]string{topics.RawJT808: topics.RawJT808},
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
true,
)
if err != nil {
t.Fatalf("bridgeBatchWithProjection() error = %v", err)
}
if acked != 1 {
t.Fatalf("acks = %d, want exactly one ack for raw plus derived fields", acked)
}
if len(writer.messages) != 2 {
t.Fatalf("kafka messages = %d, want raw and fields", len(writer.messages))
}
byTopic := map[string]kafka.Message{}
for _, message := range writer.messages {
byTopic[message.Topic] = message
}
if len(byTopic[topics.RawJT808].Value) == 0 || len(byTopic[topics.FieldsJT808].Value) == 0 {
t.Fatalf("projected topics = %#v", byTopic)
}
var fields envelope.FrameEnvelope
if err := json.Unmarshal(byTopic[topics.FieldsJT808].Value, &fields); err != nil {
t.Fatal(err)
}
if fields.EventKind != envelope.EventKindFields || fields.SourceEventID != raw.EventID {
t.Fatalf("fields envelope = %#v", fields)
}
if got := fields.Fields["jt808.location.total_mileage_km"]; got != 12345.6 {
t.Fatalf("projected mileage = %#v", got)
}
metricText := registry.Render()
for _, want := range []string{
`vehicle_bridge_fields_projection_total{protocol="JT808",status="published"} 1`,
`vehicle_bridge_fields_projection_count{protocol="JT808",status="published"} 2`,
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
} {
if !strings.Contains(metricText, want) {
t.Fatalf("projection metric missing %s:\n%s", want, metricText)
}
}
}
func TestBridgeBatchDoesNotAckRawWhenDerivedFieldsWriteFails(t *testing.T) {
raw := envelope.FrameEnvelope{
EventKind: envelope.EventKindRaw,
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "LTEST000000000001",
EventTimeMS: 1_700_000_000_000,
ReceivedAtMS: 1_700_000_000_100,
ParseStatus: envelope.ParseOK,
ParsedFields: map[string]any{"jt808.location.total_mileage_km": 12345.6},
}
payload, err := raw.MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
acked := 0
writer := &recordingBridgeWriter{topicErr: map[string]error{topics.FieldsJT808: errors.New("fields unavailable")}}
err = bridgeBatchWithProjection(
context.Background(),
nil,
writer,
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
map[string]string{topics.RawJT808: topics.RawJT808},
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
true,
)
if err == nil {
t.Fatal("bridgeBatchWithProjection() error = nil, want fields Kafka error")
}
if acked != 0 {
t.Fatalf("acks = %d, want raw left pending until both Kafka outputs succeed", acked)
}
if len(writer.messages) != 1 || writer.messages[0].Topic != topics.RawJT808 {
t.Fatalf("successful kafka messages = %#v, want only raw before replay", writer.messages)
}
}
func TestBridgeBatchSkipsFieldsProjectionForNonRealtimeRaw(t *testing.T) {
raw := envelope.FrameEnvelope{
EventKind: envelope.EventKindRaw,
Protocol: envelope.ProtocolJT808,
MessageID: "0x0100",
Phone: "13307795425",
EventTimeMS: 1_700_000_000_000,
ReceivedAtMS: 1_700_000_000_100,
ParseStatus: envelope.ParseOK,
}
payload, err := raw.MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
acked := 0
writer := &recordingBridgeWriter{}
registry := metrics.NewRegistry()
err = bridgeBatchWithProjection(
context.Background(),
registry,
writer,
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
map[string]string{topics.RawJT808: topics.RawJT808},
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
true,
)
if err != nil {
t.Fatalf("bridgeBatchWithProjection() error = %v", err)
}
if acked != 1 || len(writer.messages) != 1 || writer.messages[0].Topic != topics.RawJT808 {
t.Fatalf("acks=%d messages=%#v", acked, writer.messages)
}
if text := registry.Render(); !strings.Contains(text, `vehicle_bridge_fields_projection_total{protocol="JT808",status="skipped_non_realtime"} 1`) {
t.Fatalf("non-realtime projection metric missing:\n%s", text)
}
}
func TestBridgeBatchAcksSuccessfulTopicWhenAnotherTopicFails(t *testing.T) {
rawEnv := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
fieldsEnv := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
rawPayload, err := json.Marshal(rawEnv)
if err != nil {
t.Fatal(err)
}
fieldsPayload, err := json.Marshal(fieldsEnv)
if err != nil {
t.Fatal(err)
}
registry := metrics.NewRegistry()
writer := &recordingBridgeWriter{
topicErr: map[string]error{
"vehicle.fields.go.jt808.v1": errors.New("fields topic unavailable"),
},
}
acks := map[string]int{}
err = bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
{subject: "vehicle.fields.go.jt808.v1", data: fieldsPayload, ack: func() error {
acks["fields"]++
return nil
}},
{subject: "vehicle.raw.go.jt808.v1", data: rawPayload, ack: func() error {
acks["raw"]++
return nil
}},
}, map[string]string{
"vehicle.fields.go.jt808.v1": "vehicle.fields.go.jt808.v1",
"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1",
})
if err == nil {
t.Fatal("bridgeBatch() error = nil, want failed fields topic")
}
if len(writer.calls) != 2 {
t.Fatalf("writer calls = %d, want one call per topic", len(writer.calls))
}
if len(writer.messages) != 1 || writer.messages[0].Topic != "vehicle.raw.go.jt808.v1" {
t.Fatalf("successful kafka messages = %#v, want only raw topic", writer.messages)
}
if acks["raw"] != 1 || acks["fields"] != 0 {
t.Fatalf("acks = %#v, want raw acked and fields left unacked", acks)
}
text := registry.Render()
for _, want := range []string{
`vehicle_bridge_kafka_writes_total{status="error",topic="vehicle.fields.go.jt808.v1"} 1`,
`vehicle_bridge_kafka_writes_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("partial bridge metric missing %s:\n%s", want, text)
}
}
if strings.Contains(text, `vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.fields.go.jt808.v1"}`) {
t.Fatalf("failed topic should not be acked:\n%s", text)
}
}
func TestBridgeBatchAcksAndDropsUnroutedSubject(t *testing.T) {
writer := &recordingBridgeWriter{}
acked := 0
registry := metrics.NewRegistry()
err := bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
acked++
return nil
}},
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
if err != nil {
t.Fatalf("bridgeBatch() error = %v", err)
}
if acked != 1 {
t.Fatalf("acks = %d, want unrouted message acked", acked)
}
if len(writer.messages) != 0 {
t.Fatalf("kafka writes = %d, want 0", len(writer.messages))
}
text := registry.Render()
for _, want := range []string{
`vehicle_bridge_messages_total{status="route_error",subject="vehicle.unconfigured.v1"} 1`,
`vehicle_bridge_nats_acks_total{status="dropped_route_error",subject="vehicle.unconfigured.v1"} 1`,
`vehicle_bridge_batch_pending_kafka_messages 0`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metrics missing %s:\n%s", want, text)
}
}
}
func TestBridgeBatchKeepsValidMessagesWhenUnroutedSubjectIsPresent(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
payload, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
writer := &recordingBridgeWriter{}
acked := map[string]int{}
err = bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
acked["unknown"]++
return nil
}},
{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error {
acked["valid"]++
return nil
}},
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
if err != nil {
t.Fatalf("bridgeBatch() error = %v", err)
}
if len(writer.messages) != 1 {
t.Fatalf("kafka writes = %d, want 1 valid message", len(writer.messages))
}
if writer.messages[0].Topic != "vehicle.raw.go.jt808.v1" {
t.Fatalf("topic = %q", writer.messages[0].Topic)
}
if acked["unknown"] != 1 || acked["valid"] != 1 {
t.Fatalf("acks = %#v, want both messages acked", acked)
}
}
func TestBridgeBatchReturnsErrorWhenUnroutedAckFails(t *testing.T) {
writer := &recordingBridgeWriter{}
err := bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
return errors.New("nats ack unavailable")
}},
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
if err == nil {
t.Fatal("bridgeBatch() error = nil, want ack error")
}
if len(writer.messages) != 0 {
t.Fatalf("kafka writes = %d, want 0", len(writer.messages))
}
}
func TestBridgeBatchRecordsMetrics(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "13307795425",
MessageID: "0x0200",
ReceivedAtMS: time.Now().Add(-20 * time.Millisecond).UnixMilli(),
}
payload, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
registry := metrics.NewRegistry()
writer := &recordingBridgeWriter{}
@@ -83,8 +377,16 @@ func TestBridgeBatchRecordsMetrics(t *testing.T) {
text := registry.Render()
for _, want := range []string{
`vehicle_bridge_messages_total{status="received",subject="vehicle.raw.jt808.v1"} 1`,
`vehicle_bridge_last_message_unix_seconds{status="received",subject="vehicle.raw.jt808.v1"} `,
`vehicle_bridge_kafka_writes_total{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
`vehicle_bridge_last_kafka_write_unix_seconds{status="ok",topic="vehicle.raw.jt808.v1"} `,
`vehicle_bridge_kafka_write_duration_ms_histogram_count{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
`vehicle_bridge_kafka_e2e_duration_ms_histogram_count{topic="vehicle.raw.jt808.v1"} 1`,
`vehicle_bridge_kafka_e2e_recent_p99_ms{topic="vehicle.raw.jt808.v1"} `,
`vehicle_bridge_kafka_e2e_recent_samples{topic="vehicle.raw.jt808.v1"} `,
`vehicle_bridge_last_kafka_e2e_unix_seconds{topic="vehicle.raw.jt808.v1"} `,
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.jt808.v1"} 1`,
`vehicle_bridge_last_ack_unix_seconds{status="ok",subject="vehicle.raw.jt808.v1"} `,
} {
if !strings.Contains(text, want) {
t.Fatalf("metrics missing %s:\n%s", want, text)
@@ -92,6 +394,16 @@ func TestBridgeBatchRecordsMetrics(t *testing.T) {
}
}
func TestRecordBridgeKafkaE2EDurationSkipsMissingReceiveTime(t *testing.T) {
registry := metrics.NewRegistry()
recordBridgeKafkaE2EDuration(registry, "vehicle.raw.go.jt808.v1", 0)
if text := registry.Render(); strings.Contains(text, "vehicle_bridge_kafka_e2e_duration_ms_histogram") || strings.Contains(text, "vehicle_bridge_kafka_e2e_recent") {
t.Fatalf("e2e metric should be skipped when received_at_ms is missing:\n%s", text)
}
}
func TestBridgeBatchExposesPendingAndDurationMetrics(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
payload, err := json.Marshal(env)
@@ -135,6 +447,73 @@ func TestBridgeBatchExposesPendingAndDurationMetrics(t *testing.T) {
}
}
func TestBridgeBatchPendingAggregatesConcurrentWorkers(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
payload, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
registry := metrics.NewRegistry()
firstStarted := make(chan struct{})
secondStarted := make(chan struct{})
release := make(chan struct{})
writerFor := func(started chan struct{}) *recordingBridgeWriter {
return &recordingBridgeWriter{
onWrite: func() {
close(started)
<-release
},
}
}
messages := func(count int) []bridgeMessage {
out := make([]bridgeMessage, 0, count)
for i := 0; i < count; i++ {
out = append(out, bridgeMessage{subject: "vehicle.raw.go.gb32960.v1", data: payload, ack: func() error { return nil }})
}
return out
}
route := map[string]string{"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1"}
var wg sync.WaitGroup
wg.Add(2)
var firstErr error
var secondErr error
go func() {
defer wg.Done()
firstErr = bridgeBatch(context.Background(), registry, writerFor(firstStarted), messages(2), route)
}()
go func() {
defer wg.Done()
secondErr = bridgeBatch(context.Background(), registry, writerFor(secondStarted), messages(3), route)
}()
<-firstStarted
<-secondStarted
text := registry.Render()
for _, want := range []string{
`vehicle_bridge_batch_pending_messages 5`,
`vehicle_bridge_batch_pending_kafka_messages 5`,
} {
if !strings.Contains(text, want) {
t.Fatalf("aggregate pending bridge metric missing %s during concurrent writes:\n%s", want, text)
}
}
close(release)
wg.Wait()
if firstErr != nil || secondErr != nil {
t.Fatalf("bridgeBatch errors = %v / %v", firstErr, secondErr)
}
text = registry.Render()
for _, want := range []string{
`vehicle_bridge_batch_pending_messages 0`,
`vehicle_bridge_batch_pending_kafka_messages 0`,
} {
if !strings.Contains(text, want) {
t.Fatalf("aggregate pending bridge metric should reset after concurrent writes, missing %s:\n%s", want, text)
}
}
}
func TestRecordNATSConsumerInfoMetrics(t *testing.T) {
registry := metrics.NewRegistry()
cfg := config{NATSStream: "VEHICLE_INGEST", NATSDurable: "vehicle-kafka-bridge"}
@@ -157,8 +536,45 @@ func TestRecordNATSConsumerInfoMetrics(t *testing.T) {
}
}
func TestIsBridgeShutdownFetchError(t *testing.T) {
if isBridgeShutdownFetchError(context.Background(), errors.New("temporary nats failure")) {
t.Fatal("temporary failure should not be treated as shutdown")
}
if !isBridgeShutdownFetchError(context.Background(), nats.ErrConnectionClosed) {
t.Fatal("connection closed should stop worker without noisy error log")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if !isBridgeShutdownFetchError(ctx, errors.New("any fetch error after cancellation")) {
t.Fatal("cancelled context should stop worker without noisy error log")
}
}
func TestIsTransientBridgeFetchError(t *testing.T) {
for _, err := range []error{
errors.New("nats: disconnected during fetch"),
errors.New("nats: connection closed"),
errors.New("read tcp: connection reset by peer"),
errors.New("temporary network unavailable"),
errors.New("i/o timeout"),
} {
if !isTransientBridgeFetchError(err) {
t.Fatalf("isTransientBridgeFetchError(%v) = false, want true", err)
}
}
if isTransientBridgeFetchError(errors.New("permission denied")) {
t.Fatal("non-transient bridge fetch error should stay non-transient")
}
if isTransientBridgeFetchError(nil) {
t.Fatal("nil should not be transient")
}
}
func TestLoadConfigDefaultsToGoSubjectRoutes(t *testing.T) {
cfg := loadConfig()
if err := cfg.Validate(); err != nil {
t.Fatalf("default config Validate() error = %v", err)
}
want := map[string]string{
"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1",
@@ -176,6 +592,75 @@ func TestLoadConfigDefaultsToGoSubjectRoutes(t *testing.T) {
t.Fatalf("route[%q] = %q, want %q; route=%#v", subject, got, topic, cfg.Route)
}
}
if !cfg.DeriveFieldsFromRaw {
t.Fatal("DeriveFieldsFromRaw = false, want canonical raw projection enabled by default")
}
for subject, wantTopic := range map[string]string{
topics.RawGB32960: topics.FieldsGB32960,
topics.RawJT808: topics.FieldsJT808,
topics.RawYutongMQTT: topics.FieldsYutongMQTT,
} {
if got := cfg.RawFieldRoutes[subject].Topic; got != wantTopic {
t.Fatalf("RawFieldRoutes[%q].Topic = %q, want %q", subject, got, wantTopic)
}
}
}
func TestConfigValidateRejectsRawFieldsSubjectOverlap(t *testing.T) {
cfg := config{
RawRoutes: []subjectRoute{
{Subject: "vehicle.same.jt808", Topic: "vehicle.raw.go.jt808.v1"},
},
FieldsRoutes: []subjectRoute{
{Subject: "vehicle.same.jt808", Topic: "vehicle.fields.go.jt808.v1"},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() error = nil, want subject overlap rejection")
}
if !strings.Contains(err.Error(), "nats subject") {
t.Fatalf("Validate() error = %q, want nats subject hint", err)
}
}
func TestConfigValidateRejectsKnownProtocolTopicMismatch(t *testing.T) {
cfg := config{
RawRoutes: []subjectRoute{
{Protocol: envelope.ProtocolJT808, Subject: "vehicle.raw.go.jt808.v1", Topic: "vehicle.raw.go.gb32960.v1"},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() error = nil, want known protocol topic mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("Validate() error = %q, want protocol mismatch hint", err)
}
}
func TestConfigValidateRejectsFieldsRouteToRawKafkaTopic(t *testing.T) {
cfg := config{
RawRoutes: []subjectRoute{
{Subject: "vehicle.raw.go.jt808.v1", Topic: "vehicle.raw.go.jt808.v1"},
},
FieldsRoutes: []subjectRoute{
{Subject: "vehicle.fields.go.jt808.v1", Topic: "vehicle.raw.go.jt808.v1"},
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() error = nil, want fields topic family rejection")
}
if !strings.Contains(err.Error(), "fields kafka topic") {
t.Fatalf("Validate() error = %q, want fields kafka topic hint", err)
}
}
func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
@@ -187,11 +672,27 @@ func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
if got, want := cfg.StreamEnsureWait, 60*time.Second; got != want {
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
}
if got, want := cfg.KafkaBatchTimeout, 20*time.Millisecond; got != want {
t.Fatalf("KafkaBatchTimeout = %v, want %v", got, want)
}
if got, want := cfg.KafkaWriteConcurrency, 6; got != want {
t.Fatalf("KafkaWriteConcurrency = %d, want %d", got, want)
}
if got, want := cfg.FetchWait, 20*time.Millisecond; got != want {
t.Fatalf("FetchWait = %v, want %v", got, want)
}
if got, want := cfg.Workers, 4; got != want {
t.Fatalf("Workers = %d, want %d", got, want)
}
}
func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
t.Setenv("NATS_STREAM_MAX_BYTES", "1073741824")
t.Setenv("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", "90")
t.Setenv("BRIDGE_KAFKA_BATCH_TIMEOUT_MS", "35")
t.Setenv("BRIDGE_KAFKA_WRITE_CONCURRENCY", "4")
t.Setenv("BRIDGE_FETCH_WAIT_MS", "45")
t.Setenv("BRIDGE_WORKERS", "6")
cfg := loadConfig()
@@ -201,6 +702,57 @@ func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
if got, want := cfg.StreamEnsureWait, 90*time.Second; got != want {
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
}
if got, want := cfg.KafkaBatchTimeout, 35*time.Millisecond; got != want {
t.Fatalf("KafkaBatchTimeout = %v, want %v", got, want)
}
if got, want := cfg.KafkaWriteConcurrency, 4; got != want {
t.Fatalf("KafkaWriteConcurrency = %d, want %d", got, want)
}
if got, want := cfg.FetchWait, 45*time.Millisecond; got != want {
t.Fatalf("FetchWait = %v, want %v", got, want)
}
if got, want := cfg.Workers, 6; got != want {
t.Fatalf("Workers = %d, want %d", got, want)
}
}
func TestRecordBridgeConfigMetrics(t *testing.T) {
registry := metrics.NewRegistry()
recordBridgeConfigMetrics(registry, config{
KafkaBatchTimeout: 35 * time.Millisecond,
KafkaWriteConcurrency: 4,
BatchSize: 600,
FetchWait: 20 * time.Millisecond,
Workers: 6,
DeriveFieldsFromRaw: true,
})
text := registry.Render()
for _, want := range []string{
`vehicle_bridge_config{setting="batch_size"} 600`,
`vehicle_bridge_config{setting="fetch_wait_ms"} 20`,
`vehicle_bridge_config{setting="kafka_batch_timeout_ms"} 35`,
`vehicle_bridge_config{setting="kafka_write_concurrency"} 4`,
`vehicle_bridge_config{setting="workers"} 6`,
`vehicle_bridge_config{setting="derive_fields_from_raw_enabled"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("bridge config metric missing %s:\n%s", want, text)
}
}
}
func TestNewKafkaWriterUsesConfiguredBatchTimeout(t *testing.T) {
writer := newKafkaWriter(config{
KafkaBrokers: []string{"127.0.0.1:9092"},
KafkaBatchTimeout: 17 * time.Millisecond,
})
defer writer.Close()
if got, want := writer.BatchTimeout, 17*time.Millisecond; got != want {
t.Fatalf("BatchTimeout = %v, want %v", got, want)
}
}
func TestLoadConfigIncludesUnifiedOnlyWhenExplicitlyConfigured(t *testing.T) {
@@ -215,8 +767,11 @@ func TestLoadConfigIncludesUnifiedOnlyWhenExplicitlyConfigured(t *testing.T) {
}
type recordingBridgeWriter struct {
mu sync.Mutex
messages []kafka.Message
calls [][]kafka.Message
err error
topicErr map[string]error
onWrite func()
}
@@ -224,9 +779,72 @@ func (w *recordingBridgeWriter) WriteMessages(_ context.Context, messages ...kaf
if w.onWrite != nil {
w.onWrite()
}
w.mu.Lock()
defer w.mu.Unlock()
call := append([]kafka.Message(nil), messages...)
w.calls = append(w.calls, call)
if len(messages) > 0 && w.topicErr != nil {
if err := w.topicErr[messages[0].Topic]; err != nil {
return err
}
}
if w.err != nil {
return w.err
}
w.messages = append(w.messages, messages...)
return nil
}
type concurrentBridgeWriter struct {
started chan string
release chan struct{}
active atomic.Int32
max atomic.Int32
}
func (w *concurrentBridgeWriter) WriteMessages(_ context.Context, messages ...kafka.Message) error {
active := w.active.Add(1)
defer w.active.Add(-1)
for {
current := w.max.Load()
if active <= current || w.max.CompareAndSwap(current, active) {
break
}
}
w.started <- messages[0].Topic
<-w.release
return nil
}
func TestBridgeWritesIndependentKafkaTopicsConcurrently(t *testing.T) {
writer := &concurrentBridgeWriter{
started: make(chan string, 2),
release: make(chan struct{}),
}
messages := []bridgeMessage{
{subject: topics.RawJT808, data: []byte(`{"protocol":"JT808"}`), ack: func() error { return nil }},
{subject: topics.RawGB32960, data: []byte(`{"protocol":"GB32960"}`), ack: func() error { return nil }},
}
route := map[string]string{
topics.RawJT808: topics.RawJT808,
topics.RawGB32960: topics.RawGB32960,
}
done := make(chan error, 1)
go func() {
done <- bridgeBatchWithProjectionConcurrency(context.Background(), nil, writer, messages, route, nil, false, 2)
}()
for i := 0; i < 2; i++ {
select {
case <-writer.started:
case <-time.After(time.Second):
t.Fatal("independent Kafka topic writes did not start concurrently")
}
}
if got := writer.max.Load(); got != 2 {
t.Fatalf("max concurrent Kafka writes = %d, want 2", got)
}
close(writer.release)
if err := <-done; err != nil {
t.Fatalf("bridgeBatchWithProjectionConcurrency() error = %v", err)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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 == "" {

View File

@@ -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)
}
}