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

View File

@@ -11,7 +11,8 @@ require (
github.com/redis/go-redis/v9 v9.17.2
github.com/segmentio/kafka-go v0.4.49
github.com/taosdata/driver-go/v3 v3.8.1
golang.org/x/text v0.35.0
github.com/xuri/excelize/v2 v2.11.0
golang.org/x/text v0.38.0
)
require (
@@ -27,9 +28,14 @@ require (
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
)

View File

@@ -46,6 +46,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -55,28 +59,39 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/taosdata/driver-go/v3 v3.8.1 h1:kkd4ABsGiU+oXDbsw/sic985LKAvnpF9Gb/TEunTnLE=
github.com/taosdata/driver-go/v3 v3.8.1/go.mod h1:S6OGOinfR0xxxaMGsvBi9cLkYxEIW1p6qqr8QJATTlg=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,215 @@
package authentication
import (
"crypto/subtle"
"fmt"
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
type Mode string
const (
ModeDisabled Mode = "disabled"
ModeObserve Mode = "observe"
ModeEnforce Mode = "enforce"
)
const (
StatusAccepted = "accepted"
StatusRejected = "rejected"
StatusUnknownAccount = "unknown_account"
StatusMissingCredential = "missing_credential"
StatusUnconfigured = "unconfigured"
)
type Result struct {
Applicable bool
Allowed bool
Mode Mode
Status string
Source string
}
type Authenticator interface {
Authenticate(envelope.FrameEnvelope) Result
}
func ParseMode(value string, fallback Mode) (Mode, error) {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
value = string(fallback)
}
switch Mode(value) {
case ModeDisabled, ModeObserve, ModeEnforce:
return Mode(value), nil
default:
return "", fmt.Errorf("unsupported authentication mode %q", value)
}
}
type GB32960PlatformAuthenticator struct {
mode Mode
credentials map[string][]string
}
func NewGB32960PlatformAuthenticator(mode Mode, credentials map[string][]string) *GB32960PlatformAuthenticator {
normalized := make(map[string][]string, len(credentials))
for username, passwords := range credentials {
username = strings.TrimSpace(username)
if username == "" {
continue
}
for _, password := range passwords {
if password == "" {
continue
}
normalized[username] = append(normalized[username], password)
}
}
return &GB32960PlatformAuthenticator{mode: mode, credentials: normalized}
}
func (a *GB32960PlatformAuthenticator) Authenticate(env envelope.FrameEnvelope) Result {
if env.Protocol != envelope.ProtocolGB32960 || env.MessageID != "0x05" || a == nil || a.mode == ModeDisabled {
return Result{}
}
login := nestedMap(env.Parsed, "platform_login")
username := strings.TrimSpace(textValue(login, "username"))
password := textValue(login, "password")
status := StatusRejected
switch {
case len(a.credentials) == 0:
status = StatusUnconfigured
case username == "" || password == "":
status = StatusMissingCredential
default:
expected, ok := a.credentials[username]
if !ok || len(expected) == 0 {
status = StatusUnknownAccount
} else if anyConstantTimeEqual(expected, password) {
status = StatusAccepted
}
}
source := "none"
if status == StatusAccepted {
source = "configured"
}
return resultForMode(a.mode, status, source)
}
type JT808Authenticator struct {
mode Mode
authCode string
deviceTokens JT808DeviceTokenProvider
}
type JT808DeviceTokenProvider interface {
JT808AuthToken(phone string) (string, bool)
}
func NewJT808Authenticator(mode Mode, authCode string, deviceTokens JT808DeviceTokenProvider) *JT808Authenticator {
return &JT808Authenticator{mode: mode, authCode: authCode, deviceTokens: deviceTokens}
}
func (a *JT808Authenticator) Authenticate(env envelope.FrameEnvelope) Result {
if env.Protocol != envelope.ProtocolJT808 || env.MessageID != "0x0102" || a == nil || a.mode == ModeDisabled {
return Result{}
}
token := textValue(nestedMap(env.Parsed, "authentication"), "token")
status := StatusRejected
source := "none"
switch {
case token == "":
status = StatusMissingCredential
default:
if a.authCode != "" && constantTimeEqual(a.authCode, token) {
status = StatusAccepted
source = "configured"
break
}
deviceToken, knownDevice := "", false
if a.deviceTokens != nil {
deviceToken, knownDevice = a.deviceTokens.JT808AuthToken(env.Phone)
}
if knownDevice && deviceToken != "" && constantTimeEqual(deviceToken, token) {
status = StatusAccepted
source = "device"
} else if a.authCode == "" && !knownDevice {
status = StatusUnconfigured
}
}
return resultForMode(a.mode, status, source)
}
func Apply(env *envelope.FrameEnvelope, result Result) {
if env == nil || !result.Applicable {
return
}
env.AuthenticationMode = string(result.Mode)
env.AuthenticationStatus = result.Status
env.AuthenticationEnforced = result.Mode == ModeEnforce
}
// RedactParsedCredentials removes convenience copies of secrets before parsed
// fields are flattened and published. The original protocol frame remains in
// raw_hex for restricted forensic access.
func RedactParsedCredentials(env *envelope.FrameEnvelope) {
if env == nil || env.Protocol != envelope.ProtocolGB32960 {
return
}
login := nestedMap(env.Parsed, "platform_login")
if login == nil {
return
}
if password := textValue(login, "password"); password != "" {
login["password_present"] = true
}
delete(login, "password")
}
func resultForMode(mode Mode, status string, source ...string) Result {
allowed := status == StatusAccepted || mode != ModeEnforce
credentialSource := "none"
if len(source) > 0 && strings.TrimSpace(source[0]) != "" {
credentialSource = strings.TrimSpace(source[0])
}
return Result{Applicable: true, Allowed: allowed, Mode: mode, Status: status, Source: credentialSource}
}
func constantTimeEqual(expected string, actual string) bool {
if len(expected) != len(actual) {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func anyConstantTimeEqual(expected []string, actual string) bool {
matched := 0
for _, candidate := range expected {
if len(candidate) == len(actual) {
matched |= subtle.ConstantTimeCompare([]byte(candidate), []byte(actual))
}
}
return matched == 1
}
func nestedMap(parent map[string]any, key string) map[string]any {
if parent == nil {
return nil
}
value, _ := parent[key].(map[string]any)
return value
}
func textValue(values map[string]any, key string) string {
if values == nil {
return ""
}
value, ok := values[key]
if !ok || value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}

View File

@@ -0,0 +1,111 @@
package authentication
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestGB32960PlatformAuthenticatorModes(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x05",
Parsed: map[string]any{
"platform_login": map[string]any{"username": "platform-a", "password": "secret-a"},
},
}
accepted := NewGB32960PlatformAuthenticator(ModeEnforce, map[string][]string{"platform-a": {"secret-a"}}).Authenticate(env)
if !accepted.Applicable || !accepted.Allowed || accepted.Status != StatusAccepted {
t.Fatalf("accepted result = %#v", accepted)
}
rejected := NewGB32960PlatformAuthenticator(ModeEnforce, map[string][]string{"platform-a": {"other"}}).Authenticate(env)
if !rejected.Applicable || rejected.Allowed || rejected.Status != StatusRejected {
t.Fatalf("enforced rejected result = %#v", rejected)
}
observed := NewGB32960PlatformAuthenticator(ModeObserve, map[string][]string{"platform-a": {"other"}}).Authenticate(env)
if !observed.Allowed || observed.Status != StatusRejected {
t.Fatalf("observed rejected result = %#v", observed)
}
}
func TestGB32960PlatformAuthenticatorReportsUnknownAccount(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x05",
Parsed: map[string]any{
"platform_login": map[string]any{"username": "unknown", "password": "secret"},
},
}
result := NewGB32960PlatformAuthenticator(ModeEnforce, map[string][]string{"known": {"secret"}}).Authenticate(env)
if result.Allowed || result.Status != StatusUnknownAccount {
t.Fatalf("result = %#v", result)
}
}
func TestJT808AuthenticatorValidatesAuthenticationFrame(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0102",
Parsed: map[string]any{
"authentication": map[string]any{"token": "issued-code"},
},
}
accepted := NewJT808Authenticator(ModeEnforce, "issued-code", nil).Authenticate(env)
if !accepted.Allowed || accepted.Status != StatusAccepted {
t.Fatalf("accepted result = %#v", accepted)
}
rejected := NewJT808Authenticator(ModeEnforce, "different-code", nil).Authenticate(env)
if rejected.Allowed || rejected.Status != StatusRejected {
t.Fatalf("rejected result = %#v", rejected)
}
}
func TestJT808AuthenticatorAcceptsDeviceSnapshotToken(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0102",
Phone: "013307795425",
Parsed: map[string]any{
"authentication": map[string]any{"token": "device-code"},
},
}
provider := staticJT808DeviceTokens{"13307795425": "device-code"}
result := NewJT808Authenticator(ModeEnforce, "configured-code", provider).Authenticate(env)
if !result.Allowed || result.Status != StatusAccepted || result.Source != "device" {
t.Fatalf("device token result = %#v", result)
}
}
type staticJT808DeviceTokens map[string]string
func (p staticJT808DeviceTokens) JT808AuthToken(phone string) (string, bool) {
phone = strings.TrimLeft(phone, "0")
token, ok := p[phone]
return token, ok
}
func TestRedactParsedCredentialsRemovesGB32960Password(t *testing.T) {
login := map[string]any{"username": "platform-a", "password": "secret-a"}
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
Parsed: map[string]any{"platform_login": login},
}
RedactParsedCredentials(&env)
if _, exists := login["password"]; exists {
t.Fatalf("password remained in parsed login: %#v", login)
}
if login["password_present"] != true {
t.Fatalf("password presence marker missing: %#v", login)
}
}
func TestParseModeRejectsUnknownValue(t *testing.T) {
if _, err := ParseMode("strict-ish", ModeObserve); err == nil {
t.Fatal("expected unsupported mode error")
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,14 @@ const (
ParseBadFrame ParseStatus = "BAD_FRAME"
)
type EventKind string
const (
EventKindRaw EventKind = "RAW"
EventKindFields EventKind = "FIELDS"
EventKindUnified EventKind = "UNIFIED"
)
const (
FieldSpeedKMH = "speed_kmh"
FieldTotalMileageKM = "total_mileage_km"
@@ -33,27 +41,36 @@ const (
)
type FrameEnvelope struct {
EventID string `json:"event_id"`
TraceID string `json:"trace_id"`
Protocol Protocol `json:"protocol"`
MessageID string `json:"message_id"`
Sequence uint16 `json:"sequence"`
VIN string `json:"vin,omitempty"`
VehicleKeyHint string `json:"vehicle_key,omitempty"`
Phone string `json:"phone,omitempty"`
DeviceID string `json:"device_id,omitempty"`
Plate string `json:"plate,omitempty"`
SourceEndpoint string `json:"source_endpoint,omitempty"`
EventTimeMS int64 `json:"event_time_ms"`
ReceivedAtMS int64 `json:"received_at_ms"`
RawHex string `json:"raw_hex,omitempty"`
RawText string `json:"raw_text,omitempty"`
Parsed map[string]any `json:"parsed,omitempty"`
ParsedFields map[string]any `json:"parsed_fields,omitempty"`
ParsedFieldTypes map[string]string `json:"parsed_field_types,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
ParseStatus ParseStatus `json:"parse_status"`
ParseError string `json:"parse_error,omitempty"`
EventID string `json:"event_id"`
TraceID string `json:"trace_id"`
EventKind EventKind `json:"event_kind,omitempty"`
SourceEventID string `json:"source_event_id,omitempty"`
FieldMapping string `json:"field_mapping,omitempty"`
Protocol Protocol `json:"protocol"`
MessageID string `json:"message_id"`
Sequence uint16 `json:"sequence"`
VIN string `json:"vin,omitempty"`
VehicleKeyHint string `json:"vehicle_key,omitempty"`
Phone string `json:"phone,omitempty"`
DeviceID string `json:"device_id,omitempty"`
Plate string `json:"plate,omitempty"`
SourceEndpoint string `json:"source_endpoint,omitempty"`
SourceCode string `json:"source_code,omitempty"`
PlatformName string `json:"platform_name,omitempty"`
SourceKind string `json:"source_kind,omitempty"`
AuthenticationMode string `json:"authentication_mode,omitempty"`
AuthenticationStatus string `json:"authentication_status,omitempty"`
AuthenticationEnforced bool `json:"authentication_enforced,omitempty"`
EventTimeMS int64 `json:"event_time_ms"`
ReceivedAtMS int64 `json:"received_at_ms"`
RawHex string `json:"raw_hex,omitempty"`
RawText string `json:"raw_text,omitempty"`
Parsed map[string]any `json:"parsed,omitempty"`
ParsedFields map[string]any `json:"parsed_fields,omitempty"`
ParsedFieldTypes map[string]string `json:"parsed_field_types,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
ParseStatus ParseStatus `json:"parse_status"`
ParseError string `json:"parse_error,omitempty"`
}
func (e FrameEnvelope) VehicleKey() string {
@@ -93,5 +110,8 @@ func (e FrameEnvelope) MarshalJSONBytes() ([]byte, error) {
if e.ParseStatus == "" {
e.ParseStatus = ParseOK
}
if e.EventKind == "" {
e.EventKind = EventKindRaw
}
return json.Marshal(e)
}

View File

@@ -3,6 +3,7 @@ package envelope
import (
"encoding/json"
"testing"
"time"
)
func TestFrameEnvelopeVehicleKeyPrefersVIN(t *testing.T) {
@@ -52,4 +53,160 @@ func TestFrameEnvelopeMarshalDefaults(t *testing.T) {
if decoded.ParseStatus != ParseOK {
t.Fatalf("parse status = %q", decoded.ParseStatus)
}
if decoded.EventKind != EventKindRaw {
t.Fatalf("event kind = %q, want %q", decoded.EventKind, EventKindRaw)
}
}
func TestNormalizedEventTimeMSFallsBackToReceivedWhenEventIsFarFuture(t *testing.T) {
received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC).UnixMilli()
event := time.Date(2026, 7, 13, 9, 30, 0, 0, time.UTC).UnixMilli()
got, ok := NormalizedEventTimeMS(FrameEnvelope{EventTimeMS: event, ReceivedAtMS: received})
if !ok {
t.Fatal("NormalizedEventTimeMS() ok = false")
}
if got != received {
t.Fatalf("normalized event time = %d, want received %d", got, received)
}
_, reason, ok := NormalizedEventTimeMSWithReason(FrameEnvelope{EventTimeMS: event, ReceivedAtMS: received})
if !ok || reason != EventTimeReasonReceivedFutureEvent {
t.Fatalf("reason = %q ok=%v, want future fallback", reason, ok)
}
}
func TestNormalizedEventTimeMSKeepsSmallFutureSkew(t *testing.T) {
received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC).UnixMilli()
event := time.Date(2026, 7, 12, 9, 35, 0, 0, time.UTC).UnixMilli()
got, ok := NormalizedEventTimeMS(FrameEnvelope{EventTimeMS: event, ReceivedAtMS: received})
if !ok {
t.Fatal("NormalizedEventTimeMS() ok = false")
}
if got != event {
t.Fatalf("normalized event time = %d, want event %d", got, event)
}
}
func TestIsRealtimeTelemetryFrameRejectsKnownNonRealtimeMessageIDs(t *testing.T) {
fields := map[string]any{
FieldLatitude: 30.590151,
FieldLongitude: 121.069881,
FieldTotalMileageKM: 10241.2,
}
tests := []FrameEnvelope{
{Protocol: ProtocolJT808, MessageID: "0x0100", Fields: fields, ParseStatus: ParseOK},
{Protocol: ProtocolGB32960, MessageID: "0x01", Fields: fields, Parsed: map[string]any{"data_units": []any{}}, ParseStatus: ParseOK},
}
for _, env := range tests {
if IsRealtimeTelemetryFrame(env) {
t.Fatalf("IsRealtimeTelemetryFrame(%s/%s) = true, want false", env.Protocol, env.MessageID)
}
}
}
func TestIsRealtimeTelemetryFrameAcceptsKnownRealtimeMessageIDs(t *testing.T) {
tests := []FrameEnvelope{
{Protocol: ProtocolJT808, MessageID: "0x0200", ParsedFields: map[string]any{"jt808.location.speed_kmh": 1}, ParseStatus: ParseOK},
{Protocol: ProtocolGB32960, MessageID: "0x02", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 1}, ParseStatus: ParseOK},
{Protocol: ProtocolGB32960, MessageID: "0x03", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 1}, ParseStatus: ParseOK},
}
for _, env := range tests {
if !IsRealtimeTelemetryFrame(env) {
t.Fatalf("IsRealtimeTelemetryFrame(%s/%s) = false, want true", env.Protocol, env.MessageID)
}
}
}
func TestIsRealtimeTelemetryFrameUsesCanonicalMQTTParsedFields(t *testing.T) {
realtime := FrameEnvelope{
Protocol: ProtocolYutongMQTT,
MessageID: "MQTT",
ParseStatus: ParseOK,
ParsedFields: map[string]any{
"yutong_mqtt.data.latitude": 30.590151,
},
}
if !IsRealtimeTelemetryFrame(realtime) {
t.Fatal("canonical MQTT data field should be classified as realtime")
}
metadataOnly := realtime
metadataOnly.ParsedFields = map[string]any{"yutong_mqtt.metadata.topic": "/ytforward/shln/3"}
if IsRealtimeTelemetryFrame(metadataOnly) {
t.Fatal("MQTT metadata-only field must not be classified as realtime")
}
}
func TestRequiresVehicleIdentity(t *testing.T) {
tests := []struct {
name string
env FrameEnvelope
want bool
}{
{
name: "gb32960 realtime upload",
env: FrameEnvelope{Protocol: ProtocolGB32960, MessageID: "0x02", Parsed: map[string]any{"data_units": []any{map[string]any{"name": "vehicle"}}}, ParseStatus: ParseOK},
want: true,
},
{
name: "gb32960 platform login",
env: FrameEnvelope{Protocol: ProtocolGB32960, MessageID: "0x05", ParseStatus: ParseOK},
want: false,
},
{
name: "gb32960 command response",
env: FrameEnvelope{Protocol: ProtocolGB32960, MessageID: "0x07", ParseStatus: ParseOK},
want: false,
},
{
name: "jt808 registration",
env: FrameEnvelope{Protocol: ProtocolJT808, MessageID: "0x0100", ParseStatus: ParseOK},
want: true,
},
{
name: "jt808 location",
env: FrameEnvelope{Protocol: ProtocolJT808, MessageID: "0x0200", Parsed: map[string]any{"location": map[string]any{"speed_kmh": 1}}, ParseStatus: ParseOK},
want: true,
},
{
name: "yutong empty data",
env: FrameEnvelope{Protocol: ProtocolYutongMQTT, MessageID: "MQTT", Parsed: map[string]any{"data": map[string]any{}}, ParseStatus: ParseOK},
want: false,
},
{
name: "yutong telemetry data",
env: FrameEnvelope{Protocol: ProtocolYutongMQTT, MessageID: "MQTT", Parsed: map[string]any{"data": map[string]any{"TOTAL_MILEAGE": 123}}, ParseStatus: ParseOK},
want: true,
},
{
name: "bad frame",
env: FrameEnvelope{Protocol: ProtocolJT808, MessageID: "0x0200", ParseStatus: ParseBadFrame},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := RequiresVehicleIdentity(tt.env); got != tt.want {
t.Fatalf("RequiresVehicleIdentity() = %v, want %v", got, tt.want)
}
})
}
}
func TestNormalizeSourceEndpointKey(t *testing.T) {
tests := map[string]string{
"115.231.168.135:20215": "115.231.168.135",
"115.231.168.135": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"mqtt://yutong/ytforward/shln/3": "mqtt",
"MQTT://YUTONG/topic": "mqtt",
"": "",
" ": "",
}
for input, want := range tests {
if got := NormalizeSourceEndpointKey(input); got != want {
t.Fatalf("NormalizeSourceEndpointKey(%q) = %q, want %q", input, got, want)
}
}
}

View File

@@ -14,33 +14,93 @@ func IsRealtimeTelemetryFrame(env FrameEnvelope) bool {
command = strings.TrimSpace(stringAny(header["command"]))
}
}
if strings.EqualFold(command, "0x02") || strings.EqualFold(command, "0x03") {
return true
if command != "" && !strings.EqualFold(command, "0x02") && !strings.EqualFold(command, "0x03") {
return false
}
_, hasDataUnits := env.Parsed["data_units"]
return hasDataUnits || hasRealtimeField(env)
return hasDataUnits || hasGB32960ParsedTelemetryField(env) || hasRealtimeField(env)
case ProtocolJT808:
if strings.EqualFold(strings.TrimSpace(env.MessageID), "0x0200") {
return true
messageID := strings.TrimSpace(env.MessageID)
if messageID != "" && !strings.EqualFold(messageID, "0x0200") {
return false
}
if _, ok := env.Parsed["location"]; ok {
return true
}
return hasRealtimeField(env)
return hasParsedFieldPrefix(env, "jt808.location.") || hasRealtimeField(env)
case ProtocolYutongMQTT:
data, ok := env.Parsed["data"].(map[string]any)
return ok && len(data) > 0
if ok && len(data) > 0 {
return true
}
return hasParsedFieldPrefix(env, "yutong_mqtt.data.") ||
hasParsedFieldPrefix(env, "yutong_mqtt.root.data.")
default:
return false
}
}
func hasGB32960ParsedTelemetryField(env FrameEnvelope) bool {
for field := range env.ParsedFields {
if !strings.HasPrefix(field, "gb32960.") {
continue
}
if strings.HasPrefix(field, "gb32960.header.") ||
strings.HasPrefix(field, "gb32960.platform.") ||
strings.HasPrefix(field, "gb32960.identity.") ||
strings.HasPrefix(field, "gb32960.device_time.") {
continue
}
return true
}
return false
}
// RequiresVehicleIdentity keeps platform/control frames out of vehicle identity
// quality metrics while preserving JT808's phone-centric registration/auth flow.
func RequiresVehicleIdentity(env FrameEnvelope) bool {
if env.ParseStatus == ParseBadFrame {
return false
}
switch env.Protocol {
case ProtocolGB32960:
return IsRealtimeTelemetryFrame(env)
case ProtocolJT808:
return true
case ProtocolYutongMQTT:
return IsRealtimeTelemetryFrame(env)
default:
return false
}
}
func hasRealtimeField(env FrameEnvelope) bool {
return env.Fields[FieldLatitude] != nil ||
if env.Fields[FieldLatitude] != nil ||
env.Fields[FieldLongitude] != nil ||
env.Fields[FieldTotalMileageKM] != nil ||
env.Fields[FieldSpeedKMH] != nil ||
env.Fields[FieldSOCPercent] != nil
env.Fields[FieldSOCPercent] != nil {
return true
}
for field := range env.ParsedFields {
if strings.HasSuffix(field, ".latitude") ||
strings.HasSuffix(field, ".longitude") ||
strings.HasSuffix(field, ".total_mileage_km") ||
strings.HasSuffix(field, ".speed_kmh") ||
strings.HasSuffix(field, ".soc_percent") {
return true
}
}
return false
}
func hasParsedFieldPrefix(env FrameEnvelope, prefix string) bool {
for field := range env.ParsedFields {
if strings.HasPrefix(field, prefix) {
return true
}
}
return false
}
func stringAny(value any) string {

View File

@@ -0,0 +1,19 @@
package envelope
import "strings"
// NormalizeSourceEndpointKey returns the stable, low-cardinality source key used
// by identity lookup and statistics source tracking.
func NormalizeSourceEndpointKey(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(endpoint), "mqtt://") {
return "mqtt"
}
if host, _, ok := strings.Cut(endpoint, ":"); ok {
return strings.TrimSpace(host)
}
return endpoint
}

View File

@@ -0,0 +1,31 @@
package envelope
import "time"
const (
MaxFutureEventSkew = 10 * time.Minute
MinPlausibleReceivedTimeMS = 1577836800000 // 2020-01-01T00:00:00Z
)
const (
EventTimeReasonEventTime = "event_time"
EventTimeReasonReceivedMissingEvent = "received_time_missing_event"
EventTimeReasonReceivedFutureEvent = "received_time_future_event"
)
func NormalizedEventTimeMS(env FrameEnvelope) (int64, bool) {
eventMS, _, ok := NormalizedEventTimeMSWithReason(env)
return eventMS, ok
}
func NormalizedEventTimeMSWithReason(env FrameEnvelope) (int64, string, bool) {
eventMS := env.EventTimeMS
receivedMS := env.ReceivedAtMS
if eventMS <= 0 {
return receivedMS, EventTimeReasonReceivedMissingEvent, receivedMS > 0
}
if receivedMS >= MinPlausibleReceivedTimeMS && eventMS > receivedMS+MaxFutureEventSkew.Milliseconds() {
return receivedMS, EventTimeReasonReceivedFutureEvent, true
}
return eventMS, EventTimeReasonEventTime, true
}

View File

@@ -13,6 +13,7 @@ import (
type AsyncConfig struct {
QueueSize int
Workers int
EnqueueTimeout time.Duration
OperationTimeout time.Duration
OnError func(error)
Metrics *metrics.Registry
@@ -20,12 +21,14 @@ type AsyncConfig struct {
}
type AsyncSink struct {
delegate Sink
jobs chan asyncJob
timeout time.Duration
onError func(error)
metrics *metrics.Registry
name string
delegate Sink
jobs chan asyncJob
enqueueTimeout time.Duration
timeout time.Duration
onError func(error)
metrics *metrics.Registry
name string
queueWait *metrics.RecentLatencyByKey
closeOnce sync.Once
closed chan struct{}
@@ -34,11 +37,13 @@ type AsyncSink struct {
}
type asyncJob struct {
kind string
env envelope.FrameEnvelope
kind string
env envelope.FrameEnvelope
enqueuedAt time.Time
}
var ErrAsyncSinkClosed = errors.New("async sink is closed")
var ErrAsyncSinkEnqueueTimeout = errors.New("async sink enqueue timeout")
var asyncSinkPublishDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
@@ -52,6 +57,12 @@ func NewAsyncSink(delegate Sink, cfg AsyncConfig) *AsyncSink {
if cfg.Workers <= 0 {
cfg.Workers = 1
}
if cfg.EnqueueTimeout == 0 {
cfg.EnqueueTimeout = time.Second
}
if cfg.EnqueueTimeout < 0 {
cfg.EnqueueTimeout = 0
}
if cfg.OperationTimeout <= 0 {
cfg.OperationTimeout = 30 * time.Second
}
@@ -59,19 +70,23 @@ func NewAsyncSink(delegate Sink, cfg AsyncConfig) *AsyncSink {
cfg.Name = "async"
}
s := &AsyncSink{
delegate: delegate,
jobs: make(chan asyncJob, cfg.QueueSize),
timeout: cfg.OperationTimeout,
onError: cfg.OnError,
metrics: cfg.Metrics,
name: cfg.Name,
closed: make(chan struct{}),
done: make(chan struct{}),
delegate: delegate,
jobs: make(chan asyncJob, cfg.QueueSize),
enqueueTimeout: cfg.EnqueueTimeout,
timeout: cfg.OperationTimeout,
onError: cfg.OnError,
metrics: cfg.Metrics,
name: cfg.Name,
queueWait: metrics.NewRecentLatencyByKey(512),
closed: make(chan struct{}),
done: make(chan struct{}),
}
s.wg.Add(cfg.Workers)
for i := 0; i < cfg.Workers; i++ {
go s.worker()
}
s.recordQueueCapacity()
s.recordWorkers("default", cfg.Workers)
go func() {
s.wg.Wait()
close(s.done)
@@ -94,7 +109,6 @@ func (s *AsyncSink) PublishFields(ctx context.Context, env envelope.FrameEnvelop
func (s *AsyncSink) Close() error {
s.closeOnce.Do(func() {
close(s.closed)
close(s.jobs)
})
<-s.done
return s.delegate.Close()
@@ -107,6 +121,14 @@ func (s *AsyncSink) enqueue(ctx context.Context, job asyncJob) error {
return ErrAsyncSinkClosed
default:
}
var timeoutC <-chan time.Time
var timer *time.Timer
if s.enqueueTimeout > 0 {
timer = time.NewTimer(s.enqueueTimeout)
timeoutC = timer.C
defer timer.Stop()
}
job.enqueuedAt = time.Now()
select {
case s.jobs <- job:
s.recordEnqueue(job.kind, "queued")
@@ -119,37 +141,58 @@ func (s *AsyncSink) enqueue(ctx context.Context, job asyncJob) error {
s.recordEnqueue(job.kind, "timeout")
s.recordQueueDepth()
return ctx.Err()
case <-timeoutC:
s.recordEnqueue(job.kind, "timeout")
s.recordQueueDepth()
return ErrAsyncSinkEnqueueTimeout
}
}
func (s *AsyncSink) worker() {
defer s.wg.Done()
for job := range s.jobs {
s.recordQueueDepth()
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
started := time.Now()
var err error
switch job.kind {
case "raw":
err = s.delegate.PublishRaw(ctx, job.env)
case "unified":
err = s.delegate.PublishUnified(ctx, job.env)
case "fields":
err = s.delegate.PublishFields(ctx, job.env)
for {
select {
case job := <-s.jobs:
s.publishJob(job)
case <-s.closed:
for {
select {
case job := <-s.jobs:
s.publishJob(job)
default:
return
}
}
}
cancel()
status := "ok"
if err != nil {
status = "error"
}
s.recordPublish(job.kind, status, time.Since(started))
if err != nil && s.onError != nil {
s.onError(err)
}
s.recordQueueDepth()
}
}
func (s *AsyncSink) publishJob(job asyncJob) {
s.recordQueueDepth()
s.recordQueueWait("default", job)
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
started := time.Now()
var err error
switch job.kind {
case "raw":
err = s.delegate.PublishRaw(ctx, job.env)
case "unified":
err = s.delegate.PublishUnified(ctx, job.env)
case "fields":
err = s.delegate.PublishFields(ctx, job.env)
}
cancel()
status := "ok"
if err != nil {
status = "error"
}
s.recordPublish(job.kind, status, time.Since(started))
if err != nil && s.onError != nil {
s.onError(err)
}
s.recordQueueDepth()
}
func (s *AsyncSink) recordEnqueue(kind string, status string) {
if s.metrics == nil {
return
@@ -184,3 +227,38 @@ func (s *AsyncSink) recordQueueDepth() {
"sink": s.name,
}, float64(len(s.jobs)))
}
func (s *AsyncSink) recordQueueCapacity() {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_queue_capacity", metrics.Labels{
"sink": s.name,
}, float64(cap(s.jobs)))
}
func (s *AsyncSink) recordQueueWait(queueName string, job asyncJob) {
if s.metrics == nil || job.enqueuedAt.IsZero() {
return
}
elapsedMS := float64(time.Since(job.enqueuedAt)) / float64(time.Millisecond)
labels := metrics.Labels{
"sink": s.name,
"queue": queueName,
"kind": job.kind,
}
s.metrics.ObserveHistogram("vehicle_async_sink_queue_wait_duration_ms_histogram", labels, asyncSinkPublishDurationBucketsMS, elapsedMS)
p99, samples := s.queueWait.Observe(queueName+"\x00"+job.kind, elapsedMS)
s.metrics.SetGauge("vehicle_async_sink_queue_wait_recent_p99_ms", labels, p99)
s.metrics.SetGauge("vehicle_async_sink_queue_wait_recent_samples", labels, float64(samples))
}
func (s *AsyncSink) recordWorkers(queueName string, workers int) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_workers", metrics.Labels{
"sink": s.name,
"queue": queueName,
}, float64(workers))
}

View File

@@ -2,6 +2,7 @@ package eventbus
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -80,12 +81,17 @@ func TestAsyncSinkRecordsQueueMetrics(t *testing.T) {
for _, want := range []string{
`vehicle_async_sink_enqueue_total{kind="raw",sink="nats",status="queued"} 1`,
`vehicle_async_sink_enqueue_total{kind="fields",sink="nats",status="queued"} 1`,
`vehicle_async_sink_queue_capacity{sink="nats"} 2`,
`vehicle_async_sink_queue_depth{sink="nats"}`,
`vehicle_async_sink_workers{queue="default",sink="nats"} 1`,
`vehicle_async_sink_publish_total{kind="raw",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_total{kind="fields",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_duration_ms_histogram_bucket{le="+Inf",kind="raw",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_duration_ms_histogram_count{kind="raw",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_duration_ms_histogram_sum{kind="raw",sink="nats",status="ok"}`,
`vehicle_async_sink_queue_wait_duration_ms_histogram_count{kind="raw",queue="default",sink="nats"} 1`,
`vehicle_async_sink_queue_wait_recent_p99_ms{kind="raw",queue="default",sink="nats"}`,
`vehicle_async_sink_queue_wait_recent_samples{kind="raw",queue="default",sink="nats"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("async sink metric missing %s:\n%s", want, text)
@@ -135,6 +141,145 @@ func TestAsyncSinkRecordsEnqueueTimeoutWhenQueueIsFull(t *testing.T) {
delegate.release()
}
func TestAsyncSinkEnqueueTimeoutDoesNotRequireCallerDeadline(t *testing.T) {
registry := metrics.NewRegistry()
delegate := newBlockingSink()
sink := NewAsyncSink(delegate, AsyncConfig{
QueueSize: 1,
Workers: 1,
EnqueueTimeout: 10 * time.Millisecond,
OperationTimeout: time.Second,
Metrics: registry,
Name: "nats",
})
defer sink.Close()
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("first PublishRaw() error = %v", err)
}
select {
case <-delegate.rawStarted:
case <-time.After(time.Second):
t.Fatal("delegate raw publish was not started")
}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("second PublishFields() error = %v", err)
}
if err := sink.PublishUnified(context.Background(), env); !errors.Is(err, ErrAsyncSinkEnqueueTimeout) {
t.Fatalf("third PublishUnified() error = %v, want ErrAsyncSinkEnqueueTimeout", err)
}
text := registry.Render()
for _, want := range []string{
`vehicle_async_sink_enqueue_total{kind="unified",sink="nats",status="timeout"} 1`,
`vehicle_async_sink_queue_depth{sink="nats"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("async sink enqueue timeout metric missing %s:\n%s", want, text)
}
}
delegate.release()
}
func TestAsyncSinkCloseDoesNotPanicWithConcurrentBlockedEnqueue(t *testing.T) {
delegate := newBlockingSink()
sink := NewAsyncSink(delegate, AsyncConfig{
QueueSize: 1,
Workers: 1,
EnqueueTimeout: 20 * time.Millisecond,
OperationTimeout: time.Second,
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("first PublishRaw() error = %v", err)
}
select {
case <-delegate.rawStarted:
case <-time.After(time.Second):
t.Fatal("delegate raw publish was not started")
}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("second PublishFields() error = %v", err)
}
publishErr := make(chan error, 1)
go func() {
defer func() {
if recovered := recover(); recovered != nil {
publishErr <- errors.New("publish panicked")
}
}()
publishErr <- sink.PublishUnified(context.Background(), env)
}()
closeErr := make(chan error, 1)
go func() {
closeErr <- sink.Close()
}()
if err := <-publishErr; !errors.Is(err, ErrAsyncSinkEnqueueTimeout) && !errors.Is(err, ErrAsyncSinkClosed) {
t.Fatalf("concurrent PublishUnified() error = %v, want timeout or closed", err)
}
delegate.release()
if err := <-closeErr; err != nil {
t.Fatalf("Close() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), env); !errors.Is(err, ErrAsyncSinkClosed) {
t.Fatalf("PublishRaw() after Close error = %v, want ErrAsyncSinkClosed", err)
}
}
func TestAsyncSinkCloseUnblocksPublishWhenEnqueueTimeoutIsDisabled(t *testing.T) {
delegate := newBlockingSink()
sink := NewAsyncSink(delegate, AsyncConfig{
QueueSize: 1,
Workers: 1,
EnqueueTimeout: -1,
OperationTimeout: time.Second,
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("first PublishRaw() error = %v", err)
}
select {
case <-delegate.rawStarted:
case <-time.After(time.Second):
t.Fatal("delegate raw publish was not started")
}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("second PublishFields() error = %v", err)
}
publishErr := make(chan error, 1)
go func() {
publishErr <- sink.PublishUnified(context.Background(), env)
}()
closeErr := make(chan error, 1)
go func() {
closeErr <- sink.Close()
}()
select {
case err := <-publishErr:
if !errors.Is(err, ErrAsyncSinkClosed) {
t.Fatalf("blocked PublishUnified() error = %v, want ErrAsyncSinkClosed", err)
}
case <-time.After(time.Second):
t.Fatal("blocked PublishUnified() was not released by Close")
}
delegate.release()
select {
case err := <-closeErr:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close() did not finish after delegate release")
}
}
type blockingSink struct {
rawStarted chan struct{}
releaseRaw chan struct{}

View File

@@ -0,0 +1,348 @@
package eventbus
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
type DurableOutboxConfig struct {
Directory string
ReplayBatchSize int
SyncWrites bool
CloseTimeout time.Duration
WALSegmentBytes int64
WALSegmentAge time.Duration
WALAppendQueue int
WALCommitBatch int
WALCommitWait time.Duration
Metrics *metrics.Registry
Name string
OnError func(error)
}
type asyncRecordPublishingSink interface {
Sink
ValidateRecord(durableRecord) error
PublishRecordAsync(durableRecord, func(error)) error
}
// DurableOutboxSink accepts a record only after its WAL commit is durable.
// Publishing is asynchronous; the WAL record remains replayable until the
// broker returns PubAck. Stable event IDs make crash-window replays idempotent.
type DurableOutboxSink struct {
delegate asyncRecordPublishingSink
store *durableOutboxWAL
replayBatchSize int
closeTimeout time.Duration
metrics *metrics.Registry
name string
onError func(error)
acceptMu sync.RWMutex
mu sync.Mutex
closed bool
inflight map[outboxWALRecordRef]struct{}
pending sync.WaitGroup
closeOne sync.Once
closeErr error
}
var ErrDurableOutboxClosed = errors.New("durable outbox is closed")
func NewDurableOutboxSink(delegate Sink, cfg DurableOutboxConfig) (*DurableOutboxSink, error) {
publisher, ok := delegate.(asyncRecordPublishingSink)
if !ok || publisher == nil {
return nil, errors.New("durable outbox delegate must support async record publishing")
}
dir := strings.TrimSpace(cfg.Directory)
if dir == "" {
return nil, errors.New("durable outbox directory is required")
}
if cfg.ReplayBatchSize <= 0 {
cfg.ReplayBatchSize = 1000
}
if cfg.CloseTimeout <= 0 {
cfg.CloseTimeout = 5 * time.Second
}
store, err := newDurableOutboxWAL(durableOutboxWALConfig{
Directory: dir,
SyncWrites: cfg.SyncWrites,
SegmentBytes: cfg.WALSegmentBytes,
SegmentAge: cfg.WALSegmentAge,
AppendQueue: cfg.WALAppendQueue,
CommitBatch: cfg.WALCommitBatch,
CommitInterval: cfg.WALCommitWait,
})
if err != nil {
return nil, err
}
s := &DurableOutboxSink{
delegate: publisher,
store: store,
replayBatchSize: cfg.ReplayBatchSize,
closeTimeout: cfg.CloseTimeout,
metrics: cfg.Metrics,
name: durableMetricName(cfg.Name),
onError: cfg.OnError,
inflight: map[outboxWALRecordRef]struct{}{},
}
s.recordBacklog()
return s, nil
}
func (s *DurableOutboxSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
return s.persistAndSubmit(ctx, "raw", env)
}
func (s *DurableOutboxSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
return s.persistAndSubmit(ctx, "unified", env)
}
func (s *DurableOutboxSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
return s.persistAndSubmit(ctx, "fields", env)
}
func (s *DurableOutboxSink) Close() error {
s.closeOne.Do(func() {
s.acceptMu.Lock()
s.mu.Lock()
s.closed = true
s.mu.Unlock()
s.acceptMu.Unlock()
walErr := s.store.Close()
done := make(chan struct{})
go func() {
s.pending.Wait()
close(done)
}()
var waitErr error
select {
case <-done:
case <-time.After(s.closeTimeout):
waitErr = fmt.Errorf("durable outbox close timed out after %s", s.closeTimeout)
s.recordPublish("all", "close_timeout")
}
s.closeErr = errors.Join(walErr, waitErr, s.delegate.Close())
})
return s.closeErr
}
// ReplayOnce claims at most one configured batch. A bounded claim prevents a
// large recovered backlog from creating an unbounded number of PubAck futures.
func (s *DurableOutboxSink) ReplayOnce(ctx context.Context) error {
s.acceptMu.RLock()
defer s.acceptMu.RUnlock()
if s.isClosed() {
return ErrDurableOutboxClosed
}
return s.replay(ctx, s.replayBatchSize)
}
func (s *DurableOutboxSink) ReplayLoop(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = time.Second
}
if err := s.ReplayOnce(ctx); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
s.reportError(err)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.ReplayOnce(ctx); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
s.reportError(err)
}
}
}
}
func (s *DurableOutboxSink) persistAndSubmit(ctx context.Context, kind string, env envelope.FrameEnvelope) error {
s.acceptMu.RLock()
defer s.acceptMu.RUnlock()
if s.isClosed() {
return ErrDurableOutboxClosed
}
record := durableRecord{Kind: kind, Envelope: normalizeDurableEnvelope(env)}
if err := s.delegate.ValidateRecord(record); err != nil {
s.recordPublish(kind, "validation_error")
return err
}
stored, err := s.store.Append(ctx, record)
if err != nil {
s.recordSpool(kind, "error")
return err
}
s.recordSpool(kind, "ok")
s.recordBacklog()
if err := s.submit(stored); err != nil {
// The durable commit is the device-facing acceptance boundary. Broker
// submission errors are surfaced operationally and recovered by replay.
s.reportError(err)
}
return nil
}
func normalizeDurableEnvelope(env envelope.FrameEnvelope) envelope.FrameEnvelope {
if env.EventID == "" {
env.EventID = env.StableEventID()
}
if env.ParseStatus == "" {
env.ParseStatus = envelope.ParseOK
}
return env
}
func (s *DurableOutboxSink) submit(stored storedOutboxRecord) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
s.store.Release(stored.Ref)
return ErrDurableOutboxClosed
}
if _, exists := s.inflight[stored.Ref]; exists {
s.mu.Unlock()
return nil
}
s.inflight[stored.Ref] = struct{}{}
s.pending.Add(1)
inflight := len(s.inflight)
s.mu.Unlock()
s.recordInflight(inflight)
err := s.delegate.PublishRecordAsync(stored.Record, func(publishErr error) {
s.complete(stored, publishErr)
})
if err == nil {
s.recordPublish(stored.Record.Kind, "submitted")
return nil
}
s.mu.Lock()
delete(s.inflight, stored.Ref)
inflight = len(s.inflight)
s.mu.Unlock()
s.pending.Done()
s.store.Release(stored.Ref)
s.recordInflight(inflight)
s.recordPublish(stored.Record.Kind, "submit_error")
return fmt.Errorf("submit durable outbox record: %w", err)
}
func (s *DurableOutboxSink) complete(stored storedOutboxRecord, publishErr error) {
defer s.pending.Done()
status := "acked"
if publishErr != nil {
status = "ack_error"
s.store.Release(stored.Ref)
} else if err := s.store.Ack(stored.Ref); err != nil {
publishErr = err
status = "remove_error"
}
s.mu.Lock()
delete(s.inflight, stored.Ref)
inflight := len(s.inflight)
s.mu.Unlock()
s.recordInflight(inflight)
s.recordBacklog()
s.recordPublish(stored.Record.Kind, status)
if publishErr != nil {
s.reportError(publishErr)
}
}
func (s *DurableOutboxSink) replay(ctx context.Context, limit int) error {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
records, err := s.store.ClaimPending(limit)
if err != nil {
return fmt.Errorf("claim durable outbox records: %w", err)
}
for index, stored := range records {
if ctx != nil && ctx.Err() != nil {
for _, remaining := range records[index:] {
s.store.Release(remaining.Ref)
}
return ctx.Err()
}
if err := s.delegate.ValidateRecord(stored.Record); err != nil {
s.store.Release(stored.Ref)
for _, remaining := range records[index+1:] {
s.store.Release(remaining.Ref)
}
s.recordPublish(stored.Record.Kind, "validation_error")
return fmt.Errorf("validate durable outbox replay record: %w", err)
}
if err := s.submit(stored); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
s.reportError(err)
}
}
return nil
}
func (s *DurableOutboxSink) isClosed() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.closed
}
func (s *DurableOutboxSink) reportError(err error) {
if err != nil && s.onError != nil {
s.onError(err)
}
}
func (s *DurableOutboxSink) recordSpool(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_durable_spool_records_total", metrics.Labels{
"name": s.name, "kind": kind, "status": status,
})
}
func (s *DurableOutboxSink) recordPublish(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_durable_outbox_publish_total", metrics.Labels{
"name": s.name, "kind": kind, "status": status,
})
}
func (s *DurableOutboxSink) recordInflight(value int) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_durable_outbox_inflight", metrics.Labels{"name": s.name}, float64(value))
}
func (s *DurableOutboxSink) recordBacklog() {
if s.metrics == nil {
return
}
count, oldest := s.store.Stats()
labels := metrics.Labels{"name": s.name}
// Keep the legacy gauge during migration because capacity gates already
// consume it; its value now represents durable WAL records, not files.
s.metrics.SetGauge("vehicle_durable_spool_backlog_files", labels, float64(count))
s.metrics.SetGauge("vehicle_durable_outbox_backlog_records", labels, float64(count))
ageSeconds := 0.0
if count > 0 && !oldest.IsZero() {
ageSeconds = time.Since(oldest).Seconds()
if ageSeconds < 0 {
ageSeconds = 0
}
}
s.metrics.SetGauge("vehicle_durable_spool_oldest_age_seconds", labels, ageSeconds)
}

View File

@@ -0,0 +1,380 @@
package eventbus
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestDurableOutboxPersistsBeforeAsyncSubmitAndDeletesAfterAck(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
var sink *DurableOutboxSink
delegate.onSubmit = func(record durableRecord) {
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("durable backlog visible at submit = %d, want 1", got)
}
if record.Envelope.EventID == "" {
t.Fatal("submitted record must have a stable event id")
}
}
var err error
sink, err = NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("backlog before ack = %d, want 1", got)
}
delegate.completeNext(t, nil)
if got := outboxBacklog(sink); got != 0 {
t.Fatalf("backlog after ack = %d, want 0", got)
}
}
func TestDurableOutboxImmediateSubmitErrorKeepsAcceptedRecord(t *testing.T) {
dir := t.TempDir()
submitErr := errors.New("nats pending limit")
delegate := &outboxAsyncSink{submitErrors: []error{submitErr}}
var reported error
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
Directory: dir,
OnError: func(err error) {
reported = err
},
})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("accepted durable record should hide submit error, got %v", err)
}
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("backlog after submit error = %d, want 1", got)
}
if reported == nil || !strings.Contains(reported.Error(), submitErr.Error()) {
t.Fatalf("reported error = %v", reported)
}
}
func TestDurableOutboxAckErrorIsRetriedAndStableRecordIsRemoved(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
delegate.completeNext(t, errors.New("ack timeout"))
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("backlog after ack error = %d, want 1", got)
}
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
if got := delegate.recordCount(); got != 2 {
t.Fatalf("async submissions = %d, want 2", got)
}
first, second := delegate.recordAt(0), delegate.recordAt(1)
if first.Envelope.EventID == "" || first.Envelope.EventID != second.Envelope.EventID {
t.Fatalf("replay event ids = %q and %q", first.Envelope.EventID, second.Envelope.EventID)
}
delegate.completeNext(t, nil)
if got := outboxBacklog(sink); got != 0 {
t.Fatalf("backlog after replay ack = %d, want 0", got)
}
}
func TestDurableOutboxReplaysPreexistingRecordAfterRestart(t *testing.T) {
dir := t.TempDir()
first := &outboxAsyncSink{submitErrors: []error{errors.New("nats unavailable")}}
writer, err := NewDurableOutboxSink(first, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("create first outbox: %v", err)
}
if err := writer.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("persist restart record: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close first outbox: %v", err)
}
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir, ReplayBatchSize: 10})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
if got := delegate.recordCount(); got != 1 {
t.Fatalf("replayed records = %d, want 1", got)
}
delegate.completeNext(t, nil)
if got := outboxBacklog(sink); got != 0 {
t.Fatalf("backlog after replay ack = %d, want 0", got)
}
}
func TestDurableOutboxDoesNotResubmitInflightRecord(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
if got := delegate.recordCount(); got != 1 {
t.Fatalf("submissions while inflight = %d, want 1", got)
}
delegate.completeNext(t, nil)
}
func TestDurableOutboxRejectsInvalidRecordBeforePersistence(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{validateErr: errors.New("subject not configured")}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
err = sink.PublishRaw(context.Background(), durableTestEnvelope())
if err == nil || !strings.Contains(err.Error(), "subject not configured") {
t.Fatalf("PublishRaw() error = %v", err)
}
if got := outboxBacklog(sink); got != 0 {
t.Fatalf("invalid record backlog = %d, want 0", got)
}
if got := delegate.recordCount(); got != 0 {
t.Fatalf("invalid record submissions = %d, want 0", got)
}
}
func TestDurableOutboxCloseWaitsForPendingAck(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
Directory: dir,
CloseTimeout: time.Second,
})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
done := make(chan error, 1)
go func() { done <- sink.Close() }()
select {
case err := <-done:
t.Fatalf("Close() returned before ack: %v", err)
case <-time.After(20 * time.Millisecond):
}
delegate.completeNext(t, nil)
select {
case err := <-done:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close() did not return after ack")
}
}
func TestDurableOutboxCloseTimeoutRetainsRecord(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
Directory: dir,
CloseTimeout: 10 * time.Millisecond,
})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
err = sink.Close()
if err == nil || !strings.Contains(err.Error(), "close timed out") {
t.Fatalf("Close() error = %v", err)
}
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("backlog after close timeout = %d, want 1", got)
}
delegate.completeNext(t, errors.New("connection closed"))
}
func BenchmarkDurableOutboxPublishRaw(b *testing.B) {
for _, syncWrites := range []bool{false, true} {
name := "fsync_off"
if syncWrites {
name = "fsync_on"
}
b.Run(name, func(b *testing.B) {
sink, err := NewDurableOutboxSink(autoAckOutboxSink{}, DurableOutboxConfig{
Directory: b.TempDir(),
SyncWrites: syncWrites,
})
if err != nil {
b.Fatalf("NewDurableOutboxSink() error = %v", err)
}
var sequence atomic.Uint32
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
env := durableTestEnvelope()
env.EventID = ""
env.Sequence = uint16(sequence.Add(1))
if err := sink.PublishRaw(context.Background(), env); err != nil {
b.Fatalf("PublishRaw() error = %v", err)
}
}
})
b.StopTimer()
if err := sink.Close(); err != nil {
b.Fatalf("Close() error = %v", err)
}
})
}
}
func outboxBacklog(sink *DurableOutboxSink) int {
count, _ := sink.store.Stats()
return count
}
type outboxAsyncSink struct {
mu sync.Mutex
validateErr error
submitErrors []error
records []durableRecord
callbacks []func(error)
onSubmit func(durableRecord)
closeCalls int
}
func (s *outboxAsyncSink) ValidateRecord(record durableRecord) error {
if s.validateErr != nil {
return s.validateErr
}
switch record.Kind {
case "raw", "unified", "fields":
return nil
default:
return errUnknownRecordKind(record.Kind)
}
}
func (s *outboxAsyncSink) PublishRecordAsync(record durableRecord, complete func(error)) error {
if s.onSubmit != nil {
s.onSubmit(record)
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.submitErrors) > 0 {
err := s.submitErrors[0]
s.submitErrors = s.submitErrors[1:]
if err != nil {
return err
}
}
s.records = append(s.records, record)
s.callbacks = append(s.callbacks, complete)
return nil
}
func (s *outboxAsyncSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *outboxAsyncSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *outboxAsyncSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *outboxAsyncSink) Close() error {
s.mu.Lock()
s.closeCalls++
s.mu.Unlock()
return nil
}
func (s *outboxAsyncSink) completeNext(t *testing.T, err error) {
t.Helper()
s.mu.Lock()
if len(s.callbacks) == 0 {
s.mu.Unlock()
t.Fatal("no pending async callback")
}
callback := s.callbacks[0]
s.callbacks = s.callbacks[1:]
s.mu.Unlock()
callback(err)
}
func (s *outboxAsyncSink) recordCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.records)
}
func (s *outboxAsyncSink) recordAt(index int) durableRecord {
s.mu.Lock()
defer s.mu.Unlock()
return s.records[index]
}
type autoAckOutboxSink struct{}
func (autoAckOutboxSink) ValidateRecord(record durableRecord) error {
switch record.Kind {
case "raw", "unified", "fields":
return nil
default:
return errUnknownRecordKind(record.Kind)
}
}
func (autoAckOutboxSink) PublishRecordAsync(_ durableRecord, complete func(error)) error {
complete(nil)
return nil
}
func (autoAckOutboxSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (autoAckOutboxSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (autoAckOutboxSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (autoAckOutboxSink) Close() error { return nil }

View File

@@ -0,0 +1,804 @@
package eventbus
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/crc32"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
outboxWALMagic = uint32(0x4c4e5731) // LNW1
outboxWALHeaderSize = 12
outboxWALMaxRecordBytes = 16 << 20
defaultWALSegmentBytes = 16 << 20
defaultWALAppendQueue = 100_000
defaultWALCommitBatch = 256
defaultWALCommitInterval = time.Millisecond
defaultWALSegmentAge = 5 * time.Second
)
var ErrDurableOutboxWALClosed = errors.New("durable outbox wal is closed")
type durableOutboxWALConfig struct {
Directory string
SyncWrites bool
SegmentBytes int64
SegmentAge time.Duration
AppendQueue int
CommitBatch int
CommitInterval time.Duration
}
type outboxWALRecordRef struct {
segmentID uint64
index int
}
type storedOutboxRecord struct {
Ref outboxWALRecordRef
Record durableRecord
}
type outboxWALRecordStatus uint8
const (
outboxWALPending outboxWALRecordStatus = iota
outboxWALClaimed
outboxWALAcknowledged
)
type outboxWALRecordState struct {
payloadOffset int64
payloadLength uint32
status outboxWALRecordStatus
}
type outboxWALSegment struct {
id uint64
path string
createdAt time.Time
size int64
closed bool
deleting bool
acked int
records []*outboxWALRecordState
}
type outboxWALAppendRequest struct {
record durableRecord
payload []byte
frame []byte
result chan outboxWALAppendResult
}
type outboxWALAppendResult struct {
stored storedOutboxRecord
err error
}
type durableOutboxWAL struct {
dir string
syncWrites bool
segmentBytes int64
segmentAge time.Duration
commitBatch int
commitInterval time.Duration
mu sync.Mutex
segments []*outboxWALSegment
segmentByID map[uint64]*outboxWALSegment
current *outboxWALSegment
currentFile *os.File
backlog int
fatalErr error
appendMu sync.RWMutex
closed bool
queue chan outboxWALAppendRequest
writerWG sync.WaitGroup
closeOne sync.Once
}
func newDurableOutboxWAL(cfg durableOutboxWALConfig) (*durableOutboxWAL, error) {
dir := strings.TrimSpace(cfg.Directory)
if dir == "" {
return nil, errors.New("durable outbox wal directory is required")
}
if cfg.SegmentBytes <= 0 {
cfg.SegmentBytes = defaultWALSegmentBytes
}
if cfg.SegmentAge <= 0 {
cfg.SegmentAge = defaultWALSegmentAge
}
if cfg.AppendQueue <= 0 {
cfg.AppendQueue = defaultWALAppendQueue
}
if cfg.CommitBatch <= 0 {
cfg.CommitBatch = defaultWALCommitBatch
}
if cfg.CommitInterval <= 0 {
cfg.CommitInterval = defaultWALCommitInterval
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create durable outbox wal directory: %w", err)
}
w := &durableOutboxWAL{
dir: dir,
syncWrites: cfg.SyncWrites,
segmentBytes: cfg.SegmentBytes,
segmentAge: cfg.SegmentAge,
commitBatch: cfg.CommitBatch,
commitInterval: cfg.CommitInterval,
segmentByID: map[uint64]*outboxWALSegment{},
queue: make(chan outboxWALAppendRequest, cfg.AppendQueue),
}
if err := w.loadSegments(); err != nil {
return nil, err
}
if err := w.createCurrentSegment(); err != nil {
return nil, err
}
w.writerWG.Add(1)
go w.appendLoop()
return w, nil
}
func (w *durableOutboxWAL) Append(ctx context.Context, record durableRecord) (storedOutboxRecord, error) {
payload, err := json.Marshal(record)
if err != nil {
return storedOutboxRecord{}, fmt.Errorf("marshal durable outbox wal record: %w", err)
}
if len(payload) > outboxWALMaxRecordBytes {
return storedOutboxRecord{}, fmt.Errorf("durable outbox wal record is %d bytes, max %d", len(payload), outboxWALMaxRecordBytes)
}
request := outboxWALAppendRequest{
record: record,
payload: payload,
frame: encodeOutboxWALFrame(payload),
result: make(chan outboxWALAppendResult, 1),
}
if ctx == nil {
ctx = context.Background()
}
w.appendMu.RLock()
if w.closed {
w.appendMu.RUnlock()
return storedOutboxRecord{}, ErrDurableOutboxWALClosed
}
select {
case w.queue <- request:
w.appendMu.RUnlock()
case <-ctx.Done():
w.appendMu.RUnlock()
return storedOutboxRecord{}, ctx.Err()
}
// Once admitted to the WAL queue, wait for the durability result even if
// the caller context is cancelled. Otherwise a committed record could be
// left claimed with no publisher responsible for it.
result := <-request.result
return result.stored, result.err
}
func (w *durableOutboxWAL) ClaimPending(limit int) ([]storedOutboxRecord, error) {
if limit <= 0 {
limit = defaultWALCommitBatch
}
w.mu.Lock()
refs := make([]outboxWALRecordRef, 0, limit)
for _, segment := range w.segments {
for index, state := range segment.records {
if state.status != outboxWALPending {
continue
}
state.status = outboxWALClaimed
refs = append(refs, outboxWALRecordRef{segmentID: segment.id, index: index})
if len(refs) >= limit {
break
}
}
if len(refs) >= limit {
break
}
}
w.mu.Unlock()
if len(refs) == 0 {
return nil, nil
}
records, err := w.readClaimedRecords(refs)
if err != nil {
for _, ref := range refs {
w.Release(ref)
}
return nil, err
}
return records, nil
}
func (w *durableOutboxWAL) Release(ref outboxWALRecordRef) {
w.mu.Lock()
defer w.mu.Unlock()
state := w.recordStateLocked(ref)
if state != nil && state.status == outboxWALClaimed {
state.status = outboxWALPending
}
}
func (w *durableOutboxWAL) Ack(ref outboxWALRecordRef) error {
w.mu.Lock()
segment := w.segmentByID[ref.segmentID]
if segment == nil || ref.index < 0 || ref.index >= len(segment.records) {
w.mu.Unlock()
return nil
}
state := segment.records[ref.index]
if state.status == outboxWALAcknowledged {
w.mu.Unlock()
return nil
}
state.status = outboxWALAcknowledged
segment.acked++
if w.backlog > 0 {
w.backlog--
}
shouldDelete := segment.closed && segment.acked == len(segment.records) && !segment.deleting
if shouldDelete {
segment.deleting = true
}
w.mu.Unlock()
if !shouldDelete {
return nil
}
return w.deleteAcknowledgedSegment(segment)
}
func (w *durableOutboxWAL) Stats() (backlog int, oldest time.Time) {
w.mu.Lock()
defer w.mu.Unlock()
backlog = w.backlog
if backlog == 0 {
return backlog, time.Time{}
}
for _, segment := range w.segments {
if segment.acked < len(segment.records) {
return backlog, segment.createdAt
}
}
return backlog, time.Time{}
}
func (w *durableOutboxWAL) Close() error {
w.closeOne.Do(func() {
w.appendMu.Lock()
w.closed = true
close(w.queue)
w.appendMu.Unlock()
w.writerWG.Wait()
})
w.mu.Lock()
err := w.fatalErr
w.mu.Unlock()
return err
}
func (w *durableOutboxWAL) appendLoop() {
defer w.writerWG.Done()
maintenanceInterval := minDuration(w.segmentAge/2, time.Second)
if maintenanceInterval < 10*time.Millisecond {
maintenanceInterval = 10 * time.Millisecond
}
maintenance := time.NewTicker(maintenanceInterval)
defer maintenance.Stop()
for {
select {
case request, ok := <-w.queue:
if !ok {
w.finishWriter()
return
}
batch := w.collectAppendBatch(request)
w.commitAppendBatch(batch)
case <-maintenance.C:
if err := w.rotateIfAged(); err != nil {
w.setFatal(err)
}
}
}
}
func (w *durableOutboxWAL) collectAppendBatch(first outboxWALAppendRequest) []outboxWALAppendRequest {
batch := make([]outboxWALAppendRequest, 0, w.commitBatch)
batch = append(batch, first)
timer := time.NewTimer(w.commitInterval)
defer timer.Stop()
for len(batch) < w.commitBatch {
select {
case request, ok := <-w.queue:
if !ok {
return batch
}
batch = append(batch, request)
case <-timer.C:
return batch
}
}
return batch
}
func (w *durableOutboxWAL) commitAppendBatch(batch []outboxWALAppendRequest) {
if len(batch) == 0 {
return
}
w.mu.Lock()
fatalErr := w.fatalErr
w.mu.Unlock()
if fatalErr != nil {
w.completeAppendErrors(batch, fatalErr)
return
}
for len(batch) > 0 {
if err := w.rotateBeforeAppend(len(batch[0].frame)); err != nil {
w.setFatal(err)
w.completeAppendErrors(batch, err)
return
}
count := w.batchCountForCurrentSegment(batch)
if count <= 0 {
count = 1
}
group := batch[:count]
if err := w.commitAppendGroup(group); err != nil {
w.setFatal(err)
w.completeAppendErrors(batch, err)
return
}
batch = batch[count:]
}
}
func (w *durableOutboxWAL) commitAppendGroup(group []outboxWALAppendRequest) error {
start := w.current.size
totalBytes := 0
for _, request := range group {
totalBytes += len(request.frame)
}
buffer := make([]byte, 0, totalBytes)
for _, request := range group {
buffer = append(buffer, request.frame...)
}
written, err := w.currentFile.Write(buffer)
if err != nil || written != len(buffer) {
if err == nil {
err = io.ErrShortWrite
}
w.rollbackAppend(start)
return fmt.Errorf("append durable outbox wal: %w", err)
}
if w.syncWrites {
if err := w.currentFile.Sync(); err != nil {
w.rollbackAppend(start)
return fmt.Errorf("sync durable outbox wal: %w", err)
}
}
w.mu.Lock()
segment := w.current
offset := start
results := make([]outboxWALAppendResult, 0, len(group))
for _, request := range group {
state := &outboxWALRecordState{
payloadOffset: offset + outboxWALHeaderSize,
payloadLength: uint32(len(request.payload)),
status: outboxWALClaimed,
}
index := len(segment.records)
segment.records = append(segment.records, state)
w.backlog++
results = append(results, outboxWALAppendResult{stored: storedOutboxRecord{
Ref: outboxWALRecordRef{segmentID: segment.id, index: index},
Record: request.record,
}})
offset += int64(len(request.frame))
}
segment.size += int64(len(buffer))
w.mu.Unlock()
for index, request := range group {
request.result <- results[index]
}
return nil
}
func (w *durableOutboxWAL) rollbackAppend(size int64) {
if w.currentFile == nil {
return
}
_ = w.currentFile.Truncate(size)
if w.syncWrites {
_ = w.currentFile.Sync()
}
}
func (w *durableOutboxWAL) rotateBeforeAppend(frameBytes int) error {
if w.current == nil {
return w.createCurrentSegment()
}
if len(w.current.records) == 0 {
return nil
}
tooLarge := w.current.size+int64(frameBytes) > w.segmentBytes
tooOld := time.Since(w.current.createdAt) >= w.segmentAge
if !tooLarge && !tooOld {
return nil
}
return w.rotateCurrentSegment()
}
func (w *durableOutboxWAL) rotateIfAged() error {
w.mu.Lock()
current := w.current
shouldRotate := current != nil && len(current.records) > 0 && time.Since(current.createdAt) >= w.segmentAge
w.mu.Unlock()
if !shouldRotate {
return nil
}
return w.rotateCurrentSegment()
}
func (w *durableOutboxWAL) rotateCurrentSegment() error {
if w.currentFile == nil || w.current == nil {
return w.createCurrentSegment()
}
if w.syncWrites {
if err := w.currentFile.Sync(); err != nil {
return fmt.Errorf("sync closing durable outbox wal segment: %w", err)
}
}
if err := w.currentFile.Close(); err != nil {
return fmt.Errorf("close durable outbox wal segment: %w", err)
}
w.mu.Lock()
old := w.current
old.closed = true
w.current = nil
w.currentFile = nil
deleteOld := old.acked == len(old.records) && !old.deleting
if deleteOld {
old.deleting = true
}
w.mu.Unlock()
if deleteOld {
if err := w.deleteAcknowledgedSegment(old); err != nil {
return err
}
}
return w.createCurrentSegment()
}
func (w *durableOutboxWAL) createCurrentSegment() error {
w.mu.Lock()
lastID := uint64(0)
if len(w.segments) > 0 {
lastID = w.segments[len(w.segments)-1].id
}
w.mu.Unlock()
id := uint64(time.Now().UnixNano())
if id <= lastID {
id = lastID + 1
}
path := filepath.Join(w.dir, outboxWALFileName(id))
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR|os.O_APPEND, 0o640)
if err != nil {
return fmt.Errorf("create durable outbox wal segment: %w", err)
}
if w.syncWrites {
if err := syncDirectory(w.dir); err != nil {
_ = file.Close()
return fmt.Errorf("sync durable outbox wal directory after create: %w", err)
}
}
segment := &outboxWALSegment{id: id, path: path, createdAt: time.Now()}
w.mu.Lock()
w.segments = append(w.segments, segment)
w.segmentByID[id] = segment
w.current = segment
w.currentFile = file
w.mu.Unlock()
return nil
}
func (w *durableOutboxWAL) finishWriter() {
if w.currentFile == nil || w.current == nil {
return
}
var finishErr error
if w.syncWrites {
finishErr = w.currentFile.Sync()
}
if closeErr := w.currentFile.Close(); finishErr == nil {
finishErr = closeErr
}
w.mu.Lock()
current := w.current
current.closed = true
w.current = nil
w.currentFile = nil
deleteCurrent := current.acked == len(current.records) && !current.deleting
if deleteCurrent {
current.deleting = true
}
w.mu.Unlock()
if deleteCurrent {
if err := w.deleteAcknowledgedSegment(current); finishErr == nil {
finishErr = err
}
}
if finishErr != nil {
w.setFatal(finishErr)
}
}
func (w *durableOutboxWAL) batchCountForCurrentSegment(batch []outboxWALAppendRequest) int {
remaining := w.segmentBytes - w.current.size
count := 0
for _, request := range batch {
if count > 0 && int64(len(request.frame)) > remaining {
break
}
remaining -= int64(len(request.frame))
count++
if remaining <= 0 {
break
}
}
return count
}
func (w *durableOutboxWAL) completeAppendErrors(batch []outboxWALAppendRequest, err error) {
for _, request := range batch {
request.result <- outboxWALAppendResult{err: err}
}
}
func (w *durableOutboxWAL) readClaimedRecords(refs []outboxWALRecordRef) ([]storedOutboxRecord, error) {
files := map[uint64]*os.File{}
defer func() {
for _, file := range files {
_ = file.Close()
}
}()
records := make([]storedOutboxRecord, 0, len(refs))
for _, ref := range refs {
w.mu.Lock()
segment := w.segmentByID[ref.segmentID]
state := w.recordStateLocked(ref)
w.mu.Unlock()
if segment == nil || state == nil {
return nil, fmt.Errorf("durable outbox wal record reference not found: segment=%d index=%d", ref.segmentID, ref.index)
}
file := files[segment.id]
if file == nil {
opened, err := os.Open(segment.path)
if err != nil {
return nil, fmt.Errorf("open durable outbox wal segment for replay: %w", err)
}
files[segment.id] = opened
file = opened
}
payload := make([]byte, state.payloadLength)
if _, err := file.ReadAt(payload, state.payloadOffset); err != nil {
return nil, fmt.Errorf("read durable outbox wal record: %w", err)
}
var record durableRecord
if err := json.Unmarshal(payload, &record); err != nil {
return nil, fmt.Errorf("decode durable outbox wal record: %w", err)
}
records = append(records, storedOutboxRecord{Ref: ref, Record: record})
}
return records, nil
}
func (w *durableOutboxWAL) loadSegments() error {
paths, err := filepath.Glob(filepath.Join(w.dir, "outbox-*.wal"))
if err != nil {
return fmt.Errorf("list durable outbox wal segments: %w", err)
}
sort.Strings(paths)
for _, path := range paths {
segment, err := loadOutboxWALSegment(path, w.syncWrites)
if err != nil {
return err
}
if len(segment.records) == 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove empty durable outbox wal segment: %w", err)
}
if w.syncWrites {
if err := syncDirectory(w.dir); err != nil {
return fmt.Errorf("sync durable outbox wal directory after removing empty segment: %w", err)
}
}
continue
}
w.segments = append(w.segments, segment)
w.segmentByID[segment.id] = segment
w.backlog += len(segment.records)
}
return nil
}
func loadOutboxWALSegment(path string, syncWrites bool) (*outboxWALSegment, error) {
id, err := parseOutboxWALFileName(filepath.Base(path))
if err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("open durable outbox wal segment: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat durable outbox wal segment: %w", err)
}
segment := &outboxWALSegment{
id: id,
path: path,
createdAt: info.ModTime(),
closed: true,
}
offset := int64(0)
header := make([]byte, outboxWALHeaderSize)
for offset < info.Size() {
n, readErr := file.ReadAt(header, offset)
if readErr != nil {
if readErr == io.EOF && n < outboxWALHeaderSize {
if err := truncateOutboxWALTail(file, offset, syncWrites); err != nil {
return nil, err
}
break
}
return nil, fmt.Errorf("read durable outbox wal header at %d: %w", offset, readErr)
}
if binary.BigEndian.Uint32(header[0:4]) != outboxWALMagic {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: invalid magic", path, offset)
}
length := binary.BigEndian.Uint32(header[4:8])
checksum := binary.BigEndian.Uint32(header[8:12])
if length == 0 || length > outboxWALMaxRecordBytes {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: invalid length %d", path, offset, length)
}
frameEnd := offset + outboxWALHeaderSize + int64(length)
if frameEnd > info.Size() {
if err := truncateOutboxWALTail(file, offset, syncWrites); err != nil {
return nil, err
}
break
}
payload := make([]byte, length)
if _, err := file.ReadAt(payload, offset+outboxWALHeaderSize); err != nil {
return nil, fmt.Errorf("read durable outbox wal payload at %d: %w", offset, err)
}
if crc32.ChecksumIEEE(payload) != checksum {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: checksum mismatch", path, offset)
}
segment.records = append(segment.records, &outboxWALRecordState{
payloadOffset: offset + outboxWALHeaderSize,
payloadLength: length,
status: outboxWALPending,
})
offset = frameEnd
}
segment.size = offset
return segment, nil
}
func truncateOutboxWALTail(file *os.File, size int64, syncWrites bool) error {
if err := file.Truncate(size); err != nil {
return fmt.Errorf("truncate incomplete durable outbox wal tail: %w", err)
}
if syncWrites {
if err := file.Sync(); err != nil {
return fmt.Errorf("sync truncated durable outbox wal tail: %w", err)
}
}
return nil
}
func (w *durableOutboxWAL) deleteAcknowledgedSegment(segment *outboxWALSegment) error {
err := os.Remove(segment.path)
if os.IsNotExist(err) {
err = nil
}
if err == nil && w.syncWrites {
err = syncDirectory(w.dir)
}
w.mu.Lock()
defer w.mu.Unlock()
if err != nil {
segment.deleting = false
return fmt.Errorf("delete acknowledged durable outbox wal segment: %w", err)
}
delete(w.segmentByID, segment.id)
for index, candidate := range w.segments {
if candidate != segment {
continue
}
w.segments = append(w.segments[:index], w.segments[index+1:]...)
break
}
return nil
}
func (w *durableOutboxWAL) recordStateLocked(ref outboxWALRecordRef) *outboxWALRecordState {
segment := w.segmentByID[ref.segmentID]
if segment == nil || ref.index < 0 || ref.index >= len(segment.records) {
return nil
}
return segment.records[ref.index]
}
func (w *durableOutboxWAL) setFatal(err error) {
if err == nil {
return
}
w.mu.Lock()
if w.fatalErr == nil {
w.fatalErr = err
}
w.mu.Unlock()
}
func encodeOutboxWALFrame(payload []byte) []byte {
frame := make([]byte, outboxWALHeaderSize+len(payload))
binary.BigEndian.PutUint32(frame[0:4], outboxWALMagic)
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
binary.BigEndian.PutUint32(frame[8:12], crc32.ChecksumIEEE(payload))
copy(frame[outboxWALHeaderSize:], payload)
return frame
}
func outboxWALFileName(id uint64) string {
return fmt.Sprintf("outbox-%020d.wal", id)
}
func parseOutboxWALFileName(name string) (uint64, error) {
if !strings.HasPrefix(name, "outbox-") || !strings.HasSuffix(name, ".wal") {
return 0, fmt.Errorf("invalid durable outbox wal file name %q", name)
}
value := strings.TrimSuffix(strings.TrimPrefix(name, "outbox-"), ".wal")
id, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse durable outbox wal file name %q: %w", name, err)
}
return id, nil
}
func syncDirectory(dir string) error {
handle, err := os.Open(dir)
if err != nil {
return err
}
syncErr := handle.Sync()
closeErr := handle.Close()
return errors.Join(syncErr, closeErr)
}
func minDuration(left, right time.Duration) time.Duration {
if left <= 0 {
return right
}
if right <= 0 || left < right {
return left
}
return right
}

View File

@@ -0,0 +1,229 @@
package eventbus
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestDurableOutboxWALConcurrentGroupCommitRecoversEveryRecord(t *testing.T) {
dir := t.TempDir()
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{
Directory: dir,
SyncWrites: true,
CommitBatch: 64,
CommitInterval: 5 * time.Millisecond,
})
if err != nil {
t.Fatalf("new WAL: %v", err)
}
const total = 256
refs := make(chan outboxWALRecordRef, total)
errs := make(chan error, total)
var workers sync.WaitGroup
for i := 0; i < total; i++ {
workers.Add(1)
go func(sequence int) {
defer workers.Done()
record := walTestRecord(sequence)
stored, appendErr := wal.Append(context.Background(), record)
if appendErr != nil {
errs <- appendErr
return
}
refs <- stored.Ref
}(i)
}
workers.Wait()
close(errs)
for appendErr := range errs {
t.Fatalf("concurrent append: %v", appendErr)
}
close(refs)
for ref := range refs {
wal.Release(ref)
}
if got, _ := wal.Stats(); got != total {
t.Fatalf("backlog before restart = %d, want %d", got, total)
}
if err := wal.Close(); err != nil {
t.Fatalf("close first WAL: %v", err)
}
recovered, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: dir, SyncWrites: true})
if err != nil {
t.Fatalf("reopen WAL: %v", err)
}
records, err := recovered.ClaimPending(total + 1)
if err != nil {
t.Fatalf("claim recovered records: %v", err)
}
if len(records) != total {
t.Fatalf("recovered records = %d, want %d", len(records), total)
}
seen := make(map[string]struct{}, total)
for _, record := range records {
seen[record.Record.Envelope.EventID] = struct{}{}
if err := recovered.Ack(record.Ref); err != nil {
t.Fatalf("ack recovered record: %v", err)
}
}
if len(seen) != total {
t.Fatalf("unique recovered event ids = %d, want %d", len(seen), total)
}
if got, _ := recovered.Stats(); got != 0 {
t.Fatalf("backlog after ack = %d, want 0", got)
}
if err := recovered.Close(); err != nil {
t.Fatalf("close recovered WAL: %v", err)
}
}
func TestDurableOutboxWALTruncatesIncompleteTrailingFrame(t *testing.T) {
dir := t.TempDir()
first := encodedWALTestFrame(t, 1)
second := encodedWALTestFrame(t, 2)
path := filepath.Join(dir, outboxWALFileName(1))
payload := append(append([]byte{}, first...), second[:len(second)/2]...)
if err := os.WriteFile(path, payload, 0o640); err != nil {
t.Fatalf("write incomplete WAL: %v", err)
}
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: dir, SyncWrites: true})
if err != nil {
t.Fatalf("recover incomplete WAL: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat recovered segment: %v", err)
}
if got, want := info.Size(), int64(len(first)); got != want {
t.Fatalf("truncated size = %d, want %d", got, want)
}
if got, _ := wal.Stats(); got != 1 {
t.Fatalf("recovered backlog = %d, want 1", got)
}
if err := wal.Close(); err != nil {
t.Fatalf("close WAL: %v", err)
}
}
func TestDurableOutboxWALRejectsChecksumCorruption(t *testing.T) {
dir := t.TempDir()
frame := encodedWALTestFrame(t, 1)
frame[len(frame)-1] ^= 0xff
path := filepath.Join(dir, outboxWALFileName(1))
if err := os.WriteFile(path, frame, 0o640); err != nil {
t.Fatalf("write corrupt WAL: %v", err)
}
_, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: dir})
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("corrupt WAL error = %v", err)
}
}
func TestDurableOutboxWALAckDeletesClosedSegment(t *testing.T) {
dir := t.TempDir()
frameSize := int64(len(encodedWALTestFrame(t, 1)))
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{
Directory: dir,
SegmentBytes: frameSize,
CommitBatch: 1,
})
if err != nil {
t.Fatalf("new WAL: %v", err)
}
first, err := wal.Append(context.Background(), walTestRecord(1))
if err != nil {
t.Fatalf("append first: %v", err)
}
firstPath := filepath.Join(dir, outboxWALFileName(first.Ref.segmentID))
second, err := wal.Append(context.Background(), walTestRecord(2))
if err != nil {
t.Fatalf("append second: %v", err)
}
if first.Ref.segmentID == second.Ref.segmentID {
t.Fatal("second append should rotate to a new segment")
}
if err := wal.Ack(first.Ref); err != nil {
t.Fatalf("ack first: %v", err)
}
if _, err := os.Stat(firstPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("closed acknowledged segment still exists: %v", err)
}
wal.Release(second.Ref)
if err := wal.Close(); err != nil {
t.Fatalf("close WAL: %v", err)
}
}
func TestDurableOutboxWALClaimIsBoundedAndNotDuplicated(t *testing.T) {
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: t.TempDir(), CommitBatch: 1})
if err != nil {
t.Fatalf("new WAL: %v", err)
}
for i := 0; i < 10; i++ {
stored, err := wal.Append(context.Background(), walTestRecord(i))
if err != nil {
t.Fatalf("append %d: %v", i, err)
}
wal.Release(stored.Ref)
}
first, err := wal.ClaimPending(3)
if err != nil || len(first) != 3 {
t.Fatalf("first claim = %d, error = %v", len(first), err)
}
second, err := wal.ClaimPending(3)
if err != nil || len(second) != 3 {
t.Fatalf("second claim = %d, error = %v", len(second), err)
}
claimed := map[outboxWALRecordRef]struct{}{}
for _, record := range append(first, second...) {
if _, duplicate := claimed[record.Ref]; duplicate {
t.Fatalf("record claimed twice: %#v", record.Ref)
}
claimed[record.Ref] = struct{}{}
wal.Release(record.Ref)
}
if err := wal.Close(); err != nil {
t.Fatalf("close WAL: %v", err)
}
}
func TestDurableOutboxWALRejectsAppendAfterClose(t *testing.T) {
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: t.TempDir()})
if err != nil {
t.Fatalf("new WAL: %v", err)
}
if err := wal.Close(); err != nil {
t.Fatalf("close WAL: %v", err)
}
_, err = wal.Append(context.Background(), walTestRecord(1))
if !errors.Is(err, ErrDurableOutboxWALClosed) {
t.Fatalf("append after close error = %v", err)
}
}
func walTestRecord(sequence int) durableRecord {
env := normalizeDurableEnvelope(durableTestEnvelope())
env.EventID = "wal-event-" + time.Unix(0, int64(sequence)+1).UTC().Format("150405.000000000")
env.Sequence = uint16(sequence)
return durableRecord{Kind: "raw", Envelope: env}
}
func encodedWALTestFrame(t *testing.T, sequence int) []byte {
t.Helper()
payload, err := json.Marshal(walTestRecord(sequence))
if err != nil {
t.Fatalf("marshal WAL test record: %v", err)
}
return encodeOutboxWALFrame(payload)
}

View File

@@ -13,16 +13,21 @@ import (
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
type DurableConfig struct {
Directory string
ReplayBatchSize int
Metrics *metrics.Registry
Name string
}
type DurableSink struct {
delegate Sink
dir string
metrics *metrics.Registry
name string
mu sync.Mutex
seq uint64
@@ -50,12 +55,16 @@ func NewDurableSink(delegate Sink, cfg DurableConfig) *DurableSink {
if delegate == nil {
panic("durable delegate sink must not be nil")
}
return &DurableSink{
s := &DurableSink{
delegate: delegate,
dir: strings.TrimSpace(cfg.Directory),
metrics: cfg.Metrics,
name: durableMetricName(cfg.Name),
rawPending: map[string]struct{}{},
replayBatchSize: cfg.ReplayBatchSize,
}
s.recordBacklogAfterReplay()
return s
}
func (s *DurableSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -96,16 +105,29 @@ func (s *DurableSink) ReplayOnce(ctx context.Context) error {
func (s *DurableSink) replay(ctx context.Context, limit int) error {
files, err := durableFiles(s.dir, limit)
if err != nil {
s.recordReplay("list_error", 0)
return err
}
s.recordBacklogAfterReplay()
records := make([]durableRecordFile, 0, len(files))
for _, file := range files {
record, err := readDurableRecord(file)
if err != nil {
return err
s.recordReplay("read_error", 1)
if quarantineErr := quarantineDurableFile(file); quarantineErr != nil {
s.recordReplay("quarantine_error", 1)
return fmt.Errorf("quarantine unreadable durable record %s: read error: %w; quarantine error: %v", file, err, quarantineErr)
}
s.recordReplay("quarantined", 1)
continue
}
records = append(records, durableRecordFile{path: file, record: record})
}
if len(records) == 0 {
s.recordReplay("empty", 0)
s.recordBacklogAfterReplay()
return nil
}
sortDurableRecords(records)
if publisher, ok := s.delegate.(recordPublishingSink); ok {
durableRecords := make([]durableRecord, 0, len(records))
@@ -113,29 +135,41 @@ func (s *DurableSink) replay(ctx context.Context, limit int) error {
durableRecords = append(durableRecords, item.record)
}
if err := publisher.PublishRecords(ctx, durableRecords); err != nil {
s.recordReplay("publish_error", len(records))
s.recordReplayRecords(records, "publish_error")
return err
}
for _, item := range records {
if err := os.Remove(item.path); err != nil {
s.recordReplay("delete_error", len(records))
return err
}
if item.record.Kind == "raw" {
s.clearRawPending(item.record.Envelope)
}
}
s.recordReplay("ok", len(records))
s.recordReplayRecords(records, "ok")
s.recordBacklogAfterReplay()
return nil
}
for _, item := range records {
if err := s.publishRecord(ctx, item.record); err != nil {
s.recordReplay("publish_error", len(records))
s.recordReplayRecord(item.record, "publish_error")
return err
}
if err := os.Remove(item.path); err != nil {
s.recordReplay("delete_error", len(records))
return err
}
if item.record.Kind == "raw" {
s.clearRawPending(item.record.Envelope)
}
s.recordReplayRecord(item.record, "ok")
}
s.recordReplay("ok", len(records))
s.recordBacklogAfterReplay()
return nil
}
@@ -210,7 +244,7 @@ func errUnknownRecordKind(kind string) error {
func (s *DurableSink) spool(kind string, env envelope.FrameEnvelope) error {
if s.dir == "" {
return fmt.Errorf("durable spool directory is empty")
return s.spoolError(kind, fmt.Errorf("durable spool directory is empty"))
}
if env.EventID == "" {
env.EventID = env.StableEventID()
@@ -219,19 +253,24 @@ func (s *DurableSink) spool(kind string, env envelope.FrameEnvelope) error {
env.ParseStatus = envelope.ParseOK
}
if err := os.MkdirAll(s.dir, 0o750); err != nil {
return err
return s.spoolError(kind, err)
}
payload, err := json.Marshal(durableRecord{Kind: kind, Envelope: env})
if err != nil {
return err
return s.spoolError(kind, err)
}
name := s.nextFileName(env, kind)
path := filepath.Join(s.dir, name)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o640); err != nil {
return err
return s.spoolError(kind, err)
}
return os.Rename(tmp, path)
if err := os.Rename(tmp, path); err != nil {
return s.spoolError(kind, err)
}
s.recordSpool(kind, "ok")
s.recordBacklogAfterReplay()
return nil
}
func (s *DurableSink) nextFileName(env envelope.FrameEnvelope, kind string) string {
@@ -273,6 +312,16 @@ func readDurableRecord(path string) (durableRecord, error) {
return record, json.Unmarshal(payload, &record)
}
func quarantineDurableFile(path string) error {
target := path + ".bad"
if _, err := os.Stat(target); err == nil {
target = fmt.Sprintf("%s.%d.bad", path, time.Now().UnixNano())
} else if !os.IsNotExist(err) {
return err
}
return os.Rename(path, target)
}
func durableFiles(dir string, limit int) ([]string, error) {
if limit > 0 {
handle, err := os.Open(dir)
@@ -330,3 +379,108 @@ func durableFilesFromReader(dir string, limit int, reader durableNameReader) ([]
func errorsIsEOF(err error) bool {
return err == io.EOF
}
func durableMetricName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "default"
}
return name
}
func (s *DurableSink) spoolError(kind string, err error) error {
s.recordSpool(kind, "error")
return err
}
func (s *DurableSink) recordSpool(kind string, status string) {
if s == nil || s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_durable_spool_records_total", metrics.Labels{
"name": s.name,
"kind": kind,
"status": status,
})
}
func (s *DurableSink) recordReplay(status string, records int) {
if s == nil || s.metrics == nil {
return
}
labels := metrics.Labels{"name": s.name, "status": status}
s.metrics.IncCounter("vehicle_durable_spool_replay_total", labels)
if records > 0 {
s.metrics.AddCounter("vehicle_durable_spool_replay_records_total", metrics.Labels{
"name": s.name,
"kind": "all",
"status": status,
}, float64(records))
}
}
func (s *DurableSink) recordReplayRecords(records []durableRecordFile, status string) {
for _, item := range records {
s.recordReplayRecord(item.record, status)
}
}
func (s *DurableSink) recordReplayRecord(record durableRecord, status string) {
if s == nil || s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_durable_spool_replay_records_total", metrics.Labels{
"name": s.name,
"kind": record.Kind,
"status": status,
})
}
func (s *DurableSink) recordBacklogAfterReplay() {
if s == nil || s.metrics == nil {
return
}
if strings.TrimSpace(s.dir) == "" {
s.recordBacklog(0, 0)
return
}
files, oldestAge, err := durableBacklogStats(s.dir, time.Now())
if err != nil {
return
}
s.recordBacklog(files, oldestAge)
}
func (s *DurableSink) recordBacklog(files int, oldestAge time.Duration) {
if s == nil || s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_durable_spool_backlog_files", metrics.Labels{"name": s.name}, float64(files))
ageSeconds := 0.0
if files > 0 && oldestAge > 0 {
ageSeconds = oldestAge.Seconds()
}
s.metrics.SetGauge("vehicle_durable_spool_oldest_age_seconds", metrics.Labels{"name": s.name}, ageSeconds)
}
func durableBacklogStats(dir string, now time.Time) (count int, oldestAge time.Duration, err error) {
files, err := durableFiles(dir, 0)
if err != nil {
return 0, 0, err
}
for _, file := range files {
info, statErr := os.Stat(file)
if statErr != nil {
return 0, 0, statErr
}
age := now.Sub(info.ModTime())
if age < 0 {
age = 0
}
if count == 0 || age > oldestAge {
oldestAge = age
}
count++
}
return count, oldestAge, nil
}

View File

@@ -8,8 +8,10 @@ import (
"os"
"path/filepath"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestDurableSinkSpoolsUnifiedWhenRawWasSpooled(t *testing.T) {
@@ -167,6 +169,156 @@ func TestDurableSinkReplayUsesBatchPublisherWhenAvailable(t *testing.T) {
}
}
func TestDurableSinkRecordsSpoolAndReplayMetrics(t *testing.T) {
dir := t.TempDir()
registry := metrics.NewRegistry()
delegate := &scriptedSink{rawErrors: []error{errSpoolTest}}
sink := NewDurableSink(delegate, DurableConfig{Directory: dir, Metrics: registry, Name: "nats"})
env := durableTestEnvelope()
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
text := registry.Render()
for _, want := range []string{
`vehicle_durable_spool_records_total{kind="raw",name="nats",status="ok"} 1`,
`vehicle_durable_spool_backlog_files{name="nats"} 1`,
`vehicle_durable_spool_oldest_age_seconds{name="nats"}`,
} {
if !containsString(text, want) {
t.Fatalf("spool metric missing %s:\n%s", want, text)
}
}
delegate.rawErrors = nil
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
text = registry.Render()
for _, want := range []string{
`vehicle_durable_spool_replay_total{name="nats",status="ok"} 1`,
`vehicle_durable_spool_replay_records_total{kind="all",name="nats",status="ok"} 1`,
`vehicle_durable_spool_replay_records_total{kind="raw",name="nats",status="ok"} 1`,
`vehicle_durable_spool_backlog_files{name="nats"} 0`,
`vehicle_durable_spool_oldest_age_seconds{name="nats"} 0`,
} {
if !containsString(text, want) {
t.Fatalf("replay metric missing %s:\n%s", want, text)
}
}
}
func TestDurableSinkInitializesBacklogMetrics(t *testing.T) {
dir := t.TempDir()
registry := metrics.NewRegistry()
_ = NewDurableSink(&scriptedSink{}, DurableConfig{Directory: dir, Metrics: registry, Name: "nats"})
text := registry.Render()
for _, want := range []string{
`vehicle_durable_spool_backlog_files{name="nats"} 0`,
`vehicle_durable_spool_oldest_age_seconds{name="nats"} 0`,
} {
if !containsString(text, want) {
t.Fatalf("initial spool metric missing %s:\n%s", want, text)
}
}
}
func TestDurableSinkRecordsReplayPublishErrorMetrics(t *testing.T) {
dir := t.TempDir()
registry := metrics.NewRegistry()
env := durableTestEnvelope()
writeDurableRecord(t, filepath.Join(dir, "0001-raw.json"), durableRecord{Kind: "raw", Envelope: env})
delegate := &scriptedSink{rawErrors: []error{errSpoolTest}}
sink := NewDurableSink(delegate, DurableConfig{Directory: dir, Metrics: registry, Name: "kafka"})
if err := sink.ReplayOnce(context.Background()); err == nil {
t.Fatal("ReplayOnce() error = nil, want delegate publish error")
}
text := registry.Render()
for _, want := range []string{
`vehicle_durable_spool_replay_total{name="kafka",status="publish_error"} 1`,
`vehicle_durable_spool_replay_records_total{kind="all",name="kafka",status="publish_error"} 1`,
`vehicle_durable_spool_replay_records_total{kind="raw",name="kafka",status="publish_error"} 1`,
`vehicle_durable_spool_backlog_files{name="kafka"} 1`,
} {
if !containsString(text, want) {
t.Fatalf("replay error metric missing %s:\n%s", want, text)
}
}
if files := spoolFiles(t, dir); len(files) != 1 {
t.Fatalf("failed replay should keep spool file, files=%#v", files)
}
}
func TestDurableBacklogStatsCountsAllFilesAndOldestAge(t *testing.T) {
dir := t.TempDir()
env := durableTestEnvelope()
now := time.Date(2026, 7, 12, 15, 0, 0, 0, time.UTC)
oldFile := filepath.Join(dir, "0001-raw.json")
newFile := filepath.Join(dir, "0002-fields.json")
writeDurableRecord(t, oldFile, durableRecord{Kind: "raw", Envelope: env})
writeDurableRecord(t, newFile, durableRecord{Kind: "fields", Envelope: env})
if err := os.Chtimes(oldFile, now.Add(-10*time.Minute), now.Add(-10*time.Minute)); err != nil {
t.Fatalf("chtimes old file: %v", err)
}
if err := os.Chtimes(newFile, now.Add(-30*time.Second), now.Add(-30*time.Second)); err != nil {
t.Fatalf("chtimes new file: %v", err)
}
count, oldestAge, err := durableBacklogStats(dir, now)
if err != nil {
t.Fatalf("durableBacklogStats() error = %v", err)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
if oldestAge != 10*time.Minute {
t.Fatalf("oldestAge = %s, want 10m", oldestAge)
}
}
func TestDurableSinkQuarantinesBadRecordAndReplaysRemainingFiles(t *testing.T) {
dir := t.TempDir()
registry := metrics.NewRegistry()
env := durableTestEnvelope()
if err := os.WriteFile(filepath.Join(dir, "0001-bad.json"), []byte("{bad json"), 0o640); err != nil {
t.Fatalf("write bad durable record: %v", err)
}
writeDurableRecord(t, filepath.Join(dir, "0002-raw.json"), durableRecord{Kind: "raw", Envelope: env})
delegate := &scriptedSink{}
sink := NewDurableSink(delegate, DurableConfig{Directory: dir, Metrics: registry, Name: "nats"})
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
if delegate.rawCalls != 1 {
t.Fatalf("raw calls = %d, want 1", delegate.rawCalls)
}
if files := spoolFiles(t, dir); len(files) != 0 {
t.Fatalf("normal spool files after replay = %#v, want none", files)
}
badFiles, err := filepath.Glob(filepath.Join(dir, "*.bad"))
if err != nil {
t.Fatalf("glob bad files: %v", err)
}
if len(badFiles) != 1 {
t.Fatalf("bad files = %#v, want one quarantined file", badFiles)
}
text := registry.Render()
for _, want := range []string{
`vehicle_durable_spool_replay_total{name="nats",status="read_error"} 1`,
`vehicle_durable_spool_replay_total{name="nats",status="quarantined"} 1`,
`vehicle_durable_spool_replay_total{name="nats",status="ok"} 1`,
`vehicle_durable_spool_replay_records_total{kind="raw",name="nats",status="ok"} 1`,
`vehicle_durable_spool_backlog_files{name="nats"} 0`,
} {
if !containsString(text, want) {
t.Fatalf("quarantine metric missing %s:\n%s", want, text)
}
}
}
func TestDurableFilesFromReaderStopsAfterLimitedJSONBatch(t *testing.T) {
reader := &fakeNameReader{
batches: [][]string{

View File

@@ -0,0 +1,30 @@
package eventbus
import "github.com/segmentio/kafka-go"
// MessagesAfterCommittedPrefixes keeps every fetched message that is not
// covered by the highest committed offset for its topic partition. This is
// used when a batch partially succeeds: later poison or valid messages must
// remain in memory until the failed offset ahead of them is durably handled.
func MessagesAfterCommittedPrefixes(messages []kafka.Message, committed []kafka.Message) []kafka.Message {
type partitionKey struct {
topic string
partition int
}
highest := make(map[partitionKey]int64, len(committed))
for _, message := range committed {
key := partitionKey{topic: message.Topic, partition: message.Partition}
if offset, ok := highest[key]; !ok || message.Offset > offset {
highest[key] = message.Offset
}
}
remaining := make([]kafka.Message, 0, len(messages))
for _, message := range messages {
key := partitionKey{topic: message.Topic, partition: message.Partition}
if offset, ok := highest[key]; ok && message.Offset <= offset {
continue
}
remaining = append(remaining, message)
}
return remaining
}

View File

@@ -0,0 +1,34 @@
package eventbus
import (
"testing"
"github.com/segmentio/kafka-go"
)
func TestMessagesAfterCommittedPrefixesKeepsPartitionGaps(t *testing.T) {
messages := []kafka.Message{
{Topic: "raw", Partition: 0, Offset: 10},
{Topic: "raw", Partition: 1, Offset: 20},
{Topic: "raw", Partition: 0, Offset: 11},
{Topic: "raw", Partition: 1, Offset: 21},
{Topic: "raw", Partition: 0, Offset: 12},
}
committed := []kafka.Message{
{Topic: "raw", Partition: 0, Offset: 10},
{Topic: "raw", Partition: 1, Offset: 21},
}
remaining := MessagesAfterCommittedPrefixes(messages, committed)
if len(remaining) != 2 || remaining[0].Partition != 0 || remaining[0].Offset != 11 || remaining[1].Offset != 12 {
t.Fatalf("remaining = %#v", remaining)
}
}
func TestMessagesAfterCommittedPrefixesWithoutCommitKeepsWholeBatch(t *testing.T) {
messages := []kafka.Message{{Topic: "raw", Partition: 0, Offset: 10}}
remaining := MessagesAfterCommittedPrefixes(messages, nil)
if len(remaining) != 1 || remaining[0].Offset != 10 {
t.Fatalf("remaining = %#v", remaining)
}
}

View File

@@ -41,16 +41,38 @@ func NewKafkaSink(cfg KafkaConfig) (*KafkaSink, error) {
if len(cfg.Brokers) == 0 {
return nil, errors.New("kafka brokers are required")
}
if err := ValidateKafkaConfig(cfg); err != nil {
return nil, err
}
rawTopics, fieldsTopics := kafkaTopicMaps(cfg)
return newKafkaSinkWithWriter(&kafka.Writer{
Addr: kafka.TCP(cfg.Brokers...),
Balancer: &kafka.Hash{},
AllowAutoTopicCreation: false,
RequiredAcks: kafka.RequireAll,
Async: false,
}, cfg), nil
}, KafkaConfig{
RawTopics: rawTopics,
FieldsTopics: fieldsTopics,
UnifiedTopic: cfg.UnifiedTopic,
}), nil
}
func ValidateKafkaConfig(cfg KafkaConfig) error {
rawTopics, fieldsTopics := kafkaTopicMaps(cfg)
return topics.ValidateKafkaRawFields(protocolTopicLabels(rawTopics), protocolTopicLabels(fieldsTopics))
}
func newKafkaSinkWithWriter(writer kafkaWriter, cfg KafkaConfig) *KafkaSink {
rawTopics, fieldsTopics := kafkaTopicMaps(cfg)
unifiedTopic := cfg.UnifiedTopic
if unifiedTopic == "" {
unifiedTopic = topics.Unified
}
return &KafkaSink{writer: writer, rawTopics: rawTopics, fieldsTopics: fieldsTopics, unifiedTopic: unifiedTopic}
}
func kafkaTopicMaps(cfg KafkaConfig) (map[envelope.Protocol]string, map[envelope.Protocol]string) {
rawTopics := map[envelope.Protocol]string{
envelope.ProtocolGB32960: topics.RawGB32960,
envelope.ProtocolJT808: topics.RawJT808,
@@ -71,11 +93,15 @@ func newKafkaSinkWithWriter(writer kafkaWriter, cfg KafkaConfig) *KafkaSink {
fieldsTopics[protocol] = topic
}
}
unifiedTopic := cfg.UnifiedTopic
if unifiedTopic == "" {
unifiedTopic = topics.Unified
return rawTopics, fieldsTopics
}
func protocolTopicLabels(values map[envelope.Protocol]string) map[string]string {
out := make(map[string]string, len(values))
for protocol, topic := range values {
out[string(protocol)] = topic
}
return &KafkaSink{writer: writer, rawTopics: rawTopics, fieldsTopics: fieldsTopics, unifiedTopic: unifiedTopic}
return out
}
func (s *KafkaSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/segmentio/kafka-go"
@@ -112,6 +113,49 @@ func TestNewKafkaSinkUsesProductionDeliveryGuarantees(t *testing.T) {
}
}
func TestValidateKafkaConfigRejectsRawFieldsTopicOverlap(t *testing.T) {
err := ValidateKafkaConfig(KafkaConfig{
RawTopics: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.jt808.v1",
},
FieldsTopics: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.jt808.v1",
},
})
if err == nil {
t.Fatal("ValidateKafkaConfig() error = nil, want overlap rejection")
}
if !strings.Contains(err.Error(), "fields kafka topic") {
t.Fatalf("error = %q, want fields topic family hint", err)
}
}
func TestValidateKafkaConfigRejectsKnownProtocolTopicMismatch(t *testing.T) {
err := ValidateKafkaConfig(KafkaConfig{
RawTopics: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.gb32960.v1",
},
})
if err == nil {
t.Fatal("ValidateKafkaConfig() error = nil, want raw protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
err = ValidateKafkaConfig(KafkaConfig{
FieldsTopics: map[envelope.Protocol]string{
envelope.ProtocolYutongMQTT: "vehicle.fields.go.jt808.v1",
},
})
if err == nil {
t.Fatal("ValidateKafkaConfig() error = nil, want fields protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
}
type recordingWriter struct {
messages []kafka.Message
writeCalls int

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
@@ -13,16 +14,19 @@ import (
)
type NATSConfig struct {
URL string
Name string
RawSubjects map[envelope.Protocol]string
FieldsSubjects map[envelope.Protocol]string
UnifiedSubject string
URL string
Name string
RawSubjects map[envelope.Protocol]string
FieldsSubjects map[envelope.Protocol]string
UnifiedSubject string
AsyncMaxPending int
AsyncAckTimeout time.Duration
}
type NATSSink struct {
conn *nats.Conn
publisher natsPublisher
asyncPublisher natsAsyncPublisher
rawSubjects map[envelope.Protocol]string
fieldsSubjects map[envelope.Protocol]string
unifiedSubject string
@@ -34,10 +38,18 @@ type natsPublisher interface {
Publish(context.Context, string, []byte, ...NATSPublishOption) error
}
type natsAsyncPublisher interface {
PublishAsync(string, []byte, ...NATSPublishOption) (nats.PubAckFuture, error)
}
func NewNATSSink(cfg NATSConfig) (*NATSSink, error) {
if cfg.URL == "" {
return nil, errors.New("nats url is required")
}
if err := ValidateNATSConfig(cfg); err != nil {
return nil, err
}
rawSubjects, fieldsSubjects := natsSubjectMaps(cfg)
name := cfg.Name
if name == "" {
name = "lingniu-vehicle-gateway"
@@ -46,17 +58,58 @@ func NewNATSSink(cfg NATSConfig) (*NATSSink, error) {
if err != nil {
return nil, err
}
js, err := conn.JetStream()
var jsOptions []nats.JSOpt
if cfg.AsyncMaxPending > 0 {
jsOptions = append(jsOptions, nats.PublishAsyncMaxPending(cfg.AsyncMaxPending))
}
if cfg.AsyncAckTimeout > 0 {
jsOptions = append(jsOptions, nats.PublishAsyncTimeout(cfg.AsyncAckTimeout))
}
js, err := conn.JetStream(jsOptions...)
if err != nil {
conn.Close()
return nil, err
}
sink := newNATSSinkWithPublisher(natsJetStreamPublisher{js: js}, cfg)
publisher := natsJetStreamPublisher{js: js}
sink := newNATSSinkWithPublishers(publisher, publisher, NATSConfig{
RawSubjects: rawSubjects,
FieldsSubjects: fieldsSubjects,
UnifiedSubject: cfg.UnifiedSubject,
})
sink.conn = conn
return sink, nil
}
func ValidateNATSConfig(cfg NATSConfig) error {
rawSubjects, fieldsSubjects := natsSubjectMaps(cfg)
raw := protocolTopicLabels(rawSubjects)
fields := protocolTopicLabels(fieldsSubjects)
if err := topics.ValidateKnownRawFieldsProtocols(raw, fields, "nats subject"); err != nil {
return err
}
return topics.ValidateRawFieldsDisjoint(raw, fields, "nats subject")
}
func newNATSSinkWithPublisher(publisher natsPublisher, cfg NATSConfig) *NATSSink {
return newNATSSinkWithPublishers(publisher, nil, cfg)
}
func newNATSSinkWithPublishers(publisher natsPublisher, asyncPublisher natsAsyncPublisher, cfg NATSConfig) *NATSSink {
rawSubjects, fieldsSubjects := natsSubjectMaps(cfg)
unifiedSubject := cfg.UnifiedSubject
if unifiedSubject == "" {
unifiedSubject = topics.Unified
}
return &NATSSink{
publisher: publisher,
asyncPublisher: asyncPublisher,
rawSubjects: rawSubjects,
fieldsSubjects: fieldsSubjects,
unifiedSubject: unifiedSubject,
}
}
func natsSubjectMaps(cfg NATSConfig) (map[envelope.Protocol]string, map[envelope.Protocol]string) {
rawSubjects := map[envelope.Protocol]string{
envelope.ProtocolGB32960: topics.RawGB32960,
envelope.ProtocolJT808: topics.RawJT808,
@@ -77,16 +130,7 @@ func newNATSSinkWithPublisher(publisher natsPublisher, cfg NATSConfig) *NATSSink
fieldsSubjects[protocol] = subject
}
}
unifiedSubject := cfg.UnifiedSubject
if unifiedSubject == "" {
unifiedSubject = topics.Unified
}
return &NATSSink{
publisher: publisher,
rawSubjects: rawSubjects,
fieldsSubjects: fieldsSubjects,
unifiedSubject: unifiedSubject,
}
return rawSubjects, fieldsSubjects
}
func (s *NATSSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -94,14 +138,14 @@ func (s *NATSSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) e
if !ok || subject == "" {
return fmt.Errorf("raw subject not configured for protocol %s", env.Protocol)
}
return s.publish(ctx, subject, env)
return s.publish(ctx, subject, "raw", env)
}
func (s *NATSSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
if s.unifiedSubject == "" {
return errors.New("unified subject is empty")
}
return s.publish(ctx, s.unifiedSubject, env)
return s.publish(ctx, s.unifiedSubject, "unified", env)
}
func (s *NATSSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -109,7 +153,62 @@ func (s *NATSSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope
if !ok || subject == "" {
return fmt.Errorf("fields subject not configured for protocol %s", env.Protocol)
}
return s.publish(ctx, subject, env)
return s.publish(ctx, subject, "fields", env)
}
func (s *NATSSink) PublishRecords(ctx context.Context, records []durableRecord) error {
for _, record := range records {
subject, err := s.subjectForRecord(record)
if err != nil {
return err
}
if err := s.publish(ctx, subject, record.Kind, record.Envelope); err != nil {
return err
}
}
return nil
}
func (s *NATSSink) PublishRecordAsync(record durableRecord, complete func(error)) error {
if s == nil || s.asyncPublisher == nil {
return errors.New("nats async publisher is not configured")
}
if complete == nil {
return errors.New("nats async publish completion callback is required")
}
subject, err := s.subjectForRecord(record)
if err != nil {
return err
}
payload, err := record.Envelope.MarshalJSONBytes()
if err != nil {
return err
}
future, err := s.asyncPublisher.PublishAsync(
subject,
payload,
nats.MsgId(natsMessageID(record.Kind, subject, record.Envelope)),
)
if err != nil {
return err
}
go func() {
select {
case <-future.Ok():
complete(nil)
case asyncErr := <-future.Err():
if asyncErr == nil {
asyncErr = errors.New("nats async publish failed without error detail")
}
complete(asyncErr)
}
}()
return nil
}
func (s *NATSSink) ValidateRecord(record durableRecord) error {
_, err := s.subjectForRecord(record)
return err
}
func (s *NATSSink) Close() error {
@@ -121,12 +220,48 @@ func (s *NATSSink) Close() error {
return nil
}
func (s *NATSSink) publish(ctx context.Context, subject string, env envelope.FrameEnvelope) error {
func (s *NATSSink) publish(ctx context.Context, subject string, kind string, env envelope.FrameEnvelope) error {
payload, err := env.MarshalJSONBytes()
if err != nil {
return err
}
return s.publisher.Publish(ctx, subject, payload, nats.MsgId(env.StableEventID()))
return s.publisher.Publish(ctx, subject, payload, nats.MsgId(natsMessageID(kind, subject, env)))
}
func natsMessageID(kind string, subject string, env envelope.FrameEnvelope) string {
kind = strings.TrimSpace(kind)
if kind == "" {
kind = "unknown"
}
subject = strings.TrimSpace(subject)
if subject == "" {
subject = "unknown"
}
return kind + ":" + subject + ":" + env.StableEventID()
}
func (s *NATSSink) subjectForRecord(record durableRecord) (string, error) {
switch record.Kind {
case "raw":
subject, ok := s.rawSubjects[record.Envelope.Protocol]
if !ok || subject == "" {
return "", fmt.Errorf("raw subject not configured for protocol %s", record.Envelope.Protocol)
}
return subject, nil
case "unified":
if s.unifiedSubject == "" {
return "", errors.New("unified subject is empty")
}
return s.unifiedSubject, nil
case "fields":
subject, ok := s.fieldsSubjects[record.Envelope.Protocol]
if !ok || subject == "" {
return "", fmt.Errorf("fields subject not configured for protocol %s", record.Envelope.Protocol)
}
return subject, nil
default:
return "", errUnknownRecordKind(record.Kind)
}
}
type natsJetStreamPublisher struct {
@@ -137,3 +272,7 @@ func (p natsJetStreamPublisher) Publish(ctx context.Context, subject string, dat
_, err := p.js.Publish(subject, data, append(opts, nats.Context(ctx))...)
return err
}
func (p natsJetStreamPublisher) PublishAsync(subject string, data []byte, opts ...NATSPublishOption) (nats.PubAckFuture, error) {
return p.js.PublishAsync(subject, data, opts...)
}

View File

@@ -3,7 +3,12 @@ package eventbus
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
@@ -57,6 +62,190 @@ func TestNATSSinkDefaultsToGoRawSubjects(t *testing.T) {
}
}
func TestNATSSinkPublishesDurableRecords(t *testing.T) {
publisher := &recordingNATSPublisher{}
sink := newNATSSinkWithPublisher(publisher, NATSConfig{
RawSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.jt808.v1",
},
FieldsSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.fields.go.jt808.v1",
},
UnifiedSubject: "vehicle.event.go.unified.v1",
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
err := sink.PublishRecords(context.Background(), []durableRecord{
{Kind: "raw", Envelope: env},
{Kind: "fields", Envelope: env},
{Kind: "unified", Envelope: env},
})
if err != nil {
t.Fatalf("PublishRecords() error = %v", err)
}
if len(publisher.messages) != 3 {
t.Fatalf("published messages = %d, want 3", len(publisher.messages))
}
for i, want := range []string{
"vehicle.raw.go.jt808.v1",
"vehicle.fields.go.jt808.v1",
"vehicle.event.go.unified.v1",
} {
if got := publisher.messages[i].subject; got != want {
t.Fatalf("message %d subject = %q, want %q", i, got, want)
}
}
}
func TestNATSMessageIDSeparatesKindAndSubject(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200", Sequence: 7}
raw := natsMessageID("raw", "vehicle.raw.go.jt808.v1", env)
rawRetry := natsMessageID("raw", "vehicle.raw.go.jt808.v1", env)
fields := natsMessageID("fields", "vehicle.fields.go.jt808.v1", env)
unified := natsMessageID("unified", "vehicle.event.go.unified.v1", env)
rawOtherSubject := natsMessageID("raw", "vehicle.raw.go.gb32960.v1", env)
if raw != rawRetry {
t.Fatalf("same kind/subject/event id should be stable: %q vs %q", raw, rawRetry)
}
if raw == fields || raw == unified || raw == rawOtherSubject {
t.Fatalf("message ids should be unique per kind and subject: raw=%q fields=%q unified=%q rawOther=%q", raw, fields, unified, rawOtherSubject)
}
if !strings.Contains(raw, env.StableEventID()) {
t.Fatalf("message id %q should retain stable event id %q", raw, env.StableEventID())
}
}
func TestNATSSinkPublishRecordsRejectsUnknownKind(t *testing.T) {
sink := newNATSSinkWithPublisher(&recordingNATSPublisher{}, NATSConfig{})
err := sink.PublishRecords(context.Background(), []durableRecord{
{Kind: "unknown", Envelope: envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}},
})
if err == nil || !strings.Contains(err.Error(), "unknown durable record kind") {
t.Fatalf("PublishRecords() error = %v, want unknown kind", err)
}
}
func TestNATSSinkAsyncPublishCompletesOnlyAfterPubAck(t *testing.T) {
future := newTestPubAckFuture()
asyncPublisher := &recordingNATSAsyncPublisher{future: future}
sink := newNATSSinkWithPublishers(&recordingNATSPublisher{}, asyncPublisher, NATSConfig{})
record := durableRecord{Kind: "raw", Envelope: normalizeDurableEnvelope(durableTestEnvelope())}
completed := make(chan error, 1)
if err := sink.PublishRecordAsync(record, func(err error) { completed <- err }); err != nil {
t.Fatalf("PublishRecordAsync() error = %v", err)
}
select {
case err := <-completed:
t.Fatalf("completion fired before PubAck: %v", err)
case <-time.After(10 * time.Millisecond):
}
if got, want := asyncPublisher.subject, "vehicle.raw.go.jt808.v1"; got != want {
t.Fatalf("async subject = %q, want %q", got, want)
}
var decoded envelope.FrameEnvelope
if err := json.Unmarshal(asyncPublisher.data, &decoded); err != nil {
t.Fatalf("decode async payload: %v", err)
}
if decoded.EventID != record.Envelope.EventID {
t.Fatalf("async event id = %q, want %q", decoded.EventID, record.Envelope.EventID)
}
future.ok <- &nats.PubAck{Stream: "VEHICLE_RAW", Sequence: 1}
select {
case err := <-completed:
if err != nil {
t.Fatalf("completion error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("completion did not fire after PubAck")
}
}
func TestNATSSinkAsyncPublishPropagatesFutureError(t *testing.T) {
future := newTestPubAckFuture()
sink := newNATSSinkWithPublishers(
&recordingNATSPublisher{},
&recordingNATSAsyncPublisher{future: future},
NATSConfig{},
)
completed := make(chan error, 1)
errWant := errors.New("jetstream ack timeout")
if err := sink.PublishRecordAsync(durableRecord{
Kind: "raw",
Envelope: normalizeDurableEnvelope(durableTestEnvelope()),
}, func(err error) { completed <- err }); err != nil {
t.Fatalf("PublishRecordAsync() error = %v", err)
}
future.err <- errWant
select {
case err := <-completed:
if !errors.Is(err, errWant) {
t.Fatalf("completion error = %v, want %v", err, errWant)
}
case <-time.After(time.Second):
t.Fatal("completion did not fire after future error")
}
}
func TestNATSSinkValidatesDurableRecordBeforeOutboxPersistence(t *testing.T) {
sink := newNATSSinkWithPublisher(&recordingNATSPublisher{}, NATSConfig{})
err := sink.ValidateRecord(durableRecord{
Kind: "raw",
Envelope: envelope.FrameEnvelope{Protocol: envelope.Protocol("UNKNOWN")},
})
if err == nil || !strings.Contains(err.Error(), "raw subject not configured") {
t.Fatalf("ValidateRecord() error = %v", err)
}
}
func TestValidateNATSConfigRejectsRawFieldsSubjectOverlap(t *testing.T) {
err := ValidateNATSConfig(NATSConfig{
RawSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.same.jt808",
},
FieldsSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.same.jt808",
},
})
if err == nil {
t.Fatal("ValidateNATSConfig() error = nil, want overlap rejection")
}
if !strings.Contains(err.Error(), "nats subject") {
t.Fatalf("error = %q, want nats subject hint", err)
}
}
func TestValidateNATSConfigRejectsKnownProtocolSubjectMismatch(t *testing.T) {
err := ValidateNATSConfig(NATSConfig{
RawSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.gb32960.v1",
},
})
if err == nil {
t.Fatal("ValidateNATSConfig() error = nil, want raw protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
err = ValidateNATSConfig(NATSConfig{
FieldsSubjects: map[envelope.Protocol]string{
envelope.ProtocolGB32960: "vehicle.fields.go.yutong-mqtt.v1",
},
})
if err == nil {
t.Fatal("ValidateNATSConfig() error = nil, want fields protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
}
type recordingNATSPublisher struct {
messages []recordedNATSMessage
}
@@ -70,3 +259,36 @@ func (p *recordingNATSPublisher) Publish(_ context.Context, subject string, data
p.messages = append(p.messages, recordedNATSMessage{subject: subject, data: append([]byte(nil), data...)})
return nil
}
type recordingNATSAsyncPublisher struct {
subject string
data []byte
future nats.PubAckFuture
err error
}
func (p *recordingNATSAsyncPublisher) PublishAsync(subject string, data []byte, _ ...NATSPublishOption) (nats.PubAckFuture, error) {
p.subject = subject
p.data = append([]byte(nil), data...)
return p.future, p.err
}
type testPubAckFuture struct {
ok chan *nats.PubAck
err chan error
msg *nats.Msg
}
func newTestPubAckFuture() *testPubAckFuture {
return &testPubAckFuture{
ok: make(chan *nats.PubAck, 1),
err: make(chan error, 1),
msg: &nats.Msg{},
}
}
func (f *testPubAckFuture) Ok() <-chan *nats.PubAck { return f.ok }
func (f *testPubAckFuture) Err() <-chan error { return f.err }
func (f *testPubAckFuture) Msg() *nats.Msg { return f.msg }

View File

@@ -0,0 +1,293 @@
package eventbus
import (
"context"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
type PartitionedAsyncConfig struct {
RawQueueSize int
DerivedQueueSize int
RawWorkers int
DerivedWorkers int
EnqueueTimeout time.Duration
RawEnqueueTimeout time.Duration
DerivedEnqueueTimeout time.Duration
OperationTimeout time.Duration
OnError func(error)
Metrics *metrics.Registry
Name string
}
type PartitionedAsyncSink struct {
delegate Sink
rawJobs chan asyncJob
derivedJobs chan asyncJob
rawEnqueueTimeout time.Duration
derivedEnqueueTimeout time.Duration
timeout time.Duration
onError func(error)
metrics *metrics.Registry
name string
queueWait *metrics.RecentLatencyByKey
closeOnce sync.Once
closed chan struct{}
done chan struct{}
wg sync.WaitGroup
}
func NewPartitionedAsyncSink(delegate Sink, cfg PartitionedAsyncConfig) *PartitionedAsyncSink {
if delegate == nil {
panic("partitioned async delegate sink must not be nil")
}
if cfg.RawQueueSize <= 0 {
cfg.RawQueueSize = 100_000
}
if cfg.DerivedQueueSize <= 0 {
cfg.DerivedQueueSize = 50_000
}
if cfg.RawWorkers <= 0 {
cfg.RawWorkers = 4
}
if cfg.DerivedWorkers <= 0 {
cfg.DerivedWorkers = 2
}
enqueueTimeout := normalizePartitionedEnqueueTimeout(cfg.EnqueueTimeout, time.Second)
rawEnqueueTimeout := normalizePartitionedEnqueueTimeout(cfg.RawEnqueueTimeout, enqueueTimeout)
derivedEnqueueTimeout := normalizePartitionedEnqueueTimeout(cfg.DerivedEnqueueTimeout, enqueueTimeout)
if cfg.OperationTimeout <= 0 {
cfg.OperationTimeout = 30 * time.Second
}
if cfg.Name == "" {
cfg.Name = "partitioned-async"
}
s := &PartitionedAsyncSink{
delegate: delegate,
rawJobs: make(chan asyncJob, cfg.RawQueueSize),
derivedJobs: make(chan asyncJob, cfg.DerivedQueueSize),
rawEnqueueTimeout: rawEnqueueTimeout,
derivedEnqueueTimeout: derivedEnqueueTimeout,
timeout: cfg.OperationTimeout,
onError: cfg.OnError,
metrics: cfg.Metrics,
name: cfg.Name,
queueWait: metrics.NewRecentLatencyByKey(512),
closed: make(chan struct{}),
done: make(chan struct{}),
}
s.startWorkers("raw", s.rawJobs, cfg.RawWorkers)
s.startWorkers("derived", s.derivedJobs, cfg.DerivedWorkers)
s.recordWorkers("raw", cfg.RawWorkers)
s.recordWorkers("derived", cfg.DerivedWorkers)
s.recordQueueCapacity("raw", cap(s.rawJobs))
s.recordQueueCapacity("derived", cap(s.derivedJobs))
go func() {
s.wg.Wait()
close(s.done)
}()
return s
}
func (s *PartitionedAsyncSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
return s.enqueue(ctx, "raw", s.rawJobs, s.rawEnqueueTimeout, asyncJob{kind: "raw", env: env})
}
func (s *PartitionedAsyncSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
return s.enqueue(ctx, "derived", s.derivedJobs, s.derivedEnqueueTimeout, asyncJob{kind: "unified", env: env})
}
func (s *PartitionedAsyncSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
return s.enqueue(ctx, "derived", s.derivedJobs, s.derivedEnqueueTimeout, asyncJob{kind: "fields", env: env})
}
func (s *PartitionedAsyncSink) Close() error {
s.closeOnce.Do(func() {
close(s.closed)
})
<-s.done
return s.delegate.Close()
}
func (s *PartitionedAsyncSink) startWorkers(queueName string, jobs <-chan asyncJob, workers int) {
s.wg.Add(workers)
for i := 0; i < workers; i++ {
go s.worker(queueName, jobs)
}
}
func (s *PartitionedAsyncSink) enqueue(ctx context.Context, queueName string, jobs chan<- asyncJob, enqueueTimeout time.Duration, job asyncJob) error {
select {
case <-s.closed:
s.recordEnqueue(job.kind, "closed")
return ErrAsyncSinkClosed
default:
}
var timeoutC <-chan time.Time
var timer *time.Timer
if enqueueTimeout > 0 {
timer = time.NewTimer(enqueueTimeout)
timeoutC = timer.C
defer timer.Stop()
}
job.enqueuedAt = time.Now()
select {
case jobs <- job:
s.recordEnqueue(job.kind, "queued")
s.recordQueueDepth(queueName)
return nil
case <-s.closed:
s.recordEnqueue(job.kind, "closed")
return ErrAsyncSinkClosed
case <-ctx.Done():
s.recordEnqueue(job.kind, "timeout")
s.recordQueueDepth(queueName)
return ctx.Err()
case <-timeoutC:
s.recordEnqueue(job.kind, "timeout")
s.recordQueueDepth(queueName)
return ErrAsyncSinkEnqueueTimeout
}
}
func normalizePartitionedEnqueueTimeout(value time.Duration, fallback time.Duration) time.Duration {
if value == 0 {
value = fallback
}
if value < 0 {
return 0
}
return value
}
func (s *PartitionedAsyncSink) worker(queueName string, jobs <-chan asyncJob) {
defer s.wg.Done()
for {
select {
case job := <-jobs:
s.publishJob(queueName, job)
case <-s.closed:
for {
select {
case job := <-jobs:
s.publishJob(queueName, job)
default:
return
}
}
}
}
}
func (s *PartitionedAsyncSink) publishJob(queueName string, job asyncJob) {
s.recordQueueDepth(queueName)
s.recordQueueWait(queueName, job)
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
started := time.Now()
var err error
switch job.kind {
case "raw":
err = s.delegate.PublishRaw(ctx, job.env)
case "unified":
err = s.delegate.PublishUnified(ctx, job.env)
case "fields":
err = s.delegate.PublishFields(ctx, job.env)
}
cancel()
status := "ok"
if err != nil {
status = "error"
}
s.recordPublish(job.kind, status, time.Since(started))
if err != nil && s.onError != nil {
s.onError(err)
}
s.recordQueueDepth(queueName)
}
func (s *PartitionedAsyncSink) recordEnqueue(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_async_sink_enqueue_total", metrics.Labels{
"sink": s.name,
"kind": kind,
"status": status,
})
}
func (s *PartitionedAsyncSink) recordPublish(kind string, status string, elapsed time.Duration) {
if s.metrics == nil {
return
}
labels := metrics.Labels{
"sink": s.name,
"kind": kind,
"status": status,
}
s.metrics.IncCounter("vehicle_async_sink_publish_total", labels)
elapsedMS := float64(elapsed.Milliseconds())
s.metrics.SetGauge("vehicle_async_sink_publish_duration_ms", labels, elapsedMS)
s.metrics.ObserveHistogram("vehicle_async_sink_publish_duration_ms_histogram", labels, asyncSinkPublishDurationBucketsMS, elapsedMS)
}
func (s *PartitionedAsyncSink) recordQueueDepth(queueName string) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_queue_depth", metrics.Labels{
"sink": s.name,
"queue": queueName,
}, float64(s.queueDepth(queueName)))
}
func (s *PartitionedAsyncSink) recordQueueCapacity(queueName string, value int) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_queue_capacity", metrics.Labels{
"sink": s.name,
"queue": queueName,
}, float64(value))
}
func (s *PartitionedAsyncSink) recordQueueWait(queueName string, job asyncJob) {
if s.metrics == nil || job.enqueuedAt.IsZero() {
return
}
elapsedMS := float64(time.Since(job.enqueuedAt)) / float64(time.Millisecond)
labels := metrics.Labels{
"sink": s.name,
"queue": queueName,
"kind": job.kind,
}
s.metrics.ObserveHistogram("vehicle_async_sink_queue_wait_duration_ms_histogram", labels, asyncSinkPublishDurationBucketsMS, elapsedMS)
p99, samples := s.queueWait.Observe(queueName+"\x00"+job.kind, elapsedMS)
s.metrics.SetGauge("vehicle_async_sink_queue_wait_recent_p99_ms", labels, p99)
s.metrics.SetGauge("vehicle_async_sink_queue_wait_recent_samples", labels, float64(samples))
}
func (s *PartitionedAsyncSink) recordWorkers(queueName string, workers int) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_workers", metrics.Labels{
"sink": s.name,
"queue": queueName,
}, float64(workers))
}
func (s *PartitionedAsyncSink) queueDepth(queueName string) int {
switch queueName {
case "raw":
return len(s.rawJobs)
case "derived":
return len(s.derivedJobs)
default:
return 0
}
}

View File

@@ -0,0 +1,230 @@
package eventbus
import (
"context"
"errors"
"strings"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestPartitionedAsyncSinkRawQueueIsIsolatedFromDerivedBacklog(t *testing.T) {
delegate := newBlockingDerivedSink()
sink := NewPartitionedAsyncSink(delegate, PartitionedAsyncConfig{
RawQueueSize: 1,
DerivedQueueSize: 1,
RawWorkers: 1,
DerivedWorkers: 1,
EnqueueTimeout: 20 * time.Millisecond,
OperationTimeout: time.Second,
})
defer sink.Close()
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("PublishFields() error = %v", err)
}
select {
case <-delegate.fieldsStarted:
case <-time.After(time.Second):
t.Fatal("delegate fields publish was not started")
}
if err := sink.PublishUnified(context.Background(), env); err != nil {
t.Fatalf("PublishUnified() error = %v", err)
}
start := time.Now()
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Fatalf("PublishRaw() blocked behind derived backlog for %s", elapsed)
}
delegate.release()
}
func TestPartitionedAsyncSinkRecordsPerQueueMetrics(t *testing.T) {
registry := metrics.NewRegistry()
delegate := newBlockingDerivedSink()
sink := NewPartitionedAsyncSink(delegate, PartitionedAsyncConfig{
RawQueueSize: 2,
DerivedQueueSize: 3,
RawWorkers: 1,
DerivedWorkers: 1,
OperationTimeout: time.Second,
Metrics: registry,
Name: "nats",
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "LNBSCB3D4R1234567"}
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("PublishFields() error = %v", err)
}
delegate.release()
if err := sink.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
text := registry.Render()
for _, want := range []string{
`vehicle_async_sink_queue_capacity{queue="raw",sink="nats"} 2`,
`vehicle_async_sink_queue_capacity{queue="derived",sink="nats"} 3`,
`vehicle_async_sink_workers{queue="raw",sink="nats"} 1`,
`vehicle_async_sink_workers{queue="derived",sink="nats"} 1`,
`vehicle_async_sink_enqueue_total{kind="raw",sink="nats",status="queued"} 1`,
`vehicle_async_sink_enqueue_total{kind="fields",sink="nats",status="queued"} 1`,
`vehicle_async_sink_publish_total{kind="raw",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_total{kind="fields",sink="nats",status="ok"} 1`,
`vehicle_async_sink_queue_wait_duration_ms_histogram_count{kind="raw",queue="raw",sink="nats"} 1`,
`vehicle_async_sink_queue_wait_recent_p99_ms{kind="raw",queue="raw",sink="nats"}`,
`vehicle_async_sink_queue_wait_recent_samples{kind="raw",queue="raw",sink="nats"} 1`,
`vehicle_async_sink_queue_wait_duration_ms_histogram_count{kind="fields",queue="derived",sink="nats"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("partitioned async metric missing %s:\n%s", want, text)
}
}
}
func TestPartitionedAsyncSinkUsesIndependentDerivedEnqueueTimeout(t *testing.T) {
delegate := newBlockingDerivedSink()
sink := NewPartitionedAsyncSink(delegate, PartitionedAsyncConfig{
RawQueueSize: 1,
DerivedQueueSize: 1,
RawWorkers: 1,
DerivedWorkers: 1,
RawEnqueueTimeout: 200 * time.Millisecond,
DerivedEnqueueTimeout: 10 * time.Millisecond,
OperationTimeout: time.Second,
})
defer sink.Close()
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("PublishFields() error = %v", err)
}
select {
case <-delegate.fieldsStarted:
case <-time.After(time.Second):
t.Fatal("delegate fields publish was not started")
}
if err := sink.PublishUnified(context.Background(), env); err != nil {
t.Fatalf("first PublishUnified() error = %v", err)
}
start := time.Now()
err := sink.PublishUnified(context.Background(), env)
elapsed := time.Since(start)
if !errors.Is(err, ErrAsyncSinkEnqueueTimeout) {
t.Fatalf("second PublishUnified() error = %v, want ErrAsyncSinkEnqueueTimeout", err)
}
if elapsed > 100*time.Millisecond {
t.Fatalf("derived enqueue timeout took %s, want quick failure", elapsed)
}
delegate.release()
}
func TestPartitionedAsyncSinkCloseUnblocksBlockedDerivedEnqueue(t *testing.T) {
delegate := newBlockingDerivedSink()
sink := NewPartitionedAsyncSink(delegate, PartitionedAsyncConfig{
RawQueueSize: 1,
DerivedQueueSize: 1,
RawWorkers: 1,
DerivedWorkers: 1,
EnqueueTimeout: -1,
OperationTimeout: time.Second,
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("PublishFields() error = %v", err)
}
select {
case <-delegate.fieldsStarted:
case <-time.After(time.Second):
t.Fatal("delegate fields publish was not started")
}
if err := sink.PublishUnified(context.Background(), env); err != nil {
t.Fatalf("PublishUnified() error = %v", err)
}
publishErr := make(chan error, 1)
go func() {
publishErr <- sink.PublishUnified(context.Background(), env)
}()
closeErr := make(chan error, 1)
go func() {
closeErr <- sink.Close()
}()
select {
case err := <-publishErr:
if !errors.Is(err, ErrAsyncSinkClosed) {
t.Fatalf("blocked PublishUnified() error = %v, want ErrAsyncSinkClosed", err)
}
case <-time.After(time.Second):
t.Fatal("blocked PublishUnified() was not released by Close")
}
delegate.release()
select {
case err := <-closeErr:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close() did not finish after delegate release")
}
}
type blockingDerivedSink struct {
fieldsStarted chan struct{}
releaseFields chan struct{}
}
func newBlockingDerivedSink() *blockingDerivedSink {
return &blockingDerivedSink{
fieldsStarted: make(chan struct{}),
releaseFields: make(chan struct{}),
}
}
func (s *blockingDerivedSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *blockingDerivedSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *blockingDerivedSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
s.signalFieldsStarted()
<-s.releaseFields
return nil
}
func (s *blockingDerivedSink) Close() error {
s.release()
return nil
}
func (s *blockingDerivedSink) signalFieldsStarted() {
select {
case <-s.fieldsStarted:
default:
close(s.fieldsStarted)
}
}
func (s *blockingDerivedSink) release() {
select {
case <-s.releaseFields:
default:
close(s.releaseFields)
}
}

View File

@@ -0,0 +1,42 @@
package gateway
import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
)
const (
gatewayFieldsPublished = "published"
gatewayFieldsDelegated = "delegated_to_bridge"
gatewayFieldsSkippedNonRealtime = "skipped_non_realtime"
gatewayFieldsSkippedMissing = "skipped_missing_fields"
gatewayFieldsPublishError = "publish_error"
)
func gatewayFieldsEnvelope(env envelope.FrameEnvelope) (envelope.FrameEnvelope, string, bool) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return envelope.FrameEnvelope{}, gatewayFieldsSkippedNonRealtime, false
}
fieldsEnv, ok := realtime.BuildFieldsEnvelope(env)
if !ok {
return envelope.FrameEnvelope{}, gatewayFieldsSkippedMissing, false
}
return fieldsEnv, gatewayFieldsPublished, true
}
func gatewayDelegatedFields(env envelope.FrameEnvelope) (int, string, bool) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return 0, gatewayFieldsSkippedNonRealtime, false
}
if len(env.ParsedFields) == 0 {
return 0, gatewayFieldsSkippedMissing, false
}
return len(env.ParsedFields), gatewayFieldsDelegated, true
}
func canonicalRawEnvelope(env envelope.FrameEnvelope) envelope.FrameEnvelope {
env.EventKind = envelope.EventKindRaw
env.Parsed = nil
env.Fields = nil
return env
}

View File

@@ -0,0 +1,76 @@
package gateway
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestCanonicalRawEnvelopeDropsBareStandardizedFields(t *testing.T) {
original := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
EventKind: envelope.EventKindFields,
Parsed: map[string]any{
"location": map[string]any{"latitude": 30.5, "speed_kmh": 20},
},
Fields: map[string]any{
envelope.FieldLatitude: 30.5,
envelope.FieldSpeedKMH: 20,
},
ParsedFields: map[string]any{
"jt808.location.latitude": 30.5,
"jt808.location.speed_kmh": 20,
},
}
canonical := canonicalRawEnvelope(original)
if canonical.EventKind != envelope.EventKindRaw {
t.Fatalf("event kind = %q", canonical.EventKind)
}
if len(canonical.Fields) != 0 {
t.Fatalf("canonical raw leaked bare fields: %#v", canonical.Fields)
}
if len(canonical.Parsed) != 0 {
t.Fatalf("canonical raw leaked duplicate parsed tree: %#v", canonical.Parsed)
}
if len(canonical.ParsedFields) != 2 {
t.Fatalf("canonical raw lost protocol fields: %#v", canonical.ParsedFields)
}
if len(original.Fields) != 2 {
t.Fatalf("canonical copy mutated in-process parser fields: %#v", original.Fields)
}
if len(original.Parsed) != 1 {
t.Fatalf("canonical copy mutated in-process parsed tree: %#v", original.Parsed)
}
}
func TestCanonicalRawEnvelopeReducesDuplicateParsedPayload(t *testing.T) {
original := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x02",
VIN: "LTESTVIN000000001",
ParseStatus: envelope.ParseOK,
Parsed: map[string]any{
"data_units": []any{map[string]any{
"name": "vendor",
"value": strings.Repeat("x", 4096),
}},
},
ParsedFields: map[string]any{
"gb32960.vendor.value": strings.Repeat("x", 4096),
},
}
before, err := original.MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
after, err := canonicalRawEnvelope(original).MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
if len(after)*100 >= len(before)*65 {
t.Fatalf("canonical raw should remove the duplicate parsed tree: before=%d after=%d", len(before), len(after))
}
t.Logf("canonical raw bytes before=%d after=%d reduction=%.1f%%", len(before), len(after), 100*(1-float64(len(after))/float64(len(before))))
}

View File

@@ -0,0 +1,104 @@
package gateway
import (
"context"
"errors"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
var gatewayIdentityDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayFieldsCountBuckets = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000}
func identityErrorStatus(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return "error"
}
func recordGatewayIdentityDuration(registry *metrics.Registry, protocol envelope.Protocol, status string, elapsed time.Duration) {
if registry == nil {
return
}
elapsedMS := float64(elapsed.Nanoseconds()) / float64(time.Millisecond)
labels := metrics.Labels{
"protocol": string(protocol),
"status": status,
}
registry.SetGauge("vehicle_gateway_identity_duration_ms", labels, elapsedMS)
registry.ObserveHistogram("vehicle_gateway_identity_duration_ms_histogram", labels, gatewayIdentityDurationBucketsMS, elapsedMS)
}
func recordGatewayIdentityCacheStatus(registry *metrics.Registry, protocol envelope.Protocol, env envelope.FrameEnvelope) {
if registry == nil {
return
}
status := identityCacheStatus(env)
if status == "" {
return
}
registry.IncCounter("vehicle_gateway_identity_cache_total", metrics.Labels{
"protocol": string(protocol),
"cache_status": status,
})
}
func identityCacheStatus(env envelope.FrameEnvelope) string {
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok {
return ""
}
status, ok := identity["cache_status"].(string)
if !ok {
return ""
}
return strings.TrimSpace(status)
}
func annotateIdentityError(env *envelope.FrameEnvelope, err error) {
if env == nil || err == nil {
return
}
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
identity, _ := env.Parsed["identity"].(map[string]any)
if identity == nil {
identity = map[string]any{}
}
if _, ok := identity["resolved"]; !ok {
identity["resolved"] = strings.TrimSpace(env.VIN) != ""
}
identity["error"] = err.Error()
env.Parsed["identity"] = identity
}
func recordGatewayFieldsMetric(registry *metrics.Registry, protocol envelope.Protocol, status string) {
if registry == nil {
return
}
registry.IncCounter("vehicle_gateway_fields_total", metrics.Labels{
"protocol": string(protocol),
"status": status,
})
}
func recordGatewayFieldsCount(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) {
if registry == nil {
return
}
if fieldCount < 0 {
fieldCount = 0
}
labels := metrics.Labels{
"protocol": string(protocol),
"status": status,
}
value := float64(fieldCount)
registry.SetGauge("vehicle_gateway_fields_count", labels, value)
registry.ObserveHistogram("vehicle_gateway_fields_count_histogram", labels, gatewayFieldsCountBuckets, value)
}

View File

@@ -0,0 +1,27 @@
package gateway
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestRecordGatewayIdentityCacheStatus(t *testing.T) {
registry := metrics.NewRegistry()
recordGatewayIdentityCacheStatus(registry, envelope.ProtocolJT808, envelope.FrameEnvelope{
Parsed: map[string]any{
"identity": map[string]any{
"resolved": true,
"cache_status": "stale",
},
},
})
got := registry.Render()
want := `vehicle_gateway_identity_cache_total{cache_status="stale",protocol="JT808"} 1`
if !strings.Contains(got, want) {
t.Fatalf("metrics missing %q:\n%s", want, got)
}
}

View File

@@ -41,6 +41,7 @@ type MQTTClientConfig struct {
Logger *slog.Logger
Metrics *metrics.Registry
PublishUnified bool
DelegateFields bool
}
type MQTTClient struct {
@@ -204,28 +205,42 @@ func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []
}
env.EventID = env.StableEventID()
c.recordParseErrorMetric(err)
} else {
} else if envelope.RequiresVehicleIdentity(env) {
resolveStarted := time.Now()
resolved, resolveErr := c.cfg.Resolver.Resolve(messageCtx, env)
if resolveErr != nil {
c.cfg.Logger.Warn("mqtt identity resolve failed", "topic", topic, "event_id", env.StableEventID(), "error", resolveErr)
if env.Parsed == nil {
env.Parsed = map[string]any{}
if resolved.Protocol != "" || strings.TrimSpace(resolved.VIN) != "" || strings.TrimSpace(resolved.Phone) != "" {
env = resolved
}
env.Parsed["identity"] = map[string]any{"resolved": false, "error": resolveErr.Error()}
identityStatus := identityErrorStatus(resolveErr)
c.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
c.cfg.Logger.Warn("mqtt identity resolve failed", "topic", topic, "event_id", env.StableEventID(), "error", resolveErr)
annotateIdentityError(&env, resolveErr)
env.ParseStatus = envelope.ParsePartial
c.recordIdentityMetric("error")
c.recordIdentityMetric(identityStatus)
c.recordIdentityIssueMetric(identityStatus, env, identityIssueReason(identityStatus, resolveErr))
} else {
env = resolved
annotateIdentityUnresolved(&env)
c.recordIdentityMetric(identityStatus(env))
identityStatus := identityStatus(env)
c.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
c.recordIdentityMetric(identityStatus)
if identityStatus != "resolved" {
c.recordIdentityIssueMetric(identityStatus, env, "no_binding")
}
recordGatewayIdentityCacheStatus(c.cfg.Metrics, envelope.ProtocolYutongMQTT, env)
}
} else {
c.recordIdentitySkipMetric("non_vehicle_frame")
}
frameStatus = env.ParseStatus
env.EventKind = envelope.EventKindRaw
c.recordFrameMetric(env.ParseStatus)
if env.ParseStatus != envelope.ParseBadFrame {
realtime.EnsureParsedFields(&env)
}
if err := c.cfg.Sink.PublishRaw(messageCtx, env); err != nil {
canonicalRaw := canonicalRawEnvelope(env)
if err := c.cfg.Sink.PublishRaw(messageCtx, canonicalRaw); err != nil {
c.recordPublishMetric("raw", "error")
c.cfg.Logger.Error("publish mqtt raw failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
@@ -234,21 +249,37 @@ func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []
if env.ParseStatus == envelope.ParseBadFrame {
return
}
if fieldsEnv, ok := realtime.BuildFieldsEnvelope(env); ok {
if err := c.cfg.Sink.PublishFields(messageCtx, fieldsEnv); err != nil {
c.recordPublishMetric("fields", "error")
c.cfg.Logger.Error("publish mqtt fields failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
if c.cfg.DelegateFields {
fieldCount, fieldsStatus, ok := gatewayDelegatedFields(env)
c.recordFieldsMetric(fieldsStatus)
if ok {
c.recordFieldsCount(fieldsStatus, fieldCount)
c.recordPublishMetric("fields", "delegated")
}
} else {
fieldsEnv, fieldsStatus, ok := gatewayFieldsEnvelope(env)
if !ok {
c.recordFieldsMetric(fieldsStatus)
} else {
if err := c.cfg.Sink.PublishFields(messageCtx, fieldsEnv); err != nil {
c.recordFieldsMetric(gatewayFieldsPublishError)
c.recordFieldsCount(gatewayFieldsPublishError, len(fieldsEnv.Fields))
c.recordPublishMetric("fields", "error")
c.cfg.Logger.Error("publish mqtt fields failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
} else {
c.recordFieldsMetric(fieldsStatus)
c.recordFieldsCount(fieldsStatus, len(fieldsEnv.Fields))
c.recordPublishMetric("fields", "ok")
}
}
c.recordPublishMetric("fields", "ok")
}
if c.cfg.PublishUnified {
if err := c.cfg.Sink.PublishUnified(messageCtx, env); err != nil {
if err := c.cfg.Sink.PublishUnified(messageCtx, canonicalRaw); err != nil {
c.recordPublishMetric("unified", "error")
c.cfg.Logger.Error("publish mqtt unified failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
} else {
c.recordPublishMetric("unified", "ok")
}
c.recordPublishMetric("unified", "ok")
}
}
@@ -256,10 +287,12 @@ func (c *MQTTClient) recordFrameMetric(status envelope.ParseStatus) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_frames_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"status": string(status),
})
}
c.cfg.Metrics.IncCounter("vehicle_gateway_frames_total", labels)
metrics.RecordLastActivity(c.cfg.Metrics, "vehicle_gateway_last_frame_unix_seconds", labels)
}
func (c *MQTTClient) recordFrameDuration(status envelope.ParseStatus, elapsed time.Duration) {
@@ -282,11 +315,21 @@ func (c *MQTTClient) recordPublishMetric(kind string, status string) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_publish_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"kind": kind,
"status": status,
})
}
c.cfg.Metrics.IncCounter("vehicle_gateway_publish_total", labels)
metrics.RecordLastActivity(c.cfg.Metrics, "vehicle_gateway_last_publish_unix_seconds", labels)
}
func (c *MQTTClient) recordFieldsMetric(status string) {
recordGatewayFieldsMetric(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status)
}
func (c *MQTTClient) recordFieldsCount(status string, fieldCount int) {
recordGatewayFieldsCount(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status, fieldCount)
}
func (c *MQTTClient) recordParseErrorMetric(err error) {
@@ -308,3 +351,37 @@ func (c *MQTTClient) recordIdentityMetric(status string) {
"status": status,
})
}
func (c *MQTTClient) recordIdentitySkipMetric(reason string) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_identity_skips_total", metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"reason": reason,
})
}
func (c *MQTTClient) recordIdentityIssueMetric(status string, env envelope.FrameEnvelope, reason string) {
if c.cfg.Metrics == nil {
return
}
messageID := strings.TrimSpace(env.MessageID)
if messageID == "" {
messageID = "unknown"
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "unknown"
}
c.cfg.Metrics.IncCounter("vehicle_gateway_identity_issues_total", metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"status": status,
"message_id": messageID,
"reason": reason,
})
}
func (c *MQTTClient) recordIdentityDuration(status string, elapsed time.Duration) {
recordGatewayIdentityDuration(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status, elapsed)
}

View File

@@ -7,6 +7,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"log/slog"
"math/big"
"os"
@@ -19,7 +20,7 @@ import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestMQTTClientHandleMessagePublishesOnlyRawByDefault(t *testing.T) {
func TestMQTTClientHandleMessagePublishesRawAndFieldsByDefault(t *testing.T) {
sink := &recordingSink{}
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
@@ -39,18 +40,80 @@ func TestMQTTClientHandleMessagePublishesOnlyRawByDefault(t *testing.T) {
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
if len(sink.raw) != 1 || len(sink.fields) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d fields=%d unified=%d", len(sink.raw), len(sink.fields), len(sink.unified))
}
if sink.raw[0].Protocol != envelope.ProtocolYutongMQTT || sink.raw[0].VIN != "LTEST000000000001" {
t.Fatalf("unexpected raw envelope: %#v", sink.raw[0])
}
if sink.raw[0].EventKind != envelope.EventKindRaw {
t.Fatalf("raw event kind = %q, want %q", sink.raw[0].EventKind, envelope.EventKindRaw)
}
if sink.raw[0].RawText == "" {
t.Fatal("mqtt raw envelope should keep text payload")
}
if sink.raw[0].RawHex != "" {
t.Fatalf("mqtt raw envelope should not duplicate text payload as hex: %q", sink.raw[0].RawHex)
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
if got, want := sink.fields[0].Fields["yutong_mqtt.data.meter_speed"], sink.raw[0].ParsedFields["yutong_mqtt.data.meter_speed"]; got != want {
t.Fatalf("fields event should reuse raw parsed field, got %#v want %#v", got, want)
}
if sink.fields[0].EventKind != envelope.EventKindFields {
t.Fatalf("fields event kind = %q, want %q", sink.fields[0].EventKind, envelope.EventKindFields)
}
if got, want := sink.fields[0].SourceEventID, sink.raw[0].StableEventID(); got != want {
t.Fatalf("fields source event id = %#v, want %s", got, want)
}
if sink.fields[0].FieldMapping == "" {
t.Fatal("fields event should expose field mapping version")
}
if len(sink.fields[0].Parsed) != 0 || len(sink.fields[0].ParsedFields) != 0 {
t.Fatalf("fields envelope should not duplicate parsed payload: parsed=%#v parsed_fields=%#v", sink.fields[0].Parsed, sink.fields[0].ParsedFields)
}
}
func TestMQTTClientDelegatesFieldsProjectionWhenConfigured(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: sink,
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
DelegateFields: true,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.fields) != 0 {
t.Fatalf("raw=%d fields=%d, want canonical raw only", len(sink.raw), len(sink.fields))
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("delegated canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="delegated_to_bridge"} 1`,
`vehicle_gateway_fields_count{protocol="YUTONG_MQTT",status="delegated_to_bridge"} `,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="delegated"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("delegated fields metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
@@ -77,8 +140,18 @@ func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="OK"} 1`,
`vehicle_gateway_last_frame_unix_seconds{protocol="YUTONG_MQTT",status="OK"} `,
`vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="YUTONG_MQTT",status="resolved"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_sum{protocol="YUTONG_MQTT",status="resolved"}`,
`vehicle_gateway_publish_total{kind="raw",protocol="YUTONG_MQTT",status="ok"} 1`,
`vehicle_gateway_last_publish_unix_seconds{kind="raw",protocol="YUTONG_MQTT",status="ok"} `,
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="published"} 1`,
`vehicle_gateway_fields_count{protocol="YUTONG_MQTT",status="published"} `,
`vehicle_gateway_fields_count_histogram_count{protocol="YUTONG_MQTT",status="published"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="ok"} 1`,
`vehicle_gateway_frame_duration_ms{protocol="YUTONG_MQTT",status="OK"}`,
`vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="OK"} 1`,
`vehicle_gateway_frame_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="OK"} 1`,
@@ -93,6 +166,36 @@ func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
}
}
func TestMQTTClientRecordsNonRealtimeFieldsSkipMetric(t *testing.T) {
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: &recordingSink{},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{}
}`))
text := registry.Render()
if !strings.Contains(text, `vehicle_gateway_identity_skips_total{protocol="YUTONG_MQTT",reason="non_vehicle_frame"} 1`) {
t.Fatalf("identity skip metric missing:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestMQTTClientHandleBadPayloadPublishesOnlyRaw(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
@@ -167,6 +270,66 @@ func TestMQTTClientUsesUncancelledMessageContextForReceivedMessage(t *testing.T)
}
}
func TestMQTTClientPreservesResolvedEnvelopeWhenIdentitySideEffectFails(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
wantErr := errors.New("registration upsert failed")
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: sink,
Resolver: resolvedErrorResolver{vin: "LRESOLVED00000001", err: wantErr},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LRESOLVED00000001" {
t.Fatalf("raw vin = %q, want resolver vin", sink.raw[0].VIN)
}
if sink.raw[0].ParseStatus != envelope.ParsePartial {
t.Fatalf("parse status = %q, want PARTIAL", sink.raw[0].ParseStatus)
}
if len(sink.raw[0].Parsed) != 0 {
t.Fatalf("canonical raw should not duplicate parsed tree: %#v", sink.raw[0].Parsed)
}
if got := sink.raw[0].ParsedFields["yutong_mqtt.data.meter_speed"]; got != "52.3" {
t.Fatalf("protocol field lost after identity side-effect failure: %#v", got)
}
for field := range sink.raw[0].ParsedFields {
if strings.HasPrefix(field, "yutong_mqtt.identity.") {
t.Fatalf("derived identity annotation leaked into protocol fields: %s", field)
}
}
if len(sink.fields) != 1 || sink.fields[0].VIN != "LRESOLVED00000001" {
t.Fatalf("fields should preserve resolved vin, fields=%#v", sink.fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="error"} 1`,
`vehicle_gateway_identity_issues_total{message_id="MQTT",protocol="YUTONG_MQTT",reason="resolver_error",status="error"} 1`,
`vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="PARTIAL"} 1`,
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="published"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) {
sink := &recordingSink{}
client, err := NewMQTTClient(MQTTClientConfig{
@@ -193,6 +356,44 @@ func TestMQTTClientPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) {
}
}
func TestMQTTClientPublishesUnifiedWhenFieldsPublishFails(t *testing.T) {
sink := &recordingSink{fieldsErr: errors.New("fields queue full")}
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
PublishUnified: true,
Sink: sink,
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.fields) != 1 || len(sink.unified) != 1 {
t.Fatalf("raw=%d fields=%d unified=%d", len(sink.raw), len(sink.fields), len(sink.unified))
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="publish_error"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="error"} 1`,
`vehicle_gateway_publish_total{kind="unified",protocol="YUTONG_MQTT",status="ok"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientBuildOptionsLoadsTLSCertificates(t *testing.T) {
dir := t.TempDir()
caPath, certPath, keyPath := writeTestTLSMaterial(t, dir)

View File

@@ -10,8 +10,10 @@ import (
"net"
"strings"
"sync"
"syscall"
"time"
"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/identity"
@@ -26,11 +28,12 @@ type FrameParser func(raw []byte, receivedAtMS int64, sourceEndpoint string) (en
type FrameResponder func(raw []byte, env envelope.FrameEnvelope) (response []byte, ok bool, err error)
type TCPProtocol struct {
Protocol envelope.Protocol
Addr string
Extract FrameExtractor
Parse FrameParser
Respond FrameResponder
Protocol envelope.Protocol
Addr string
Extract FrameExtractor
Parse FrameParser
Authenticate authentication.Authenticator
Respond FrameResponder
}
type connectionState struct {
@@ -47,6 +50,9 @@ type TCPServer struct {
idleTimeout time.Duration
maxConnections int
publishUnified bool
delegateFields bool
activeMu sync.Mutex
activeConns map[net.Conn]struct{}
}
type TCPServerConfig struct {
@@ -59,11 +65,14 @@ type TCPServerConfig struct {
IdleTimeout time.Duration
MaxConnections int
PublishUnified bool
DelegateFields bool
}
const frameOperationTimeout = 30 * time.Second
var gatewayFrameDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayResponseDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayResponseE2ERecent = metrics.NewRecentLatencyByKey(512)
func NewTCPServer(cfg TCPServerConfig) (*TCPServer, error) {
if cfg.Protocol.Protocol == "" {
@@ -106,6 +115,8 @@ func NewTCPServer(cfg TCPServerConfig) (*TCPServer, error) {
idleTimeout: cfg.IdleTimeout,
maxConnections: cfg.MaxConnections,
publishUnified: cfg.PublishUnified,
delegateFields: cfg.DelegateFields,
activeConns: map[net.Conn]struct{}{},
}, nil
}
@@ -120,6 +131,7 @@ func (s *TCPServer) ListenAndServe(ctx context.Context) error {
go func() {
<-ctx.Done()
_ = listener.Close()
s.closeActiveConnections()
}()
s.logger.Info("tcp listener started", "protocol", s.protocol.Protocol, "addr", listener.Addr().String())
@@ -155,12 +167,15 @@ func (s *TCPServer) ListenAndServe(ctx context.Context) error {
func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
defer conn.Close()
s.trackConnection(conn)
defer s.untrackConnection(conn)
source := conn.RemoteAddr().String()
log := s.logger.With("protocol", s.protocol.Protocol, "remote", source)
s.recordConnectionMetric(1)
defer s.recordConnectionMetric(-1)
log.Info("tcp connection opened")
defer log.Info("tcp connection closed")
log.Debug("tcp connection opened")
defer log.Debug("tcp connection closed")
readBuffer := make([]byte, s.readBufferSize)
var pending []byte
@@ -182,16 +197,21 @@ func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
}
}
if err != nil {
if errors.Is(err, io.EOF) {
s.recordConnectionClose("eof")
if ctx.Err() != nil {
s.recordConnectionClose("context_cancelled")
return
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
log.Warn("tcp connection idle timeout")
log.Debug("tcp connection idle timeout")
s.recordConnectionClose("read_timeout")
return
}
if isRoutineTCPReadClose(err) {
log.Debug("tcp connection closed by peer", "error", err)
s.recordConnectionClose("remote_closed")
return
}
log.Warn("tcp read failed", "error", err)
s.recordConnectionClose("read_error")
return
@@ -203,6 +223,55 @@ func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
}
}
func isRoutineTCPReadClose(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, syscall.ECONNRESET) ||
errors.Is(err, syscall.EPIPE) {
return true
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "connection reset by peer") ||
strings.Contains(text, "broken pipe") ||
strings.Contains(text, "use of closed network connection")
}
func (s *TCPServer) trackConnection(conn net.Conn) {
if conn == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
if s.activeConns == nil {
s.activeConns = map[net.Conn]struct{}{}
}
s.activeConns[conn] = struct{}{}
}
func (s *TCPServer) untrackConnection(conn net.Conn) {
if conn == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
delete(s.activeConns, conn)
}
func (s *TCPServer) closeActiveConnections() {
s.activeMu.Lock()
conns := make([]net.Conn, 0, len(s.activeConns))
for conn := range s.activeConns {
conns = append(conns, conn)
}
s.activeMu.Unlock()
for _, conn := range conns {
_ = conn.Close()
}
}
func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte, source string, state *connectionState) {
started := time.Now()
frameStatus := envelope.ParseBadFrame
@@ -228,29 +297,53 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
env.EventID = env.StableEventID()
s.recordParseErrorMetric(err)
} else {
resolved, resolveErr := s.resolver.Resolve(frameCtx, env)
if resolveErr != nil {
s.logger.Warn("identity resolve failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", resolveErr)
if env.Parsed == nil {
env.Parsed = map[string]any{}
if s.protocol.Authenticate != nil {
result := s.protocol.Authenticate.Authenticate(env)
authentication.Apply(&env, result)
if result.Applicable {
s.recordAuthenticationMetric(result)
}
env.Parsed["identity"] = map[string]any{"resolved": false, "error": resolveErr.Error()}
env.ParseStatus = envelope.ParsePartial
s.recordIdentityMetric("error")
} else {
env = resolved
annotateIdentityUnresolved(&env)
s.recordIdentityMetric(identityStatus(env))
}
enrichConnectionPlatform(&env, state)
authentication.RedactParsedCredentials(&env)
if envelope.RequiresVehicleIdentity(env) {
resolveStarted := time.Now()
resolved, resolveErr := s.resolver.Resolve(frameCtx, env)
if resolveErr != nil {
if resolved.Protocol != "" || strings.TrimSpace(resolved.VIN) != "" || strings.TrimSpace(resolved.Phone) != "" {
env = resolved
}
identityStatus := identityErrorStatus(resolveErr)
s.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
s.logger.Warn("identity resolve failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", resolveErr)
annotateIdentityError(&env, resolveErr)
env.ParseStatus = envelope.ParsePartial
s.recordIdentityMetric(identityStatus)
s.recordIdentityIssueMetric(identityStatus, env, identityIssueReason(identityStatus, resolveErr))
} else {
env = resolved
annotateIdentityUnresolved(&env)
identityStatus := identityStatus(env)
s.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
s.recordIdentityMetric(identityStatus)
if identityStatus != "resolved" {
s.recordIdentityIssueMetric(identityStatus, env, "no_binding")
}
recordGatewayIdentityCacheStatus(s.metrics, s.protocol.Protocol, env)
}
} else {
s.recordIdentitySkipMetric("non_vehicle_frame")
}
}
enrichConnectionPlatform(&env, state)
frameStatus = env.ParseStatus
env.EventKind = envelope.EventKindRaw
s.recordFrameMetric(env.ParseStatus)
if env.ParseStatus != envelope.ParseBadFrame {
realtime.EnsureParsedFields(&env)
}
if err := s.sink.PublishRaw(frameCtx, env); err != nil {
canonicalRaw := canonicalRawEnvelope(env)
if err := s.sink.PublishRaw(frameCtx, canonicalRaw); err != nil {
s.recordPublishMetric("raw", "error")
s.logger.Error("publish raw failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
@@ -259,37 +352,79 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
if env.ParseStatus == envelope.ParseBadFrame {
return
}
if fieldsEnv, ok := realtime.BuildFieldsEnvelope(env); ok {
if err := s.sink.PublishFields(frameCtx, fieldsEnv); err != nil {
s.recordPublishMetric("fields", "error")
s.logger.Error("publish fields failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
s.writeProtocolResponse(conn, raw, env, started)
if shouldCloseAfterAuthentication(env) && conn != nil {
_ = conn.Close()
return
}
if s.delegateFields {
fieldCount, fieldsStatus, ok := gatewayDelegatedFields(env)
s.recordFieldsMetric(fieldsStatus)
if ok {
s.recordFieldsCount(fieldsStatus, fieldCount)
s.recordPublishMetric("fields", "delegated")
}
} else {
fieldsEnv, fieldsStatus, ok := gatewayFieldsEnvelope(env)
if !ok {
s.recordFieldsMetric(fieldsStatus)
} else {
if err := s.sink.PublishFields(frameCtx, fieldsEnv); err != nil {
s.recordFieldsMetric(gatewayFieldsPublishError)
s.recordFieldsCount(gatewayFieldsPublishError, len(fieldsEnv.Fields))
s.recordPublishMetric("fields", "error")
s.logger.Error("publish fields failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
} else {
s.recordFieldsMetric(fieldsStatus)
s.recordFieldsCount(fieldsStatus, len(fieldsEnv.Fields))
s.recordPublishMetric("fields", "ok")
}
}
s.recordPublishMetric("fields", "ok")
}
if s.publishUnified {
if err := s.sink.PublishUnified(frameCtx, env); err != nil {
if err := s.sink.PublishUnified(frameCtx, canonicalRaw); err != nil {
s.recordPublishMetric("unified", "error")
s.logger.Error("publish unified failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
} else {
s.recordPublishMetric("unified", "ok")
}
s.recordPublishMetric("unified", "ok")
}
}
func (s *TCPServer) writeProtocolResponse(conn net.Conn, raw []byte, env envelope.FrameEnvelope, frameStarted time.Time) {
if s.protocol.Respond == nil {
return
}
status := "skipped"
defer func() {
s.recordResponseDuration(env.MessageID, status, time.Since(frameStarted))
}()
response, ok, err := s.protocol.Respond(raw, env)
if err != nil {
status = "build_error"
s.recordResponseMetric(env.MessageID, "build_error")
s.logger.Warn("build protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
}
if !ok || len(response) == 0 {
s.recordResponseMetric(env.MessageID, "skipped")
return
}
if conn == nil {
status = "write_error"
s.recordResponseMetric(env.MessageID, "write_error")
s.logger.Warn("write protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", "nil connection")
return
}
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if _, err := conn.Write(response); err != nil {
status = "write_error"
s.recordResponseMetric(env.MessageID, "write_error")
s.logger.Warn("write protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
}
status = "ok"
s.recordResponseMetric(env.MessageID, "ok")
}
func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionState) {
@@ -299,6 +434,9 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
if env.Fields == nil {
env.Fields = map[string]any{}
}
if shouldCloseAfterAuthentication(*env) {
return
}
if current := strings.TrimSpace(fmt.Sprint(env.Fields["platform_account"])); current != "" && current != "<nil>" {
state.platformName = current
}
@@ -308,6 +446,15 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
if strings.TrimSpace(fmt.Sprint(env.Fields["platform_account"])) == "" || fmt.Sprint(env.Fields["platform_account"]) == "<nil>" {
env.Fields["platform_account"] = state.platformName
}
if strings.TrimSpace(env.PlatformName) == "" {
env.PlatformName = state.platformName
}
if strings.TrimSpace(env.SourceCode) == "" {
env.SourceCode = sourceCodeFromPlatformName(state.platformName)
}
if strings.TrimSpace(env.SourceKind) == "" || strings.EqualFold(strings.TrimSpace(env.SourceKind), "UNKNOWN") {
env.SourceKind = "PLATFORM"
}
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
@@ -316,14 +463,37 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
}
}
func sourceCodeFromPlatformName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var builder strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_' || r == '-' || r == '.':
builder.WriteRune(r)
}
}
return builder.String()
}
func (s *TCPServer) recordFrameMetric(status envelope.ParseStatus) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_frames_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"status": string(status),
})
}
s.metrics.IncCounter("vehicle_gateway_frames_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_frame_unix_seconds", labels)
}
func (s *TCPServer) recordFrameDuration(status envelope.ParseStatus, elapsed time.Duration) {
@@ -346,10 +516,74 @@ func (s *TCPServer) recordPublishMetric(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_publish_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"kind": kind,
"status": status,
}
s.metrics.IncCounter("vehicle_gateway_publish_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_publish_unix_seconds", labels)
}
func (s *TCPServer) recordFieldsMetric(status string) {
recordGatewayFieldsMetric(s.metrics, s.protocol.Protocol, status)
}
func (s *TCPServer) recordFieldsCount(status string, fieldCount int) {
recordGatewayFieldsCount(s.metrics, s.protocol.Protocol, status, fieldCount)
}
func (s *TCPServer) recordResponseMetric(messageID string, status string) {
if s.metrics == nil {
return
}
messageID = strings.TrimSpace(messageID)
if messageID == "" {
messageID = "unknown"
}
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"message_id": messageID,
"status": status,
}
s.metrics.IncCounter("vehicle_gateway_response_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_response_unix_seconds", labels)
}
func (s *TCPServer) recordResponseDuration(messageID string, status string, elapsed time.Duration) {
if s.metrics == nil {
return
}
messageID = strings.TrimSpace(messageID)
if messageID == "" {
messageID = "unknown"
}
elapsedMS := float64(elapsed.Microseconds()) / 1000
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"message_id": messageID,
"status": status,
}
s.metrics.ObserveHistogram("vehicle_gateway_response_duration_ms_histogram", labels, gatewayResponseDurationBucketsMS, elapsedMS)
if status != "ok" {
return
}
protocol := string(s.protocol.Protocol)
p99, samples := gatewayResponseE2ERecent.Observe(protocol, elapsedMS)
protocolLabels := metrics.Labels{"protocol": protocol}
s.metrics.SetGauge("vehicle_gateway_response_e2e_recent_p99_ms", protocolLabels, p99)
s.metrics.SetGauge("vehicle_gateway_response_e2e_recent_samples", protocolLabels, float64(samples))
}
func (s *TCPServer) recordAuthenticationMetric(result authentication.Result) {
if s.metrics == nil || !result.Applicable {
return
}
s.metrics.IncCounter("vehicle_gateway_authentication_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"mode": string(result.Mode),
"source": result.Source,
"status": result.Status,
})
}
@@ -373,6 +607,40 @@ func (s *TCPServer) recordIdentityMetric(status string) {
})
}
func (s *TCPServer) recordIdentitySkipMetric(reason string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_identity_skips_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"reason": reason,
})
}
func (s *TCPServer) recordIdentityIssueMetric(status string, env envelope.FrameEnvelope, reason string) {
if s.metrics == nil {
return
}
messageID := strings.TrimSpace(env.MessageID)
if messageID == "" {
messageID = "unknown"
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "unknown"
}
s.metrics.IncCounter("vehicle_gateway_identity_issues_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"status": status,
"message_id": messageID,
"reason": reason,
})
}
func (s *TCPServer) recordIdentityDuration(status string, elapsed time.Duration) {
recordGatewayIdentityDuration(s.metrics, s.protocol.Protocol, status, elapsed)
}
func (s *TCPServer) recordConnectionMetric(delta float64) {
if s.metrics == nil {
return
@@ -402,6 +670,12 @@ func (s *TCPServer) recordConnectionClose(reason string) {
})
}
func shouldCloseAfterAuthentication(env envelope.FrameEnvelope) bool {
return env.AuthenticationEnforced &&
strings.TrimSpace(env.AuthenticationStatus) != "" &&
env.AuthenticationStatus != authentication.StatusAccepted
}
func identityStatus(env envelope.FrameEnvelope) string {
if strings.TrimSpace(env.VIN) != "" {
return "resolved"
@@ -412,6 +686,28 @@ func identityStatus(env envelope.FrameEnvelope) string {
return "unresolved"
}
func identityIssueReason(status string, err error) string {
switch status {
case "timeout":
return "timeout"
case "error":
if err == nil {
return "resolver_error"
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
switch {
case strings.Contains(text, "connection"), strings.Contains(text, "tcp"), strings.Contains(text, "network"):
return "identity_store_connection"
case strings.Contains(text, "timeout"), strings.Contains(text, "deadline"):
return "timeout"
default:
return "resolver_error"
}
default:
return "no_binding"
}
}
func annotateIdentityUnresolved(env *envelope.FrameEnvelope) {
if env == nil || env.ParseStatus == envelope.ParseBadFrame || strings.TrimSpace(env.VIN) != "" {
return

View File

@@ -3,20 +3,48 @@ package gateway
import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"strings"
"syscall"
"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/metrics"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/jt808"
)
func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
func TestEnrichConnectionPlatformPromotesSourceMetadata(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x02",
ParseStatus: envelope.ParseOK,
Fields: map[string]any{},
Parsed: map[string]any{},
}
state := &connectionState{platformName: "Hyundai"}
enrichConnectionPlatform(&env, state)
if env.PlatformName != "Hyundai" || env.SourceCode != "Hyundai" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
if got := fmt.Sprint(env.Fields["platform_account"]); got != "Hyundai" {
t.Fatalf("platform_account = %q", got)
}
if got := fmt.Sprint(env.Parsed["platform_name"]); got != "Hyundai" {
t.Fatalf("parsed platform_name = %q", got)
}
}
func TestTCPServerPublishesGoodFrameToRawAndFieldsByDefault(t *testing.T) {
frame, err := hex.DecodeString("7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E")
if err != nil {
t.Fatal(err)
@@ -28,6 +56,7 @@ func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
}, sink)
server.resolver = vinResolver{vin: "LNBVIN00000000001"}
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
@@ -39,11 +68,71 @@ func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
if len(sink.raw) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
}
if len(sink.fields) != 1 {
t.Fatalf("fields=%d, want 1", len(sink.fields))
}
if sink.raw[0].Phone != "13307795425" {
t.Fatalf("phone = %q", sink.raw[0].Phone)
}
if sink.raw[0].Fields[envelope.FieldTotalMileageKM] != 10241.2 {
t.Fatalf("total mileage = %#v", sink.raw[0].Fields[envelope.FieldTotalMileageKM])
if sink.raw[0].EventKind != envelope.EventKindRaw {
t.Fatalf("raw event kind = %q, want %q", sink.raw[0].EventKind, envelope.EventKindRaw)
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
if got := sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got == nil {
t.Fatalf("raw parsed fields missing total mileage: %#v", sink.raw[0].ParsedFields)
}
if got, want := sink.fields[0].Fields["jt808.location.total_mileage_km"], sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got != want {
t.Fatalf("fields event should reuse raw parsed field, got %#v want %#v", got, want)
}
if sink.fields[0].EventKind != envelope.EventKindFields {
t.Fatalf("fields event kind = %q, want %q", sink.fields[0].EventKind, envelope.EventKindFields)
}
if got, want := sink.fields[0].SourceEventID, sink.raw[0].StableEventID(); got != want {
t.Fatalf("fields source event id = %#v, want %s", got, want)
}
if sink.fields[0].FieldMapping == "" {
t.Fatal("fields event should expose field mapping version")
}
if len(sink.fields[0].Parsed) != 0 || len(sink.fields[0].ParsedFields) != 0 {
t.Fatalf("fields envelope should not duplicate parsed payload: parsed=%#v parsed_fields=%#v", sink.fields[0].Parsed, sink.fields[0].ParsedFields)
}
}
func TestTCPServerRecordsFieldsPublishedMetric(t *testing.T) {
frame, err := hex.DecodeString("7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E")
if err != nil {
t.Fatal(err)
}
sink := &recordingSink{}
registry := metrics.NewRegistry()
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = vinResolver{vin: "LNBVIN00000000001"}
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
t.Fatalf("client.Write() error = %v", err)
}
_ = client.Close()
<-done
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="JT808",status="published"} 1`,
`vehicle_gateway_fields_count{protocol="JT808",status="published"} `,
`vehicle_gateway_fields_count_histogram_count{protocol="JT808",status="published"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="JT808",status="ok"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metrics missing %s:\n%s", want, text)
}
}
}
@@ -79,8 +168,16 @@ func TestTCPServerRecordsFrameMetrics(t *testing.T) {
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_frames_total{protocol="JT808",status="OK"} 1`,
`vehicle_gateway_last_frame_unix_seconds{protocol="JT808",status="OK"} `,
`vehicle_gateway_identity_total{protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="no_binding",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="JT808",status="unresolved"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_sum{protocol="JT808",status="unresolved"}`,
`vehicle_gateway_publish_total{kind="raw",protocol="JT808",status="ok"} 1`,
`vehicle_gateway_last_publish_unix_seconds{kind="raw",protocol="JT808",status="ok"} `,
`vehicle_gateway_fields_total{protocol="JT808",status="skipped_non_realtime"} 1`,
`vehicle_gateway_frame_duration_ms{protocol="JT808",status="OK"}`,
`vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="OK"} 1`,
`vehicle_gateway_frame_duration_ms_histogram_count{protocol="JT808",status="OK"} 1`,
@@ -95,6 +192,244 @@ func TestTCPServerRecordsFrameMetrics(t *testing.T) {
}
}
func TestTCPServerRecordsNonRealtimeFieldsSkipMetric(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.handleFrame(context.Background(), nil, buildGBFrame(0x07, 0xfe, "LNBSCB3D4R1234567", nil), "127.0.0.1:32960", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if len(sink.fields) != 0 {
t.Fatalf("fields=%d, want 0", len(sink.fields))
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_fields_total{protocol="GB32960",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestTCPServerSkipsIdentityForGB32960PlatformControlFrame(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
resolver := &countingResolver{vin: "LNBVIN00000000001"}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = resolver
server.handleFrame(context.Background(), nil, buildGBFrame(0x05, 0xfe, "PLATFORMLOGIN0001", nil), "127.0.0.1:32960", &connectionState{})
if resolver.calls != 0 {
t.Fatalf("resolver calls = %d, want 0 for platform control frame", resolver.calls)
}
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
text := registry.Render()
if strings.Contains(text, "vehicle_gateway_identity_total") {
t.Fatalf("identity metric should not be recorded for platform control frame:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_identity_skips_total{protocol="GB32960",reason="non_vehicle_frame"} 1`) {
t.Fatalf("identity skip metric missing:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_fields_total{protocol="GB32960",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestTCPServerAuthenticatesAndRedactsGB32960PlatformLogin(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
Authenticate: authentication.NewGB32960PlatformAuthenticator(authentication.ModeObserve, map[string][]string{"platform-a": {"secret-a"}}),
}, sink)
server.metrics = registry
body := []byte{0x1a, 0x07, 0x0d, 0x14, 0x00, 0x00, 0x00, 0x01}
body = append(body, fixedASCIIBytes("platform-a", 12)...)
body = append(body, fixedASCIIBytes("secret-a", 20)...)
body = append(body, 0x01)
server.handleFrame(context.Background(), nil, buildGBFrame(0x05, 0xfe, "12345678901234567", body), "127.0.0.1:32960", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
raw := sink.raw[0]
if raw.AuthenticationMode != "observe" || raw.AuthenticationStatus != authentication.StatusAccepted || raw.AuthenticationEnforced {
t.Fatalf("authentication metadata = mode:%q status:%q enforced:%v", raw.AuthenticationMode, raw.AuthenticationStatus, raw.AuthenticationEnforced)
}
if _, exists := raw.ParsedFields["gb32960.platform_login.password"]; exists {
t.Fatalf("plaintext password leaked into parsed fields: %#v", raw.ParsedFields)
}
if got := raw.ParsedFields["gb32960.platform_login.password_present"]; got != "true" {
t.Fatalf("password presence marker = %#v", got)
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_authentication_total{mode="observe",protocol="GB32960",source="configured",status="accepted"} 1`) {
t.Fatalf("authentication metric missing:\n%s", text)
}
}
func TestTCPServerResolvesIdentityForGB32960RealtimeFrame(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
resolver := &countingResolver{vin: "LNBVIN00000000001"}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = resolver
server.handleFrame(context.Background(), nil, buildGBFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil), "127.0.0.1:32960", &connectionState{})
if resolver.calls != 1 {
t.Fatalf("resolver calls = %d, want 1 for realtime frame", resolver.calls)
}
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LNBVIN00000000001" {
t.Fatalf("raw vin = %q, want resolved vin", sink.raw[0].VIN)
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_identity_total{protocol="GB32960",status="resolved"} 1`) {
t.Fatalf("identity resolved metric missing:\n%s", text)
}
}
func TestTCPServerRecordsIdentityTimeoutMetrics(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParseStatus: envelope.ParseOK,
}, nil
},
},
Sink: &recordingSink{},
Resolver: errorResolver{err: context.DeadlineExceeded},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="timeout",status="timeout"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="JT808",status="timeout"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_frames_total{protocol="JT808",status="PARTIAL"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metrics missing %s:\n%s", want, text)
}
}
}
func TestTCPServerPreservesResolvedEnvelopeWhenIdentitySideEffectFails(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
wantErr := errors.New("registration upsert failed")
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParsedFields: map[string]any{
"jt808.location.total_mileage_km": "10241.2",
},
ParseStatus: envelope.ParseOK,
}, nil
},
},
Sink: sink,
Resolver: resolvedErrorResolver{vin: "LNBVIN00000000001", err: wantErr},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw count = %d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LNBVIN00000000001" {
t.Fatalf("raw vin = %q, want resolved vin", sink.raw[0].VIN)
}
if sink.raw[0].ParseStatus != envelope.ParsePartial {
t.Fatalf("parse status = %q, want PARTIAL", sink.raw[0].ParseStatus)
}
if len(sink.raw[0].Parsed) != 0 {
t.Fatalf("canonical raw should not duplicate parsed tree: %#v", sink.raw[0].Parsed)
}
if got := sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got != "10241.2" {
t.Fatalf("protocol field lost after identity side-effect failure: %#v", got)
}
for field := range sink.raw[0].ParsedFields {
if strings.HasPrefix(field, "jt808.identity.") {
t.Fatalf("derived identity annotation leaked into protocol fields: %s", field)
}
}
if len(sink.fields) != 1 || sink.fields[0].VIN != "LNBVIN00000000001" {
t.Fatalf("fields should preserve resolved vin, fields=%#v", sink.fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="JT808",status="error"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="resolver_error",status="error"} 1`,
`vehicle_gateway_frames_total{protocol="JT808",status="PARTIAL"} 1`,
`vehicle_gateway_fields_total{protocol="JT808",status="published"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerRecordsActiveConnectionGauge(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
@@ -140,6 +475,45 @@ func TestTCPServerRecordsActiveConnectionGauge(t *testing.T) {
}
}
func TestTCPServerClosesActiveConnectionWhenContextCancelled(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
},
Sink: &recordingSink{},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
IdleTimeout: time.Minute,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
client, srv := net.Pipe()
done := make(chan struct{})
go func() {
server.handleConnection(ctx, srv)
close(done)
}()
waitForMetric(t, registry, `vehicle_gateway_active_connections{protocol="JT808"} 1`)
cancel()
server.closeActiveConnections()
defer client.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection handler did not exit after context cancellation")
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_connection_closes_total{protocol="JT808",reason="context_cancelled"} 1`) {
t.Fatalf("context cancellation close metric missing:\n%s", text)
}
}
func TestTCPServerRecordsConnectionRejectionMetric(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
@@ -188,6 +562,57 @@ func TestTCPServerRecordsConnectionCloseReasonMetric(t *testing.T) {
}
}
func TestTCPServerConnectionLifecycleLogsStayDebug(t *testing.T) {
source, err := os.ReadFile("tcp_server.go")
if err != nil {
t.Fatal(err)
}
text := string(source)
for _, forbidden := range []string{
`Info("tcp connection opened"`,
`Info("tcp connection closed"`,
`Warn("tcp connection idle timeout"`,
`Warn("tcp connection closed by peer"`,
} {
if strings.Contains(text, forbidden) {
t.Fatalf("high-volume connection lifecycle log should not be info/warn: %s", forbidden)
}
}
for _, want := range []string{
`Debug("tcp connection opened"`,
`Debug("tcp connection closed"`,
`Debug("tcp connection idle timeout"`,
`Debug("tcp connection closed by peer"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("connection lifecycle log should remain debug: %s", want)
}
}
}
func TestIsRoutineTCPReadCloseClassifiesRemoteCloseNoise(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "eof", err: io.EOF, want: true},
{name: "net closed", err: net.ErrClosed, want: true},
{name: "reset wrapped", err: fmt.Errorf("read tcp: %w", syscall.ECONNRESET), want: true},
{name: "pipe wrapped", err: fmt.Errorf("write tcp: %w", syscall.EPIPE), want: true},
{name: "reset text", err: errors.New("read tcp 172.17.111.55:808->117.132.196.176:22187: read: connection reset by peer"), want: true},
{name: "unexpected", err: errors.New("checksum parser exploded"), want: false},
{name: "nil", err: nil, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRoutineTCPReadClose(tt.err); got != tt.want {
t.Fatalf("isRoutineTCPReadClose(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestTCPServerPublishesBadFrameOnlyToRaw(t *testing.T) {
good := buildGBFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil)
good[len(good)-1] ^= 0xff
@@ -248,14 +673,14 @@ func TestTCPServerAnnotatesUnresolvedIdentity(t *testing.T) {
if len(sink.raw) != 1 {
t.Fatalf("raw count = %d", len(sink.raw))
}
identity, ok := sink.raw[0].Parsed["identity"].(map[string]any)
if !ok || identity["resolved"] != false || identity["reason"] != "no_binding" {
t.Fatalf("identity metadata = %#v", sink.raw[0].Parsed["identity"])
if len(sink.raw[0].Parsed) != 0 || len(sink.raw[0].ParsedFields) != 0 {
t.Fatalf("unresolved derived metadata must not enter canonical protocol payload: parsed=%#v fields=%#v", sink.raw[0].Parsed, sink.raw[0].ParsedFields)
}
}
func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
frame := buildGBFrame(0x07, 0xfe, "LNBSCB3D4R1234567", nil)
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
@@ -269,6 +694,7 @@ func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
return []byte("ACK"), true, nil
},
}, sink)
server.metrics = registry
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
@@ -283,6 +709,79 @@ func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
}
_ = client.Close()
<-done
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_response_total{message_id="0x07",protocol="GB32960",status="ok"} 1`,
`vehicle_gateway_last_response_unix_seconds{message_id="0x07",protocol="GB32960",status="ok"} `,
`vehicle_gateway_response_duration_ms_histogram_count{message_id="0x07",protocol="GB32960",status="ok"} 1`,
`vehicle_gateway_response_e2e_recent_p99_ms{protocol="GB32960"} `,
`vehicle_gateway_response_e2e_recent_samples{protocol="GB32960"} `,
} {
if !strings.Contains(text, want) {
t.Fatalf("response metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerWritesProtocolResponseWhenFieldsPublishFails(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{fieldsErr: errors.New("fields queue full")}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: func(raw []byte) ([][]byte, []byte, error) {
return [][]byte{raw}, nil, nil
},
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "LNBVIN00000000001",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParsedFields: map[string]any{
"jt808.location.total_mileage_km": "10241.2",
},
ParseStatus: envelope.ParseOK,
}, nil
},
Respond: func(_ []byte, env envelope.FrameEnvelope) ([]byte, bool, error) {
if len(sink.raw) != 1 || sink.raw[0].EventID != env.EventID {
t.Fatalf("response built before raw publish: raw=%d", len(sink.raw))
}
return []byte("ACK"), true, nil
},
}, sink)
server.metrics = registry
client, done := runPipe(t, server)
if _, err := client.Write([]byte{0x01}); err != nil {
t.Fatalf("client.Write() error = %v", err)
}
buf := make([]byte, 3)
if _, err := io.ReadFull(client, buf); err != nil {
t.Fatalf("read response error = %v", err)
}
if string(buf) != "ACK" {
t.Fatalf("response = %q", string(buf))
}
_ = client.Close()
<-done
if len(sink.fields) != 1 {
t.Fatalf("fields publish attempts = %d, want 1", len(sink.fields))
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_response_total{message_id="0x0200",protocol="JT808",status="ok"} 1`,
`vehicle_gateway_fields_total{protocol="JT808",status="publish_error"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="JT808",status="error"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerUsesUncancelledFrameContextForAlreadyReadFrame(t *testing.T) {
@@ -368,6 +867,49 @@ func (r *contextCheckingResolver) Resolve(ctx context.Context, env envelope.Fram
return env, r.ctxErr
}
type vinResolver struct {
vin string
}
func (r vinResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
env.VIN = r.vin
return env, nil
}
type countingResolver struct {
calls int
vin string
}
func (r *countingResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
r.calls++
env.VIN = r.vin
return env, nil
}
type errorResolver struct {
err error
}
func (r errorResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
return env, r.err
}
type resolvedErrorResolver struct {
vin string
err error
}
func (r resolvedErrorResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
env.VIN = r.vin
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
env.Parsed["identity"] = map[string]any{"resolved": true, "source": "test"}
env.EventID = env.StableEventID()
return env, r.err
}
type contextCheckingSink struct {
rawCtxErr error
unifiedCtxErr error
@@ -426,25 +968,40 @@ func runPipe(t *testing.T, server *TCPServer) (net.Conn, <-chan struct{}) {
return client, done
}
func waitForMetric(t *testing.T, registry *metrics.Registry, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if strings.Contains(registry.Render(), want) {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("metric missing %s:\n%s", want, registry.Render())
}
type recordingSink struct {
raw []envelope.FrameEnvelope
unified []envelope.FrameEnvelope
fields []envelope.FrameEnvelope
raw []envelope.FrameEnvelope
unified []envelope.FrameEnvelope
fields []envelope.FrameEnvelope
rawErr error
unifiedErr error
fieldsErr error
}
func (s *recordingSink) PublishRaw(_ context.Context, env envelope.FrameEnvelope) error {
s.raw = append(s.raw, env)
return nil
return s.rawErr
}
func (s *recordingSink) PublishUnified(_ context.Context, env envelope.FrameEnvelope) error {
s.unified = append(s.unified, env)
return nil
return s.unifiedErr
}
func (s *recordingSink) PublishFields(_ context.Context, env envelope.FrameEnvelope) error {
s.fields = append(s.fields, env)
return nil
return s.fieldsErr
}
func (s *recordingSink) Close() error {
@@ -475,3 +1032,9 @@ func buildGBFrame(command byte, response byte, vin string, body []byte) []byte {
}
return append(frame, bcc)
}
func fixedASCIIBytes(value string, size int) []byte {
out := make([]byte, size)
copy(out, []byte(value))
return out
}

View File

@@ -40,6 +40,7 @@ func NewMux(service string, checks []Check, registry *metrics.Registry) *http.Se
mux.Handle("/healthz", handler)
mux.Handle("/readyz", handler)
if registry != nil {
metrics.RegisterServiceInfo(registry, handler.service)
mux.Handle("/metrics", metrics.NewHandler(registry))
}
return mux

View File

@@ -80,6 +80,13 @@ func TestNewMuxRegistersHealthAndReadinessRoutes(t *testing.T) {
t.Fatalf("%s status = %d body=%s", path, response.Code, response.Body.String())
}
}
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if !strings.Contains(response.Body.String(), `vehicle_service_info{service="vehicle-stat-writer"} 1`) {
t.Fatalf("service info metric missing: %s", response.Body.String())
}
}
func TestNewServerReturnsNilWhenAddressIsEmpty(t *testing.T) {

View File

@@ -76,6 +76,7 @@ type LocationRow struct {
Latitude float64 `json:"latitude"`
AltitudeM *float64 `json:"altitude_m,omitempty"`
SpeedKMH *float64 `json:"speed_kmh,omitempty"`
SOCPercent *float64 `json:"soc_percent,omitempty"`
DirectionDeg *int64 `json:"direction_deg,omitempty"`
AlarmFlag *int64 `json:"alarm_flag,omitempty"`
StatusFlag *int64 `json:"status_flag,omitempty"`
@@ -218,6 +219,7 @@ func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]
var receivedAt scanDateTime
var altitude sql.NullFloat64
var speed sql.NullFloat64
var soc sql.NullFloat64
var direction sql.NullInt64
var alarm sql.NullInt64
var status sql.NullInt64
@@ -230,6 +232,7 @@ func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]
&row.Latitude,
&altitude,
&speed,
&soc,
&direction,
&alarm,
&status,
@@ -243,6 +246,7 @@ func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]
row.ReceivedAt = receivedAt.String
row.AltitudeM = nullableFloat(altitude)
row.SpeedKMH = nullableFloat(speed)
row.SOCPercent = nullableFloat(soc)
row.DirectionDeg = nullableInt(direction)
row.AlarmFlag = nullableInt(alarm)
row.StatusFlag = nullableInt(status)
@@ -563,7 +567,7 @@ func quotedList(values []string) string {
func buildLocationSQL(table string, query LocationQuery) (string, []any) {
where := locationWhere(query)
sqlText := `SELECT ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh, direction_deg, alarm_flag, status_flag, total_mileage_km, protocol, vin FROM ` + table
sqlText := `SELECT ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh, soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km, protocol, vin FROM ` + table
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
@@ -954,11 +958,11 @@ func normalizeDateTimeLiteral(value string) string {
shanghai := time.FixedZone("Asia/Shanghai", 8*3600)
for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil {
return parsed.UTC().Format("2006-01-02 15:04:05")
return parsed.In(shanghai).Format("2006-01-02 15:04:05")
}
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed.UTC().Format("2006-01-02 15:04:05")
return parsed.In(shanghai).Format("2006-01-02 15:04:05")
}
return value
}

View File

@@ -391,10 +391,10 @@ func TestLocationHandlerReturnsLocationsByVIN(t *testing.T) {
mock.ExpectQuery("vin = 'LKLG7C4E3NA774736'").
WillReturnRows(sqlmock.NewRows([]string{
"ts", "event_id", "received_at", "longitude", "latitude", "altitude_m", "speed_kmh",
"direction_deg", "alarm_flag", "status_flag", "total_mileage_km", "protocol", "vin",
"soc_percent", "direction_deg", "alarm_flag", "status_flag", "total_mileage_km", "protocol", "vin",
}).AddRow(
"2026-07-02 00:18:22", "event-3", "2026-07-02 00:22:43",
121.07764, 30.585928, 11.0, 8.0, 171, 0, 4718595, 8792.8,
121.07764, 30.585928, 11.0, 8.0, 82.5, 171, 0, 4718595, 8792.8,
"JT808", "LKLG7C4E3NA774736",
))
@@ -408,7 +408,7 @@ func TestLocationHandlerReturnsLocationsByVIN(t *testing.T) {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"vin":"LKLG7C4E3NA774736"`, `"longitude":121.07764`, `"total_mileage_km":8792.8`, `"total":17`} {
for _, want := range []string{`"vin":"LKLG7C4E3NA774736"`, `"longitude":121.07764`, `"soc_percent":82.5`, `"total_mileage_km":8792.8`, `"total":17`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
@@ -432,10 +432,10 @@ func TestLocationHandlerSkipsTotalCountByDefault(t *testing.T) {
mock.ExpectQuery("vin = 'LKLG7C4E3NA774736'").
WillReturnRows(sqlmock.NewRows([]string{
"ts", "event_id", "received_at", "longitude", "latitude", "altitude_m", "speed_kmh",
"direction_deg", "alarm_flag", "status_flag", "total_mileage_km", "protocol", "vin",
"soc_percent", "direction_deg", "alarm_flag", "status_flag", "total_mileage_km", "protocol", "vin",
}).AddRow(
"2026-07-02 00:18:22", "event-3", "2026-07-02 00:22:43",
121.07764, 30.585928, 11.0, 8.0, 171, 0, 4718595, 8792.8,
121.07764, 30.585928, 11.0, 8.0, 82.5, 171, 0, 4718595, 8792.8,
"JT808", "LKLG7C4E3NA774736",
))
@@ -476,17 +476,17 @@ func TestParseRawFrameQueryAcceptsDatetimeLocalValues(t *testing.T) {
if err != nil {
t.Fatalf("parseRawFrameQuery() error = %v", err)
}
if query.DateFrom != "2026-06-30 16:00:00" || query.DateTo != "2026-07-01 16:00:00" {
if query.DateFrom != "2026-07-01 00:00:00" || query.DateTo != "2026-07-02 00:00:00" {
t.Fatalf("date range = %q -> %q", query.DateFrom, query.DateTo)
}
}
func TestNormalizeDateTimeLiteralConvertsInputToTDengineUTCTime(t *testing.T) {
func TestNormalizeDateTimeLiteralUsesAsiaShanghaiQueryTime(t *testing.T) {
for raw, want := range map[string]string{
"2026-07-01T00:00:00": "2026-06-30 16:00:00",
"2026-07-01 00:00:00": "2026-06-30 16:00:00",
"2026-07-01T00:00:00+08:00": "2026-06-30 16:00:00",
"2026-06-30T16:00:00Z": "2026-06-30 16:00:00",
"2026-07-01T00:00:00": "2026-07-01 00:00:00",
"2026-07-01 00:00:00": "2026-07-01 00:00:00",
"2026-07-01T00:00:00+08:00": "2026-07-01 00:00:00",
"2026-06-30T16:00:00Z": "2026-07-01 00:00:00",
} {
if got := normalizeDateTimeLiteral(raw); got != want {
t.Fatalf("normalizeDateTimeLiteral(%q) = %q, want %q", raw, got, want)
@@ -522,7 +522,7 @@ func TestBuildLocationSQLUsesLiteralsForTDengine(t *testing.T) {
for _, want := range []string{
"protocol = 'JT808'",
"vin = 'LKLG7C4E3NA774736'",
"ts >= '2026-07-01 16:00:00'",
"ts >= '2026-07-02 00:00:00'",
"LIMIT 20 OFFSET 5",
} {
if !strings.Contains(sqlText, want) {
@@ -556,8 +556,8 @@ func TestBuildRawFrameSQLUsesLiteralsForTDengine(t *testing.T) {
"vehicle_key = 'JT808:013307811350'",
"vin = 'VIN''1'",
"message_id = 512",
"ts >= '2026-06-30 16:00:00'",
"ts <= '2026-07-01 15:59:59'",
"ts >= '2026-07-01 00:00:00'",
"ts <= '2026-07-01 23:59:59'",
"LIMIT 20 OFFSET 5",
} {
if !strings.Contains(sqlText, want) {

View File

@@ -54,6 +54,7 @@ func SchemaStatements(database string) []string {
latitude DOUBLE,
altitude_m DOUBLE,
speed_kmh DOUBLE,
soc_percent DOUBLE,
direction_deg INT,
alarm_flag BIGINT,
status_flag BIGINT,
@@ -64,3 +65,13 @@ func SchemaStatements(database string) []string {
)`,
}
}
// SchemaMigrationStatements contains additive TDengine changes that must also
// be applied to an already existing stable. The writer treats duplicate-column
// errors as success so startup remains idempotent across releases.
func SchemaMigrationStatements(database string) []string {
if database == "" {
database = DefaultDatabase
}
return []string{"ALTER STABLE " + database + ".vehicle_locations ADD COLUMN soc_percent DOUBLE"}
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"sync"
@@ -15,6 +16,7 @@ import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
type Execer interface {
@@ -27,9 +29,23 @@ type Writer struct {
cache tableCache
}
type AppendResult struct {
RawRows int
LocationRows int
LocationError error
}
const (
LocationStatusOK = "ok"
LocationStatusSkippedNonRealtime = "skipped_non_realtime"
LocationStatusSkippedMissingVIN = "skipped_missing_vin"
LocationStatusSkippedMissingCoordinates = "skipped_missing_coordinates"
)
const (
rawFramePayloadInlineLimit = 12_000
rawFramePayloadChunkSize = 16_000
tdengineInsertSoftLimit = 6 * 1024 * 1024
)
type payloadChunk struct {
@@ -100,9 +116,22 @@ func (w *Writer) EnsureSchema(ctx context.Context, database string) error {
return err
}
}
for _, statement := range SchemaMigrationStatements(database) {
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isDuplicateTDengineColumnError(err) {
return err
}
}
return nil
}
func isDuplicateTDengineColumnError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "duplicate column") || strings.Contains(message, "duplicated column") || strings.Contains(message, "column already exists")
}
func (w *Writer) qualify(table string) string {
table = normalizeIdentifier(table)
if w.database == "" {
@@ -112,20 +141,52 @@ func (w *Writer) qualify(table string) string {
}
func (w *Writer) AppendAll(ctx context.Context, env envelope.FrameEnvelope) error {
if err := w.AppendRawFrame(ctx, env); err != nil {
result, err := w.AppendAllWithResult(ctx, env)
if err != nil {
return err
}
return w.AppendLocation(ctx, env)
return result.LocationError
}
func (w *Writer) AppendAllWithResult(ctx context.Context, env envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if err := w.AppendRawFrame(ctx, env); err != nil {
return result, err
}
result.RawRows = 1
rows, err := w.appendLocationWithCount(ctx, env)
if err != nil {
result.LocationError = err
return result, nil
}
result.LocationRows = rows
return result, nil
}
func (w *Writer) AppendAllBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
if len(envelopes) == 0 {
return nil
}
if err := w.AppendRawFrameBatch(ctx, envelopes); err != nil {
result, err := w.AppendAllBatchWithResult(ctx, envelopes)
if err != nil {
return err
}
return w.AppendLocationBatch(ctx, envelopes)
return result.LocationError
}
func (w *Writer) AppendAllBatchWithResult(ctx context.Context, envelopes []envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if len(envelopes) == 0 {
return result, nil
}
if err := w.AppendRawFrameBatch(ctx, envelopes); err != nil {
return result, err
}
result.RawRows = len(envelopes)
rows, err := w.appendLocationBatchWithCount(ctx, envelopes)
if err != nil {
result.LocationError = err
return result, nil
}
result.LocationRows = rows
return result, nil
}
func (w *Writer) AppendRawFrame(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -165,7 +226,6 @@ VALUES (%s)`, w.qualify(chunkTable), joinLiterals(chunkValues(env, chunk)))); er
func (w *Writer) AppendRawFrameBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
rowsByTable := map[string][]string{}
chunkRowsByTable := map[string][]string{}
chunkEnvByTable := map[string]envelope.FrameEnvelope{}
for _, env := range envelopes {
table := tableName("raw", env)
if err := w.ensureRawChild(ctx, table, "raw_frames", env); err != nil {
@@ -184,87 +244,141 @@ func (w *Writer) AppendRawFrameBatch(ctx context.Context, envelopes []envelope.F
if err := w.ensureRawChild(ctx, chunkTable, "raw_frame_payload_chunks", env); err != nil {
return err
}
chunkEnvByTable[chunkTable] = env
for _, chunk := range chunks {
chunkRowsByTable[chunkTable] = append(chunkRowsByTable[chunkTable], "("+joinLiterals(chunkValues(env, chunk))+")")
}
}
for table, rows := range rowsByTable {
if len(rows) == 0 {
continue
}
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes,
raw_hex, raw_text, parsed_json, parse_status, parse_error, source_endpoint)
VALUES %s`, w.qualify(table), strings.Join(rows, ","))); err != nil {
return err
}
if err := w.execMultiTableInsert(ctx, rowsByTable, `ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes,
raw_hex, raw_text, parsed_json, parse_status, parse_error, source_endpoint`); err != nil {
return err
}
for table, rows := range chunkRowsByTable {
if len(rows) == 0 {
continue
}
_ = chunkEnvByTable[table]
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, frame_id, received_at, payload_kind, chunk_index, chunk_count, chunk_text)
VALUES %s`, w.qualify(table), strings.Join(rows, ","))); err != nil {
return err
}
if err := w.execMultiTableInsert(ctx, chunkRowsByTable, `ts, event_id, frame_id, received_at, payload_kind, chunk_index, chunk_count, chunk_text`); err != nil {
return err
}
return nil
}
func (w *Writer) AppendLocation(ctx context.Context, env envelope.FrameEnvelope) error {
if strings.TrimSpace(env.VIN) == "" {
return nil
}
longitude, okLon := floatField(env, envelope.FieldLongitude)
latitude, okLat := floatField(env, envelope.FieldLatitude)
if !okLon || !okLat {
return nil
}
table := locationTableName(env)
if err := w.ensureLocationChild(ctx, table, env); err != nil {
return err
}
_, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
direction_deg, alarm_flag, status_flag, total_mileage_km)
VALUES (%s)`, w.qualify(table), joinLiterals(locationValues(env, longitude, latitude))))
_, err := w.appendLocationWithCount(ctx, env)
return err
}
func (w *Writer) appendLocationWithCount(ctx context.Context, env envelope.FrameEnvelope) (int, error) {
longitude, latitude, status := locationCandidate(env)
if status != LocationStatusOK {
return 0, nil
}
table := locationTableName(env)
if err := w.ensureLocationChild(ctx, table, env); err != nil {
return 0, err
}
_, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km)
VALUES (%s)`, w.qualify(table), joinLiterals(locationValues(env, longitude, latitude))))
if err != nil {
return 0, err
}
return 1, nil
}
func (w *Writer) AppendLocationBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
_, err := w.appendLocationBatchWithCount(ctx, envelopes)
return err
}
func (w *Writer) appendLocationBatchWithCount(ctx context.Context, envelopes []envelope.FrameEnvelope) (int, error) {
rowsByTable := map[string][]string{}
rowCount := 0
for _, env := range envelopes {
if strings.TrimSpace(env.VIN) == "" {
continue
}
longitude, okLon := floatField(env, envelope.FieldLongitude)
latitude, okLat := floatField(env, envelope.FieldLatitude)
if !okLon || !okLat {
longitude, latitude, status := locationCandidate(env)
if status != LocationStatusOK {
continue
}
table := locationTableName(env)
if err := w.ensureLocationChild(ctx, table, env); err != nil {
return err
return rowCount, err
}
rowsByTable[table] = append(rowsByTable[table], "("+joinLiterals(locationValues(env, longitude, latitude))+")")
rowCount++
}
for table, rows := range rowsByTable {
if len(rows) == 0 {
continue
}
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
direction_deg, alarm_flag, status_flag, total_mileage_km)
VALUES %s`, w.qualify(table), strings.Join(rows, ","))); err != nil {
if err := w.execMultiTableInsert(ctx, rowsByTable, `ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km`); err != nil {
return rowCount, err
}
return rowCount, nil
}
func (w *Writer) execMultiTableInsert(ctx context.Context, rowsByTable map[string][]string, columns string) error {
statements := buildMultiTableInsertStatements(rowsByTable, w.qualify, columns, tdengineInsertSoftLimit)
for _, statement := range statements {
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func buildMultiTableInsertStatements(rowsByTable map[string][]string, qualify func(string) string, columns string, softLimit int) []string {
if len(rowsByTable) == 0 {
return nil
}
keys := make([]string, 0, len(rowsByTable))
for table, rows := range rowsByTable {
if len(rows) > 0 {
keys = append(keys, table)
}
}
sort.Strings(keys)
if len(keys) == 0 {
return nil
}
if qualify == nil {
qualify = func(table string) string { return table }
}
var statements []string
current := "INSERT INTO "
parts := 0
for _, table := range keys {
part := fmt.Sprintf(`%s
(%s)
VALUES %s`, qualify(table), columns, strings.Join(rowsByTable[table], ","))
if parts > 0 && softLimit > 0 && len(current)+1+len(part) > softLimit {
statements = append(statements, current)
current = "INSERT INTO "
parts = 0
}
if parts > 0 {
current += " "
}
current += part
parts++
}
if parts > 0 {
statements = append(statements, current)
}
return statements
}
func LocationStatus(env envelope.FrameEnvelope) string {
_, _, status := locationCandidate(env)
return status
}
func locationCandidate(env envelope.FrameEnvelope) (float64, float64, string) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return 0, 0, LocationStatusSkippedNonRealtime
}
if strings.TrimSpace(env.VIN) == "" {
return 0, 0, LocationStatusSkippedMissingVIN
}
location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.ParsedFields)
if !ok {
return 0, 0, LocationStatusSkippedMissingCoordinates
}
return location.Longitude, location.Latitude, LocationStatusOK
}
func (w *Writer) ensureRawChild(ctx context.Context, table string, stable string, env envelope.FrameEnvelope) error {
key := stable + "." + table
return w.cache.doOnce(key, func() error {
@@ -325,7 +439,7 @@ func rawValues(env envelope.FrameEnvelope, rawHex string, rawText string, parsed
func chunkValues(env envelope.FrameEnvelope, chunk payloadChunk) []any {
received := millis(env.ReceivedAtMS)
return []any{
received,
received.Add(time.Duration(chunk.Index) * time.Millisecond),
env.StableEventID(),
frameID(env),
received,
@@ -379,18 +493,21 @@ func safeChunkEnd(value string, start int, maxBytes int) int {
func locationValues(env envelope.FrameEnvelope, longitude float64, latitude float64) []any {
received := millis(env.ReceivedAtMS)
location, _ := telemetry.LocationProjectionForProtocol(env.Protocol, env.ParsedFields)
totalMileageKM, hasTotalMileage := telemetry.TotalMileageKM(env.Protocol, env.ParsedFields)
return []any{
eventTimeOrReceived(env),
env.StableEventID(),
received,
longitude,
latitude,
floatFieldOrNil(env, "altitude_m"),
floatFieldOrNil(env, envelope.FieldSpeedKMH),
intFieldOrNil(env, "direction_deg"),
intFieldOrNil(env, "alarm_flag"),
intFieldOrNil(env, "status_flag"),
floatFieldOrNil(env, envelope.FieldTotalMileageKM),
optionalFloat(location.AltitudeM),
optionalFloat(location.SpeedKMH),
optionalFloat(location.SOCPercent),
optionalInt(location.DirectionDeg),
optionalInt64(location.AlarmFlag),
optionalInt64(location.StatusFlag),
optionalPositiveFloat(totalMileageKM, hasTotalMileage),
}
}
@@ -463,76 +580,37 @@ func parsedFieldsJSONString(env envelope.FrameEnvelope) string {
return jsonString(fields)
}
func floatField(env envelope.FrameEnvelope, key string) (float64, bool) {
if env.Fields == nil {
return 0, false
}
value, ok := env.Fields[key]
if !ok || value == nil {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
default:
return 0, false
func optionalFloat(value *float64) any {
if value == nil {
return nil
}
return *value
}
func floatFieldOrNil(env envelope.FrameEnvelope, key string) any {
value, ok := floatField(env, key)
if !ok {
func optionalInt(value *float64) any {
if value == nil {
return nil
}
return int64(*value)
}
func optionalInt64(value *int64) any {
if value == nil {
return nil
}
return *value
}
func optionalPositiveFloat(value float64, ok bool) any {
if !ok || value <= 0 {
return nil
}
return value
}
func intFieldOrNil(env envelope.FrameEnvelope, key string) any {
if env.Fields == nil {
return nil
}
value, ok := env.Fields[key]
if !ok || value == nil {
return nil
}
switch typed := value.(type) {
case int:
return typed
case int64:
return typed
case uint16:
return int64(typed)
case uint32:
return int64(typed)
case float64:
return int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed
}
}
return nil
}
func eventTimeOrReceived(env envelope.FrameEnvelope) time.Time {
if env.EventTimeMS > 0 {
return millis(env.EventTimeMS)
}
return millis(env.ReceivedAtMS)
eventMS, _ := envelope.NormalizedEventTimeMS(env)
return millis(eventMS)
}
func millis(value int64) time.Time {
@@ -543,7 +621,10 @@ func millis(value int64) time.Time {
}
func quote(value string) string {
return strings.ReplaceAll(value, "'", "''")
return strings.NewReplacer(
`\`, `\\`,
`'`, `''`,
).Replace(value)
}
func joinLiterals(values []any) string {

View File

@@ -3,6 +3,8 @@ package history
import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"sync"
"testing"
@@ -32,6 +34,18 @@ func TestSchemaStatementsCreateCoreStables(t *testing.T) {
}
}
func TestSchemaMigrationAddsTrackSOCIdempotently(t *testing.T) {
statements := strings.Join(SchemaMigrationStatements("test_ts"), "\n")
if !strings.Contains(statements, "ALTER STABLE test_ts.vehicle_locations ADD COLUMN soc_percent DOUBLE") {
t.Fatalf("SOC migration missing: %s", statements)
}
for _, message := range []string{"duplicate column name", "Duplicated column names", "column already exists"} {
if !isDuplicateTDengineColumnError(errors.New(message)) {
t.Fatalf("duplicate TDengine column error should be idempotent: %s", message)
}
}
}
func TestWriterAppendsRawAndLocationOnly(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
@@ -130,6 +144,16 @@ func TestWriterChunksOversizedParsedFields(t *testing.T) {
if got := countSQL(exec.calls, "INSERT INTO chunk_"); got < 2 {
t.Fatalf("chunk insert count = %d, calls=%v", got, exec.calls)
}
chunkInserts := matchingSQL(exec.calls, "INSERT INTO chunk_")
if len(chunkInserts) != 2 {
t.Fatalf("chunk inserts = %d, calls=%v", len(chunkInserts), exec.calls)
}
if !strings.Contains(chunkInserts[0], "VALUES (1782745114999, '") {
t.Fatalf("first chunk ts should use received_at: %s", chunkInserts[0])
}
if !strings.Contains(chunkInserts[1], "VALUES (1782745115000, '") {
t.Fatalf("second chunk ts should be offset by chunk_index ms: %s", chunkInserts[1])
}
}
func TestWriterUsesEnvelopeParsedFieldsWithoutReflattening(t *testing.T) {
@@ -185,11 +209,20 @@ func TestTimeLiteralsUseEpochMilliseconds(t *testing.T) {
}
}
func TestStringLiteralPreservesJSONEscapesForTDengine(t *testing.T) {
value := `{"bits":"{\"abs\":false}"}`
want := `'{"bits":"{\\"abs\\":false}"}'`
if got := literal(value); got != want {
t.Fatalf("literal() = %q, want %q", got, want)
}
}
func TestWriterSkipsSparseDerivedRows(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
env := sampleEnvelope()
env.Fields = map[string]any{}
env.ParsedFields = map[string]any{}
if err := writer.AppendAll(context.Background(), env); err != nil {
t.Fatalf("AppendAll() error = %v", err)
@@ -227,6 +260,86 @@ func TestWriterSkipsLocationWhenVINIsMissing(t *testing.T) {
}
}
func TestLocationStatusClassifiesDerivedLocationEligibility(t *testing.T) {
realtimeWithoutCoordinates := sampleEnvelope()
realtimeWithoutCoordinates.ParsedFields = map[string]any{
"jt808.location.speed_kmh": 30,
}
bareFieldsOnly := realtimeWithoutCoordinates
bareFieldsOnly.ParsedFields = nil
bareFieldsOnly.Fields = map[string]any{
envelope.FieldLatitude: 30.590151,
envelope.FieldLongitude: 121.069881,
}
withoutVIN := sampleEnvelope()
withoutVIN.VIN = ""
nonRealtimeWithCoordinates := sampleEnvelope()
nonRealtimeWithCoordinates.MessageID = "0x0100"
tests := []struct {
name string
env envelope.FrameEnvelope
want string
}{
{name: "ok", env: sampleEnvelope(), want: LocationStatusOK},
{name: "non realtime", env: nonRealtimeWithCoordinates, want: LocationStatusSkippedNonRealtime},
{name: "missing vin", env: withoutVIN, want: LocationStatusSkippedMissingVIN},
{name: "missing coordinates", env: realtimeWithoutCoordinates, want: LocationStatusSkippedMissingCoordinates},
{name: "bare fields are not canonical", env: bareFieldsOnly, want: LocationStatusSkippedMissingCoordinates},
}
for _, test := range tests {
if got := LocationStatus(test.env); got != test.want {
t.Fatalf("%s LocationStatus() = %q, want %q", test.name, got, test.want)
}
}
}
func TestWriterSkipsLocationForNonRealtimeFrame(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
env := sampleEnvelope()
env.MessageID = "0x0100"
env.Parsed = map[string]any{"registration": map[string]any{"plate": "沪A12345"}}
if err := writer.AppendAll(context.Background(), env); err != nil {
t.Fatalf("AppendAll() error = %v", err)
}
if got := countSQL(exec.calls, "INSERT INTO raw_"); got != 1 {
t.Fatalf("raw insert count = %d", got)
}
if got := countSQL(exec.calls, "USING vehicle_locations"); got != 0 {
t.Fatalf("location child create count = %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO loc_"); got != 0 {
t.Fatalf("location insert count = %d", got)
}
}
func TestWriterAppendAllWithResultKeepsRawSuccessWhenLocationFails(t *testing.T) {
locationErr := errors.New("location insert failed")
exec := &recordingExec{errs: []error{nil, nil, nil, nil, locationErr}}
writer := NewWriter(exec)
env := sampleEnvelope()
result, err := writer.AppendAllWithResult(context.Background(), env)
if err != nil {
t.Fatalf("AppendAllWithResult() raw error = %v", err)
}
if !errors.Is(result.LocationError, locationErr) {
t.Fatalf("location error = %v, want %v", result.LocationError, locationErr)
}
if result.RawRows != 1 || result.LocationRows != 0 {
t.Fatalf("result = %+v, want raw row retained and no location rows", result)
}
if got := countSQL(exec.calls, "INSERT INTO raw_"); got != 1 {
t.Fatalf("raw insert count = %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO loc_"); got != 1 {
t.Fatalf("location insert attempted count = %d", got)
}
}
func TestWriterAppendsBatchRowsByChildTable(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
@@ -262,6 +375,94 @@ func TestWriterAppendsBatchRowsByChildTable(t *testing.T) {
}
}
func TestWriterAppendsBatchAcrossChildTablesWithSingleMultiTableInsert(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
first := sampleEnvelope()
second := sampleEnvelope()
second.Sequence = 2
second.EventID = "second-event"
second.VIN = "LNBVIN00000000002"
second.Phone = "013307795426"
second.EventTimeMS += 1000
second.ReceivedAtMS += 1000
if err := writer.AppendAllBatch(context.Background(), []envelope.FrameEnvelope{first, second}); err != nil {
t.Fatalf("AppendAllBatch() error = %v", err)
}
if got := countSQL(exec.calls, "USING raw_frames"); got != 2 {
t.Fatalf("raw child create count = %d", got)
}
if got := countSQL(exec.calls, "USING vehicle_locations"); got != 2 {
t.Fatalf("location child create count = %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO raw_"); got != 1 {
t.Fatalf("raw multi-table insert count = %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO loc_"); got != 1 {
t.Fatalf("location multi-table insert count = %d", got)
}
rawInsert := findSQL(exec.calls, "INSERT INTO raw_")
if got := strings.Count(rawInsert, "\nVALUES "); got != 2 {
t.Fatalf("raw multi-table VALUES sections = %d, sql=%s", got, rawInsert)
}
locationInsert := findSQL(exec.calls, "INSERT INTO loc_")
if got := strings.Count(locationInsert, "\nVALUES "); got != 2 {
t.Fatalf("location multi-table VALUES sections = %d, sql=%s", got, locationInsert)
}
}
func TestWriterAppendAllBatchSkipsLocationForNonRealtimeFrames(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
first := sampleEnvelope()
first.MessageID = "0x0100"
first.Parsed = map[string]any{"registration": map[string]any{"plate": "沪A12345"}}
second := sampleEnvelope()
second.Sequence = 2
second.EventTimeMS += 1000
second.ReceivedAtMS += 1000
if err := writer.AppendAllBatch(context.Background(), []envelope.FrameEnvelope{first, second}); err != nil {
t.Fatalf("AppendAllBatch() error = %v", err)
}
if got := countSQL(exec.calls, "INSERT INTO raw_"); got != 1 {
t.Fatalf("raw batch insert count = %d", got)
}
locationInsert := findSQL(exec.calls, "INSERT INTO loc_")
if got := strings.Count(locationInsert, "),(") + 1; got != 1 {
t.Fatalf("location batch row count = %d, sql=%s", got, locationInsert)
}
}
func TestWriterNormalizesFarFutureEventTimeForLocationButKeepsRawEvidence(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
env := sampleEnvelope()
received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC)
futureEvent := time.Date(2026, 7, 13, 9, 30, 0, 0, time.UTC)
env.ReceivedAtMS = received.UnixMilli()
env.EventTimeMS = futureEvent.UnixMilli()
if err := writer.AppendAll(context.Background(), env); err != nil {
t.Fatalf("AppendAll() error = %v", err)
}
rawInsert := findSQL(exec.calls, "INSERT INTO raw_")
locationInsert := findSQL(exec.calls, "INSERT INTO loc_")
if !strings.Contains(rawInsert, strconv.FormatInt(futureEvent.UnixMilli(), 10)) {
t.Fatalf("raw insert should keep original event time %d: %s", futureEvent.UnixMilli(), rawInsert)
}
if strings.Contains(locationInsert, strconv.FormatInt(futureEvent.UnixMilli(), 10)) {
t.Fatalf("location insert should not use far future event time: %s", locationInsert)
}
if !strings.Contains(locationInsert, strconv.FormatInt(received.UnixMilli(), 10)) {
t.Fatalf("location insert should use received time %d: %s", received.UnixMilli(), locationInsert)
}
}
func TestWriterWithDatabaseQualifiesTDengineTables(t *testing.T) {
exec := &recordingExec{}
writer := NewWriterWithDatabase(exec, "vehicle_ts")
@@ -372,14 +573,14 @@ func sampleEnvelope() envelope.FrameEnvelope {
ReceivedAtMS: 1782745114999,
RawHex: "7E0200",
Parsed: map[string]any{"message": "location"},
Fields: map[string]any{
envelope.FieldLongitude: 121.069881,
envelope.FieldLatitude: 30.590151,
envelope.FieldSpeedKMH: 23.0,
envelope.FieldTotalMileageKM: 10241.2,
"direction_deg": uint16(79),
"alarm_flag": uint32(0),
"status_flag": uint32(72),
ParsedFields: map[string]any{
"jt808.location.longitude": 121.069881,
"jt808.location.latitude": 30.590151,
"jt808.location.speed_kmh": 23.0,
"jt808.location.total_mileage_km": 10241.2,
"jt808.location.direction_deg": uint16(79),
"jt808.location.alarm_flag": uint32(0),
"jt808.location.status_flag": uint32(72),
},
ParseStatus: envelope.ParseOK,
}
@@ -404,6 +605,16 @@ func findSQL(calls []execCall, pattern string) string {
return ""
}
func matchingSQL(calls []execCall, pattern string) []string {
out := []string{}
for _, call := range calls {
if strings.Contains(call.query, pattern) {
out = append(out, call.query)
}
}
return out
}
func containsSQL(calls []execCall, pattern string) bool {
return findSQL(calls, pattern) != ""
}
@@ -428,10 +639,16 @@ type execCall struct {
type recordingExec struct {
calls []execCall
errs []error
}
func (e *recordingExec) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) {
e.calls = append(e.calls, execCall{query: query, args: args})
if len(e.errs) > 0 {
err := e.errs[0]
e.errs = e.errs[1:]
return nil, err
}
return nil, nil
}

View File

@@ -0,0 +1,773 @@
package identity
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
)
const (
IdentifierTypeJT808Phone = "JT808_PHONE"
IdentifierTypePlate = "PLATE"
)
type MappingRecord struct {
File string `json:"file"`
Sheet string `json:"sheet"`
Row int `json:"row"`
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Protocol string `json:"protocol"`
IdentifierType string `json:"identifier_type"`
IdentifierValue string `json:"identifier_value"`
RawValue string `json:"raw_value,omitempty"`
Plate string `json:"plate,omitempty"`
OEM string `json:"oem,omitempty"`
}
type MappingScanReport struct {
Files int `json:"files"`
Sheets int `json:"sheets"`
Rows int `json:"rows"`
Records int `json:"records"`
Skipped int `json:"skipped"`
UnsupportedFiles int `json:"unsupported_files,omitempty"`
Sources []MappingSourceScanReport `json:"sources,omitempty"`
UnsupportedItems []MappingUnsupportedFileReport `json:"unsupported_items,omitempty"`
Errors []string `json:"errors,omitempty"`
}
type MappingSourceScanReport struct {
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Files int `json:"files"`
Sheets int `json:"sheets"`
Rows int `json:"rows"`
Records int `json:"records"`
PhoneRecords int `json:"phone_records"`
PlateRecords int `json:"plate_records"`
Skipped int `json:"skipped"`
}
type MappingUnsupportedFileReport struct {
File string `json:"file"`
Ext string `json:"ext"`
Reason string `json:"reason"`
}
type MappingImportOptions struct {
Apply bool
LegacyTable string
ReportItemLimit int
}
type MappingImportReport struct {
Scan MappingScanReport `json:"scan"`
Records int `json:"records"`
Deduplicated int `json:"deduplicated"`
Resolved int `json:"resolved"`
Unresolved int `json:"unresolved"`
Conflicts int `json:"conflicts"`
WouldInsert int `json:"would_insert,omitempty"`
WouldUpdate int `json:"would_update,omitempty"`
Inserted int `json:"inserted,omitempty"`
Updated int `json:"updated,omitempty"`
SourceResults []MappingSourceImportStat `json:"source_results,omitempty"`
UnresolvedItems []MappingRecord `json:"unresolved_items,omitempty"`
ConflictItems []MappingConflict `json:"conflict_items,omitempty"`
}
type MappingSourceImportStat struct {
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Records int `json:"records"`
Deduplicated int `json:"deduplicated"`
Resolved int `json:"resolved"`
Unresolved int `json:"unresolved"`
Conflicts int `json:"conflicts"`
WouldInsert int `json:"would_insert,omitempty"`
WouldUpdate int `json:"would_update,omitempty"`
Inserted int `json:"inserted,omitempty"`
Updated int `json:"updated,omitempty"`
}
type MappingConflict struct {
Record MappingRecord `json:"record"`
ExistingVIN string `json:"existing_vin,omitempty"`
NewVIN string `json:"new_vin,omitempty"`
Reason string `json:"reason"`
}
type resolvedMappingRecord struct {
MappingRecord
VIN string
}
type mappingStore interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
var digitPattern = regexp.MustCompile(`\D+`)
func EnsureVehicleIdentifierSchema(ctx context.Context, db *sql.DB) error {
if db == nil {
return errors.New("identity db must not be nil")
}
if _, err := db.ExecContext(ctx, vehicleTableSQL); err != nil {
return err
}
_, err := db.ExecContext(ctx, vehicleIdentifierTableSQL)
return err
}
func ReadMappingDirectory(root string) ([]MappingRecord, MappingScanReport, error) {
root = strings.TrimSpace(root)
if root == "" {
return nil, MappingScanReport{}, errors.New("mapping input directory is empty")
}
var records []MappingRecord
report := MappingScanReport{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
report.Errors = append(report.Errors, err.Error())
return nil
}
if entry == nil || entry.IsDir() {
return nil
}
name := entry.Name()
if strings.HasPrefix(name, "~$") || strings.HasPrefix(name, "._") {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if !isSupportedMappingWorkbookExt(ext) {
if isUnsupportedMappingWorkbookExt(ext) {
report.UnsupportedFiles++
report.UnsupportedItems = append(report.UnsupportedItems, MappingUnsupportedFileReport{
File: path,
Ext: ext,
Reason: "convert legacy workbook to .xlsx before import",
})
}
return nil
}
fileRecords, fileReport, err := readMappingWorkbook(root, path)
report.Files++
report.Sheets += fileReport.Sheets
report.Rows += fileReport.Rows
report.Records += fileReport.Records
report.Skipped += fileReport.Skipped
report.Errors = append(report.Errors, fileReport.Errors...)
for _, source := range fileReport.Sources {
mergeMappingSourceScan(&report, source)
}
if err != nil {
report.Errors = append(report.Errors, fmt.Sprintf("%s: %v", path, err))
return nil
}
records = append(records, fileRecords...)
return nil
})
if err != nil {
return records, report, err
}
sortMappingSourceScans(report.Sources)
return records, report, nil
}
func readMappingWorkbook(root string, path string) ([]MappingRecord, MappingScanReport, error) {
workbook, err := excelize.OpenFile(path)
if err != nil {
return nil, MappingScanReport{}, err
}
defer func() { _ = workbook.Close() }()
sourceCode, sourceName := mappingSource(root, path)
sourceReport := MappingSourceScanReport{
SourceCode: sourceCode,
SourceName: sourceName,
Files: 1,
}
var records []MappingRecord
report := MappingScanReport{}
for _, sheet := range workbook.GetSheetList() {
rows, err := workbook.GetRows(sheet)
if err != nil {
report.Errors = append(report.Errors, fmt.Sprintf("%s/%s: %v", path, sheet, err))
continue
}
report.Sheets++
sourceReport.Sheets++
report.Rows += len(rows)
sourceReport.Rows += len(rows)
if len(rows) == 0 {
continue
}
header, dataStart := mappingHeader(rows)
for rowIndex := dataStart; rowIndex < len(rows); rowIndex++ {
row := rows[rowIndex]
plate := normalizePlate(cellByHeader(row, header, "plate"))
rawPhone := cellByHeader(row, header, "phone")
phone := normalizeMappingPhone(rawPhone)
if len(header) == 0 {
plate = normalizePlate(cell(row, 0))
rawPhone = cell(row, 1)
phone = normalizeMappingPhone(rawPhone)
}
if plate == "" && phone == "" {
report.Skipped++
sourceReport.Skipped++
continue
}
if phone != "" {
records = append(records, MappingRecord{
File: path,
Sheet: sheet,
Row: rowIndex + 1,
SourceCode: sourceCode,
SourceName: sourceName,
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: phone,
RawValue: strings.TrimSpace(rawPhone),
Plate: plate,
OEM: sourceName,
})
sourceReport.PhoneRecords++
sourceReport.Records++
}
if plate != "" {
records = append(records, MappingRecord{
File: path,
Sheet: sheet,
Row: rowIndex + 1,
SourceCode: sourceCode,
SourceName: sourceName,
Protocol: "JT808",
IdentifierType: IdentifierTypePlate,
IdentifierValue: plate,
RawValue: plate,
Plate: plate,
OEM: sourceName,
})
sourceReport.PlateRecords++
sourceReport.Records++
}
}
}
report.Records = len(records)
report.Sources = []MappingSourceScanReport{sourceReport}
return records, report, nil
}
func mergeMappingSourceScan(report *MappingScanReport, source MappingSourceScanReport) {
if report == nil || strings.TrimSpace(source.SourceCode) == "" {
return
}
for index := range report.Sources {
if report.Sources[index].SourceCode != source.SourceCode {
continue
}
report.Sources[index].Files += source.Files
report.Sources[index].Sheets += source.Sheets
report.Sources[index].Rows += source.Rows
report.Sources[index].Records += source.Records
report.Sources[index].PhoneRecords += source.PhoneRecords
report.Sources[index].PlateRecords += source.PlateRecords
report.Sources[index].Skipped += source.Skipped
if report.Sources[index].SourceName == "" {
report.Sources[index].SourceName = source.SourceName
}
return
}
report.Sources = append(report.Sources, source)
}
func sortMappingSourceScans(sources []MappingSourceScanReport) {
sort.SliceStable(sources, func(i, j int) bool {
return sources[i].SourceCode < sources[j].SourceCode
})
}
func isSupportedMappingWorkbookExt(ext string) bool {
switch strings.ToLower(strings.TrimSpace(ext)) {
case ".xlsx", ".xlsm":
return true
default:
return false
}
}
func isUnsupportedMappingWorkbookExt(ext string) bool {
switch strings.ToLower(strings.TrimSpace(ext)) {
case ".xls", ".xlsb":
return true
default:
return false
}
}
func ImportMappingRecords(ctx context.Context, db *sql.DB, records []MappingRecord, scan MappingScanReport, opts MappingImportOptions) (MappingImportReport, error) {
if db == nil {
return MappingImportReport{}, errors.New("identity db must not be nil")
}
legacyTable := strings.TrimSpace(opts.LegacyTable)
if legacyTable == "" || !safeIdentifier(legacyTable) {
legacyTable = "vehicle_identity_binding"
}
report := MappingImportReport{
Scan: scan,
Records: len(records),
}
sourceStats := map[string]*MappingSourceImportStat{}
for _, record := range records {
sourceImportStat(sourceStats, record).Records++
}
reportLimit := opts.ReportItemLimit
if reportLimit == 0 {
reportLimit = 50
}
deduped, conflicts := dedupeMappingRecords(records)
report.Deduplicated = len(deduped)
report.Conflicts += len(conflicts)
for _, conflict := range conflicts {
sourceImportStat(sourceStats, conflict.Record).Conflicts++
appendConflictItem(&report, conflict, reportLimit)
}
store := mappingStore(db)
var tx *sql.Tx
if opts.Apply {
var err error
tx, err = db.BeginTx(ctx, nil)
if err != nil {
return report, err
}
store = tx
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
}
for _, record := range deduped {
sourceStat := sourceImportStat(sourceStats, record)
sourceStat.Deduplicated++
vin, err := resolveMappingVIN(ctx, store, legacyTable, record)
if err != nil {
return report, err
}
if vin == "" {
report.Unresolved++
sourceStat.Unresolved++
appendUnresolvedItem(&report, record, reportLimit)
continue
}
resolved := resolvedMappingRecord{MappingRecord: record, VIN: vin}
existingVIN, exists, err := existingIdentifierVIN(ctx, store, resolved)
if err != nil {
return report, err
}
if exists && !strings.EqualFold(existingVIN, vin) {
report.Conflicts++
sourceStat.Conflicts++
appendConflictItem(&report, MappingConflict{
Record: record,
ExistingVIN: existingVIN,
NewVIN: vin,
Reason: "identifier already points to another vin",
}, reportLimit)
continue
}
report.Resolved++
sourceStat.Resolved++
if exists {
if opts.Apply {
if err := updateVehicleIdentifier(ctx, store, resolved); err != nil {
return report, err
}
report.Updated++
sourceStat.Updated++
} else {
report.WouldUpdate++
sourceStat.WouldUpdate++
}
continue
}
if opts.Apply {
if err := upsertVehicle(ctx, store, resolved); err != nil {
return report, err
}
if err := insertVehicleIdentifier(ctx, store, resolved); err != nil {
return report, err
}
report.Inserted++
sourceStat.Inserted++
} else {
report.WouldInsert++
sourceStat.WouldInsert++
}
}
if tx != nil {
if err := tx.Commit(); err != nil {
return report, err
}
tx = nil
}
report.SourceResults = sortedMappingSourceImportStats(sourceStats)
return report, nil
}
func sourceImportStat(stats map[string]*MappingSourceImportStat, record MappingRecord) *MappingSourceImportStat {
sourceCode := strings.TrimSpace(record.SourceCode)
if sourceCode == "" {
sourceCode = "unknown"
}
stat := stats[sourceCode]
if stat != nil {
if stat.SourceName == "" {
stat.SourceName = strings.TrimSpace(record.SourceName)
}
return stat
}
stat = &MappingSourceImportStat{
SourceCode: sourceCode,
SourceName: strings.TrimSpace(record.SourceName),
}
stats[sourceCode] = stat
return stat
}
func sortedMappingSourceImportStats(stats map[string]*MappingSourceImportStat) []MappingSourceImportStat {
if len(stats) == 0 {
return nil
}
keys := make([]string, 0, len(stats))
for key := range stats {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]MappingSourceImportStat, 0, len(keys))
for _, key := range keys {
out = append(out, *stats[key])
}
return out
}
func appendUnresolvedItem(report *MappingImportReport, record MappingRecord, limit int) {
if report == nil {
return
}
if limit >= 0 && len(report.UnresolvedItems) >= limit {
return
}
report.UnresolvedItems = append(report.UnresolvedItems, record)
}
func appendConflictItem(report *MappingImportReport, conflict MappingConflict, limit int) {
if report == nil {
return
}
if limit >= 0 && len(report.ConflictItems) >= limit {
return
}
report.ConflictItems = append(report.ConflictItems, conflict)
}
func dedupeMappingRecords(records []MappingRecord) ([]MappingRecord, []MappingConflict) {
seen := map[string]MappingRecord{}
indexByKey := map[string]int{}
var out []MappingRecord
var conflicts []MappingConflict
for _, record := range records {
record.IdentifierValue = normalizeIdentifierValue(record.IdentifierType, record.IdentifierValue)
record.Plate = normalizePlate(record.Plate)
if record.Protocol == "" {
record.Protocol = "JT808"
}
if record.IdentifierValue == "" || record.IdentifierType == "" {
continue
}
key := strings.Join([]string{record.Protocol, record.SourceCode, record.IdentifierType, record.IdentifierValue}, "\x00")
existing, ok := seen[key]
if !ok {
seen[key] = record
indexByKey[key] = len(out)
out = append(out, record)
continue
}
if existing.Plate != "" && record.Plate != "" && existing.Plate != record.Plate {
conflicts = append(conflicts, MappingConflict{
Record: record,
Reason: fmt.Sprintf("same source identifier maps to multiple plates: %s/%s", existing.Plate, record.Plate),
})
continue
}
merged := mergeMappingRecord(existing, record)
seen[key] = merged
if index, ok := indexByKey[key]; ok && index >= 0 && index < len(out) {
out[index] = merged
}
}
return out, conflicts
}
func mergeMappingRecord(existing MappingRecord, incoming MappingRecord) MappingRecord {
merged := existing
if merged.File == "" {
merged.File = incoming.File
}
if merged.Sheet == "" {
merged.Sheet = incoming.Sheet
}
if merged.Row == 0 {
merged.Row = incoming.Row
}
if merged.SourceName == "" {
merged.SourceName = incoming.SourceName
}
if merged.Protocol == "" {
merged.Protocol = incoming.Protocol
}
if merged.IdentifierType == "" {
merged.IdentifierType = incoming.IdentifierType
}
if merged.IdentifierValue == "" {
merged.IdentifierValue = incoming.IdentifierValue
}
if merged.RawValue == "" {
merged.RawValue = incoming.RawValue
}
if merged.Plate == "" {
merged.Plate = incoming.Plate
}
if merged.OEM == "" {
merged.OEM = incoming.OEM
}
return merged
}
func resolveMappingVIN(ctx context.Context, db mappingStore, legacyTable string, record MappingRecord) (string, error) {
if record.Plate != "" {
vin, err := lookupLegacyVIN(ctx, db, legacyTable, "plate", record.Plate)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
if vin != "" {
return vin, nil
}
}
if record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue != "" {
vin, err := lookupLegacyVIN(ctx, db, legacyTable, "phone", record.IdentifierValue)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
return vin, nil
}
return "", nil
}
func lookupLegacyVIN(ctx context.Context, db mappingStore, table string, column string, value string) (string, error) {
if !safeIdentifier(table) || !safeIdentifier(column) {
return "", sql.ErrNoRows
}
query := "SELECT vin FROM " + table + " WHERE " + column + " = ? AND vin IS NOT NULL AND vin <> '' LIMIT 1"
var vin string
err := db.QueryRowContext(ctx, query, value).Scan(&vin)
if err != nil {
return "", err
}
return strings.TrimSpace(vin), nil
}
func existingIdentifierVIN(ctx context.Context, db mappingStore, record resolvedMappingRecord) (string, bool, error) {
var vin string
err := db.QueryRowContext(ctx, `SELECT vin FROM vehicle_identifier
WHERE protocol = ? AND source_code = ? AND identifier_type = ? AND identifier_value = ?`,
record.Protocol, record.SourceCode, record.IdentifierType, record.IdentifierValue).Scan(&vin)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return strings.TrimSpace(vin), true, nil
}
func upsertVehicle(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `INSERT INTO vehicle (vin, plate, oem, enabled)
VALUES (?, ?, ?, 1)
ON DUPLICATE KEY UPDATE
plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
oem = IF(VALUES(oem) <> '', VALUES(oem), oem),
enabled = 1`,
record.VIN, record.Plate, record.OEM)
return err
}
func insertVehicleIdentifier(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `INSERT INTO vehicle_identifier
(protocol, source_code, identifier_type, identifier_value, vin, plate, oem, raw_value, enabled, latest_import_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
record.Protocol,
record.SourceCode,
record.IdentifierType,
record.IdentifierValue,
record.VIN,
record.Plate,
record.OEM,
record.RawValue,
record.File,
)
return err
}
func updateVehicleIdentifier(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `UPDATE vehicle_identifier
SET plate = IF(? <> '', ?, plate),
oem = IF(? <> '', ?, oem),
raw_value = IF(? <> '', ?, raw_value),
latest_import_file = ?,
enabled = 1
WHERE protocol = ? AND source_code = ? AND identifier_type = ? AND identifier_value = ?`,
record.Plate, record.Plate,
record.OEM, record.OEM,
record.RawValue, record.RawValue,
record.File,
record.Protocol,
record.SourceCode,
record.IdentifierType,
record.IdentifierValue,
)
return err
}
func mappingSource(root string, path string) (string, string) {
rel, err := filepath.Rel(root, path)
if err != nil {
rel = filepath.Base(path)
}
parts := strings.Split(filepath.ToSlash(rel), "/")
name := strings.TrimSpace(parts[0])
if name == "" || strings.EqualFold(name, ".") {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
code := sourceCode(name)
return code, name
}
func sourceCode(name string) string {
switch strings.TrimSpace(strings.ToLower(name)) {
case "g7s":
return "g7s"
case "信达":
return "xinda"
case "广安北斗", "广安车联":
return "guangan_beidou"
case "东方北斗":
return "dongfang_beidou"
case "赛格":
return "saige"
default:
return normalizeASCIIKey(name)
}
}
func mappingHeader(rows [][]string) (map[string]int, int) {
for index, row := range rows {
header := map[string]int{}
for columnIndex, value := range row {
key := normalizeHeader(value)
switch key {
case "车牌", "车牌号", "车牌号码":
header["plate"] = columnIndex
case "sim", "sim卡号", "手机号", "终端手机号", "终端id", "终端标识":
header["phone"] = columnIndex
}
}
if len(header) > 0 {
return header, index + 1
}
if index >= 5 {
break
}
}
return nil, 0
}
func cellByHeader(row []string, header map[string]int, key string) string {
if len(header) == 0 {
return ""
}
index, ok := header[key]
if !ok {
return ""
}
return cell(row, index)
}
func cell(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return strings.TrimSpace(row[index])
}
func normalizeHeader(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "\t", "")
value = strings.ReplaceAll(value, "", "(")
value = strings.ReplaceAll(value, "", ")")
return value
}
func normalizePlate(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "\t", "")
return value
}
func normalizeMappingPhone(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strings.ContainsAny(value, ".eE") {
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
return normalizePhone(strconv.FormatFloat(parsed, 'f', 0, 64))
}
}
digits := digitPattern.ReplaceAllString(value, "")
return normalizePhone(digits)
}
func normalizeASCIIKey(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
var b strings.Builder
lastUnderscore := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
b.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}

View File

@@ -0,0 +1,345 @@
package identity
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/xuri/excelize/v2"
)
func TestReadMappingDirectoryExtractsPhoneAndPlate(t *testing.T) {
dir := t.TempDir()
writeWorkbook(t, filepath.Join(dir, "G7s", "宇速全量.xlsx"), [][]string{
{"车牌号", "sim卡号", "设备号"},
{"粤AG18312", "013307795425", "DEV001"},
})
writeWorkbook(t, filepath.Join(dir, "东方北斗", "无标题0703.xlsx"), [][]string{
{"沪A01559F", "64341233712"},
})
if err := os.MkdirAll(filepath.Join(dir, "信达"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "信达", "旧格式.xls"), []byte("legacy xls"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "信达", "说明.txt"), []byte("ignored"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
records, report, err := ReadMappingDirectory(dir)
if err != nil {
t.Fatalf("ReadMappingDirectory() error = %v", err)
}
if report.Files != 2 {
t.Fatalf("files = %d, report=%#v", report.Files, report)
}
if report.UnsupportedFiles != 1 || len(report.UnsupportedItems) != 1 {
t.Fatalf("unsupported files = %d, items=%#v", report.UnsupportedFiles, report.UnsupportedItems)
}
if got := report.UnsupportedItems[0]; got.Ext != ".xls" || got.Reason == "" {
t.Fatalf("unsupported item = %#v", got)
}
sources := map[string]MappingSourceScanReport{}
for _, source := range report.Sources {
sources[source.SourceCode] = source
}
if got := sources["g7s"]; got.Files != 1 || got.Sheets != 1 || got.PhoneRecords != 1 || got.PlateRecords != 1 || got.Records != 2 {
t.Fatalf("g7s source report = %#v", got)
}
if got := sources["dongfang_beidou"]; got.Files != 1 || got.Sheets != 1 || got.PhoneRecords != 1 || got.PlateRecords != 1 || got.Records != 2 {
t.Fatalf("dongfang source report = %#v", got)
}
var phoneSeen bool
var plateSeen bool
var headerlessSeen bool
for _, record := range records {
if record.SourceCode == "g7s" && record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue == "13307795425" && record.Plate == "粤AG18312" {
phoneSeen = true
}
if record.SourceCode == "g7s" && record.IdentifierType == IdentifierTypePlate && record.IdentifierValue == "粤AG18312" {
plateSeen = true
}
if record.SourceCode == "dongfang_beidou" && record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue == "64341233712" && record.Plate == "沪A01559F" {
headerlessSeen = true
}
}
if !phoneSeen || !plateSeen || !headerlessSeen {
t.Fatalf("records missing expected mappings: %#v", records)
}
}
func TestImportMappingRecordsDryRunResolvesVINFromLegacyPlate(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "013307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Resolved != 1 || report.WouldInsert != 1 || report.Unresolved != 0 {
t.Fatalf("report = %#v", report)
}
if len(report.SourceResults) != 1 {
t.Fatalf("source results = %#v, want one source", report.SourceResults)
}
if got := report.SourceResults[0]; got.SourceCode != "g7s" || got.Records != 1 || got.Deduplicated != 1 || got.Resolved != 1 || got.WouldInsert != 1 {
t.Fatalf("source result = %#v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsReportsSourceResults(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤B00000").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("14400000000").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤B99999",
OEM: "G7s",
},
{
SourceCode: "xinda",
SourceName: "信达",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "14400000000",
Plate: "粤B00000",
OEM: "信达",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Records != 3 || report.Deduplicated != 2 || report.Resolved != 1 || report.Unresolved != 1 || report.Conflicts != 1 || report.WouldInsert != 1 {
t.Fatalf("report = %#v", report)
}
got := map[string]MappingSourceImportStat{}
for _, source := range report.SourceResults {
got[source.SourceCode] = source
}
if source := got["g7s"]; source.Records != 2 || source.Deduplicated != 1 || source.Resolved != 1 || source.Conflicts != 1 || source.WouldInsert != 1 {
t.Fatalf("g7s source result = %#v", source)
}
if source := got["xinda"]; source.Records != 1 || source.Deduplicated != 1 || source.Unresolved != 1 {
t.Fatalf("xinda source result = %#v", source)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsMergesDuplicateIdentifierDetails(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
File: "G7s/no-plate.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
OEM: "G7s",
},
{
File: "G7s/with-plate.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Records != 2 || report.Deduplicated != 1 || report.Resolved != 1 || report.WouldInsert != 1 || report.Unresolved != 0 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsApplyCommitsSingleTransaction(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectBegin()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectExec("INSERT INTO vehicle").
WithArgs("LB9A32A22P0LS1230", "粤AG18312", "G7s").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425", "LB9A32A22P0LS1230", "粤AG18312", "G7s", "13307795425", "G7s/example.xlsx").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
File: "G7s/example.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
RawValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{
Apply: true,
LegacyTable: "vehicle_identity_binding",
})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Inserted != 1 || report.Resolved != 1 || report.WouldInsert != 0 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsApplyRollsBackOnWriteError(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
errWrite := errors.New("insert vehicle failed")
mock.ExpectBegin()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectExec("INSERT INTO vehicle").
WithArgs("LB9A32A22P0LS1230", "粤AG18312", "G7s").
WillReturnError(errWrite)
mock.ExpectRollback()
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
RawValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{
Apply: true,
LegacyTable: "vehicle_identity_binding",
})
if !errors.Is(err, errWrite) {
t.Fatalf("ImportMappingRecords() error = %v, want %v", err, errWrite)
}
if report.Inserted != 0 || report.Resolved != 1 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestNormalizeMappingPhoneHandlesExcelNumericFormats(t *testing.T) {
tests := map[string]string{
"013307795425": "13307795425",
"13307795425.0": "13307795425",
"1.3307795425E10": "13307795425",
}
for input, want := range tests {
if got := normalizeMappingPhone(input); got != want {
t.Fatalf("normalizeMappingPhone(%q) = %q, want %q", input, got, want)
}
}
}
func writeWorkbook(t *testing.T, path string, rows [][]string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
workbook := excelize.NewFile()
sheet := "Sheet1"
for rowIndex, row := range rows {
for columnIndex, value := range row {
cellName, err := excelize.CoordinatesToCellName(columnIndex+1, rowIndex+1)
if err != nil {
t.Fatalf("CoordinatesToCellName() error = %v", err)
}
if err := workbook.SetCellValue(sheet, cellName, value); err != nil {
t.Fatalf("SetCellValue() error = %v", err)
}
}
}
if err := workbook.SaveAs(path); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
if err := workbook.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}

View File

@@ -0,0 +1,357 @@
package identity
import (
"context"
"database/sql"
"fmt"
"strings"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
const (
JT808RegisterMessageID = "0x0100"
JT808AuthMessageID = "0x0102"
JT808LocationMessageID = "0x0200"
)
// JT808RegistrationFact is the durable identity projection carried by a raw
// JT808 envelope. SeenAt uses gateway receive time so replay cannot move a
// terminal to a future date because its device clock was wrong.
type JT808RegistrationFact struct {
Phone string
DeviceID string
Plate string
VIN string
Province string
City string
Manufacturer string
DeviceType string
PlateColor string
AuthToken string
AuthIMEI string
AuthSoftwareVersion string
SourceEndpoint string
SourceIP string
FirstRegisteredAt *time.Time
LatestRegisteredAt *time.Time
LatestAuthenticated *time.Time
SeenAt time.Time
}
// JT808RegistrationProjector throttles ordinary location touches in memory.
// Registration and authentication frames are never throttled.
type JT808RegistrationProjector struct {
location *time.Location
touchInterval time.Duration
retention time.Duration
mu sync.Mutex
lastTouches map[string]time.Time
nextCleanup time.Time
}
func NewJT808RegistrationProjector(location *time.Location, touchInterval time.Duration) *JT808RegistrationProjector {
if location == nil {
location = time.Local
}
if touchInterval <= 0 {
touchInterval = 10 * time.Minute
}
return &JT808RegistrationProjector{
location: location,
touchInterval: touchInterval,
retention: 24 * time.Hour,
lastTouches: map[string]time.Time{},
}
}
// ProjectBatch returns at most one merged fact per phone. Call MarkPersisted
// only after the database transaction succeeds; otherwise replay must remain
// eligible immediately.
func (p *JT808RegistrationProjector) ProjectBatch(envelopes []envelope.FrameEnvelope) []JT808RegistrationFact {
if p == nil || len(envelopes) == 0 {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
byPhone := make(map[string]JT808RegistrationFact)
order := make([]string, 0, len(envelopes))
for _, env := range envelopes {
fact, ok := p.projectLocked(env)
if !ok {
continue
}
if current, exists := byPhone[fact.Phone]; exists {
byPhone[fact.Phone] = mergeJT808RegistrationFact(current, fact)
continue
}
byPhone[fact.Phone] = fact
order = append(order, fact.Phone)
}
result := make([]JT808RegistrationFact, 0, len(order))
for _, phone := range order {
result = append(result, byPhone[phone])
}
return result
}
func (p *JT808RegistrationProjector) MarkPersisted(facts []JT808RegistrationFact) {
if p == nil || len(facts) == 0 {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for _, fact := range facts {
phone := normalizePhone(fact.Phone)
if phone == "" || fact.SeenAt.IsZero() {
continue
}
if current := p.lastTouches[phone]; fact.SeenAt.After(current) {
p.lastTouches[phone] = fact.SeenAt
}
}
p.cleanupLocked(time.Now())
}
func (p *JT808RegistrationProjector) projectLocked(env envelope.FrameEnvelope) (JT808RegistrationFact, bool) {
if env.Protocol != envelope.ProtocolJT808 || env.ParseStatus == envelope.ParseBadFrame {
return JT808RegistrationFact{}, false
}
messageID := strings.TrimSpace(env.MessageID)
if messageID != JT808RegisterMessageID && messageID != JT808AuthMessageID && messageID != JT808LocationMessageID {
return JT808RegistrationFact{}, false
}
phone := normalizePhone(env.Phone)
if phone == "" {
return JT808RegistrationFact{}, false
}
seenAt := p.receivedAt(env)
if seenAt.IsZero() {
return JT808RegistrationFact{}, false
}
if messageID == JT808LocationMessageID {
if last := p.lastTouches[phone]; !last.IsZero() && seenAt.Before(last.Add(p.touchInterval)) {
return JT808RegistrationFact{}, false
}
}
registration := mapValue(env.Parsed, "registration")
authentication := mapValue(env.Parsed, "authentication")
authenticationAccepted := messageID != JT808AuthMessageID ||
!env.AuthenticationEnforced || env.AuthenticationStatus == "accepted"
vin := strings.TrimSpace(env.VIN)
if vin == "" {
vin = "unknown"
}
fact := JT808RegistrationFact{
Phone: phone,
DeviceID: firstNonEmpty(env.DeviceID, textValue(registration, "device_id"), parsedFieldText(env.ParsedFields, "jt808.registration.device_id")),
Plate: firstNonEmpty(env.Plate, textValue(registration, "plate"), parsedFieldText(env.ParsedFields, "jt808.registration.plate")),
VIN: vin,
Province: firstNonEmpty(textValue(registration, "province"), parsedFieldText(env.ParsedFields, "jt808.registration.province")),
City: firstNonEmpty(textValue(registration, "city"), parsedFieldText(env.ParsedFields, "jt808.registration.city")),
Manufacturer: firstNonEmpty(textValue(registration, "manufacturer"), parsedFieldText(env.ParsedFields, "jt808.registration.manufacturer")),
DeviceType: firstNonEmpty(textValue(registration, "device_type"), parsedFieldText(env.ParsedFields, "jt808.registration.device_type")),
PlateColor: firstNonEmpty(textValue(registration, "plate_color"), parsedFieldText(env.ParsedFields, "jt808.registration.plate_color")),
AuthToken: firstNonEmpty(textValue(authentication, "token"), parsedFieldText(env.ParsedFields, "jt808.authentication.token")),
AuthIMEI: firstNonEmpty(textValue(authentication, "imei"), parsedFieldText(env.ParsedFields, "jt808.authentication.imei")),
AuthSoftwareVersion: firstNonEmpty(textValue(authentication, "software_version"), parsedFieldText(env.ParsedFields, "jt808.authentication.software_version")),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
SourceIP: normalizeEndpointIP(env.SourceEndpoint),
SeenAt: seenAt,
}
if !authenticationAccepted {
fact.AuthToken = ""
fact.AuthIMEI = ""
fact.AuthSoftwareVersion = ""
}
if messageID == JT808RegisterMessageID {
fact.FirstRegisteredAt = timePointer(seenAt)
fact.LatestRegisteredAt = timePointer(seenAt)
}
if messageID == JT808AuthMessageID && authenticationAccepted {
fact.LatestAuthenticated = timePointer(seenAt)
}
return fact, true
}
func parsedFieldText(fields map[string]any, key string) string {
value, ok := fields[key]
if !ok || value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "<nil>" {
return ""
}
return text
}
func (p *JT808RegistrationProjector) receivedAt(env envelope.FrameEnvelope) time.Time {
milliseconds := env.ReceivedAtMS
if milliseconds <= 0 {
milliseconds = env.EventTimeMS
}
if milliseconds <= 0 {
return time.Time{}
}
return time.UnixMilli(milliseconds).In(p.location).Truncate(time.Second)
}
func (p *JT808RegistrationProjector) cleanupLocked(now time.Time) {
if !p.nextCleanup.IsZero() && now.Before(p.nextCleanup) {
return
}
p.nextCleanup = now.Add(time.Hour)
cutoff := now.Add(-p.retention)
for phone, touchedAt := range p.lastTouches {
if touchedAt.Before(cutoff) {
delete(p.lastTouches, phone)
}
}
}
func mergeJT808RegistrationFact(current JT808RegistrationFact, candidate JT808RegistrationFact) JT808RegistrationFact {
if candidate.SeenAt.After(current.SeenAt) || candidate.SeenAt.Equal(current.SeenAt) {
current.DeviceID = firstNonEmpty(candidate.DeviceID, current.DeviceID)
current.Plate = firstNonEmpty(candidate.Plate, current.Plate)
current.VIN = preferKnownVIN(candidate.VIN, current.VIN)
current.Province = firstNonEmpty(candidate.Province, current.Province)
current.City = firstNonEmpty(candidate.City, current.City)
current.Manufacturer = firstNonEmpty(candidate.Manufacturer, current.Manufacturer)
current.DeviceType = firstNonEmpty(candidate.DeviceType, current.DeviceType)
current.PlateColor = firstNonEmpty(candidate.PlateColor, current.PlateColor)
current.AuthToken = firstNonEmpty(candidate.AuthToken, current.AuthToken)
current.AuthIMEI = firstNonEmpty(candidate.AuthIMEI, current.AuthIMEI)
current.AuthSoftwareVersion = firstNonEmpty(candidate.AuthSoftwareVersion, current.AuthSoftwareVersion)
current.SourceEndpoint = firstNonEmpty(candidate.SourceEndpoint, current.SourceEndpoint)
current.SourceIP = firstNonEmpty(candidate.SourceIP, current.SourceIP)
current.SeenAt = candidate.SeenAt
} else {
current.DeviceID = firstNonEmpty(current.DeviceID, candidate.DeviceID)
current.Plate = firstNonEmpty(current.Plate, candidate.Plate)
current.VIN = preferKnownVIN(current.VIN, candidate.VIN)
current.Province = firstNonEmpty(current.Province, candidate.Province)
current.City = firstNonEmpty(current.City, candidate.City)
current.Manufacturer = firstNonEmpty(current.Manufacturer, candidate.Manufacturer)
current.DeviceType = firstNonEmpty(current.DeviceType, candidate.DeviceType)
current.PlateColor = firstNonEmpty(current.PlateColor, candidate.PlateColor)
current.AuthToken = firstNonEmpty(current.AuthToken, candidate.AuthToken)
current.AuthIMEI = firstNonEmpty(current.AuthIMEI, candidate.AuthIMEI)
current.AuthSoftwareVersion = firstNonEmpty(current.AuthSoftwareVersion, candidate.AuthSoftwareVersion)
}
current.FirstRegisteredAt = earlierTimePointer(current.FirstRegisteredAt, candidate.FirstRegisteredAt)
current.LatestRegisteredAt = laterTimePointer(current.LatestRegisteredAt, candidate.LatestRegisteredAt)
current.LatestAuthenticated = laterTimePointer(current.LatestAuthenticated, candidate.LatestAuthenticated)
return current
}
func timePointer(value time.Time) *time.Time {
copy := value
return &copy
}
func earlierTimePointer(left *time.Time, right *time.Time) *time.Time {
if left == nil {
return right
}
if right == nil || left.Before(*right) || left.Equal(*right) {
return left
}
return right
}
func laterTimePointer(left *time.Time, right *time.Time) *time.Time {
if left == nil {
return right
}
if right == nil || left.After(*right) || left.Equal(*right) {
return left
}
return right
}
type JT808RegistrationStore struct {
db *sql.DB
}
func NewJT808RegistrationStore(db *sql.DB) *JT808RegistrationStore {
if db == nil {
panic("jt808 registration db must not be nil")
}
return &JT808RegistrationStore{db: db}
}
func EnsureJT808RegistrationSchema(ctx context.Context, db *sql.DB) error {
if db == nil {
return fmt.Errorf("jt808 registration db is nil")
}
if _, err := db.ExecContext(ctx, jt808RegistrationTableSQL); err != nil {
return err
}
for _, statement := range jt808RegistrationAlterSQL {
if _, err := db.ExecContext(ctx, statement); err != nil && !isIgnoredJT808RegistrationAlterError(err) {
return err
}
}
_, err := db.ExecContext(ctx, jt808RegistrationSourceIPBackfillSQL)
return err
}
func (s *JT808RegistrationStore) UpsertBatch(ctx context.Context, facts []JT808RegistrationFact) error {
if s == nil || s.db == nil || len(facts) == 0 {
return nil
}
const columns = `phone, device_id, plate, vin, province, city, manufacturer, device_type, plate_color,
auth_token, auth_imei, auth_software_version, source_endpoint, source_ip,
first_registered_at, latest_registered_at, latest_authenticated_at, latest_seen_at`
values := make([]string, 0, len(facts))
args := make([]any, 0, len(facts)*18)
for _, fact := range facts {
phone := normalizePhone(fact.Phone)
if phone == "" || fact.SeenAt.IsZero() {
continue
}
vin := strings.TrimSpace(fact.VIN)
if vin == "" {
vin = "unknown"
}
values = append(values, "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
args = append(args,
phone, strings.TrimSpace(fact.DeviceID), strings.TrimSpace(fact.Plate), vin,
strings.TrimSpace(fact.Province), strings.TrimSpace(fact.City), strings.TrimSpace(fact.Manufacturer),
strings.TrimSpace(fact.DeviceType), strings.TrimSpace(fact.PlateColor), strings.TrimSpace(fact.AuthToken),
strings.TrimSpace(fact.AuthIMEI), strings.TrimSpace(fact.AuthSoftwareVersion),
strings.TrimSpace(fact.SourceEndpoint), strings.TrimSpace(fact.SourceIP),
fact.FirstRegisteredAt, fact.LatestRegisteredAt, fact.LatestAuthenticated, fact.SeenAt,
)
}
if len(values) == 0 {
return nil
}
query := `INSERT INTO jt808_registration (` + columns + `) VALUES ` + strings.Join(values, ",") + `
ON DUPLICATE KEY UPDATE
device_id = IF(VALUES(device_id) <> '' AND (device_id IS NULL OR device_id = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(device_id), device_id),
plate = IF(VALUES(plate) <> '' AND (plate IS NULL OR plate = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(plate), plate),
vin = IF(VALUES(vin) <> '' AND VALUES(vin) <> 'unknown' AND (vin IS NULL OR vin = '' OR vin = 'unknown' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(vin), vin),
province = IF(VALUES(province) <> '' AND (province IS NULL OR province = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(province), province),
city = IF(VALUES(city) <> '' AND (city IS NULL OR city = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(city), city),
manufacturer = IF(VALUES(manufacturer) <> '' AND (manufacturer IS NULL OR manufacturer = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(manufacturer), manufacturer),
device_type = IF(VALUES(device_type) <> '' AND (device_type IS NULL OR device_type = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(device_type), device_type),
plate_color = IF(VALUES(plate_color) <> '' AND (plate_color IS NULL OR plate_color = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(plate_color), plate_color),
auth_token = IF(VALUES(auth_token) <> '' AND (auth_token IS NULL OR auth_token = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_token), auth_token),
auth_imei = IF(VALUES(auth_imei) <> '' AND (auth_imei IS NULL OR auth_imei = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_imei), auth_imei),
auth_software_version = IF(VALUES(auth_software_version) <> '' AND (auth_software_version IS NULL OR auth_software_version = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_software_version), auth_software_version),
source_endpoint = IF(VALUES(source_endpoint) <> '' AND (source_endpoint IS NULL OR source_endpoint = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(source_endpoint), source_endpoint),
source_ip = IF(VALUES(source_ip) <> '' AND (source_ip IS NULL OR source_ip = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(source_ip), source_ip),
first_registered_at = CASE WHEN VALUES(first_registered_at) IS NULL THEN first_registered_at WHEN first_registered_at IS NULL THEN VALUES(first_registered_at) ELSE LEAST(first_registered_at, VALUES(first_registered_at)) END,
latest_registered_at = CASE WHEN VALUES(latest_registered_at) IS NULL THEN latest_registered_at WHEN latest_registered_at IS NULL THEN VALUES(latest_registered_at) ELSE GREATEST(latest_registered_at, VALUES(latest_registered_at)) END,
latest_authenticated_at = CASE WHEN VALUES(latest_authenticated_at) IS NULL THEN latest_authenticated_at WHEN latest_authenticated_at IS NULL THEN VALUES(latest_authenticated_at) ELSE GREATEST(latest_authenticated_at, VALUES(latest_authenticated_at)) END,
latest_seen_at = GREATEST(latest_seen_at, VALUES(latest_seen_at))`
_, err := s.db.ExecContext(ctx, query, args...)
return err
}

View File

@@ -0,0 +1,221 @@
package identity
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestJT808RegistrationProjectorProjectsRegistrationUsingReceiveTime(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*60*60)
projector := NewJT808RegistrationProjector(loc, 10*time.Minute)
receivedAt := time.Date(2026, 7, 13, 17, 20, 30, 987000000, loc)
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "0013307795425",
VIN: "LTESTVIN000000001",
DeviceID: "DEV-1",
Plate: "粤A00001",
SourceEndpoint: "115.231.168.135:43625",
ReceivedAtMS: receivedAt.UnixMilli(),
EventTimeMS: receivedAt.Add(24 * time.Hour).UnixMilli(),
ParseStatus: envelope.ParseOK,
ParsedFields: map[string]any{
"jt808.registration.province": "44",
"jt808.registration.city": "1",
"jt808.registration.manufacturer": "YUTNG",
"jt808.registration.device_type": "TBOX-1",
"jt808.registration.plate_color": "2",
},
}
facts := projector.ProjectBatch([]envelope.FrameEnvelope{env})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1", len(facts))
}
fact := facts[0]
if fact.Phone != "13307795425" || fact.VIN != env.VIN || fact.Plate != env.Plate {
t.Fatalf("identity fact = %+v", fact)
}
wantTime := receivedAt.Truncate(time.Second)
if !fact.SeenAt.Equal(wantTime) || fact.FirstRegisteredAt == nil || !fact.FirstRegisteredAt.Equal(wantTime) {
t.Fatalf("fact times = seen %s first %#v, want %s", fact.SeenAt, fact.FirstRegisteredAt, wantTime)
}
if fact.SourceIP != "115.231.168.135" || fact.Manufacturer != "YUTNG" || fact.PlateColor != "2" {
t.Fatalf("registration details = %+v", fact)
}
}
func TestJT808RegistrationProjectorThrottlesLocationOnlyAfterPersist(t *testing.T) {
loc := time.UTC
projector := NewJT808RegistrationProjector(loc, 10*time.Minute)
base := time.Date(2026, 7, 13, 8, 0, 0, 0, loc)
location := func(at time.Time) envelope.FrameEnvelope {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808LocationMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
ReceivedAtMS: at.UnixMilli(),
ParseStatus: envelope.ParseOK,
}
}
first := projector.ProjectBatch([]envelope.FrameEnvelope{location(base)})
if len(first) != 1 {
t.Fatalf("first facts = %d, want 1", len(first))
}
// A failed database attempt must remain immediately replayable.
if replay := projector.ProjectBatch([]envelope.FrameEnvelope{location(base)}); len(replay) != 1 {
t.Fatalf("uncommitted replay facts = %d, want 1", len(replay))
}
projector.MarkPersisted(first)
if throttled := projector.ProjectBatch([]envelope.FrameEnvelope{location(base.Add(9 * time.Minute))}); len(throttled) != 0 {
t.Fatalf("throttled facts = %d, want 0", len(throttled))
}
if due := projector.ProjectBatch([]envelope.FrameEnvelope{location(base.Add(10 * time.Minute))}); len(due) != 1 {
t.Fatalf("due facts = %d, want 1", len(due))
}
}
func TestJT808RegistrationProjectorMergesRegisterAndAuthForPhone(t *testing.T) {
projector := NewJT808RegistrationProjector(time.UTC, 10*time.Minute)
base := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
register := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
Plate: "粤A00001",
ReceivedAtMS: base.UnixMilli(),
ParseStatus: envelope.ParseOK,
}
auth := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808AuthMessageID,
Phone: "13307795425",
ReceivedAtMS: base.Add(time.Second).UnixMilli(),
ParseStatus: envelope.ParseOK,
ParsedFields: map[string]any{
"jt808.authentication.token": "g7gps",
"jt808.authentication.imei": "123456789012345",
},
}
facts := projector.ProjectBatch([]envelope.FrameEnvelope{register, auth})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1", len(facts))
}
fact := facts[0]
if fact.VIN != register.VIN || fact.Plate != register.Plate || fact.AuthToken != "g7gps" {
t.Fatalf("merged fact = %+v", fact)
}
if fact.FirstRegisteredAt == nil || fact.LatestRegisteredAt == nil || fact.LatestAuthenticated == nil {
t.Fatalf("merged timestamps missing: %+v", fact)
}
}
func TestJT808RegistrationProjectorDoesNotTrustEnforcedRejectedAuth(t *testing.T) {
projector := NewJT808RegistrationProjector(time.UTC, 10*time.Minute)
seenAt := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
facts := projector.ProjectBatch([]envelope.FrameEnvelope{{
Protocol: envelope.ProtocolJT808,
MessageID: JT808AuthMessageID,
Phone: "13307795425",
ReceivedAtMS: seenAt.UnixMilli(),
ParseStatus: envelope.ParseOK,
AuthenticationEnforced: true,
AuthenticationStatus: "rejected",
Parsed: map[string]any{
"authentication": map[string]any{
"token": "untrusted-token",
"imei": "untrusted-imei",
"software_version": "untrusted-version",
},
},
}})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1 audit touch", len(facts))
}
fact := facts[0]
if fact.AuthToken != "" || fact.AuthIMEI != "" || fact.AuthSoftwareVersion != "" || fact.LatestAuthenticated != nil {
t.Fatalf("rejected credential leaked into registration fact: %+v", fact)
}
}
func TestJT808RegistrationStoreUsesIdempotentEventTimeUpsert(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewJT808RegistrationStore(db)
seenAt := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO jt808_registration")).
WithArgs(
"13307795425", "DEV-1", "粤A00001", "LTESTVIN000000001", "", "", "YUTNG", "", "",
"g7gps", "", "", "115.231.168.135:43625", "115.231.168.135",
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), seenAt,
).
WillReturnResult(sqlmock.NewResult(1, 1))
err = store.UpsertBatch(context.Background(), []JT808RegistrationFact{{
Phone: "013307795425",
DeviceID: "DEV-1",
Plate: "粤A00001",
VIN: "LTESTVIN000000001",
Manufacturer: "YUTNG",
AuthToken: "g7gps",
SourceEndpoint: "115.231.168.135:43625",
SourceIP: "115.231.168.135",
FirstRegisteredAt: timePointer(seenAt),
LatestRegisteredAt: timePointer(seenAt),
LatestAuthenticated: timePointer(seenAt),
SeenAt: seenAt,
}})
if err != nil {
t.Fatalf("UpsertBatch() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRegistrationUpsertProtectsLatestValuesFromOldReplay(t *testing.T) {
var query string
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(_ string, actual string) error {
query = actual
return nil
})))
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewJT808RegistrationStore(db)
mock.ExpectExec("INSERT INTO jt808_registration").WillReturnResult(sqlmock.NewResult(1, 1))
if err := store.UpsertBatch(context.Background(), []JT808RegistrationFact{{
Phone: "13307795425",
VIN: "unknown",
SeenAt: time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC),
}}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
for _, required := range []string{
"VALUES(latest_seen_at) >= latest_seen_at",
"LEAST(first_registered_at, VALUES(first_registered_at))",
"GREATEST(latest_authenticated_at, VALUES(latest_authenticated_at))",
"latest_seen_at = GREATEST(latest_seen_at, VALUES(latest_seen_at))",
} {
if !regexp.MustCompile(regexp.QuoteMeta(required)).MatchString(query) {
t.Fatalf("upsert SQL missing %q", required)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"strings"
"sync"
"testing"
"time"
@@ -57,9 +58,7 @@ func TestCandidateKeysNormalizesPhone(t *testing.T) {
func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNBVIN00000000001"))
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
@@ -74,7 +73,7 @@ func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
t.Fatalf("vin = %q", env.VIN)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "phone" {
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
@@ -82,9 +81,254 @@ func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
}
}
func TestMySQLResolverUsesDataSourceCodeForJT808IdentifierLookup(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeHit(mock, "115.231.168.135", "xinda")
expectVehicleIdentifierHitForSource(mock, "xinda", "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "xinda" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "xinda" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverVehicleIdentifierSourceOverridesDataSourceCode(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", "115.159.85.149").
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("dongfang_beidou", "G7易流", "PLATFORM"))
expectVehicleIdentifierMissForSource(mock, "dongfang_beidou", "JT808_PHONE", "64341232682")
expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "64341232682", "LB9A32A23R0LS1045", "g7s", "G7s")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "064341232682",
SourceEndpoint: "115.159.85.149:42823",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LB9A32A23R0LS1045" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "g7s" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.g7s" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "g7s" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesStaleIdentifierCacheWhenLookupFails(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Nanosecond,
StaleLookupTTL: time.Hour,
})
first, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("first Resolve() error = %v", err)
}
if first.VIN != "LNBVIN00000000001" {
t.Fatalf("first vin = %q", first.VIN)
}
time.Sleep(time.Millisecond)
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", "JT808_PHONE", "13307795425").
WillReturnError(errors.New("mysql temporarily unavailable"))
second, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("second Resolve() error = %v", err)
}
if second.VIN != "LNBVIN00000000001" {
t.Fatalf("second vin = %q, want stale cached vin", second.VIN)
}
identity, ok := second.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", second.Parsed["identity"])
}
if identity["cache_status"] != "stale" {
t.Fatalf("identity cache_status = %#v, want stale", identity["cache_status"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestNormalizeEndpointIPUsesSharedSourceEndpointKey(t *testing.T) {
tests := map[string]string{
"115.231.168.135:43625": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"mqtt://yutong/ytforward/shln/3": "mqtt",
"MQTT://YUTONG/topic": "mqtt",
}
for input, want := range tests {
if got := normalizeEndpointIP(input); got != want {
t.Fatalf("normalizeEndpointIP(%q) = %q, want %q", input, got, want)
}
}
}
func TestMySQLResolverKeepsDirectSourceKindWithoutSourceCode(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", "39.144.3.22").
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("", "", "DIRECT"))
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307765812", "LA9GG64L7PBAF4001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307765812",
SourceEndpoint: "39.144.3.22:60177",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LA9GG64L7PBAF4001" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "" || env.SourceKind != "DIRECT" {
t.Fatalf("source metadata = code:%q kind:%q", env.SourceCode, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source_kind"] != "DIRECT" {
t.Fatalf("identity source metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverFallsBackToGlobalIdentifierWhenSourceCodeMisses(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeHit(mock, "115.231.168.135", "xinda")
expectVehicleIdentifierMissForSource(mock, "xinda", "JT808_PHONE", "13307795425")
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000002")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000002" {
t.Fatalf("vin = %q", env.VIN)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesSingleGlobalIdentifierSourceCodeForJT808SourceMetadata(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeMiss(mock, "117.132.196.119")
expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "41456413943", "LNXNEGRR0SR321372", "xinda", "信达")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "41456413943",
SourceEndpoint: "117.132.196.119:3275",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNXNEGRR0SR321372" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "xinda" || env.PlatformName != "信达" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "xinda" || identity["platform_name"] != "信达" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverLooksUpVINByUniqueKeyWithoutSort(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\? AND vin IS NOT NULL AND vin <> ''$").
WithArgs("13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNBVIN00000000001"))
@@ -109,6 +353,7 @@ func TestMySQLResolverLooksUpVINByUniqueKeyWithoutSort(t *testing.T) {
func TestMySQLResolverDoesNotLookupBindingByDeviceID(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -133,9 +378,11 @@ func TestMySQLResolverDoesNotLookupBindingByDeviceID(t *testing.T) {
func TestMySQLResolverTracksJT808RegistrationWithoutWritingBinding(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13079963379")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13079963379").
WillReturnError(sql.ErrNoRows)
expectVehicleIdentifierMiss(mock, "PLATE", "TEST123")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("TEST123").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LKLG7C4E3NA774736"))
@@ -176,6 +423,7 @@ func TestMySQLResolverTracksJT808RegistrationWithoutWritingBinding(t *testing.T)
func TestMySQLResolverTracksFirstJT808LocationThenThrottles(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -206,12 +454,14 @@ func TestMySQLResolverTracksFirstJT808LocationThenThrottles(t *testing.T) {
func TestMySQLResolverUsesJT808RegistrationPlateForLocationVIN(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("40692934322").
WillReturnRows(sqlmock.NewRows([]string{"vin", "device_id", "plate"}).AddRow("unknown", "18285", "粤AG18285"))
expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18285").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
@@ -248,9 +498,352 @@ func TestMySQLResolverUsesJT808RegistrationPlateForLocationVIN(t *testing.T) {
}
}
func TestMySQLResolverRefreshesRegistrationCacheAfterRegisterFrame(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18285").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
firstLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("first location Resolve() error = %v", err)
}
if firstLocation.VIN != "" {
t.Fatalf("first location vin = %q, want unresolved", firstLocation.VIN)
}
registered, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0100",
Phone: "040692934322",
DeviceID: "18285",
Plate: "粤AG18285",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{
"registration": map[string]any{
"device_id": "18285",
"plate": "粤AG18285",
},
},
})
if err != nil {
t.Fatalf("registration Resolve() error = %v", err)
}
if registered.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("registered vin = %q", registered.VIN)
}
secondLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("second location Resolve() error = %v", err)
}
if secondLocation.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("second location vin = %q, want cache-refreshed registration vin", secondLocation.VIN)
}
if secondLocation.DeviceID != "18285" || secondLocation.Plate != "粤AG18285" {
t.Fatalf("second location identity not copied: device=%q plate=%q", secondLocation.DeviceID, secondLocation.Plate)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverDelegatesRegistrationPersistenceButKeepsLocalSession(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
var results []RegistrationWriteResult
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
DisableRegistrationWrites: true,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results = append(results, result)
},
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
DeviceID: "DEV-1",
Plate: "粤A00001",
SourceEndpoint: "115.231.168.135:43625",
ParseStatus: envelope.ParseOK,
}
resolved, err := resolver.Resolve(context.Background(), env)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != env.VIN {
t.Fatalf("resolved vin = %q, want %q", resolved.VIN, env.VIN)
}
entry, ok := resolver.registrationCache["13307795425"]
if !ok || entry.vin != env.VIN || entry.plate != env.Plate || entry.deviceID != env.DeviceID {
t.Fatalf("local session = %+v exists=%v", entry, ok)
}
if len(results) != 1 || results[0].Mode != "delegated" || results[0].Status != "ok" {
t.Fatalf("registration write results = %#v", results)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unexpected mysql access: %v", err)
}
}
func TestMySQLResolverRetriesRegistrationUpsertTransientFailure(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection: read tcp: connection reset by peer"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
RegistrationWriteAttempts: 2,
RegistrationWriteRetryDelay: -1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v, want internal retry to recover", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverAsyncRegistrationWritesDrainOnClose(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
results := make(chan RegistrationWriteResult, 2)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
AsyncRegistrationWrites: true,
RegistrationWriteQueueSize: 8,
RegistrationWriteWorkers: 1,
RegistrationWriteTimeout: time.Second,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results <- result
},
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err := resolver.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
close(results)
got := map[string]int{}
for result := range results {
got[result.Mode+":"+result.Status]++
}
if got["async_enqueue:ok"] != 1 || got["async_background:ok"] != 1 {
t.Fatalf("registration write results = %#v, want enqueue/background ok", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverAsyncRegistrationWriteFailureMarksLocationRetry(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection"))
errs := make(chan error, 1)
results := make(chan RegistrationWriteResult, 2)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
AsyncRegistrationWrites: true,
RegistrationWriteQueueSize: 8,
RegistrationWriteWorkers: 1,
RegistrationWriteTimeout: time.Second,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results <- result
},
OnRegistrationWriteError: func(err error) {
errs <- err
},
LocationTouchInterval: time.Hour,
LocationTouchRetryInterval: time.Hour,
RegistrationWriteAttempts: 1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v, async write failure should be reported out-of-band", err)
}
if err := resolver.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
select {
case err := <-errs:
if err == nil {
t.Fatal("async error callback received nil")
}
default:
t.Fatal("async write failure was not reported")
}
stats := resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
}
close(results)
got := map[string]int{}
for result := range results {
got[result.Mode+":"+result.Status]++
}
if got["async_enqueue:ok"] != 1 || got["async_background:error"] != 1 {
t.Fatalf("registration write results = %#v, want enqueue ok/background error", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverBacksOffLocationTouchAfterExhaustedUpsert(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection"))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LocationTouchRetryInterval: time.Hour,
RegistrationWriteAttempts: 1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err == nil {
t.Fatal("first Resolve() error = nil, want exhausted transient registration upsert failure")
}
stats := resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("second Resolve() error = %v, want short backoff to skip immediate retry", err)
}
stats = resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries after backoff = %d, want 1", stats.LocationTouchFailureEntries)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestIsTransientMySQLIdentityWriteError(t *testing.T) {
for _, err := range []error{
errors.New("dial tcp 127.0.0.1:3306: connection refused"),
errors.New("read tcp: connection reset by peer"),
errors.New("write tcp: broken pipe"),
errors.New("driver: bad connection"),
errors.New("invalid connection"),
errors.New("i/o timeout"),
errors.New("EOF"),
errors.New("server is down"),
errors.New("network is unreachable"),
} {
if !isTransientMySQLIdentityWriteError(err) {
t.Fatalf("isTransientMySQLIdentityWriteError(%q) = false, want true", err.Error())
}
}
for _, err := range []error{
context.Canceled,
context.DeadlineExceeded,
errors.New("duplicate key conflict"),
nil,
} {
if isTransientMySQLIdentityWriteError(err) {
t.Fatalf("isTransientMySQLIdentityWriteError(%v) = true, want false", err)
}
}
}
func TestTimeoutResolverAppliesDeadlineToDelegate(t *testing.T) {
delegate := &deadlineCheckingResolver{}
resolver := TimeoutResolver{Delegate: delegate, Timeout: 50 * time.Millisecond}
if _, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if delegate.deadline.IsZero() {
t.Fatal("delegate did not receive a deadline")
}
if remaining := time.Until(delegate.deadline); remaining <= 0 || remaining > time.Second {
t.Fatalf("deadline remaining = %s", remaining)
}
}
func TestMySQLResolverCachesIdentityMissesForHighFrequencyFrames(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -274,6 +867,95 @@ func TestMySQLResolverCachesIdentityMissesForHighFrequencyFrames(t *testing.T) {
}
}
func TestMySQLResolverBoundsIdentityLookupCaches(t *testing.T) {
db, _ := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Hour,
CacheCleanupInterval: time.Hour,
MaxCacheEntries: 2,
LocationTouchInterval: time.Hour,
})
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
for i, key := range []string{"lookup-1", "lookup-2", "lookup-3"} {
entryNow := now.Add(time.Duration(i) * time.Second)
resolver.cacheLookup(key, lookupCacheEntry{
vin: key,
expiresAt: entryNow.Add(time.Hour),
}, entryNow)
resolver.cacheRegistration("phone-"+key, registrationCacheEntry{
vin: key,
expiresAt: entryNow.Add(time.Hour),
}, entryNow)
resolver.cacheSourceMetadata("source-"+key, sourceMetadata{SourceCode: key}, false, entryNow)
}
stats := resolver.CacheStats()
if stats.LookupEntries != 2 || stats.RegistrationEntries != 2 || stats.SourceCodeEntries != 2 || stats.MaxEntries != 2 {
t.Fatalf("cache stats = %+v, want all identity caches capped at 2", stats)
}
resolver.lookupMu.Lock()
_, hasOldLookup := resolver.lookupCache["lookup-1"]
_, hasOldRegistration := resolver.registrationCache["phone-lookup-1"]
_, hasOldSource := resolver.sourceCodeCache["source-lookup-1"]
resolver.lookupMu.Unlock()
if hasOldLookup || hasOldRegistration || hasOldSource {
t.Fatalf("oldest cache entries should be evicted")
}
}
func TestMySQLResolverBoundsLocationTouchCache(t *testing.T) {
db, _ := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Hour,
CacheCleanupInterval: time.Hour,
MaxCacheEntries: 2,
LocationTouchInterval: time.Hour,
})
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
resolver.touchMu.Lock()
resolver.locationTouches["old-expired"] = now.Add(-2 * time.Hour)
resolver.locationTouches["phone-1"] = now.Add(-2 * time.Minute)
resolver.locationTouches["phone-2"] = now.Add(-time.Minute)
resolver.locationTouches["phone-3"] = now
resolver.locationTouchFailures["old-failure"] = now.Add(-time.Minute)
resolver.locationTouchFailures["phone-2"] = now.Add(time.Minute)
resolver.locationTouchFailures["phone-3"] = now.Add(2 * time.Minute)
resolver.locationTouchFailures["phone-4"] = now.Add(3 * time.Minute)
resolver.cleanupLocationTouchesLocked(now, false)
resolver.touchMu.Unlock()
stats := resolver.CacheStats()
if stats.LocationTouchEntries != 2 || stats.LocationTouchFailureEntries != 2 || stats.MaxEntries != 2 {
t.Fatalf("cache stats = %+v, want location touch caches capped at 2", stats)
}
resolver.touchMu.Lock()
_, hasExpired := resolver.locationTouches["old-expired"]
_, hasOldest := resolver.locationTouches["phone-1"]
_, hasExpiredFailure := resolver.locationTouchFailures["old-failure"]
_, hasOldestFailure := resolver.locationTouchFailures["phone-2"]
resolver.touchMu.Unlock()
if hasExpired || hasOldest || hasExpiredFailure || hasOldestFailure {
t.Fatalf("expired and oldest location touch entries should be evicted")
}
}
type deadlineCheckingResolver struct {
mu sync.Mutex
deadline time.Time
}
func (r *deadlineCheckingResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
deadline, _ := ctx.Deadline()
r.mu.Lock()
r.deadline = deadline
r.mu.Unlock()
return env, nil
}
func TestMySQLResolverEnsuresMinimalIdentitySchema(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
@@ -285,8 +967,18 @@ func TestMySQLResolverEnsuresMinimalIdentitySchema(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
if err := resolver.EnsureSchema(context.Background()); err != nil {
@@ -308,8 +1000,18 @@ func TestMySQLResolverIgnoresExistingOEMColumn(t *testing.T) {
WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'uk_identity_device'; check that column/key exists"))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'device_id'; check that column/key exists"))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
WillReturnError(errors.New("Error 1060 (42S21): Duplicate column name 'source_ip'"))
mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
WillReturnError(errors.New("Error 1061 (42000): Duplicate key name 'idx_jt808_registration_source_ip'"))
mock.ExpectExec("UPDATE jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
if err := resolver.EnsureSchema(context.Background()); err != nil {
@@ -339,6 +1041,15 @@ func TestIdentitySchemaUsesBusinessKeysOnly(t *testing.T) {
if !strings.Contains(registration, "phone VARCHAR(32) PRIMARY KEY") {
t.Fatalf("registration table should key by phone:\n%s", registration)
}
if !strings.Contains(registration, "source_ip VARCHAR(64)") || !strings.Contains(registration, "idx_jt808_registration_source_ip") {
t.Fatalf("registration table should keep indexed source_ip:\n%s", registration)
}
if !strings.Contains(vehicleIdentifierTableSQL, "PRIMARY KEY (protocol, source_code, identifier_type, identifier_value)") {
t.Fatalf("vehicle identifier should use protocol/source/type/value as key:\n%s", vehicleIdentifierTableSQL)
}
if strings.Contains(vehicleIdentifierTableSQL, "AUTO_INCREMENT") {
t.Fatalf("vehicle identifier should not use surrogate auto increment id:\n%s", vehicleIdentifierTableSQL)
}
}
func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
@@ -349,3 +1060,43 @@ func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
}
return db, mock
}
func expectVehicleIdentifierMiss(mock sqlmock.Sqlmock, identifierType string, value string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}
func expectVehicleIdentifierHit(mock sqlmock.Sqlmock, identifierType string, value string, vin string) {
expectVehicleIdentifierHitWithSource(mock, identifierType, value, vin, "", "")
}
func expectVehicleIdentifierHitWithSource(mock sqlmock.Sqlmock, identifierType string, value string, vin string, sourceCode string, platformName string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, platformName))
}
func expectSourceCodeHit(mock sqlmock.Sqlmock, sourceIP string, sourceCode string) {
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", sourceIP).
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow(sourceCode, "G7s", "PLATFORM"))
}
func expectSourceCodeMiss(mock sqlmock.Sqlmock, sourceIP string) {
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", sourceIP).
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}))
}
func expectVehicleIdentifierMissForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value, sourceCode).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}
func expectVehicleIdentifierHitForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string, vin string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value, sourceCode).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, "G7s"))
}

View File

@@ -0,0 +1,325 @@
package identity
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
type SnapshotRefreshResult struct {
BindingEntries int
IdentifierEntries int
RegistrationEntries int
SourceEntries int
RefreshedAt time.Time
}
// identitySnapshot is immutable after atomic publication, so frame handling
// performs only local map lookups and never waits for MySQL or a refresh lock.
type identitySnapshot struct {
bindings map[string]string
identifiers map[string]vehicleIdentifierMatch
registrations map[string]registrationCacheEntry
sources map[string]sourceMetadata
refreshedAt time.Time
}
func (r *MySQLResolver) RefreshSnapshot(ctx context.Context) (SnapshotRefreshResult, error) {
if r == nil || r.db == nil {
return SnapshotRefreshResult{}, fmt.Errorf("identity snapshot database is not configured")
}
r.snapshotRefreshMu.Lock()
defer r.snapshotRefreshMu.Unlock()
next := &identitySnapshot{
bindings: map[string]string{},
identifiers: map[string]vehicleIdentifierMatch{},
registrations: map[string]registrationCacheEntry{},
sources: map[string]sourceMetadata{},
}
if err := r.loadBindingSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadIdentifierSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadRegistrationSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadSourceSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
next.refreshedAt = time.Now()
r.snapshot.Store(next)
return SnapshotRefreshResult{
BindingEntries: len(next.bindings),
IdentifierEntries: len(next.identifiers),
RegistrationEntries: len(next.registrations),
SourceEntries: len(next.sources),
RefreshedAt: next.refreshedAt,
}, nil
}
func (r *MySQLResolver) loadBindingSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, "SELECT vin, plate, phone FROM "+r.table+" WHERE vin IS NOT NULL AND TRIM(vin) <> ''")
if err != nil {
return fmt.Errorf("load identity binding snapshot: %w", err)
}
defer rows.Close()
ambiguous := map[string]struct{}{}
for rows.Next() {
var vin, plate, phone sql.NullString
if err := rows.Scan(&vin, &plate, &phone); err != nil {
return fmt.Errorf("scan identity binding snapshot: %w", err)
}
vinValue := strings.TrimSpace(vin.String)
if vinValue == "" {
continue
}
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("vin", vinValue), vinValue)
if value := strings.TrimSpace(plate.String); value != "" {
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("plate", value), vinValue)
}
if value := normalizePhone(phone.String); value != "" {
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("phone", value), vinValue)
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate identity binding snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadIdentifierSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT protocol, source_code, identifier_type, identifier_value,
vin, COALESCE(NULLIF(TRIM(oem), ''), source_code) AS platform_name
FROM vehicle_identifier
WHERE enabled = 1 AND vin IS NOT NULL AND TRIM(vin) <> ''
AND identifier_value IS NOT NULL AND TRIM(identifier_value) <> ''`)
if err != nil {
return fmt.Errorf("load vehicle identifier snapshot: %w", err)
}
defer rows.Close()
ambiguous := map[string]struct{}{}
for rows.Next() {
var protocol, sourceCode, identifierType, identifierValue, vin, platformName sql.NullString
if err := rows.Scan(&protocol, &sourceCode, &identifierType, &identifierValue, &vin, &platformName); err != nil {
return fmt.Errorf("scan vehicle identifier snapshot: %w", err)
}
protocolValue := strings.TrimSpace(protocol.String)
typeValue := strings.ToUpper(strings.TrimSpace(identifierType.String))
value := normalizeIdentifierValue(typeValue, identifierValue.String)
vinValue := strings.TrimSpace(vin.String)
if protocolValue == "" || typeValue == "" || value == "" || vinValue == "" {
continue
}
match := vehicleIdentifierMatch{
VIN: vinValue,
SourceCode: strings.TrimSpace(sourceCode.String),
PlatformName: strings.TrimSpace(platformName.String),
}
scopedKey := vehicleIdentifierSnapshotKey(protocolValue, match.SourceCode, typeValue, value)
addSnapshotIdentifier(target.identifiers, ambiguous, scopedKey, match)
globalKey := vehicleIdentifierSnapshotKey(protocolValue, "", typeValue, value)
addSnapshotIdentifier(target.identifiers, ambiguous, globalKey, match)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate vehicle identifier snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadRegistrationSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT phone, vin, device_id, plate, auth_token
FROM jt808_registration
WHERE phone IS NOT NULL AND TRIM(phone) <> ''`)
if err != nil {
return fmt.Errorf("load jt808 registration snapshot: %w", err)
}
defer rows.Close()
for rows.Next() {
var phone, vin, deviceID, plate, authToken sql.NullString
if err := rows.Scan(&phone, &vin, &deviceID, &plate, &authToken); err != nil {
return fmt.Errorf("scan jt808 registration snapshot: %w", err)
}
phoneValue := normalizePhone(phone.String)
if phoneValue == "" {
continue
}
target.registrations[phoneValue] = registrationCacheEntry{
vin: strings.TrimSpace(vin.String),
deviceID: strings.TrimSpace(deviceID.String),
plate: strings.TrimSpace(plate.String),
authToken: strings.TrimSpace(authToken.String),
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate jt808 registration snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadSourceSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT protocol, source_ip, source_code, platform_name, source_kind
FROM vehicle_data_source
WHERE enabled = 1 AND source_ip IS NOT NULL AND TRIM(source_ip) <> ''
AND (
(source_code IS NOT NULL AND TRIM(source_code) <> '')
OR source_kind IN ('PLATFORM', 'DIRECT')
OR (platform_name IS NOT NULL AND TRIM(platform_name) <> '')
)`)
if err != nil {
if isOptionalSourceCodeLookupError(err) {
return nil
}
return fmt.Errorf("load vehicle data source snapshot: %w", err)
}
defer rows.Close()
for rows.Next() {
var protocol, sourceIP, sourceCode, platformName, sourceKind sql.NullString
if err := rows.Scan(&protocol, &sourceIP, &sourceCode, &platformName, &sourceKind); err != nil {
return fmt.Errorf("scan vehicle data source snapshot: %w", err)
}
key := sourceSnapshotKey(protocol.String, sourceIP.String)
if key == "" {
continue
}
target.sources[key] = sourceMetadata{
SourceCode: strings.TrimSpace(sourceCode.String),
PlatformName: strings.TrimSpace(platformName.String),
SourceKind: strings.TrimSpace(sourceKind.String),
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate vehicle data source snapshot: %w", err)
}
return nil
}
func addSnapshotBinding(values map[string]string, ambiguous map[string]struct{}, key string, vin string) {
if key == "" {
return
}
if _, exists := ambiguous[key]; exists {
return
}
if current, exists := values[key]; exists && !strings.EqualFold(current, vin) {
delete(values, key)
ambiguous[key] = struct{}{}
return
}
values[key] = vin
}
func addSnapshotIdentifier(values map[string]vehicleIdentifierMatch, ambiguous map[string]struct{}, key string, match vehicleIdentifierMatch) {
if key == "" {
return
}
if _, exists := ambiguous[key]; exists {
return
}
current, exists := values[key]
if !exists {
values[key] = match
return
}
if !strings.EqualFold(current.VIN, match.VIN) {
delete(values, key)
ambiguous[key] = struct{}{}
return
}
if !strings.EqualFold(current.SourceCode, match.SourceCode) {
current.SourceCode = ""
current.PlatformName = ""
} else if current.PlatformName != match.PlatformName {
current.PlatformName = ""
}
values[key] = current
}
func (r *MySQLResolver) snapshotBinding(column string, value string) (string, bool) {
key := bindingSnapshotKey(column, value)
if key == "" {
return "", false
}
snapshot := r.snapshot.Load()
if snapshot == nil {
return "", false
}
vin, ok := snapshot.bindings[key]
return vin, ok
}
func (r *MySQLResolver) snapshotIdentifier(protocolValue string, sourceCode string, identifierType string, value string) (vehicleIdentifierMatch, bool) {
key := vehicleIdentifierSnapshotKey(protocolValue, sourceCode, identifierType, value)
snapshot := r.snapshot.Load()
if snapshot == nil {
return vehicleIdentifierMatch{}, false
}
match, ok := snapshot.identifiers[key]
return match, ok
}
func (r *MySQLResolver) snapshotRegistration(phone string) (registrationCacheEntry, bool) {
phone = normalizePhone(phone)
snapshot := r.snapshot.Load()
if snapshot == nil {
return registrationCacheEntry{}, false
}
entry, ok := snapshot.registrations[phone]
return entry, ok
}
func (r *MySQLResolver) snapshotSource(protocolValue string, sourceIP string) (sourceMetadata, bool) {
key := sourceSnapshotKey(protocolValue, sourceIP)
snapshot := r.snapshot.Load()
if snapshot == nil {
return sourceMetadata{}, false
}
metadata, ok := snapshot.sources[key]
return metadata, ok
}
// JT808AuthToken serves authentication from the same immutable snapshot used
// by identity resolution. It deliberately never falls back to a per-frame SQL
// query because authentication is on the protocol response hot path.
func (r *MySQLResolver) JT808AuthToken(phone string) (string, bool) {
entry, ok := r.snapshotRegistration(phone)
token := strings.TrimSpace(entry.authToken)
return token, ok && token != ""
}
func bindingSnapshotKey(column string, value string) string {
column = strings.ToLower(strings.TrimSpace(column))
value = strings.TrimSpace(value)
if column == "phone" {
value = normalizePhone(value)
}
if value == "" || (column != "vin" && column != "plate" && column != "phone") {
return ""
}
return column + "\x00" + value
}
func vehicleIdentifierSnapshotKey(protocolValue string, sourceCode string, identifierType string, value string) string {
protocolValue = strings.TrimSpace(protocolValue)
sourceCode = strings.TrimSpace(sourceCode)
identifierType = strings.ToUpper(strings.TrimSpace(identifierType))
value = normalizeIdentifierValue(identifierType, value)
if protocolValue == "" || identifierType == "" || value == "" {
return ""
}
return "vehicle_identifier\x00" + protocolValue + "\x00" + sourceCode + "\x00" + identifierType + "\x00" + value
}
func sourceSnapshotKey(protocolValue string, sourceIP string) string {
protocolValue = strings.TrimSpace(protocolValue)
sourceIP = normalizeEndpointIP(sourceIP)
if protocolValue == "" || sourceIP == "" {
return ""
}
return protocolValue + "\x00" + sourceIP
}

View File

@@ -0,0 +1,166 @@
package identity
import (
"context"
"errors"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestRefreshSnapshotResolvesKnownJT808WithoutPerFrameQueries(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
result, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
if result.BindingEntries != 3 || result.IdentifierEntries != 2 || result.RegistrationEntries != 1 || result.SourceEntries != 1 {
t.Fatalf("snapshot result = %+v", result)
}
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", resolved.VIN)
}
if resolved.SourceCode != "g7s" || resolved.PlatformName != "G7s" || resolved.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", resolved.SourceCode, resolved.PlatformName, resolved.SourceKind)
}
identityMetadata, _ := resolved.Parsed["identity"].(map[string]any)
if identityMetadata["cache_status"] != "snapshot" {
t.Fatalf("identity metadata = %#v, want snapshot cache status", identityMetadata)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestSnapshotOnlyResolverMissDoesNotQueryMySQL(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307700000",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != "" {
t.Fatalf("vin = %q, want unresolved", resolved.VIN)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("snapshot-only miss should not query mysql: %v", err)
}
}
func TestRefreshSnapshotFailureKeepsLastKnownGoodSnapshot(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
first, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("first RefreshSnapshot() error = %v", err)
}
mock.ExpectQuery("SELECT vin, plate, phone").
WillReturnError(errors.New("mysql unavailable"))
if _, err := resolver.RefreshSnapshot(context.Background()); err == nil {
t.Fatal("second RefreshSnapshot() error = nil, want failure")
}
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() after failed refresh error = %v", err)
}
if resolved.VIN != "LNBVIN00000000001" {
t.Fatalf("vin after failed refresh = %q", resolved.VIN)
}
stats := resolver.CacheStats()
if !stats.SnapshotReady || stats.SnapshotRefreshedAt.IsZero() || !stats.SnapshotRefreshedAt.Equal(first.RefreshedAt) {
t.Fatalf("snapshot stats after failed refresh = %+v, first = %+v", stats, first)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func expectIdentitySnapshot(mock sqlmock.Sqlmock) {
mock.ExpectQuery("SELECT vin, plate, phone").
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "phone"}).
AddRow("LNBVIN00000000001", "粤A00001", "13307795425"))
mock.ExpectQuery("SELECT protocol, source_code, identifier_type, identifier_value").
WillReturnRows(sqlmock.NewRows([]string{"protocol", "source_code", "identifier_type", "identifier_value", "vin", "platform_name"}).
AddRow("JT808", "g7s", "JT808_PHONE", "13307795425", "LNBVIN00000000001", "G7s"))
mock.ExpectQuery("SELECT phone, vin, device_id, plate, auth_token").
WillReturnRows(sqlmock.NewRows([]string{"phone", "vin", "device_id", "plate", "auth_token"}).
AddRow("13307795425", "LNBVIN00000000001", "DEVICE-1", "粤A00001", "device-code"))
mock.ExpectQuery("SELECT protocol, source_ip, source_code, platform_name, source_kind").
WillReturnRows(sqlmock.NewRows([]string{"protocol", "source_ip", "source_code", "platform_name", "source_kind"}).
AddRow("JT808", "115.231.168.135", "g7s", "G7s", "PLATFORM"))
}
func TestSnapshotServesJT808AuthenticationTokenByNormalizedPhone(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
if _, err := resolver.RefreshSnapshot(context.Background()); err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
token, ok := resolver.JT808AuthToken("0013307795425")
if !ok || token != "device-code" {
t.Fatalf("JT808AuthToken() = %q, %v", token, ok)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestSnapshotResultRefreshedAtUsesCurrentTime(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
before := time.Now()
result, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
if result.RefreshedAt.Before(before) || result.RefreshedAt.After(time.Now()) {
t.Fatalf("refreshed_at = %v, want current time", result.RefreshedAt)
}
}

View File

@@ -14,6 +14,9 @@ const (
ProtocolGB32960 Protocol = "gb32960"
ProtocolJT808 Protocol = "jt808"
ProtocolYutongMQTT Protocol = "yutong-mqtt"
DefaultJT808PhoneBase int64 = 139000000000
maxJT808Phone int64 = 999999999999
)
type Config struct {
@@ -25,17 +28,21 @@ type Config struct {
Duration time.Duration
Template string
SendFrames bool
JT808PhoneBase int64
DrainResponses bool
}
type FlagConfig struct {
protocol string
addr string
connections int
connectRate int
sendInterval time.Duration
duration time.Duration
template string
sendFrames bool
protocol string
addr string
connections int
connectRate int
sendInterval time.Duration
duration time.Duration
template string
sendFrames bool
jt808PhoneBase int64
drainResponses bool
}
func RegisterFlags(fs *flag.FlagSet) *FlagConfig {
@@ -48,6 +55,8 @@ func RegisterFlags(fs *flag.FlagSet) *FlagConfig {
fs.DurationVar(&cfg.duration, "duration", 10*time.Minute, "load test duration")
fs.StringVar(&cfg.template, "template", "", "frame template name")
fs.BoolVar(&cfg.sendFrames, "send", true, "send protocol frames while connections are open")
fs.Int64Var(&cfg.jt808PhoneBase, "jt808-phone-base", DefaultJT808PhoneBase, "first synthetic JT808 phone; one consecutive phone is assigned per connection")
fs.BoolVar(&cfg.drainResponses, "drain-responses", true, "continuously read protocol responses while frames are being sent")
return cfg
}
@@ -73,6 +82,14 @@ func (c *FlagConfig) Build() (Config, error) {
if c.duration <= 0 {
return Config{}, errors.New("duration must be positive")
}
if protocol == ProtocolJT808 {
if c.jt808PhoneBase <= 0 || c.jt808PhoneBase > maxJT808Phone {
return Config{}, errors.New("jt808-phone-base must be a positive 12-digit-or-shorter number")
}
if int64(c.connections-1) > maxJT808Phone-c.jt808PhoneBase {
return Config{}, errors.New("jt808 synthetic phone range exceeds 12 digits")
}
}
return Config{
Protocol: protocol,
Addr: strings.TrimSpace(c.addr),
@@ -82,5 +99,7 @@ func (c *FlagConfig) Build() (Config, error) {
Duration: c.duration,
Template: strings.TrimSpace(c.template),
SendFrames: c.sendFrames,
JT808PhoneBase: c.jt808PhoneBase,
DrainResponses: c.drainResponses,
}, nil
}

View File

@@ -18,6 +18,7 @@ func TestConfigFromFlagsParsesCapacityKnobs(t *testing.T) {
"-send-interval", "5s",
"-duration", "30m",
"-template", "0200",
"-jt808-phone-base", "138900000000",
"-send=false",
})
if err != nil {
@@ -53,6 +54,12 @@ func TestConfigFromFlagsParsesCapacityKnobs(t *testing.T) {
if got.SendFrames {
t.Fatal("SendFrames = true, want false")
}
if got.JT808PhoneBase != 138900000000 {
t.Fatalf("JT808PhoneBase = %d, want 138900000000", got.JT808PhoneBase)
}
if !got.DrainResponses {
t.Fatal("DrainResponses = false, want true by default")
}
}
func TestConfigFromFlagsRejectsUnsafeValues(t *testing.T) {
@@ -62,6 +69,7 @@ func TestConfigFromFlagsRejectsUnsafeValues(t *testing.T) {
"zero connections": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "0"},
"zero connect rate": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "1", "-connect-rate", "0"},
"zero interval": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "1", "-send-interval", "0s"},
"phone overflow": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "2", "-jt808-phone-base", "999999999999"},
} {
t.Run(name, func(t *testing.T) {
fs := flag.NewFlagSet("load-sim", flag.ContinueOnError)

View File

@@ -7,22 +7,30 @@ import (
)
type FrameFactory struct {
protocol Protocol
base []byte
protocol Protocol
base []byte
jt808PhoneBase int64
}
func NewFrameFactory(protocol Protocol, template string) (*FrameFactory, error) {
return NewFrameFactoryWithJT808PhoneBase(protocol, template, DefaultJT808PhoneBase)
}
func NewFrameFactoryWithJT808PhoneBase(protocol Protocol, template string, phoneBase int64) (*FrameFactory, error) {
base, err := FrameTemplate(protocol, template)
if err != nil {
return nil, err
}
return &FrameFactory{protocol: protocol, base: base}, nil
if phoneBase <= 0 {
phoneBase = DefaultJT808PhoneBase
}
return &FrameFactory{protocol: protocol, base: base, jt808PhoneBase: phoneBase}, nil
}
func (f *FrameFactory) Frame(connectionIndex int, frameIndex int64) ([]byte, error) {
switch f.protocol {
case ProtocolJT808:
return mutateJT808Frame(f.base, connectionIndex, frameIndex)
return mutateJT808Frame(f.base, f.jt808PhoneBase, connectionIndex, frameIndex)
case ProtocolGB32960:
return mutateGB32960Frame(f.base, connectionIndex, frameIndex)
default:
@@ -31,7 +39,7 @@ func (f *FrameFactory) Frame(connectionIndex int, frameIndex int64) ([]byte, err
}
}
func mutateJT808Frame(base []byte, connectionIndex int, frameIndex int64) ([]byte, error) {
func mutateJT808Frame(base []byte, phoneBase int64, connectionIndex int, frameIndex int64) ([]byte, error) {
if len(base) < 2 || base[0] != 0x7e || base[len(base)-1] != 0x7e {
return nil, fmt.Errorf("jt808 template must include 0x7e delimiters")
}
@@ -42,7 +50,7 @@ func mutateJT808Frame(base []byte, connectionIndex int, frameIndex int64) ([]byt
if len(payload) < 13 {
return nil, fmt.Errorf("jt808 template too short: %d", len(payload))
}
phone := fmt.Sprintf("%012d", 139000000000+connectionIndex%100000000)
phone := fmt.Sprintf("%012d", phoneBase+int64(connectionIndex))
copy(payload[4:10], encodeBCD(phone, 6))
binary.BigEndian.PutUint16(payload[10:12], uint16((int(frameIndex)+connectionIndex)%65536))
bodySize := int(binary.BigEndian.Uint16(payload[2:4]) & 0x03ff)

View File

@@ -38,6 +38,20 @@ func TestFrameFactoryGeneratesUniqueParsableJT808Frames(t *testing.T) {
}
}
func TestFrameFactoryUsesConfiguredJT808PhoneBase(t *testing.T) {
factory, err := NewFrameFactoryWithJT808PhoneBase(ProtocolJT808, "0200", 138900000000)
if err != nil {
t.Fatalf("NewFrameFactoryWithJT808PhoneBase() error = %v", err)
}
frame, err := factory.Frame(42, 0)
if err != nil {
t.Fatalf("Frame() error = %v", err)
}
if got := parseJT808Frame(t, frame).Phone; got != "138900000042" {
t.Fatalf("phone = %q, want 138900000042", got)
}
}
func TestFrameFactoryGeneratesUniqueParsableGB32960Frames(t *testing.T) {
factory, err := NewFrameFactory(ProtocolGB32960, "realtime")
if err != nil {

View File

@@ -2,6 +2,8 @@ package loadsim
import (
"context"
"errors"
"io"
"net"
"sync"
"sync/atomic"
@@ -19,24 +21,32 @@ type Stats struct {
ConnectionsFailed int64
FramesWritten int64
WriteErrors int64
ResponseBytes int64
ReadErrors int64
}
func (r Runner) Run(ctx context.Context, cfg Config) (Stats, error) {
phoneBase := cfg.JT808PhoneBase
if phoneBase <= 0 {
phoneBase = DefaultJT808PhoneBase
}
if _, err := (&FlagConfig{
protocol: string(cfg.Protocol),
addr: cfg.Addr,
connections: cfg.Connections,
connectRate: cfg.ConnectRatePerSecond,
sendInterval: cfg.SendInterval,
duration: cfg.Duration,
template: cfg.Template,
protocol: string(cfg.Protocol),
addr: cfg.Addr,
connections: cfg.Connections,
connectRate: cfg.ConnectRatePerSecond,
sendInterval: cfg.SendInterval,
duration: cfg.Duration,
template: cfg.Template,
jt808PhoneBase: phoneBase,
drainResponses: cfg.DrainResponses,
}).Build(); err != nil {
return Stats{}, err
}
var factory *FrameFactory
if cfg.SendFrames {
var err error
factory, err = NewFrameFactory(cfg.Protocol, cfg.Template)
factory, err = NewFrameFactoryWithJT808PhoneBase(cfg.Protocol, cfg.Template, phoneBase)
if err != nil {
return Stats{}, err
}
@@ -74,12 +84,24 @@ func (r Runner) Run(ctx context.Context, cfg Config) (Stats, error) {
connectionIndex++
go func() {
defer wg.Done()
defer conn.Close()
if cfg.SendFrames {
var readDone chan struct{}
if cfg.DrainResponses {
readDone = make(chan struct{})
go func() {
drainResponses(runCtx, conn, &stats)
close(readDone)
}()
}
writeLoop(runCtx, conn, factory, connIndex, cfg.SendInterval, &stats)
_ = conn.Close()
if readDone != nil {
<-readDone
}
return
}
<-runCtx.Done()
_ = conn.Close()
}()
}
}
@@ -88,6 +110,28 @@ func (r Runner) Run(ctx context.Context, cfg Config) (Stats, error) {
return stats, nil
}
func drainResponses(ctx context.Context, conn net.Conn, stats *Stats) {
buffer := make([]byte, 4096)
for {
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
read, err := conn.Read(buffer)
if read > 0 {
atomic.AddInt64(&stats.ResponseBytes, int64(read))
}
if err == nil {
continue
}
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
return
}
if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
continue
}
atomic.AddInt64(&stats.ReadErrors, 1)
return
}
}
func writeLoop(ctx context.Context, conn net.Conn, factory *FrameFactory, connectionIndex int, interval time.Duration, stats *Stats) {
for frameIndex := int64(0); ; frameIndex++ {
payload, err := factory.Frame(connectionIndex, frameIndex)

View File

@@ -131,6 +131,48 @@ func TestRunnerCanHoldConnectionsWithoutWritingFrames(t *testing.T) {
}
}
func TestRunnerDrainsProtocolResponses(t *testing.T) {
runner := Runner{
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
buffer := make([]byte, 4096)
for {
if _, err := server.Read(buffer); err != nil {
return
}
if _, err := server.Write([]byte{0x7e, 0x80, 0x01, 0x7e}); err != nil {
return
}
}
}()
return client, nil
},
}
stats, err := runner.Run(context.Background(), Config{
Protocol: ProtocolJT808,
Addr: "127.0.0.1:808",
Connections: 1,
ConnectRatePerSecond: 1000,
SendInterval: time.Millisecond,
Duration: 10 * time.Millisecond,
Template: "0200",
SendFrames: true,
DrainResponses: true,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if stats.ResponseBytes == 0 {
t.Fatalf("ResponseBytes = 0, want drained protocol responses")
}
if stats.ReadErrors != 0 {
t.Fatalf("ReadErrors = %d, want 0", stats.ReadErrors)
}
}
type recordingConn struct {
write func([]byte) (int, error)
close func() error

View File

@@ -4,12 +4,16 @@ import (
"fmt"
"math"
"net/http"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
var processStartedAt = time.Now()
type Labels map[string]string
type Registry struct {
@@ -33,6 +37,56 @@ func NewRegistry() *Registry {
}
}
func RegisterServiceInfo(registry *Registry, service string) {
if registry == nil {
return
}
service = strings.TrimSpace(service)
if service == "" {
service = "vehicle-service"
}
registry.SetGauge("vehicle_service_info", Labels{"service": service}, 1)
RecordProcessRuntime(registry)
}
func RecordProcessRuntime(registry *Registry) {
if registry == nil {
return
}
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
registry.SetGauge("vehicle_process_start_time_unix_seconds", nil, float64(processStartedAt.Unix()))
registry.SetGauge("vehicle_process_uptime_seconds", nil, time.Since(processStartedAt).Seconds())
registry.SetGauge("vehicle_process_heap_alloc_bytes", nil, float64(mem.HeapAlloc))
registry.SetGauge("vehicle_process_heap_sys_bytes", nil, float64(mem.HeapSys))
registry.SetGauge("vehicle_process_goroutines", nil, float64(runtime.NumGoroutine()))
}
func RegisterKafkaConsumerInfo(registry *Registry, service string, group string, topics []string) {
if registry == nil {
return
}
service = strings.TrimSpace(service)
if service == "" {
service = "vehicle-service"
}
group = strings.TrimSpace(group)
if group == "" {
return
}
for _, topic := range topics {
topic = strings.TrimSpace(topic)
if topic == "" {
continue
}
registry.SetGauge("vehicle_kafka_consumer_info", Labels{
"service": service,
"group": group,
"topic": topic,
}, 1)
}
}
func (r *Registry) IncCounter(name string, labels Labels) {
r.AddCounter(name, labels, 1)
}
@@ -90,6 +144,13 @@ func (r *Registry) SetKafkaLag(name string, topic string, partition int, offset
}, float64(lag))
}
func RecordLastActivity(registry *Registry, name string, labels Labels) {
if registry == nil {
return
}
registry.SetGauge(name, labels, float64(time.Now().Unix()))
}
func (r *Registry) ObserveHistogram(name string, labels Labels, buckets []float64, value float64) {
name = strings.TrimSpace(name)
if r == nil || name == "" {
@@ -265,6 +326,7 @@ func NewHandler(registry *Registry) http.Handler {
http.NotFound(w, r)
return
}
RecordProcessRuntime(registry)
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
_, _ = w.Write([]byte(registry.Render()))
})

View File

@@ -85,6 +85,65 @@ func TestRegistryRecordsKafkaLagGauge(t *testing.T) {
}
}
func TestRecordLastActivityRendersUnixGauge(t *testing.T) {
registry := NewRegistry()
RecordLastActivity(registry, "vehicle_stat_last_message_unix_seconds", Labels{"topic": "vehicle.fields.go.jt808.v1", "status": "received"})
text := registry.Render()
if !strings.Contains(text, `# TYPE vehicle_stat_last_message_unix_seconds gauge`) ||
!strings.Contains(text, `vehicle_stat_last_message_unix_seconds{status="received",topic="vehicle.fields.go.jt808.v1"} `) {
t.Fatalf("last activity gauge missing:\n%s", text)
}
}
func TestRegisterServiceInfoRendersStableGauge(t *testing.T) {
registry := NewRegistry()
RegisterServiceInfo(registry, "vehicle-realtime-api")
text := registry.Render()
if !strings.Contains(text, `vehicle_service_info{service="vehicle-realtime-api"} 1`) {
t.Fatalf("service info metric missing:\n%s", text)
}
for _, want := range []string{
`# TYPE vehicle_process_start_time_unix_seconds gauge`,
`# TYPE vehicle_process_uptime_seconds gauge`,
`# TYPE vehicle_process_heap_alloc_bytes gauge`,
`# TYPE vehicle_process_heap_sys_bytes gauge`,
`# TYPE vehicle_process_goroutines gauge`,
`vehicle_process_start_time_unix_seconds `,
`vehicle_process_uptime_seconds `,
`vehicle_process_heap_alloc_bytes `,
`vehicle_process_heap_sys_bytes `,
`vehicle_process_goroutines `,
} {
if !strings.Contains(text, want) {
t.Fatalf("process runtime metric missing %s:\n%s", want, text)
}
}
}
func TestRegisterKafkaConsumerInfoRendersTopics(t *testing.T) {
registry := NewRegistry()
RegisterKafkaConsumerInfo(registry, "vehicle-stat-writer", "go-stat-writer", []string{
"vehicle.fields.go.gb32960.v1",
"vehicle.fields.go.jt808.v1",
"",
})
text := registry.Render()
for _, want := range []string{
`vehicle_kafka_consumer_info{group="go-stat-writer",service="vehicle-stat-writer",topic="vehicle.fields.go.gb32960.v1"} 1`,
`vehicle_kafka_consumer_info{group="go-stat-writer",service="vehicle-stat-writer",topic="vehicle.fields.go.jt808.v1"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("consumer info metric missing %s:\n%s", want, text)
}
}
}
func TestHandlerServesPrometheusText(t *testing.T) {
registry := NewRegistry()
registry.IncCounter("vehicle_kafka_commits_total", Labels{"service": "history"})

View File

@@ -0,0 +1,35 @@
package metrics
import "sync"
// PendingPairGauge keeps process-wide in-flight totals correct when a service
// has multiple workers updating the same pair of pending gauges.
type PendingPairGauge struct {
mu sync.Mutex
first int
second int
}
func (g *PendingPairGauge) Add(registry *Registry, firstMetric string, secondMetric string, firstDelta int, secondDelta int) {
if g == nil {
return
}
g.mu.Lock()
g.first += firstDelta
g.second += secondDelta
if g.first < 0 {
g.first = 0
}
if g.second < 0 {
g.second = 0
}
first := g.first
second := g.second
g.mu.Unlock()
if registry == nil {
return
}
registry.SetGauge(firstMetric, nil, float64(first))
registry.SetGauge(secondMetric, nil, float64(second))
}

View File

@@ -0,0 +1,79 @@
package metrics
import (
"math"
"sort"
"strings"
"sync"
)
// RecentLatencyByKey tracks a bounded in-memory latency window per logical key.
// It is intentionally small and process-local; Prometheus histograms keep the
// lifetime view, while this gives capacity-check a recent-window signal.
type RecentLatencyByKey struct {
mu sync.Mutex
size int
windows map[string]*recentLatencyWindow
}
type recentLatencyWindow struct {
values []float64
next int
count int
}
func NewRecentLatencyByKey(size int) *RecentLatencyByKey {
if size <= 0 {
size = 1
}
return &RecentLatencyByKey{
size: size,
windows: map[string]*recentLatencyWindow{},
}
}
func (r *RecentLatencyByKey) Observe(key string, value float64) (p99 float64, samples int) {
if r == nil {
return 0, 0
}
key = strings.TrimSpace(key)
if key == "" {
key = "unknown"
}
if value < 0 || math.IsNaN(value) {
value = 0
}
if math.IsInf(value, 1) {
value = math.MaxFloat64
}
r.mu.Lock()
defer r.mu.Unlock()
window := r.windows[key]
if window == nil {
window = &recentLatencyWindow{values: make([]float64, r.size)}
r.windows[key] = window
}
window.values[window.next] = value
window.next = (window.next + 1) % len(window.values)
if window.count < len(window.values) {
window.count++
}
return window.quantileLocked(0.99), window.count
}
func (w *recentLatencyWindow) quantileLocked(q float64) float64 {
if w == nil || w.count == 0 {
return 0
}
snapshot := append([]float64(nil), w.values[:w.count]...)
sort.Float64s(snapshot)
index := int(math.Ceil(q*float64(len(snapshot)))) - 1
if index < 0 {
index = 0
}
if index >= len(snapshot) {
index = len(snapshot) - 1
}
return snapshot[index]
}

View File

@@ -0,0 +1,34 @@
package metrics
import "testing"
func TestRecentLatencyByKeyTracksBoundedP99PerKey(t *testing.T) {
tracker := NewRecentLatencyByKey(3)
p99, samples := tracker.Observe("a", 10)
if p99 != 10 || samples != 1 {
t.Fatalf("first p99=%v samples=%d", p99, samples)
}
tracker.Observe("a", 20)
p99, samples = tracker.Observe("a", 30)
if p99 != 30 || samples != 3 {
t.Fatalf("filled p99=%v samples=%d", p99, samples)
}
p99, samples = tracker.Observe("a", 5)
if p99 != 30 || samples != 3 {
t.Fatalf("bounded p99=%v samples=%d", p99, samples)
}
p99, samples = tracker.Observe("b", 7)
if p99 != 7 || samples != 1 {
t.Fatalf("second key p99=%v samples=%d", p99, samples)
}
}
func TestRecentLatencyByKeySanitizesBadValues(t *testing.T) {
tracker := NewRecentLatencyByKey(2)
p99, samples := tracker.Observe("", -10)
if p99 != 0 || samples != 1 {
t.Fatalf("p99=%v samples=%d", p99, samples)
}
}

View File

@@ -3,11 +3,28 @@ package observability
import (
"log/slog"
"os"
"strings"
)
// NewLogger returns a JSON logger with a stable service field so ECS logs from
// different Go processes can be filtered without relying on container names.
func NewLogger(service string) *slog.Logger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: parseLogLevel(os.Getenv("LOG_LEVEL")),
})
return slog.New(handler).With("service", service)
}
func parseLogLevel(value string) slog.Level {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,27 @@
package observability
import (
"log/slog"
"testing"
)
func TestParseLogLevel(t *testing.T) {
tests := []struct {
name string
value string
want slog.Level
}{
{name: "empty defaults to info", value: "", want: slog.LevelInfo},
{name: "unknown defaults to info", value: "trace", want: slog.LevelInfo},
{name: "debug", value: "debug", want: slog.LevelDebug},
{name: "warn alias", value: "warning", want: slog.LevelWarn},
{name: "error trims and lowercases", value: " ERROR ", want: slog.LevelError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseLogLevel(tt.value); got != tt.want {
t.Fatalf("parseLogLevel(%q) = %v, want %v", tt.value, got, tt.want)
}
})
}
}

View File

@@ -124,6 +124,28 @@ func TestAutoResponseEchoesRawVINAndOriginalTime(t *testing.T) {
}
}
func TestAutoResponseRejectsEnforcedInvalidPlatformLogin(t *testing.T) {
body := []byte{0x1a, 0x07, 0x02, 0x00, 0x26, 0x0f, 0x00, 0x01}
body = append(body, fixedASCII("platform-a", 12)...)
body = append(body, fixedASCII("wrong-password", 20)...)
body = append(body, 0x01)
request := buildFrame(0x05, 0xfe, "12345678901234567", body)
env, err := ParseFrame(request, 1782914969584, "127.0.0.1:32960")
if err != nil {
t.Fatal(err)
}
env.AuthenticationEnforced = true
env.AuthenticationStatus = "rejected"
response, ok, err := AutoResponse(request, env)
if err != nil || !ok {
t.Fatalf("AutoResponse() ok=%v err=%v", ok, err)
}
if response[3] != responseError {
t.Fatalf("response flag = 0x%02x, want 0x%02x", response[3], responseError)
}
}
func TestParseFrameExtractsRealtimeVehicleMileageAndPosition(t *testing.T) {
body := []byte{0x1a, 0x06, 0x1e, 0x16, 0x17, 0x39}
body = append(body, 0x01)

View File

@@ -12,6 +12,7 @@ var ErrResponseFrameTooShort = errors.New("gb32960 response frame too short")
const (
responseSuccess = byte(0x01)
responseError = byte(0x02)
responseCommand = byte(0xfe)
encryptNone = byte(0x01)
)
@@ -37,7 +38,11 @@ func AutoResponse(raw []byte, env envelope.FrameEnvelope) ([]byte, bool, error)
if timestamp := responseTime(raw, command); !timestamp.IsZero() {
body = encodeGBTime(timestamp)
}
return buildResponse(raw[0], command, responseSuccess, raw[4:21], body), true, nil
responseFlag := responseSuccess
if command == 0x05 && env.AuthenticationEnforced && env.AuthenticationStatus != "accepted" {
responseFlag = responseError
}
return buildResponse(raw[0], command, responseFlag, raw[4:21], body), true, nil
}
func shouldRespond(command byte) bool {

View File

@@ -416,6 +416,32 @@ func TestAutoResponderBuildsVersionedGeneralAck(t *testing.T) {
}
}
func TestAutoResponderRejectsEnforcedInvalidAuthentication(t *testing.T) {
request := buildFrame(0x0102, "064646848757", 13, []byte("wrong-code"))
env, err := ParseFrame(request, 1782918600000, "127.0.0.1:808")
if err != nil {
t.Fatal(err)
}
env.AuthenticationEnforced = true
env.AuthenticationStatus = "rejected"
response, ok, err := NewAutoResponder("issued-code").Respond(request, env)
if err != nil || !ok {
t.Fatalf("Respond() ok=%v err=%v", ok, err)
}
frames, remainder, err := ExtractFrames(response)
if err != nil || len(frames) != 1 || len(remainder) != 0 {
t.Fatalf("ExtractFrames() frames=%d remainder=%x err=%v", len(frames), remainder, err)
}
payload := frames[0]
if got := binary.BigEndian.Uint16(payload[0:2]); got != msgPlatformGeneralResponse {
t.Fatalf("message id = 0x%04x", got)
}
if result := payload[len(payload)-2]; result != 1 {
t.Fatalf("authentication response result = %d, want 1", result)
}
}
func TestExtractFramesHandlesEscapedPayload(t *testing.T) {
payload := []byte{0x02, 0x00, 0x00, 0x02, 0x01, 0x33, 0x07, 0x79, 0x54, 0x25, 0x00, 0x01, 0x7e, 0x7d}
frame := append([]byte{0x7e}, escape(append(payload, checksum(payload)))...)

View File

@@ -49,6 +49,9 @@ func (r AutoResponder) Respond(raw []byte, env envelope.FrameEnvelope) ([]byte,
binary.BigEndian.PutUint16(body[0:2], header.sequence)
binary.BigEndian.PutUint16(body[2:4], header.messageID)
body[4] = 0
if header.messageID == 0x0102 && env.AuthenticationEnforced && env.AuthenticationStatus != "accepted" {
body[4] = 1
}
return encodeResponse(msgPlatformGeneralResponse, header, body), true, nil
}

View File

@@ -52,6 +52,9 @@ func ParseMessage(endpoint string, topic string, payload []byte, receivedAtMS in
DeviceID: deviceID,
Plate: plate,
SourceEndpoint: "mqtt://" + endpoint + topic,
SourceCode: sourceCodeFromEndpoint(endpoint),
PlatformName: platformNameFromEndpoint(endpoint),
SourceKind: "PLATFORM",
EventTimeMS: eventTimeMS,
ReceivedAtMS: receivedAtMS,
RawText: string(payload),
@@ -63,6 +66,32 @@ func ParseMessage(endpoint string, topic string, payload []byte, receivedAtMS in
return env, nil
}
func platformNameFromEndpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if strings.EqualFold(endpoint, "yutong") {
return "宇通"
}
return endpoint
}
func sourceCodeFromEndpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
var builder strings.Builder
for _, r := range endpoint {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_' || r == '-' || r == '.':
builder.WriteRune(r)
}
}
return builder.String()
}
func fieldsFromData(data map[string]any) map[string]any {
fields := map[string]any{}
if speed, ok := firstFloat(data, "METER_SPEED", "speed", "speed_kmh"); ok {

View File

@@ -47,6 +47,20 @@ func TestParseMessageMapsYutongPayloadToEnvelope(t *testing.T) {
if env.RawText == "" || env.Parsed["data"] == nil {
t.Fatalf("raw/parsed missing: %#v", env)
}
if env.SourceCode != "endpoint-a" || env.PlatformName != "endpoint-a" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
}
func TestParseMessageUsesYutongEndpointDisplayName(t *testing.T) {
payload := []byte(`{"device":"LMRKH9AC3R1004101","time":"20260413100000","data":{"TOTAL_MILEAGE":56905000}}`)
env, err := ParseMessage("yutong", "/ytforward/shln/1", payload, 1782745114999)
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if env.SourceCode != "yutong" || env.PlatformName != "宇通" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
}
func TestParseMessageUsesDeviceAsVehicleKeyWhenNotVIN(t *testing.T) {

View File

@@ -8,6 +8,7 @@ import (
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
type RealtimeKVField struct {
@@ -26,32 +27,36 @@ func BuildFieldsEnvelope(env envelope.FrameEnvelope) (envelope.FrameEnvelope, bo
if !envelope.IsRealtimeTelemetryFrame(env) {
return envelope.FrameEnvelope{}, false
}
fields, fieldTypes, ok := ParsedFieldsForEnvelope(env)
fields, _, ok := ParsedFieldsForEnvelope(env)
if !ok {
return envelope.FrameEnvelope{}, false
}
filterInvalidRealtimeMeasurementFields(fields)
if len(fields) == 0 || !telemetry.HasRealtimeFields(env.Protocol, fields) {
return envelope.FrameEnvelope{}, false
}
out := envelope.FrameEnvelope{
EventID: env.StableEventID() + ":fields",
TraceID: env.TraceID,
Protocol: env.Protocol,
MessageID: env.MessageID,
Sequence: env.Sequence,
VIN: env.VIN,
VehicleKeyHint: env.VehicleKeyHint,
Phone: env.Phone,
DeviceID: env.DeviceID,
Plate: env.Plate,
SourceEndpoint: env.SourceEndpoint,
EventTimeMS: env.EventTimeMS,
ReceivedAtMS: env.ReceivedAtMS,
Fields: fields,
ParsedFields: fields,
ParsedFieldTypes: fieldTypes,
Parsed: map[string]any{
"source_event_id": env.StableEventID(),
"field_mapping": realtimeFieldMappingVersion,
},
ParseStatus: envelope.ParseOK,
EventID: env.StableEventID() + ":fields",
TraceID: env.TraceID,
EventKind: envelope.EventKindFields,
SourceEventID: env.StableEventID(),
FieldMapping: realtimeFieldMappingVersion,
Protocol: env.Protocol,
MessageID: env.MessageID,
Sequence: env.Sequence,
VIN: env.VIN,
VehicleKeyHint: env.VehicleKeyHint,
Phone: env.Phone,
DeviceID: env.DeviceID,
Plate: env.Plate,
SourceEndpoint: env.SourceEndpoint,
SourceCode: env.SourceCode,
PlatformName: env.PlatformName,
SourceKind: env.SourceKind,
EventTimeMS: env.EventTimeMS,
ReceivedAtMS: env.ReceivedAtMS,
Fields: fields,
ParseStatus: envelope.ParseOK,
}
return out, true
}
@@ -61,12 +66,34 @@ func EnsureParsedFields(env *envelope.FrameEnvelope) bool {
return false
}
if len(env.ParsedFields) > 0 {
if derived, derivedTypes, ok := computeParsedFieldsFromParsed(*env); ok {
for field, value := range derived {
if _, exists := env.ParsedFields[field]; !exists {
env.ParsedFields[field] = value
}
}
if env.ParsedFieldTypes == nil {
env.ParsedFieldTypes = map[string]string{}
}
for field, valueType := range derivedTypes {
if _, exists := env.ParsedFieldTypes[field]; !exists {
env.ParsedFieldTypes[field] = valueType
}
}
}
inferredTypes := inferParsedFieldTypes(env.ParsedFields)
if env.ParsedFieldTypes == nil {
env.ParsedFieldTypes = inferParsedFieldTypes(env.ParsedFields)
env.ParsedFieldTypes = inferredTypes
} else {
for field, valueType := range inferredTypes {
if _, exists := env.ParsedFieldTypes[field]; !exists {
env.ParsedFieldTypes[field] = valueType
}
}
}
return true
}
fields, fieldTypes, ok := ParsedFieldsForEnvelope(*env)
fields, fieldTypes, ok := ComputeParsedFieldsForEnvelope(*env)
if !ok {
return false
}
@@ -76,14 +103,27 @@ func EnsureParsedFields(env *envelope.FrameEnvelope) bool {
}
func ParsedFieldsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[string]string, bool) {
if len(env.ParsedFields) > 0 {
fields := cloneAnyMap(env.ParsedFields)
fieldTypes := cloneStringMap(env.ParsedFieldTypes)
if len(fieldTypes) == 0 {
fieldTypes = inferParsedFieldTypes(fields)
}
if len(env.ParsedFields) == 0 {
return nil, nil, false
}
fields := cloneAnyMap(env.ParsedFields)
fieldTypes := cloneStringMap(env.ParsedFieldTypes)
if len(fieldTypes) == 0 {
fieldTypes = inferParsedFieldTypes(fields)
}
return fields, fieldTypes, true
}
// ComputeParsedFieldsForEnvelope is reserved for ingress and offline backfills.
// Runtime projections must consume the precomputed ParsedFields contract instead.
func ComputeParsedFieldsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[string]string, bool) {
if fields, fieldTypes, ok := ParsedFieldsForEnvelope(env); ok {
return fields, fieldTypes, true
}
return computeParsedFieldsFromParsed(env)
}
func computeParsedFieldsFromParsed(env envelope.FrameEnvelope) (map[string]any, map[string]string, bool) {
rows := realtimeKVFields(env, env.Parsed)
if len(rows) == 0 {
return nil, nil, false
@@ -119,6 +159,22 @@ func inferParsedFieldTypes(fields map[string]any) map[string]string {
return out
}
func filterInvalidRealtimeMeasurementFields(fields map[string]any) {
for field, value := range fields {
if isRealtimeTotalMileageField(field) && !positiveNumber(value) {
delete(fields, field)
}
}
}
func isRealtimeTotalMileageField(field string) bool {
field = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(field), "/", "."))
return field == envelope.FieldTotalMileageKM ||
field == "total_mileage" ||
strings.HasSuffix(field, "."+envelope.FieldTotalMileageKM) ||
strings.HasSuffix(field, ".total_mileage")
}
func cloneAnyMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
@@ -191,8 +247,9 @@ const realtimeFieldMappingVersion = "2026-07-03.v1"
func realtimeKVFields(env envelope.FrameEnvelope, parsed map[string]any) []RealtimeKVField {
vin := strings.TrimSpace(env.VIN)
if vin == "" {
return nil
if env.Protocol == envelope.ProtocolGB32960 && len(parsed) > 0 {
parsed = cloneMap(parsed)
normalizeGB32960RealtimeParsed(parsed)
}
eventID := env.StableEventID()
mapping := realtimeMapping(env.Protocol)
@@ -205,6 +262,9 @@ func realtimeKVFields(env envelope.FrameEnvelope, parsed map[string]any) []Realt
}
sort.Strings(names)
for _, name := range names {
if isRealtimeTotalMileageField(realtimeKVFieldPath(domain, name)) && !positiveNumber(flat[name]) {
continue
}
value, valueType, ok := stringifyKVValue(flat[name])
if !ok {
continue
@@ -255,6 +315,9 @@ func gb32960KVFields(env envelope.FrameEnvelope, parsed map[string]any, mapping
}
sort.Strings(names)
for _, name := range names {
if isRealtimeTotalMileageField(realtimeKVFieldPath(domain, name)) && !positiveNumber(flat[name]) {
continue
}
value, valueType, ok := stringifyKVValue(flat[name])
if !ok {
continue
@@ -335,6 +398,12 @@ func flattenKV(prefix string, value any, out map[string]any, mapping protocolFie
}
flattenKV(next, typed[key], out, mapping)
}
case map[string]bool:
normalized := make(map[string]any, len(typed))
for key, item := range typed {
normalized[key] = item
}
flattenKV(prefix, normalized, out, mapping)
case []any:
for index, item := range typed {
itemMap, ok := item.(map[string]any)
@@ -397,8 +466,14 @@ func mappedTopLevelDomain(mapping protocolFieldMapping, key string) string {
if key == "" {
return ""
}
name := mapping.TopLevelName[key]
if name == "" {
name := ""
if len(mapping.TopLevelName) > 0 {
var ok bool
name, ok = mapping.TopLevelName[key]
if !ok {
return ""
}
} else {
name = sanitizeFieldPart(key)
}
if name == "" {

View File

@@ -1,6 +1,8 @@
package realtime
import (
"encoding/json"
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
@@ -40,7 +42,7 @@ func TestRealtimeKVFieldsFromGB32960ParsedDomains(t *testing.T) {
}
func TestBuildFieldsEnvelopeUsesOnlyProtocolMappedFieldNames(t *testing.T) {
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
VIN: "VIN001",
MessageID: "0x02",
@@ -61,10 +63,17 @@ func TestBuildFieldsEnvelopeUsesOnlyProtocolMappedFieldNames(t *testing.T) {
}},
},
},
})
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
}
if len(fieldsEnv.ParsedFields) != 0 || len(fieldsEnv.ParsedFieldTypes) != 0 || len(fieldsEnv.Parsed) != 0 {
t.Fatalf("fields envelope should keep only slim fields payload, parsed=%#v parsed_fields=%#v parsed_field_types=%#v", fieldsEnv.Parsed, fieldsEnv.ParsedFields, fieldsEnv.ParsedFieldTypes)
}
for _, bareKey := range []string{"charge_status", "soc_percent", "total_mileage_km"} {
if _, exists := fieldsEnv.Fields[bareKey]; exists {
t.Fatalf("fields envelope should not expose non-protocol bare key %q: %#v", bareKey, fieldsEnv.Fields)
@@ -81,6 +90,240 @@ func TestBuildFieldsEnvelopeUsesOnlyProtocolMappedFieldNames(t *testing.T) {
}
}
func TestEnsureParsedFieldsKeepsUnknownVINProtocolFieldsAndExcludesAnnotations(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "13307795425",
Parsed: map[string]any{
"registration": map[string]any{
"manufacturer": "YUTNG",
},
"identity": map[string]any{
"resolved": false,
"reason": "no_binding",
},
},
ParsedFields: map[string]any{
"jt808.location.speed_kmh": "12.3",
},
}
if !EnsureParsedFields(&env) {
t.Fatal("unknown VIN raw frame should still retain parsed fields")
}
if got := env.ParsedFields["jt808.location.speed_kmh"]; got != "12.3" {
t.Fatalf("precomputed field = %#v", got)
}
if got := env.ParsedFields["jt808.registration.manufacturer"]; got != "YUTNG" {
t.Fatalf("merged registration field = %#v", got)
}
if _, exists := env.ParsedFields["jt808.identity.resolved"]; exists {
t.Fatalf("derived identity annotations must not enter protocol fields: %#v", env.ParsedFields)
}
for _, field := range []string{"jt808.location.speed_kmh", "jt808.registration.manufacturer"} {
if env.ParsedFieldTypes[field] == "" {
t.Fatalf("field type missing for %s: %#v", field, env.ParsedFieldTypes)
}
}
}
func TestBuildFieldsEnvelopeCopiesSourceMetadata(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
VIN: "LMRKH9AC3R1004101",
MessageID: "MQTT",
SourceEndpoint: "mqtt://yutong/ytforward/shln/1",
SourceCode: "yutong",
PlatformName: "宇通",
SourceKind: "PLATFORM",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
EventID: "event-yutong",
Fields: map[string]any{
envelope.FieldTotalMileageKM: 56905,
},
Parsed: map[string]any{
"data": map[string]any{"TOTAL_MILEAGE": 56905000},
},
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
}
if fieldsEnv.SourceCode != "yutong" || fieldsEnv.PlatformName != "宇通" || fieldsEnv.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", fieldsEnv.SourceCode, fieldsEnv.PlatformName, fieldsEnv.SourceKind)
}
}
func TestBuildFieldsEnvelopeDropsNonPositiveTotalMileage(t *testing.T) {
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "VIN001",
MessageID: "0x0200",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
EventID: "event-1",
ParsedFields: map[string]any{
"jt808.location.latitude": "31.259555",
"jt808.location.longitude": "119.892413",
"jt808.location.total_mileage_km": "0",
},
})
if !ok {
t.Fatal("BuildFieldsEnvelope() should still emit valid fields")
}
if _, exists := fieldsEnv.Fields["jt808.location.total_mileage_km"]; exists {
t.Fatalf("fields envelope should drop non-positive mileage: %#v", fieldsEnv.Fields)
}
if fieldsEnv.Fields["jt808.location.latitude"] != "31.259555" {
t.Fatalf("fields envelope should keep valid location fields: %#v", fieldsEnv.Fields)
}
}
func TestBuildFieldsEnvelopeDropsNonPositiveRawTotalMileage(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
VIN: "LMRKH9AC3R1004101",
MessageID: "MQTT",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
EventID: "event-yutong-zero-mileage",
Parsed: map[string]any{
"data": map[string]any{
"LATITUDE": 30.590921,
"LONGITUDE": 121.075044,
"TOTAL_MILEAGE": 0,
},
},
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should still emit non-mileage fields")
}
if _, exists := fieldsEnv.Fields["yutong_mqtt.data.total_mileage"]; exists {
t.Fatalf("fields envelope should drop non-positive raw mileage: %#v", fieldsEnv.Fields)
}
if fieldsEnv.Fields["yutong_mqtt.data.latitude"] != "30.590921" {
t.Fatalf("fields envelope should keep valid location fields: %#v", fieldsEnv.Fields)
}
}
func TestBuildFieldsEnvelopeJSONOmitsDuplicateParsedPayload(t *testing.T) {
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "VIN001",
MessageID: "0x0200",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
EventID: "event-1",
ParsedFields: map[string]any{
"jt808.location.total_mileage_km": "10241.2",
"jt808.location.speed_kmh": "23",
},
ParsedFieldTypes: map[string]string{
"jt808.location.total_mileage_km": "number",
"jt808.location.speed_kmh": "number",
},
})
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
}
data, err := json.Marshal(fieldsEnv)
if err != nil {
t.Fatalf("Marshal(fieldsEnv) error = %v", err)
}
text := string(data)
for _, duplicateKey := range []string{`"parsed_fields"`, `"parsed_field_types"`, `"parsed"`} {
if strings.Contains(text, duplicateKey) {
t.Fatalf("fields JSON should omit duplicate %s payload: %s", duplicateKey, text)
}
}
if !strings.Contains(text, `"fields"`) || !strings.Contains(text, `"source_event_id"`) || !strings.Contains(text, `"field_mapping"`) {
t.Fatalf("fields JSON missing slim payload metadata: %s", text)
}
}
func TestBuildFieldsEnvelopeOmitsGB32960VendorFragmentFields(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
VIN: "VIN001",
MessageID: "0x02",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
Parsed: map[string]any{
"data_units": []any{
map[string]any{
"type": "0x30",
"name": "gd_fc_stack",
"value": map[string]any{
"stack_count": 1,
"summaries": []any{
map[string]any{
"cell_count": 432,
"stack_water_outlet_temp_c": 63,
"frame_cell_start": 401,
"frame_cell_count": 32,
"frame_max_cell_voltage_v": 1,
"frame_min_cell_voltage_v": 1,
"hydrogen_inlet_pressure_kpa": 130,
"air_inlet_pressure_kpa": 150,
"stack_water_outlet_temp_extra": "kept",
},
},
},
},
},
},
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
}
for _, fragmentField := range []string{
"gb32960.gd_fc_stack.frame_cell_start",
"gb32960.gd_fc_stack.frame_cell_count",
"gb32960.gd_fc_stack.frame_max_cell_voltage_v",
"gb32960.gd_fc_stack.frame_min_cell_voltage_v",
} {
if _, exists := fieldsEnv.Fields[fragmentField]; exists {
t.Fatalf("fields envelope should omit fragment-only field %q: %#v", fragmentField, fieldsEnv.Fields)
}
}
for _, keptField := range []string{
"gb32960.gd_fc_stack.stack_count",
"gb32960.gd_fc_stack.stack_water_outlet_temp_c",
"gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa",
} {
if _, exists := fieldsEnv.Fields[keptField]; !exists {
t.Fatalf("fields envelope should keep current-state field %q: %#v", keptField, fieldsEnv.Fields)
}
}
}
func TestBuildFieldsEnvelopeRequiresIngressParsedFields(t *testing.T) {
_, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "VIN001",
MessageID: "0x0200",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
Parsed: map[string]any{
"location": map[string]any{"speed_kmh": 99},
},
})
if ok {
t.Fatal("BuildFieldsEnvelope() must not re-flatten parsed payload downstream")
}
}
func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) {
jtRows := realtimeKVFields(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
@@ -97,6 +340,10 @@ func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) {
"latitude": 30.2,
"total_mileage_km": 10241.2,
"additional": []any{map[string]any{"id": "0x01", "value_hex": "00077235"}},
"io_status": map[string]any{
"value": uint16(2),
"bits": map[string]bool{"deep_sleep": false, "sleep": true},
},
},
})
jtValues := kvMap(jtRows)
@@ -105,9 +352,14 @@ func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) {
}
if jtValues["jt808.location/total_mileage_km"] != "10241.2" ||
jtValues["jt808.location/longitude"] != "121.1" ||
jtValues["jt808.location/additional.additional_1.id"] != "0x01" {
jtValues["jt808.location/additional.additional_1.id"] != "0x01" ||
jtValues["jt808.location/io_status.bits.deep_sleep"] != "false" ||
jtValues["jt808.location/io_status.bits.sleep"] != "true" {
t.Fatalf("jt808 location kv missing: %#v", jtValues)
}
if _, exists := jtValues["jt808.location/io_status.bits"]; exists {
t.Fatalf("JT808 bit fields must be flattened instead of embedded JSON: %#v", jtValues)
}
if jtValues["jt808.location/soc_percent"] != "" {
t.Fatalf("jt808 kv should not include standardized env.Fields-only values: %#v", jtValues)
}

View File

@@ -46,6 +46,7 @@ type LocationRow struct {
Longitude float64 `json:"longitude"`
SpeedKMH *float64 `json:"speed_kmh,omitempty"`
TotalMileageKM *float64 `json:"total_mileage_km,omitempty"`
TotalMileageAt string `json:"total_mileage_event_time,omitempty"`
SOCPercent *float64 `json:"soc_percent,omitempty"`
AltitudeM *float64 `json:"altitude_m,omitempty"`
DirectionDeg *float64 `json:"direction_deg,omitempty"`
@@ -124,7 +125,7 @@ func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTable
query = normalizeRealtimeTableQuery(query)
sqlText, args := buildRealtimeSelectSQL(
"vehicle_realtime_location",
"protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at",
"protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, total_mileage_event_time, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at",
query,
)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
@@ -136,7 +137,7 @@ func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTable
out := make([]LocationRow, 0)
for rows.Next() {
var row LocationRow
var eventTime, receivedAt, updatedAt scanSQLDateTime
var eventTime, mileageAt, receivedAt, updatedAt scanSQLDateTime
var speed, mileage, soc, altitude, direction sql.NullFloat64
var alarm, status sql.NullInt64
if err := rows.Scan(
@@ -148,6 +149,7 @@ func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTable
&row.Longitude,
&speed,
&mileage,
&mileageAt,
&soc,
&altitude,
&direction,
@@ -162,6 +164,7 @@ func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTable
row.EventTime = eventTime.String
row.SpeedKMH = nullableFloat(speed)
row.TotalMileageKM = nullableFloat(mileage)
row.TotalMileageAt = mileageAt.String
row.SOCPercent = nullableFloat(soc)
row.AltitudeM = nullableFloat(altitude)
row.DirectionDeg = nullableFloat(direction)

View File

@@ -61,14 +61,14 @@ func TestLocationQueryHandlerReturnsRealtimeLocations(t *testing.T) {
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_realtime_location").
WithArgs("JT808", "粤B98765").
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at FROM vehicle_realtime_location").
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, total_mileage_event_time, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at FROM vehicle_realtime_location").
WithArgs("JT808", "粤B98765", 10, 10).
WillReturnRows(sqlmock.NewRows([]string{
"protocol", "vin", "plate", "event_time", "latitude", "longitude", "speed_kmh", "total_mileage_km",
"soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "updated_at",
"total_mileage_event_time", "soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "updated_at",
}).AddRow(
"JT808", "LKLG7C4E3NA774736", "粤B98765", "2026-07-02 16:11:02.000", 30.123456, 120.654321, 54.3, 48798.9,
nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:04",
"2026-07-02 16:11:02.000", nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:04",
))
handler := NewLocationQueryHandler(NewLocationQueryRepository(db))
@@ -81,7 +81,7 @@ func TestLocationQueryHandlerReturnsRealtimeLocations(t *testing.T) {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"protocol":"JT808"`, `"latitude":30.123456`, `"longitude":120.654321`, `"total_mileage_km":48798.9`, `"offset":10`} {
for _, want := range []string{`"protocol":"JT808"`, `"latitude":30.123456`, `"longitude":120.654321`, `"total_mileage_km":48798.9`, `"total_mileage_event_time":"2026-07-02 16:11:02.000"`, `"offset":10`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
@@ -133,14 +133,14 @@ func TestLocationQueryHandlerSkipsTotalCountByDefault(t *testing.T) {
}
defer db.Close()
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at FROM vehicle_realtime_location").
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, total_mileage_event_time, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at FROM vehicle_realtime_location").
WithArgs("JT808", 1, 0).
WillReturnRows(sqlmock.NewRows([]string{
"protocol", "vin", "plate", "event_time", "latitude", "longitude", "speed_kmh", "total_mileage_km",
"soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "updated_at",
"total_mileage_event_time", "soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "updated_at",
}).AddRow(
"JT808", "LKLG7C4E3NA774736", "粤B98765", "2026-07-02 16:11:02.000", 30.123456, 120.654321, 54.3, 48798.9,
nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:04",
"2026-07-02 16:11:02.000", nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:04",
))
handler := NewLocationQueryHandler(NewLocationQueryRepository(db))

View File

@@ -79,11 +79,358 @@ func realtimeOpenAPISpec() map[string]any {
},
},
},
"/api/stats/daily-metrics": map[string]any{
"get": map[string]any{
"summary": "查询车辆每日里程",
"description": "从 vehicle_daily_mileage 查询最终选举后的每日里程,并通过 source_id 关联 vehicle_data_source 返回来源证据。默认不执行 COUNT(*)total 默认表示本页返回条数;需要精确总数时传 includeTotal=true。",
"tags": []string{"Stats API"},
"x-table": "vehicle_daily_mileage,vehicle_data_source",
"parameters": dailyMetricParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/sources": map[string]any{
"get": map[string]any{
"summary": "查询每日里程候选来源",
"description": "从 vehicle_daily_mileage_source 查询每辆车、每天、每个协议下各来源独立计算出的里程候选,并关联 vehicle_data_source 返回平台、来源类型和可信优先级,用于解释最终日里程为什么选中某个来源。",
"tags": []string{"Stats API"},
"x-table": "vehicle_daily_mileage_source,vehicle_data_source",
"parameters": dailyMetricSourceParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric source page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricSourcePage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/sources/quality": map[string]any{
"get": map[string]any{
"summary": "汇总每日里程候选质量",
"description": "按日期、协议、quality_status、quality_reason 汇总 vehicle_daily_mileage_source用于发现某协议或某来源类型是否正在批量产生无基线、异常跳变等候选里程质量问题。",
"tags": []string{"Stats API"},
"x-table": "vehicle_daily_mileage_source",
"parameters": dailyMetricSourceParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric source quality summary page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricSourceQualityPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/sources/selection": map[string]any{
"get": map[string]any{
"summary": "汇总每日里程来源选举",
"description": "按日期、协议、selection_status、selection_reason 汇总 vehicle_daily_mileage_source并结合 vehicle_data_source 解释各来源被选中、质量淘汰、禁用淘汰或低优先级未选中的原因。",
"tags": []string{"Stats API"},
"x-table": "vehicle_daily_mileage_source,vehicle_data_source",
"parameters": dailyMetricSourceParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric source selection summary page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricSourceSelectionPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/diagnostics": map[string]any{
"get": map[string]any{
"summary": "诊断实时在线但日里程缺失",
"description": "从 vehicle_realtime_snapshot/location 按 event_time/received_at 找到指定日期活跃车辆,并关联 vehicle_daily_mileage 与 vehicle_daily_mileage_source解释车辆有实时数据但日里程缺失的原因。默认不执行 COUNT(*)total 默认表示本页返回条数;需要精确总数时传 includeTotal=true。",
"tags": []string{"Stats API"},
"x-table": "vehicle_realtime_snapshot,vehicle_realtime_location,vehicle_daily_mileage,vehicle_daily_mileage_source",
"parameters": dailyMetricDiagnosisParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric diagnostics page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/diagnostics/summary": map[string]any{
"get": map[string]any{
"summary": "汇总每日里程诊断",
"description": "按协议汇总指定日期 event_time/received_at 活跃车辆的日里程诊断结果,返回 OK、MISSING_DAILY、NO_SOURCE_SAMPLE、NO_TOTAL_MILEAGE 等分类数量,用于大屏、告警和排障入口。",
"tags": []string{"Stats API"},
"x-table": "vehicle_realtime_snapshot,vehicle_realtime_location,vehicle_daily_mileage,vehicle_daily_mileage_source",
"parameters": dailyMetricDiagnosisSummaryParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric diagnostics summary",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisSummaryPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/diagnostics/reasons": map[string]any{
"get": map[string]any{
"summary": "按原因汇总每日里程诊断",
"description": "按协议、诊断分类和 reason 汇总指定日期 event_time/received_at 活跃车辆的日里程诊断结果,用于大屏和告警直接区分源头未上报、总里程为 0、旧总里程未更新、统计抽取缺失等问题。",
"tags": []string{"Stats API"},
"x-table": "vehicle_realtime_snapshot,vehicle_realtime_location,vehicle_daily_mileage,vehicle_daily_mileage_source",
"parameters": dailyMetricDiagnosisReasonSummaryParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric diagnostics reason summary",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisReasonSummaryPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/diagnostics/field-status": map[string]any{
"get": map[string]any{
"summary": "按字段状态汇总每日里程诊断",
"description": "按协议、诊断分类、reason 和实时总里程字段状态汇总,直接区分源头没有可用总里程、疑似字段未映射、标准总里程为 0、标准总里程停留在旧日期、统计消费缺失等问题。",
"tags": []string{"Stats API"},
"x-table": "vehicle_realtime_snapshot,vehicle_realtime_location,vehicle_daily_mileage,vehicle_daily_mileage_source",
"parameters": dailyMetricDiagnosisReasonSummaryParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric diagnostics field status summary",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisFieldStatusSummaryPage"},
},
},
},
},
},
},
"/api/stats/data-sources": map[string]any{
"get": map[string]any{
"summary": "查询数据来源",
"description": "查询 vehicle_data_source 中自动发现的协议来源用于人工维护平台名称、source_code、可信优先级和启停状态。",
"tags": []string{"Data Source API"},
"x-table": "vehicle_data_source",
"parameters": []map[string]any{
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceCode", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceKind", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"UNKNOWN", "PLATFORM", "DIRECT"}}, "required": false},
{"name": "sourceCodeMissing", "in": "query", "schema": map[string]any{"type": "boolean"}, "required": false},
{"name": "enabled", "in": "query", "schema": map[string]any{"type": "boolean"}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Data source page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DataSourcePage"},
},
},
},
},
},
},
"/api/stats/data-sources/diagnostics": map[string]any{
"get": map[string]any{
"summary": "诊断 JT808 来源映射",
"description": "按来源 IP 汇总 jt808_registration 与 vehicle_identifier 的匹配情况,帮助判断缺失 source_code 的 808 来源应维护到哪个平台。",
"tags": []string{"Data Source API"},
"x-table": "vehicle_data_source,jt808_registration,vehicle_identifier",
"parameters": []map[string]any{
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"JT808"}, "default": "JT808"}, "required": false},
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceCodeMissing", "in": "query", "schema": map[string]any{"type": "boolean", "default": true}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Data source diagnostics page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DataSourceDiagnosticsPage"},
},
},
},
},
},
},
"/api/stats/data-sources/kind-suggestions": map[string]any{
"get": map[string]any{
"summary": "建议 JT808 来源类型",
"description": "基于来源活跃时长、注册手机号、vehicle_identifier 匹配和 source_code 情况,对 UNKNOWN 来源给出 PLATFORM/DIRECT/UNKNOWN 的只读建议。",
"tags": []string{"Data Source API"},
"x-table": "vehicle_data_source,jt808_registration,vehicle_identifier",
"parameters": []map[string]any{
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"JT808"}, "default": "JT808"}, "required": false},
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceKind", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"UNKNOWN", "PLATFORM", "DIRECT"}, "default": "UNKNOWN"}, "required": false},
{"name": "sourceCodeMissing", "in": "query", "schema": map[string]any{"type": "boolean"}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Data source kind suggestion page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DataSourceDiagnosticsPage"},
},
},
},
},
},
},
"/api/stats/data-sources/jt808-identity-gaps": map[string]any{
"get": map[string]any{
"summary": "诊断 JT808 未绑定设备",
"description": "列出 jt808_registration 中仍无法通过 phone/plate 关联 VIN 的设备,用于定位 0x0200 no_binding 和补充 vehicle_identifier 映射。",
"tags": []string{"Data Source API"},
"x-table": "jt808_registration,vehicle_identifier,vehicle_identity_binding,vehicle_data_source",
"parameters": []map[string]any{
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceCode", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "recentSeconds", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 2592000, "default": 86400}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "JT808 identity gap page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/JT808IdentityGapPage"},
},
},
},
},
},
},
"/api/stats/data-sources/jt808-mapping-gaps": map[string]any{
"get": map[string]any{
"summary": "诊断 JT808 来源映射缺口",
"description": "列出已配置来源平台但缺少当前 source_code 下 JT808_PHONE 到 VIN 映射的注册手机号,用于维护 vehicle_identifier 并提升 VIN 解析、在线统计和里程来源选举覆盖率。",
"tags": []string{"Data Source API"},
"x-table": "jt808_registration,vehicle_identifier,vehicle_data_source",
"parameters": []map[string]any{
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceCode", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "recentSeconds", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 2592000, "default": 86400}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "JT808 mapping gap page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/JT808MappingGapPage"},
},
},
},
},
},
},
"/api/stats/data-sources/{id}": map[string]any{
"patch": map[string]any{
"summary": "维护数据来源人工字段",
"description": "只允许更新 platform_name、source_code、source_kind、trust_priority、enabled、remarksource_ip、latest_seen_at 等运行态字段由接入链路自动维护。",
"tags": []string{"Data Source API"},
"parameters": []map[string]any{
{"name": "id", "in": "path", "schema": map[string]any{"type": "integer"}, "required": true},
},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DataSourceUpdate"},
},
},
},
"responses": map[string]any{
"200": map[string]any{"description": "Updated"},
},
},
},
},
"components": map[string]any{
"schemas": map[string]any{
"SnapshotPage": pageSchema("#/components/schemas/SnapshotRow"),
"LocationPage": pageSchema("#/components/schemas/LocationRow"),
"SnapshotPage": pageSchema("#/components/schemas/SnapshotRow"),
"LocationPage": pageSchema("#/components/schemas/LocationRow"),
"DailyMetricPage": pageSchema("#/components/schemas/DailyMetricRow"),
"DailyMetricSourcePage": pageSchema("#/components/schemas/DailyMetricSourceRow"),
"DailyMetricSourceQualityPage": pageSchema("#/components/schemas/DailyMetricSourceQualityRow"),
"DailyMetricSourceSelectionPage": pageSchema("#/components/schemas/DailyMetricSourceSelectionRow"),
"DailyMetricDiagnosisPage": pageSchema("#/components/schemas/DailyMetricDiagnosisRow"),
"DailyMetricDiagnosisSummaryPage": map[string]any{
"type": "object",
"properties": map[string]any{
"items": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisSummaryRow"}},
"total": integerSchema(3),
"active_total": integerSchema(256),
"actionable_issue_total": integerSchema(6),
},
},
"DailyMetricDiagnosisReasonSummaryPage": map[string]any{
"type": "object",
"properties": map[string]any{
"items": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisReasonSummaryRow"}},
"total": integerSchema(5),
"vehicle_total": integerSchema(347),
"actionable_issue_total": integerSchema(9),
"pipeline_issue_total": integerSchema(0),
"source_data_issue_total": integerSchema(9),
},
},
"DailyMetricDiagnosisFieldStatusSummaryPage": map[string]any{
"type": "object",
"properties": map[string]any{
"items": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisFieldStatusSummaryRow"}},
"total": integerSchema(7),
"vehicle_total": integerSchema(347),
"actionable_issue_total": integerSchema(9),
"pipeline_issue_total": integerSchema(2),
"source_data_issue_total": integerSchema(7),
},
},
"DataSourcePage": pageSchema("#/components/schemas/DataSourceRow"),
"DataSourceDiagnosticsPage": pageSchema("#/components/schemas/DataSourceDiagnosticRow"),
"JT808IdentityGapPage": pageSchema("#/components/schemas/JT808IdentityGapRow"),
"JT808MappingGapPage": pageSchema("#/components/schemas/JT808MappingGapRow"),
"SnapshotRow": map[string]any{
"type": "object",
"properties": map[string]any{
@@ -99,22 +446,291 @@ func realtimeOpenAPISpec() map[string]any {
"LocationRow": map[string]any{
"type": "object",
"properties": map[string]any{
"protocol": stringSchema("JT808"),
"vin": stringSchema("LKLG7C4E3NA774736"),
"plate": stringSchema("粤B98765"),
"event_time": stringSchema("2026-07-02 16:11:02.000"),
"latitude": numberSchema(30.123456),
"longitude": numberSchema(120.654321),
"speed_kmh": numberSchema(54.3),
"total_mileage_km": numberSchema(48798.9),
"total_mileage_event_time": stringSchema("2026-07-02 16:11:02.000"),
"soc_percent": numberSchema(81.5),
"altitude_m": numberSchema(19.0),
"direction_deg": numberSchema(88.0),
"alarm_flag": integerSchema(0),
"status_flag": integerSchema(3),
"received_at": stringSchema("2026-07-02 16:11:03.000"),
"event_id": stringSchema("event id"),
"updated_at": stringSchema("2026-07-02 16:11:04"),
},
},
"DailyMetricRow": map[string]any{
"type": "object",
"properties": map[string]any{
"vin": stringSchema("LA9GG64L7PBAF4001"),
"stat_date": stringSchema("2026-07-08"),
"protocol": stringSchema("JT808"),
"source_id": integerSchema(3),
"source_ip": stringSchema("115.231.168.135"),
"latest_source_endpoint": stringSchema("115.231.168.135:41561"),
"platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"),
"daily_mileage_km": numberSchema(23.1),
"latest_total_mileage_km": numberSchema(4123.9),
"updated_at": stringSchema("2026-07-08 13:30:57"),
},
},
"DailyMetricSourceRow": map[string]any{
"type": "object",
"properties": map[string]any{
"vin": stringSchema("LA9GG64L7PBAF4001"),
"stat_date": stringSchema("2026-07-08"),
"protocol": stringSchema("JT808"),
"source_key": stringSchema("JT808:13307795425@115.231.168.135"),
"source_ip": stringSchema("115.231.168.135"),
"source_endpoint": stringSchema("115.231.168.135:41561"),
"phone": stringSchema("13307795425"),
"platform_name": stringSchema("信达"),
"source_id": integerSchema(5),
"source_code": stringSchema("xinda"),
"source_kind": stringSchema("PLATFORM"),
"source_enabled": map[string]any{"type": "boolean", "example": true},
"trust_priority": integerSchema(10),
"first_total_mileage_km": numberSchema(4100.8),
"latest_total_mileage_km": numberSchema(4123.9),
"daily_mileage_km": numberSchema(23.1),
"sample_count": integerSchema(128),
"first_event_time": stringSchema("2026-07-08 00:01:00"),
"latest_event_time": stringSchema("2026-07-08 23:59:00"),
"quality_status": stringSchema("OK"),
"quality_reason": stringSchema("historical_source_baseline"),
"is_selected": map[string]any{"type": "boolean", "example": true},
"selection_status": stringSchema("selected"),
"selection_reason": stringSchema("selected_current_projection"),
"selection_action": stringSchema("当前来源已被投影到最终日里程表"),
"updated_at": stringSchema("2026-07-08 23:59:10"),
},
},
"DailyMetricSourceQualityRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("JT808"),
"vin": stringSchema("LKLG7C4E3NA774736"),
"plate": stringSchema("粤B98765"),
"event_time": stringSchema("2026-07-02 16:11:02.000"),
"latitude": numberSchema(30.123456),
"longitude": numberSchema(120.654321),
"speed_kmh": numberSchema(54.3),
"total_mileage_km": numberSchema(48798.9),
"soc_percent": numberSchema(81.5),
"altitude_m": numberSchema(19.0),
"direction_deg": numberSchema(88.0),
"alarm_flag": integerSchema(0),
"status_flag": integerSchema(3),
"received_at": stringSchema("2026-07-02 16:11:03.000"),
"event_id": stringSchema("event id"),
"updated_at": stringSchema("2026-07-02 16:11:04"),
"quality_status": stringSchema("INVALID_DELTA"),
"quality_reason": stringSchema("outside_daily_range"),
"source_count": integerSchema(3),
"vehicle_count": integerSchema(3),
"selected_count": integerSchema(0),
"sample_count": integerSchema(128),
"daily_mileage_km": numberSchema(12435.2),
},
},
"DailyMetricSourceSelectionRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("JT808"),
"selection_status": stringSchema("not_selected"),
"selection_reason": stringSchema("lower_trust_priority_or_sample_count"),
"selection_action": stringSchema("来源质量可用,但被更高可信优先级、更多样本或更新时间更新的来源覆盖"),
"source_count": integerSchema(36),
"vehicle_count": integerSchema(31),
"selected_count": integerSchema(0),
"sample_count": integerSchema(3200),
"daily_mileage_km": numberSchema(812.5),
},
},
"DailyMetricDiagnosisRow": map[string]any{
"type": "object",
"properties": map[string]any{
"vin": stringSchema("LA9GG64L7PBAF4001"),
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("JT808"),
"plate": stringSchema("粤A12345"),
"platform_name": stringSchema("信达"),
"peer": stringSchema("115.231.168.135:47849"),
"snapshot_event_time": stringSchema("2026-07-12 10:01:02.000"),
"location_event_time": stringSchema("2026-07-12 10:01:02.000"),
"snapshot_updated_at": stringSchema("2026-07-12 10:01:03"),
"location_updated_at": stringSchema("2026-07-12 10:01:03"),
"realtime_total_mileage_km": numberSchema(25246.6),
"realtime_total_mileage_event_time": stringSchema("2026-07-12 10:01:02.000"),
"daily_mileage_km": numberSchema(0.3),
"daily_latest_total_mileage_km": numberSchema(25246.6),
"source_sample_count": integerSchema(36),
"ok_source_count": integerSchema(1),
"selectable_source_count": integerSchema(1),
"latest_stat_event_time": stringSchema("2026-07-12 10:01:02"),
"quality_statuses": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"OK"}},
"realtime_field_count": integerSchema(32),
"realtime_sample_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"yutong_mqtt.data.latitude", "yutong_mqtt.data.longitude", "yutong_mqtt.data.meter_speed"}},
"realtime_mileage_field_status": map[string]any{"type": "string", "enum": dailyMetricMileageFieldStatusEnum(), "example": "no_candidate_mileage_field"},
"realtime_mileage_candidate_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"yutong_mqtt.data.odometer"}},
"realtime_mileage_evidence": stringSchema("实时快照已有字段,但没有发现 mileage/odometer/odo 等疑似总里程字段"),
"diagnosis": stringSchema("OK"),
"reason": stringSchema("daily_metric_exists"),
"severity": stringSchema("ok"),
},
},
"DailyMetricDiagnosisSummaryRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("JT808"),
"active_count": integerSchema(244),
"ok_count": integerSchema(244),
"missing_daily_count": integerSchema(0),
"no_source_sample_count": integerSchema(0),
"no_total_mileage_count": integerSchema(0),
"actionable_issue_count": integerSchema(0),
},
},
"DailyMetricDiagnosisReasonSummaryRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("YUTONG_MQTT"),
"diagnosis": stringSchema("NO_TOTAL_MILEAGE"),
"reason": stringSchema("realtime_total_mileage_not_reported_on_stat_date"),
"count": integerSchema(7),
"severity": stringSchema("source_data"),
},
},
"DailyMetricDiagnosisFieldStatusSummaryRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("YUTONG_MQTT"),
"diagnosis": stringSchema("NO_TOTAL_MILEAGE"),
"reason": stringSchema("realtime_total_mileage_missing"),
"realtime_mileage_field_status": map[string]any{"type": "string", "enum": dailyMetricMileageFieldStatusEnum(), "example": "no_candidate_mileage_field"},
"count": integerSchema(7),
"severity": stringSchema("source_data"),
"field_status_severity": stringSchema("source_data"),
"recommended_operator_action": stringSchema("当日实时数据缺少总里程字段,核对平台是否上报该字段以及协议字段映射是否覆盖"),
"field_status_action": stringSchema("实时字段中没有疑似里程字段,优先向源平台核对是否上报总里程"),
},
},
"DataSourceRow": map[string]any{
"type": "object",
"properties": map[string]any{
"id": integerSchema(3),
"protocol": stringSchema("JT808"),
"source_ip": stringSchema("115.231.168.135"),
"latest_source_endpoint": stringSchema("115.231.168.135:41561"),
"platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"),
"trust_priority": integerSchema(10),
"enabled": map[string]any{"type": "boolean", "example": true},
"first_seen_at": stringSchema("2026-07-09 14:48:30"),
"latest_seen_at": stringSchema("2026-07-12 01:16:04"),
"remark": stringSchema("可信来源"),
"updated_at": stringSchema("2026-07-12 01:16:05"),
},
},
"DataSourceDiagnosticRow": map[string]any{
"type": "object",
"properties": map[string]any{
"id": integerSchema(242190),
"protocol": stringSchema("JT808"),
"source_ip": stringSchema("117.132.194.31"),
"latest_source_endpoint": stringSchema("117.132.194.31:20471"),
"platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"),
"first_seen_at": stringSchema("2026-07-12 01:00:00"),
"latest_seen_at": stringSchema("2026-07-12 01:36:26"),
"active_span_seconds": integerSchema(2186),
"latest_seen_age_seconds": integerSchema(1800),
"registration_rows": integerSchema(5),
"phone_count": integerSchema(4),
"unknown_vin_rows": integerSchema(2),
"identifier_matched_phones": integerSchema(3),
"unmapped_phone_count": integerSchema(1),
"identifier_match_ratio": map[string]any{"type": "number", "example": 0.75},
"configured_source_code_matched_phones": integerSchema(3),
"configured_source_code_platform_name": stringSchema("东方北斗"),
"configured_source_code_conflict": map[string]any{"type": "boolean", "example": false},
"source_platform_name_mismatch": map[string]any{"type": "boolean", "example": true},
"matched_source_code_count": integerSchema(1),
"candidate_source_code": stringSchema("g7s"),
"candidate_platform_name": stringSchema("G7s"),
"matched_source_codes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"g7s"}},
"matched_platform_names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"G7s"}},
"sample_phones": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"13307795425"}},
"reason": stringSchema("candidate_available"),
"recommended_operator_action": stringSchema("可用候选 source_code执行 identity-import -sync-data-sources -apply 或在来源管理中确认"),
"suggested_source_kind": stringSchema("PLATFORM"),
"suggestion_confidence": stringSchema("HIGH"),
"suggestion_reason": stringSchema("single_source_code_with_many_phones_or_long_activity"),
},
},
"JT808IdentityGapRow": map[string]any{
"type": "object",
"properties": map[string]any{
"phone": stringSchema("13307795425"),
"device_id": stringSchema("TERM-001"),
"plate": stringSchema("沪A63305F"),
"vin": stringSchema("unknown"),
"source_ip": stringSchema("117.132.194.31"),
"source_endpoint": stringSchema("117.132.194.31:20471"),
"source_code": stringSchema("guangan_beidou"),
"platform_name": stringSchema("广安北斗"),
"source_kind": stringSchema("PLATFORM"),
"first_registered_at": stringSchema("2026-07-12 09:00:00"),
"latest_registered_at": stringSchema("2026-07-12 09:00:00"),
"latest_authenticated_at": stringSchema("2026-07-12 09:00:03"),
"latest_seen_at": stringSchema("2026-07-12 22:24:53"),
"latest_seen_age_seconds": integerSchema(32),
"reason": stringSchema("missing_phone_and_plate_binding"),
"recommended_operator_action": stringSchema("将该 phone 或 plate 维护到 vehicle_identifier并确认 VIN、source_code、oem"),
"raw_frame_query_path": stringSchema("/api/history/raw-frames?protocol=JT808&phone=13307795425&includeFields=true"),
"data_source_query_path": stringSchema("/api/stats/data-sources?protocol=JT808&sourceIP=117.132.194.31"),
},
},
"JT808MappingGapRow": map[string]any{
"type": "object",
"properties": map[string]any{
"phone": stringSchema("64646848246"),
"device_id": stringSchema("TERM-001"),
"plate": stringSchema("粤AG18312"),
"vin": stringSchema("LKLG7C4E8NA774778"),
"source_ip": stringSchema("115.159.85.149"),
"source_endpoint": stringSchema("115.159.85.149:16885"),
"source_code": stringSchema("dongfang_beidou"),
"platform_name": stringSchema("G7易流"),
"source_kind": stringSchema("PLATFORM"),
"identifier_vin": stringSchema(""),
"identifier_plate": stringSchema(""),
"matched_source_codes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"g7s"}},
"matched_platform_names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"G7s"}},
"latest_seen_at": stringSchema("2026-07-12 23:38:22"),
"latest_seen_age_seconds": integerSchema(32),
"reason": stringSchema("missing_source_phone_identifier"),
"recommended_action": stringSchema("按当前来源 source_code 维护 JT808_PHONE 到 VIN 的映射;如平台名与 source_code 不一致,先修正来源配置"),
"suggested_source_code": stringSchema("dongfang_beidou"),
"suggested_platform_name": stringSchema("G7易流"),
"suggested_identifier_type": stringSchema("JT808_PHONE"),
"suggested_identifier_value": stringSchema("64646848246"),
"suggested_vin": stringSchema("LKLG7C4E8NA774778"),
"suggested_plate": stringSchema("粤AG18312"),
"raw_frame_query_path": stringSchema("/api/history/raw-frames?protocol=JT808&phone=64646848246&includeFields=true"),
"data_source_query_path": stringSchema("/api/stats/data-sources?protocol=JT808&sourceIP=115.159.85.149"),
"vehicle_identifier_example": stringSchema("protocol=JT808, source_code=dongfang_beidou, identifier_type=JT808_PHONE, identifier_value=64646848246, vin=LKLG7C4E8NA774778, plate=粤AG18312, oem=G7易流"),
},
},
"DataSourceUpdate": map[string]any{
"type": "object",
"properties": map[string]any{
"platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"),
"trust_priority": integerSchema(10),
"enabled": map[string]any{"type": "boolean", "example": true},
"remark": stringSchema("可信来源"),
},
},
},
@@ -122,6 +738,92 @@ func realtimeOpenAPISpec() map[string]any {
}
}
func dailyMetricSourceParameters() []map[string]any {
parameters := dailyMetricParameters()
parameters = append(parameters,
map[string]any{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
map[string]any{"name": "qualityStatus", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"OK", "NO_PREVIOUS_BASELINE", "INVALID_DELTA"}}, "required": false},
map[string]any{"name": "qualityReason", "in": "query", "schema": map[string]any{"type": "string", "example": "historical_source_baseline"}, "required": false},
map[string]any{"name": "selected", "in": "query", "schema": map[string]any{"type": "boolean"}, "required": false},
)
return parameters
}
func dailyMetricDiagnosisParameters() []map[string]any {
return []map[string]any{
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "date", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false, "description": "按东八区业务日期诊断;不传时默认服务当前日期。"},
{"name": "diagnosis", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"OK", "MISSING_DAILY", "NO_SOURCE_SAMPLE", "NO_TOTAL_MILEAGE"}}, "required": false},
{"name": "reason", "in": "query", "schema": map[string]any{"type": "string", "enum": dailyMetricDiagnosisReasonEnum()}, "required": false},
{"name": "severity", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"ok", "pipeline", "source_data"}}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false, "description": "默认 false不执行 COUNT(*)true 时返回精确总数。"},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
}
}
func dailyMetricDiagnosisSummaryParameters() []map[string]any {
return []map[string]any{
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "date", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false, "description": "按东八区业务日期诊断;不传时默认服务当前日期。"},
}
}
func dailyMetricDiagnosisReasonSummaryParameters() []map[string]any {
return []map[string]any{
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "date", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false, "description": "按东八区业务日期诊断;不传时默认服务当前日期。"},
{"name": "diagnosis", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"OK", "MISSING_DAILY", "NO_SOURCE_SAMPLE", "NO_TOTAL_MILEAGE"}}, "required": false},
{"name": "reason", "in": "query", "schema": map[string]any{"type": "string", "enum": dailyMetricDiagnosisReasonEnum()}, "required": false},
{"name": "severity", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"ok", "pipeline", "source_data"}}, "required": false},
}
}
func dailyMetricDiagnosisReasonEnum() []string {
return []string{
"daily_metric_exists",
"source_samples_all_invalid",
"source_samples_all_excluded",
"source_samples_exist_but_final_metric_missing",
"realtime_location_has_total_mileage_but_no_stat_sample",
"realtime_total_mileage_missing",
"realtime_total_mileage_non_positive",
"realtime_total_mileage_time_missing",
"realtime_total_mileage_not_reported_on_stat_date",
"realtime_active_without_total_mileage",
}
}
func dailyMetricMileageFieldStatusEnum() []string {
return []string{
"daily_metric_exists",
"source_sample_exists",
"standard_field_not_consumed",
"standard_field_non_positive",
"standard_field_time_missing",
"standard_field_stale",
"candidate_field_unmapped",
"mapped_protocol_field_without_fresh_evidence",
"no_candidate_mileage_field",
"no_realtime_fields",
}
}
func dailyMetricParameters() []map[string]any {
return []map[string]any{
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "dateFrom", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false},
{"name": "dateTo", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false, "description": "默认 false不执行 COUNT(*)true 时返回精确总数。"},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
}
}
func commonRealtimeParameters() []map[string]any {
return []map[string]any{
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},

View File

@@ -17,17 +17,46 @@ func TestOpenAPIHandlerDocumentsRealtimeSnapshotAndLocation(t *testing.T) {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"/api/realtime/snapshots"`, `"/api/realtime/locations"`, `"vehicle_realtime_snapshot"`, `"vehicle_realtime_location"`} {
for _, want := range []string{
`"/api/realtime/snapshots"`,
`"/api/realtime/locations"`,
`"/api/stats/daily-metrics"`,
`"/api/stats/daily-metrics/sources"`,
`"/api/stats/daily-metrics/sources/quality"`,
`"/api/stats/daily-metrics/sources/selection"`,
`"/api/stats/daily-metrics/diagnostics"`,
`"/api/stats/daily-metrics/diagnostics/summary"`,
`"/api/stats/daily-metrics/diagnostics/reasons"`,
`"/api/stats/daily-metrics/diagnostics/field-status"`,
`"/api/stats/data-sources"`,
`"/api/stats/data-sources/diagnostics"`,
`"/api/stats/data-sources/kind-suggestions"`,
`"/api/stats/data-sources/jt808-identity-gaps"`,
`"/api/stats/data-sources/jt808-mapping-gaps"`,
`"/api/stats/data-sources/{id}"`,
`"vehicle_realtime_snapshot"`,
`"vehicle_realtime_location"`,
`vehicle_daily_mileage`,
`vehicle_daily_mileage_source`,
`"vehicle_data_source"`,
`"Stats API"`,
`"Data Source API"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("openapi missing %s: %s", want, body)
}
}
for _, want := range []string{`"daily_mileage_km"`, `"latest_total_mileage_km"`, `"source_key"`, `"source_count"`, `"vehicle_count"`, `"selected_count"`, `"sample_count"`, `"quality_status"`, `"quality_reason"`, `"is_selected"`, `"selection_status"`, `"selection_reason"`, `"selection_action"`, `"lower_trust_priority_or_sample_count"`, `"platform_name"`, `"source_code"`, `"source_kind"`, `"sourceKind"`, `"trust_priority"`, `"enabled"`, `"latest_seen_at"`, `"candidate_source_code"`, `"recommended_operator_action"`, `"suggested_source_kind"`, `"suggestion_confidence"`, `"NO_SOURCE_SAMPLE"`, `"NO_TOTAL_MILEAGE"`, `"source_sample_count"`, `"realtime_total_mileage_km"`, `"total_mileage_event_time"`, `"realtime_total_mileage_event_time"`, `"active_count"`, `"actionable_issue_count"`, `"vehicle_total"`, `"pipeline_issue_total"`, `"source_data_issue_total"`, `"severity"`, `"field_status_severity"`, `"realtime_total_mileage_not_reported_on_stat_date"`, `"realtime_mileage_field_status"`, `"field_status_action"`, `"candidate_field_unmapped"`, `"standard_field_stale"`, `"missing_phone_and_plate_binding"`, `"missing_source_phone_identifier"`, `"configured_source_code_platform_name"`, `"source_platform_name_mismatch"`, `"suggested_identifier_value"`, `"vehicle_identifier_example"`, `"raw_frame_query_path"`, `"data_source_query_path"`} {
if !strings.Contains(body, want) {
t.Fatalf("openapi should document data source field %s: %s", want, body)
}
}
for _, removed := range []string{`"/api/realtime/kv"`, `"vehicle_realtime_kv"`, `"Realtime KV API"`} {
if strings.Contains(body, removed) {
t.Fatalf("openapi should not expose removed MySQL realtime kv API %s: %s", removed, body)
}
}
for _, want := range []string{`"includeTotal"`, `默认不执行 COUNT(*)`, `total 默认表示本页返回条数`} {
for _, want := range []string{`"includeTotal"`, `"sourceCodeMissing"`, `默认不执行 COUNT(*)`, `total 默认表示本页返回条数`} {
if !strings.Contains(body, want) {
t.Fatalf("openapi should document lightweight total semantics, missing %s: %s", want, body)
}

View File

@@ -20,6 +20,32 @@ type Repository struct {
cfg Config
}
type FastUpdateResult struct {
EnvelopesSeen int
EnvelopesUpdated int
EnvelopesSkippedNonRealtime int
EnvelopesSkippedMissingVIN int
EnvelopesSkippedMissingVehicleKey int
EnvelopesSkippedMissingFields int
FieldsSeen int
FieldsWritten int
FieldsSkippedStale int
}
func (r FastUpdateResult) Add(other FastUpdateResult) FastUpdateResult {
return FastUpdateResult{
EnvelopesSeen: r.EnvelopesSeen + other.EnvelopesSeen,
EnvelopesUpdated: r.EnvelopesUpdated + other.EnvelopesUpdated,
EnvelopesSkippedNonRealtime: r.EnvelopesSkippedNonRealtime + other.EnvelopesSkippedNonRealtime,
EnvelopesSkippedMissingVIN: r.EnvelopesSkippedMissingVIN + other.EnvelopesSkippedMissingVIN,
EnvelopesSkippedMissingVehicleKey: r.EnvelopesSkippedMissingVehicleKey + other.EnvelopesSkippedMissingVehicleKey,
EnvelopesSkippedMissingFields: r.EnvelopesSkippedMissingFields + other.EnvelopesSkippedMissingFields,
FieldsSeen: r.FieldsSeen + other.FieldsSeen,
FieldsWritten: r.FieldsWritten + other.FieldsWritten,
FieldsSkippedStale: r.FieldsSkippedStale + other.FieldsSkippedStale,
}
}
func NewRepository(client *redis.Client, cfg Config) *Repository {
if client == nil {
panic("redis client must not be nil")
@@ -28,48 +54,82 @@ func NewRepository(client *redis.Client, cfg Config) *Repository {
}
func (r *Repository) FastUpdate(ctx context.Context, env envelope.FrameEnvelope) error {
_, err := r.FastUpdateWithResult(ctx, env)
return err
}
func (r *Repository) FastUpdateWithResult(ctx context.Context, env envelope.FrameEnvelope) (FastUpdateResult, error) {
result := FastUpdateResult{EnvelopesSeen: 1}
if !envelope.IsRealtimeTelemetryFrame(env) {
return nil
result.EnvelopesSkippedNonRealtime = 1
return result, nil
}
vin := strings.TrimSpace(env.VIN)
if vin == "" {
return nil
result.EnvelopesSkippedMissingVIN = 1
return result, nil
}
vehicleKey := strings.TrimSpace(env.VehicleKey())
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
return nil
result.EnvelopesSkippedMissingVehicleKey = 1
return result, nil
}
if len(env.ParsedFields) == 0 {
result.EnvelopesSkippedMissingFields = 1
return result, nil
}
return r.setFastProjection(ctx, vehicleKey, vin, env)
}
func (r *Repository) FastUpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
_, err := r.FastUpdateBatchWithResult(ctx, envs)
return err
}
func (r *Repository) FastUpdateBatchWithResult(ctx context.Context, envs []envelope.FrameEnvelope) (FastUpdateResult, error) {
if len(envs) == 0 {
return nil
return FastUpdateResult{}, nil
}
pipe := r.client.Pipeline()
queued := 0
var result FastUpdateResult
queued := make([]queuedFastProjection, 0, len(envs))
for _, env := range envs {
result.EnvelopesSeen++
if !envelope.IsRealtimeTelemetryFrame(env) {
result.EnvelopesSkippedNonRealtime++
continue
}
vin := strings.TrimSpace(env.VIN)
if vin == "" {
result.EnvelopesSkippedMissingVIN++
continue
}
vehicleKey := strings.TrimSpace(env.VehicleKey())
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
result.EnvelopesSkippedMissingVehicleKey++
continue
}
if err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env); err != nil {
return err
if len(env.ParsedFields) == 0 {
result.EnvelopesSkippedMissingFields++
continue
}
queued++
queuedProjection, err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env)
if err != nil {
return FastUpdateResult{}, err
}
queued = append(queued, queuedProjection)
}
if queued == 0 {
return nil
if len(queued) == 0 {
return result, nil
}
_, err := pipe.Exec(ctx)
return err
if err != nil {
return FastUpdateResult{}, err
}
for _, item := range queued {
result = result.Add(item.result())
}
return result, nil
}
func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -85,10 +145,7 @@ func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) err
return nil
}
nowMS := time.Now().UnixMilli()
eventMS := env.EventTimeMS
if eventMS <= 0 {
eventMS = env.ReceivedAtMS
}
eventMS, _ := envelope.NormalizedEventTimeMS(env)
protocolSnapshot := Snapshot{
VehicleKey: vehicleKey,
VIN: vin,
@@ -129,7 +186,7 @@ func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) err
if err := r.setJSON(ctx, realtimeRawKey(vehicleKey, env.Protocol), protocolSnapshot.Parsed, r.cfg.ttl()); err != nil {
return err
}
if err := r.setKV(ctx, vin, env, protocolSnapshot.Parsed); err != nil {
if err := r.setKV(ctx, vin, env); err != nil {
return err
}
@@ -377,10 +434,7 @@ func (r *Repository) setJSON(ctx context.Context, key string, value any, ttl tim
return r.client.Set(ctx, key, payload, ttl).Err()
}
func (r *Repository) setKV(ctx context.Context, vin string, env envelope.FrameEnvelope, parsed map[string]any) error {
if len(env.ParsedFields) == 0 && len(parsed) > 0 {
env.Parsed = parsed
}
func (r *Repository) setKV(ctx context.Context, vin string, env envelope.FrameEnvelope) error {
values, types := realtimeKVMapsForEnvelope(env)
if len(values) == 0 {
return nil
@@ -394,24 +448,43 @@ func (r *Repository) setKV(ctx context.Context, vin string, env envelope.FrameEn
"source_endpoint": env.SourceEndpoint,
"field_mapping": realtimeFieldMappingVersion,
}
pipe := r.client.Pipeline()
pipe.HSet(ctx, realtimeKVValuesKey(env.Protocol, vin), values)
pipe.HSet(ctx, realtimeKVTypesKey(env.Protocol, vin), types)
pipe.HSet(ctx, realtimeKVMetaKey(env.Protocol, vin), meta)
_, err := pipe.Exec(ctx)
return err
return evalGuardedRealtimeKV(ctx, r.client, env.Protocol, vin, eventTimeOrReceivedMS(env), values, types, meta).Err()
}
func (r *Repository) setFastProjection(ctx context.Context, vehicleKey string, vin string, env envelope.FrameEnvelope) error {
func (r *Repository) setFastProjection(ctx context.Context, vehicleKey string, vin string, env envelope.FrameEnvelope) (FastUpdateResult, error) {
pipe := r.client.Pipeline()
if err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env); err != nil {
return err
queued, err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env)
if err != nil {
return FastUpdateResult{}, err
}
_, err := pipe.Exec(ctx)
return err
if _, err := pipe.Exec(ctx); err != nil {
return FastUpdateResult{}, err
}
result := queued.result()
result.EnvelopesSeen = 1
return result, nil
}
func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipeliner, vehicleKey string, vin string, env envelope.FrameEnvelope) error {
type queuedFastProjection struct {
fieldsSeen int
writeCmd *redis.Cmd
}
func (q queuedFastProjection) result() FastUpdateResult {
written := redisCmdInt(q.writeCmd)
skipped := q.fieldsSeen - written
if skipped < 0 {
skipped = 0
}
return FastUpdateResult{
EnvelopesUpdated: 1,
FieldsSeen: q.fieldsSeen,
FieldsWritten: written,
FieldsSkippedStale: skipped,
}
}
func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipeliner, vehicleKey string, vin string, env envelope.FrameEnvelope) (queuedFastProjection, error) {
values, types := realtimeKVMapsForEnvelope(env)
eventTimeMS := eventTimeOrReceivedMS(env)
offlineAfterMS := env.ReceivedAtMS + r.cfg.ttl().Milliseconds()
@@ -428,7 +501,7 @@ func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipelin
}
payload, err := json.Marshal(online)
if err != nil {
return err
return queuedFastProjection{}, err
}
meta := map[string]any{
"event_time_ms": strconv.FormatInt(eventTimeMS, 10),
@@ -449,16 +522,171 @@ func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipelin
"ttl_seconds": strconv.FormatInt(int64(r.cfg.ttl().Seconds()), 10),
"source_endpoint": env.SourceEndpoint,
}
queued := queuedFastProjection{fieldsSeen: len(values)}
if len(values) > 0 {
pipe.HSet(ctx, realtimeKVValuesKey(env.Protocol, vin), values)
pipe.HSet(ctx, realtimeKVTypesKey(env.Protocol, vin), types)
pipe.HSet(ctx, realtimeKVMetaKey(env.Protocol, vin), meta)
queued.writeCmd = evalGuardedRealtimeKV(ctx, pipe, env.Protocol, vin, eventTimeMS, values, types, meta)
}
pipe.Set(ctx, onlineKey(env.Protocol, vin), payload, r.cfg.ttl())
pipe.HSet(ctx, onlineStateKey(env.Protocol, vin), state)
pipe.ZAdd(ctx, "vehicle:last_seen", redis.Z{Score: float64(env.ReceivedAtMS), Member: onlineMember(env.Protocol, vin)})
pipe.SAdd(ctx, realtimeIndexKey(env.Protocol), vin)
return nil
if _, err := evalGuardedOnlineStatus(ctx, pipe, online, state, payload, r.cfg.ttl()); err != nil {
return queuedFastProjection{}, err
}
return queued, nil
}
const guardedRealtimeKVScript = `
local incoming = tonumber(ARGV[1]) or 0
local value_count = tonumber(ARGV[2]) or 0
local idx = 3
local written = 0
for i = 1, value_count do
local field = ARGV[idx]
local value = ARGV[idx + 1]
idx = idx + 2
local current = tonumber(redis.call('HGET', KEYS[3], field) or '') or 0
if current <= incoming then
redis.call('HSET', KEYS[1], field, value)
redis.call('HSET', KEYS[3], field, incoming)
written = written + 1
end
end
local type_count = tonumber(ARGV[idx]) or 0
idx = idx + 1
for i = 1, type_count do
local field = ARGV[idx]
local value_type = ARGV[idx + 1]
idx = idx + 2
local current = tonumber(redis.call('HGET', KEYS[3], field) or '') or 0
if current <= incoming then
redis.call('HSET', KEYS[2], field, value_type)
end
end
local meta_count = tonumber(ARGV[idx]) or 0
idx = idx + 1
local current_meta = tonumber(redis.call('HGET', KEYS[4], 'event_time_ms') or '') or 0
if current_meta <= incoming then
for i = 1, meta_count do
redis.call('HSET', KEYS[4], ARGV[idx], ARGV[idx + 1])
idx = idx + 2
end
end
return written
`
const guardedOnlineStatusScript = `
local incoming = tonumber(ARGV[1]) or 0
local ttl_ms = tonumber(ARGV[2]) or 60000
local payload = ARGV[3]
local member = ARGV[4]
local vin = ARGV[5]
local state_count = tonumber(ARGV[6]) or 0
local idx = 7
local current = tonumber(redis.call('HGET', KEYS[2], 'last_seen_ms') or '') or 0
redis.call('SADD', KEYS[4], vin)
if current <= incoming then
redis.call('SET', KEYS[1], payload, 'PX', ttl_ms)
for i = 1, state_count do
redis.call('HSET', KEYS[2], ARGV[idx], ARGV[idx + 1])
idx = idx + 2
end
redis.call('ZADD', KEYS[3], incoming, member)
return 1
end
return 0
`
type redisEvaler interface {
Eval(ctx context.Context, script string, keys []string, args ...any) *redis.Cmd
}
func redisCmdInt(cmd *redis.Cmd) int {
if cmd == nil {
return 0
}
value, err := cmd.Int64()
if err != nil {
return 0
}
return int(value)
}
func evalGuardedRealtimeKV(ctx context.Context, evaler redisEvaler, protocol envelope.Protocol, vin string, eventTimeMS int64, values map[string]any, types map[string]any, meta map[string]any) *redis.Cmd {
keys := []string{
realtimeKVValuesKey(protocol, vin),
realtimeKVTypesKey(protocol, vin),
realtimeKVTimesKey(protocol, vin),
realtimeKVMetaKey(protocol, vin),
}
args := guardedRealtimeKVArgs(eventTimeMS, values, types, meta)
return evaler.Eval(ctx, guardedRealtimeKVScript, keys, args...)
}
func evalGuardedOnlineStatus(ctx context.Context, evaler redisEvaler, online OnlineStatus, state map[string]any, payload []byte, ttl time.Duration) (*redis.Cmd, error) {
protocol := online.Protocol
if protocol == "" && len(online.Protocols) > 0 {
protocol = online.Protocols[0]
}
vin := strings.TrimSpace(online.VIN)
if protocol == "" || vin == "" {
return nil, nil
}
if len(payload) == 0 {
var err error
payload, err = json.Marshal(online)
if err != nil {
return nil, err
}
}
if ttl <= 0 {
ttl = time.Minute
}
keys := []string{
onlineKey(protocol, vin),
onlineStateKey(protocol, vin),
"vehicle:last_seen",
realtimeIndexKey(protocol),
}
args := guardedOnlineStatusArgs(online, protocol, state, payload, ttl)
return evaler.Eval(ctx, guardedOnlineStatusScript, keys, args...), nil
}
func guardedOnlineStatusArgs(online OnlineStatus, protocol envelope.Protocol, state map[string]any, payload []byte, ttl time.Duration) []any {
args := []any{
strconv.FormatInt(online.LastSeenMS, 10),
strconv.FormatInt(ttl.Milliseconds(), 10),
string(payload),
onlineMember(protocol, online.VIN),
strings.TrimSpace(online.VIN),
}
return appendMapPairs(args, state)
}
func guardedRealtimeKVArgs(eventTimeMS int64, values map[string]any, types map[string]any, meta map[string]any) []any {
args := []any{strconv.FormatInt(eventTimeMS, 10)}
args = appendMapPairs(args, values)
args = appendMapPairs(args, types)
args = appendMapPairs(args, meta)
return args
}
func appendMapPairs(args []any, values map[string]any) []any {
keys := make([]string, 0, len(values))
for key := range values {
key = strings.TrimSpace(key)
if key == "" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
args = append(args, strconv.Itoa(len(keys)))
for _, key := range keys {
args = append(args, key, fmt.Sprint(values[key]))
}
return args
}
func realtimeKVMapsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[string]any) {
@@ -469,6 +697,9 @@ func realtimeKVMapsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[
if strings.TrimSpace(field) == "" {
continue
}
if isRealtimeTotalMileageField(field) && !positiveNumber(value) {
continue
}
stringValue, valueType, ok := stringifyKVValue(value)
if !ok {
continue
@@ -528,7 +759,6 @@ func (r *Repository) setOnlineStatus(ctx context.Context, online OnlineStatus) e
if online.OfflineAfterMS <= 0 {
online.OfflineAfterMS = online.LastSeenMS + r.cfg.ttl().Milliseconds()
}
member := onlineMember(protocol, online.VIN)
state := map[string]any{
"vehicle_key": online.VehicleKey,
"vin": online.VIN,
@@ -544,10 +774,9 @@ func (r *Repository) setOnlineStatus(ctx context.Context, online OnlineStatus) e
if err != nil {
return err
}
pipe.Set(ctx, onlineKey(protocol, online.VIN), payload, r.cfg.ttl())
pipe.HSet(ctx, onlineStateKey(protocol, online.VIN), state)
pipe.ZAdd(ctx, "vehicle:last_seen", redis.Z{Score: float64(online.LastSeenMS), Member: member})
pipe.SAdd(ctx, realtimeIndexKey(protocol), online.VIN)
if _, err := evalGuardedOnlineStatus(ctx, pipe, online, state, payload, r.cfg.ttl()); err != nil {
return err
}
_, err = pipe.Exec(ctx)
return err
}
@@ -879,6 +1108,10 @@ func realtimeKVTypesKey(protocol envelope.Protocol, vin string) string {
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":types"
}
func realtimeKVTimesKey(protocol envelope.Protocol, vin string) string {
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":times"
}
func realtimeKVMetaKey(protocol envelope.Protocol, vin string) string {
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":meta"
}
@@ -897,10 +1130,8 @@ func realtimeKVFieldPath(domain string, field string) string {
}
func eventTimeOrReceivedMS(env envelope.FrameEnvelope) int64 {
if env.EventTimeMS > 0 {
return env.EventTimeMS
}
return env.ReceivedAtMS
eventMS, _ := envelope.NormalizedEventTimeMS(env)
return eventMS
}
func onlineKey(protocol envelope.Protocol, vin string) string {

Some files were not shown because too many files have changed in this diff Show More