feat: build vehicle data platform and production pipeline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
490
go/vehicle-gateway/cmd/fields-projector/main.go
Normal file
490
go/vehicle-gateway/cmd/fields-projector/main.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := observability.NewLogger("vehicle-fields-projector")
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Error("invalid fields projector config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := pingKafka(ctx, cfg.KafkaBrokers); err != nil {
|
||||
logger.Error("kafka connectivity check failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
recordConfigMetrics(registry, cfg)
|
||||
for _, route := range cfg.Routes {
|
||||
metrics.RegisterKafkaConsumerInfo(registry, "vehicle-fields-projector", route.GroupID, []string{route.RawTopic})
|
||||
}
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-fields-projector", nil, registry))
|
||||
|
||||
logger.Info("fields projector started",
|
||||
"kafka_brokers", strings.Join(cfg.KafkaBrokers, ","),
|
||||
"group_prefix", cfg.GroupPrefix,
|
||||
"workers_per_protocol", cfg.WorkersPerProtocol,
|
||||
"batch_size", cfg.BatchSize,
|
||||
"batch_wait_ms", cfg.BatchWait.Milliseconds(),
|
||||
"operation_timeout_ms", cfg.OperationTimeout.Milliseconds(),
|
||||
"retry_delay_ms", cfg.RetryDelay.Milliseconds(),
|
||||
"start_offset", cfg.StartOffsetName)
|
||||
|
||||
var workers sync.WaitGroup
|
||||
for _, route := range cfg.Routes {
|
||||
for workerID := 1; workerID <= cfg.WorkersPerProtocol; workerID++ {
|
||||
workers.Add(1)
|
||||
go func(route projectionRoute, workerID int) {
|
||||
defer workers.Done()
|
||||
runProjector(ctx, logger.With("protocol", route.Protocol, "worker", workerID), registry, cfg, route, workerID)
|
||||
}(route, workerID)
|
||||
}
|
||||
}
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
type config struct {
|
||||
KafkaBrokers []string
|
||||
GroupPrefix string
|
||||
Routes []projectionRoute
|
||||
WorkersPerProtocol int
|
||||
BatchSize int
|
||||
BatchWait time.Duration
|
||||
OperationTimeout time.Duration
|
||||
RetryDelay time.Duration
|
||||
StartOffset int64
|
||||
StartOffsetName string
|
||||
}
|
||||
|
||||
type projectionRoute struct {
|
||||
Protocol envelope.Protocol
|
||||
RawTopic string
|
||||
FieldsTopic string
|
||||
GroupID string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
groupPrefix := env("FIELDS_PROJECTOR_GROUP_PREFIX", "vehicle-fields-projector-v1")
|
||||
startOffsetName := strings.ToLower(env("KAFKA_START_OFFSET", "last"))
|
||||
startOffset := int64(kafka.LastOffset)
|
||||
if startOffsetName == "first" {
|
||||
startOffset = kafka.FirstOffset
|
||||
} else {
|
||||
startOffsetName = "last"
|
||||
}
|
||||
routes := []projectionRoute{
|
||||
{Protocol: envelope.ProtocolGB32960, RawTopic: env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960), FieldsTopic: env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)},
|
||||
{Protocol: envelope.ProtocolJT808, RawTopic: env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808), FieldsTopic: env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)},
|
||||
{Protocol: envelope.ProtocolYutongMQTT, RawTopic: env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT), FieldsTopic: env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)},
|
||||
}
|
||||
for index := range routes {
|
||||
routes[index].GroupID = groupPrefix + "-" + strings.ToLower(strings.ReplaceAll(string(routes[index].Protocol), "_", "-"))
|
||||
}
|
||||
return config{
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
GroupPrefix: groupPrefix,
|
||||
Routes: routes,
|
||||
WorkersPerProtocol: envInt("FIELDS_PROJECTOR_WORKERS_PER_PROTOCOL", 1),
|
||||
BatchSize: envInt("FIELDS_PROJECTOR_BATCH_SIZE", 500),
|
||||
BatchWait: time.Duration(envInt("FIELDS_PROJECTOR_BATCH_WAIT_MS", 20)) * time.Millisecond,
|
||||
OperationTimeout: time.Duration(envInt("FIELDS_PROJECTOR_OPERATION_TIMEOUT_MS", 30000)) * time.Millisecond,
|
||||
RetryDelay: time.Duration(envInt("FIELDS_PROJECTOR_RETRY_DELAY_MS", 500)) * time.Millisecond,
|
||||
StartOffset: startOffset,
|
||||
StartOffsetName: startOffsetName,
|
||||
}
|
||||
}
|
||||
|
||||
func (c config) Validate() error {
|
||||
if len(c.KafkaBrokers) == 0 {
|
||||
return errors.New("kafka brokers are required")
|
||||
}
|
||||
if strings.TrimSpace(c.GroupPrefix) == "" {
|
||||
return errors.New("fields projector group prefix is required")
|
||||
}
|
||||
if c.WorkersPerProtocol < 1 || c.BatchSize < 1 || c.BatchWait <= 0 || c.OperationTimeout <= 0 || c.RetryDelay <= 0 {
|
||||
return errors.New("fields projector worker, batch and timeout settings must be positive")
|
||||
}
|
||||
raw := make(map[string]string, len(c.Routes))
|
||||
fields := make(map[string]string, len(c.Routes))
|
||||
groups := map[string]struct{}{}
|
||||
for _, route := range c.Routes {
|
||||
protocol := string(route.Protocol)
|
||||
raw[protocol] = route.RawTopic
|
||||
fields[protocol] = route.FieldsTopic
|
||||
if strings.TrimSpace(route.GroupID) == "" {
|
||||
return fmt.Errorf("consumer group is required for protocol %s", route.Protocol)
|
||||
}
|
||||
if _, exists := groups[route.GroupID]; exists {
|
||||
return fmt.Errorf("duplicate consumer group %q", route.GroupID)
|
||||
}
|
||||
groups[route.GroupID] = struct{}{}
|
||||
}
|
||||
return topics.ValidateKafkaRawFields(raw, fields)
|
||||
}
|
||||
|
||||
type kafkaBatchWriter interface {
|
||||
WriteMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
type kafkaMessageFetcher interface {
|
||||
FetchMessage(context.Context) (kafka.Message, error)
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
func runProjector(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, cfg config, route projectionRoute, workerID int) {
|
||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: cfg.KafkaBrokers,
|
||||
GroupID: route.GroupID,
|
||||
GroupTopics: []string{route.RawTopic},
|
||||
StartOffset: cfg.StartOffset,
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
})
|
||||
defer reader.Close()
|
||||
writer := &kafka.Writer{
|
||||
Addr: kafka.TCP(cfg.KafkaBrokers...),
|
||||
Balancer: &kafka.Hash{},
|
||||
RequiredAcks: kafka.RequireAll,
|
||||
AllowAutoTopicCreation: false,
|
||||
BatchTimeout: cfg.BatchWait,
|
||||
Async: false,
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
labels := metrics.Labels{"protocol": string(route.Protocol), "worker": strconv.Itoa(workerID)}
|
||||
registry.SetGauge("vehicle_fields_projector_worker_active", labels, 1)
|
||||
defer registry.SetGauge("vehicle_fields_projector_worker_active", labels, 0)
|
||||
for {
|
||||
first, err := reader.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.Error("kafka fetch failed", "error", err)
|
||||
continue
|
||||
}
|
||||
batch := collectBatch(ctx, reader, first, cfg.BatchSize, cfg.BatchWait)
|
||||
processBatchReliably(ctx, logger, registry, writer, reader, route, batch, cfg.OperationTimeout, cfg.RetryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func collectBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message {
|
||||
if maxSize <= 1 {
|
||||
return []kafka.Message{first}
|
||||
}
|
||||
batch := []kafka.Message{first}
|
||||
deadline := time.Now().Add(maxWait)
|
||||
for len(batch) < maxSize {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, remaining)
|
||||
message, err := fetcher.FetchMessage(fetchCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
batch = append(batch, message)
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
func processBatchReliably(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, writer kafkaBatchWriter, committer kafkaMessageCommitter, route projectionRoute, messages []kafka.Message, operationTimeout time.Duration, retryDelay time.Duration) {
|
||||
labels := metrics.Labels{"protocol": string(route.Protocol)}
|
||||
defer registry.SetGauge("vehicle_fields_projector_retry_pending_messages", labels, 0)
|
||||
for len(messages) > 0 {
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), operationTimeout)
|
||||
outputs, err := projectBatch(operationCtx, logger, registry, writer, route, messages)
|
||||
cancel()
|
||||
if err != nil {
|
||||
registry.SetGauge("vehicle_fields_projector_retry_pending_messages", labels, float64(len(messages)))
|
||||
registry.IncCounter("vehicle_fields_projector_batch_retries_total", metrics.Labels{"protocol": string(route.Protocol), "reason": "write_error"})
|
||||
if !waitForRetry(ctx, retryDelay) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
commitCtx, commitCancel := context.WithTimeout(context.WithoutCancel(ctx), operationTimeout)
|
||||
err = committer.CommitMessages(commitCtx, messages...)
|
||||
commitCancel()
|
||||
if err == nil {
|
||||
for _, message := range messages {
|
||||
recordMessageMetric(registry, "vehicle_fields_projector_kafka_commits_total", message.Topic, "ok")
|
||||
}
|
||||
_ = outputs
|
||||
return
|
||||
}
|
||||
for _, message := range messages {
|
||||
recordMessageMetric(registry, "vehicle_fields_projector_kafka_commits_total", message.Topic, "error")
|
||||
}
|
||||
registry.SetGauge("vehicle_fields_projector_retry_pending_messages", labels, float64(len(messages)))
|
||||
registry.IncCounter("vehicle_fields_projector_batch_retries_total", metrics.Labels{"protocol": string(route.Protocol), "reason": "commit_error"})
|
||||
logger.Error("kafka source offset commit failed", "topic", route.RawTopic, "messages", len(messages), "error", err)
|
||||
if !retryCommit(ctx, logger, registry, committer, messages, route.Protocol, operationTimeout, retryDelay) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func projectBatch(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, writer kafkaBatchWriter, route projectionRoute, messages []kafka.Message) (int, error) {
|
||||
if len(messages) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
outputs := make([]kafka.Message, 0, len(messages))
|
||||
setBatchPending(registry, route.Protocol, len(messages), 0)
|
||||
defer setBatchPending(registry, route.Protocol, -len(messages), -len(outputs))
|
||||
for _, message := range messages {
|
||||
recordMessageMetric(registry, "vehicle_fields_projector_kafka_messages_total", message.Topic, "received")
|
||||
recordKafkaLag(registry, message)
|
||||
var raw envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &raw); err != nil {
|
||||
recordMessageMetric(registry, "vehicle_fields_projector_kafka_messages_total", message.Topic, "invalid_json")
|
||||
logger.Warn("skip invalid raw envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
continue
|
||||
}
|
||||
if status, err := topics.ValidateRawEnvelope(route.RawTopic, raw); err != nil {
|
||||
recordMessageMetric(registry, "vehicle_fields_projector_kafka_messages_total", message.Topic, status)
|
||||
logger.Warn("skip mismatched raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", raw.Protocol, "event_id", raw.StableEventID(), "error", err)
|
||||
continue
|
||||
}
|
||||
fields, ok := realtime.BuildFieldsEnvelope(raw)
|
||||
if !ok {
|
||||
status := "skipped_missing_fields"
|
||||
if !envelope.IsRealtimeTelemetryFrame(raw) {
|
||||
status = "skipped_non_realtime"
|
||||
}
|
||||
recordProjectionMetric(registry, route.Protocol, status, 0)
|
||||
continue
|
||||
}
|
||||
if status, err := topics.ValidateFieldsEnvelope(route.FieldsTopic, fields); err != nil {
|
||||
recordProjectionMetric(registry, route.Protocol, status, len(fields.Fields))
|
||||
logger.Warn("skip invalid projected fields envelope", "topic", route.FieldsTopic, "protocol", fields.Protocol, "event_id", fields.StableEventID(), "error", err)
|
||||
continue
|
||||
}
|
||||
payload, err := fields.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
recordProjectionMetric(registry, route.Protocol, "marshal_error", len(fields.Fields))
|
||||
logger.Warn("skip fields envelope marshal error", "topic", route.FieldsTopic, "event_id", fields.StableEventID(), "error", err)
|
||||
continue
|
||||
}
|
||||
outputs = append(outputs, kafka.Message{
|
||||
Topic: route.FieldsTopic,
|
||||
Key: fields.KafkaKey(),
|
||||
Value: payload,
|
||||
Time: message.Time,
|
||||
})
|
||||
recordProjectionMetric(registry, route.Protocol, "projected", len(fields.Fields))
|
||||
}
|
||||
setBatchPending(registry, route.Protocol, 0, len(outputs))
|
||||
defer setBatchPending(registry, route.Protocol, 0, -len(outputs))
|
||||
if len(outputs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
started := time.Now()
|
||||
err := writer.WriteMessages(ctx, outputs...)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
recordWriteDuration(registry, route.FieldsTopic, status, time.Since(started))
|
||||
for range outputs {
|
||||
recordMessageMetric(registry, "vehicle_fields_projector_kafka_writes_total", route.FieldsTopic, status)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error("fields kafka write failed", "topic", route.FieldsTopic, "messages", len(outputs), "error", err)
|
||||
return len(outputs), err
|
||||
}
|
||||
return len(outputs), nil
|
||||
}
|
||||
|
||||
func retryCommit(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
}, registry *metrics.Registry, committer kafkaMessageCommitter, messages []kafka.Message, protocol envelope.Protocol, operationTimeout, retryDelay time.Duration) bool {
|
||||
for {
|
||||
if !waitForRetry(ctx, retryDelay) {
|
||||
return false
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), operationTimeout)
|
||||
err := committer.CommitMessages(operationCtx, messages...)
|
||||
cancel()
|
||||
if err == nil {
|
||||
for _, message := range messages {
|
||||
recordMessageMetric(registry, "vehicle_fields_projector_kafka_commits_total", message.Topic, "ok")
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, message := range messages {
|
||||
recordMessageMetric(registry, "vehicle_fields_projector_kafka_commits_total", message.Topic, "error")
|
||||
}
|
||||
registry.IncCounter("vehicle_fields_projector_batch_retries_total", metrics.Labels{"protocol": string(protocol), "reason": "commit_error"})
|
||||
logger.Error("kafka source offset commit retry failed", "messages", len(messages), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func pingKafka(ctx context.Context, brokers []string) error {
|
||||
if len(brokers) == 0 {
|
||||
return errors.New("kafka broker is required")
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
conn, err := kafka.DialContext(checkCtx, "tcp", brokers[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.Close()
|
||||
}
|
||||
|
||||
func recordConfigMetrics(registry *metrics.Registry, cfg config) {
|
||||
registry.SetGauge("vehicle_fields_projector_config", metrics.Labels{"setting": "workers_per_protocol"}, float64(cfg.WorkersPerProtocol))
|
||||
registry.SetGauge("vehicle_fields_projector_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize))
|
||||
registry.SetGauge("vehicle_fields_projector_config", metrics.Labels{"setting": "batch_wait_ms"}, float64(cfg.BatchWait.Milliseconds()))
|
||||
registry.SetGauge("vehicle_fields_projector_config", metrics.Labels{"setting": "operation_timeout_ms"}, float64(cfg.OperationTimeout.Milliseconds()))
|
||||
}
|
||||
|
||||
func recordMessageMetric(registry *metrics.Registry, name, topic, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
labels := metrics.Labels{"topic": topic, "status": status}
|
||||
registry.IncCounter(name, labels)
|
||||
switch name {
|
||||
case "vehicle_fields_projector_kafka_messages_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_fields_projector_last_message_unix_seconds", labels)
|
||||
case "vehicle_fields_projector_kafka_writes_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_fields_projector_last_write_unix_seconds", labels)
|
||||
case "vehicle_fields_projector_kafka_commits_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_fields_projector_last_commit_unix_seconds", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func recordProjectionMetric(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
labels := metrics.Labels{"protocol": string(protocol), "status": status}
|
||||
registry.IncCounter("vehicle_fields_projector_projections_total", labels)
|
||||
if fieldCount > 0 {
|
||||
registry.SetGauge("vehicle_fields_projector_field_count", labels, float64(fieldCount))
|
||||
}
|
||||
}
|
||||
|
||||
func recordKafkaLag(registry *metrics.Registry, message kafka.Message) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
lag := message.HighWaterMark - message.Offset - 1
|
||||
if lag < 0 {
|
||||
lag = 0
|
||||
}
|
||||
registry.SetGauge("vehicle_fields_projector_kafka_lag", metrics.Labels{
|
||||
"topic": message.Topic, "partition": strconv.Itoa(message.Partition),
|
||||
}, float64(lag))
|
||||
}
|
||||
|
||||
var projectorPendingMu sync.Mutex
|
||||
var projectorPendingMessages = map[envelope.Protocol]int{}
|
||||
var projectorPendingFields = map[envelope.Protocol]int{}
|
||||
|
||||
func setBatchPending(registry *metrics.Registry, protocol envelope.Protocol, messagesDelta, fieldsDelta int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
projectorPendingMu.Lock()
|
||||
projectorPendingMessages[protocol] += messagesDelta
|
||||
projectorPendingFields[protocol] += fieldsDelta
|
||||
messages := projectorPendingMessages[protocol]
|
||||
fields := projectorPendingFields[protocol]
|
||||
projectorPendingMu.Unlock()
|
||||
labels := metrics.Labels{"protocol": string(protocol)}
|
||||
registry.SetGauge("vehicle_fields_projector_batch_pending_messages", labels, float64(messages))
|
||||
registry.SetGauge("vehicle_fields_projector_batch_pending_fields", labels, float64(fields))
|
||||
}
|
||||
|
||||
var projectorWriteBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func recordWriteDuration(registry *metrics.Registry, topic, status string, elapsed time.Duration) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_fields_projector_write_duration_ms_histogram", metrics.Labels{
|
||||
"topic": topic, "status": status,
|
||||
}, projectorWriteBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
242
go/vehicle-gateway/cmd/fields-projector/main_test.go
Normal file
242
go/vehicle-gateway/cmd/fields-projector/main_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func TestLoadConfigCreatesProtocolIsolatedConsumerGroups(t *testing.T) {
|
||||
t.Setenv("FIELDS_PROJECTOR_GROUP_PREFIX", "projector-test")
|
||||
t.Setenv("KAFKA_START_OFFSET", "first")
|
||||
cfg := loadConfig()
|
||||
if cfg.StartOffset != kafka.FirstOffset || cfg.StartOffsetName != "first" {
|
||||
t.Fatalf("start offset = %d/%q", cfg.StartOffset, cfg.StartOffsetName)
|
||||
}
|
||||
wantGroups := map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: "projector-test-gb32960",
|
||||
envelope.ProtocolJT808: "projector-test-jt808",
|
||||
envelope.ProtocolYutongMQTT: "projector-test-yutong-mqtt",
|
||||
}
|
||||
for _, route := range cfg.Routes {
|
||||
if route.GroupID != wantGroups[route.Protocol] {
|
||||
t.Fatalf("group for %s = %q", route.Protocol, route.GroupID)
|
||||
}
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBatchUsesPrecomputedFieldsAndPreservesSourceMetadata(t *testing.T) {
|
||||
raw := projectorRawEnvelope()
|
||||
payload, err := raw.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal raw: %v", err)
|
||||
}
|
||||
writer := &recordingProjectorWriter{}
|
||||
registry := metrics.NewRegistry()
|
||||
route := jt808ProjectionRoute()
|
||||
count, err := projectBatch(context.Background(), discardProjectorLogger{}, registry, writer, route, []kafka.Message{{
|
||||
Topic: route.RawTopic, Key: raw.KafkaKey(), Value: payload, Partition: 2, Offset: 10, HighWaterMark: 11,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("projectBatch() error = %v", err)
|
||||
}
|
||||
if count != 1 || writer.callCount() != 1 || len(writer.messages) != 1 {
|
||||
t.Fatalf("projected=%d calls=%d messages=%d", count, writer.callCount(), len(writer.messages))
|
||||
}
|
||||
var fields envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(writer.messages[0].Value, &fields); err != nil {
|
||||
t.Fatalf("decode fields: %v", err)
|
||||
}
|
||||
if fields.EventKind != envelope.EventKindFields || fields.SourceEventID != raw.EventID || fields.EventID != raw.EventID+":fields" {
|
||||
t.Fatalf("fields identity = %#v", fields)
|
||||
}
|
||||
if fields.SourceCode != raw.SourceCode || fields.SourceKind != raw.SourceKind || fields.SourceEndpoint != raw.SourceEndpoint {
|
||||
t.Fatalf("source metadata not preserved: %#v", fields)
|
||||
}
|
||||
if got := fields.Fields["jt808.location.total_mileage_km"]; got != 1234.5 {
|
||||
t.Fatalf("total mileage = %#v", got)
|
||||
}
|
||||
if len(fields.Parsed) != 0 || len(fields.ParsedFields) != 0 {
|
||||
t.Fatalf("fields projection must not duplicate raw payload: parsed=%v parsed_fields=%v", fields.Parsed, fields.ParsedFields)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fields_projector_projections_total{protocol="JT808",status="projected"} 1`,
|
||||
`vehicle_fields_projector_kafka_writes_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`,
|
||||
`vehicle_fields_projector_kafka_lag{partition="2",topic="vehicle.raw.go.jt808.v1"} 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metric missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBatchReliablySkipsNonRealtimeAndInvalidWithoutWriting(t *testing.T) {
|
||||
route := jt808ProjectionRoute()
|
||||
nonRealtime := projectorRawEnvelope()
|
||||
nonRealtime.MessageID = "0x0002"
|
||||
nonRealtime.ParsedFields = map[string]any{"jt808.header.message_id": "0x0002"}
|
||||
payload, _ := nonRealtime.MarshalJSONBytes()
|
||||
messages := []kafka.Message{
|
||||
{Topic: route.RawTopic, Value: []byte("{bad"), Partition: 0, Offset: 1, HighWaterMark: 3},
|
||||
{Topic: route.RawTopic, Value: payload, Partition: 0, Offset: 2, HighWaterMark: 3},
|
||||
}
|
||||
writer := &recordingProjectorWriter{}
|
||||
committer := &recordingProjectorCommitter{}
|
||||
registry := metrics.NewRegistry()
|
||||
processBatchReliably(context.Background(), discardProjectorLogger{}, registry, writer, committer, route, messages, time.Second, time.Millisecond)
|
||||
if writer.callCount() != 0 {
|
||||
t.Fatalf("writer calls = %d, want 0", writer.callCount())
|
||||
}
|
||||
if committer.callCount() != 1 || committer.messageCount != 2 {
|
||||
t.Fatalf("commit calls/messages = %d/%d", committer.callCount(), committer.messageCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if !strings.Contains(text, `vehicle_fields_projector_kafka_messages_total{status="invalid_json",topic="vehicle.raw.go.jt808.v1"} 1`) ||
|
||||
!strings.Contains(text, `vehicle_fields_projector_projections_total{protocol="JT808",status="skipped_non_realtime"} 1`) {
|
||||
t.Fatalf("skip metrics missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBatchReliablyRetriesWriteBeforeCommitting(t *testing.T) {
|
||||
route := jt808ProjectionRoute()
|
||||
payload, _ := projectorRawEnvelope().MarshalJSONBytes()
|
||||
messages := []kafka.Message{{Topic: route.RawTopic, Value: payload, Partition: 1, Offset: 7, HighWaterMark: 8}}
|
||||
writer := &recordingProjectorWriter{errors: []error{errors.New("kafka unavailable"), nil}}
|
||||
committer := &recordingProjectorCommitter{}
|
||||
registry := metrics.NewRegistry()
|
||||
processBatchReliably(context.Background(), discardProjectorLogger{}, registry, writer, committer, route, messages, time.Second, time.Millisecond)
|
||||
if writer.callCount() != 2 {
|
||||
t.Fatalf("writer calls = %d, want 2", writer.callCount())
|
||||
}
|
||||
if committer.callCount() != 1 {
|
||||
t.Fatalf("commit calls = %d, want 1 after successful write", committer.callCount())
|
||||
}
|
||||
if !strings.Contains(registry.Render(), `vehicle_fields_projector_batch_retries_total{protocol="JT808",reason="write_error"} 1`) {
|
||||
t.Fatalf("write retry metric missing:\n%s", registry.Render())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBatchReliablyRetriesOnlyCommitAfterSuccessfulWrite(t *testing.T) {
|
||||
route := jt808ProjectionRoute()
|
||||
payload, _ := projectorRawEnvelope().MarshalJSONBytes()
|
||||
messages := []kafka.Message{{Topic: route.RawTopic, Value: payload, Partition: 1, Offset: 7, HighWaterMark: 8}}
|
||||
writer := &recordingProjectorWriter{}
|
||||
committer := &recordingProjectorCommitter{errors: []error{errors.New("commit timeout"), nil}}
|
||||
processBatchReliably(context.Background(), discardProjectorLogger{}, metrics.NewRegistry(), writer, committer, route, messages, time.Second, time.Millisecond)
|
||||
if writer.callCount() != 1 {
|
||||
t.Fatalf("writer calls = %d, want 1", writer.callCount())
|
||||
}
|
||||
if committer.callCount() != 2 {
|
||||
t.Fatalf("commit calls = %d, want 2", committer.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBatchWriteFailureDoesNotCommitByItself(t *testing.T) {
|
||||
route := jt808ProjectionRoute()
|
||||
payload, _ := projectorRawEnvelope().MarshalJSONBytes()
|
||||
writer := &recordingProjectorWriter{errors: []error{errors.New("write failed")}}
|
||||
count, err := projectBatch(context.Background(), discardProjectorLogger{}, nil, writer, route, []kafka.Message{{Topic: route.RawTopic, Value: payload}})
|
||||
if err == nil || count != 1 {
|
||||
t.Fatalf("projectBatch() count/error = %d/%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func projectorRawEnvelope() envelope.FrameEnvelope {
|
||||
return envelope.FrameEnvelope{
|
||||
EventID: "raw-event-1",
|
||||
EventKind: envelope.EventKindRaw,
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
VIN: "VIN001",
|
||||
Phone: "13307795425",
|
||||
SourceEndpoint: "115.231.168.135:43625",
|
||||
SourceCode: "g7s",
|
||||
SourceKind: "PLATFORM",
|
||||
PlatformName: "G7s",
|
||||
EventTimeMS: 1783960000000,
|
||||
ReceivedAtMS: 1783960000010,
|
||||
ParsedFields: map[string]any{
|
||||
"jt808.location.latitude": 30.1,
|
||||
"jt808.location.longitude": 121.2,
|
||||
"jt808.location.total_mileage_km": 1234.5,
|
||||
},
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
}
|
||||
|
||||
func jt808ProjectionRoute() projectionRoute {
|
||||
return projectionRoute{
|
||||
Protocol: envelope.ProtocolJT808, RawTopic: topics.RawJT808, FieldsTopic: topics.FieldsJT808, GroupID: "projector-jt808",
|
||||
}
|
||||
}
|
||||
|
||||
type recordingProjectorWriter struct {
|
||||
mu sync.Mutex
|
||||
errors []error
|
||||
calls int
|
||||
messages []kafka.Message
|
||||
}
|
||||
|
||||
func (w *recordingProjectorWriter) WriteMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.calls++
|
||||
w.messages = append(w.messages, messages...)
|
||||
if len(w.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := w.errors[0]
|
||||
w.errors = w.errors[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *recordingProjectorWriter) callCount() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.calls
|
||||
}
|
||||
|
||||
type recordingProjectorCommitter struct {
|
||||
mu sync.Mutex
|
||||
errors []error
|
||||
calls int
|
||||
messageCount int
|
||||
}
|
||||
|
||||
func (c *recordingProjectorCommitter) CommitMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls++
|
||||
c.messageCount += len(messages)
|
||||
if len(c.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := c.errors[0]
|
||||
c.errors = c.errors[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *recordingProjectorCommitter) callCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.calls
|
||||
}
|
||||
|
||||
type discardProjectorLogger struct{}
|
||||
|
||||
func (discardProjectorLogger) Error(string, ...any) {}
|
||||
func (discardProjectorLogger) Warn(string, ...any) {}
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/gateway"
|
||||
@@ -37,7 +40,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer sink.Close()
|
||||
resolver, closeResolver, err := buildIdentityResolver(ctx, logger)
|
||||
resolver, jt808DeviceTokens, closeResolver, err := buildIdentityResolver(ctx, logger, registry)
|
||||
if err != nil {
|
||||
logger.Error("build identity resolver failed", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -45,21 +48,38 @@ func main() {
|
||||
defer closeResolver()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-gateway", nil, registry))
|
||||
publishUnified := envBool("PUBLISH_UNIFIED_ENABLED", false)
|
||||
delegateFields := envBool("FIELDS_DERIVE_FROM_RAW_ENABLED", strings.TrimSpace(os.Getenv("NATS_URL")) != "")
|
||||
gb32960Authenticator, gb32960AuthMode, gb32960CredentialCount, err := buildGB32960Authenticator()
|
||||
if err != nil {
|
||||
logger.Error("build gb32960 authenticator failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
jt808AuthCode := env("JT808_REGISTER_AUTH_CODE", "g7gps")
|
||||
jt808Authenticator, jt808AuthMode, err := buildJT808Authenticator(jt808AuthCode, jt808DeviceTokens)
|
||||
if err != nil {
|
||||
logger.Error("build jt808 authenticator failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
recordAuthenticationConfig(registry, envelope.ProtocolGB32960, gb32960AuthMode, gb32960CredentialCount)
|
||||
recordAuthenticationConfig(registry, envelope.ProtocolJT808, jt808AuthMode, int(boolMetric(strings.TrimSpace(jt808AuthCode) != "")))
|
||||
logger.Info("protocol authentication configured", "gb32960_mode", gb32960AuthMode, "gb32960_accounts", gb32960CredentialCount, "jt808_mode", jt808AuthMode)
|
||||
|
||||
protocols := []gateway.TCPProtocol{
|
||||
{
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
Addr: env("GB32960_TCP_ADDR", ":32960"),
|
||||
Extract: gb32960.ExtractFrames,
|
||||
Parse: gb32960.ParseFrame,
|
||||
Respond: gb32960.AutoResponse,
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
Addr: env("GB32960_TCP_ADDR", ":32960"),
|
||||
Extract: gb32960.ExtractFrames,
|
||||
Parse: gb32960.ParseFrame,
|
||||
Authenticate: gb32960Authenticator,
|
||||
Respond: gb32960.AutoResponse,
|
||||
},
|
||||
{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: env("JT808_TCP_ADDR", ":808"),
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: jt808.ParseFrame,
|
||||
Respond: jt808.NewAutoResponder(env("JT808_REGISTER_AUTH_CODE", "g7gps")).Respond,
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: env("JT808_TCP_ADDR", ":808"),
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: jt808.ParseFrame,
|
||||
Authenticate: jt808Authenticator,
|
||||
Respond: jt808.NewAutoResponder(jt808AuthCode).Respond,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,6 +96,7 @@ func main() {
|
||||
IdleTimeout: time.Duration(envInt("TCP_IDLE_TIMEOUT_SECONDS", 180)) * time.Second,
|
||||
MaxConnections: envInt("TCP_MAX_CONNECTIONS", 120_000),
|
||||
PublishUnified: publishUnified,
|
||||
DelegateFields: delegateFields,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("build tcp server failed", "protocol", protocol.Protocol, "error", err)
|
||||
@@ -110,6 +131,7 @@ func main() {
|
||||
Logger: logger,
|
||||
Metrics: registry,
|
||||
PublishUnified: publishUnified,
|
||||
DelegateFields: delegateFields,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("build yutong mqtt client failed", "error", err)
|
||||
@@ -122,7 +144,7 @@ func main() {
|
||||
logger.Info("yutong mqtt client started")
|
||||
}
|
||||
|
||||
logger.Info("vehicle gateway started")
|
||||
logger.Info("vehicle gateway started", "fields_derive_from_raw_enabled", delegateFields)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-errs:
|
||||
@@ -132,63 +154,433 @@ func main() {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func buildIdentityResolver(ctx context.Context, logger *slog.Logger) (identity.Resolver, func(), error) {
|
||||
func buildGB32960Authenticator() (authentication.Authenticator, authentication.Mode, int, error) {
|
||||
mode, err := authentication.ParseMode(os.Getenv("GB32960_AUTH_MODE"), authentication.ModeObserve)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
credentials, err := loadGB32960Credentials()
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
if mode == authentication.ModeEnforce && len(credentials) == 0 {
|
||||
return nil, "", 0, fmt.Errorf("GB32960_AUTH_MODE=enforce requires configured platform credentials")
|
||||
}
|
||||
return authentication.NewGB32960PlatformAuthenticator(mode, credentials), mode, len(credentials), nil
|
||||
}
|
||||
|
||||
func buildJT808Authenticator(authCode string, deviceTokens authentication.JT808DeviceTokenProvider) (authentication.Authenticator, authentication.Mode, error) {
|
||||
mode, err := authentication.ParseMode(os.Getenv("JT808_AUTH_MODE"), authentication.ModeObserve)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if mode == authentication.ModeEnforce && strings.TrimSpace(authCode) == "" && deviceTokens == nil {
|
||||
return nil, "", fmt.Errorf("JT808_AUTH_MODE=enforce requires a configured or device token provider")
|
||||
}
|
||||
return authentication.NewJT808Authenticator(mode, authCode, deviceTokens), mode, nil
|
||||
}
|
||||
|
||||
func loadGB32960Credentials() (map[string][]string, error) {
|
||||
payload := strings.TrimSpace(os.Getenv("GB32960_PLATFORM_CREDENTIALS_JSON"))
|
||||
if path := strings.TrimSpace(os.Getenv("GB32960_PLATFORM_CREDENTIALS_FILE")); path != "" {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read gb32960 credentials file: %w", err)
|
||||
}
|
||||
payload = strings.TrimSpace(string(contents))
|
||||
}
|
||||
if payload == "" {
|
||||
return map[string][]string{}, nil
|
||||
}
|
||||
rawCredentials := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(payload), &rawCredentials); err != nil {
|
||||
return nil, fmt.Errorf("parse gb32960 platform credentials: %w", err)
|
||||
}
|
||||
credentials := make(map[string][]string, len(rawCredentials))
|
||||
for username, rawPassword := range rawCredentials {
|
||||
trimmed := strings.TrimSpace(username)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
var passwords []string
|
||||
var single string
|
||||
if err := json.Unmarshal(rawPassword, &single); err == nil {
|
||||
passwords = []string{single}
|
||||
} else if err := json.Unmarshal(rawPassword, &passwords); err != nil {
|
||||
return nil, fmt.Errorf("parse gb32960 credentials for account %q: expected string or string array", trimmed)
|
||||
}
|
||||
for _, password := range passwords {
|
||||
if password != "" {
|
||||
credentials[trimmed] = append(credentials[trimmed], password)
|
||||
}
|
||||
}
|
||||
if len(credentials[trimmed]) == 0 {
|
||||
delete(credentials, trimmed)
|
||||
}
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func recordAuthenticationConfig(registry *metrics.Registry, protocol envelope.Protocol, mode authentication.Mode, credentialCount int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_gateway_authentication_mode", metrics.Labels{
|
||||
"protocol": string(protocol),
|
||||
"mode": string(mode),
|
||||
}, 1)
|
||||
registry.SetGauge("vehicle_gateway_authentication_credentials", metrics.Labels{
|
||||
"protocol": string(protocol),
|
||||
}, float64(credentialCount))
|
||||
}
|
||||
|
||||
func buildIdentityResolver(ctx context.Context, logger *slog.Logger, registry *metrics.Registry) (identity.Resolver, authentication.JT808DeviceTokenProvider, func(), error) {
|
||||
dsn := env("IDENTITY_MYSQL_DSN", strings.TrimSpace(os.Getenv("MYSQL_DSN")))
|
||||
if strings.TrimSpace(dsn) == "" {
|
||||
logger.Warn("identity mysql dsn is empty; using noop identity resolver")
|
||||
return identity.NoopResolver{}, func() {}, nil
|
||||
return identity.NoopResolver{}, nil, func() {}, nil
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, nil, err
|
||||
logger.Error("identity mysql configuration failed; continuing with unresolved identities", "error", err)
|
||||
return identity.NoopResolver{}, nil, func() {}, nil
|
||||
}
|
||||
db.SetMaxOpenConns(envInt("IDENTITY_MYSQL_MAX_OPEN_CONNS", 16))
|
||||
db.SetMaxIdleConns(envInt("IDENTITY_MYSQL_MAX_IDLE_CONNS", 8))
|
||||
db.SetConnMaxLifetime(time.Duration(envInt("IDENTITY_MYSQL_CONN_MAX_LIFETIME_SECONDS", 300)) * time.Second)
|
||||
db.SetConnMaxIdleTime(time.Duration(envInt("IDENTITY_MYSQL_CONN_MAX_IDLE_SECONDS", 60)) * time.Second)
|
||||
table := env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding")
|
||||
cacheMaxEntries := envInt("IDENTITY_LOOKUP_CACHE_MAX_ENTRIES", 300000)
|
||||
cacheCleanupInterval := time.Duration(envInt("IDENTITY_LOOKUP_CACHE_CLEANUP_INTERVAL_SECONDS", 60)) * time.Second
|
||||
staleLookupTTLSeconds := envInt("IDENTITY_STALE_LOOKUP_TTL_SECONDS", 3600)
|
||||
registrationGatewayWritesEnabled := envBool("JT808_REGISTRATION_GATEWAY_WRITES_ENABLED", false)
|
||||
resolver := identity.NewMySQLResolverWithOptions(db, table, identity.MySQLResolverOptions{
|
||||
LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
LookupCacheTTL: time.Duration(envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600)) * time.Second,
|
||||
SnapshotOnlyLookups: envBool("IDENTITY_SNAPSHOT_ONLY_ENABLED", true),
|
||||
LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
LocationTouchRetryInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_RETRY_INTERVAL_SECONDS", 5)) * time.Second,
|
||||
RegistrationWriteAttempts: envInt("JT808_REGISTRATION_WRITE_RETRY_ATTEMPTS", 2),
|
||||
RegistrationWriteRetryDelay: time.Duration(envInt("JT808_REGISTRATION_WRITE_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
||||
LookupCacheTTL: time.Duration(envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600)) * time.Second,
|
||||
StaleLookupTTL: time.Duration(staleLookupTTLSeconds) * time.Second,
|
||||
CacheCleanupInterval: cacheCleanupInterval,
|
||||
MaxCacheEntries: cacheMaxEntries,
|
||||
SourceCodeLookup: envBool("IDENTITY_SOURCE_CODE_LOOKUP_ENABLED", true),
|
||||
AsyncRegistrationWrites: envBool("JT808_REGISTRATION_ASYNC_WRITE_ENABLED", true),
|
||||
RegistrationWriteQueueSize: envInt("JT808_REGISTRATION_WRITE_QUEUE_SIZE", 100000),
|
||||
RegistrationWriteWorkers: envInt("JT808_REGISTRATION_WRITE_WORKERS", 4),
|
||||
RegistrationWriteTimeout: time.Duration(envInt("JT808_REGISTRATION_WRITE_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
RegistrationEnqueueTimeout: time.Duration(envInt("JT808_REGISTRATION_WRITE_ENQUEUE_TIMEOUT_MS", 50)) * time.Millisecond,
|
||||
DisableRegistrationWrites: !registrationGatewayWritesEnabled,
|
||||
OnRegistrationWriteResult: func(result identity.RegistrationWriteResult) {
|
||||
recordJT808RegistrationWriteResult(registry, result)
|
||||
},
|
||||
OnRegistrationWriteError: func(err error) {
|
||||
logger.Warn("jt808 registration async write failed", "error", err)
|
||||
},
|
||||
})
|
||||
if envBool("IDENTITY_MYSQL_ENSURE_SCHEMA", true) {
|
||||
if err := resolver.EnsureSchema(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
startIdentityDatabaseMaintenance(
|
||||
ctx,
|
||||
logger,
|
||||
db,
|
||||
resolver,
|
||||
envBool("IDENTITY_MYSQL_ENSURE_SCHEMA", false),
|
||||
time.Duration(envInt("IDENTITY_MYSQL_PING_TIMEOUT_MS", 3000))*time.Millisecond,
|
||||
time.Duration(envInt("IDENTITY_MYSQL_SCHEMA_TIMEOUT_SECONDS", 10))*time.Second,
|
||||
)
|
||||
startIdentitySnapshotRefresh(
|
||||
ctx,
|
||||
logger,
|
||||
registry,
|
||||
resolver,
|
||||
time.Duration(envInt("IDENTITY_SNAPSHOT_REFRESH_INTERVAL_SECONDS", 60))*time.Second,
|
||||
time.Duration(envInt("IDENTITY_SNAPSHOT_REFRESH_TIMEOUT_SECONDS", 10))*time.Second,
|
||||
)
|
||||
startIdentityCacheMetrics(ctx, registry, resolver, time.Duration(envInt("IDENTITY_CACHE_METRICS_INTERVAL_SECONDS", 30))*time.Second)
|
||||
resolveTimeout := time.Duration(envInt("IDENTITY_RESOLVE_TIMEOUT_MS", 50)) * time.Millisecond
|
||||
registry.SetGauge("vehicle_gateway_jt808_registration_gateway_writes_enabled", nil, boolMetric(registrationGatewayWritesEnabled))
|
||||
logger.Info("identity mysql resolver enabled", "table", table, "snapshot_only_enabled", envBool("IDENTITY_SNAPSHOT_ONLY_ENABLED", true), "snapshot_refresh_interval_seconds", envInt("IDENTITY_SNAPSHOT_REFRESH_INTERVAL_SECONDS", 60), "lookup_cache_ttl_seconds", envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600), "stale_lookup_ttl_seconds", staleLookupTTLSeconds, "lookup_cache_max_entries", cacheMaxEntries, "lookup_cache_cleanup_interval_seconds", cacheCleanupInterval.Seconds(), "source_code_lookup_enabled", envBool("IDENTITY_SOURCE_CODE_LOOKUP_ENABLED", true), "resolve_timeout_ms", resolveTimeout.Milliseconds(), "jt808_registration_gateway_writes_enabled", registrationGatewayWritesEnabled, "jt808_registration_location_touch_retry_interval_seconds", envInt("JT808_REGISTRATION_LOCATION_TOUCH_RETRY_INTERVAL_SECONDS", 5), "jt808_registration_write_retry_attempts", envInt("JT808_REGISTRATION_WRITE_RETRY_ATTEMPTS", 2), "jt808_registration_write_retry_delay_ms", envInt("JT808_REGISTRATION_WRITE_RETRY_DELAY_MS", 20), "jt808_registration_async_write_enabled", envBool("JT808_REGISTRATION_ASYNC_WRITE_ENABLED", true), "jt808_registration_write_queue_size", envInt("JT808_REGISTRATION_WRITE_QUEUE_SIZE", 100000), "jt808_registration_write_workers", envInt("JT808_REGISTRATION_WRITE_WORKERS", 4), "jt808_registration_write_timeout_ms", envInt("JT808_REGISTRATION_WRITE_TIMEOUT_MS", 5000), "jt808_registration_write_enqueue_timeout_ms", envInt("JT808_REGISTRATION_WRITE_ENQUEUE_TIMEOUT_MS", 50))
|
||||
return identity.TimeoutResolver{Delegate: resolver, Timeout: resolveTimeout}, resolver, func() {
|
||||
_ = resolver.Close()
|
||||
_ = db.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
type identitySnapshotRefresher interface {
|
||||
RefreshSnapshot(context.Context) (identity.SnapshotRefreshResult, error)
|
||||
}
|
||||
|
||||
type identityDatabasePinger interface {
|
||||
PingContext(context.Context) error
|
||||
}
|
||||
|
||||
type identitySchemaEnsurer interface {
|
||||
EnsureSchema(context.Context) error
|
||||
}
|
||||
|
||||
func startIdentityDatabaseMaintenance(ctx context.Context, logger *slog.Logger, db identityDatabasePinger, schema identitySchemaEnsurer, ensureSchema bool, pingTimeout time.Duration, schemaTimeout time.Duration) {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
logger.Info("identity mysql resolver enabled", "table", table, "lookup_cache_ttl_seconds", envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600))
|
||||
return resolver, func() { _ = db.Close() }, nil
|
||||
if pingTimeout <= 0 {
|
||||
pingTimeout = 3 * time.Second
|
||||
}
|
||||
if schemaTimeout <= 0 {
|
||||
schemaTimeout = 10 * time.Second
|
||||
}
|
||||
go func() {
|
||||
pingCtx, cancelPing := context.WithTimeout(ctx, pingTimeout)
|
||||
err := db.PingContext(pingCtx)
|
||||
cancelPing()
|
||||
if err != nil {
|
||||
logger.Warn("identity mysql unavailable; ingress remains enabled and snapshot refresh will retry", "error", err)
|
||||
return
|
||||
}
|
||||
if !ensureSchema || schema == nil {
|
||||
return
|
||||
}
|
||||
schemaCtx, cancelSchema := context.WithTimeout(ctx, schemaTimeout)
|
||||
err = schema.EnsureSchema(schemaCtx)
|
||||
cancelSchema()
|
||||
if err != nil {
|
||||
logger.Warn("identity mysql schema check failed; ingress remains enabled", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startIdentitySnapshotRefresh(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, refresher identitySnapshotRefresher, interval time.Duration, timeout time.Duration) {
|
||||
if refresher == nil {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
refresh := func() {
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
result, err := refresher.RefreshSnapshot(refreshCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
recordIdentitySnapshotRefresh(registry, identity.SnapshotRefreshResult{}, "error")
|
||||
logger.Warn("identity snapshot refresh failed; keeping last known good snapshot", "error", err)
|
||||
return
|
||||
}
|
||||
recordIdentitySnapshotRefresh(registry, result, "ok")
|
||||
logger.Info("identity snapshot refreshed", "binding_entries", result.BindingEntries, "identifier_entries", result.IdentifierEntries, "registration_entries", result.RegistrationEntries, "source_entries", result.SourceEntries)
|
||||
}
|
||||
go func() {
|
||||
refresh()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func recordIdentitySnapshotRefresh(registry *metrics.Registry, result identity.SnapshotRefreshResult, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_gateway_identity_snapshot_refresh_total", metrics.Labels{"status": status})
|
||||
if status != "ok" {
|
||||
return
|
||||
}
|
||||
for _, item := range []struct {
|
||||
kind string
|
||||
value int
|
||||
}{
|
||||
{kind: "binding", value: result.BindingEntries},
|
||||
{kind: "identifier", value: result.IdentifierEntries},
|
||||
{kind: "registration", value: result.RegistrationEntries},
|
||||
{kind: "source", value: result.SourceEntries},
|
||||
} {
|
||||
registry.SetGauge("vehicle_gateway_identity_snapshot_entries", metrics.Labels{"kind": item.kind}, float64(item.value))
|
||||
}
|
||||
registry.SetGauge("vehicle_gateway_identity_snapshot_last_success_unix_seconds", nil, float64(result.RefreshedAt.Unix()))
|
||||
}
|
||||
|
||||
type identityCacheStatsReporter interface {
|
||||
CacheStats() identity.CacheStats
|
||||
}
|
||||
|
||||
func startIdentityCacheMetrics(ctx context.Context, registry *metrics.Registry, reporter identityCacheStatsReporter, interval time.Duration) {
|
||||
if registry == nil || reporter == nil {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
recordIdentityCacheStats(registry, reporter.CacheStats())
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
recordIdentityCacheStats(registry, reporter.CacheStats())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func recordIdentityCacheStats(registry *metrics.Registry, stats identity.CacheStats) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value int
|
||||
max int
|
||||
}{
|
||||
{name: "lookup", value: stats.LookupEntries, max: stats.MaxEntries},
|
||||
{name: "registration", value: stats.RegistrationEntries, max: stats.MaxEntries},
|
||||
{name: "source_code", value: stats.SourceCodeEntries, max: stats.MaxEntries},
|
||||
{name: "location_touch", value: stats.LocationTouchEntries, max: stats.MaxEntries},
|
||||
{name: "location_touch_failure", value: stats.LocationTouchFailureEntries, max: stats.MaxEntries},
|
||||
{name: "registration_write_queue", value: stats.RegistrationWriteQueueDepth, max: stats.RegistrationWriteQueueCap},
|
||||
{name: "snapshot_binding", value: stats.SnapshotBindingEntries, max: stats.MaxEntries},
|
||||
{name: "snapshot_identifier", value: stats.SnapshotIdentifierEntries, max: stats.MaxEntries},
|
||||
{name: "snapshot_registration", value: stats.SnapshotRegistrationEntries, max: stats.MaxEntries},
|
||||
{name: "snapshot_source", value: stats.SnapshotSourceEntries, max: stats.MaxEntries},
|
||||
} {
|
||||
registry.SetGauge("vehicle_gateway_identity_cache_entries", metrics.Labels{"cache": item.name}, float64(item.value))
|
||||
registry.SetGauge("vehicle_gateway_identity_cache_max_entries", metrics.Labels{"cache": item.name}, float64(item.max))
|
||||
}
|
||||
ready := 0.0
|
||||
if stats.SnapshotReady {
|
||||
ready = 1
|
||||
}
|
||||
registry.SetGauge("vehicle_gateway_identity_snapshot_ready", nil, ready)
|
||||
if !stats.SnapshotRefreshedAt.IsZero() {
|
||||
registry.SetGauge("vehicle_gateway_identity_snapshot_last_success_unix_seconds", nil, float64(stats.SnapshotRefreshedAt.Unix()))
|
||||
}
|
||||
}
|
||||
|
||||
func recordJT808RegistrationWriteResult(registry *metrics.Registry, result identity.RegistrationWriteResult) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
mode := strings.TrimSpace(result.Mode)
|
||||
if mode == "" {
|
||||
mode = "unknown"
|
||||
}
|
||||
status := strings.TrimSpace(result.Status)
|
||||
if status == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
registry.IncCounter("vehicle_gateway_jt808_registration_write_total", metrics.Labels{
|
||||
"mode": mode,
|
||||
"status": status,
|
||||
})
|
||||
metrics.RecordLastActivity(registry, "vehicle_gateway_last_jt808_registration_write_unix_seconds", metrics.Labels{
|
||||
"mode": mode,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func buildSink(ctx context.Context, logger *slog.Logger, registry *metrics.Registry) (eventbus.Sink, error) {
|
||||
if strings.TrimSpace(os.Getenv("NATS_URL")) != "" {
|
||||
sink, err := eventbus.NewNATSSink(natsSinkConfigFromEnv())
|
||||
natsConfig := natsSinkConfigFromEnv()
|
||||
sink, err := eventbus.NewNATSSink(natsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outboxRuntime := natsOutboxConfigFromEnv(registry, func(err error) {
|
||||
logger.Warn("nats durable outbox publish failed", "error", err)
|
||||
})
|
||||
if outboxRuntime.Enabled {
|
||||
outbox, err := eventbus.NewDurableOutboxSink(sink, outboxRuntime.Config)
|
||||
if err != nil {
|
||||
_ = sink.Close()
|
||||
return nil, err
|
||||
}
|
||||
go outbox.ReplayLoop(ctx, outboxRuntime.ReplayInterval)
|
||||
logger.Info("nats durable outbox enabled",
|
||||
"dir", outboxRuntime.Config.Directory,
|
||||
"fsync", outboxRuntime.Config.SyncWrites,
|
||||
"replay_interval_ms", outboxRuntime.ReplayInterval.Milliseconds(),
|
||||
"replay_batch_size", outboxRuntime.Config.ReplayBatchSize,
|
||||
"close_timeout_ms", outboxRuntime.Config.CloseTimeout.Milliseconds(),
|
||||
"wal_segment_bytes", outboxRuntime.Config.WALSegmentBytes,
|
||||
"wal_segment_age_ms", outboxRuntime.Config.WALSegmentAge.Milliseconds(),
|
||||
"wal_append_queue_size", outboxRuntime.Config.WALAppendQueue,
|
||||
"wal_commit_batch_size", outboxRuntime.Config.WALCommitBatch,
|
||||
"wal_commit_interval_ms", outboxRuntime.Config.WALCommitWait.Milliseconds(),
|
||||
"max_inflight", natsConfig.AsyncMaxPending,
|
||||
"ack_timeout_ms", natsConfig.AsyncAckTimeout.Milliseconds(),
|
||||
)
|
||||
return outbox, nil
|
||||
}
|
||||
var out eventbus.Sink = eventbus.NewRetryingSink(sink, eventbus.RetryConfig{
|
||||
Attempts: envInt("NATS_PUBLISH_ATTEMPTS", 3),
|
||||
Backoff: time.Duration(envInt("NATS_PUBLISH_BACKOFF_MS", 100)) * time.Millisecond,
|
||||
AttemptTimeout: time.Duration(envInt("NATS_PUBLISH_TIMEOUT_MS", 3000)) * time.Millisecond,
|
||||
})
|
||||
spoolDir := strings.TrimSpace(os.Getenv("NATS_SPOOL_DIR"))
|
||||
if spoolDir != "" {
|
||||
replayBatchSize := envInt("NATS_SPOOL_REPLAY_BATCH_SIZE", 200)
|
||||
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{
|
||||
Directory: spoolDir,
|
||||
ReplayBatchSize: replayBatchSize,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
})
|
||||
interval := time.Duration(envInt("NATS_SPOOL_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond
|
||||
go durable.ReplayLoop(ctx, interval, func(err error) {
|
||||
logger.Warn("nats spool replay failed", "error", err)
|
||||
})
|
||||
logger.Info("nats durable spool enabled", "dir", spoolDir, "replay_interval_ms", interval.Milliseconds(), "replay_batch_size", replayBatchSize)
|
||||
out = durable
|
||||
}
|
||||
if envBool("NATS_ASYNC_ENABLED", true) {
|
||||
queueSize := envInt("NATS_ASYNC_QUEUE_SIZE", envInt("KAFKA_ASYNC_QUEUE_SIZE", 100000))
|
||||
workers := envInt("NATS_ASYNC_WORKERS", envInt("KAFKA_ASYNC_WORKERS", 8))
|
||||
enqueueTimeout := time.Duration(envInt("NATS_ASYNC_ENQUEUE_TIMEOUT_MS", envInt("KAFKA_ASYNC_ENQUEUE_TIMEOUT_MS", 1000))) * time.Millisecond
|
||||
timeout := time.Duration(envInt("NATS_ASYNC_PUBLISH_TIMEOUT_MS", envInt("KAFKA_ASYNC_PUBLISH_TIMEOUT_MS", 30000))) * time.Millisecond
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("nats async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("nats async publish enabled", "queue_size", queueSize, "workers", workers, "publish_timeout_ms", timeout.Milliseconds())
|
||||
if envBool("NATS_PARTITIONED_ASYNC_ENABLED", true) {
|
||||
rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers := partitionedAsyncConfigFromEnv("NATS", queueSize, workers)
|
||||
rawEnqueueTimeout, derivedEnqueueTimeout := partitionedAsyncEnqueueTimeoutsFromEnv("NATS", enqueueTimeout)
|
||||
out = eventbus.NewPartitionedAsyncSink(out, eventbus.PartitionedAsyncConfig{
|
||||
RawQueueSize: rawQueueSize,
|
||||
DerivedQueueSize: derivedQueueSize,
|
||||
RawWorkers: rawWorkers,
|
||||
DerivedWorkers: derivedWorkers,
|
||||
RawEnqueueTimeout: rawEnqueueTimeout,
|
||||
DerivedEnqueueTimeout: derivedEnqueueTimeout,
|
||||
EnqueueTimeout: enqueueTimeout,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("nats partitioned async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("nats partitioned async publish enabled", "raw_queue_size", rawQueueSize, "derived_queue_size", derivedQueueSize, "raw_workers", rawWorkers, "derived_workers", derivedWorkers, "raw_enqueue_timeout_ms", rawEnqueueTimeout.Milliseconds(), "derived_enqueue_timeout_ms", derivedEnqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
|
||||
} else {
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
EnqueueTimeout: enqueueTimeout,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("nats async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("nats async publish enabled", "queue_size", queueSize, "workers", workers, "enqueue_timeout_ms", enqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
|
||||
}
|
||||
}
|
||||
logger.Info("nats jetstream sink enabled", "url", env("NATS_URL", ""), "unified_subject", natsSinkConfigFromEnv().UnifiedSubject, "publish_unified_enabled", envBool("PUBLISH_UNIFIED_ENABLED", false), "publish_fields_enabled", true)
|
||||
logger.Info("nats jetstream sink enabled", "url", env("NATS_URL", ""), "unified_subject", natsSinkConfigFromEnv().UnifiedSubject, "publish_unified_enabled", envBool("PUBLISH_UNIFIED_ENABLED", false), "publish_fields_enabled", !envBool("FIELDS_DERIVE_FROM_RAW_ENABLED", true))
|
||||
return out, nil
|
||||
}
|
||||
brokers := splitCSV(os.Getenv("KAFKA_BROKERS"))
|
||||
@@ -221,7 +613,12 @@ func buildSink(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
|
||||
spoolDir := strings.TrimSpace(os.Getenv("KAFKA_SPOOL_DIR"))
|
||||
if spoolDir != "" {
|
||||
replayBatchSize := envInt("KAFKA_SPOOL_REPLAY_BATCH_SIZE", 200)
|
||||
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{Directory: spoolDir, ReplayBatchSize: replayBatchSize})
|
||||
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{
|
||||
Directory: spoolDir,
|
||||
ReplayBatchSize: replayBatchSize,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
})
|
||||
interval := time.Duration(envInt("KAFKA_SPOOL_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond
|
||||
go durable.ReplayLoop(ctx, interval, func(err error) {
|
||||
logger.Warn("kafka spool replay failed", "error", err)
|
||||
@@ -232,26 +629,73 @@ func buildSink(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
|
||||
if envBool("KAFKA_ASYNC_ENABLED", true) {
|
||||
queueSize := envInt("KAFKA_ASYNC_QUEUE_SIZE", 100000)
|
||||
workers := envInt("KAFKA_ASYNC_WORKERS", 8)
|
||||
enqueueTimeout := time.Duration(envInt("KAFKA_ASYNC_ENQUEUE_TIMEOUT_MS", 1000)) * time.Millisecond
|
||||
timeout := time.Duration(envInt("KAFKA_ASYNC_PUBLISH_TIMEOUT_MS", 30000)) * time.Millisecond
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("kafka async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("kafka async publish enabled", "queue_size", queueSize, "workers", workers, "publish_timeout_ms", timeout.Milliseconds())
|
||||
if envBool("KAFKA_PARTITIONED_ASYNC_ENABLED", true) {
|
||||
rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers := partitionedAsyncConfigFromEnv("KAFKA", queueSize, workers)
|
||||
rawEnqueueTimeout, derivedEnqueueTimeout := partitionedAsyncEnqueueTimeoutsFromEnv("KAFKA", enqueueTimeout)
|
||||
out = eventbus.NewPartitionedAsyncSink(out, eventbus.PartitionedAsyncConfig{
|
||||
RawQueueSize: rawQueueSize,
|
||||
DerivedQueueSize: derivedQueueSize,
|
||||
RawWorkers: rawWorkers,
|
||||
DerivedWorkers: derivedWorkers,
|
||||
RawEnqueueTimeout: rawEnqueueTimeout,
|
||||
DerivedEnqueueTimeout: derivedEnqueueTimeout,
|
||||
EnqueueTimeout: enqueueTimeout,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("kafka partitioned async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("kafka partitioned async publish enabled", "raw_queue_size", rawQueueSize, "derived_queue_size", derivedQueueSize, "raw_workers", rawWorkers, "derived_workers", derivedWorkers, "raw_enqueue_timeout_ms", rawEnqueueTimeout.Milliseconds(), "derived_enqueue_timeout_ms", derivedEnqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
|
||||
} else {
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
EnqueueTimeout: enqueueTimeout,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("kafka async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("kafka async publish enabled", "queue_size", queueSize, "workers", workers, "enqueue_timeout_ms", enqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func partitionedAsyncConfigFromEnv(prefix string, queueSize int, workers int) (rawQueueSize int, derivedQueueSize int, rawWorkers int, derivedWorkers int) {
|
||||
derivedQueueDefault := queueSize / 2
|
||||
if derivedQueueDefault <= 0 {
|
||||
derivedQueueDefault = 1
|
||||
}
|
||||
derivedWorkersDefault := workers / 2
|
||||
if derivedWorkersDefault <= 0 {
|
||||
derivedWorkersDefault = 1
|
||||
}
|
||||
rawQueueSize = envInt(prefix+"_ASYNC_RAW_QUEUE_SIZE", queueSize)
|
||||
derivedQueueSize = envInt(prefix+"_ASYNC_DERIVED_QUEUE_SIZE", derivedQueueDefault)
|
||||
rawWorkers = envInt(prefix+"_ASYNC_RAW_WORKERS", workers)
|
||||
derivedWorkers = envInt(prefix+"_ASYNC_DERIVED_WORKERS", derivedWorkersDefault)
|
||||
return rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers
|
||||
}
|
||||
|
||||
func partitionedAsyncEnqueueTimeoutsFromEnv(prefix string, enqueueTimeout time.Duration) (rawEnqueueTimeout time.Duration, derivedEnqueueTimeout time.Duration) {
|
||||
rawEnqueueTimeout = time.Duration(envInt(prefix+"_ASYNC_RAW_ENQUEUE_TIMEOUT_MS", int(enqueueTimeout/time.Millisecond))) * time.Millisecond
|
||||
derivedEnqueueTimeout = time.Duration(envInt(prefix+"_ASYNC_DERIVED_ENQUEUE_TIMEOUT_MS", 50)) * time.Millisecond
|
||||
return rawEnqueueTimeout, derivedEnqueueTimeout
|
||||
}
|
||||
|
||||
func natsSinkConfigFromEnv() eventbus.NATSConfig {
|
||||
return eventbus.NATSConfig{
|
||||
URL: env("NATS_URL", ""),
|
||||
Name: env("NATS_CLIENT_NAME", "lingniu-vehicle-gateway"),
|
||||
URL: env("NATS_URL", ""),
|
||||
Name: env("NATS_CLIENT_NAME", "lingniu-vehicle-gateway"),
|
||||
AsyncMaxPending: envInt("NATS_OUTBOX_MAX_INFLIGHT", 10_000),
|
||||
AsyncAckTimeout: time.Duration(envInt("NATS_OUTBOX_ACK_TIMEOUT_MS", 3000)) * time.Millisecond,
|
||||
RawSubjects: map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)),
|
||||
envelope.ProtocolJT808: env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)),
|
||||
@@ -266,6 +710,34 @@ func natsSinkConfigFromEnv() eventbus.NATSConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type natsOutboxRuntimeConfig struct {
|
||||
Enabled bool
|
||||
Config eventbus.DurableOutboxConfig
|
||||
ReplayInterval time.Duration
|
||||
}
|
||||
|
||||
func natsOutboxConfigFromEnv(registry *metrics.Registry, onError func(error)) natsOutboxRuntimeConfig {
|
||||
directory := strings.TrimSpace(os.Getenv("NATS_OUTBOX_DIR"))
|
||||
return natsOutboxRuntimeConfig{
|
||||
Enabled: envBool("NATS_DURABLE_OUTBOX_ENABLED", false),
|
||||
Config: eventbus.DurableOutboxConfig{
|
||||
Directory: directory,
|
||||
ReplayBatchSize: envInt("NATS_OUTBOX_REPLAY_BATCH_SIZE", 1000),
|
||||
SyncWrites: envBool("NATS_OUTBOX_FSYNC", true),
|
||||
CloseTimeout: time.Duration(envInt("NATS_OUTBOX_CLOSE_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
WALSegmentBytes: int64(envInt("NATS_OUTBOX_WAL_SEGMENT_BYTES", 16<<20)),
|
||||
WALSegmentAge: time.Duration(envInt("NATS_OUTBOX_WAL_SEGMENT_AGE_MS", 5000)) * time.Millisecond,
|
||||
WALAppendQueue: envInt("NATS_OUTBOX_WAL_APPEND_QUEUE_SIZE", 100_000),
|
||||
WALCommitBatch: envInt("NATS_OUTBOX_WAL_COMMIT_BATCH_SIZE", 256),
|
||||
WALCommitWait: time.Duration(envInt("NATS_OUTBOX_WAL_COMMIT_INTERVAL_MS", 1)) * time.Millisecond,
|
||||
Metrics: registry,
|
||||
Name: "nats-outbox",
|
||||
OnError: onError,
|
||||
},
|
||||
ReplayInterval: time.Duration(envInt("NATS_OUTBOX_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
@@ -300,6 +772,13 @@ func envBool(key string, fallback bool) bool {
|
||||
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||
}
|
||||
|
||||
func boolMetric(value bool) float64 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestNATSSinkConfigFromEnvUsesExplicitSubjects(t *testing.T) {
|
||||
@@ -13,7 +21,12 @@ func TestNATSSinkConfigFromEnvUsesExplicitSubjects(t *testing.T) {
|
||||
t.Setenv("NATS_SUBJECT_GB32960_RAW", "custom.raw.gb32960")
|
||||
t.Setenv("NATS_SUBJECT_JT808_RAW", "custom.raw.jt808")
|
||||
t.Setenv("NATS_SUBJECT_YUTONG_MQTT_RAW", "custom.raw.yutong")
|
||||
t.Setenv("NATS_SUBJECT_GB32960_FIELDS", "custom.fields.gb32960")
|
||||
t.Setenv("NATS_SUBJECT_JT808_FIELDS", "custom.fields.jt808")
|
||||
t.Setenv("NATS_SUBJECT_YUTONG_MQTT_FIELDS", "custom.fields.yutong")
|
||||
t.Setenv("NATS_SUBJECT_UNIFIED", "custom.unified")
|
||||
t.Setenv("NATS_OUTBOX_MAX_INFLIGHT", "12000")
|
||||
t.Setenv("NATS_OUTBOX_ACK_TIMEOUT_MS", "4500")
|
||||
|
||||
cfg := natsSinkConfigFromEnv()
|
||||
|
||||
@@ -29,9 +42,131 @@ func TestNATSSinkConfigFromEnvUsesExplicitSubjects(t *testing.T) {
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolYutongMQTT], "custom.raw.yutong"; got != want {
|
||||
t.Fatalf("yutong subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolGB32960], "custom.fields.gb32960"; got != want {
|
||||
t.Fatalf("gb32960 fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolJT808], "custom.fields.jt808"; got != want {
|
||||
t.Fatalf("jt808 fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolYutongMQTT], "custom.fields.yutong"; got != want {
|
||||
t.Fatalf("yutong fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.UnifiedSubject, "custom.unified"; got != want {
|
||||
t.Fatalf("unified subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.AsyncMaxPending, 12000; got != want {
|
||||
t.Fatalf("async max pending = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := cfg.AsyncAckTimeout, 4500*time.Millisecond; got != want {
|
||||
t.Fatalf("async ack timeout = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATSOutboxConfigFromEnvIsExplicitAndDefaultsToSafeFsync(t *testing.T) {
|
||||
t.Setenv("NATS_DURABLE_OUTBOX_ENABLED", "true")
|
||||
t.Setenv("NATS_OUTBOX_DIR", "/var/lib/lingniu-go/nats-outbox")
|
||||
t.Setenv("NATS_OUTBOX_REPLAY_BATCH_SIZE", "750")
|
||||
t.Setenv("NATS_OUTBOX_REPLAY_INTERVAL_MS", "250")
|
||||
t.Setenv("NATS_OUTBOX_CLOSE_TIMEOUT_MS", "7000")
|
||||
t.Setenv("NATS_OUTBOX_WAL_SEGMENT_BYTES", "8388608")
|
||||
t.Setenv("NATS_OUTBOX_WAL_SEGMENT_AGE_MS", "4000")
|
||||
t.Setenv("NATS_OUTBOX_WAL_APPEND_QUEUE_SIZE", "50000")
|
||||
t.Setenv("NATS_OUTBOX_WAL_COMMIT_BATCH_SIZE", "128")
|
||||
t.Setenv("NATS_OUTBOX_WAL_COMMIT_INTERVAL_MS", "2")
|
||||
t.Setenv("NATS_OUTBOX_FSYNC", "")
|
||||
registry := metrics.NewRegistry()
|
||||
onError := func(error) {}
|
||||
|
||||
runtime := natsOutboxConfigFromEnv(registry, onError)
|
||||
|
||||
if !runtime.Enabled {
|
||||
t.Fatal("outbox should be enabled")
|
||||
}
|
||||
if got, want := runtime.Config.Directory, "/var/lib/lingniu-go/nats-outbox"; got != want {
|
||||
t.Fatalf("directory = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.ReplayBatchSize, 750; got != want {
|
||||
t.Fatalf("replay batch = %d, want %d", got, want)
|
||||
}
|
||||
if !runtime.Config.SyncWrites {
|
||||
t.Fatal("outbox fsync should default to true")
|
||||
}
|
||||
if got, want := runtime.Config.CloseTimeout, 7*time.Second; got != want {
|
||||
t.Fatalf("close timeout = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALSegmentBytes, int64(8<<20); got != want {
|
||||
t.Fatalf("WAL segment bytes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALSegmentAge, 4*time.Second; got != want {
|
||||
t.Fatalf("WAL segment age = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALAppendQueue, 50_000; got != want {
|
||||
t.Fatalf("WAL append queue = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALCommitBatch, 128; got != want {
|
||||
t.Fatalf("WAL commit batch = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := runtime.Config.WALCommitWait, 2*time.Millisecond; got != want {
|
||||
t.Fatalf("WAL commit wait = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := runtime.ReplayInterval, 250*time.Millisecond; got != want {
|
||||
t.Fatalf("replay interval = %s, want %s", got, want)
|
||||
}
|
||||
if runtime.Config.Metrics != registry || runtime.Config.OnError == nil {
|
||||
t.Fatal("metrics and error callback should be wired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATSOutboxConfigDoesNotReuseLegacySpoolDirectory(t *testing.T) {
|
||||
t.Setenv("NATS_DURABLE_OUTBOX_ENABLED", "")
|
||||
t.Setenv("NATS_OUTBOX_DIR", "")
|
||||
t.Setenv("NATS_SPOOL_DIR", "/var/lib/lingniu-go/nats-spool")
|
||||
t.Setenv("NATS_OUTBOX_FSYNC", "false")
|
||||
|
||||
runtime := natsOutboxConfigFromEnv(nil, nil)
|
||||
|
||||
if runtime.Enabled {
|
||||
t.Fatal("outbox must remain opt-in")
|
||||
}
|
||||
if runtime.Config.Directory != "" {
|
||||
t.Fatalf("WAL directory must be explicit, got %q", runtime.Config.Directory)
|
||||
}
|
||||
if runtime.Config.SyncWrites {
|
||||
t.Fatal("explicit false should disable fsync")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGB32960AuthenticatorUsesConfiguredCredentials(t *testing.T) {
|
||||
t.Setenv("GB32960_AUTH_MODE", "enforce")
|
||||
t.Setenv("GB32960_PLATFORM_CREDENTIALS_FILE", "")
|
||||
t.Setenv("GB32960_PLATFORM_CREDENTIALS_JSON", `{"platform-a":"secret-a","platform-b":["secret-b-old","secret-b"]}`)
|
||||
|
||||
authenticator, mode, count, err := buildGB32960Authenticator()
|
||||
if err != nil {
|
||||
t.Fatalf("buildGB32960Authenticator() error = %v", err)
|
||||
}
|
||||
if mode != authentication.ModeEnforce || count != 2 {
|
||||
t.Fatalf("mode=%q count=%d", mode, count)
|
||||
}
|
||||
result := authenticator.Authenticate(envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
MessageID: "0x05",
|
||||
Parsed: map[string]any{
|
||||
"platform_login": map[string]any{"username": "platform-b", "password": "secret-b"},
|
||||
},
|
||||
})
|
||||
if !result.Allowed || result.Status != authentication.StatusAccepted {
|
||||
t.Fatalf("authentication result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGB32960AuthenticatorRejectsEnforceWithoutCredentials(t *testing.T) {
|
||||
t.Setenv("GB32960_AUTH_MODE", "enforce")
|
||||
t.Setenv("GB32960_PLATFORM_CREDENTIALS_FILE", "")
|
||||
t.Setenv("GB32960_PLATFORM_CREDENTIALS_JSON", "")
|
||||
if _, _, _, err := buildGB32960Authenticator(); err == nil {
|
||||
t.Fatal("expected enforce mode configuration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayConfiguresIdentityLookupCacheTTL(t *testing.T) {
|
||||
@@ -39,13 +174,158 @@ func TestGatewayConfiguresIdentityLookupCacheTTL(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{"LookupCacheTTL", "IDENTITY_LOOKUP_CACHE_TTL_SECONDS"} {
|
||||
for _, want := range []string{"LookupCacheTTL", "StaleLookupTTL", "IDENTITY_LOOKUP_CACHE_TTL_SECONDS", "IDENTITY_STALE_LOOKUP_TTL_SECONDS", "IDENTITY_LOOKUP_CACHE_MAX_ENTRIES", "IDENTITY_LOOKUP_CACHE_CLEANUP_INTERVAL_SECONDS", "vehicle_gateway_identity_cache_entries", "vehicle_gateway_jt808_registration_write_total", "OnRegistrationWriteResult", "IDENTITY_SOURCE_CODE_LOOKUP_ENABLED", "IDENTITY_RESOLVE_TIMEOUT_MS", "IDENTITY_SNAPSHOT_ONLY_ENABLED", "IDENTITY_SNAPSHOT_REFRESH_INTERVAL_SECONDS", "startIdentitySnapshotRefresh", "JT808_REGISTRATION_LOCATION_TOUCH_RETRY_INTERVAL_SECONDS", "JT808_REGISTRATION_WRITE_RETRY_ATTEMPTS", "JT808_REGISTRATION_WRITE_RETRY_DELAY_MS", "JT808_REGISTRATION_ASYNC_WRITE_ENABLED", "JT808_REGISTRATION_WRITE_QUEUE_SIZE", "JT808_REGISTRATION_WRITE_WORKERS", "JT808_REGISTRATION_WRITE_ENQUEUE_TIMEOUT_MS", "TimeoutResolver"} {
|
||||
if !strings.Contains(string(source), want) {
|
||||
t.Fatalf("gateway should expose identity lookup cache ttl, missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordIdentityCacheStatsIncludesLocationTouchFailures(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
recordIdentityCacheStats(registry, identity.CacheStats{
|
||||
LookupEntries: 1,
|
||||
RegistrationEntries: 2,
|
||||
SourceCodeEntries: 3,
|
||||
LocationTouchEntries: 4,
|
||||
LocationTouchFailureEntries: 5,
|
||||
SnapshotBindingEntries: 6,
|
||||
SnapshotIdentifierEntries: 7,
|
||||
SnapshotRegistrationEntries: 8,
|
||||
SnapshotSourceEntries: 9,
|
||||
SnapshotReady: true,
|
||||
SnapshotRefreshedAt: time.Unix(1234, 0),
|
||||
MaxEntries: 9,
|
||||
})
|
||||
|
||||
rendered := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_gateway_identity_cache_entries{cache="location_touch_failure"} 5`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="registration_write_queue"} 0`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="snapshot_binding"} 6`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="snapshot_identifier"} 7`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="snapshot_registration"} 8`,
|
||||
`vehicle_gateway_identity_cache_entries{cache="snapshot_source"} 9`,
|
||||
`vehicle_gateway_identity_cache_max_entries{cache="location_touch_failure"} 9`,
|
||||
`vehicle_gateway_identity_snapshot_ready 1`,
|
||||
`vehicle_gateway_identity_snapshot_last_success_unix_seconds 1234`,
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("identity cache metrics missing %s in:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordIdentitySnapshotRefresh(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
recordIdentitySnapshotRefresh(registry, identity.SnapshotRefreshResult{
|
||||
BindingEntries: 10,
|
||||
IdentifierEntries: 20,
|
||||
RegistrationEntries: 30,
|
||||
SourceEntries: 40,
|
||||
RefreshedAt: time.Unix(5678, 0),
|
||||
}, "ok")
|
||||
|
||||
rendered := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_gateway_identity_snapshot_refresh_total{status="ok"} 1`,
|
||||
`vehicle_gateway_identity_snapshot_entries{kind="binding"} 10`,
|
||||
`vehicle_gateway_identity_snapshot_entries{kind="identifier"} 20`,
|
||||
`vehicle_gateway_identity_snapshot_entries{kind="registration"} 30`,
|
||||
`vehicle_gateway_identity_snapshot_entries{kind="source"} 40`,
|
||||
`vehicle_gateway_identity_snapshot_last_success_unix_seconds 5678`,
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("identity snapshot metrics missing %s in:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityDatabaseMaintenanceDoesNotBlockIngressStartup(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
db := &blockingIdentityDatabase{
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
startedAt := time.Now()
|
||||
startIdentityDatabaseMaintenance(ctx, logger, db, nil, false, time.Second, time.Second)
|
||||
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("database maintenance blocked gateway startup for %s", elapsed)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-db.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("database maintenance did not start in background")
|
||||
}
|
||||
close(db.release)
|
||||
}
|
||||
|
||||
func TestIdentitySnapshotRefreshDoesNotBlockIngressStartup(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
refresher := &blockingIdentitySnapshotRefresher{started: make(chan struct{})}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
startedAt := time.Now()
|
||||
startIdentitySnapshotRefresh(ctx, logger, metrics.NewRegistry(), refresher, time.Hour, time.Second)
|
||||
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("snapshot refresh blocked gateway startup for %s", elapsed)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-refresher.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("snapshot refresh did not start in background")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordJT808RegistrationWriteResult(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
recordJT808RegistrationWriteResult(registry, identity.RegistrationWriteResult{
|
||||
Mode: "async_background",
|
||||
Status: "error",
|
||||
})
|
||||
|
||||
rendered := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_gateway_jt808_registration_write_total{mode="async_background",status="error"} 1`,
|
||||
`vehicle_gateway_last_jt808_registration_write_unix_seconds{mode="async_background",status="error"}`,
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("registration write metric missing %s in:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type blockingIdentityDatabase struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (d *blockingIdentityDatabase) PingContext(ctx context.Context) error {
|
||||
close(d.started)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-d.release:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type blockingIdentitySnapshotRefresher struct {
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (r *blockingIdentitySnapshotRefresher) RefreshSnapshot(ctx context.Context) (identity.SnapshotRefreshResult, error) {
|
||||
close(r.started)
|
||||
<-ctx.Done()
|
||||
return identity.SnapshotRefreshResult{}, errors.New("test refresh stopped")
|
||||
}
|
||||
|
||||
func TestGatewayDefaultsTo100KConnectionCeiling(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
@@ -64,6 +344,18 @@ func TestGatewayPassesMetricsRegistryToAsyncSink(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"buildSink(ctx, logger, registry)",
|
||||
"Metrics: registry",
|
||||
"EnqueueTimeout: enqueueTimeout",
|
||||
"NewPartitionedAsyncSink",
|
||||
"NATS_PARTITIONED_ASYNC_ENABLED",
|
||||
"KAFKA_PARTITIONED_ASYNC_ENABLED",
|
||||
"_ASYNC_RAW_QUEUE_SIZE",
|
||||
"_ASYNC_DERIVED_QUEUE_SIZE",
|
||||
"_ASYNC_RAW_ENQUEUE_TIMEOUT_MS",
|
||||
"_ASYNC_DERIVED_ENQUEUE_TIMEOUT_MS",
|
||||
"RawEnqueueTimeout",
|
||||
"DerivedEnqueueTimeout",
|
||||
"NATS_ASYNC_ENQUEUE_TIMEOUT_MS",
|
||||
"KAFKA_ASYNC_ENQUEUE_TIMEOUT_MS",
|
||||
`Name: "nats"`,
|
||||
`Name: "kafka"`,
|
||||
} {
|
||||
@@ -73,6 +365,43 @@ func TestGatewayPassesMetricsRegistryToAsyncSink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayExposesNATSDurableSpoolConfig(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"NATS_DURABLE_OUTBOX_ENABLED",
|
||||
"NATS_OUTBOX_DIR",
|
||||
"NATS_OUTBOX_MAX_INFLIGHT",
|
||||
"NATS_OUTBOX_ACK_TIMEOUT_MS",
|
||||
"NATS_OUTBOX_FSYNC",
|
||||
"NATS_OUTBOX_CLOSE_TIMEOUT_MS",
|
||||
"NATS_OUTBOX_REPLAY_BATCH_SIZE",
|
||||
"NATS_OUTBOX_REPLAY_INTERVAL_MS",
|
||||
"NATS_OUTBOX_WAL_SEGMENT_BYTES",
|
||||
"NATS_OUTBOX_WAL_SEGMENT_AGE_MS",
|
||||
"NATS_OUTBOX_WAL_APPEND_QUEUE_SIZE",
|
||||
"NATS_OUTBOX_WAL_COMMIT_BATCH_SIZE",
|
||||
"NATS_OUTBOX_WAL_COMMIT_INTERVAL_MS",
|
||||
"eventbus.NewDurableOutboxSink",
|
||||
"nats durable outbox enabled",
|
||||
"NATS_SPOOL_DIR",
|
||||
"NATS_SPOOL_REPLAY_BATCH_SIZE",
|
||||
"NATS_SPOOL_REPLAY_INTERVAL_MS",
|
||||
"nats durable spool enabled",
|
||||
"nats spool replay failed",
|
||||
"eventbus.NewDurableSink",
|
||||
"Metrics: registry",
|
||||
`Name: "nats"`,
|
||||
`Name: "kafka"`,
|
||||
} {
|
||||
if !strings.Contains(string(source), want) {
|
||||
t.Fatalf("gateway nats spool wiring missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATSSinkConfigFromEnvDefaultsToGoSubjects(t *testing.T) {
|
||||
t.Setenv("NATS_URL", "nats://172.17.111.56:4222")
|
||||
|
||||
@@ -87,6 +416,15 @@ func TestNATSSinkConfigFromEnvDefaultsToGoSubjects(t *testing.T) {
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolYutongMQTT], "vehicle.raw.go.yutong-mqtt.v1"; got != want {
|
||||
t.Fatalf("yutong subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolGB32960], "vehicle.fields.go.gb32960.v1"; got != want {
|
||||
t.Fatalf("gb32960 fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolJT808], "vehicle.fields.go.jt808.v1"; got != want {
|
||||
t.Fatalf("jt808 fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.FieldsSubjects[envelope.ProtocolYutongMQTT], "vehicle.fields.go.yutong-mqtt.v1"; got != want {
|
||||
t.Fatalf("yutong fields subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.UnifiedSubject, "vehicle.event.go.unified.v1"; got != want {
|
||||
t.Fatalf("unified subject = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +19,7 @@ import (
|
||||
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/history"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
@@ -28,6 +33,10 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Error("invalid history writer config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if err != nil {
|
||||
logger.Error("tdengine open failed", "error", err)
|
||||
@@ -39,18 +48,51 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
metrics.RegisterKafkaConsumerInfo(registry, "vehicle-history-writer", cfg.KafkaGroup, cfg.KafkaTopics)
|
||||
registry.SetGauge("vehicle_history_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-history-writer", []health.Check{
|
||||
{Name: "tdengine", Check: db.PingContext},
|
||||
}, registry))
|
||||
|
||||
writer := history.NewWriter(db)
|
||||
writer := history.NewWriterWithDatabase(db, cfg.TDengineDatabase)
|
||||
if cfg.EnsureSchema {
|
||||
if err := writer.EnsureSchema(ctx, cfg.TDengineDatabase); err != nil {
|
||||
logger.Error("tdengine schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
var appender historyAppender = retryHistoryAppender{
|
||||
delegate: writer,
|
||||
attempts: cfg.RetryAttempts,
|
||||
delay: cfg.RetryDelay,
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
logger.Info("history writer started",
|
||||
"driver", cfg.TDengineDriver,
|
||||
"group", cfg.KafkaGroup,
|
||||
"topics", strings.Join(cfg.KafkaTopics, ","),
|
||||
"workers", cfg.Workers,
|
||||
"batch_size", cfg.BatchSize,
|
||||
"batch_wait_ms", cfg.BatchWait,
|
||||
"retry_attempts", cfg.RetryAttempts,
|
||||
"retry_delay_ms", cfg.RetryDelay.Milliseconds())
|
||||
|
||||
var workers sync.WaitGroup
|
||||
for workerID := 1; workerID <= cfg.Workers; workerID++ {
|
||||
workers.Add(1)
|
||||
go func(id int) {
|
||||
defer workers.Done()
|
||||
runHistoryConsumer(ctx, logger, registry, appender, cfg, id)
|
||||
}(workerID)
|
||||
}
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func runHistoryConsumer(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, cfg config, workerID int) {
|
||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: cfg.KafkaBrokers,
|
||||
GroupID: cfg.KafkaGroup,
|
||||
@@ -60,12 +102,9 @@ func main() {
|
||||
})
|
||||
defer reader.Close()
|
||||
|
||||
logger.Info("history writer started",
|
||||
"driver", cfg.TDengineDriver,
|
||||
"group", cfg.KafkaGroup,
|
||||
"topics", strings.Join(cfg.KafkaTopics, ","),
|
||||
"batch_size", cfg.BatchSize,
|
||||
"batch_wait_ms", cfg.BatchWait)
|
||||
workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)}
|
||||
registry.SetGauge("vehicle_history_worker_active", workerLabels, 1)
|
||||
defer registry.SetGauge("vehicle_history_worker_active", workerLabels, 0)
|
||||
|
||||
for {
|
||||
message, err := reader.FetchMessage(ctx)
|
||||
@@ -77,7 +116,7 @@ func main() {
|
||||
continue
|
||||
}
|
||||
batch := collectHistoryBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond)
|
||||
processHistoryBatch(ctx, logger, registry, writer, reader, batch)
|
||||
processHistoryBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +127,11 @@ type historyAppender interface {
|
||||
AppendAllBatch(context.Context, []envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type historyResultAppender interface {
|
||||
AppendAllWithResult(context.Context, envelope.FrameEnvelope) (history.AppendResult, error)
|
||||
AppendAllBatchWithResult(context.Context, []envelope.FrameEnvelope) (history.AppendResult, error)
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
@@ -96,6 +140,17 @@ type kafkaMessageFetcher interface {
|
||||
FetchMessage(context.Context) (kafka.Message, error)
|
||||
}
|
||||
|
||||
type historyBatchItem struct {
|
||||
message kafka.Message
|
||||
valid bool
|
||||
processed bool
|
||||
}
|
||||
|
||||
type historyBatchOutcome struct {
|
||||
commitMessages []kafka.Message
|
||||
retryMessages []kafka.Message
|
||||
}
|
||||
|
||||
func collectHistoryBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message {
|
||||
if maxSize <= 1 {
|
||||
return []kafka.Message{first}
|
||||
@@ -134,15 +189,38 @@ func processHistoryMessage(ctx context.Context, logger interface {
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
_ = committer.CommitMessages(messageCtx, message)
|
||||
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error")
|
||||
logger.Error("kafka commit invalid envelope failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
return
|
||||
}
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok")
|
||||
return
|
||||
}
|
||||
if err := appender.AppendAll(messageCtx, env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
logger.Error("tdengine append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
if status, err := topics.ValidateRawEnvelope(message.Topic, env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, status)
|
||||
logger.Warn("skip mismatched history raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "event_kind", env.EventKind, "error", err)
|
||||
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error")
|
||||
logger.Error("kafka commit mismatched raw envelope failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "status", status, "error", err)
|
||||
return
|
||||
}
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok")
|
||||
return
|
||||
}
|
||||
recordHistoryParsedFieldMetrics(registry, message, env)
|
||||
result, err := appendHistoryEnvelope(messageCtx, appender, env)
|
||||
if err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
logger.Error("tdengine raw append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
}
|
||||
recordHistoryDerivedMetrics(registry, []kafka.Message{message}, []envelope.FrameEnvelope{env}, result)
|
||||
if result.LocationError != nil {
|
||||
logger.Warn("tdengine location append failed after raw append", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", result.LocationError)
|
||||
}
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "ok")
|
||||
recordHistoryWriteE2EDuration(registry, message, env)
|
||||
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error")
|
||||
logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
@@ -154,30 +232,52 @@ func processHistoryMessage(ctx context.Context, logger interface {
|
||||
func processHistoryBatch(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message) {
|
||||
}, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message) historyBatchOutcome {
|
||||
return processHistoryBatchForWorker(ctx, logger, registry, appender, committer, messages, nil)
|
||||
}
|
||||
|
||||
func processHistoryBatchForWorker(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message, workerLabels metrics.Labels) historyBatchOutcome {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
return historyBatchOutcome{}
|
||||
}
|
||||
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
envelopes := make([]envelope.FrameEnvelope, 0, len(messages))
|
||||
validMessages := make([]kafka.Message, 0, len(messages))
|
||||
validItemIndexes := make([]int, 0, len(messages))
|
||||
items := make([]historyBatchItem, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
itemIndex := len(items)
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "received")
|
||||
addWriterLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
items = append(items, historyBatchItem{message: message, processed: true})
|
||||
continue
|
||||
}
|
||||
if status, err := topics.ValidateRawEnvelope(message.Topic, env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, status)
|
||||
logger.Warn("skip mismatched history raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "event_kind", env.EventKind, "error", err)
|
||||
items = append(items, historyBatchItem{message: message, processed: true})
|
||||
continue
|
||||
}
|
||||
recordHistoryParsedFieldMetrics(registry, message, env)
|
||||
envelopes = append(envelopes, env)
|
||||
validMessages = append(validMessages, message)
|
||||
validItemIndexes = append(validItemIndexes, itemIndex)
|
||||
items = append(items, historyBatchItem{message: message, valid: true})
|
||||
}
|
||||
if len(envelopes) > 0 {
|
||||
setBatchPending(registry, len(messages), len(envelopes))
|
||||
defer setBatchPending(registry, 0, 0)
|
||||
setBatchPendingForWorker(registry, workerLabels, len(messages), len(envelopes))
|
||||
defer setBatchPendingForWorker(registry, workerLabels, 0, 0)
|
||||
started := time.Now()
|
||||
err := appender.AppendAllBatch(messageCtx, envelopes)
|
||||
result, err := appendHistoryBatch(messageCtx, appender, envelopes)
|
||||
elapsed := time.Since(started)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
@@ -187,15 +287,33 @@ func processHistoryBatch(ctx context.Context, logger interface {
|
||||
addBatchMetric(registry, "vehicle_history_batch_rows_total", status, float64(len(envelopes)))
|
||||
setBatchDuration(registry, status, elapsed)
|
||||
if err != nil {
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("tdengine batch append failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "rows", len(envelopes), "error", err)
|
||||
return
|
||||
logger.Error("tdengine raw batch append failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "rows", len(envelopes), "error", err)
|
||||
if isTransientTDengineHistoryError(err) {
|
||||
for _, message := range validMessages {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
}
|
||||
addBatchMetric(registry, "vehicle_history_batch_fallback_total", "skipped_transient", 1)
|
||||
committed, commitErr := commitHistoryProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
|
||||
return historyFailureOutcome(messages, committed, commitErr)
|
||||
}
|
||||
if fallbackErr := fallbackHistoryBatchToSingles(messageCtx, logger, registry, appender, validMessages, envelopes, validItemIndexes, items); fallbackErr != nil {
|
||||
addBatchMetric(registry, "vehicle_history_batch_fallback_total", "error", 1)
|
||||
committed, commitErr := commitHistoryProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
|
||||
return historyFailureOutcome(messages, committed, commitErr)
|
||||
}
|
||||
addBatchMetric(registry, "vehicle_history_batch_fallback_total", "ok", 1)
|
||||
committed, commitErr := commitHistoryProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
|
||||
return historyFailureOutcome(messages, committed, commitErr)
|
||||
}
|
||||
for _, message := range messages {
|
||||
recordHistoryDerivedMetrics(registry, validMessages, envelopes, result)
|
||||
if result.LocationError != nil {
|
||||
first := messages[0]
|
||||
logger.Warn("tdengine location batch append failed after raw append", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "rows", len(envelopes), "location_rows", result.LocationRows, "error", result.LocationError)
|
||||
}
|
||||
for index, message := range validMessages {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "ok")
|
||||
recordHistoryWriteE2EDuration(registry, message, envelopes[index])
|
||||
}
|
||||
}
|
||||
if err := committer.CommitMessages(messageCtx, messages...); err != nil {
|
||||
@@ -204,18 +322,337 @@ func processHistoryBatch(ctx context.Context, logger interface {
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("kafka batch commit failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
|
||||
return
|
||||
return historyBatchOutcome{commitMessages: messages}
|
||||
}
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return historyBatchOutcome{}
|
||||
}
|
||||
|
||||
func historyFailureOutcome(messages []kafka.Message, committed []kafka.Message, commitErr error) historyBatchOutcome {
|
||||
outcome := historyBatchOutcome{
|
||||
retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed),
|
||||
}
|
||||
if commitErr != nil {
|
||||
outcome.commitMessages = committed
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
func processHistoryBatchReliably(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) {
|
||||
processHistoryBatchReliablyForWorker(ctx, logger, registry, appender, committer, messages, retryDelay, nil)
|
||||
}
|
||||
|
||||
func processHistoryBatchReliablyForWorker(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) {
|
||||
defer registry.SetGauge("vehicle_history_retry_pending_messages", workerLabels, 0)
|
||||
pending := messages
|
||||
for len(pending) > 0 {
|
||||
outcome := processHistoryBatchForWorker(ctx, logger, registry, appender, committer, pending, workerLabels)
|
||||
if len(outcome.commitMessages) > 0 {
|
||||
registry.IncCounter("vehicle_history_batch_retries_total", metrics.Labels{"reason": "commit_error"})
|
||||
if !retryHistoryCommit(ctx, logger, registry, committer, outcome.commitMessages, retryDelay) {
|
||||
return
|
||||
}
|
||||
}
|
||||
pending = outcome.retryMessages
|
||||
registry.SetGauge("vehicle_history_retry_pending_messages", workerLabels, float64(len(pending)))
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_history_batch_retries_total", metrics.Labels{"reason": "write_error"})
|
||||
if !waitForHistoryRetry(ctx, retryDelay) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retryHistoryCommit(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) bool {
|
||||
for len(messages) > 0 {
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
err := committer.CommitMessages(operationCtx, messages...)
|
||||
cancel()
|
||||
if err == nil {
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error")
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("kafka commit retry failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
|
||||
registry.IncCounter("vehicle_history_batch_retries_total", metrics.Labels{"reason": "commit_error"})
|
||||
if !waitForHistoryRetry(ctx, retryDelay) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func waitForHistoryRetry(ctx context.Context, retryDelay time.Duration) bool {
|
||||
if retryDelay <= 0 {
|
||||
retryDelay = 100 * time.Millisecond
|
||||
}
|
||||
timer := time.NewTimer(retryDelay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackHistoryBatchToSingles(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, messages []kafka.Message, envelopes []envelope.FrameEnvelope, itemIndexes []int, items []historyBatchItem) error {
|
||||
for index, env := range envelopes {
|
||||
message := messages[index]
|
||||
started := time.Now()
|
||||
result, err := appendHistoryEnvelope(ctx, appender, env)
|
||||
setFallbackDuration(registry, statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
logger.Error("tdengine raw single append failed after batch fallback", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
return err
|
||||
}
|
||||
recordHistoryDerivedMetrics(registry, []kafka.Message{message}, []envelope.FrameEnvelope{env}, result)
|
||||
if result.LocationError != nil {
|
||||
logger.Warn("tdengine location append failed after raw single fallback", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", result.LocationError)
|
||||
}
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "ok")
|
||||
recordHistoryWriteE2EDuration(registry, message, env)
|
||||
items[itemIndexes[index]].processed = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commitHistoryProcessedPrefixAfterFailure(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, committer kafkaMessageCommitter, items []historyBatchItem) ([]kafka.Message, error) {
|
||||
committable := processedHistoryPrefixMessagesByPartition(items)
|
||||
if len(committable) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := committer.CommitMessages(ctx, committable...); err != nil {
|
||||
for _, message := range committable {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error")
|
||||
}
|
||||
first := committable[0]
|
||||
logger.Error("kafka processed-prefix commit failed after raw batch append failure", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(committable), "error", err)
|
||||
return committable, err
|
||||
}
|
||||
for _, message := range committable {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return committable, nil
|
||||
}
|
||||
|
||||
func processedHistoryPrefixMessagesByPartition(items []historyBatchItem) []kafka.Message {
|
||||
type partitionKey struct {
|
||||
topic string
|
||||
partition int
|
||||
}
|
||||
groups := map[partitionKey][]historyBatchItem{}
|
||||
for _, item := range items {
|
||||
key := partitionKey{topic: item.message.Topic, partition: item.message.Partition}
|
||||
groups[key] = append(groups[key], item)
|
||||
}
|
||||
var keys []partitionKey
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].topic != keys[j].topic {
|
||||
return keys[i].topic < keys[j].topic
|
||||
}
|
||||
return keys[i].partition < keys[j].partition
|
||||
})
|
||||
var out []kafka.Message
|
||||
for _, key := range keys {
|
||||
group := groups[key]
|
||||
sort.Slice(group, func(i, j int) bool {
|
||||
return group[i].message.Offset < group[j].message.Offset
|
||||
})
|
||||
var previousOffset int64
|
||||
for index, item := range group {
|
||||
if index > 0 && item.message.Offset != previousOffset+1 {
|
||||
break
|
||||
}
|
||||
if !item.processed {
|
||||
break
|
||||
}
|
||||
out = append(out, item.message)
|
||||
previousOffset = item.message.Offset
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendHistoryEnvelope(ctx context.Context, appender historyAppender, env envelope.FrameEnvelope) (history.AppendResult, error) {
|
||||
if resultAppender, ok := appender.(historyResultAppender); ok {
|
||||
return resultAppender.AppendAllWithResult(ctx, env)
|
||||
}
|
||||
if err := appender.AppendAll(ctx, env); err != nil {
|
||||
return history.AppendResult{}, err
|
||||
}
|
||||
return history.AppendResult{RawRows: 1}, nil
|
||||
}
|
||||
|
||||
func appendHistoryBatch(ctx context.Context, appender historyAppender, envelopes []envelope.FrameEnvelope) (history.AppendResult, error) {
|
||||
if resultAppender, ok := appender.(historyResultAppender); ok {
|
||||
return resultAppender.AppendAllBatchWithResult(ctx, envelopes)
|
||||
}
|
||||
if err := appender.AppendAllBatch(ctx, envelopes); err != nil {
|
||||
return history.AppendResult{}, err
|
||||
}
|
||||
return history.AppendResult{RawRows: len(envelopes)}, nil
|
||||
}
|
||||
|
||||
type retryHistoryAppender struct {
|
||||
delegate historyAppender
|
||||
attempts int
|
||||
delay time.Duration
|
||||
registry *metrics.Registry
|
||||
}
|
||||
|
||||
func (a retryHistoryAppender) AppendAll(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
_, err := a.AppendAllWithResult(ctx, env)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a retryHistoryAppender) AppendAllWithResult(ctx context.Context, env envelope.FrameEnvelope) (history.AppendResult, error) {
|
||||
if a.delegate == nil {
|
||||
return history.AppendResult{}, nil
|
||||
}
|
||||
attempts := a.attempts
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
var result history.AppendResult
|
||||
var err error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
result, err = appendHistoryEnvelope(ctx, a.delegate, env)
|
||||
if err == nil || !isTransientTDengineHistoryError(err) {
|
||||
return result, err
|
||||
}
|
||||
if attempt == attempts {
|
||||
a.recordRetry("single", "exhausted")
|
||||
return result, err
|
||||
}
|
||||
a.recordRetry("single", "retry")
|
||||
if waitErr := sleepBeforeRetry(ctx, a.delay); waitErr != nil {
|
||||
return result, waitErr
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (a retryHistoryAppender) AppendAllBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
|
||||
_, err := a.AppendAllBatchWithResult(ctx, envelopes)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a retryHistoryAppender) AppendAllBatchWithResult(ctx context.Context, envelopes []envelope.FrameEnvelope) (history.AppendResult, error) {
|
||||
if a.delegate == nil {
|
||||
return history.AppendResult{}, nil
|
||||
}
|
||||
attempts := a.attempts
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
var result history.AppendResult
|
||||
var err error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
result, err = appendHistoryBatch(ctx, a.delegate, envelopes)
|
||||
if err == nil || !isTransientTDengineHistoryError(err) {
|
||||
return result, err
|
||||
}
|
||||
if attempt == attempts {
|
||||
a.recordRetry("batch", "exhausted")
|
||||
return result, err
|
||||
}
|
||||
a.recordRetry("batch", "retry")
|
||||
if waitErr := sleepBeforeRetry(ctx, a.delay); waitErr != nil {
|
||||
return result, waitErr
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (a retryHistoryAppender) recordRetry(operation string, status string) {
|
||||
if a.registry == nil {
|
||||
return
|
||||
}
|
||||
labels := metrics.Labels{"operation": operation, "status": status}
|
||||
a.registry.IncCounter("vehicle_history_write_retries_total", labels)
|
||||
metrics.RecordLastActivity(a.registry, "vehicle_history_last_write_retry_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func sleepBeforeRetry(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientTDengineHistoryError(err error) bool {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(text, "timeout") ||
|
||||
strings.Contains(text, "temporary") ||
|
||||
strings.Contains(text, "temporarily") ||
|
||||
strings.Contains(text, "connection refused") ||
|
||||
strings.Contains(text, "connection reset") ||
|
||||
strings.Contains(text, "connection closed") ||
|
||||
strings.Contains(text, "broken pipe") ||
|
||||
strings.Contains(text, "bad connection") ||
|
||||
strings.Contains(text, "i/o timeout") ||
|
||||
text == "eof" ||
|
||||
strings.Contains(text, "unexpected eof") ||
|
||||
strings.Contains(text, "server is down") ||
|
||||
strings.Contains(text, "network is unreachable") ||
|
||||
strings.Contains(text, "no route to host")
|
||||
}
|
||||
|
||||
func addWriterMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"topic": message.Topic, "status": status})
|
||||
labels := metrics.Labels{"topic": message.Topic, "status": status}
|
||||
registry.IncCounter(name, labels)
|
||||
switch name {
|
||||
case "vehicle_history_kafka_messages_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_history_last_message_unix_seconds", labels)
|
||||
case "vehicle_history_writes_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_history_last_write_unix_seconds", labels)
|
||||
case "vehicle_history_kafka_commits_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_history_last_commit_unix_seconds", labels)
|
||||
case "vehicle_history_location_writes_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_history_last_location_write_unix_seconds", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func addBatchMetric(registry *metrics.Registry, name string, status string, value float64) {
|
||||
@@ -225,19 +662,113 @@ func addBatchMetric(registry *metrics.Registry, name string, status string, valu
|
||||
registry.AddCounter(name, metrics.Labels{"status": status}, value)
|
||||
}
|
||||
|
||||
func recordHistoryParsedFieldMetrics(registry *metrics.Registry, message kafka.Message, env envelope.FrameEnvelope) {
|
||||
if registry == nil || !envelope.IsRealtimeTelemetryFrame(env) {
|
||||
return
|
||||
}
|
||||
status := "present"
|
||||
if len(env.ParsedFields) == 0 {
|
||||
status = "missing"
|
||||
}
|
||||
registry.IncCounter("vehicle_history_parsed_fields_total", metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"protocol": historyProtocolLabel(env.Protocol),
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func recordHistoryDerivedMetrics(registry *metrics.Registry, messages []kafka.Message, envelopes []envelope.FrameEnvelope, result history.AppendResult) {
|
||||
if registry == nil || len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
batchStatus := "skipped"
|
||||
if result.LocationError != nil {
|
||||
batchStatus = "error"
|
||||
} else if result.LocationRows > 0 {
|
||||
batchStatus = "ok"
|
||||
}
|
||||
for index, message := range messages {
|
||||
status := batchStatus
|
||||
if index < len(envelopes) {
|
||||
status = history.LocationStatus(envelopes[index])
|
||||
if status == history.LocationStatusOK && result.LocationError != nil {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
addWriterMetric(registry, "vehicle_history_location_writes_total", message, status)
|
||||
}
|
||||
addBatchMetric(registry, "vehicle_history_location_batch_flush_total", batchStatus, 1)
|
||||
addBatchMetric(registry, "vehicle_history_location_rows_total", batchStatus, float64(result.LocationRows))
|
||||
}
|
||||
|
||||
func historyProtocolLabel(protocol envelope.Protocol) string {
|
||||
protocolLabel := strings.TrimSpace(string(protocol))
|
||||
if protocolLabel == "" {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return protocolLabel
|
||||
}
|
||||
|
||||
func setBatchDuration(registry *metrics.Registry, status string, elapsed time.Duration) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_history_batch_flush_duration_ms", metrics.Labels{"status": status}, float64(elapsed.Milliseconds()))
|
||||
elapsedMS := float64(elapsed.Milliseconds())
|
||||
labels := metrics.Labels{"status": status}
|
||||
registry.SetGauge("vehicle_history_batch_flush_duration_ms", labels, elapsedMS)
|
||||
registry.ObserveHistogram("vehicle_history_batch_flush_duration_ms_histogram", labels, historyBatchFlushDurationBucketsMS, elapsedMS)
|
||||
}
|
||||
|
||||
func setBatchPending(registry *metrics.Registry, messages int, rows int) {
|
||||
var historyBatchFlushDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func setFallbackDuration(registry *metrics.Registry, status string, elapsed time.Duration) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_history_batch_pending_messages", nil, float64(messages))
|
||||
registry.SetGauge("vehicle_history_batch_pending_rows", nil, float64(rows))
|
||||
elapsedMS := float64(elapsed.Milliseconds())
|
||||
labels := metrics.Labels{"status": status}
|
||||
registry.SetGauge("vehicle_history_batch_fallback_duration_ms", labels, elapsedMS)
|
||||
registry.ObserveHistogram("vehicle_history_batch_fallback_duration_ms_histogram", labels, historyBatchFallbackDurationBucketsMS, elapsedMS)
|
||||
}
|
||||
|
||||
var historyBatchFallbackDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
var historyWriteE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
|
||||
var historyWriteE2ERecent = metrics.NewRecentLatencyByKey(512)
|
||||
|
||||
func recordHistoryWriteE2EDuration(registry *metrics.Registry, message kafka.Message, env envelope.FrameEnvelope) {
|
||||
if registry == nil || env.ReceivedAtMS <= 0 {
|
||||
return
|
||||
}
|
||||
elapsed := time.Since(time.UnixMilli(env.ReceivedAtMS)).Milliseconds()
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
labels := metrics.Labels{"topic": message.Topic}
|
||||
registry.ObserveHistogram("vehicle_history_write_e2e_duration_ms_histogram", labels, historyWriteE2EDurationBucketsMS, float64(elapsed))
|
||||
p99, samples := historyWriteE2ERecent.Observe(message.Topic, float64(elapsed))
|
||||
registry.SetGauge("vehicle_history_write_e2e_recent_p99_ms", labels, p99)
|
||||
registry.SetGauge("vehicle_history_write_e2e_recent_samples", labels, float64(samples))
|
||||
metrics.RecordLastActivity(registry, "vehicle_history_last_write_e2e_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func setBatchPending(registry *metrics.Registry, messages int, rows int) {
|
||||
setBatchPendingForWorker(registry, nil, messages, rows)
|
||||
}
|
||||
|
||||
func setBatchPendingForWorker(registry *metrics.Registry, workerLabels metrics.Labels, messages int, rows int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_history_batch_pending_messages", workerLabels, float64(messages))
|
||||
registry.SetGauge("vehicle_history_batch_pending_rows", workerLabels, float64(rows))
|
||||
}
|
||||
|
||||
func addWriterLagMetric(registry *metrics.Registry, message kafka.Message) {
|
||||
@@ -255,8 +786,26 @@ type config struct {
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
EnsureSchema bool
|
||||
Workers int
|
||||
BatchSize int
|
||||
BatchWait int
|
||||
RetryAttempts int
|
||||
RetryDelay time.Duration
|
||||
}
|
||||
|
||||
func (c config) Validate() error {
|
||||
if len(c.KafkaTopics) == 0 {
|
||||
return errors.New("KAFKA_TOPICS must include raw topics")
|
||||
}
|
||||
for _, topic := range c.KafkaTopics {
|
||||
if _, ok := topics.ProtocolForKnownRawTopic(topic); !ok {
|
||||
return fmt.Errorf("history-writer consumes known raw topics only, got %q", topic)
|
||||
}
|
||||
}
|
||||
if c.Workers <= 0 {
|
||||
return errors.New("HISTORY_WORKERS must be greater than zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -268,8 +817,11 @@ func loadConfig() config {
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
EnsureSchema: env("TDENGINE_ENSURE_SCHEMA", "true") != "false",
|
||||
Workers: envInt("HISTORY_WORKERS", 3),
|
||||
BatchSize: envInt("HISTORY_BATCH_SIZE", 200),
|
||||
BatchWait: envInt("HISTORY_BATCH_WAIT_MS", 100),
|
||||
BatchWait: envInt("HISTORY_BATCH_WAIT_MS", 20),
|
||||
RetryAttempts: envInt("HISTORY_RETRY_ATTEMPTS", 3),
|
||||
RetryDelay: time.Duration(envInt("HISTORY_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
695
go/vehicle-gateway/cmd/identity-import/main.go
Normal file
695
go/vehicle-gateway/cmd/identity-import/main.go
Normal file
@@ -0,0 +1,695 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var input string
|
||||
var dsn string
|
||||
var legacyTable string
|
||||
var apply bool
|
||||
var ensureSchema bool
|
||||
var reportLimit int
|
||||
var unresolvedOut string
|
||||
var conflictsOut string
|
||||
var syncDataSources bool
|
||||
var pruneUnmanagedDataSources bool
|
||||
var pruneDataSourceMinAge time.Duration
|
||||
var retireStaleUnmanagedDataSources bool
|
||||
var retireDataSourceMinAge time.Duration
|
||||
var timeout time.Duration
|
||||
flag.StringVar(&input, "input", env("IDENTITY_MAPPING_INPUT", ""), "directory that contains 808 plate/phone mapping workbooks")
|
||||
flag.StringVar(&dsn, "mysql-dsn", env("IDENTITY_MYSQL_DSN", env("MYSQL_DSN", "")), "MySQL DSN; empty means scan only")
|
||||
flag.StringVar(&legacyTable, "legacy-table", env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding"), "legacy VIN/plate binding table used to resolve VIN")
|
||||
flag.BoolVar(&apply, "apply", false, "write resolved mappings to vehicle and vehicle_identifier")
|
||||
flag.BoolVar(&ensureSchema, "ensure-schema", false, "create vehicle and vehicle_identifier tables before import")
|
||||
flag.IntVar(&reportLimit, "report-limit", 50, "max unresolved/conflict items embedded in JSON; use -1 for all")
|
||||
flag.StringVar(&unresolvedOut, "unresolved-out", "", "optional CSV path for all unresolved mappings")
|
||||
flag.StringVar(&conflictsOut, "conflicts-out", "", "optional CSV path for all conflict mappings")
|
||||
flag.BoolVar(&syncDataSources, "sync-data-sources", false, "infer JT808 vehicle_data_source platform names from jt808_registration and vehicle_identifier")
|
||||
flag.BoolVar(&pruneUnmanagedDataSources, "prune-unmanaged-data-sources", false, "delete stale unclassified vehicle_data_source rows that have no registration evidence and are not referenced by final mileage")
|
||||
flag.DurationVar(&pruneDataSourceMinAge, "prune-data-source-min-age", time.Hour, "minimum latest_seen/updated age before -prune-unmanaged-data-sources can delete rows")
|
||||
flag.BoolVar(&retireStaleUnmanagedDataSources, "retire-stale-unmanaged-data-sources", false, "disable stale unclassified vehicle_data_source rows that have no registration evidence but may be referenced by historical mileage")
|
||||
flag.DurationVar(&retireDataSourceMinAge, "retire-data-source-min-age", 24*time.Hour, "minimum latest_seen/updated age before -retire-stale-unmanaged-data-sources can disable rows")
|
||||
flag.DurationVar(&timeout, "timeout", 2*time.Minute, "import timeout")
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
output := map[string]any{}
|
||||
var records []identity.MappingRecord
|
||||
var scan identity.MappingScanReport
|
||||
if input != "" {
|
||||
var err error
|
||||
records, scan, err = identity.ReadMappingDirectory(input)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
output["input"] = input
|
||||
output["scan"] = scan
|
||||
} else if !syncDataSources && !pruneUnmanagedDataSources && !retireStaleUnmanagedDataSources {
|
||||
fail(errors.New("-input is required unless -sync-data-sources, -prune-unmanaged-data-sources or -retire-stale-unmanaged-data-sources is set"))
|
||||
}
|
||||
if err := validateMappingApplyInput(apply, scan); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if strings.TrimSpace(dsn) == "" {
|
||||
if syncDataSources || pruneUnmanagedDataSources || retireStaleUnmanagedDataSources {
|
||||
fail(errors.New("-mysql-dsn is required when -sync-data-sources, -prune-unmanaged-data-sources or -retire-stale-unmanaged-data-sources is set"))
|
||||
}
|
||||
output["mode"] = "scan_only"
|
||||
writeJSON(output)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if input != "" && (ensureSchema || apply) {
|
||||
if err := identity.EnsureVehicleIdentifierSchema(ctx, db); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
if input != "" {
|
||||
report, err := identity.ImportMappingRecords(ctx, db, records, scan, identity.MappingImportOptions{
|
||||
Apply: apply,
|
||||
LegacyTable: legacyTable,
|
||||
ReportItemLimit: reportItemLimit(reportLimit, unresolvedOut, conflictsOut),
|
||||
})
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if strings.TrimSpace(unresolvedOut) != "" {
|
||||
if err := writeUnresolvedCSV(unresolvedOut, report.UnresolvedItems); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(conflictsOut) != "" {
|
||||
if err := writeConflictsCSV(conflictsOut, report.ConflictItems); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
output["report"] = report
|
||||
output["mode"] = map[bool]string{true: "apply", false: "dry_run"}[apply]
|
||||
}
|
||||
if syncDataSources {
|
||||
syncReport, err := syncJT808DataSourcesFromIdentifiers(ctx, db, apply)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
output["data_source_sync"] = syncReport
|
||||
}
|
||||
if pruneUnmanagedDataSources {
|
||||
pruneReport, err := pruneUnmanagedDataSourcesWithoutEvidence(ctx, db, apply, pruneDataSourceMinAge)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
output["data_source_prune"] = pruneReport
|
||||
}
|
||||
if retireStaleUnmanagedDataSources {
|
||||
retireReport, err := retireStaleUnmanagedDataSourcesWithoutEvidence(ctx, db, apply, retireDataSourceMinAge)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
output["data_source_retire"] = retireReport
|
||||
}
|
||||
if input == "" {
|
||||
output["mode"] = maintenanceMode(apply, syncDataSources, pruneUnmanagedDataSources, retireStaleUnmanagedDataSources)
|
||||
}
|
||||
writeJSON(output)
|
||||
}
|
||||
|
||||
func maintenanceMode(apply bool, syncDataSources bool, pruneUnmanagedDataSources bool, retireStaleUnmanagedDataSources bool) string {
|
||||
var parts []string
|
||||
if syncDataSources {
|
||||
parts = append(parts, "sync")
|
||||
}
|
||||
if pruneUnmanagedDataSources {
|
||||
parts = append(parts, "prune")
|
||||
}
|
||||
if retireStaleUnmanagedDataSources {
|
||||
parts = append(parts, "retire")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return map[bool]string{true: "apply", false: "dry_run"}[apply]
|
||||
}
|
||||
parts = append(parts, map[bool]string{true: "apply", false: "dry_run"}[apply])
|
||||
return strings.Join(parts, "_")
|
||||
}
|
||||
|
||||
func reportItemLimit(limit int, unresolvedOut string, conflictsOut string) int {
|
||||
if strings.TrimSpace(unresolvedOut) != "" || strings.TrimSpace(conflictsOut) != "" {
|
||||
return -1
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func validateMappingApplyInput(apply bool, scan identity.MappingScanReport) error {
|
||||
if !apply {
|
||||
return nil
|
||||
}
|
||||
return unsupportedMappingFilesError(scan)
|
||||
}
|
||||
|
||||
func unsupportedMappingFilesError(scan identity.MappingScanReport) error {
|
||||
if scan.UnsupportedFiles <= 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]string, 0, len(scan.UnsupportedItems))
|
||||
for _, item := range scan.UnsupportedItems {
|
||||
path := strings.TrimSpace(item.File)
|
||||
if path == "" {
|
||||
path = strings.TrimSpace(item.Ext)
|
||||
}
|
||||
if path != "" {
|
||||
items = append(items, path)
|
||||
}
|
||||
if len(items) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
suffix := ""
|
||||
if len(items) > 0 {
|
||||
suffix = ": " + strings.Join(items, ", ")
|
||||
}
|
||||
return fmt.Errorf("identity mapping import has %d unsupported workbook(s); convert .xls/.xlsb to .xlsx before -apply%s", scan.UnsupportedFiles, suffix)
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func writeJSON(value any) {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeUnresolvedCSV(path string, items []identity.MappingRecord) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
if err := writer.Write([]string{
|
||||
"file", "sheet", "row", "source_code", "source_name", "protocol",
|
||||
"identifier_type", "identifier_value", "raw_value", "plate", "oem", "reason",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := writer.Write([]string{
|
||||
item.File,
|
||||
item.Sheet,
|
||||
fmt.Sprint(item.Row),
|
||||
item.SourceCode,
|
||||
item.SourceName,
|
||||
item.Protocol,
|
||||
item.IdentifierType,
|
||||
item.IdentifierValue,
|
||||
item.RawValue,
|
||||
item.Plate,
|
||||
item.OEM,
|
||||
"vin_not_found_in_legacy_binding",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writer.Error()
|
||||
}
|
||||
|
||||
func writeConflictsCSV(path string, items []identity.MappingConflict) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
if err := writer.Write([]string{
|
||||
"file", "sheet", "row", "source_code", "source_name", "protocol",
|
||||
"identifier_type", "identifier_value", "raw_value", "plate", "oem",
|
||||
"existing_vin", "new_vin", "reason",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
record := item.Record
|
||||
if err := writer.Write([]string{
|
||||
record.File,
|
||||
record.Sheet,
|
||||
fmt.Sprint(record.Row),
|
||||
record.SourceCode,
|
||||
record.SourceName,
|
||||
record.Protocol,
|
||||
record.IdentifierType,
|
||||
record.IdentifierValue,
|
||||
record.RawValue,
|
||||
record.Plate,
|
||||
record.OEM,
|
||||
item.ExistingVIN,
|
||||
item.NewVIN,
|
||||
item.Reason,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writer.Error()
|
||||
}
|
||||
|
||||
type dataSourceSyncReport struct {
|
||||
Apply bool `json:"apply"`
|
||||
CandidateSources int64 `json:"candidate_sources"`
|
||||
SkippedSources int64 `json:"skipped_sources"`
|
||||
ConflictingSources int64 `json:"conflicting_sources"`
|
||||
PlatformKindCandidates int64 `json:"platform_kind_candidates"`
|
||||
PlatformKindClassified int64 `json:"platform_kind_classified,omitempty"`
|
||||
ReprojectDailyMileageTargets int64 `json:"reproject_daily_mileage_targets,omitempty"`
|
||||
ReprojectedDailyMileageTargets int64 `json:"reprojected_daily_mileage_targets,omitempty"`
|
||||
Synced int64 `json:"synced,omitempty"`
|
||||
}
|
||||
|
||||
type dailyMileageProjectionTarget struct {
|
||||
VIN string
|
||||
StatDate string
|
||||
Protocol envelope.Protocol
|
||||
}
|
||||
|
||||
type dataSourcePruneReport struct {
|
||||
Apply bool `json:"apply"`
|
||||
MinAgeSeconds int64 `json:"min_age_seconds"`
|
||||
CandidateSources int64 `json:"candidate_sources"`
|
||||
Pruned int64 `json:"pruned,omitempty"`
|
||||
}
|
||||
|
||||
type dataSourceRetireReport struct {
|
||||
Apply bool `json:"apply"`
|
||||
MinAgeSeconds int64 `json:"min_age_seconds"`
|
||||
CandidateSources int64 `json:"candidate_sources"`
|
||||
Retired int64 `json:"retired,omitempty"`
|
||||
}
|
||||
|
||||
func syncJT808DataSourcesFromIdentifiers(ctx context.Context, db *sql.DB, apply bool) (dataSourceSyncReport, error) {
|
||||
report := dataSourceSyncReport{Apply: apply}
|
||||
var err error
|
||||
report.CandidateSources, err = queryInt64(ctx, db, syncJT808DataSourcesCandidateCountSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.SkippedSources, err = queryInt64(ctx, db, syncJT808DataSourcesSkippedCountSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.ConflictingSources, err = queryInt64(ctx, db, syncJT808DataSourcesConflictCountSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if apply {
|
||||
result, err := db.ExecContext(ctx, syncJT808DataSourcesSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil {
|
||||
report.Synced = rowsAffected
|
||||
}
|
||||
}
|
||||
report.PlatformKindCandidates, err = queryInt64(ctx, db, classifyConfiguredDataSourcesCountSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if apply {
|
||||
result, err := db.ExecContext(ctx, classifyConfiguredDataSourcesSQL)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil {
|
||||
report.PlatformKindClassified = rowsAffected
|
||||
}
|
||||
}
|
||||
targets, err := queryDailyMileageProjectionTargets(ctx, db)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.ReprojectDailyMileageTargets = int64(len(targets))
|
||||
if apply {
|
||||
for _, target := range targets {
|
||||
if err := stats.ProjectDailyMileage(ctx, db, target.VIN, target.StatDate, target.Protocol); err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.ReprojectedDailyMileageTargets++
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func queryDailyMileageProjectionTargets(ctx context.Context, db *sql.DB) ([]dailyMileageProjectionTarget, error) {
|
||||
rows, err := db.QueryContext(ctx, reprojectSelectableDailyMileageSourcesSQL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var targets []dailyMileageProjectionTarget
|
||||
for rows.Next() {
|
||||
var target dailyMileageProjectionTarget
|
||||
var protocol string
|
||||
if err := rows.Scan(&target.VIN, &target.StatDate, &protocol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target.Protocol = envelope.Protocol(strings.TrimSpace(protocol))
|
||||
if strings.TrimSpace(target.VIN) != "" && strings.TrimSpace(target.StatDate) != "" && strings.TrimSpace(string(target.Protocol)) != "" {
|
||||
targets = append(targets, target)
|
||||
}
|
||||
}
|
||||
return targets, rows.Err()
|
||||
}
|
||||
|
||||
func pruneUnmanagedDataSourcesWithoutEvidence(ctx context.Context, db *sql.DB, apply bool, minAge time.Duration) (dataSourcePruneReport, error) {
|
||||
if minAge < 0 {
|
||||
minAge = 0
|
||||
}
|
||||
minAgeSeconds := int64(minAge.Seconds())
|
||||
report := dataSourcePruneReport{Apply: apply, MinAgeSeconds: minAgeSeconds}
|
||||
var err error
|
||||
report.CandidateSources, err = queryInt64WithArgs(ctx, db, pruneUnmanagedDataSourcesCountSQL, minAgeSeconds)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if !apply {
|
||||
return report, nil
|
||||
}
|
||||
result, err := db.ExecContext(ctx, pruneUnmanagedDataSourcesSQL, minAgeSeconds)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil {
|
||||
report.Pruned = rowsAffected
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func retireStaleUnmanagedDataSourcesWithoutEvidence(ctx context.Context, db *sql.DB, apply bool, minAge time.Duration) (dataSourceRetireReport, error) {
|
||||
if minAge < 0 {
|
||||
minAge = 0
|
||||
}
|
||||
minAgeSeconds := int64(minAge.Seconds())
|
||||
report := dataSourceRetireReport{Apply: apply, MinAgeSeconds: minAgeSeconds}
|
||||
var err error
|
||||
report.CandidateSources, err = queryInt64WithArgs(ctx, db, retireStaleUnmanagedDataSourcesCountSQL, minAgeSeconds)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if !apply {
|
||||
return report, nil
|
||||
}
|
||||
result, err := db.ExecContext(ctx, retireStaleUnmanagedDataSourcesSQL, minAgeSeconds)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil {
|
||||
report.Retired = rowsAffected
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func queryInt64(ctx context.Context, db *sql.DB, query string) (int64, error) {
|
||||
return queryInt64WithArgs(ctx, db, query)
|
||||
}
|
||||
|
||||
func queryInt64WithArgs(ctx context.Context, db *sql.DB, query string, args ...any) (int64, error) {
|
||||
var count int64
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const jt808DataSourceInferenceSQL = `
|
||||
SELECT
|
||||
'JT808' AS protocol,
|
||||
TRIM(r.source_ip) AS source_ip,
|
||||
MAX(TRIM(r.source_endpoint)) AS latest_source_endpoint,
|
||||
MIN(COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), ''))) AS platform_name,
|
||||
MIN(NULLIF(TRIM(vi.source_code), '')) AS source_code,
|
||||
MIN(COALESCE(r.first_registered_at, r.latest_registered_at, r.latest_authenticated_at, r.latest_seen_at, r.updated_at, CURRENT_TIMESTAMP)) AS first_seen_at,
|
||||
MAX(COALESCE(r.latest_seen_at, r.latest_authenticated_at, r.latest_registered_at, r.updated_at, CURRENT_TIMESTAMP)) AS latest_seen_at,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), ''))) AS platform_count,
|
||||
COUNT(DISTINCT NULLIF(TRIM(vi.source_code), '')) AS source_code_count
|
||||
FROM jt808_registration r
|
||||
JOIN vehicle_identifier vi
|
||||
ON vi.protocol = 'JT808'
|
||||
AND vi.identifier_type = 'JT808_PHONE'
|
||||
AND vi.identifier_value = r.phone
|
||||
AND vi.enabled = 1
|
||||
WHERE r.source_endpoint IS NOT NULL
|
||||
AND TRIM(r.source_endpoint) <> ''
|
||||
AND r.source_ip IS NOT NULL
|
||||
AND TRIM(r.source_ip) <> ''
|
||||
GROUP BY TRIM(r.source_ip)
|
||||
`
|
||||
|
||||
const jt808DataSourceCandidateWhereSQL = `inferred.source_ip IS NOT NULL
|
||||
AND inferred.source_ip <> ''
|
||||
AND inferred.platform_name IS NOT NULL
|
||||
AND inferred.platform_name <> ''
|
||||
AND inferred.platform_count = 1
|
||||
AND inferred.source_code_count = 1`
|
||||
|
||||
const jt808DataSourceNeedsSyncWhereSQL = `(ds.id IS NULL
|
||||
OR ds.platform_name IS NULL OR TRIM(ds.platform_name) = ''
|
||||
OR ds.source_code IS NULL OR TRIM(ds.source_code) = ''
|
||||
OR ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'
|
||||
OR (ds.enabled = 0 AND (ds.remark LIKE 'auto-retired:%' OR ds.remark = 'auto-reenabled: source evidence restored')))`
|
||||
|
||||
const syncJT808DataSourcesSQL = `
|
||||
INSERT INTO vehicle_data_source
|
||||
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, first_seen_at, latest_seen_at)
|
||||
SELECT
|
||||
inferred.protocol,
|
||||
inferred.source_ip,
|
||||
inferred.latest_source_endpoint,
|
||||
inferred.platform_name,
|
||||
inferred.source_code,
|
||||
'PLATFORM',
|
||||
inferred.first_seen_at,
|
||||
inferred.latest_seen_at
|
||||
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
ON ds.protocol = inferred.protocol
|
||||
AND ds.source_ip = inferred.source_ip
|
||||
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
||||
AND ` + jt808DataSourceNeedsSyncWhereSQL + `
|
||||
ON DUPLICATE KEY UPDATE
|
||||
latest_source_endpoint = VALUES(latest_source_endpoint),
|
||||
platform_name = CASE
|
||||
WHEN vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''
|
||||
THEN VALUES(platform_name)
|
||||
ELSE vehicle_data_source.platform_name
|
||||
END,
|
||||
source_code = CASE
|
||||
WHEN vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''
|
||||
THEN VALUES(source_code)
|
||||
ELSE vehicle_data_source.source_code
|
||||
END,
|
||||
source_kind = CASE
|
||||
WHEN vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'
|
||||
THEN VALUES(source_kind)
|
||||
ELSE vehicle_data_source.source_kind
|
||||
END,
|
||||
enabled = CASE
|
||||
WHEN vehicle_data_source.enabled = 0
|
||||
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
|
||||
AND (
|
||||
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
|
||||
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
|
||||
OR VALUES(source_kind) <> 'UNKNOWN'
|
||||
)
|
||||
THEN 1
|
||||
ELSE vehicle_data_source.enabled
|
||||
END,
|
||||
remark = CASE
|
||||
WHEN vehicle_data_source.enabled = 0
|
||||
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
|
||||
AND (
|
||||
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
|
||||
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
|
||||
OR VALUES(source_kind) <> 'UNKNOWN'
|
||||
)
|
||||
THEN 'auto-reenabled: source evidence restored'
|
||||
ELSE vehicle_data_source.remark
|
||||
END,
|
||||
first_seen_at = COALESCE(vehicle_data_source.first_seen_at, VALUES(first_seen_at)),
|
||||
latest_seen_at = GREATEST(COALESCE(vehicle_data_source.latest_seen_at, VALUES(latest_seen_at)), VALUES(latest_seen_at)),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
const reprojectSelectableDailyMileageSourcesSQL = `
|
||||
SELECT DISTINCT s.vin, s.stat_date, s.protocol
|
||||
FROM vehicle_daily_mileage_source s
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
|
||||
LEFT JOIN vehicle_daily_mileage m
|
||||
ON m.vin = s.vin AND m.stat_date = s.stat_date AND m.protocol = s.protocol
|
||||
WHERE s.protocol = 'JT808'
|
||||
AND s.quality_status = 'OK'
|
||||
AND (ds.id IS NULL OR ds.enabled = 1 OR (
|
||||
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
|
||||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||||
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
||||
))
|
||||
AND (
|
||||
m.vin IS NULL
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM vehicle_daily_mileage_source selected
|
||||
WHERE selected.vin = s.vin
|
||||
AND selected.stat_date = s.stat_date
|
||||
AND selected.protocol = s.protocol
|
||||
AND selected.is_selected = 1
|
||||
)
|
||||
)
|
||||
ORDER BY s.stat_date DESC, s.vin ASC
|
||||
`
|
||||
|
||||
const syncJT808DataSourcesCandidateCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
ON ds.protocol = inferred.protocol
|
||||
AND ds.source_ip = inferred.source_ip
|
||||
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
||||
AND ` + jt808DataSourceNeedsSyncWhereSQL
|
||||
|
||||
const syncJT808DataSourcesSkippedCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
||||
WHERE inferred.source_ip IS NOT NULL
|
||||
AND inferred.source_ip <> ''
|
||||
AND inferred.platform_name IS NOT NULL
|
||||
AND inferred.platform_name <> ''
|
||||
AND NOT (` + jt808DataSourceCandidateWhereSQL + `)`
|
||||
|
||||
const syncJT808DataSourcesConflictCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
||||
JOIN vehicle_data_source ds
|
||||
ON ds.protocol = inferred.protocol
|
||||
AND ds.source_ip = inferred.source_ip
|
||||
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
||||
AND ds.source_code IS NOT NULL
|
||||
AND TRIM(ds.source_code) <> ''
|
||||
AND ds.source_code <> inferred.source_code`
|
||||
|
||||
const classifyConfiguredDataSourcesWhereSQL = `
|
||||
(source_kind IS NULL OR TRIM(source_kind) = '' OR source_kind = 'UNKNOWN')
|
||||
AND (
|
||||
(source_code IS NOT NULL AND TRIM(source_code) <> '')
|
||||
OR (platform_name IS NOT NULL AND TRIM(platform_name) <> '')
|
||||
)`
|
||||
|
||||
const classifyConfiguredDataSourcesCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM vehicle_data_source
|
||||
WHERE ` + classifyConfiguredDataSourcesWhereSQL
|
||||
|
||||
const classifyConfiguredDataSourcesSQL = `
|
||||
UPDATE vehicle_data_source
|
||||
SET source_kind = 'PLATFORM',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE ` + classifyConfiguredDataSourcesWhereSQL
|
||||
|
||||
const pruneUnmanagedDataSourcesWhereSQL = `
|
||||
(ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
||||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||||
AND (ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN')
|
||||
AND TIMESTAMPDIFF(SECOND, COALESCE(ds.latest_seen_at, ds.updated_at, ds.created_at), CURRENT_TIMESTAMP) >= ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM vehicle_daily_mileage m
|
||||
WHERE m.source_id = ds.id
|
||||
LIMIT 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jt808_registration r
|
||||
WHERE ds.protocol = 'JT808'
|
||||
AND r.source_ip = ds.source_ip
|
||||
LIMIT 1
|
||||
)`
|
||||
|
||||
const pruneUnmanagedDataSourcesCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM vehicle_data_source ds
|
||||
WHERE ` + pruneUnmanagedDataSourcesWhereSQL
|
||||
|
||||
const pruneUnmanagedDataSourcesSQL = `
|
||||
DELETE ds
|
||||
FROM vehicle_data_source ds
|
||||
WHERE ` + pruneUnmanagedDataSourcesWhereSQL
|
||||
|
||||
const retireStaleUnmanagedDataSourcesWhereSQL = `
|
||||
ds.enabled = 1
|
||||
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
||||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||||
AND (ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN')
|
||||
AND TIMESTAMPDIFF(SECOND, COALESCE(ds.latest_seen_at, ds.updated_at, ds.created_at), CURRENT_TIMESTAMP) >= ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jt808_registration r
|
||||
WHERE ds.protocol = 'JT808'
|
||||
AND r.source_ip = ds.source_ip
|
||||
LIMIT 1
|
||||
)`
|
||||
|
||||
const retireStaleUnmanagedDataSourcesCountSQL = `
|
||||
SELECT COUNT(*)
|
||||
FROM vehicle_data_source ds
|
||||
WHERE ` + retireStaleUnmanagedDataSourcesWhereSQL
|
||||
|
||||
const retireStaleUnmanagedDataSourcesSQL = `
|
||||
UPDATE vehicle_data_source ds
|
||||
SET enabled = 0,
|
||||
remark = CASE
|
||||
WHEN remark IS NULL OR TRIM(remark) = ''
|
||||
THEN 'auto-retired: stale unmanaged source without registration evidence'
|
||||
ELSE remark
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE ` + retireStaleUnmanagedDataSourcesWhereSQL
|
||||
|
||||
func fail(err error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
426
go/vehicle-gateway/cmd/identity-import/main_test.go
Normal file
426
go/vehicle-gateway/cmd/identity-import/main_test.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
)
|
||||
|
||||
func TestReportItemLimitExpandsWhenCSVOutputIsRequested(t *testing.T) {
|
||||
if got := reportItemLimit(50, "", ""); got != 50 {
|
||||
t.Fatalf("reportItemLimit without csv = %d, want 50", got)
|
||||
}
|
||||
if got := reportItemLimit(50, "unresolved.csv", ""); got != -1 {
|
||||
t.Fatalf("reportItemLimit with unresolved csv = %d, want -1", got)
|
||||
}
|
||||
if got := reportItemLimit(50, "", "conflicts.csv"); got != -1 {
|
||||
t.Fatalf("reportItemLimit with conflicts csv = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedMappingFilesError(t *testing.T) {
|
||||
err := unsupportedMappingFilesError(identity.MappingScanReport{})
|
||||
if err != nil {
|
||||
t.Fatalf("unsupportedMappingFilesError(empty) = %v", err)
|
||||
}
|
||||
err = unsupportedMappingFilesError(identity.MappingScanReport{
|
||||
UnsupportedFiles: 2,
|
||||
UnsupportedItems: []identity.MappingUnsupportedFileReport{
|
||||
{File: "G7s/legacy.xls", Ext: ".xls"},
|
||||
{File: "信达/legacy.xlsb", Ext: ".xlsb"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("unsupportedMappingFilesError() nil, want error")
|
||||
}
|
||||
text := err.Error()
|
||||
for _, want := range []string{"2 unsupported workbook", "G7s/legacy.xls", "信达/legacy.xlsb", "before -apply"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("error missing %q: %s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMappingApplyInputAllowsDryRunButBlocksApply(t *testing.T) {
|
||||
scan := identity.MappingScanReport{
|
||||
UnsupportedFiles: 1,
|
||||
UnsupportedItems: []identity.MappingUnsupportedFileReport{
|
||||
{File: "G7s/legacy.xls", Ext: ".xls"},
|
||||
},
|
||||
}
|
||||
if err := validateMappingApplyInput(false, scan); err != nil {
|
||||
t.Fatalf("validateMappingApplyInput(dry-run) = %v", err)
|
||||
}
|
||||
if err := validateMappingApplyInput(true, scan); err == nil {
|
||||
t.Fatal("validateMappingApplyInput(apply) nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteUnresolvedCSV(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "unresolved.csv")
|
||||
err := writeUnresolvedCSV(path, []identity.MappingRecord{
|
||||
{
|
||||
File: "G7s/example.xlsx",
|
||||
Sheet: "Sheet1",
|
||||
Row: 2,
|
||||
SourceCode: "g7s",
|
||||
SourceName: "G7s",
|
||||
Protocol: "JT808",
|
||||
IdentifierType: identity.IdentifierTypeJT808Phone,
|
||||
IdentifierValue: "13307795425",
|
||||
RawValue: "013307795425",
|
||||
Plate: "粤AG18312",
|
||||
OEM: "G7s",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeUnresolvedCSV() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{"identifier_type", "JT808_PHONE", "13307795425", "vin_not_found_in_legacy_binding"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("csv missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConflictsCSV(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "conflicts.csv")
|
||||
err := writeConflictsCSV(path, []identity.MappingConflict{
|
||||
{
|
||||
Record: identity.MappingRecord{
|
||||
File: "source.xlsx",
|
||||
Sheet: "Sheet1",
|
||||
Row: 3,
|
||||
SourceCode: "xinda",
|
||||
Protocol: "JT808",
|
||||
IdentifierType: identity.IdentifierTypePlate,
|
||||
IdentifierValue: "粤AG18312",
|
||||
Plate: "粤AG18312",
|
||||
},
|
||||
ExistingVIN: "VIN001",
|
||||
NewVIN: "VIN002",
|
||||
Reason: "identifier already points to another vin",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeConflictsCSV() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{"existing_vin", "VIN001", "VIN002", "identifier already points to another vin"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("csv missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncJT808DataSourcesFromIdentifiersPreservesManualPlatformNames(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectExec("INSERT INTO vehicle_data_source").
|
||||
WillReturnResult(driver.RowsAffected(3))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(5))
|
||||
mock.ExpectExec("UPDATE vehicle_data_source").
|
||||
WillReturnResult(driver.RowsAffected(5))
|
||||
mock.ExpectQuery("SELECT DISTINCT s.vin, s.stat_date, s.protocol").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol"}).
|
||||
AddRow("LNXNEGRRXSR319449", "2026-07-13", "JT808"))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").
|
||||
WillReturnResult(driver.RowsAffected(1))
|
||||
mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").
|
||||
WillReturnResult(driver.RowsAffected(1))
|
||||
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").
|
||||
WillReturnResult(driver.RowsAffected(0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
report, err := syncJT808DataSourcesFromIdentifiers(context.Background(), db, true)
|
||||
if err != nil {
|
||||
t.Fatalf("syncJT808DataSourcesFromIdentifiers() error = %v", err)
|
||||
}
|
||||
if !report.Apply || report.CandidateSources != 3 || report.SkippedSources != 1 || report.ConflictingSources != 2 || report.Synced != 3 || report.PlatformKindCandidates != 5 || report.PlatformKindClassified != 5 || report.ReprojectDailyMileageTargets != 1 || report.ReprojectedDailyMileageTargets != 1 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"COUNT(DISTINCT",
|
||||
"vehicle_identifier vi",
|
||||
"TRIM(r.source_ip) AS source_ip",
|
||||
"GROUP BY TRIM(r.source_ip)",
|
||||
"inferred.source_code",
|
||||
"'PLATFORM'",
|
||||
"inferred.source_code_count = 1",
|
||||
"vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''",
|
||||
"ELSE vehicle_data_source.source_code",
|
||||
"vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'",
|
||||
"ELSE vehicle_data_source.source_kind",
|
||||
"vehicle_data_source.enabled = 0",
|
||||
"vehicle_data_source.remark LIKE 'auto-retired:%'",
|
||||
"vehicle_data_source.remark = 'auto-reenabled: source evidence restored'",
|
||||
"THEN 'auto-reenabled: source evidence restored'",
|
||||
"THEN 1",
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"ds.id IS NULL",
|
||||
} {
|
||||
if !strings.Contains(syncJT808DataSourcesSQL, want) {
|
||||
t.Fatalf("sync sql missing %q:\n%s", want, syncJT808DataSourcesSQL)
|
||||
}
|
||||
}
|
||||
if strings.Index(syncJT808DataSourcesSQL, "enabled = CASE") < 0 ||
|
||||
strings.Index(syncJT808DataSourcesSQL, "remark = CASE") < 0 ||
|
||||
strings.Index(syncJT808DataSourcesSQL, "enabled = CASE") > strings.Index(syncJT808DataSourcesSQL, "remark = CASE") {
|
||||
t.Fatalf("sync sql must restore enabled before updating remark because MySQL evaluates assignments in order:\n%s", syncJT808DataSourcesSQL)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"ds.id IS NULL",
|
||||
"ds.source_code IS NULL OR TRIM(ds.source_code) = ''",
|
||||
"ds.enabled = 0 AND (ds.remark LIKE 'auto-retired:%'",
|
||||
"ds.remark = 'auto-reenabled: source evidence restored'",
|
||||
} {
|
||||
if !strings.Contains(syncJT808DataSourcesCandidateCountSQL, want) {
|
||||
t.Fatalf("candidate count sql missing %q:\n%s", want, syncJT808DataSourcesCandidateCountSQL)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"JOIN vehicle_data_source ds",
|
||||
"ds.source_code <> inferred.source_code",
|
||||
} {
|
||||
if !strings.Contains(syncJT808DataSourcesConflictCountSQL, want) {
|
||||
t.Fatalf("conflict count sql missing %q:\n%s", want, syncJT808DataSourcesConflictCountSQL)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''",
|
||||
"ELSE vehicle_data_source.platform_name",
|
||||
} {
|
||||
if !strings.Contains(syncJT808DataSourcesSQL, want) {
|
||||
t.Fatalf("sync sql missing %q:\n%s", want, syncJT808DataSourcesSQL)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"source_kind IS NULL OR TRIM(source_kind) = '' OR source_kind = 'UNKNOWN'",
|
||||
"source_code IS NOT NULL AND TRIM(source_code) <> ''",
|
||||
"platform_name IS NOT NULL AND TRIM(platform_name) <> ''",
|
||||
"SET source_kind = 'PLATFORM'",
|
||||
} {
|
||||
if !strings.Contains(classifyConfiguredDataSourcesSQL, want) && !strings.Contains(classifyConfiguredDataSourcesCountSQL, want) {
|
||||
t.Fatalf("configured-source classification sql missing %q:\n%s\n%s", want, classifyConfiguredDataSourcesSQL, classifyConfiguredDataSourcesCountSQL)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"SELECT DISTINCT s.vin, s.stat_date, s.protocol",
|
||||
"vehicle_daily_mileage_source s",
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"LEFT JOIN vehicle_daily_mileage m",
|
||||
"s.protocol = 'JT808'",
|
||||
"s.quality_status = 'OK'",
|
||||
"ds.enabled = 1",
|
||||
"m.vin IS NULL",
|
||||
"selected.is_selected = 1",
|
||||
} {
|
||||
if !strings.Contains(reprojectSelectableDailyMileageSourcesSQL, want) {
|
||||
t.Fatalf("reproject sql missing %q:\n%s", want, reprojectSelectableDailyMileageSourcesSQL)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncJT808DataSourcesDryRunDoesNotWrite(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(4))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(6))
|
||||
mock.ExpectQuery("SELECT DISTINCT s.vin, s.stat_date, s.protocol").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol"}).
|
||||
AddRow("LNXNEGRRXSR319449", "2026-07-13", "JT808"))
|
||||
|
||||
report, err := syncJT808DataSourcesFromIdentifiers(context.Background(), db, false)
|
||||
if err != nil {
|
||||
t.Fatalf("syncJT808DataSourcesFromIdentifiers() error = %v", err)
|
||||
}
|
||||
if report.Apply || report.CandidateSources != 4 || report.SkippedSources != 2 || report.ConflictingSources != 1 || report.PlatformKindCandidates != 6 || report.PlatformKindClassified != 0 || report.Synced != 0 || report.ReprojectDailyMileageTargets != 1 || report.ReprojectedDailyMileageTargets != 0 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceModeNamesAllSourceMaintenanceSteps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apply bool
|
||||
sync bool
|
||||
prune bool
|
||||
retire bool
|
||||
want string
|
||||
}{
|
||||
{name: "dry run", want: "dry_run"},
|
||||
{name: "apply", apply: true, want: "apply"},
|
||||
{name: "sync dry run", sync: true, want: "sync_dry_run"},
|
||||
{name: "sync prune retire apply", apply: true, sync: true, prune: true, retire: true, want: "sync_prune_retire_apply"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := maintenanceMode(tt.apply, tt.sync, tt.prune, tt.retire); got != tt.want {
|
||||
t.Fatalf("maintenanceMode() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneUnmanagedDataSourcesDryRunDoesNotDelete(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WithArgs(int64(3600)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(339))
|
||||
|
||||
report, err := pruneUnmanagedDataSourcesWithoutEvidence(context.Background(), db, false, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("pruneUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
||||
}
|
||||
if report.Apply || report.MinAgeSeconds != 3600 || report.CandidateSources != 339 || report.Pruned != 0 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vehicle_data_source ds",
|
||||
"vehicle_daily_mileage m",
|
||||
"m.source_id = ds.id",
|
||||
"jt808_registration r",
|
||||
"r.source_ip = ds.source_ip",
|
||||
"ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'",
|
||||
"TIMESTAMPDIFF(SECOND",
|
||||
} {
|
||||
if !strings.Contains(pruneUnmanagedDataSourcesCountSQL, want) {
|
||||
t.Fatalf("prune count sql missing %q:\n%s", want, pruneUnmanagedDataSourcesCountSQL)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireStaleUnmanagedDataSourcesDryRunDoesNotDisable(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WithArgs(int64(86400)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(83))
|
||||
|
||||
report, err := retireStaleUnmanagedDataSourcesWithoutEvidence(context.Background(), db, false, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("retireStaleUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
||||
}
|
||||
if report.Apply || report.MinAgeSeconds != 86400 || report.CandidateSources != 83 || report.Retired != 0 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vehicle_data_source ds",
|
||||
"ds.enabled = 1",
|
||||
"jt808_registration r",
|
||||
"r.source_ip = ds.source_ip",
|
||||
"ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'",
|
||||
"TIMESTAMPDIFF(SECOND",
|
||||
} {
|
||||
if !strings.Contains(retireStaleUnmanagedDataSourcesCountSQL, want) {
|
||||
t.Fatalf("retire count sql missing %q:\n%s", want, retireStaleUnmanagedDataSourcesCountSQL)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireStaleUnmanagedDataSourcesApplyDisablesOnlyCandidates(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WithArgs(int64(7200)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(7))
|
||||
mock.ExpectExec("UPDATE vehicle_data_source ds").
|
||||
WithArgs(int64(7200)).
|
||||
WillReturnResult(driver.RowsAffected(7))
|
||||
|
||||
report, err := retireStaleUnmanagedDataSourcesWithoutEvidence(context.Background(), db, true, 2*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("retireStaleUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
||||
}
|
||||
if !report.Apply || report.MinAgeSeconds != 7200 || report.CandidateSources != 7 || report.Retired != 7 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneUnmanagedDataSourcesApplyDeletesOnlyCandidates(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
||||
WithArgs(int64(1800)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(12))
|
||||
mock.ExpectExec("DELETE ds\\s+FROM vehicle_data_source ds").
|
||||
WithArgs(int64(1800)).
|
||||
WillReturnResult(driver.RowsAffected(12))
|
||||
|
||||
report, err := pruneUnmanagedDataSourcesWithoutEvidence(context.Background(), db, true, 30*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("pruneUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
||||
}
|
||||
if !report.Apply || report.MinAgeSeconds != 1800 || report.CandidateSources != 12 || report.Pruned != 12 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
512
go/vehicle-gateway/cmd/identity-writer/main.go
Normal file
512
go/vehicle-gateway/cmd/identity-writer/main.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
const identityBatchOperationTimeout = 30 * time.Second
|
||||
|
||||
func main() {
|
||||
logger := observability.NewLogger("vehicle-identity-writer")
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Error("invalid identity writer config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := sql.Open("mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
logger.Error("mysql open failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(cfg.MySQLMaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MySQLMaxIdleConns)
|
||||
db.SetConnMaxLifetime(cfg.MySQLConnMaxLifetime)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
logger.Error("mysql ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if cfg.EnsureSchema {
|
||||
if err := identity.EnsureJT808RegistrationSchema(ctx, db); err != nil {
|
||||
logger.Error("jt808 registration schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
registry := metrics.NewRegistry()
|
||||
metrics.RegisterKafkaConsumerInfo(registry, "vehicle-identity-writer", cfg.KafkaGroup, []string{cfg.KafkaTopic})
|
||||
registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize))
|
||||
registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "batch_wait_ms"}, float64(cfg.BatchWait.Milliseconds()))
|
||||
registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "location_touch_interval_seconds"}, cfg.LocationTouchInterval.Seconds())
|
||||
registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
|
||||
health.Start(ctx, logger, health.NewServer(cfg.HealthAddr, "vehicle-identity-writer", []health.Check{
|
||||
{Name: "mysql", Check: db.PingContext},
|
||||
}, registry))
|
||||
|
||||
store := identity.NewJT808RegistrationStore(db)
|
||||
|
||||
logger.Info("identity writer started",
|
||||
"group", cfg.KafkaGroup,
|
||||
"topic", cfg.KafkaTopic,
|
||||
"workers", cfg.Workers,
|
||||
"batch_size", cfg.BatchSize,
|
||||
"batch_wait_ms", cfg.BatchWait.Milliseconds(),
|
||||
"location_touch_interval_seconds", cfg.LocationTouchInterval.Seconds())
|
||||
var workers sync.WaitGroup
|
||||
for workerID := 1; workerID <= cfg.Workers; workerID++ {
|
||||
workers.Add(1)
|
||||
go func(id int) {
|
||||
defer workers.Done()
|
||||
runIdentityConsumer(ctx, logger, registry, store, cfg, id)
|
||||
}(workerID)
|
||||
}
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func runIdentityConsumer(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
store registrationBatchStore,
|
||||
cfg config,
|
||||
workerID int,
|
||||
) {
|
||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: cfg.KafkaBrokers,
|
||||
GroupID: cfg.KafkaGroup,
|
||||
GroupTopics: []string{cfg.KafkaTopic},
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
StartOffset: cfg.StartOffset,
|
||||
})
|
||||
defer reader.Close()
|
||||
|
||||
// Kafka keeps one phone on one partition. Per-worker throttling avoids a
|
||||
// global hot lock; a rebalance can only cause one harmless idempotent touch.
|
||||
projector := identity.NewJT808RegistrationProjector(cfg.Location, cfg.LocationTouchInterval)
|
||||
workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)}
|
||||
registry.SetGauge("vehicle_identity_writer_worker_active", workerLabels, 1)
|
||||
defer registry.SetGauge("vehicle_identity_writer_worker_active", workerLabels, 0)
|
||||
|
||||
logger.Info("identity kafka consumer started", "worker", workerID, "topic", cfg.KafkaTopic)
|
||||
for {
|
||||
first, err := reader.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.Error("kafka fetch failed", "error", err)
|
||||
if !waitForRetry(ctx, cfg.RetryDelay) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
batch := collectIdentityBatch(ctx, reader, first, cfg.BatchSize, cfg.BatchWait)
|
||||
if !processIdentityBatchReliablyForWorker(ctx, logger, registry, projector, store, reader, batch, cfg.RetryDelay, workerLabels) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type registrationBatchStore interface {
|
||||
UpsertBatch(context.Context, []identity.JT808RegistrationFact) error
|
||||
}
|
||||
|
||||
type registrationProjector interface {
|
||||
ProjectBatch([]envelope.FrameEnvelope) []identity.JT808RegistrationFact
|
||||
MarkPersisted([]identity.JT808RegistrationFact)
|
||||
}
|
||||
|
||||
type kafkaMessageFetcher interface {
|
||||
FetchMessage(context.Context) (kafka.Message, error)
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
const (
|
||||
identityFailureWrite = "write_error"
|
||||
identityFailureCommit = "commit_error"
|
||||
)
|
||||
|
||||
type identityBatchFailure struct {
|
||||
reason string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *identityBatchFailure) Error() string { return e.err.Error() }
|
||||
func (e *identityBatchFailure) Unwrap() error { return e.err }
|
||||
|
||||
func collectIdentityBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message {
|
||||
if maxSize <= 1 {
|
||||
return []kafka.Message{first}
|
||||
}
|
||||
if maxWait <= 0 {
|
||||
maxWait = 20 * time.Millisecond
|
||||
}
|
||||
batch := []kafka.Message{first}
|
||||
deadline := time.Now().Add(maxWait)
|
||||
for len(batch) < maxSize {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, remaining)
|
||||
message, err := fetcher.FetchMessage(fetchCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
batch = append(batch, message)
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
func processIdentityBatch(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
) error {
|
||||
return processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, true, nil)
|
||||
}
|
||||
|
||||
func processIdentityBatchAttempt(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
recordReceived bool,
|
||||
) error {
|
||||
return processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, recordReceived, nil)
|
||||
}
|
||||
|
||||
func processIdentityBatchAttemptForWorker(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
recordReceived bool,
|
||||
workerLabels metrics.Labels,
|
||||
) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), identityBatchOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
registry.SetGauge("vehicle_identity_writer_batch_pending_messages", workerLabels, float64(len(messages)))
|
||||
defer registry.SetGauge("vehicle_identity_writer_batch_pending_messages", workerLabels, 0)
|
||||
envelopes := make([]envelope.FrameEnvelope, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
if recordReceived {
|
||||
recordIdentityMessage(registry, message, "received")
|
||||
registry.SetKafkaLag("vehicle_identity_writer_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark)
|
||||
}
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
if recordReceived {
|
||||
recordIdentityMessage(registry, message, "invalid_json")
|
||||
logger.Warn("skip invalid identity raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status, err := topics.ValidateRawEnvelope(message.Topic, env); err != nil {
|
||||
if recordReceived {
|
||||
recordIdentityMessage(registry, message, status)
|
||||
logger.Warn("skip mismatched identity raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
envelopes = append(envelopes, env)
|
||||
}
|
||||
facts := projector.ProjectBatch(envelopes)
|
||||
registry.SetGauge("vehicle_identity_writer_batch_pending_facts", workerLabels, float64(len(facts)))
|
||||
defer registry.SetGauge("vehicle_identity_writer_batch_pending_facts", workerLabels, 0)
|
||||
if len(facts) > 0 {
|
||||
started := time.Now()
|
||||
if err := store.UpsertBatch(operationCtx, facts); err != nil {
|
||||
recordIdentityWrite(registry, "error", len(facts), time.Since(started))
|
||||
return &identityBatchFailure{reason: identityFailureWrite, err: fmt.Errorf("upsert jt808 registration facts: %w", err)}
|
||||
}
|
||||
projector.MarkPersisted(facts)
|
||||
recordIdentityWrite(registry, "ok", len(facts), time.Since(started))
|
||||
}
|
||||
if err := committer.CommitMessages(operationCtx, messages...); err != nil {
|
||||
for _, message := range messages {
|
||||
recordIdentityCommit(registry, message, "error")
|
||||
}
|
||||
return &identityBatchFailure{reason: identityFailureCommit, err: fmt.Errorf("commit identity kafka batch: %w", err)}
|
||||
}
|
||||
for _, message := range messages {
|
||||
recordIdentityCommit(registry, message, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processIdentityBatchReliably(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
retryDelay time.Duration,
|
||||
) bool {
|
||||
return processIdentityBatchReliablyForWorker(ctx, logger, registry, projector, store, committer, messages, retryDelay, nil)
|
||||
}
|
||||
|
||||
func processIdentityBatchReliablyForWorker(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
projector registrationProjector,
|
||||
store registrationBatchStore,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
retryDelay time.Duration,
|
||||
workerLabels metrics.Labels,
|
||||
) bool {
|
||||
defer registry.SetGauge("vehicle_identity_writer_retry_pending_messages", workerLabels, 0)
|
||||
recordReceived := true
|
||||
for {
|
||||
err := processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, recordReceived, workerLabels)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
reason := identityFailureReason(err)
|
||||
registry.SetGauge("vehicle_identity_writer_retry_pending_messages", workerLabels, float64(len(messages)))
|
||||
registry.IncCounter("vehicle_identity_writer_batch_retries_total", metrics.Labels{"reason": reason})
|
||||
logger.Error("identity batch failed; retrying without fetching newer offsets", "messages", len(messages), "reason", reason, "error", err)
|
||||
if reason == identityFailureCommit {
|
||||
return retryIdentityCommit(ctx, logger, registry, committer, messages, retryDelay)
|
||||
}
|
||||
if !waitForRetry(ctx, retryDelay) {
|
||||
return false
|
||||
}
|
||||
recordReceived = false
|
||||
}
|
||||
}
|
||||
|
||||
func retryIdentityCommit(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry *metrics.Registry,
|
||||
committer kafkaMessageCommitter,
|
||||
messages []kafka.Message,
|
||||
retryDelay time.Duration,
|
||||
) bool {
|
||||
for {
|
||||
if !waitForRetry(ctx, retryDelay) {
|
||||
return false
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), identityBatchOperationTimeout)
|
||||
err := committer.CommitMessages(operationCtx, messages...)
|
||||
cancel()
|
||||
if err == nil {
|
||||
for _, message := range messages {
|
||||
recordIdentityCommit(registry, message, "ok")
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, message := range messages {
|
||||
recordIdentityCommit(registry, message, "error")
|
||||
}
|
||||
registry.IncCounter("vehicle_identity_writer_batch_retries_total", metrics.Labels{"reason": identityFailureCommit})
|
||||
logger.Error("identity kafka commit retry failed", "messages", len(messages), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func identityFailureReason(err error) string {
|
||||
var failure *identityBatchFailure
|
||||
if errors.As(err, &failure) && failure.reason != "" {
|
||||
return failure.reason
|
||||
}
|
||||
return identityFailureWrite
|
||||
}
|
||||
|
||||
var identityWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func recordIdentityMessage(registry *metrics.Registry, message kafka.Message, status string) {
|
||||
labels := metrics.Labels{"topic": message.Topic, "status": status}
|
||||
registry.IncCounter("vehicle_identity_writer_kafka_messages_total", labels)
|
||||
metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_message_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func recordIdentityWrite(registry *metrics.Registry, status string, facts int, elapsed time.Duration) {
|
||||
labels := metrics.Labels{"status": status}
|
||||
registry.IncCounter("vehicle_identity_writer_batches_total", labels)
|
||||
registry.AddCounter("vehicle_identity_writer_facts_total", labels, float64(facts))
|
||||
registry.ObserveHistogram("vehicle_identity_writer_write_duration_ms_histogram", labels, identityWriteDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_write_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func recordIdentityCommit(registry *metrics.Registry, message kafka.Message, status string) {
|
||||
labels := metrics.Labels{"topic": message.Topic, "status": status}
|
||||
registry.IncCounter("vehicle_identity_writer_kafka_commits_total", labels)
|
||||
metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_commit_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) bool {
|
||||
if delay <= 0 {
|
||||
delay = time.Second
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type config struct {
|
||||
KafkaBrokers []string
|
||||
KafkaTopic string
|
||||
KafkaGroup string
|
||||
MySQLDSN string
|
||||
MySQLMaxOpenConns int
|
||||
MySQLMaxIdleConns int
|
||||
MySQLConnMaxLifetime time.Duration
|
||||
EnsureSchema bool
|
||||
HealthAddr string
|
||||
Location *time.Location
|
||||
LocationTouchInterval time.Duration
|
||||
BatchSize int
|
||||
BatchWait time.Duration
|
||||
RetryDelay time.Duration
|
||||
StartOffset int64
|
||||
Workers int
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
location, err := time.LoadLocation(env("LOCAL_TZ", "Asia/Shanghai"))
|
||||
if err != nil {
|
||||
location = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
}
|
||||
startOffset := kafka.LastOffset
|
||||
if strings.EqualFold(env("KAFKA_START_OFFSET", "last"), "first") {
|
||||
startOffset = kafka.FirstOffset
|
||||
}
|
||||
return config{
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
KafkaTopic: env("KAFKA_TOPIC", topics.RawJT808),
|
||||
KafkaGroup: env("KAFKA_GROUP", "go-identity-writer"),
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
MySQLMaxOpenConns: envInt("MYSQL_MAX_OPEN_CONNS", 8),
|
||||
MySQLMaxIdleConns: envInt("MYSQL_MAX_IDLE_CONNS", 4),
|
||||
MySQLConnMaxLifetime: time.Duration(envInt("MYSQL_CONN_MAX_LIFETIME_SECONDS", 300)) * time.Second,
|
||||
EnsureSchema: envBool("MYSQL_ENSURE_SCHEMA", true),
|
||||
HealthAddr: env("HEALTH_ADDR", "127.0.0.1:20217"),
|
||||
Location: location,
|
||||
LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
BatchSize: envInt("IDENTITY_WRITER_BATCH_SIZE", 500),
|
||||
BatchWait: time.Duration(envInt("IDENTITY_WRITER_BATCH_WAIT_MS", 20)) * time.Millisecond,
|
||||
RetryDelay: time.Duration(envInt("IDENTITY_WRITER_RETRY_DELAY_MS", 1000)) * time.Millisecond,
|
||||
StartOffset: startOffset,
|
||||
Workers: envInt("IDENTITY_WRITER_WORKERS", 3),
|
||||
}
|
||||
}
|
||||
|
||||
func (c config) Validate() error {
|
||||
if len(c.KafkaBrokers) == 0 {
|
||||
return fmt.Errorf("KAFKA_BROKERS is required")
|
||||
}
|
||||
if strings.TrimSpace(c.MySQLDSN) == "" {
|
||||
return fmt.Errorf("MYSQL_DSN is required")
|
||||
}
|
||||
protocol, ok := topics.ProtocolForKnownRawTopic(c.KafkaTopic)
|
||||
if !ok || protocol != string(envelope.ProtocolJT808) {
|
||||
return fmt.Errorf("identity writer consumes JT808 raw topic only, got %q", c.KafkaTopic)
|
||||
}
|
||||
if c.BatchSize <= 0 {
|
||||
return fmt.Errorf("IDENTITY_WRITER_BATCH_SIZE must be positive")
|
||||
}
|
||||
if c.Workers <= 0 {
|
||||
return fmt.Errorf("IDENTITY_WRITER_WORKERS must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, item := range parts {
|
||||
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
314
go/vehicle-gateway/cmd/identity-writer/main_test.go
Normal file
314
go/vehicle-gateway/cmd/identity-writer/main_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
type fakeRegistrationProjector struct {
|
||||
facts []identity.JT808RegistrationFact
|
||||
envelopes []envelope.FrameEnvelope
|
||||
markedFacts []identity.JT808RegistrationFact
|
||||
}
|
||||
|
||||
func (p *fakeRegistrationProjector) ProjectBatch(envs []envelope.FrameEnvelope) []identity.JT808RegistrationFact {
|
||||
p.envelopes = append(p.envelopes, envs...)
|
||||
return p.facts
|
||||
}
|
||||
|
||||
func (p *fakeRegistrationProjector) MarkPersisted(facts []identity.JT808RegistrationFact) {
|
||||
p.markedFacts = append(p.markedFacts, facts...)
|
||||
}
|
||||
|
||||
type fakeRegistrationStore struct {
|
||||
facts []identity.JT808RegistrationFact
|
||||
err error
|
||||
count int
|
||||
failOnCount int
|
||||
}
|
||||
|
||||
func (s *fakeRegistrationStore) UpsertBatch(_ context.Context, facts []identity.JT808RegistrationFact) error {
|
||||
s.count++
|
||||
s.facts = append(s.facts, facts...)
|
||||
if s.err != nil && (s.failOnCount == 0 || s.count == s.failOnCount) {
|
||||
return s.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeCommitter struct {
|
||||
messages []kafka.Message
|
||||
err error
|
||||
count int
|
||||
failOnCount int
|
||||
}
|
||||
|
||||
func (c *fakeCommitter) CommitMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
c.count++
|
||||
c.messages = append(c.messages, messages...)
|
||||
if c.err != nil && (c.failOnCount == 0 || c.count == c.failOnCount) {
|
||||
return c.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchDoesNotCommitOrThrottleOnStoreFailure(t *testing.T) {
|
||||
wantErr := errors.New("mysql unavailable")
|
||||
projector := &fakeRegistrationProjector{facts: []identity.JT808RegistrationFact{{
|
||||
Phone: "13307795425",
|
||||
SeenAt: time.Now(),
|
||||
}}}
|
||||
store := &fakeRegistrationStore{err: wantErr}
|
||||
committer := &fakeCommitter{}
|
||||
|
||||
err := processIdentityBatch(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
metrics.NewRegistry(),
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
[]kafka.Message{validIdentityMessage(t, 1)},
|
||||
)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("processIdentityBatch() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if len(committer.messages) != 0 {
|
||||
t.Fatalf("committed messages = %d, want 0", len(committer.messages))
|
||||
}
|
||||
if len(projector.markedFacts) != 0 {
|
||||
t.Fatalf("marked facts = %d, want 0", len(projector.markedFacts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchMarksOnlyAfterStoreAndCommits(t *testing.T) {
|
||||
fact := identity.JT808RegistrationFact{Phone: "13307795425", SeenAt: time.Now()}
|
||||
projector := &fakeRegistrationProjector{facts: []identity.JT808RegistrationFact{fact}}
|
||||
store := &fakeRegistrationStore{}
|
||||
committer := &fakeCommitter{}
|
||||
messages := []kafka.Message{validIdentityMessage(t, 1), validIdentityMessage(t, 2)}
|
||||
|
||||
err := processIdentityBatch(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
metrics.NewRegistry(),
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
messages,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("processIdentityBatch() error = %v", err)
|
||||
}
|
||||
if len(store.facts) != 1 || len(projector.markedFacts) != 1 {
|
||||
t.Fatalf("store facts = %d marked facts = %d, want 1/1", len(store.facts), len(projector.markedFacts))
|
||||
}
|
||||
if len(committer.messages) != len(messages) {
|
||||
t.Fatalf("committed messages = %d, want %d", len(committer.messages), len(messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchCommitsPoisonEnvelopeWithoutProjection(t *testing.T) {
|
||||
projector := &fakeRegistrationProjector{}
|
||||
store := &fakeRegistrationStore{}
|
||||
committer := &fakeCommitter{}
|
||||
message := kafka.Message{Topic: topics.RawJT808, Partition: 1, Offset: 7, Value: []byte("not-json")}
|
||||
|
||||
err := processIdentityBatch(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
metrics.NewRegistry(),
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
[]kafka.Message{message},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("processIdentityBatch() error = %v", err)
|
||||
}
|
||||
if len(store.facts) != 0 || len(projector.envelopes) != 0 {
|
||||
t.Fatalf("poison message reached projector/store: envs=%d facts=%d", len(projector.envelopes), len(store.facts))
|
||||
}
|
||||
if len(committer.messages) != 1 {
|
||||
t.Fatalf("committed messages = %d, want 1", len(committer.messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchReliablyRetriesCommitWithoutRewritingMySQL(t *testing.T) {
|
||||
fact := identity.JT808RegistrationFact{Phone: "13307795425", SeenAt: time.Now()}
|
||||
projector := &fakeRegistrationProjector{facts: []identity.JT808RegistrationFact{fact}}
|
||||
store := &fakeRegistrationStore{}
|
||||
committer := &fakeCommitter{err: errors.New("commit failed"), failOnCount: 1}
|
||||
registry := metrics.NewRegistry()
|
||||
messages := []kafka.Message{validIdentityMessage(t, 1), validIdentityMessage(t, 2)}
|
||||
|
||||
ok := processIdentityBatchReliably(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
registry,
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
messages,
|
||||
time.Nanosecond,
|
||||
)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("reliable identity batch returned false")
|
||||
}
|
||||
if store.count != 1 {
|
||||
t.Fatalf("mysql writes = %d, want 1 after commit-only retry", store.count)
|
||||
}
|
||||
if committer.count != 2 {
|
||||
t.Fatalf("commit attempts = %d, want initial failure and one retry", committer.count)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_identity_writer_batch_retries_total{reason="commit_error"} 1`,
|
||||
`vehicle_identity_writer_retry_pending_messages 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("missing metric %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchReliablyRetriesStoreBeforeCommit(t *testing.T) {
|
||||
fact := identity.JT808RegistrationFact{Phone: "13307795425", SeenAt: time.Now()}
|
||||
projector := &fakeRegistrationProjector{facts: []identity.JT808RegistrationFact{fact}}
|
||||
store := &fakeRegistrationStore{err: errors.New("mysql unavailable"), failOnCount: 1}
|
||||
committer := &fakeCommitter{}
|
||||
registry := metrics.NewRegistry()
|
||||
messages := []kafka.Message{validIdentityMessage(t, 1)}
|
||||
|
||||
ok := processIdentityBatchReliably(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
registry,
|
||||
projector,
|
||||
store,
|
||||
committer,
|
||||
messages,
|
||||
time.Nanosecond,
|
||||
)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("reliable identity batch returned false")
|
||||
}
|
||||
if store.count != 2 || committer.count != 1 {
|
||||
t.Fatalf("mysql writes=%d commits=%d, want 2/1", store.count, committer.count)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_identity_writer_batch_retries_total{reason="write_error"} 1`,
|
||||
`vehicle_identity_writer_kafka_messages_total{status="received",topic="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("missing metric %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsNonJT808Topic(t *testing.T) {
|
||||
cfg := config{
|
||||
KafkaBrokers: []string{"127.0.0.1:9092"},
|
||||
KafkaTopic: topics.RawGB32960,
|
||||
MySQLDSN: "user:pass@tcp(localhost:3306)/db",
|
||||
BatchSize: 100,
|
||||
Workers: 3,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() error = nil, want non-JT808 topic error")
|
||||
}
|
||||
cfg.KafkaTopic = topics.RawJT808
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsAndOverridesWorkers(t *testing.T) {
|
||||
t.Setenv("IDENTITY_WRITER_WORKERS", "")
|
||||
if got := loadConfig().Workers; got != 3 {
|
||||
t.Fatalf("default workers = %d, want 3", got)
|
||||
}
|
||||
t.Setenv("IDENTITY_WRITER_WORKERS", "5")
|
||||
if got := loadConfig().Workers; got != 5 {
|
||||
t.Fatalf("configured workers = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsNonPositiveWorkers(t *testing.T) {
|
||||
cfg := config{
|
||||
KafkaBrokers: []string{"127.0.0.1:9092"},
|
||||
KafkaTopic: topics.RawJT808,
|
||||
MySQLDSN: "user:pass@tcp(localhost:3306)/db",
|
||||
BatchSize: 100,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "IDENTITY_WRITER_WORKERS") {
|
||||
t.Fatalf("Validate() error = %v, want workers error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIdentityBatchForWorkerLabelsPendingMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
workerLabels := metrics.Labels{"worker": "2"}
|
||||
err := processIdentityBatchAttemptForWorker(
|
||||
context.Background(),
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
registry,
|
||||
&fakeRegistrationProjector{},
|
||||
&fakeRegistrationStore{},
|
||||
&fakeCommitter{},
|
||||
[]kafka.Message{validIdentityMessage(t, 1)},
|
||||
true,
|
||||
workerLabels,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("processIdentityBatchAttemptForWorker() error = %v", err)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_identity_writer_batch_pending_messages{worker="2"} 0`,
|
||||
`vehicle_identity_writer_batch_pending_facts{worker="2"} 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("missing metric %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validIdentityMessage(t *testing.T, offset int64) kafka.Message {
|
||||
t.Helper()
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: identity.JT808LocationMessageID,
|
||||
EventKind: envelope.EventKindRaw,
|
||||
Phone: "13307795425",
|
||||
VIN: "LTESTVIN000000001",
|
||||
ReceivedAtMS: time.Now().UnixMilli(),
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kafka.Message{
|
||||
Topic: topics.RawJT808,
|
||||
Partition: 1,
|
||||
Offset: offset,
|
||||
HighWaterMark: offset + 1,
|
||||
Value: payload,
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,26 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
flagCfg := loadsim.RegisterFlags(flag.CommandLine)
|
||||
cleanupRegistration := flag.Bool("cleanup-registration", false, "delete this run's loopback JT808 registration rows after the simulation")
|
||||
cleanupOnly := flag.Bool("cleanup-only", false, "skip the simulation and only clean the configured JT808 phone range")
|
||||
mysqlDSN := flag.String("mysql-dsn", strings.TrimSpace(os.Getenv("MYSQL_DSN")), "MySQL DSN used only by JT808 registration cleanup")
|
||||
if err := flag.CommandLine.Parse(os.Args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -25,6 +32,14 @@ func main() {
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if *cleanupOnly {
|
||||
deleted, err := cleanupJT808Registrations(ctx, cfg, *mysqlDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("jt808 synthetic registration cleanup completed rows_deleted=%d", deleted)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("load simulation started protocol=%s addr=%s connections=%d connect_rate=%d send_interval=%s duration=%s template=%s",
|
||||
cfg.Protocol, cfg.Addr, cfg.Connections, cfg.ConnectRatePerSecond, cfg.SendInterval, cfg.Duration, cfg.Template)
|
||||
@@ -33,13 +48,57 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Print(formatStats(stats))
|
||||
if *cleanupRegistration {
|
||||
deleted, err := cleanupJT808Registrations(ctx, cfg, *mysqlDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("jt808 synthetic registration cleanup completed rows_deleted=%d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupJT808Registrations(ctx context.Context, cfg loadsim.Config, dsn string) (int64, error) {
|
||||
if cfg.Protocol != loadsim.ProtocolJT808 {
|
||||
return 0, fmt.Errorf("registration cleanup only supports jt808")
|
||||
}
|
||||
if strings.TrimSpace(dsn) == "" {
|
||||
return 0, fmt.Errorf("mysql-dsn or MYSQL_DSN is required for registration cleanup")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open mysql for registration cleanup: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
return cleanupJT808RegistrationsWithDB(ctx, db, cfg)
|
||||
}
|
||||
|
||||
type cleanupExecer interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func cleanupJT808RegistrationsWithDB(ctx context.Context, exec cleanupExecer, cfg loadsim.Config) (int64, error) {
|
||||
firstPhone := fmt.Sprintf("%012d", cfg.JT808PhoneBase)
|
||||
lastPhone := fmt.Sprintf("%012d", cfg.JT808PhoneBase+int64(cfg.Connections)-1)
|
||||
result, err := exec.ExecContext(ctx, `DELETE FROM jt808_registration
|
||||
WHERE phone BETWEEN ? AND ?
|
||||
AND source_ip IN ('127.0.0.1', '::1')`, firstPhone, lastPhone)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete loopback jt808 registrations: %w", err)
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read registration cleanup result: %w", err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func formatStats(stats loadsim.Stats) string {
|
||||
return fmt.Sprintf("connections_opened=%d connections_failed=%d frames_written=%d write_errors=%d",
|
||||
return fmt.Sprintf("connections_opened=%d connections_failed=%d frames_written=%d write_errors=%d response_bytes=%d read_errors=%d",
|
||||
stats.ConnectionsOpened,
|
||||
stats.ConnectionsFailed,
|
||||
stats.FramesWritten,
|
||||
stats.WriteErrors,
|
||||
stats.ResponseBytes,
|
||||
stats.ReadErrors,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim"
|
||||
)
|
||||
|
||||
@@ -13,6 +16,8 @@ func TestFormatStatsIncludesCapacityCounters(t *testing.T) {
|
||||
ConnectionsFailed: 2,
|
||||
FramesWritten: 300,
|
||||
WriteErrors: 1,
|
||||
ResponseBytes: 2048,
|
||||
ReadErrors: 0,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
@@ -20,9 +25,37 @@ func TestFormatStatsIncludesCapacityCounters(t *testing.T) {
|
||||
"connections_failed=2",
|
||||
"frames_written=300",
|
||||
"write_errors=1",
|
||||
"response_bytes=2048",
|
||||
"read_errors=0",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("formatStats() = %q, missing %q", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupJT808RegistrationsUsesBoundedLoopbackDelete(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectExec(`DELETE FROM jt808_registration`).
|
||||
WithArgs("139000000000", "139000000999").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1000))
|
||||
|
||||
deleted, err := cleanupJT808RegistrationsWithDB(context.Background(), db, loadsim.Config{
|
||||
Protocol: loadsim.ProtocolJT808,
|
||||
Connections: 1000,
|
||||
JT808PhoneBase: loadsim.DefaultJT808PhoneBase,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("cleanupJT808RegistrationsWithDB() error = %v", err)
|
||||
}
|
||||
if deleted != 1000 {
|
||||
t.Fatalf("deleted = %d, want 1000", deleted)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,24 +60,32 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tdDB, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if err != nil {
|
||||
logger.Error("tdengine open failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
tdDB.SetMaxOpenConns(cfg.TDengineMaxOpenConns)
|
||||
tdDB.SetMaxIdleConns(cfg.TDengineMaxIdleConns)
|
||||
if err := tdDB.PingContext(ctx); err != nil {
|
||||
logger.Error("tdengine ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
historyWriter := history.NewWriterWithDatabase(tdDB, cfg.TDengineDatabase)
|
||||
if cfg.TDengineEnsureSchema {
|
||||
if err := historyWriter.EnsureSchema(ctx, cfg.TDengineDatabase); err != nil {
|
||||
logger.Error("tdengine schema bootstrap failed", "error", err)
|
||||
var historyWriter fastAppender
|
||||
var tdCheck health.Check
|
||||
if cfg.TDengineEnabled {
|
||||
tdDB, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if err != nil {
|
||||
logger.Error("tdengine open failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
tdDB.SetMaxOpenConns(cfg.TDengineMaxOpenConns)
|
||||
tdDB.SetMaxIdleConns(cfg.TDengineMaxIdleConns)
|
||||
if err := tdDB.PingContext(ctx); err != nil {
|
||||
logger.Error("tdengine ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
writer := history.NewWriterWithDatabase(tdDB, cfg.TDengineDatabase)
|
||||
if cfg.TDengineEnsureSchema {
|
||||
if err := writer.EnsureSchema(ctx, cfg.TDengineDatabase); err != nil {
|
||||
logger.Error("tdengine schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
historyWriter = writer
|
||||
tdCheck = health.Check{Name: "tdengine", Check: tdDB.PingContext}
|
||||
} else {
|
||||
logger.Info("nats fast writer tdengine stage disabled")
|
||||
}
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
@@ -94,23 +102,30 @@ func main() {
|
||||
realtimeRepo := realtime.NewRepository(redisClient, realtime.Config{OnlineTTL: cfg.OnlineTTL})
|
||||
|
||||
registry := metrics.NewRegistry()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-fast-writer", []health.Check{
|
||||
recordFastWriterConfigMetrics(registry, cfg)
|
||||
registry.SetGauge("vehicle_fast_writer_tdengine_enabled", nil, boolGauge(cfg.TDengineEnabled))
|
||||
healthChecks := []health.Check{
|
||||
{Name: "nats", Check: func(context.Context) error {
|
||||
if conn.Status() != nats.CONNECTED {
|
||||
return fmt.Errorf("nats status is %s", conn.Status().String())
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Name: "tdengine", Check: tdDB.PingContext},
|
||||
{Name: "redis", Check: func(ctx context.Context) error { return redisClient.Ping(ctx).Err() }},
|
||||
}, registry))
|
||||
}
|
||||
if tdCheck.Name != "" {
|
||||
healthChecks = append(healthChecks, tdCheck)
|
||||
}
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-fast-writer", healthChecks, registry))
|
||||
|
||||
logger.Info("nats fast writer started",
|
||||
"stream", cfg.NATSStream,
|
||||
"durable", cfg.NATSDurable,
|
||||
"filter", cfg.NATSFilter,
|
||||
"batch_size", cfg.BatchSize,
|
||||
"fetch_wait_ms", cfg.FetchWait.Milliseconds(),
|
||||
"workers", cfg.Workers,
|
||||
"tdengine_enabled", cfg.TDengineEnabled,
|
||||
"tdengine_max_open_conns", cfg.TDengineMaxOpenConns,
|
||||
"tdengine_max_idle_conns", cfg.TDengineMaxIdleConns,
|
||||
"operation_timeout_ms", cfg.OperationWait.Milliseconds())
|
||||
@@ -135,6 +150,7 @@ type config struct {
|
||||
StreamMaxBytes int64
|
||||
StreamEnsureWait time.Duration
|
||||
Workers int
|
||||
TDengineEnabled bool
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
@@ -168,13 +184,14 @@ func loadConfig() config {
|
||||
NATSFilter: env("NATS_FILTER", "vehicle.raw.go.>"),
|
||||
NATSSubjects: subjects,
|
||||
BatchSize: envInt("FAST_WRITER_BATCH_SIZE", 100),
|
||||
FetchWait: time.Duration(envInt("FAST_WRITER_FETCH_WAIT_MS", 100)) * time.Millisecond,
|
||||
FetchWait: time.Duration(envInt("FAST_WRITER_FETCH_WAIT_MS", 20)) * time.Millisecond,
|
||||
OperationWait: time.Duration(envInt("FAST_WRITER_OPERATION_TIMEOUT_MS", 1000)) * time.Millisecond,
|
||||
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 30)) * time.Second,
|
||||
StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour,
|
||||
StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024),
|
||||
StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second,
|
||||
Workers: workers,
|
||||
TDengineEnabled: envBool("FAST_WRITER_TDENGINE_ENABLED", false),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
@@ -198,10 +215,18 @@ type fastUpdater interface {
|
||||
FastUpdate(context.Context, envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type fastResultUpdater interface {
|
||||
FastUpdateWithResult(context.Context, envelope.FrameEnvelope) (realtime.FastUpdateResult, error)
|
||||
}
|
||||
|
||||
type fastBatchUpdater interface {
|
||||
FastUpdateBatch(context.Context, []envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type fastBatchResultUpdater interface {
|
||||
FastUpdateBatchWithResult(context.Context, []envelope.FrameEnvelope) (realtime.FastUpdateResult, error)
|
||||
}
|
||||
|
||||
type fastMessage struct {
|
||||
subject string
|
||||
data []byte
|
||||
@@ -227,9 +252,17 @@ func runFastWorker(ctx context.Context, logger *slog.Logger, registry *metrics.R
|
||||
}
|
||||
msgs, err := sub.Fetch(cfg.BatchSize, nats.MaxWait(cfg.FetchWait))
|
||||
if err != nil {
|
||||
if isFastWorkerShutdownFetchError(ctx, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, nats.ErrTimeout) {
|
||||
continue
|
||||
}
|
||||
if isTransientFastFetchError(err) {
|
||||
logger.Warn("nats fetch interrupted", "error", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
logger.Error("nats fetch failed", "error", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
@@ -253,12 +286,33 @@ func runFastWorker(ctx context.Context, logger *slog.Logger, registry *metrics.R
|
||||
logger.Error("fast write batch failed", "messages", len(fastMessages), "error", err)
|
||||
continue
|
||||
}
|
||||
for _, msg := range fastMessages {
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isFastWorkerShutdownFetchError(ctx context.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, nats.ErrConnectionClosed)
|
||||
}
|
||||
|
||||
func isTransientFastFetchError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(text, "disconnected during fetch") ||
|
||||
strings.Contains(text, "connection closed") ||
|
||||
strings.Contains(text, "connection reset") ||
|
||||
strings.Contains(text, "broken pipe") ||
|
||||
strings.Contains(text, "temporary") ||
|
||||
strings.Contains(text, "temporarily") ||
|
||||
strings.Contains(text, "timeout")
|
||||
}
|
||||
|
||||
type natsPullSubscription interface {
|
||||
Fetch(int, ...nats.PullOpt) ([]*nats.Msg, error)
|
||||
}
|
||||
@@ -268,18 +322,44 @@ type natsConsumerInfoReader interface {
|
||||
}
|
||||
|
||||
var fastWriterStageDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
var fastWriterRedisE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
|
||||
var fastWriterRedisE2ERecent = metrics.NewRecentLatencyByKey(512)
|
||||
var fastBatchPending = metrics.PendingPairGauge{}
|
||||
|
||||
func processFastBatch(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, messages []*fastMessage) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, msg := range messages {
|
||||
addFastMetric(registry, msg.subject, "received")
|
||||
}
|
||||
envelopes := make([]envelope.FrameEnvelope, 0, len(messages))
|
||||
validMessages := make([]*fastMessage, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(msg.data, &env); err != nil {
|
||||
addFastMetric(registry, msg.subject, "invalid_json")
|
||||
if msg.ack != nil {
|
||||
_ = msg.ack()
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack invalid json: %w", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status, err := topics.ValidateRawEnvelope(msg.subject, env); err != nil {
|
||||
addFastMetric(registry, msg.subject, status)
|
||||
if msg.ack != nil {
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack mismatched raw envelope: %w", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -289,85 +369,358 @@ func processFastBatch(ctx context.Context, registry *metrics.Registry, appender
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
setFastBatchPending(registry, len(messages), len(envelopes))
|
||||
defer setFastBatchPending(registry, 0, 0)
|
||||
addFastBatchPending(registry, len(messages), len(envelopes))
|
||||
defer addFastBatchPending(registry, -len(messages), -len(envelopes))
|
||||
subject := fastBatchSubject(validMessages)
|
||||
started := time.Now()
|
||||
err := appender.AppendAllBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("tdengine batch append: %w", err)
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
|
||||
started = time.Now()
|
||||
err = batchUpdater.FastUpdateBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if appender != nil {
|
||||
started := time.Now()
|
||||
err := appender.AppendAllBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast batch update: %w", err)
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
if fallbackErr := processFastMessagesIndividually(ctx, registry, appender, updater, validMessages); fallbackErr != nil {
|
||||
addFastBatchFallbackMetric(registry, "tdengine", "error")
|
||||
return fmt.Errorf("tdengine batch fallback: %w", fallbackErr)
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "tdengine", "ok")
|
||||
return nil
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "tdengine", "skipped_transient")
|
||||
if catchupErr := updateFastRedisBatchWithoutAck(ctx, registry, updater, validMessages, envelopes); catchupErr != nil {
|
||||
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "error")
|
||||
return fmt.Errorf("tdengine batch append: %w; redis catchup: %v", err, catchupErr)
|
||||
}
|
||||
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "ok")
|
||||
return fmt.Errorf("tdengine batch append: %w", err)
|
||||
}
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchResultUpdater); ok {
|
||||
for _, group := range fastSubjectGroups(validMessages, envelopes) {
|
||||
started := time.Now()
|
||||
result, err := batchUpdater.FastUpdateBatchWithResult(ctx, group.envelopes)
|
||||
recordFastWriterStageDuration(registry, group.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
if fallbackErr := processFastMessagesIndividually(ctx, registry, nil, updater, validMessages); fallbackErr != nil {
|
||||
addFastBatchFallbackMetric(registry, "redis", "error")
|
||||
return fmt.Errorf("redis fast batch fallback: %w", fallbackErr)
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "redis", "ok")
|
||||
return nil
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "redis", "skipped_transient")
|
||||
return fmt.Errorf("redis fast batch update: %w", err)
|
||||
}
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, group.subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, group.subject, result)
|
||||
recordFastWriterRedisE2EDurationMessages(registry, group.messages, group.envelopes, group.subject)
|
||||
}
|
||||
for _, msg := range validMessages {
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
|
||||
for _, group := range fastSubjectGroups(validMessages, envelopes) {
|
||||
started := time.Now()
|
||||
err := batchUpdater.FastUpdateBatch(ctx, group.envelopes)
|
||||
recordFastWriterStageDuration(registry, group.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
if fallbackErr := processFastMessagesIndividually(ctx, registry, nil, updater, validMessages); fallbackErr != nil {
|
||||
addFastBatchFallbackMetric(registry, "redis", "error")
|
||||
return fmt.Errorf("redis fast batch fallback: %w", fallbackErr)
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "redis", "ok")
|
||||
return nil
|
||||
}
|
||||
addFastBatchFallbackMetric(registry, "redis", "skipped_transient")
|
||||
return fmt.Errorf("redis fast batch update: %w", err)
|
||||
}
|
||||
recordFastWriterRedisE2EDurationMessages(registry, group.messages, group.envelopes, group.subject)
|
||||
}
|
||||
for _, msg := range validMessages {
|
||||
if msg.ack != nil {
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, env := range envelopes {
|
||||
msg := validMessages[i]
|
||||
started = time.Now()
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
started := time.Now()
|
||||
var result realtime.FastUpdateResult
|
||||
var err error
|
||||
if resultUpdater, ok := updater.(fastResultUpdater); ok {
|
||||
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
|
||||
} else {
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
}
|
||||
recordFastWriterStageDuration(registry, msg.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast update: %w", err)
|
||||
}
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, msg.subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, msg.subject, result)
|
||||
recordFastWriterRedisE2EDuration(registry, msg.subject, env)
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processFastMessagesIndividually(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, messages []*fastMessage) error {
|
||||
for _, msg := range messages {
|
||||
if err := processFastMessageWithReceived(ctx, registry, appender, updater, msg, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processFastMessage(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, msg *fastMessage) error {
|
||||
return processFastMessageWithReceived(ctx, registry, appender, updater, msg, true)
|
||||
}
|
||||
|
||||
func processFastMessageWithReceived(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, msg *fastMessage, recordReceived bool) error {
|
||||
if recordReceived {
|
||||
addFastMetric(registry, msg.subject, "received")
|
||||
}
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(msg.data, &env); err != nil {
|
||||
addFastMetric(registry, msg.subject, "invalid_json")
|
||||
if msg.ack != nil {
|
||||
_ = msg.ack()
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack invalid json: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
started := time.Now()
|
||||
err := appender.AppendAll(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("tdengine append: %w", err)
|
||||
if status, err := topics.ValidateRawEnvelope(msg.subject, env); err != nil {
|
||||
addFastMetric(registry, msg.subject, status)
|
||||
if msg.ack != nil {
|
||||
started := time.Now()
|
||||
err := msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack mismatched raw envelope: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if appender != nil {
|
||||
started := time.Now()
|
||||
err := appender.AppendAll(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
if isTransientFastBatchError(err) {
|
||||
if catchupErr := updateFastRedisSingleWithoutAck(ctx, registry, updater, msg.subject, env); catchupErr != nil {
|
||||
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "error")
|
||||
return fmt.Errorf("tdengine append: %w; redis catchup: %v", err, catchupErr)
|
||||
}
|
||||
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "ok")
|
||||
}
|
||||
return fmt.Errorf("tdengine append: %w", err)
|
||||
}
|
||||
}
|
||||
started := time.Now()
|
||||
var result realtime.FastUpdateResult
|
||||
var err error
|
||||
if resultUpdater, ok := updater.(fastResultUpdater); ok {
|
||||
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
|
||||
} else {
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
}
|
||||
started = time.Now()
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast update: %w", err)
|
||||
}
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, msg.subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, msg.subject, result)
|
||||
recordFastWriterRedisE2EDuration(registry, msg.subject, env)
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addFastMetric(registry, msg.subject, "ack_error")
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateFastRedisBatchWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, messages []*fastMessage, envelopes []envelope.FrameEnvelope) error {
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
subject := fastBatchSubject(messages)
|
||||
if batchUpdater, ok := updater.(fastBatchResultUpdater); ok {
|
||||
started := time.Now()
|
||||
result, err := batchUpdater.FastUpdateBatchWithResult(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err == nil {
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, subject, result)
|
||||
recordFastWriterRedisE2EDurationMessages(registry, messages, envelopes, subject)
|
||||
return nil
|
||||
}
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
addFastBatchFallbackMetric(registry, "redis_catchup", "attempted")
|
||||
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
|
||||
started := time.Now()
|
||||
err := batchUpdater.FastUpdateBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err == nil {
|
||||
recordFastWriterRedisE2EDurationMessages(registry, messages, envelopes, subject)
|
||||
return nil
|
||||
}
|
||||
if shouldFallbackFastBatchError(err) {
|
||||
addFastBatchFallbackMetric(registry, "redis_catchup", "attempted")
|
||||
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
|
||||
}
|
||||
|
||||
func updateFastRedisSinglesWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, messages []*fastMessage, envelopes []envelope.FrameEnvelope) error {
|
||||
for index, env := range envelopes {
|
||||
subject := fastMessageSubject(messages, index, fastBatchSubject(messages))
|
||||
if err := updateFastRedisSingleWithoutAck(ctx, registry, updater, subject, env); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateFastRedisSingleWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, subject string, env envelope.FrameEnvelope) error {
|
||||
started := time.Now()
|
||||
var result realtime.FastUpdateResult
|
||||
var err error
|
||||
if resultUpdater, ok := updater.(fastResultUpdater); ok {
|
||||
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
|
||||
} else {
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
}
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordFastWriterRedisEnvelopeMetrics(registry, subject, result)
|
||||
recordFastWriterRedisFieldMetrics(registry, subject, result)
|
||||
recordFastWriterRedisE2EDuration(registry, subject, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldFallbackFastBatchError(err error) bool {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
return !isTransientFastBatchError(err)
|
||||
}
|
||||
|
||||
func isTransientFastBatchError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(text, "timeout") ||
|
||||
strings.Contains(text, "temporary") ||
|
||||
strings.Contains(text, "temporarily") ||
|
||||
strings.Contains(text, "connection refused") ||
|
||||
strings.Contains(text, "connection reset") ||
|
||||
strings.Contains(text, "connection closed") ||
|
||||
strings.Contains(text, "broken pipe") ||
|
||||
strings.Contains(text, "bad connection") ||
|
||||
strings.Contains(text, "i/o timeout") ||
|
||||
text == "eof" ||
|
||||
strings.Contains(text, "unexpected eof") ||
|
||||
strings.Contains(text, "server is down") ||
|
||||
strings.Contains(text, "network is unreachable") ||
|
||||
strings.Contains(text, "no route to host")
|
||||
}
|
||||
|
||||
func recordFastWriterConfigMetrics(registry *metrics.Registry, cfg config) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
|
||||
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize))
|
||||
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "fetch_wait_ms"}, float64(cfg.FetchWait.Milliseconds()))
|
||||
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "operation_timeout_ms"}, float64(cfg.OperationWait.Milliseconds()))
|
||||
}
|
||||
|
||||
func boolGauge(value bool) float64 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type fastSubjectGroup struct {
|
||||
subject string
|
||||
messages []*fastMessage
|
||||
envelopes []envelope.FrameEnvelope
|
||||
}
|
||||
|
||||
func fastSubjectGroups(messages []*fastMessage, envelopes []envelope.FrameEnvelope) []fastSubjectGroup {
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
fallback := fastBatchSubject(messages)
|
||||
groups := make([]fastSubjectGroup, 0, len(envelopes))
|
||||
indexBySubject := make(map[string]int, len(envelopes))
|
||||
for index, env := range envelopes {
|
||||
subject := fastMessageSubject(messages, index, fallback)
|
||||
groupIndex, exists := indexBySubject[subject]
|
||||
if !exists {
|
||||
groupIndex = len(groups)
|
||||
indexBySubject[subject] = groupIndex
|
||||
groups = append(groups, fastSubjectGroup{subject: subject})
|
||||
}
|
||||
groups[groupIndex].envelopes = append(groups[groupIndex].envelopes, env)
|
||||
if index >= 0 && index < len(messages) {
|
||||
groups[groupIndex].messages = append(groups[groupIndex].messages, messages[index])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func fastBatchSubject(messages []*fastMessage) string {
|
||||
if len(messages) == 0 {
|
||||
return "unknown"
|
||||
@@ -384,6 +737,16 @@ func fastBatchSubject(messages []*fastMessage) string {
|
||||
return subject
|
||||
}
|
||||
|
||||
func fastMessageSubject(messages []*fastMessage, index int, fallback string) string {
|
||||
if index >= 0 && index < len(messages) && strings.TrimSpace(messages[index].subject) != "" {
|
||||
return messages[index].subject
|
||||
}
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func ensureStream(js nats.JetStreamContext, cfg config) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.StreamEnsureWait)
|
||||
defer cancel()
|
||||
@@ -416,15 +779,36 @@ func addFastMetric(registry *metrics.Registry, subject string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_fast_writer_messages_total", metrics.Labels{"subject": subject, "status": status})
|
||||
labels := metrics.Labels{"subject": subject, "status": status}
|
||||
registry.IncCounter("vehicle_fast_writer_messages_total", labels)
|
||||
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_message_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func setFastBatchPending(registry *metrics.Registry, messages int, envelopes int) {
|
||||
func addFastBatchFallbackMetric(registry *metrics.Registry, stage string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_fast_writer_batch_pending_messages", nil, float64(messages))
|
||||
registry.SetGauge("vehicle_fast_writer_batch_pending_envelopes", nil, float64(envelopes))
|
||||
registry.IncCounter("vehicle_fast_writer_batch_fallback_total", metrics.Labels{
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func addFastDecoupledUpdateMetric(registry *metrics.Registry, reason string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_fast_writer_decoupled_updates_total", metrics.Labels{
|
||||
"reason": reason,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func addFastBatchPending(registry *metrics.Registry, messages int, envelopes int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
fastBatchPending.Add(registry, "vehicle_fast_writer_batch_pending_messages", "vehicle_fast_writer_batch_pending_envelopes", messages, envelopes)
|
||||
}
|
||||
|
||||
func recordFastNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info *nats.ConsumerInfo) {
|
||||
@@ -441,11 +825,78 @@ func recordFastWriterStageDuration(registry *metrics.Registry, subject string, s
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_fast_writer_stage_duration_ms_histogram", metrics.Labels{
|
||||
labels := metrics.Labels{
|
||||
"subject": subject,
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
}, fastWriterStageDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_fast_writer_stage_duration_ms_histogram", labels, fastWriterStageDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_stage_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func recordFastWriterRedisE2EDurationMessages(registry *metrics.Registry, messages []*fastMessage, envelopes []envelope.FrameEnvelope, fallback string) {
|
||||
for index, env := range envelopes {
|
||||
recordFastWriterRedisE2EDuration(registry, fastMessageSubject(messages, index, fallback), env)
|
||||
}
|
||||
}
|
||||
|
||||
func recordFastWriterRedisE2EDuration(registry *metrics.Registry, subject string, env envelope.FrameEnvelope) {
|
||||
if registry == nil || env.ReceivedAtMS <= 0 {
|
||||
return
|
||||
}
|
||||
elapsedMS := time.Since(time.UnixMilli(env.ReceivedAtMS)).Milliseconds()
|
||||
if elapsedMS < 0 {
|
||||
elapsedMS = 0
|
||||
}
|
||||
labels := metrics.Labels{
|
||||
"subject": subject,
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_fast_writer_redis_e2e_duration_ms_histogram", labels, fastWriterRedisE2EDurationBucketsMS, float64(elapsedMS))
|
||||
p99, samples := fastWriterRedisE2ERecent.Observe(subject, float64(elapsedMS))
|
||||
registry.SetGauge("vehicle_fast_writer_redis_e2e_recent_p99_ms", labels, p99)
|
||||
registry.SetGauge("vehicle_fast_writer_redis_e2e_recent_samples", labels, float64(samples))
|
||||
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_redis_e2e_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func recordFastWriterRedisFieldMetrics(registry *metrics.Registry, subject string, result realtime.FastUpdateResult) {
|
||||
if registry == nil || result.FieldsSeen == 0 {
|
||||
return
|
||||
}
|
||||
addFastWriterRedisFieldMetric(registry, subject, "seen", result.FieldsSeen)
|
||||
addFastWriterRedisFieldMetric(registry, subject, "written", result.FieldsWritten)
|
||||
addFastWriterRedisFieldMetric(registry, subject, "skipped_stale", result.FieldsSkippedStale)
|
||||
}
|
||||
|
||||
func recordFastWriterRedisEnvelopeMetrics(registry *metrics.Registry, subject string, result realtime.FastUpdateResult) {
|
||||
if registry == nil || result.EnvelopesSeen == 0 {
|
||||
return
|
||||
}
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "seen", result.EnvelopesSeen)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "updated", result.EnvelopesUpdated)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_non_realtime", result.EnvelopesSkippedNonRealtime)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_vin", result.EnvelopesSkippedMissingVIN)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_vehicle_key", result.EnvelopesSkippedMissingVehicleKey)
|
||||
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_fields", result.EnvelopesSkippedMissingFields)
|
||||
}
|
||||
|
||||
func addFastWriterRedisEnvelopeMetric(registry *metrics.Registry, subject string, status string, value int) {
|
||||
if value <= 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_fast_writer_redis_envelopes_total", metrics.Labels{
|
||||
"subject": subject,
|
||||
"status": status,
|
||||
}, float64(value))
|
||||
}
|
||||
|
||||
func addFastWriterRedisFieldMetric(registry *metrics.Registry, subject string, status string, value int) {
|
||||
if value <= 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_fast_writer_redis_fields_total", metrics.Labels{
|
||||
"subject": subject,
|
||||
"status": status,
|
||||
}, float64(value))
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
@@ -487,6 +938,20 @@ func envInt64(key string, fallback int64) int64 {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
switch value {
|
||||
case "":
|
||||
return fallback
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
case "0", "false", "no", "n", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
||||
)
|
||||
|
||||
func TestProcessFastMessageWritesTDengineAndRedisBeforeAck(t *testing.T) {
|
||||
@@ -58,8 +59,45 @@ func TestProcessFastMessageDoesNotAckWhenRedisFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageUpdatesRedisButDoesNotAckOnTransientTDengineFailure(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-td-transient-single"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{err: errors.New("connection reset by peer")}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error {
|
||||
ackCount++
|
||||
return nil
|
||||
}}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, appender, updater, msg); err == nil {
|
||||
t.Fatal("processFastMessage() error = nil, want tdengine transient failure")
|
||||
}
|
||||
if updater.count != 1 || ackCount != 0 {
|
||||
t.Fatalf("updates=%d acks=%d, want 1/0", updater.count, ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="received",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_last_message_unix_seconds{status="received",subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_decoupled_updates_total{reason="tdengine_transient",status="ok"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("tdengine transient redis catchup metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"}`) {
|
||||
t.Fatalf("message should not be counted ok because it was not acked:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageRecordsStageDurationMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-3"}
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-3", ReceivedAtMS: time.Now().Add(-20 * time.Millisecond).UnixMilli()}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -77,6 +115,14 @@ func TestProcessFastMessageRecordsStageDurationMetrics(t *testing.T) {
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_count{stage="tdengine",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_last_stage_unix_seconds{stage="tdengine",status="ok",subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_last_stage_unix_seconds{stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_last_stage_unix_seconds{stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_redis_e2e_duration_ms_histogram_bucket{le="+Inf",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_e2e_duration_ms_histogram_count{subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_e2e_recent_p99_ms{subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_redis_e2e_recent_samples{subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_last_redis_e2e_unix_seconds{subject="vehicle.raw.go.jt808.v1"} `,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fast writer stage metric missing %s:\n%s", want, text)
|
||||
@@ -84,6 +130,151 @@ func TestProcessFastMessageRecordsStageDurationMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFastWriterRedisE2EDurationSkipsMissingReceiveTime(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
recordFastWriterRedisE2EDuration(registry, "vehicle.raw.go.jt808.v1", envelope.FrameEnvelope{})
|
||||
|
||||
if text := registry.Render(); strings.Contains(text, "vehicle_fast_writer_redis_e2e_duration_ms_histogram") || strings.Contains(text, "vehicle_fast_writer_redis_e2e_recent") {
|
||||
t.Fatalf("e2e metric should be skipped when received_at_ms is missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageAcksInvalidJSONAndRecordsMetric(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
ackCount := 0
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: []byte(`{bad json`), ack: func() error {
|
||||
ackCount++
|
||||
return nil
|
||||
}}
|
||||
updater := &recordingFastUpdater{}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, nil, updater, msg); err != nil {
|
||||
t.Fatalf("processFastMessage() error = %v", err)
|
||||
}
|
||||
if ackCount != 1 {
|
||||
t.Fatalf("ack count = %d, want 1", ackCount)
|
||||
}
|
||||
if updater.count != 0 || updater.batchCount != 0 {
|
||||
t.Fatalf("updater counts = single %d batch %d, want 0", updater.count, updater.batchCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="invalid_json",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("invalid json metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"}`) {
|
||||
t.Fatalf("invalid json should not be counted as ok:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageAcksExplicitNonRawEventKindWithoutWriting(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
EventKind: envelope.EventKindFields,
|
||||
MessageID: "0x0200",
|
||||
VIN: "VIN001",
|
||||
}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{}
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error {
|
||||
ackCount++
|
||||
return nil
|
||||
}}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, appender, updater, msg); err != nil {
|
||||
t.Fatalf("processFastMessage() error = %v", err)
|
||||
}
|
||||
if appender.count != 0 || updater.count != 0 || updater.batchCount != 0 {
|
||||
t.Fatalf("appends=%d updates=%d batch_updates=%d, want no writes", appender.count, updater.count, updater.batchCount)
|
||||
}
|
||||
if ackCount != 1 {
|
||||
t.Fatalf("ack count=%d, want 1", ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="event_kind_mismatch",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_count{stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("mismatch metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"}`) {
|
||||
t.Fatalf("mismatched event should not be counted ok:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageRecordsRedisFieldMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fields"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastResultUpdater{result: realtime.FastUpdateResult{
|
||||
EnvelopesSeen: 4,
|
||||
EnvelopesUpdated: 1,
|
||||
EnvelopesSkippedNonRealtime: 1,
|
||||
EnvelopesSkippedMissingVIN: 1,
|
||||
EnvelopesSkippedMissingVehicleKey: 0,
|
||||
EnvelopesSkippedMissingFields: 1,
|
||||
FieldsSeen: 3,
|
||||
FieldsWritten: 2,
|
||||
FieldsSkippedStale: 1,
|
||||
}}
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { return nil }}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, nil, updater, msg); err != nil {
|
||||
t.Fatalf("processFastMessage() error = %v", err)
|
||||
}
|
||||
if updater.resultCount != 1 {
|
||||
t.Fatalf("FastUpdateWithResult count=%d, want 1", updater.resultCount)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 4`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="updated",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="skipped_missing_vin",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="skipped_non_realtime",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="skipped_missing_fields",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 3`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="skipped_stale",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="written",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("redis field metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddFastMetricRecordsLastMessage(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
addFastMetric(registry, "vehicle.raw.go.gb32960.v1", "ok")
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
`vehicle_fast_writer_last_message_unix_seconds{status="ok",subject="vehicle.raw.go.gb32960.v1"} `,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fast writer message metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchAppendsTDengineBatchBeforeRedisAndAck(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-4"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-5"}
|
||||
@@ -149,6 +340,407 @@ func TestProcessFastBatchUsesRedisBatchUpdaterWhenAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchFallsBackToSinglesAfterNonTransientTDengineBatchFailure(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fallback-td-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-fallback-td-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{batchErr: errors.New("syntax error in batch insert")}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, appender, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if appender.batchCount != 1 || appender.count != 2 {
|
||||
t.Fatalf("batch appends=%d single appends=%d, want 1/2", appender.batchCount, appender.count)
|
||||
}
|
||||
if updater.count != 2 || ackCount != 2 {
|
||||
t.Fatalf("updates=%d acks=%d, want 2/2", updater.count, ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if !strings.Contains(text, `vehicle_fast_writer_batch_fallback_total{stage="tdengine",status="ok"} 1`) {
|
||||
t.Fatalf("tdengine fallback metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchUpdatesRedisButDoesNotAckOnTransientTDengineBatchFailure(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fallback-td-transient"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{batchErr: errors.New("i/o timeout")}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, appender, updater, msgs); err == nil {
|
||||
t.Fatal("processFastBatch() error = nil, want transient tdengine failure")
|
||||
}
|
||||
if appender.batchCount != 1 || appender.count != 0 {
|
||||
t.Fatalf("batch appends=%d single appends=%d, want 1/0", appender.batchCount, appender.count)
|
||||
}
|
||||
if updater.count != 1 || ackCount != 0 {
|
||||
t.Fatalf("updates=%d acks=%d, want 1/0", updater.count, ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="received",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_batch_fallback_total{stage="tdengine",status="skipped_transient"} 1`,
|
||||
`vehicle_fast_writer_decoupled_updates_total{reason="tdengine_transient",status="ok"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("tdengine transient catchup metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchFallsBackToRedisSinglesAfterNonTransientRedisBatchFailure(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fallback-redis-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-fallback-redis-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{}
|
||||
updater := &recordingFastUpdater{batchErr: errors.New("WRONGTYPE operation against a key holding the wrong kind of value")}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, appender, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if appender.batchCount != 1 || appender.count != 0 {
|
||||
t.Fatalf("tdengine batch appends=%d single appends=%d, want 1/0", appender.batchCount, appender.count)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.count != 2 {
|
||||
t.Fatalf("redis batch updates=%d single updates=%d, want 1/2", updater.batchCount, updater.count)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count=%d, want 2", ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if !strings.Contains(text, `vehicle_fast_writer_batch_fallback_total{stage="redis",status="ok"} 1`) {
|
||||
t.Fatalf("redis fallback metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchFallbackLeavesFailedAndRemainingMessagesUnacked(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fallback-prefix-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-fallback-prefix-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &recordingFastAppender{
|
||||
batchErr: errors.New("syntax error in batch insert"),
|
||||
singleErr: errors.New("single insert failed"),
|
||||
failSingleOnCount: 2,
|
||||
}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, appender, updater, msgs); err == nil {
|
||||
t.Fatal("processFastBatch() error = nil, want fallback single write failure")
|
||||
}
|
||||
if appender.batchCount != 1 || appender.count != 2 {
|
||||
t.Fatalf("tdengine batch appends=%d single appends=%d, want 1/2", appender.batchCount, appender.count)
|
||||
}
|
||||
if updater.count != 1 || ackCount != 1 {
|
||||
t.Fatalf("updates=%d acks=%d, want successful prefix 1/1", updater.count, ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if !strings.Contains(text, `vehicle_fast_writer_batch_fallback_total{stage="tdengine",status="error"} 1`) {
|
||||
t.Fatalf("tdengine fallback error metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchRecordsRedisFieldMetrics(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-batch-fields-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-batch-fields-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastResultUpdater{result: realtime.FastUpdateResult{
|
||||
EnvelopesSeen: 2,
|
||||
EnvelopesUpdated: 2,
|
||||
FieldsSeen: 10,
|
||||
FieldsWritten: 7,
|
||||
FieldsSkippedStale: 3,
|
||||
}}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if updater.batchResultCount != 1 || updater.batchCount != 0 {
|
||||
t.Fatalf("batch result count=%d legacy batch count=%d, want 1/0", updater.batchResultCount, updater.batchCount)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count=%d, want 2", ackCount)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="updated",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 10`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="skipped_stale",subject="vehicle.raw.go.jt808.v1"} 3`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="written",subject="vehicle.raw.go.jt808.v1"} 7`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("redis field metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchRecordsRedisE2EByOriginalSubject(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
VIN: "VIN001",
|
||||
EventID: "evt-batch-e2e-1",
|
||||
ReceivedAtMS: time.Now().Add(-20 * time.Millisecond).UnixMilli(),
|
||||
}
|
||||
second := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
VIN: "VIN002",
|
||||
EventID: "evt-batch-e2e-2",
|
||||
ReceivedAtMS: time.Now().Add(-25 * time.Millisecond).UnixMilli(),
|
||||
}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastResultUpdater{result: realtime.FastUpdateResult{
|
||||
EnvelopesSeen: 2,
|
||||
EnvelopesUpdated: 2,
|
||||
}}
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload},
|
||||
{subject: "vehicle.raw.go.gb32960.v1", data: secondPayload},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_redis_e2e_duration_ms_histogram_count{subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_e2e_duration_ms_histogram_count{subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_e2e_recent_p99_ms{subject="vehicle.raw.go.jt808.v1"} `,
|
||||
`vehicle_fast_writer_redis_e2e_recent_p99_ms{subject="vehicle.raw.go.gb32960.v1"} `,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("redis e2e metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_fast_writer_redis_e2e_duration_ms_histogram_count{subject="mixed"}`) {
|
||||
t.Fatalf("redis e2e metric should keep original subjects, not mixed:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchRecordsRedisFieldMetricsByOriginalSubjectForMixedBatch(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-batch-mixed-fields-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN002", EventID: "evt-batch-mixed-fields-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastDynamicResultUpdater{}
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload},
|
||||
{subject: "vehicle.raw.go.gb32960.v1", data: secondPayload},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if updater.batchResultCount != 2 {
|
||||
t.Fatalf("batch result count=%d, want one batch per subject", updater.batchResultCount)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_envelopes_total{status="seen",subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="seen",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="seen",subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="written",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_redis_fields_total{status="written",subject="vehicle.raw.go.gb32960.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("redis field metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `subject="mixed"`) {
|
||||
t.Fatalf("mixed subject should not be used for per-subject Redis metrics:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchAcksInvalidJSONWithoutMarkingItOK(t *testing.T) {
|
||||
valid := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-valid"}
|
||||
validPayload, err := valid.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: []byte(`{bad json`), ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: validPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count = %d, want 2", ackCount)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.batchRows != 1 {
|
||||
t.Fatalf("redis batch count=%d rows=%d, want one valid row", updater.batchCount, updater.batchRows)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="received",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
`vehicle_fast_writer_messages_total{status="invalid_json",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_batch_pending_messages 0`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("batch invalid json metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchSkipsExplicitNonRawEventKindAndWritesValidRows(t *testing.T) {
|
||||
mismatched := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, EventKind: envelope.EventKindFields, VIN: "VIN_BAD", EventID: "evt-fields-on-raw"}
|
||||
valid := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, EventKind: envelope.EventKindRaw, VIN: "VIN001", EventID: "evt-valid-raw"}
|
||||
mismatchedPayload, err := mismatched.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validPayload, err := valid.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: mismatchedPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: validPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count = %d, want 2", ackCount)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.batchRows != 1 {
|
||||
t.Fatalf("redis batch count=%d rows=%d, want only valid row", updater.batchCount, updater.batchRows)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_messages_total{status="received",subject="vehicle.raw.go.jt808.v1"} 2`,
|
||||
`vehicle_fast_writer_messages_total{status="event_kind_mismatch",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_messages_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_batch_pending_messages 0`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("batch mismatch metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchCanSkipTDengineAppender(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-no-td"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), registry, nil, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.batchRows != 1 {
|
||||
t.Fatalf("redis batch count=%d rows=%d, want 1/1", updater.batchCount, updater.batchRows)
|
||||
}
|
||||
if ackCount != 1 {
|
||||
t.Fatalf("ack count=%d, want 1", ackCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
if strings.Contains(text, `stage="tdengine"`) {
|
||||
t.Fatalf("tdengine stage should not be recorded when appender is nil:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, `stage="redis"`) || !strings.Contains(text, `stage="ack"`) {
|
||||
t.Fatalf("redis and ack stages should still be recorded:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchExposesPendingMetricsDuringAppend(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-6"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-7"}
|
||||
@@ -208,6 +800,77 @@ func TestProcessFastBatchExposesPendingMetricsDuringAppend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchPendingAggregatesConcurrentWorkers(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-fast-pending-concurrent-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-fast-pending-concurrent-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
firstStarted := make(chan struct{})
|
||||
secondStarted := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
appenderFor := func(started chan struct{}) *recordingFastAppender {
|
||||
return &recordingFastAppender{
|
||||
onAppendBatch: func() {
|
||||
close(started)
|
||||
<-release
|
||||
},
|
||||
}
|
||||
}
|
||||
messages := func(payload []byte, count int) []*fastMessage {
|
||||
out := make([]*fastMessage, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
out = append(out, &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { return nil }})
|
||||
}
|
||||
return out
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var firstErr error
|
||||
var secondErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
firstErr = processFastBatch(context.Background(), registry, appenderFor(firstStarted), &recordingFastUpdater{}, messages(firstPayload, 2))
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
secondErr = processFastBatch(context.Background(), registry, appenderFor(secondStarted), &recordingFastUpdater{}, messages(secondPayload, 3))
|
||||
}()
|
||||
<-firstStarted
|
||||
<-secondStarted
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_batch_pending_messages 5`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 5`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("aggregate pending metric missing %s during concurrent append:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if firstErr != nil || secondErr != nil {
|
||||
t.Fatalf("processFastBatch errors = %v / %v", firstErr, secondErr)
|
||||
}
|
||||
text = registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_batch_pending_messages 0`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("aggregate pending metric should reset after concurrent append, missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFastNATSConsumerInfoMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
cfg := config{NATSStream: "VEHICLE_INGEST", NATSDurable: "vehicle-fast-writer"}
|
||||
@@ -230,6 +893,41 @@ func TestRecordFastNATSConsumerInfoMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFastWorkerShutdownFetchError(t *testing.T) {
|
||||
if isFastWorkerShutdownFetchError(context.Background(), errors.New("temporary nats failure")) {
|
||||
t.Fatal("temporary failure should not be treated as shutdown")
|
||||
}
|
||||
if !isFastWorkerShutdownFetchError(context.Background(), nats.ErrConnectionClosed) {
|
||||
t.Fatal("connection closed should stop worker without noisy error log")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if !isFastWorkerShutdownFetchError(ctx, errors.New("any fetch error after cancellation")) {
|
||||
t.Fatal("cancelled context should stop worker without noisy error log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransientFastFetchError(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errors.New("nats: disconnected during fetch"),
|
||||
errors.New("nats: connection closed"),
|
||||
errors.New("read tcp: connection reset by peer"),
|
||||
errors.New("write tcp: broken pipe"),
|
||||
errors.New("temporary network unavailable"),
|
||||
errors.New("i/o timeout"),
|
||||
} {
|
||||
if !isTransientFastFetchError(err) {
|
||||
t.Fatalf("isTransientFastFetchError(%q) = false, want true", err.Error())
|
||||
}
|
||||
}
|
||||
if isTransientFastFetchError(errors.New("permission denied")) {
|
||||
t.Fatal("permission denied should remain an unexpected fetch error")
|
||||
}
|
||||
if isTransientFastFetchError(nil) {
|
||||
t.Fatal("nil should not be treated as transient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsTDenginePoolToSingleConnection(t *testing.T) {
|
||||
t.Setenv("FAST_WRITER_WORKERS", "12")
|
||||
t.Setenv("FAST_WRITER_TDENGINE_MAX_OPEN_CONNS", "")
|
||||
@@ -244,6 +942,28 @@ func TestLoadConfigDefaultsTDenginePoolToSingleConnection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsTDengineStageDisabled(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
if cfg.TDengineEnabled {
|
||||
t.Fatal("TDengineEnabled = true, want production default false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigCanEnableTDengineStageExplicitly(t *testing.T) {
|
||||
for _, value := range []string{"true", "1", "yes", "on"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("FAST_WRITER_TDENGINE_ENABLED", value)
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
if !cfg.TDengineEnabled {
|
||||
t.Fatalf("TDengineEnabled = false for %q, want explicit true", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -256,12 +976,16 @@ func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
||||
if got, want := cfg.OperationWait, time.Second; got != want {
|
||||
t.Fatalf("OperationWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.FetchWait, 20*time.Millisecond; got != want {
|
||||
t.Fatalf("FetchWait = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
||||
t.Setenv("NATS_STREAM_MAX_BYTES", "1073741824")
|
||||
t.Setenv("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", "90")
|
||||
t.Setenv("FAST_WRITER_OPERATION_TIMEOUT_MS", "1500")
|
||||
t.Setenv("FAST_WRITER_FETCH_WAIT_MS", "45")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -274,6 +998,32 @@ func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
||||
if got, want := cfg.OperationWait, 1500*time.Millisecond; got != want {
|
||||
t.Fatalf("OperationWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.FetchWait, 45*time.Millisecond; got != want {
|
||||
t.Fatalf("FetchWait = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFastWriterConfigMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
recordFastWriterConfigMetrics(registry, config{
|
||||
BatchSize: 120,
|
||||
FetchWait: 20 * time.Millisecond,
|
||||
OperationWait: 1500 * time.Millisecond,
|
||||
Workers: 8,
|
||||
})
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_config{setting="batch_size"} 120`,
|
||||
`vehicle_fast_writer_config{setting="fetch_wait_ms"} 20`,
|
||||
`vehicle_fast_writer_config{setting="operation_timeout_ms"} 1500`,
|
||||
`vehicle_fast_writer_config{setting="workers"} 8`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fast writer config metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsTDenginePoolOverride(t *testing.T) {
|
||||
@@ -292,15 +1042,30 @@ func TestLoadConfigReadsTDenginePoolOverride(t *testing.T) {
|
||||
}
|
||||
|
||||
type recordingFastAppender struct {
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
onAppendBatch func()
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
batchErr error
|
||||
singleErr error
|
||||
failSingleOnCount int
|
||||
onAppendBatch func()
|
||||
}
|
||||
|
||||
func (a *recordingFastAppender) AppendAll(context.Context, envelope.FrameEnvelope) error {
|
||||
a.count++
|
||||
if a.failSingleOnCount > 0 && a.count == a.failSingleOnCount {
|
||||
if a.singleErr != nil {
|
||||
return a.singleErr
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
if a.failSingleOnCount > 0 {
|
||||
return nil
|
||||
}
|
||||
if a.singleErr != nil {
|
||||
return a.singleErr
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
|
||||
@@ -310,24 +1075,45 @@ func (a *recordingFastAppender) AppendAllBatch(_ context.Context, envs []envelop
|
||||
if a.onAppendBatch != nil {
|
||||
a.onAppendBatch()
|
||||
}
|
||||
if a.batchErr != nil {
|
||||
return a.batchErr
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
|
||||
type recordingFastUpdater struct {
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
batchErr error
|
||||
singleErr error
|
||||
failSingleOnCount int
|
||||
}
|
||||
|
||||
func (u *recordingFastUpdater) FastUpdate(context.Context, envelope.FrameEnvelope) error {
|
||||
u.count++
|
||||
if u.failSingleOnCount > 0 && u.count == u.failSingleOnCount {
|
||||
if u.singleErr != nil {
|
||||
return u.singleErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
if u.failSingleOnCount > 0 {
|
||||
return nil
|
||||
}
|
||||
if u.singleErr != nil {
|
||||
return u.singleErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastUpdater) FastUpdateBatch(_ context.Context, envs []envelope.FrameEnvelope) error {
|
||||
u.batchCount++
|
||||
u.batchRows += len(envs)
|
||||
if u.batchErr != nil {
|
||||
return u.batchErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
@@ -340,3 +1126,73 @@ func (u *recordingFastSingleUpdater) FastUpdate(context.Context, envelope.FrameE
|
||||
u.count++
|
||||
return u.err
|
||||
}
|
||||
|
||||
type recordingFastResultUpdater struct {
|
||||
count int
|
||||
resultCount int
|
||||
batchCount int
|
||||
batchResultCount int
|
||||
batchRows int
|
||||
result realtime.FastUpdateResult
|
||||
err error
|
||||
batchErr error
|
||||
singleErr error
|
||||
}
|
||||
|
||||
func (u *recordingFastResultUpdater) FastUpdate(context.Context, envelope.FrameEnvelope) error {
|
||||
u.count++
|
||||
if u.singleErr != nil {
|
||||
return u.singleErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastResultUpdater) FastUpdateWithResult(context.Context, envelope.FrameEnvelope) (realtime.FastUpdateResult, error) {
|
||||
u.resultCount++
|
||||
if u.singleErr != nil {
|
||||
return u.result, u.singleErr
|
||||
}
|
||||
return u.result, u.err
|
||||
}
|
||||
|
||||
type recordingFastDynamicResultUpdater struct {
|
||||
batchResultCount int
|
||||
batchRows int
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *recordingFastDynamicResultUpdater) FastUpdate(context.Context, envelope.FrameEnvelope) error {
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastDynamicResultUpdater) FastUpdateBatchWithResult(_ context.Context, envs []envelope.FrameEnvelope) (realtime.FastUpdateResult, error) {
|
||||
u.batchResultCount++
|
||||
u.batchRows += len(envs)
|
||||
if u.err != nil {
|
||||
return realtime.FastUpdateResult{}, u.err
|
||||
}
|
||||
return realtime.FastUpdateResult{
|
||||
EnvelopesSeen: len(envs),
|
||||
EnvelopesUpdated: len(envs),
|
||||
FieldsSeen: len(envs),
|
||||
FieldsWritten: len(envs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *recordingFastResultUpdater) FastUpdateBatch(_ context.Context, envs []envelope.FrameEnvelope) error {
|
||||
u.batchCount++
|
||||
u.batchRows += len(envs)
|
||||
if u.batchErr != nil {
|
||||
return u.batchErr
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastResultUpdater) FastUpdateBatchWithResult(_ context.Context, envs []envelope.FrameEnvelope) (realtime.FastUpdateResult, error) {
|
||||
u.batchResultCount++
|
||||
u.batchRows += len(envs)
|
||||
if u.batchErr != nil {
|
||||
return u.result, u.batchErr
|
||||
}
|
||||
return u.result, u.err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
@@ -29,6 +31,10 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Error("invalid bridge config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
conn, err := nats.Connect(cfg.NATSURL, nats.Name(cfg.NATSClientName), nats.Timeout(5*time.Second))
|
||||
if err != nil {
|
||||
logger.Error("nats connect failed", "error", err)
|
||||
@@ -36,6 +42,7 @@ func main() {
|
||||
}
|
||||
defer conn.Close()
|
||||
registry := metrics.NewRegistry()
|
||||
recordBridgeConfigMetrics(registry, cfg)
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-kafka-bridge", []health.Check{
|
||||
{Name: "nats", Check: func(context.Context) error {
|
||||
if conn.Status() != nats.CONNECTED {
|
||||
@@ -66,71 +73,205 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
writer := &kafka.Writer{
|
||||
Addr: kafka.TCP(cfg.KafkaBrokers...),
|
||||
Balancer: &kafka.Hash{},
|
||||
AllowAutoTopicCreation: false,
|
||||
RequiredAcks: kafka.RequireAll,
|
||||
Async: false,
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
logger.Info("nats kafka bridge started",
|
||||
"nats_url", cfg.NATSURL,
|
||||
"stream", cfg.NATSStream,
|
||||
"durable", cfg.NATSDurable,
|
||||
"filter", cfg.NATSFilter,
|
||||
"kafka_brokers", strings.Join(cfg.KafkaBrokers, ","))
|
||||
runBridge(ctx, logger, registry, js, sub, writer, cfg)
|
||||
"kafka_brokers", strings.Join(cfg.KafkaBrokers, ","),
|
||||
"kafka_batch_timeout_ms", cfg.KafkaBatchTimeout.Milliseconds(),
|
||||
"kafka_write_concurrency", cfg.KafkaWriteConcurrency,
|
||||
"fetch_wait_ms", cfg.FetchWait.Milliseconds(),
|
||||
"batch_size", cfg.BatchSize,
|
||||
"workers", cfg.Workers,
|
||||
"derive_fields_from_raw_enabled", cfg.DeriveFieldsFromRaw)
|
||||
for i := 0; i < cfg.Workers; i++ {
|
||||
writer := newKafkaWriter(cfg)
|
||||
defer writer.Close()
|
||||
go runBridge(ctx, logger.With("worker", i), registry, js, sub, writer, cfg)
|
||||
}
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
type config struct {
|
||||
NATSURL string
|
||||
NATSClientName string
|
||||
NATSStream string
|
||||
NATSDurable string
|
||||
NATSFilter string
|
||||
NATSSubjects []string
|
||||
KafkaBrokers []string
|
||||
Route map[string]string
|
||||
BatchSize int
|
||||
FetchWait time.Duration
|
||||
OperationWait time.Duration
|
||||
AckWait time.Duration
|
||||
StreamMaxAge time.Duration
|
||||
StreamMaxBytes int64
|
||||
StreamEnsureWait time.Duration
|
||||
NATSURL string
|
||||
NATSClientName string
|
||||
NATSStream string
|
||||
NATSDurable string
|
||||
NATSFilter string
|
||||
NATSSubjects []string
|
||||
KafkaBrokers []string
|
||||
RawRoutes []subjectRoute
|
||||
FieldsRoutes []subjectRoute
|
||||
Route map[string]string
|
||||
RawFieldRoutes map[string]fieldsProjectionRoute
|
||||
DeriveFieldsFromRaw bool
|
||||
KafkaBatchTimeout time.Duration
|
||||
KafkaWriteConcurrency int
|
||||
BatchSize int
|
||||
FetchWait time.Duration
|
||||
OperationWait time.Duration
|
||||
AckWait time.Duration
|
||||
StreamMaxAge time.Duration
|
||||
StreamMaxBytes int64
|
||||
StreamEnsureWait time.Duration
|
||||
Workers int
|
||||
}
|
||||
|
||||
type subjectRoute struct {
|
||||
Protocol envelope.Protocol
|
||||
Subject string
|
||||
Topic string
|
||||
}
|
||||
|
||||
type fieldsProjectionRoute struct {
|
||||
Protocol envelope.Protocol
|
||||
Topic string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
route := map[string]string{
|
||||
env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)): env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960),
|
||||
env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)): env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808),
|
||||
env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)): env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT),
|
||||
env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)): env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960),
|
||||
env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)): env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808),
|
||||
env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)): env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT),
|
||||
rawRoutes := []subjectRoute{
|
||||
{Protocol: envelope.ProtocolGB32960, Subject: env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)), Topic: env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)},
|
||||
{Protocol: envelope.ProtocolJT808, Subject: env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)), Topic: env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)},
|
||||
{Protocol: envelope.ProtocolYutongMQTT, Subject: env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)), Topic: env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)},
|
||||
}
|
||||
fieldsRoutes := []subjectRoute{
|
||||
{Protocol: envelope.ProtocolGB32960, Subject: env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)), Topic: env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)},
|
||||
{Protocol: envelope.ProtocolJT808, Subject: env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)), Topic: env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)},
|
||||
{Protocol: envelope.ProtocolYutongMQTT, Subject: env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)), Topic: env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)},
|
||||
}
|
||||
route := routeMap(rawRoutes, fieldsRoutes)
|
||||
if unifiedSubject, unifiedTopic, ok := unifiedRouteFromEnv(); ok {
|
||||
route[unifiedSubject] = unifiedTopic
|
||||
}
|
||||
return config{
|
||||
NATSURL: env("NATS_URL", "nats://127.0.0.1:4222"),
|
||||
NATSClientName: env("NATS_CLIENT_NAME", "lingniu-nats-kafka-bridge"),
|
||||
NATSStream: env("NATS_STREAM", "VEHICLE_INGEST"),
|
||||
NATSDurable: env("NATS_DURABLE", "vehicle-kafka-bridge"),
|
||||
NATSFilter: env("NATS_FILTER", "vehicle.>"),
|
||||
NATSSubjects: splitCSV(env("NATS_STREAM_SUBJECTS", strings.Join(mapKeys(route), ","))),
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
Route: route,
|
||||
BatchSize: envInt("BRIDGE_BATCH_SIZE", 500),
|
||||
FetchWait: time.Duration(envInt("BRIDGE_FETCH_WAIT_MS", 1000)) * time.Millisecond,
|
||||
OperationWait: time.Duration(envInt("BRIDGE_OPERATION_TIMEOUT_MS", 30000)) * time.Millisecond,
|
||||
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 60)) * time.Second,
|
||||
StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour,
|
||||
StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024),
|
||||
StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second,
|
||||
workers := envInt("BRIDGE_WORKERS", 4)
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
return config{
|
||||
NATSURL: env("NATS_URL", "nats://127.0.0.1:4222"),
|
||||
NATSClientName: env("NATS_CLIENT_NAME", "lingniu-nats-kafka-bridge"),
|
||||
NATSStream: env("NATS_STREAM", "VEHICLE_INGEST"),
|
||||
NATSDurable: env("NATS_DURABLE", "vehicle-kafka-bridge"),
|
||||
NATSFilter: env("NATS_FILTER", "vehicle.>"),
|
||||
NATSSubjects: splitCSV(env("NATS_STREAM_SUBJECTS", strings.Join(mapKeys(route), ","))),
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
RawRoutes: rawRoutes,
|
||||
FieldsRoutes: fieldsRoutes,
|
||||
Route: route,
|
||||
RawFieldRoutes: rawFieldProjectionRoutes(rawRoutes, fieldsRoutes),
|
||||
DeriveFieldsFromRaw: envBool("BRIDGE_DERIVE_FIELDS_FROM_RAW_ENABLED", true),
|
||||
KafkaBatchTimeout: time.Duration(envInt("BRIDGE_KAFKA_BATCH_TIMEOUT_MS", 20)) * time.Millisecond,
|
||||
KafkaWriteConcurrency: envInt("BRIDGE_KAFKA_WRITE_CONCURRENCY", 6),
|
||||
BatchSize: envInt("BRIDGE_BATCH_SIZE", 500),
|
||||
FetchWait: time.Duration(envInt("BRIDGE_FETCH_WAIT_MS", 20)) * time.Millisecond,
|
||||
OperationWait: time.Duration(envInt("BRIDGE_OPERATION_TIMEOUT_MS", 30000)) * time.Millisecond,
|
||||
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 60)) * time.Second,
|
||||
StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour,
|
||||
StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024),
|
||||
StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second,
|
||||
Workers: workers,
|
||||
}
|
||||
}
|
||||
|
||||
func recordBridgeConfigMetrics(registry *metrics.Registry, cfg config) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
|
||||
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize))
|
||||
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "fetch_wait_ms"}, float64(cfg.FetchWait.Milliseconds()))
|
||||
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "kafka_batch_timeout_ms"}, float64(cfg.KafkaBatchTimeout.Milliseconds()))
|
||||
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "kafka_write_concurrency"}, float64(cfg.KafkaWriteConcurrency))
|
||||
registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "derive_fields_from_raw_enabled"}, boolMetric(cfg.DeriveFieldsFromRaw))
|
||||
}
|
||||
|
||||
func newKafkaWriter(cfg config) *kafka.Writer {
|
||||
return &kafka.Writer{
|
||||
Addr: kafka.TCP(cfg.KafkaBrokers...),
|
||||
Balancer: &kafka.Hash{},
|
||||
AllowAutoTopicCreation: false,
|
||||
RequiredAcks: kafka.RequireAll,
|
||||
BatchTimeout: cfg.KafkaBatchTimeout,
|
||||
Async: false,
|
||||
}
|
||||
}
|
||||
|
||||
func routeMap(rawRoutes []subjectRoute, fieldsRoutes []subjectRoute) map[string]string {
|
||||
route := make(map[string]string, len(rawRoutes)+len(fieldsRoutes))
|
||||
for _, item := range rawRoutes {
|
||||
if item.Subject != "" {
|
||||
route[item.Subject] = item.Topic
|
||||
}
|
||||
}
|
||||
for _, item := range fieldsRoutes {
|
||||
if item.Subject != "" {
|
||||
route[item.Subject] = item.Topic
|
||||
}
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
func rawFieldProjectionRoutes(rawRoutes []subjectRoute, fieldsRoutes []subjectRoute) map[string]fieldsProjectionRoute {
|
||||
fieldsByProtocol := make(map[envelope.Protocol]string, len(fieldsRoutes))
|
||||
for _, route := range fieldsRoutes {
|
||||
if route.Protocol != "" && strings.TrimSpace(route.Topic) != "" {
|
||||
fieldsByProtocol[route.Protocol] = strings.TrimSpace(route.Topic)
|
||||
}
|
||||
}
|
||||
out := make(map[string]fieldsProjectionRoute, len(rawRoutes))
|
||||
for _, route := range rawRoutes {
|
||||
subject := strings.TrimSpace(route.Subject)
|
||||
topic := fieldsByProtocol[route.Protocol]
|
||||
if subject == "" || topic == "" {
|
||||
continue
|
||||
}
|
||||
out[subject] = fieldsProjectionRoute{Protocol: route.Protocol, Topic: topic}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c config) Validate() error {
|
||||
rawSubjects, rawTopics := routeSubjectsAndTopics(c.RawRoutes)
|
||||
fieldsSubjects, fieldsTopics := routeSubjectsAndTopics(c.FieldsRoutes)
|
||||
if err := topics.ValidateKafkaRawFields(rawTopics, fieldsTopics); err != nil {
|
||||
return err
|
||||
}
|
||||
return topics.ValidateRawFieldsDisjoint(rawSubjects, fieldsSubjects, "nats subject")
|
||||
}
|
||||
|
||||
func routeSubjectsAndTopics(routes []subjectRoute) (map[string]string, map[string]string) {
|
||||
subjects := make(map[string]string, len(routes))
|
||||
kafkaTopics := make(map[string]string, len(routes))
|
||||
for _, route := range routes {
|
||||
name := strings.TrimSpace(route.Subject)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(route.Topic)
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
subjects[name] = strings.TrimSpace(route.Subject)
|
||||
topicName := routeProtocolName(route)
|
||||
if topicName == "" {
|
||||
topicName = name
|
||||
}
|
||||
kafkaTopics[topicName] = strings.TrimSpace(route.Topic)
|
||||
}
|
||||
return subjects, kafkaTopics
|
||||
}
|
||||
|
||||
func routeProtocolName(route subjectRoute) string {
|
||||
if protocol := strings.TrimSpace(string(route.Protocol)); protocol != "" {
|
||||
return protocol
|
||||
}
|
||||
if protocol, ok := topics.ProtocolForKnownRawTopic(route.Topic); ok {
|
||||
return protocol
|
||||
}
|
||||
if protocol, ok := topics.ProtocolForKnownFieldsTopic(route.Topic); ok {
|
||||
return protocol
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unifiedRouteFromEnv() (string, string, bool) {
|
||||
@@ -169,6 +310,18 @@ type bridgeMessage struct {
|
||||
ack func() error
|
||||
}
|
||||
|
||||
type routedBridgeMessage struct {
|
||||
sourceIndex int
|
||||
kafka kafka.Message
|
||||
receivedAtMS int64
|
||||
}
|
||||
|
||||
type bridgeSourceState struct {
|
||||
source bridgeMessage
|
||||
routed bool
|
||||
failed bool
|
||||
}
|
||||
|
||||
func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, infoReader natsConsumerInfoReader, sub natsPullSubscription, writer kafkaBatchWriter, cfg config) {
|
||||
var lastConsumerInfoAt time.Time
|
||||
for {
|
||||
@@ -188,9 +341,17 @@ func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
|
||||
}
|
||||
msgs, err := sub.Fetch(cfg.BatchSize, nats.MaxWait(cfg.FetchWait))
|
||||
if err != nil {
|
||||
if isBridgeShutdownFetchError(ctx, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, nats.ErrTimeout) {
|
||||
continue
|
||||
}
|
||||
if isTransientBridgeFetchError(err) {
|
||||
logger.Warn("nats fetch interrupted", "error", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
logger.Error("nats fetch failed", "error", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
@@ -207,7 +368,7 @@ func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
|
||||
})
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.OperationWait)
|
||||
err = bridgeBatch(operationCtx, registry, writer, bridgeMessages, cfg.Route)
|
||||
err = bridgeBatchWithProjectionConcurrency(operationCtx, registry, writer, bridgeMessages, cfg.Route, cfg.RawFieldRoutes, cfg.DeriveFieldsFromRaw, cfg.KafkaWriteConcurrency)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("bridge batch failed", "count", len(bridgeMessages), "error", err)
|
||||
@@ -216,6 +377,30 @@ func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Regis
|
||||
}
|
||||
}
|
||||
|
||||
func isBridgeShutdownFetchError(ctx context.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, nats.ErrConnectionClosed)
|
||||
}
|
||||
|
||||
func isTransientBridgeFetchError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(text, "disconnected during fetch") ||
|
||||
strings.Contains(text, "connection closed") ||
|
||||
strings.Contains(text, "connection reset") ||
|
||||
strings.Contains(text, "broken pipe") ||
|
||||
strings.Contains(text, "temporary") ||
|
||||
strings.Contains(text, "temporarily") ||
|
||||
strings.Contains(text, "timeout")
|
||||
}
|
||||
|
||||
func ensureStream(js nats.JetStreamContext, cfg config) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.StreamEnsureWait)
|
||||
defer cancel()
|
||||
@@ -245,6 +430,31 @@ func ensureStream(js nats.JetStreamContext, cfg config) error {
|
||||
}
|
||||
|
||||
func bridgeBatch(ctx context.Context, registry *metrics.Registry, writer kafkaBatchWriter, messages []bridgeMessage, route map[string]string) error {
|
||||
return bridgeBatchWithProjectionConcurrency(ctx, registry, writer, messages, route, nil, false, 6)
|
||||
}
|
||||
|
||||
func bridgeBatchWithProjection(
|
||||
ctx context.Context,
|
||||
registry *metrics.Registry,
|
||||
writer kafkaBatchWriter,
|
||||
messages []bridgeMessage,
|
||||
route map[string]string,
|
||||
rawFieldRoutes map[string]fieldsProjectionRoute,
|
||||
deriveFieldsFromRaw bool,
|
||||
) error {
|
||||
return bridgeBatchWithProjectionConcurrency(ctx, registry, writer, messages, route, rawFieldRoutes, deriveFieldsFromRaw, 6)
|
||||
}
|
||||
|
||||
func bridgeBatchWithProjectionConcurrency(
|
||||
ctx context.Context,
|
||||
registry *metrics.Registry,
|
||||
writer kafkaBatchWriter,
|
||||
messages []bridgeMessage,
|
||||
route map[string]string,
|
||||
rawFieldRoutes map[string]fieldsProjectionRoute,
|
||||
deriveFieldsFromRaw bool,
|
||||
kafkaWriteConcurrency int,
|
||||
) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -252,66 +462,240 @@ func bridgeBatch(ctx context.Context, registry *metrics.Registry, writer kafkaBa
|
||||
status := "ok"
|
||||
defer func() {
|
||||
recordBridgeBatchDuration(registry, status, time.Since(started))
|
||||
recordBridgeBatchPending(registry, 0, 0)
|
||||
}()
|
||||
kafkaMessages := make([]kafka.Message, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
routed := make([]routedBridgeMessage, 0, len(messages)*2)
|
||||
sources := make([]bridgeSourceState, len(messages))
|
||||
for sourceIndex, message := range messages {
|
||||
sources[sourceIndex].source = message
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_messages_total", message.subject, "received")
|
||||
topic, ok := route[message.subject]
|
||||
if !ok || topic == "" {
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_messages_total", message.subject, "route_error")
|
||||
status = "error"
|
||||
return fmt.Errorf("kafka topic not configured for nats subject %q", message.subject)
|
||||
}
|
||||
kafkaMessages = append(kafkaMessages, kafkaMessage(topic, message.data))
|
||||
}
|
||||
recordBridgeBatchPending(registry, len(messages), len(kafkaMessages))
|
||||
if err := writer.WriteMessages(ctx, kafkaMessages...); err != nil {
|
||||
for _, message := range kafkaMessages {
|
||||
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", message.Topic, "error")
|
||||
}
|
||||
status = "error"
|
||||
return err
|
||||
}
|
||||
for _, message := range kafkaMessages {
|
||||
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", message.Topic, "ok")
|
||||
}
|
||||
for _, message := range messages {
|
||||
if message.ack == nil {
|
||||
if message.ack != nil {
|
||||
if err := message.ack(); err != nil {
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "error")
|
||||
status = "error"
|
||||
return fmt.Errorf("ack unrouted nats subject %q: %w", message.subject, err)
|
||||
}
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "dropped_route_error")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := message.ack(); err != nil {
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "error")
|
||||
kafkaMessage, receivedAtMS, decoded, decodeErr := kafkaMessageWithEnvelope(topic, message.data)
|
||||
sources[sourceIndex].routed = true
|
||||
routed = append(routed, routedBridgeMessage{
|
||||
sourceIndex: sourceIndex,
|
||||
kafka: kafkaMessage,
|
||||
receivedAtMS: receivedAtMS,
|
||||
})
|
||||
if !deriveFieldsFromRaw {
|
||||
continue
|
||||
}
|
||||
projection, ok := rawFieldRoutes[message.subject]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fieldsMessage, fieldsReceivedAtMS, fieldCount, projectionStatus, projected := projectRawFields(message.subject, decoded, decodeErr, projection)
|
||||
recordBridgeFieldsProjection(registry, projection.Protocol, projectionStatus, fieldCount)
|
||||
if !projected {
|
||||
continue
|
||||
}
|
||||
routed = append(routed, routedBridgeMessage{
|
||||
sourceIndex: sourceIndex,
|
||||
kafka: fieldsMessage,
|
||||
receivedAtMS: fieldsReceivedAtMS,
|
||||
})
|
||||
}
|
||||
if len(routed) == 0 {
|
||||
addBridgeBatchPending(registry, 0, 0)
|
||||
return nil
|
||||
}
|
||||
addBridgeBatchPending(registry, len(messages), len(routed))
|
||||
defer addBridgeBatchPending(registry, -len(messages), -len(routed))
|
||||
groups := groupRoutedBridgeMessagesByTopic(routed)
|
||||
results := writeBridgeKafkaGroups(ctx, writer, groups, kafkaWriteConcurrency)
|
||||
var firstErr error
|
||||
for _, result := range results {
|
||||
group := result.group
|
||||
err := result.err
|
||||
recordBridgeKafkaWriteDuration(registry, group[0].kafka.Topic, statusFromError(err), result.elapsed)
|
||||
if err != nil {
|
||||
for _, item := range group {
|
||||
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", item.kafka.Topic, "error")
|
||||
sources[item.sourceIndex].failed = true
|
||||
}
|
||||
status = "error"
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("kafka write topic %s: %w", group[0].kafka.Topic, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, item := range group {
|
||||
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", item.kafka.Topic, "ok")
|
||||
recordBridgeKafkaE2EDuration(registry, item.kafka.Topic, item.receivedAtMS)
|
||||
}
|
||||
}
|
||||
for i := range sources {
|
||||
source := &sources[i]
|
||||
if !source.routed || source.failed || source.source.ack == nil {
|
||||
continue
|
||||
}
|
||||
if err := source.source.ack(); err != nil {
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", source.source.subject, "error")
|
||||
status = "error"
|
||||
return err
|
||||
}
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "ok")
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", source.source.subject, "ok")
|
||||
}
|
||||
return nil
|
||||
return firstErr
|
||||
}
|
||||
|
||||
type bridgeKafkaWriteResult struct {
|
||||
group []routedBridgeMessage
|
||||
elapsed time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
func writeBridgeKafkaGroups(ctx context.Context, writer kafkaBatchWriter, groups [][]routedBridgeMessage, concurrency int) []bridgeKafkaWriteResult {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
if concurrency > len(groups) {
|
||||
concurrency = len(groups)
|
||||
}
|
||||
semaphore := make(chan struct{}, concurrency)
|
||||
results := make(chan bridgeKafkaWriteResult, len(groups))
|
||||
for _, group := range groups {
|
||||
group := group
|
||||
semaphore <- struct{}{}
|
||||
go func() {
|
||||
defer func() { <-semaphore }()
|
||||
messages := make([]kafka.Message, 0, len(group))
|
||||
for _, item := range group {
|
||||
messages = append(messages, item.kafka)
|
||||
}
|
||||
started := time.Now()
|
||||
err := writer.WriteMessages(ctx, messages...)
|
||||
results <- bridgeKafkaWriteResult{group: group, elapsed: time.Since(started), err: err}
|
||||
}()
|
||||
}
|
||||
out := make([]bridgeKafkaWriteResult, 0, len(groups))
|
||||
for range groups {
|
||||
out = append(out, <-results)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func projectRawFields(subject string, raw envelope.FrameEnvelope, decodeErr error, projection fieldsProjectionRoute) (kafka.Message, int64, int, string, bool) {
|
||||
if decodeErr != nil {
|
||||
return kafka.Message{}, 0, 0, "invalid_json", false
|
||||
}
|
||||
if _, err := topics.ValidateRawEnvelope(subject, raw); err != nil {
|
||||
return kafka.Message{}, 0, 0, "invalid_envelope", false
|
||||
}
|
||||
if raw.Protocol != projection.Protocol {
|
||||
return kafka.Message{}, 0, 0, "protocol_mismatch", false
|
||||
}
|
||||
if !envelope.IsRealtimeTelemetryFrame(raw) {
|
||||
return kafka.Message{}, 0, 0, "skipped_non_realtime", false
|
||||
}
|
||||
if len(raw.ParsedFields) == 0 {
|
||||
return kafka.Message{}, 0, 0, "skipped_missing_fields", false
|
||||
}
|
||||
fields, ok := realtime.BuildFieldsEnvelope(raw)
|
||||
if !ok || len(fields.Fields) == 0 {
|
||||
return kafka.Message{}, 0, 0, "skipped_missing_fields", false
|
||||
}
|
||||
if _, err := topics.ValidateFieldsEnvelope(projection.Topic, fields); err != nil {
|
||||
return kafka.Message{}, 0, 0, "invalid_fields_envelope", false
|
||||
}
|
||||
payload, err := fields.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
return kafka.Message{}, 0, 0, "marshal_error", false
|
||||
}
|
||||
result := kafka.Message{Topic: projection.Topic, Key: fields.KafkaKey(), Value: payload}
|
||||
return result, fields.ReceivedAtMS, len(fields.Fields), "published", true
|
||||
}
|
||||
|
||||
func groupRoutedBridgeMessagesByTopic(messages []routedBridgeMessage) [][]routedBridgeMessage {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
groupsByTopic := make(map[string][]routedBridgeMessage)
|
||||
for _, message := range messages {
|
||||
groupsByTopic[message.kafka.Topic] = append(groupsByTopic[message.kafka.Topic], message)
|
||||
}
|
||||
topics := make([]string, 0, len(groupsByTopic))
|
||||
for topic := range groupsByTopic {
|
||||
topics = append(topics, topic)
|
||||
}
|
||||
sort.Strings(topics)
|
||||
groups := make([][]routedBridgeMessage, 0, len(topics))
|
||||
for _, topic := range topics {
|
||||
groups = append(groups, groupsByTopic[topic])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
var bridgeBatchDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
var bridgeKafkaWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
var bridgeKafkaE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
|
||||
var bridgeKafkaE2ERecent = metrics.NewRecentLatencyByKey(512)
|
||||
var bridgeBatchPending = metrics.PendingPairGauge{}
|
||||
|
||||
func addBridgeSubjectMetric(registry *metrics.Registry, name string, subject string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"subject": subject, "status": status})
|
||||
labels := metrics.Labels{"subject": subject, "status": status}
|
||||
registry.IncCounter(name, labels)
|
||||
if name == "vehicle_bridge_messages_total" {
|
||||
metrics.RecordLastActivity(registry, "vehicle_bridge_last_message_unix_seconds", labels)
|
||||
return
|
||||
}
|
||||
if name == "vehicle_bridge_nats_acks_total" {
|
||||
metrics.RecordLastActivity(registry, "vehicle_bridge_last_ack_unix_seconds", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func addBridgeTopicMetric(registry *metrics.Registry, name string, topic string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"topic": topic, "status": status})
|
||||
labels := metrics.Labels{"topic": topic, "status": status}
|
||||
registry.IncCounter(name, labels)
|
||||
if name == "vehicle_bridge_kafka_writes_total" {
|
||||
metrics.RecordLastActivity(registry, "vehicle_bridge_last_kafka_write_unix_seconds", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func recordBridgeBatchPending(registry *metrics.Registry, messages int, kafkaMessages int) {
|
||||
func recordBridgeFieldsProjection(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_bridge_batch_pending_messages", nil, float64(messages))
|
||||
registry.SetGauge("vehicle_bridge_batch_pending_kafka_messages", nil, float64(kafkaMessages))
|
||||
protocolLabel := strings.TrimSpace(string(protocol))
|
||||
if protocolLabel == "" {
|
||||
protocolLabel = "unknown"
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = "unknown"
|
||||
}
|
||||
labels := metrics.Labels{"protocol": protocolLabel, "status": status}
|
||||
registry.IncCounter("vehicle_bridge_fields_projection_total", labels)
|
||||
metrics.RecordLastActivity(registry, "vehicle_bridge_last_fields_projection_unix_seconds", labels)
|
||||
if status == "published" {
|
||||
registry.SetGauge("vehicle_bridge_fields_projection_count", labels, float64(fieldCount))
|
||||
}
|
||||
}
|
||||
|
||||
func addBridgeBatchPending(registry *metrics.Registry, messages int, kafkaMessages int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
bridgeBatchPending.Add(registry, "vehicle_bridge_batch_pending_messages", "vehicle_bridge_batch_pending_kafka_messages", messages, kafkaMessages)
|
||||
}
|
||||
|
||||
func recordBridgeBatchDuration(registry *metrics.Registry, status string, elapsed time.Duration) {
|
||||
@@ -323,6 +707,41 @@ func recordBridgeBatchDuration(registry *metrics.Registry, status string, elapse
|
||||
}, bridgeBatchDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func recordBridgeKafkaWriteDuration(registry *metrics.Registry, topic string, status string, elapsed time.Duration) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_bridge_kafka_write_duration_ms_histogram", metrics.Labels{
|
||||
"topic": topic,
|
||||
"status": status,
|
||||
}, bridgeKafkaWriteDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func recordBridgeKafkaE2EDuration(registry *metrics.Registry, topic string, receivedAtMS int64) {
|
||||
if registry == nil || receivedAtMS <= 0 {
|
||||
return
|
||||
}
|
||||
elapsedMS := time.Since(time.UnixMilli(receivedAtMS)).Milliseconds()
|
||||
if elapsedMS < 0 {
|
||||
elapsedMS = 0
|
||||
}
|
||||
labels := metrics.Labels{
|
||||
"topic": topic,
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_bridge_kafka_e2e_duration_ms_histogram", labels, bridgeKafkaE2EDurationBucketsMS, float64(elapsedMS))
|
||||
p99, samples := bridgeKafkaE2ERecent.Observe(topic, float64(elapsedMS))
|
||||
registry.SetGauge("vehicle_bridge_kafka_e2e_recent_p99_ms", labels, p99)
|
||||
registry.SetGauge("vehicle_bridge_kafka_e2e_recent_samples", labels, float64(samples))
|
||||
metrics.RecordLastActivity(registry, "vehicle_bridge_last_kafka_e2e_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func recordNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info *nats.ConsumerInfo) {
|
||||
if registry == nil || info == nil {
|
||||
return
|
||||
@@ -334,12 +753,23 @@ func recordNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info
|
||||
}
|
||||
|
||||
func kafkaMessage(topic string, data []byte) kafka.Message {
|
||||
message, _ := kafkaMessageWithReceivedAt(topic, data)
|
||||
return message
|
||||
}
|
||||
|
||||
func kafkaMessageWithReceivedAt(topic string, data []byte) (kafka.Message, int64) {
|
||||
message, receivedAtMS, _, _ := kafkaMessageWithEnvelope(topic, data)
|
||||
return message, receivedAtMS
|
||||
}
|
||||
|
||||
func kafkaMessageWithEnvelope(topic string, data []byte) (kafka.Message, int64, envelope.FrameEnvelope, error) {
|
||||
var env envelope.FrameEnvelope
|
||||
message := kafka.Message{Topic: topic, Value: data}
|
||||
if err := json.Unmarshal(data, &env); err == nil {
|
||||
message.Key = env.KafkaKey()
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
return message, 0, envelope.FrameEnvelope{}, err
|
||||
}
|
||||
return message
|
||||
message.Key = env.KafkaKey()
|
||||
return message, env.ReceivedAtMS, env, nil
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
@@ -350,6 +780,25 @@ func env(key string, fallback string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func boolMetric(value bool) float64 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func envOptional(key string) (string, bool) {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func TestBridgeBatchWritesKafkaThenAcks(t *testing.T) {
|
||||
@@ -65,12 +68,303 @@ func TestBridgeBatchDoesNotAckWhenKafkaFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchRecordsMetrics(t *testing.T) {
|
||||
func TestBridgeBatchProjectsFieldsFromCanonicalRawAndAcksOnce(t *testing.T) {
|
||||
raw := envelope.FrameEnvelope{
|
||||
EventID: "raw-event-1",
|
||||
EventKind: envelope.EventKindRaw,
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
VIN: "LTEST000000000001",
|
||||
EventTimeMS: 1_700_000_000_000,
|
||||
ReceivedAtMS: 1_700_000_000_100,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
ParsedFields: map[string]any{
|
||||
"jt808.location.speed_kmh": 52.3,
|
||||
"jt808.location.total_mileage_km": 12345.6,
|
||||
},
|
||||
}
|
||||
payload, err := raw.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acked := 0
|
||||
writer := &recordingBridgeWriter{}
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
err = bridgeBatchWithProjection(
|
||||
context.Background(),
|
||||
registry,
|
||||
writer,
|
||||
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
|
||||
map[string]string{topics.RawJT808: topics.RawJT808},
|
||||
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("bridgeBatchWithProjection() error = %v", err)
|
||||
}
|
||||
if acked != 1 {
|
||||
t.Fatalf("acks = %d, want exactly one ack for raw plus derived fields", acked)
|
||||
}
|
||||
if len(writer.messages) != 2 {
|
||||
t.Fatalf("kafka messages = %d, want raw and fields", len(writer.messages))
|
||||
}
|
||||
byTopic := map[string]kafka.Message{}
|
||||
for _, message := range writer.messages {
|
||||
byTopic[message.Topic] = message
|
||||
}
|
||||
if len(byTopic[topics.RawJT808].Value) == 0 || len(byTopic[topics.FieldsJT808].Value) == 0 {
|
||||
t.Fatalf("projected topics = %#v", byTopic)
|
||||
}
|
||||
var fields envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(byTopic[topics.FieldsJT808].Value, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fields.EventKind != envelope.EventKindFields || fields.SourceEventID != raw.EventID {
|
||||
t.Fatalf("fields envelope = %#v", fields)
|
||||
}
|
||||
if got := fields.Fields["jt808.location.total_mileage_km"]; got != 12345.6 {
|
||||
t.Fatalf("projected mileage = %#v", got)
|
||||
}
|
||||
metricText := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_fields_projection_total{protocol="JT808",status="published"} 1`,
|
||||
`vehicle_bridge_fields_projection_count{protocol="JT808",status="published"} 2`,
|
||||
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(metricText, want) {
|
||||
t.Fatalf("projection metric missing %s:\n%s", want, metricText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchDoesNotAckRawWhenDerivedFieldsWriteFails(t *testing.T) {
|
||||
raw := envelope.FrameEnvelope{
|
||||
EventKind: envelope.EventKindRaw,
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
VIN: "LTEST000000000001",
|
||||
EventTimeMS: 1_700_000_000_000,
|
||||
ReceivedAtMS: 1_700_000_000_100,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
ParsedFields: map[string]any{"jt808.location.total_mileage_km": 12345.6},
|
||||
}
|
||||
payload, err := raw.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acked := 0
|
||||
writer := &recordingBridgeWriter{topicErr: map[string]error{topics.FieldsJT808: errors.New("fields unavailable")}}
|
||||
|
||||
err = bridgeBatchWithProjection(
|
||||
context.Background(),
|
||||
nil,
|
||||
writer,
|
||||
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
|
||||
map[string]string{topics.RawJT808: topics.RawJT808},
|
||||
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
|
||||
true,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("bridgeBatchWithProjection() error = nil, want fields Kafka error")
|
||||
}
|
||||
if acked != 0 {
|
||||
t.Fatalf("acks = %d, want raw left pending until both Kafka outputs succeed", acked)
|
||||
}
|
||||
if len(writer.messages) != 1 || writer.messages[0].Topic != topics.RawJT808 {
|
||||
t.Fatalf("successful kafka messages = %#v, want only raw before replay", writer.messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchSkipsFieldsProjectionForNonRealtimeRaw(t *testing.T) {
|
||||
raw := envelope.FrameEnvelope{
|
||||
EventKind: envelope.EventKindRaw,
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0100",
|
||||
Phone: "13307795425",
|
||||
EventTimeMS: 1_700_000_000_000,
|
||||
ReceivedAtMS: 1_700_000_000_100,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
payload, err := raw.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acked := 0
|
||||
writer := &recordingBridgeWriter{}
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
err = bridgeBatchWithProjection(
|
||||
context.Background(),
|
||||
registry,
|
||||
writer,
|
||||
[]bridgeMessage{{subject: topics.RawJT808, data: payload, ack: func() error { acked++; return nil }}},
|
||||
map[string]string{topics.RawJT808: topics.RawJT808},
|
||||
map[string]fieldsProjectionRoute{topics.RawJT808: {Protocol: envelope.ProtocolJT808, Topic: topics.FieldsJT808}},
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("bridgeBatchWithProjection() error = %v", err)
|
||||
}
|
||||
if acked != 1 || len(writer.messages) != 1 || writer.messages[0].Topic != topics.RawJT808 {
|
||||
t.Fatalf("acks=%d messages=%#v", acked, writer.messages)
|
||||
}
|
||||
if text := registry.Render(); !strings.Contains(text, `vehicle_bridge_fields_projection_total{protocol="JT808",status="skipped_non_realtime"} 1`) {
|
||||
t.Fatalf("non-realtime projection metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchAcksSuccessfulTopicWhenAnotherTopicFails(t *testing.T) {
|
||||
rawEnv := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
fieldsEnv := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
rawPayload, err := json.Marshal(rawEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fieldsPayload, err := json.Marshal(fieldsEnv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
writer := &recordingBridgeWriter{
|
||||
topicErr: map[string]error{
|
||||
"vehicle.fields.go.jt808.v1": errors.New("fields topic unavailable"),
|
||||
},
|
||||
}
|
||||
acks := map[string]int{}
|
||||
|
||||
err = bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
|
||||
{subject: "vehicle.fields.go.jt808.v1", data: fieldsPayload, ack: func() error {
|
||||
acks["fields"]++
|
||||
return nil
|
||||
}},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: rawPayload, ack: func() error {
|
||||
acks["raw"]++
|
||||
return nil
|
||||
}},
|
||||
}, map[string]string{
|
||||
"vehicle.fields.go.jt808.v1": "vehicle.fields.go.jt808.v1",
|
||||
"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("bridgeBatch() error = nil, want failed fields topic")
|
||||
}
|
||||
if len(writer.calls) != 2 {
|
||||
t.Fatalf("writer calls = %d, want one call per topic", len(writer.calls))
|
||||
}
|
||||
if len(writer.messages) != 1 || writer.messages[0].Topic != "vehicle.raw.go.jt808.v1" {
|
||||
t.Fatalf("successful kafka messages = %#v, want only raw topic", writer.messages)
|
||||
}
|
||||
if acks["raw"] != 1 || acks["fields"] != 0 {
|
||||
t.Fatalf("acks = %#v, want raw acked and fields left unacked", acks)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_kafka_writes_total{status="error",topic="vehicle.fields.go.jt808.v1"} 1`,
|
||||
`vehicle_bridge_kafka_writes_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("partial bridge metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.fields.go.jt808.v1"}`) {
|
||||
t.Fatalf("failed topic should not be acked:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchAcksAndDropsUnroutedSubject(t *testing.T) {
|
||||
writer := &recordingBridgeWriter{}
|
||||
acked := 0
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
err := bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
|
||||
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
|
||||
acked++
|
||||
return nil
|
||||
}},
|
||||
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("bridgeBatch() error = %v", err)
|
||||
}
|
||||
if acked != 1 {
|
||||
t.Fatalf("acks = %d, want unrouted message acked", acked)
|
||||
}
|
||||
if len(writer.messages) != 0 {
|
||||
t.Fatalf("kafka writes = %d, want 0", len(writer.messages))
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_messages_total{status="route_error",subject="vehicle.unconfigured.v1"} 1`,
|
||||
`vehicle_bridge_nats_acks_total{status="dropped_route_error",subject="vehicle.unconfigured.v1"} 1`,
|
||||
`vehicle_bridge_batch_pending_kafka_messages 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchKeepsValidMessagesWhenUnroutedSubjectIsPresent(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := &recordingBridgeWriter{}
|
||||
acked := map[string]int{}
|
||||
|
||||
err = bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
|
||||
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
|
||||
acked["unknown"]++
|
||||
return nil
|
||||
}},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error {
|
||||
acked["valid"]++
|
||||
return nil
|
||||
}},
|
||||
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("bridgeBatch() error = %v", err)
|
||||
}
|
||||
if len(writer.messages) != 1 {
|
||||
t.Fatalf("kafka writes = %d, want 1 valid message", len(writer.messages))
|
||||
}
|
||||
if writer.messages[0].Topic != "vehicle.raw.go.jt808.v1" {
|
||||
t.Fatalf("topic = %q", writer.messages[0].Topic)
|
||||
}
|
||||
if acked["unknown"] != 1 || acked["valid"] != 1 {
|
||||
t.Fatalf("acks = %#v, want both messages acked", acked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchReturnsErrorWhenUnroutedAckFails(t *testing.T) {
|
||||
writer := &recordingBridgeWriter{}
|
||||
|
||||
err := bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
|
||||
{subject: "vehicle.unconfigured.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
|
||||
return errors.New("nats ack unavailable")
|
||||
}},
|
||||
}, map[string]string{"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1"})
|
||||
if err == nil {
|
||||
t.Fatal("bridgeBatch() error = nil, want ack error")
|
||||
}
|
||||
if len(writer.messages) != 0 {
|
||||
t.Fatalf("kafka writes = %d, want 0", len(writer.messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchRecordsMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Phone: "13307795425",
|
||||
MessageID: "0x0200",
|
||||
ReceivedAtMS: time.Now().Add(-20 * time.Millisecond).UnixMilli(),
|
||||
}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
writer := &recordingBridgeWriter{}
|
||||
|
||||
@@ -83,8 +377,16 @@ func TestBridgeBatchRecordsMetrics(t *testing.T) {
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_messages_total{status="received",subject="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_bridge_last_message_unix_seconds{status="received",subject="vehicle.raw.jt808.v1"} `,
|
||||
`vehicle_bridge_kafka_writes_total{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_bridge_last_kafka_write_unix_seconds{status="ok",topic="vehicle.raw.jt808.v1"} `,
|
||||
`vehicle_bridge_kafka_write_duration_ms_histogram_count{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_bridge_kafka_e2e_duration_ms_histogram_count{topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_bridge_kafka_e2e_recent_p99_ms{topic="vehicle.raw.jt808.v1"} `,
|
||||
`vehicle_bridge_kafka_e2e_recent_samples{topic="vehicle.raw.jt808.v1"} `,
|
||||
`vehicle_bridge_last_kafka_e2e_unix_seconds{topic="vehicle.raw.jt808.v1"} `,
|
||||
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_bridge_last_ack_unix_seconds{status="ok",subject="vehicle.raw.jt808.v1"} `,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
@@ -92,6 +394,16 @@ func TestBridgeBatchRecordsMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordBridgeKafkaE2EDurationSkipsMissingReceiveTime(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
recordBridgeKafkaE2EDuration(registry, "vehicle.raw.go.jt808.v1", 0)
|
||||
|
||||
if text := registry.Render(); strings.Contains(text, "vehicle_bridge_kafka_e2e_duration_ms_histogram") || strings.Contains(text, "vehicle_bridge_kafka_e2e_recent") {
|
||||
t.Fatalf("e2e metric should be skipped when received_at_ms is missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchExposesPendingAndDurationMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
|
||||
payload, err := json.Marshal(env)
|
||||
@@ -135,6 +447,73 @@ func TestBridgeBatchExposesPendingAndDurationMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchPendingAggregatesConcurrentWorkers(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
firstStarted := make(chan struct{})
|
||||
secondStarted := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
writerFor := func(started chan struct{}) *recordingBridgeWriter {
|
||||
return &recordingBridgeWriter{
|
||||
onWrite: func() {
|
||||
close(started)
|
||||
<-release
|
||||
},
|
||||
}
|
||||
}
|
||||
messages := func(count int) []bridgeMessage {
|
||||
out := make([]bridgeMessage, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
out = append(out, bridgeMessage{subject: "vehicle.raw.go.gb32960.v1", data: payload, ack: func() error { return nil }})
|
||||
}
|
||||
return out
|
||||
}
|
||||
route := map[string]string{"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1"}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var firstErr error
|
||||
var secondErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
firstErr = bridgeBatch(context.Background(), registry, writerFor(firstStarted), messages(2), route)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
secondErr = bridgeBatch(context.Background(), registry, writerFor(secondStarted), messages(3), route)
|
||||
}()
|
||||
<-firstStarted
|
||||
<-secondStarted
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_batch_pending_messages 5`,
|
||||
`vehicle_bridge_batch_pending_kafka_messages 5`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("aggregate pending bridge metric missing %s during concurrent writes:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if firstErr != nil || secondErr != nil {
|
||||
t.Fatalf("bridgeBatch errors = %v / %v", firstErr, secondErr)
|
||||
}
|
||||
text = registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_batch_pending_messages 0`,
|
||||
`vehicle_bridge_batch_pending_kafka_messages 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("aggregate pending bridge metric should reset after concurrent writes, missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordNATSConsumerInfoMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
cfg := config{NATSStream: "VEHICLE_INGEST", NATSDurable: "vehicle-kafka-bridge"}
|
||||
@@ -157,8 +536,45 @@ func TestRecordNATSConsumerInfoMetrics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBridgeShutdownFetchError(t *testing.T) {
|
||||
if isBridgeShutdownFetchError(context.Background(), errors.New("temporary nats failure")) {
|
||||
t.Fatal("temporary failure should not be treated as shutdown")
|
||||
}
|
||||
if !isBridgeShutdownFetchError(context.Background(), nats.ErrConnectionClosed) {
|
||||
t.Fatal("connection closed should stop worker without noisy error log")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if !isBridgeShutdownFetchError(ctx, errors.New("any fetch error after cancellation")) {
|
||||
t.Fatal("cancelled context should stop worker without noisy error log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransientBridgeFetchError(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errors.New("nats: disconnected during fetch"),
|
||||
errors.New("nats: connection closed"),
|
||||
errors.New("read tcp: connection reset by peer"),
|
||||
errors.New("temporary network unavailable"),
|
||||
errors.New("i/o timeout"),
|
||||
} {
|
||||
if !isTransientBridgeFetchError(err) {
|
||||
t.Fatalf("isTransientBridgeFetchError(%v) = false, want true", err)
|
||||
}
|
||||
}
|
||||
if isTransientBridgeFetchError(errors.New("permission denied")) {
|
||||
t.Fatal("non-transient bridge fetch error should stay non-transient")
|
||||
}
|
||||
if isTransientBridgeFetchError(nil) {
|
||||
t.Fatal("nil should not be transient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsToGoSubjectRoutes(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("default config Validate() error = %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1",
|
||||
@@ -176,6 +592,75 @@ func TestLoadConfigDefaultsToGoSubjectRoutes(t *testing.T) {
|
||||
t.Fatalf("route[%q] = %q, want %q; route=%#v", subject, got, topic, cfg.Route)
|
||||
}
|
||||
}
|
||||
if !cfg.DeriveFieldsFromRaw {
|
||||
t.Fatal("DeriveFieldsFromRaw = false, want canonical raw projection enabled by default")
|
||||
}
|
||||
for subject, wantTopic := range map[string]string{
|
||||
topics.RawGB32960: topics.FieldsGB32960,
|
||||
topics.RawJT808: topics.FieldsJT808,
|
||||
topics.RawYutongMQTT: topics.FieldsYutongMQTT,
|
||||
} {
|
||||
if got := cfg.RawFieldRoutes[subject].Topic; got != wantTopic {
|
||||
t.Fatalf("RawFieldRoutes[%q].Topic = %q, want %q", subject, got, wantTopic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateRejectsRawFieldsSubjectOverlap(t *testing.T) {
|
||||
cfg := config{
|
||||
RawRoutes: []subjectRoute{
|
||||
{Subject: "vehicle.same.jt808", Topic: "vehicle.raw.go.jt808.v1"},
|
||||
},
|
||||
FieldsRoutes: []subjectRoute{
|
||||
{Subject: "vehicle.same.jt808", Topic: "vehicle.fields.go.jt808.v1"},
|
||||
},
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want subject overlap rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nats subject") {
|
||||
t.Fatalf("Validate() error = %q, want nats subject hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateRejectsKnownProtocolTopicMismatch(t *testing.T) {
|
||||
cfg := config{
|
||||
RawRoutes: []subjectRoute{
|
||||
{Protocol: envelope.ProtocolJT808, Subject: "vehicle.raw.go.jt808.v1", Topic: "vehicle.raw.go.gb32960.v1"},
|
||||
},
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want known protocol topic mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must match protocol") {
|
||||
t.Fatalf("Validate() error = %q, want protocol mismatch hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateRejectsFieldsRouteToRawKafkaTopic(t *testing.T) {
|
||||
cfg := config{
|
||||
RawRoutes: []subjectRoute{
|
||||
{Subject: "vehicle.raw.go.jt808.v1", Topic: "vehicle.raw.go.jt808.v1"},
|
||||
},
|
||||
FieldsRoutes: []subjectRoute{
|
||||
{Subject: "vehicle.fields.go.jt808.v1", Topic: "vehicle.raw.go.jt808.v1"},
|
||||
},
|
||||
}
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want fields topic family rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fields kafka topic") {
|
||||
t.Fatalf("Validate() error = %q, want fields kafka topic hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
||||
@@ -187,11 +672,27 @@ func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
||||
if got, want := cfg.StreamEnsureWait, 60*time.Second; got != want {
|
||||
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.KafkaBatchTimeout, 20*time.Millisecond; got != want {
|
||||
t.Fatalf("KafkaBatchTimeout = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.KafkaWriteConcurrency, 6; got != want {
|
||||
t.Fatalf("KafkaWriteConcurrency = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := cfg.FetchWait, 20*time.Millisecond; got != want {
|
||||
t.Fatalf("FetchWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.Workers, 4; got != want {
|
||||
t.Fatalf("Workers = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
||||
t.Setenv("NATS_STREAM_MAX_BYTES", "1073741824")
|
||||
t.Setenv("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", "90")
|
||||
t.Setenv("BRIDGE_KAFKA_BATCH_TIMEOUT_MS", "35")
|
||||
t.Setenv("BRIDGE_KAFKA_WRITE_CONCURRENCY", "4")
|
||||
t.Setenv("BRIDGE_FETCH_WAIT_MS", "45")
|
||||
t.Setenv("BRIDGE_WORKERS", "6")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -201,6 +702,57 @@ func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
||||
if got, want := cfg.StreamEnsureWait, 90*time.Second; got != want {
|
||||
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.KafkaBatchTimeout, 35*time.Millisecond; got != want {
|
||||
t.Fatalf("KafkaBatchTimeout = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.KafkaWriteConcurrency, 4; got != want {
|
||||
t.Fatalf("KafkaWriteConcurrency = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := cfg.FetchWait, 45*time.Millisecond; got != want {
|
||||
t.Fatalf("FetchWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.Workers, 6; got != want {
|
||||
t.Fatalf("Workers = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordBridgeConfigMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
recordBridgeConfigMetrics(registry, config{
|
||||
KafkaBatchTimeout: 35 * time.Millisecond,
|
||||
KafkaWriteConcurrency: 4,
|
||||
BatchSize: 600,
|
||||
FetchWait: 20 * time.Millisecond,
|
||||
Workers: 6,
|
||||
DeriveFieldsFromRaw: true,
|
||||
})
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_config{setting="batch_size"} 600`,
|
||||
`vehicle_bridge_config{setting="fetch_wait_ms"} 20`,
|
||||
`vehicle_bridge_config{setting="kafka_batch_timeout_ms"} 35`,
|
||||
`vehicle_bridge_config{setting="kafka_write_concurrency"} 4`,
|
||||
`vehicle_bridge_config{setting="workers"} 6`,
|
||||
`vehicle_bridge_config{setting="derive_fields_from_raw_enabled"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("bridge config metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKafkaWriterUsesConfiguredBatchTimeout(t *testing.T) {
|
||||
writer := newKafkaWriter(config{
|
||||
KafkaBrokers: []string{"127.0.0.1:9092"},
|
||||
KafkaBatchTimeout: 17 * time.Millisecond,
|
||||
})
|
||||
defer writer.Close()
|
||||
|
||||
if got, want := writer.BatchTimeout, 17*time.Millisecond; got != want {
|
||||
t.Fatalf("BatchTimeout = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigIncludesUnifiedOnlyWhenExplicitlyConfigured(t *testing.T) {
|
||||
@@ -215,8 +767,11 @@ func TestLoadConfigIncludesUnifiedOnlyWhenExplicitlyConfigured(t *testing.T) {
|
||||
}
|
||||
|
||||
type recordingBridgeWriter struct {
|
||||
mu sync.Mutex
|
||||
messages []kafka.Message
|
||||
calls [][]kafka.Message
|
||||
err error
|
||||
topicErr map[string]error
|
||||
onWrite func()
|
||||
}
|
||||
|
||||
@@ -224,9 +779,72 @@ func (w *recordingBridgeWriter) WriteMessages(_ context.Context, messages ...kaf
|
||||
if w.onWrite != nil {
|
||||
w.onWrite()
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
call := append([]kafka.Message(nil), messages...)
|
||||
w.calls = append(w.calls, call)
|
||||
if len(messages) > 0 && w.topicErr != nil {
|
||||
if err := w.topicErr[messages[0].Topic]; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.err != nil {
|
||||
return w.err
|
||||
}
|
||||
w.messages = append(w.messages, messages...)
|
||||
return nil
|
||||
}
|
||||
|
||||
type concurrentBridgeWriter struct {
|
||||
started chan string
|
||||
release chan struct{}
|
||||
active atomic.Int32
|
||||
max atomic.Int32
|
||||
}
|
||||
|
||||
func (w *concurrentBridgeWriter) WriteMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
active := w.active.Add(1)
|
||||
defer w.active.Add(-1)
|
||||
for {
|
||||
current := w.max.Load()
|
||||
if active <= current || w.max.CompareAndSwap(current, active) {
|
||||
break
|
||||
}
|
||||
}
|
||||
w.started <- messages[0].Topic
|
||||
<-w.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBridgeWritesIndependentKafkaTopicsConcurrently(t *testing.T) {
|
||||
writer := &concurrentBridgeWriter{
|
||||
started: make(chan string, 2),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
messages := []bridgeMessage{
|
||||
{subject: topics.RawJT808, data: []byte(`{"protocol":"JT808"}`), ack: func() error { return nil }},
|
||||
{subject: topics.RawGB32960, data: []byte(`{"protocol":"GB32960"}`), ack: func() error { return nil }},
|
||||
}
|
||||
route := map[string]string{
|
||||
topics.RawJT808: topics.RawJT808,
|
||||
topics.RawGB32960: topics.RawGB32960,
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- bridgeBatchWithProjectionConcurrency(context.Background(), nil, writer, messages, route, nil, false, 2)
|
||||
}()
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-writer.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("independent Kafka topic writes did not start concurrently")
|
||||
}
|
||||
}
|
||||
if got := writer.max.Load(); got != 2 {
|
||||
t.Fatalf("max concurrent Kafka writes = %d, want 2", got)
|
||||
}
|
||||
close(writer.release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("bridgeBatchWithProjectionConcurrency() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +19,7 @@ import (
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
@@ -27,6 +33,10 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Error("invalid stat writer config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := sql.Open("mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
logger.Error("mysql open failed", "error", err)
|
||||
@@ -38,18 +48,64 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
metrics.RegisterKafkaConsumerInfo(registry, "vehicle-stat-writer", cfg.KafkaGroup, cfg.KafkaTopics)
|
||||
registry.SetGauge("vehicle_stat_project_interval_seconds", nil, cfg.ProjectInterval.Seconds())
|
||||
registry.SetGauge("vehicle_stat_source_touch_interval_seconds", nil, cfg.SourceTouchInterval.Seconds())
|
||||
registry.SetGauge("vehicle_stat_cache_retention_seconds", nil, cfg.CacheRetention.Seconds())
|
||||
registry.SetGauge("vehicle_stat_cache_cleanup_interval_seconds", nil, cfg.CacheCleanupInterval.Seconds())
|
||||
registry.SetGauge("vehicle_stat_baseline_miss_ttl_seconds", nil, cfg.BaselineMissTTL.Seconds())
|
||||
registry.SetGauge("vehicle_stat_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{
|
||||
{Name: "mysql", Check: db.PingContext},
|
||||
}, registry))
|
||||
|
||||
writer := stats.NewWriter(db, cfg.Location)
|
||||
writer.SetProjectionInterval(cfg.ProjectInterval)
|
||||
writer.SetSourceTouchInterval(cfg.SourceTouchInterval)
|
||||
writer.SetCacheRetention(cfg.CacheRetention)
|
||||
writer.SetCacheCleanupInterval(cfg.CacheCleanupInterval)
|
||||
writer.SetBaselineMissTTL(cfg.BaselineMissTTL)
|
||||
writer.SetMaxCacheEntries(cfg.CacheMaxEntries)
|
||||
if cfg.EnsureSchema {
|
||||
if err := writer.EnsureSchema(ctx); err != nil {
|
||||
logger.Error("mysql schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if cfg.NormalizePlatformSourcesOnStart {
|
||||
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
|
||||
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
|
||||
normalized, err := stats.NormalizePlatformSourceMileageForDate(normalizeCtx, db, statDate, envelope.ProtocolJT808)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("platform source startup normalization failed", "stat_date", statDate, "protocol", envelope.ProtocolJT808, "normalized", normalized, "error", err)
|
||||
} else if normalized > 0 {
|
||||
logger.Info("platform source startup normalization finished", "stat_date", statDate, "protocol", envelope.ProtocolJT808, "normalized", normalized)
|
||||
}
|
||||
}
|
||||
var appender statAppender = retryStatAppender{
|
||||
delegate: writer,
|
||||
attempts: cfg.RetryAttempts,
|
||||
delay: cfg.RetryDelay,
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds())
|
||||
var workers sync.WaitGroup
|
||||
for workerID := 1; workerID <= cfg.Workers; workerID++ {
|
||||
workers.Add(1)
|
||||
go func(id int) {
|
||||
defer workers.Done()
|
||||
runStatConsumer(ctx, logger, registry, appender, cfg, id)
|
||||
}(workerID)
|
||||
}
|
||||
workers.Wait()
|
||||
}
|
||||
|
||||
func runStatConsumer(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, cfg config, workerID int) {
|
||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: cfg.KafkaBrokers,
|
||||
GroupID: cfg.KafkaGroup,
|
||||
@@ -59,7 +115,10 @@ func main() {
|
||||
})
|
||||
defer reader.Close()
|
||||
|
||||
logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","))
|
||||
workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)}
|
||||
registry.SetGauge("vehicle_stat_worker_active", workerLabels, 1)
|
||||
defer registry.SetGauge("vehicle_stat_worker_active", workerLabels, 0)
|
||||
|
||||
for {
|
||||
message, err := reader.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
@@ -69,7 +128,8 @@ func main() {
|
||||
logger.Error("kafka fetch failed", "error", err)
|
||||
continue
|
||||
}
|
||||
processStatMessage(ctx, logger, registry, writer, reader, message)
|
||||
batch := collectStatBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond)
|
||||
processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,50 +139,488 @@ type statAppender interface {
|
||||
Append(context.Context, envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type statResultAppender interface {
|
||||
AppendWithResult(context.Context, envelope.FrameEnvelope) (stats.AppendResult, error)
|
||||
}
|
||||
|
||||
type statCacheReporter interface {
|
||||
CacheStats() stats.CacheStats
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
type kafkaMessageFetcher interface {
|
||||
FetchMessage(context.Context) (kafka.Message, error)
|
||||
}
|
||||
|
||||
type statBatchItem struct {
|
||||
message kafka.Message
|
||||
processed bool
|
||||
}
|
||||
|
||||
type statBatchOutcome struct {
|
||||
commitMessages []kafka.Message
|
||||
retryMessages []kafka.Message
|
||||
}
|
||||
|
||||
var statWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
var statWriteE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
|
||||
var statWriteE2ERecent = metrics.NewRecentLatencyByKey(512)
|
||||
|
||||
func collectStatBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message {
|
||||
if maxSize <= 1 {
|
||||
return []kafka.Message{first}
|
||||
}
|
||||
if maxWait <= 0 {
|
||||
maxWait = 100 * time.Millisecond
|
||||
}
|
||||
batch := []kafka.Message{first}
|
||||
deadline := time.Now().Add(maxWait)
|
||||
for len(batch) < maxSize {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, remaining)
|
||||
message, err := fetcher.FetchMessage(fetchCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
batch = append(batch, message)
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
func processStatMessage(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, message kafka.Message) {
|
||||
processStatBatch(ctx, logger, registry, appender, committer, []kafka.Message{message})
|
||||
}
|
||||
|
||||
func processStatBatch(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message) statBatchOutcome {
|
||||
return processStatBatchForWorker(ctx, logger, registry, appender, committer, messages, nil)
|
||||
}
|
||||
|
||||
func processStatBatchForWorker(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, workerLabels metrics.Labels) statBatchOutcome {
|
||||
if len(messages) == 0 {
|
||||
return statBatchOutcome{}
|
||||
}
|
||||
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "received")
|
||||
addStatLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
_ = committer.CommitMessages(messageCtx, message)
|
||||
setStatBatchPendingForWorker(registry, workerLabels, len(messages))
|
||||
defer setStatBatchPendingForWorker(registry, workerLabels, 0)
|
||||
defer recordStatCacheMetrics(registry, appender)
|
||||
items := make([]statBatchItem, len(messages))
|
||||
for index, message := range messages {
|
||||
items[index].message = message
|
||||
}
|
||||
for itemIndex, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "received")
|
||||
addStatLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
items[itemIndex].processed = true
|
||||
continue
|
||||
}
|
||||
if status, err := validateStatFieldsEnvelope(message.Topic, env); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, status)
|
||||
logger.Warn("skip mismatched stat fields envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "event_kind", env.EventKind, "error", err)
|
||||
items[itemIndex].processed = true
|
||||
continue
|
||||
}
|
||||
started := time.Now()
|
||||
result, err := appendStatEnvelope(messageCtx, appender, env)
|
||||
recordStatWriteDuration(registry, message, statusFromError(err), time.Since(started))
|
||||
recordStatSampleMetrics(registry, message, env.Protocol, result)
|
||||
recordStatSourceMetrics(registry, message, env.Protocol, result)
|
||||
recordStatProjectionMetrics(registry, message, env.Protocol, result)
|
||||
if err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "error")
|
||||
logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
committed, commitErr := commitStatProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
|
||||
outcome := statBatchOutcome{retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed)}
|
||||
if commitErr != nil {
|
||||
outcome.commitMessages = committed
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "ok")
|
||||
recordStatWriteE2EDuration(registry, message, env)
|
||||
items[itemIndex].processed = true
|
||||
}
|
||||
if err := committer.CommitMessages(messageCtx, messages...); err != nil {
|
||||
for _, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("kafka commit failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
|
||||
return statBatchOutcome{commitMessages: messages}
|
||||
}
|
||||
for _, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return statBatchOutcome{}
|
||||
}
|
||||
|
||||
func processStatBatchReliably(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) {
|
||||
processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, messages, retryDelay, nil)
|
||||
}
|
||||
|
||||
func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) {
|
||||
defer registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, 0)
|
||||
pending := messages
|
||||
for len(pending) > 0 {
|
||||
outcome := processStatBatchForWorker(ctx, logger, registry, appender, committer, pending, workerLabels)
|
||||
if len(outcome.commitMessages) > 0 {
|
||||
registry.IncCounter("vehicle_stat_batch_retries_total", metrics.Labels{"reason": "commit_error"})
|
||||
if !retryStatCommit(ctx, logger, registry, committer, outcome.commitMessages, retryDelay) {
|
||||
return
|
||||
}
|
||||
}
|
||||
pending = outcome.retryMessages
|
||||
registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, float64(len(pending)))
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_stat_batch_retries_total", metrics.Labels{"reason": "write_error"})
|
||||
if !waitForStatRetry(ctx, retryDelay) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retryStatCommit(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) bool {
|
||||
for len(messages) > 0 {
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
err := committer.CommitMessages(operationCtx, messages...)
|
||||
cancel()
|
||||
if err == nil {
|
||||
for _, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, message := range messages {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("kafka commit retry failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
|
||||
registry.IncCounter("vehicle_stat_batch_retries_total", metrics.Labels{"reason": "commit_error"})
|
||||
if !waitForStatRetry(ctx, retryDelay) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func waitForStatRetry(ctx context.Context, retryDelay time.Duration) bool {
|
||||
if retryDelay <= 0 {
|
||||
retryDelay = 100 * time.Millisecond
|
||||
}
|
||||
timer := time.NewTimer(retryDelay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func validateStatFieldsEnvelope(topic string, env envelope.FrameEnvelope) (string, error) {
|
||||
return topics.ValidateFieldsEnvelope(topic, env)
|
||||
}
|
||||
|
||||
func commitStatProcessedPrefixAfterFailure(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, committer kafkaMessageCommitter, items []statBatchItem) ([]kafka.Message, error) {
|
||||
committable := processedPrefixMessagesByPartition(items)
|
||||
if len(committable) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := committer.CommitMessages(ctx, committable...); err != nil {
|
||||
for _, message := range committable {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
|
||||
}
|
||||
first := committable[0]
|
||||
logger.Error("kafka processed-prefix commit failed after mysql append failure", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(committable), "error", err)
|
||||
return committable, err
|
||||
}
|
||||
for _, message := range committable {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
}
|
||||
return committable, nil
|
||||
}
|
||||
|
||||
func processedPrefixMessagesByPartition(items []statBatchItem) []kafka.Message {
|
||||
type partitionKey struct {
|
||||
topic string
|
||||
partition int
|
||||
}
|
||||
groups := map[partitionKey][]statBatchItem{}
|
||||
for _, item := range items {
|
||||
key := partitionKey{topic: item.message.Topic, partition: item.message.Partition}
|
||||
groups[key] = append(groups[key], item)
|
||||
}
|
||||
var keys []partitionKey
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].topic != keys[j].topic {
|
||||
return keys[i].topic < keys[j].topic
|
||||
}
|
||||
return keys[i].partition < keys[j].partition
|
||||
})
|
||||
var out []kafka.Message
|
||||
for _, key := range keys {
|
||||
group := groups[key]
|
||||
sort.Slice(group, func(i, j int) bool {
|
||||
return group[i].message.Offset < group[j].message.Offset
|
||||
})
|
||||
var previousOffset int64
|
||||
for index, item := range group {
|
||||
if index > 0 && item.message.Offset != previousOffset+1 {
|
||||
break
|
||||
}
|
||||
if !item.processed {
|
||||
break
|
||||
}
|
||||
out = append(out, item.message)
|
||||
previousOffset = item.message.Offset
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendStatEnvelope(ctx context.Context, appender statAppender, env envelope.FrameEnvelope) (stats.AppendResult, error) {
|
||||
if resultAppender, ok := appender.(statResultAppender); ok {
|
||||
return resultAppender.AppendWithResult(ctx, env)
|
||||
}
|
||||
return stats.AppendResult{}, appender.Append(ctx, env)
|
||||
}
|
||||
|
||||
type retryStatAppender struct {
|
||||
delegate statAppender
|
||||
attempts int
|
||||
delay time.Duration
|
||||
registry *metrics.Registry
|
||||
}
|
||||
|
||||
func (a retryStatAppender) Append(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
_, err := a.AppendWithResult(ctx, env)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a retryStatAppender) AppendWithResult(ctx context.Context, env envelope.FrameEnvelope) (stats.AppendResult, error) {
|
||||
if a.delegate == nil {
|
||||
return stats.AppendResult{}, nil
|
||||
}
|
||||
attempts := a.attempts
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
var result stats.AppendResult
|
||||
var err error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
result, err = appendStatEnvelope(ctx, a.delegate, env)
|
||||
if err == nil || !isTransientMySQLStatError(err) {
|
||||
return result, err
|
||||
}
|
||||
if attempt == attempts {
|
||||
a.recordRetry("single", "exhausted")
|
||||
return result, err
|
||||
}
|
||||
a.recordRetry("single", "retry")
|
||||
if a.delay <= 0 {
|
||||
continue
|
||||
}
|
||||
timer := time.NewTimer(a.delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return result, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (a retryStatAppender) recordRetry(operation string, status string) {
|
||||
if a.registry == nil {
|
||||
return
|
||||
}
|
||||
started := time.Now()
|
||||
err := appender.Append(messageCtx, env)
|
||||
recordStatWriteDuration(registry, message, statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "error")
|
||||
logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
labels := metrics.Labels{"operation": operation, "status": status}
|
||||
a.registry.IncCounter("vehicle_stat_write_retries_total", labels)
|
||||
metrics.RecordLastActivity(a.registry, "vehicle_stat_last_write_retry_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func (a retryStatAppender) CacheStats() stats.CacheStats {
|
||||
if reporter, ok := a.delegate.(statCacheReporter); ok {
|
||||
return reporter.CacheStats()
|
||||
}
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "ok")
|
||||
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
|
||||
logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
return
|
||||
return stats.CacheStats{}
|
||||
}
|
||||
|
||||
func isTransientMySQLStatError(err error) bool {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(text, "deadlock") ||
|
||||
strings.Contains(text, "error 1213") ||
|
||||
strings.Contains(text, "40001") ||
|
||||
strings.Contains(text, "lock wait timeout") ||
|
||||
strings.Contains(text, "error 1205") ||
|
||||
strings.Contains(text, "timeout") ||
|
||||
strings.Contains(text, "temporary") ||
|
||||
strings.Contains(text, "temporarily") ||
|
||||
strings.Contains(text, "connection refused") ||
|
||||
strings.Contains(text, "connection reset") ||
|
||||
strings.Contains(text, "connection closed") ||
|
||||
strings.Contains(text, "broken pipe") ||
|
||||
strings.Contains(text, "bad connection") ||
|
||||
strings.Contains(text, "invalid connection") ||
|
||||
strings.Contains(text, "i/o timeout") ||
|
||||
text == "eof" ||
|
||||
strings.Contains(text, "unexpected eof") ||
|
||||
strings.Contains(text, "server is down") ||
|
||||
strings.Contains(text, "network is unreachable") ||
|
||||
strings.Contains(text, "no route to host")
|
||||
}
|
||||
|
||||
func addStatMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"topic": message.Topic, "status": status})
|
||||
labels := metrics.Labels{"topic": message.Topic, "status": status}
|
||||
registry.IncCounter(name, labels)
|
||||
switch name {
|
||||
case "vehicle_stat_kafka_messages_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_last_message_unix_seconds", labels)
|
||||
case "vehicle_stat_writes_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_last_write_unix_seconds", labels)
|
||||
case "vehicle_stat_kafka_commits_total":
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_last_commit_unix_seconds", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func recordStatSampleMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
|
||||
addStatSampleMetric(registry, message, protocol, "found", result.SamplesFound)
|
||||
addStatSampleMetric(registry, message, protocol, "written", result.SamplesWritten)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_fields", result.SamplesSkippedMissingFields)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_vin", result.SamplesSkippedMissingVIN)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_mileage", result.SamplesSkippedMissingMileage)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_non_mileage_frame", result.SamplesSkippedNonMileageFrame)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_non_positive_mileage", result.SamplesSkippedNonPositiveMileage)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_time", result.SamplesSkippedMissingTime)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_same_mileage", result.SamplesSkippedSameMileage)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_source", result.SamplesSkippedMissingSource)
|
||||
addStatSampleMetric(registry, message, protocol, "event_time_future_adjusted", result.SamplesAdjustedFutureEventTime)
|
||||
}
|
||||
|
||||
func addStatSampleMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
|
||||
if registry == nil || count == 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_stat_samples_total", metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"protocol": statProtocolLabel(protocol),
|
||||
"status": status,
|
||||
}, float64(count))
|
||||
}
|
||||
|
||||
func recordStatSourceMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
|
||||
addStatSourceMetric(registry, message, protocol, "attempted", result.SourceTouchesAttempted)
|
||||
addStatSourceMetric(registry, message, protocol, "written", result.SourceTouchesWritten)
|
||||
addStatSourceMetric(registry, message, protocol, "skipped_throttled", result.SourceTouchesSkippedThrottled)
|
||||
addStatSourceMetric(registry, message, protocol, "skipped_missing_endpoint", result.SourceTouchesSkippedMissing)
|
||||
addStatSourceMetric(registry, message, protocol, "skipped_unmanaged", result.SourceTouchesSkippedUnmanaged)
|
||||
}
|
||||
|
||||
func addStatSourceMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
|
||||
if registry == nil || count == 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_stat_sources_total", metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"protocol": statProtocolLabel(protocol),
|
||||
"status": status,
|
||||
}, float64(count))
|
||||
}
|
||||
|
||||
func recordStatProjectionMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
|
||||
addStatProjectionMetric(registry, message, protocol, "attempted", result.ProjectionsAttempted)
|
||||
addStatProjectionMetric(registry, message, protocol, "written", result.ProjectionsWritten)
|
||||
addStatProjectionMetric(registry, message, protocol, "skipped_throttled", result.ProjectionsSkippedThrottled)
|
||||
}
|
||||
|
||||
func addStatProjectionMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
|
||||
if registry == nil || count == 0 {
|
||||
return
|
||||
}
|
||||
registry.AddCounter("vehicle_stat_projections_total", metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"protocol": statProtocolLabel(protocol),
|
||||
"status": status,
|
||||
}, float64(count))
|
||||
}
|
||||
|
||||
func statProtocolLabel(protocol envelope.Protocol) string {
|
||||
protocolLabel := strings.TrimSpace(string(protocol))
|
||||
if protocolLabel == "" {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
return protocolLabel
|
||||
}
|
||||
|
||||
func recordStatCacheMetrics(registry *metrics.Registry, appender statAppender) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
reporter, ok := appender.(statCacheReporter)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
stats := reporter.CacheStats()
|
||||
setStatCacheGauge(registry, "last_total_mileage", stats.LastTotalMileageEntries, stats.MaxEntries, stats.LastCleanupTotalMileage, stats.TotalMileageEvictions)
|
||||
setStatCacheGauge(registry, "source_seen", stats.LastSourceSeenEntries, stats.MaxEntries, stats.LastCleanupSourceSeen, stats.SourceSeenEvictions)
|
||||
setStatCacheGauge(registry, "projection", stats.LastProjectionEntries, stats.MaxEntries, stats.LastCleanupProjection, stats.ProjectionEvictions)
|
||||
setStatCacheGauge(registry, "baseline", stats.BaselineEntries, stats.MaxEntries, stats.LastCleanupBaseline, stats.BaselineEvictions)
|
||||
if !stats.LastCleanupAt.IsZero() {
|
||||
registry.SetGauge("vehicle_stat_cache_last_cleanup_unix_seconds", nil, float64(stats.LastCleanupAt.Unix()))
|
||||
}
|
||||
}
|
||||
|
||||
func setStatCacheGauge(registry *metrics.Registry, cache string, entries int, maxEntries int, lastCleanupDeleted int, evictions int) {
|
||||
labels := metrics.Labels{"cache": cache}
|
||||
registry.SetGauge("vehicle_stat_cache_entries", labels, float64(entries))
|
||||
registry.SetGauge("vehicle_stat_cache_max_entries", labels, float64(maxEntries))
|
||||
registry.SetGauge("vehicle_stat_cache_last_cleanup_deleted", labels, float64(lastCleanupDeleted))
|
||||
registry.SetGauge("vehicle_stat_cache_evictions_total", labels, float64(evictions))
|
||||
}
|
||||
|
||||
func addStatLagMetric(registry *metrics.Registry, message kafka.Message) {
|
||||
@@ -142,6 +640,33 @@ func recordStatWriteDuration(registry *metrics.Registry, message kafka.Message,
|
||||
}, statWriteDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func recordStatWriteE2EDuration(registry *metrics.Registry, message kafka.Message, env envelope.FrameEnvelope) {
|
||||
if registry == nil || env.ReceivedAtMS <= 0 {
|
||||
return
|
||||
}
|
||||
elapsed := time.Since(time.UnixMilli(env.ReceivedAtMS)).Milliseconds()
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
labels := metrics.Labels{"topic": message.Topic}
|
||||
registry.ObserveHistogram("vehicle_stat_write_e2e_duration_ms_histogram", labels, statWriteE2EDurationBucketsMS, float64(elapsed))
|
||||
p99, samples := statWriteE2ERecent.Observe(message.Topic, float64(elapsed))
|
||||
registry.SetGauge("vehicle_stat_write_e2e_recent_p99_ms", labels, p99)
|
||||
registry.SetGauge("vehicle_stat_write_e2e_recent_samples", labels, float64(samples))
|
||||
metrics.RecordLastActivity(registry, "vehicle_stat_last_write_e2e_unix_seconds", labels)
|
||||
}
|
||||
|
||||
func setStatBatchPending(registry *metrics.Registry, messages int) {
|
||||
setStatBatchPendingForWorker(registry, nil, messages)
|
||||
}
|
||||
|
||||
func setStatBatchPendingForWorker(registry *metrics.Registry, workerLabels metrics.Labels, messages int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_stat_batch_pending_messages", workerLabels, float64(messages))
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
if err != nil {
|
||||
return "error"
|
||||
@@ -150,12 +675,40 @@ func statusFromError(err error) string {
|
||||
}
|
||||
|
||||
type config struct {
|
||||
KafkaBrokers []string
|
||||
KafkaTopics []string
|
||||
KafkaGroup string
|
||||
MySQLDSN string
|
||||
EnsureSchema bool
|
||||
Location *time.Location
|
||||
KafkaBrokers []string
|
||||
KafkaTopics []string
|
||||
KafkaGroup string
|
||||
MySQLDSN string
|
||||
EnsureSchema bool
|
||||
Location *time.Location
|
||||
ProjectInterval time.Duration
|
||||
SourceTouchInterval time.Duration
|
||||
CacheRetention time.Duration
|
||||
CacheCleanupInterval time.Duration
|
||||
BaselineMissTTL time.Duration
|
||||
CacheMaxEntries int
|
||||
Workers int
|
||||
BatchSize int
|
||||
BatchWait int
|
||||
RetryAttempts int
|
||||
RetryDelay time.Duration
|
||||
NormalizePlatformSourcesOnStart bool
|
||||
NormalizePlatformSourcesTimeout time.Duration
|
||||
}
|
||||
|
||||
func (c config) Validate() error {
|
||||
if len(c.KafkaTopics) == 0 {
|
||||
return fmt.Errorf("KAFKA_TOPICS must include fields topics")
|
||||
}
|
||||
for _, topic := range c.KafkaTopics {
|
||||
if !strings.HasPrefix(topic, "vehicle.fields.") {
|
||||
return fmt.Errorf("stat-writer consumes fields topics only, got %q", topic)
|
||||
}
|
||||
}
|
||||
if c.Workers <= 0 {
|
||||
return fmt.Errorf("STATS_WORKERS must be greater than zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -164,12 +717,25 @@ func loadConfig() config {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
return config{
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
KafkaTopics: splitCSV(env("KAFKA_TOPICS", strings.Join([]string{topics.FieldsGB32960, topics.FieldsJT808, topics.FieldsYutongMQTT}, ","))),
|
||||
KafkaGroup: env("KAFKA_GROUP", "go-stat-writer"),
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
EnsureSchema: env("MYSQL_ENSURE_SCHEMA", "true") != "false",
|
||||
Location: loc,
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
KafkaTopics: splitCSV(env("KAFKA_TOPICS", strings.Join([]string{topics.FieldsGB32960, topics.FieldsJT808, topics.FieldsYutongMQTT}, ","))),
|
||||
KafkaGroup: env("KAFKA_GROUP", "go-stat-writer"),
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
EnsureSchema: env("MYSQL_ENSURE_SCHEMA", "true") != "false",
|
||||
Location: loc,
|
||||
ProjectInterval: time.Duration(envInt("STATS_PROJECT_INTERVAL_SECONDS", 15)) * time.Second,
|
||||
SourceTouchInterval: time.Duration(envInt("STATS_SOURCE_TOUCH_INTERVAL_SECONDS", 60)) * time.Second,
|
||||
CacheRetention: time.Duration(envInt("STATS_CACHE_RETENTION_HOURS", 72)) * time.Hour,
|
||||
CacheCleanupInterval: time.Duration(envInt("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
BaselineMissTTL: time.Duration(envInt("STATS_BASELINE_MISS_TTL_SECONDS", 60)) * time.Second,
|
||||
CacheMaxEntries: envInt("STATS_CACHE_MAX_ENTRIES", 1000000),
|
||||
Workers: envInt("STATS_WORKERS", 3),
|
||||
BatchSize: envInt("STATS_BATCH_SIZE", 200),
|
||||
BatchWait: envInt("STATS_BATCH_WAIT_MS", 20),
|
||||
RetryAttempts: envInt("STATS_RETRY_ATTEMPTS", 3),
|
||||
RetryDelay: time.Duration(envInt("STATS_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
||||
NormalizePlatformSourcesOnStart: env("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "true") != "false",
|
||||
NormalizePlatformSourcesTimeout: time.Duration(envInt("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", 30)) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +747,18 @@ func env(key string, fallback string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,20 +22,21 @@ import (
|
||||
)
|
||||
|
||||
type config struct {
|
||||
MySQLDSN string
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
DateFrom string
|
||||
DateTo string
|
||||
Protocols []envelope.Protocol
|
||||
Method string
|
||||
Limit int
|
||||
DryRun bool
|
||||
Reset bool
|
||||
Debug bool
|
||||
ProgressEvery int64
|
||||
Location *time.Location
|
||||
MySQLDSN string
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
DateFrom string
|
||||
DateTo string
|
||||
Protocols []envelope.Protocol
|
||||
Method string
|
||||
Limit int
|
||||
DryRun bool
|
||||
Reset bool
|
||||
Debug bool
|
||||
EventTimeFullScan bool
|
||||
ProgressEvery int64
|
||||
Location *time.Location
|
||||
}
|
||||
|
||||
type rawFrameRow struct {
|
||||
@@ -62,6 +63,9 @@ type metricAgg struct {
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
SourceCode string
|
||||
PlatformName string
|
||||
SourceKind string
|
||||
FirstEventTime time.Time
|
||||
LatestEventTime time.Time
|
||||
QualityStatus string
|
||||
@@ -74,6 +78,9 @@ type dailySourceLast struct {
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
SourceCode string
|
||||
PlatformName string
|
||||
SourceKind string
|
||||
FirstTS time.Time
|
||||
TS time.Time
|
||||
FirstTotalKM float64
|
||||
@@ -81,8 +88,19 @@ type dailySourceLast struct {
|
||||
RawSampleCount int64
|
||||
}
|
||||
|
||||
type sourceHistoryID struct {
|
||||
VIN string
|
||||
SourceKey string
|
||||
}
|
||||
|
||||
var defaultBackfillEnvFiles = []string{
|
||||
"/opt/lingniu-go-native/env/base.env",
|
||||
"/opt/lingniu-go-native/env/stat-writer.env",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := loadEnvFiles(env("BACKFILL_ENV_FILES", os.Getenv("BACKFILL_ENV_FILE"))); err != nil {
|
||||
envFiles := backfillEnvFiles(os.Getenv("BACKFILL_ENV_FILES"), os.Getenv("BACKFILL_ENV_FILE"), defaultBackfillEnvFiles)
|
||||
if err := loadEnvFiles(envFiles); err != nil {
|
||||
fail("load env file", err)
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
@@ -121,18 +139,27 @@ func main() {
|
||||
slog.Info("reset stats rows", "deleted", deleted)
|
||||
}
|
||||
if cfg.Method == "last_diff" {
|
||||
aggregates, err := buildLastDiffAggregates(ctx, td, cfg)
|
||||
aggregates, err := buildLastDiffAggregates(ctx, mysqlDB, td, cfg)
|
||||
if err != nil {
|
||||
fail("build last-diff aggregates", err)
|
||||
}
|
||||
fallbacks, err := addRealtimeLocationFallbackAggregates(ctx, mysqlDB, td, cfg, aggregates)
|
||||
if err != nil {
|
||||
fail("build realtime-location fallback aggregates", err)
|
||||
}
|
||||
var written int64
|
||||
var normalized int
|
||||
if !cfg.DryRun {
|
||||
written, err = writeAggregates(ctx, mysqlDB, aggregates, 500)
|
||||
if err != nil {
|
||||
fail("write aggregates", err)
|
||||
}
|
||||
normalized, err = normalizeHistoricalPlatformSources(ctx, mysqlDB, cfg)
|
||||
if err != nil {
|
||||
fail("normalize historical platform sources", err)
|
||||
}
|
||||
}
|
||||
slog.Info("stats backfill complete", "method", cfg.Method, "dry_run", cfg.DryRun, "aggregates", len(aggregates), "written", written)
|
||||
slog.Info("stats backfill complete", "method", cfg.Method, "dry_run", cfg.DryRun, "aggregates", len(aggregates), "realtimeLocationFallbacks", fallbacks, "written", written, "platformSourcesNormalized", normalized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -234,6 +261,7 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
SourceEndpoint: sample.SourceEndpoint,
|
||||
PlatformName: sample.PlatformName,
|
||||
FirstEventTime: sample.EventTime,
|
||||
LatestEventTime: sample.EventTime,
|
||||
QualityStatus: stats.QualityOK,
|
||||
@@ -255,6 +283,9 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
if strings.TrimSpace(sample.DeviceID) != "" {
|
||||
agg.DeviceID = sample.DeviceID
|
||||
}
|
||||
if strings.TrimSpace(sample.PlatformName) != "" {
|
||||
agg.PlatformName = sample.PlatformName
|
||||
}
|
||||
}
|
||||
agg.Count++
|
||||
}
|
||||
@@ -271,6 +302,9 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
Protocol: agg.Protocol,
|
||||
SourceIP: stats.NormalizeSourceIP(agg.SourceEndpoint),
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
SourceCode: agg.SourceCode,
|
||||
PlatformName: agg.PlatformName,
|
||||
SourceKind: agg.SourceKind,
|
||||
}
|
||||
if strings.TrimSpace(identity.SourceIP) == "" {
|
||||
continue
|
||||
@@ -285,7 +319,7 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil {
|
||||
return written, err
|
||||
}
|
||||
dailyKM := agg.LatestKM - agg.FirstKM
|
||||
dailyKM := stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM)
|
||||
candidate := stats.SourceMileageSample{
|
||||
VIN: agg.VIN,
|
||||
StatDate: agg.Date,
|
||||
@@ -295,6 +329,7 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
Phone: agg.Phone,
|
||||
DeviceID: agg.DeviceID,
|
||||
PlatformName: agg.PlatformName,
|
||||
FirstTotalKM: agg.FirstKM,
|
||||
LatestTotalKM: agg.LatestKM,
|
||||
DailyKM: dailyKM,
|
||||
@@ -310,10 +345,7 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
if candidate.QualityReason == "" {
|
||||
candidate.QualityReason = "same_source_previous_day"
|
||||
}
|
||||
if candidate.QualityStatus == stats.QualityOK && (candidate.DailyKM < 0 || candidate.DailyKM > maxTrustedDailyMileageKM) {
|
||||
candidate.QualityStatus = stats.QualityInvalidDelta
|
||||
candidate.QualityReason = "outside_daily_range"
|
||||
}
|
||||
stats.ApplyMileageQualityRules(&candidate)
|
||||
if err := stats.UpsertSourceMileage(ctx, db, candidate); err != nil {
|
||||
return written, err
|
||||
}
|
||||
@@ -325,6 +357,27 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func normalizeHistoricalPlatformSources(ctx context.Context, db *sql.DB, cfg config) (int, error) {
|
||||
dates, err := dateRange(cfg.DateFrom, cfg.DateTo)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
normalized := 0
|
||||
for _, protocol := range cfg.Protocols {
|
||||
if protocol != envelope.ProtocolJT808 {
|
||||
continue
|
||||
}
|
||||
for _, date := range dates {
|
||||
count, err := stats.NormalizePlatformSourceMileageForDate(ctx, db, date, protocol)
|
||||
if err != nil {
|
||||
return normalized, err
|
||||
}
|
||||
normalized += count
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func clearBackfillTargetMileage(ctx context.Context, db *sql.DB, vin string, statDate string, protocol envelope.Protocol) error {
|
||||
if db == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(string(protocol)) == "" {
|
||||
return nil
|
||||
@@ -337,47 +390,173 @@ func clearBackfillTargetMileage(ctx context.Context, db *sql.DB, vin string, sta
|
||||
return err
|
||||
}
|
||||
|
||||
func buildLastDiffAggregates(ctx context.Context, db *sql.DB, cfg config) (map[string]*metricAgg, error) {
|
||||
func buildLastDiffAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config) (map[string]*metricAgg, error) {
|
||||
targetDates, err := dateRange(cfg.DateFrom, cfg.DateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aggregates := map[string]*metricAgg{}
|
||||
for _, protocol := range cfg.Protocols {
|
||||
latestHistory := map[sourceHistoryID]dailySourceLast{}
|
||||
preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rememberLatestSourceRows(latestHistory, preWindow)
|
||||
slog.Info("pre-window baseline loaded", "protocol", protocol, "before_date", targetDates[0], "vehicles", len(preWindow))
|
||||
for _, date := range targetDates {
|
||||
current, err := queryDailyLastSourceRows(ctx, db, cfg, protocol, date)
|
||||
current, err := queryDailyLastSourceRows(ctx, tdDB, cfg, protocol, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previous, err := queryPreviousLastSourceRows(ctx, db, cfg, protocol, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("daily last loaded", "protocol", protocol, "date", date, "vehicles", len(current), "previousVehicles", len(previous))
|
||||
slog.Info("daily last loaded", "protocol", protocol, "date", date, "vehicles", len(current), "historicalSources", len(latestHistory))
|
||||
for vin, currentSources := range current {
|
||||
previousBySource := map[string]dailySourceLast{}
|
||||
for _, previousRow := range previous[vin] {
|
||||
previousBySource[previousRow.SourceKey] = previousRow
|
||||
}
|
||||
for _, currentRow := range currentSources {
|
||||
key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey
|
||||
previousRow, hasPrevious := previousBySource[currentRow.SourceKey]
|
||||
previousRow, hasPrevious, err := resolvePreviousSourceRow(ctx, mysqlDB, date, protocol, latestHistory, currentRow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aggregates[key] = aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious)
|
||||
}
|
||||
}
|
||||
// Current-day last values become eligible only for later target dates.
|
||||
rememberLatestSourceRows(latestHistory, current)
|
||||
}
|
||||
}
|
||||
return aggregates, nil
|
||||
}
|
||||
|
||||
func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) {
|
||||
if aggregates == nil {
|
||||
return 0, fmt.Errorf("aggregates map is nil")
|
||||
}
|
||||
targetDates, err := dateRange(cfg.DateFrom, cfg.DateTo)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var added int
|
||||
for _, protocol := range cfg.Protocols {
|
||||
if protocol != envelope.ProtocolYutongMQTT {
|
||||
continue
|
||||
}
|
||||
latestHistory := map[sourceHistoryID]dailySourceLast{}
|
||||
preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0])
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
rememberLatestSourceRows(latestHistory, preWindow)
|
||||
aggregateRowsByDate := indexAggregateSourceRowsByDate(aggregates, protocol)
|
||||
for _, date := range targetDates {
|
||||
current, err := queryRealtimeLocationLastRows(ctx, mysqlDB, cfg, protocol, date)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
for vin, currentSources := range current {
|
||||
for _, currentRow := range currentSources {
|
||||
key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey
|
||||
if _, exists := aggregates[key]; exists {
|
||||
continue
|
||||
}
|
||||
previousRow, hasPrevious, err := resolvePreviousSourceRow(ctx, mysqlDB, date, protocol, latestHistory, currentRow)
|
||||
if err != nil {
|
||||
return added, err
|
||||
}
|
||||
agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious)
|
||||
if hasPrevious {
|
||||
agg.QualityReason = "realtime_location_fallback_historical_baseline"
|
||||
} else {
|
||||
agg.QualityReason = "realtime_location_fallback_current_day_first_baseline"
|
||||
}
|
||||
aggregates[key] = agg
|
||||
added++
|
||||
}
|
||||
}
|
||||
rememberLatestSourceRows(latestHistory, aggregateRowsByDate[date])
|
||||
rememberLatestSourceRows(latestHistory, current)
|
||||
if len(current) > 0 {
|
||||
slog.Info("realtime location fallback loaded", "protocol", protocol, "date", date, "vehicles", len(current), "added", added)
|
||||
}
|
||||
}
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
func indexAggregateSourceRowsByDate(aggregates map[string]*metricAgg, protocol envelope.Protocol) map[string]map[string][]dailySourceLast {
|
||||
indexed := map[string]map[string][]dailySourceLast{}
|
||||
for _, agg := range aggregates {
|
||||
if agg == nil || agg.Protocol != protocol || strings.TrimSpace(agg.Date) == "" {
|
||||
continue
|
||||
}
|
||||
rows := indexed[agg.Date]
|
||||
if rows == nil {
|
||||
rows = map[string][]dailySourceLast{}
|
||||
indexed[agg.Date] = rows
|
||||
}
|
||||
rows[agg.VIN] = append(rows[agg.VIN], dailySourceLast{
|
||||
VIN: agg.VIN,
|
||||
SourceKey: agg.SourceKey,
|
||||
Phone: agg.Phone,
|
||||
DeviceID: agg.DeviceID,
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
SourceCode: agg.SourceCode,
|
||||
PlatformName: agg.PlatformName,
|
||||
SourceKind: agg.SourceKind,
|
||||
FirstTS: agg.LatestEventTime,
|
||||
TS: agg.LatestEventTime,
|
||||
FirstTotalKM: agg.LatestKM,
|
||||
TotalKM: agg.LatestKM,
|
||||
RawSampleCount: agg.Count,
|
||||
})
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
func rememberLatestSourceRows(history map[sourceHistoryID]dailySourceLast, rows map[string][]dailySourceLast) {
|
||||
for vin, sourceRows := range rows {
|
||||
for _, row := range sourceRows {
|
||||
rowVIN := strings.TrimSpace(row.VIN)
|
||||
if rowVIN == "" {
|
||||
rowVIN = strings.TrimSpace(vin)
|
||||
row.VIN = rowVIN
|
||||
}
|
||||
id := sourceHistoryID{VIN: rowVIN, SourceKey: strings.TrimSpace(row.SourceKey)}
|
||||
if id.VIN == "" || id.SourceKey == "" || row.TS.IsZero() {
|
||||
continue
|
||||
}
|
||||
if existing, ok := history[id]; !ok || row.TS.After(existing.TS) {
|
||||
history[id] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func latestHistoricalSourceRow(history map[sourceHistoryID]dailySourceLast, current dailySourceLast) (dailySourceLast, bool) {
|
||||
id := sourceHistoryID{
|
||||
VIN: strings.TrimSpace(current.VIN),
|
||||
SourceKey: strings.TrimSpace(current.SourceKey),
|
||||
}
|
||||
previous, ok := history[id]
|
||||
return previous, ok && previous.TS.Before(current.TS)
|
||||
}
|
||||
|
||||
func resolvePreviousSourceRow(ctx context.Context, db *sql.DB, date string, protocol envelope.Protocol, history map[sourceHistoryID]dailySourceLast, current dailySourceLast) (dailySourceLast, bool, error) {
|
||||
if previous, ok := latestHistoricalSourceRow(history, current); ok {
|
||||
return previous, true, nil
|
||||
}
|
||||
return queryDurablePreviousSourceRow(ctx, db, date, protocol, current)
|
||||
}
|
||||
|
||||
func aggregateFromDailySource(date string, protocol envelope.Protocol, current dailySourceLast, previous dailySourceLast, hasPrevious bool) *metricAgg {
|
||||
firstKM := current.FirstTotalKM
|
||||
firstEventTime := current.FirstTS
|
||||
qualityReason := "current_day_first_sample"
|
||||
qualityStatus := stats.QualityOK
|
||||
qualityReason := stats.QualityReasonCurrentDayFirst
|
||||
if hasPrevious {
|
||||
firstKM = previous.TotalKM
|
||||
firstEventTime = previous.TS
|
||||
qualityReason = "historical_source_baseline"
|
||||
qualityStatus = stats.QualityOK
|
||||
qualityReason = stats.QualityReasonHistorical
|
||||
}
|
||||
if firstEventTime.IsZero() {
|
||||
firstEventTime = current.TS
|
||||
@@ -386,7 +565,7 @@ func aggregateFromDailySource(date string, protocol envelope.Protocol, current d
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
return &metricAgg{
|
||||
agg := &metricAgg{
|
||||
VIN: current.VIN,
|
||||
Date: date,
|
||||
Protocol: protocol,
|
||||
@@ -397,11 +576,43 @@ func aggregateFromDailySource(date string, protocol envelope.Protocol, current d
|
||||
Phone: current.Phone,
|
||||
DeviceID: current.DeviceID,
|
||||
SourceEndpoint: current.SourceEndpoint,
|
||||
SourceCode: current.SourceCode,
|
||||
PlatformName: current.PlatformName,
|
||||
SourceKind: current.SourceKind,
|
||||
FirstEventTime: firstEventTime,
|
||||
LatestEventTime: current.TS,
|
||||
QualityStatus: stats.QualityOK,
|
||||
QualityStatus: qualityStatus,
|
||||
QualityReason: qualityReason,
|
||||
}
|
||||
candidate := stats.SourceMileageSample{
|
||||
FirstTotalKM: agg.FirstKM,
|
||||
LatestTotalKM: agg.LatestKM,
|
||||
DailyKM: stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM),
|
||||
FirstEventTime: agg.FirstEventTime,
|
||||
LatestEventTime: agg.LatestEventTime,
|
||||
QualityStatus: agg.QualityStatus,
|
||||
QualityReason: agg.QualityReason,
|
||||
}
|
||||
stats.ApplyMileageQualityRules(&candidate)
|
||||
agg.QualityStatus = candidate.QualityStatus
|
||||
agg.QualityReason = candidate.QualityReason
|
||||
return agg
|
||||
}
|
||||
|
||||
func queryDurablePreviousSourceRow(ctx context.Context, db *sql.DB, date string, protocol envelope.Protocol, current dailySourceLast) (dailySourceLast, bool, error) {
|
||||
if db == nil {
|
||||
return dailySourceLast{}, false, nil
|
||||
}
|
||||
totalKM, eventTime, found, err := stats.LookupLatestSourceBaselineBefore(ctx, db, current.VIN, date, protocol, current.SourceKey)
|
||||
if err != nil || !found {
|
||||
return dailySourceLast{}, found, err
|
||||
}
|
||||
previous := current
|
||||
previous.FirstTS = eventTime
|
||||
previous.TS = eventTime
|
||||
previous.FirstTotalKM = totalKM
|
||||
previous.TotalKM = totalKM
|
||||
return previous, true, nil
|
||||
}
|
||||
|
||||
type trustedChoice struct {
|
||||
@@ -409,8 +620,6 @@ type trustedChoice struct {
|
||||
previous dailySourceLast
|
||||
}
|
||||
|
||||
const maxTrustedDailyMileageKM = 1000
|
||||
|
||||
func chooseTrustedSource(current []dailySourceLast, previous []dailySourceLast) (trustedChoice, bool) {
|
||||
previousBySource := map[string]dailySourceLast{}
|
||||
for _, row := range previous {
|
||||
@@ -423,8 +632,8 @@ func chooseTrustedSource(current []dailySourceLast, previous []dailySourceLast)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delta := currentRow.TotalKM - previousRow.TotalKM
|
||||
if delta < 0 || delta > maxTrustedDailyMileageKM {
|
||||
delta, ok, _ := stats.NormalizeDailyMileageDeltaForWindow(stats.DailyMileageFromDayBoundary(previousRow.TotalKM, currentRow.TotalKM), previousRow.TS, currentRow.TS)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if chosen.current.SourceKey == "" || delta < chosenDelta {
|
||||
@@ -436,19 +645,18 @@ func chooseTrustedSource(current []dailySourceLast, previous []dailySourceLast)
|
||||
}
|
||||
|
||||
func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
|
||||
where := []string{
|
||||
fmt.Sprintf("ts >= '%s 00:00:00'", quote(date)),
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(date))),
|
||||
where := backfillTimePredicates(cfg, date, nextDate(date))
|
||||
where = append(where,
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
"vin <> ''",
|
||||
fmt.Sprintf("protocol = '%s'", quote(string(protocol))),
|
||||
realtimeMileageFramePredicate(),
|
||||
}
|
||||
)
|
||||
if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(ts), FIRST(parsed_json), LAST(ts), LAST(parsed_json), COUNT(*)
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(event_time), FIRST(parsed_json), LAST(event_time), LAST(parsed_json), COUNT(*)
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
@@ -456,24 +664,103 @@ GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), s
|
||||
}
|
||||
|
||||
func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
|
||||
where := []string{
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(date)),
|
||||
// The baseline is the nearest earlier sample, not necessarily yesterday's.
|
||||
// LAST aggregates the complete pre-window history once per source so empty
|
||||
// calendar days do not make a backfill fall back to the current day's first
|
||||
// sample.
|
||||
where := backfillBeforePredicates(date)
|
||||
where = append(where,
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
"vin <> ''",
|
||||
fmt.Sprintf("protocol = '%s'", quote(string(protocol))),
|
||||
realtimeMileageFramePredicate(),
|
||||
}
|
||||
)
|
||||
if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
|
||||
where = append(where, predicate)
|
||||
}
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(ts), LAST(parsed_json), COUNT(*)
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(event_time), LAST(parsed_json), COUNT(*)
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
return querySourceRows(ctx, db, cfg, protocol, sqlText, false)
|
||||
}
|
||||
|
||||
func backfillBeforePredicates(eventDateExclusive string) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateExclusive)),
|
||||
}
|
||||
}
|
||||
|
||||
func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
|
||||
if db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
loc := cfg.Location
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
sqlText := `SELECT l.vin, COALESCE(NULLIF(s.peer, ''), ''), l.total_mileage_event_time, l.total_mileage_km
|
||||
FROM vehicle_realtime_location l
|
||||
LEFT JOIN vehicle_realtime_snapshot s ON s.protocol = l.protocol AND s.vin = l.vin
|
||||
WHERE l.protocol = ?
|
||||
AND l.vin IS NOT NULL AND l.vin <> ''
|
||||
AND l.total_mileage_km IS NOT NULL AND l.total_mileage_km > 0
|
||||
AND l.total_mileage_event_time >= ?
|
||||
AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)`
|
||||
rows, err := db.QueryContext(ctx, sqlText, string(protocol), date, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string][]dailySourceLast{}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
var peer sql.NullString
|
||||
var eventTime time.Time
|
||||
var totalKM float64
|
||||
if err := rows.Scan(&vin, &peer, &eventTime, &totalKM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.TrimSpace(vin)
|
||||
if vin == "" || totalKM <= 0 {
|
||||
continue
|
||||
}
|
||||
sourceEndpoint := strings.TrimSpace(peer.String)
|
||||
if sourceEndpoint == "" && protocol == envelope.ProtocolYutongMQTT {
|
||||
sourceEndpoint = "mqtt://yutong/realtime-location"
|
||||
}
|
||||
deviceID := ""
|
||||
if protocol == envelope.ProtocolYutongMQTT {
|
||||
deviceID = vin
|
||||
}
|
||||
sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint)
|
||||
sourceKey := normalizedSourceKeyForSource(string(protocol), "", deviceID, sourceEndpoint, sourceKind, sourceCode)
|
||||
if sourceKey == "" {
|
||||
continue
|
||||
}
|
||||
row := dailySourceLast{
|
||||
VIN: vin,
|
||||
SourceKey: sourceKey,
|
||||
DeviceID: deviceID,
|
||||
SourceEndpoint: sourceEndpoint,
|
||||
SourceCode: sourceCode,
|
||||
PlatformName: platformName,
|
||||
SourceKind: sourceKind,
|
||||
FirstTS: eventTime.In(loc),
|
||||
TS: eventTime.In(loc),
|
||||
FirstTotalKM: totalKM,
|
||||
TotalKM: totalKM,
|
||||
RawSampleCount: 1,
|
||||
}
|
||||
out[vin] = append(out[vin], row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, sqlText string, includeFirst bool) (map[string][]dailySourceLast, error) {
|
||||
rows, err := db.QueryContext(ctx, sqlText)
|
||||
if err != nil {
|
||||
@@ -512,7 +799,8 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
|
||||
firstTotalKM = parsedFirst
|
||||
}
|
||||
}
|
||||
sourceKey := normalizedSourceKey(string(protocol), phone, deviceID, sourceEndpoint)
|
||||
sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint)
|
||||
sourceKey := normalizedSourceKeyForSource(string(protocol), phone, deviceID, sourceEndpoint, sourceKind, sourceCode)
|
||||
if sourceKey == "" {
|
||||
continue
|
||||
}
|
||||
@@ -522,8 +810,11 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
|
||||
Phone: strings.TrimSpace(phone),
|
||||
DeviceID: strings.TrimSpace(deviceID),
|
||||
SourceEndpoint: strings.TrimSpace(sourceEndpoint),
|
||||
SourceCode: sourceCode,
|
||||
PlatformName: platformName,
|
||||
SourceKind: sourceKind,
|
||||
FirstTS: firstTS.In(cfg.Location),
|
||||
TS: ts,
|
||||
TS: ts.In(cfg.Location),
|
||||
FirstTotalKM: firstTotalKM,
|
||||
TotalKM: latestTotalKM,
|
||||
RawSampleCount: rawSampleCount,
|
||||
@@ -562,8 +853,19 @@ func mileageFromParsed(protocol envelope.Protocol, vin string, parsedJSON string
|
||||
}
|
||||
|
||||
func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string {
|
||||
return normalizedSourceKeyForSource(protocol, phone, deviceID, endpoint, "", "")
|
||||
}
|
||||
|
||||
func normalizedSourceKeyForSource(protocol string, phone string, deviceID string, endpoint string, sourceKind string, sourceCode string) string {
|
||||
sourceIP := stats.NormalizeSourceIP(endpoint)
|
||||
return stats.SourceKey(envelope.Protocol(protocol), phone, deviceID, sourceIP)
|
||||
return stats.SourceKeyForSource(envelope.Protocol(protocol), phone, deviceID, sourceIP, sourceKind, sourceCode)
|
||||
}
|
||||
|
||||
func knownPlatformSourceMetadata(protocol envelope.Protocol, endpoint string) (sourceCode string, platformName string, sourceKind string) {
|
||||
if protocol == envelope.ProtocolYutongMQTT && stats.NormalizeSourceIP(endpoint) == "mqtt" {
|
||||
return "yutong", "宇通", "PLATFORM"
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[string]any {
|
||||
@@ -574,7 +876,7 @@ func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[str
|
||||
}
|
||||
parsed := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err == nil && len(parsed) > 0 {
|
||||
if flattened, _, ok := realtime.ParsedFieldsForEnvelope(envelope.FrameEnvelope{
|
||||
if flattened, _, ok := realtime.ComputeParsedFieldsForEnvelope(envelope.FrameEnvelope{
|
||||
Protocol: protocol,
|
||||
VIN: vin,
|
||||
Parsed: parsed,
|
||||
@@ -604,7 +906,12 @@ func mileageKeys(protocol envelope.Protocol) []string {
|
||||
case envelope.ProtocolJT808:
|
||||
return []string{"jt808.location.total_mileage_km"}
|
||||
case envelope.ProtocolYutongMQTT:
|
||||
return []string{"yutong_mqtt.data.total_mileage", "yutong_mqtt.root.data.total_mileage"}
|
||||
return []string{
|
||||
"yutong_mqtt.data.total_mileage_km",
|
||||
"yutong_mqtt.root.data.total_mileage_km",
|
||||
"yutong_mqtt.data.total_mileage",
|
||||
"yutong_mqtt.root.data.total_mileage",
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -624,38 +931,67 @@ func loadConfig() (config, error) {
|
||||
if err != nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
dateTo := env("BACKFILL_DATE_TO", time.Now().In(loc).Format("2006-01-02"))
|
||||
dateFrom := env("BACKFILL_DATE_FROM", dateTo)
|
||||
dateFrom, dateTo := resolveBackfillDateRange(time.Now(), loc)
|
||||
protocols, err := parseProtocols(env("BACKFILL_PROTOCOLS", "GB32960,JT808,YUTONG_MQTT"))
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
return config{
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
Protocols: protocols,
|
||||
Method: env("BACKFILL_METHOD", "last_diff"),
|
||||
Limit: envInt("BACKFILL_LIMIT", 0),
|
||||
DryRun: envBool("BACKFILL_DRY_RUN", true),
|
||||
Reset: envBool("BACKFILL_RESET", false),
|
||||
Debug: envBool("BACKFILL_DEBUG", false),
|
||||
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
|
||||
Location: loc,
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
Protocols: protocols,
|
||||
Method: env("BACKFILL_METHOD", "last_diff"),
|
||||
Limit: envInt("BACKFILL_LIMIT", 0),
|
||||
DryRun: envBool("BACKFILL_DRY_RUN", true),
|
||||
Reset: envBool("BACKFILL_RESET", false),
|
||||
Debug: envBool("BACKFILL_DEBUG", false),
|
||||
EventTimeFullScan: envBool("BACKFILL_EVENT_TIME_FULL_SCAN", false),
|
||||
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
|
||||
Location: loc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveBackfillDateRange(now time.Time, loc *time.Location) (string, string) {
|
||||
if loc == nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
explicitFrom := strings.TrimSpace(os.Getenv("BACKFILL_DATE_FROM"))
|
||||
explicitTo := strings.TrimSpace(os.Getenv("BACKFILL_DATE_TO"))
|
||||
if explicitTo != "" || explicitFrom != "" {
|
||||
dateTo := explicitTo
|
||||
if dateTo == "" {
|
||||
dateTo = now.In(loc).Format("2006-01-02")
|
||||
}
|
||||
dateFrom := explicitFrom
|
||||
if dateFrom == "" {
|
||||
dateFrom = dateTo
|
||||
}
|
||||
return dateFrom, dateTo
|
||||
}
|
||||
daysBack := envInt("BACKFILL_DAYS_BACK", 0)
|
||||
if daysBack < 0 {
|
||||
daysBack = 0
|
||||
}
|
||||
windowDays := envInt("BACKFILL_WINDOW_DAYS", 1)
|
||||
if windowDays < 1 {
|
||||
windowDays = 1
|
||||
}
|
||||
dateToTime := now.In(loc).AddDate(0, 0, -daysBack)
|
||||
dateFromTime := dateToTime.AddDate(0, 0, -(windowDays - 1))
|
||||
return dateFromTime.Format("2006-01-02"), dateToTime.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, error) {
|
||||
where := []string{
|
||||
fmt.Sprintf("ts >= '%s 00:00:00'", quote(cfg.DateFrom)),
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(cfg.DateTo))),
|
||||
where := backfillTimePredicates(cfg, cfg.DateFrom, nextDate(cfg.DateTo))
|
||||
where = append(where,
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
"vin <> ''",
|
||||
}
|
||||
)
|
||||
if len(cfg.Protocols) > 0 {
|
||||
quoted := make([]string, 0, len(cfg.Protocols))
|
||||
for _, protocol := range cfg.Protocols {
|
||||
@@ -667,7 +1003,7 @@ func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, err
|
||||
sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
ORDER BY ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
ORDER BY event_time ASC, ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
if cfg.Limit > 0 {
|
||||
sqlText += fmt.Sprintf(" LIMIT %d", cfg.Limit)
|
||||
}
|
||||
@@ -675,6 +1011,23 @@ ORDER BY ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
return db.QueryContext(ctx, sqlText)
|
||||
}
|
||||
|
||||
func backfillTimePredicates(cfg config, eventDateFrom string, eventDateToExclusive string) []string {
|
||||
where := make([]string, 0, 4)
|
||||
if !cfg.EventTimeFullScan {
|
||||
// ts is the TDengine primary timestamp and may be adjusted slightly to keep
|
||||
// rows unique. Use it only as a broad index-friendly window; event_time is
|
||||
// the protocol business boundary used for the final natural-day result.
|
||||
where = append(where,
|
||||
fmt.Sprintf("ts >= '%s 00:00:00'", quote(previousDate(eventDateFrom))),
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(eventDateToExclusive))),
|
||||
)
|
||||
}
|
||||
return append(where,
|
||||
fmt.Sprintf("event_time >= '%s 00:00:00'", quote(eventDateFrom)),
|
||||
fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateToExclusive)),
|
||||
)
|
||||
}
|
||||
|
||||
func realtimeMileageFramePredicate() string {
|
||||
return `(
|
||||
(protocol = 'GB32960' AND message_id IN (2,3))
|
||||
@@ -684,17 +1037,39 @@ func realtimeMileageFramePredicate() string {
|
||||
}
|
||||
|
||||
func mileageBearingFramePredicate(protocol envelope.Protocol) string {
|
||||
keys := mileageKeys(protocol)
|
||||
if len(keys) == 0 {
|
||||
tokens := mileageSearchTokens(protocol)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
conditions := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(key)))
|
||||
conditions := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(token)))
|
||||
}
|
||||
return "(" + strings.Join(conditions, " OR ") + ")"
|
||||
}
|
||||
|
||||
func mileageSearchTokens(protocol envelope.Protocol) []string {
|
||||
tokens := append([]string{}, mileageKeys(protocol)...)
|
||||
switch protocol {
|
||||
case envelope.ProtocolYutongMQTT:
|
||||
tokens = append(tokens, "TOTAL_MILEAGE", "totalMileage", "total_mileage_km")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(tokens))
|
||||
out := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[token]; ok {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
out = append(out, token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) {
|
||||
var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed string
|
||||
var messageID int64
|
||||
@@ -804,6 +1179,26 @@ func loadEnvFiles(paths string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillEnvFiles(explicitFiles string, explicitFile string, defaults []string) string {
|
||||
if value := strings.TrimSpace(explicitFiles); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimSpace(explicitFile); value != "" {
|
||||
return value
|
||||
}
|
||||
var existing []string
|
||||
for _, path := range defaults {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
existing = append(existing, path)
|
||||
}
|
||||
}
|
||||
return strings.Join(existing, ",")
|
||||
}
|
||||
|
||||
func loadEnvFile(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
|
||||
@@ -2,6 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -47,6 +50,58 @@ func TestChooseTrustedSourceKeepsContinuingSourceAndRejectsNewJump(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseTrustedSourceAcceptsSmallNegativeMileageJitter(t *testing.T) {
|
||||
previous := []dailySourceLast{{
|
||||
VIN: "LB9A32A28R0LS1574",
|
||||
SourceKey: normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53330"),
|
||||
Phone: "64115156034",
|
||||
SourceEndpoint: "115.159.85.149:53330",
|
||||
TotalKM: 15355.4,
|
||||
}}
|
||||
current := []dailySourceLast{{
|
||||
VIN: "LB9A32A28R0LS1574",
|
||||
SourceKey: normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53338"),
|
||||
Phone: "64115156034",
|
||||
SourceEndpoint: "115.159.85.149:53338",
|
||||
TotalKM: 15355.3,
|
||||
}}
|
||||
|
||||
chosen, ok := chooseTrustedSource(current, previous)
|
||||
if !ok {
|
||||
t.Fatal("chooseTrustedSource() should accept tiny negative mileage jitter")
|
||||
}
|
||||
if chosen.current.SourceKey != current[0].SourceKey {
|
||||
t.Fatalf("chosen source = %q", chosen.current.SourceKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseTrustedSourceAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
sourceKey := normalizedSourceKey("GB32960", "", "", "8.134.95.166:37720")
|
||||
previous := []dailySourceLast{{
|
||||
VIN: "LNXNEGRR1SR319498",
|
||||
SourceKey: sourceKey,
|
||||
SourceEndpoint: "8.134.95.166:37720",
|
||||
TS: time.Date(2026, 7, 4, 11, 7, 58, 0, loc),
|
||||
TotalKM: 8832.1,
|
||||
}}
|
||||
current := []dailySourceLast{{
|
||||
VIN: "LNXNEGRR1SR319498",
|
||||
SourceKey: sourceKey,
|
||||
SourceEndpoint: "8.134.95.166:37720",
|
||||
TS: time.Date(2026, 7, 12, 2, 52, 47, 0, loc),
|
||||
TotalKM: 16665.6,
|
||||
}}
|
||||
|
||||
chosen, ok := chooseTrustedSource(current, previous)
|
||||
if !ok {
|
||||
t.Fatal("chooseTrustedSource() should accept delta within the historical baseline window")
|
||||
}
|
||||
if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 7833.4 || delta > 7833.6 {
|
||||
t.Fatalf("delta = %v, want historical gap delta", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) {
|
||||
sourceA := dailySourceLast{
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
@@ -96,7 +151,7 @@ func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
|
||||
if agg.FirstEventTime != previous.TS || agg.LatestEventTime != current.TS {
|
||||
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||
}
|
||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != "historical_source_baseline" {
|
||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonHistorical {
|
||||
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
||||
}
|
||||
if agg.Count != 15 {
|
||||
@@ -104,7 +159,40 @@ func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateFromDailySourceUsesCurrentFirstSampleWithoutHistory(t *testing.T) {
|
||||
func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
current := dailySourceLast{
|
||||
VIN: "LNXNEGRR6SR319464",
|
||||
SourceKey: normalizedSourceKey("GB32960", "", "", "8.134.95.166:49206"),
|
||||
SourceEndpoint: "8.134.95.166:49206",
|
||||
FirstTS: time.Date(2026, 7, 12, 8, 5, 42, 0, loc),
|
||||
TS: time.Date(2026, 7, 12, 10, 44, 56, 0, loc),
|
||||
FirstTotalKM: 28004.2,
|
||||
TotalKM: 40009.7,
|
||||
RawSampleCount: 1938,
|
||||
}
|
||||
previous := dailySourceLast{
|
||||
VIN: current.VIN,
|
||||
SourceKey: current.SourceKey,
|
||||
SourceEndpoint: current.SourceEndpoint,
|
||||
TS: time.Date(2026, 7, 3, 19, 0, 39, 0, loc),
|
||||
TotalKM: 10009.7,
|
||||
}
|
||||
|
||||
agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true)
|
||||
|
||||
if agg.FirstKM != previous.TotalKM || agg.LatestKM != current.TotalKM {
|
||||
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
||||
}
|
||||
if !agg.FirstEventTime.Equal(previous.TS) || !agg.LatestEventTime.Equal(current.TS) {
|
||||
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||
}
|
||||
if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" {
|
||||
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateFromDailySourceUsesCurrentDayFirstWhenHistoryIsEmpty(t *testing.T) {
|
||||
current := dailySourceLast{
|
||||
VIN: "LMRKH9AC2R1004087",
|
||||
SourceKey: normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
|
||||
@@ -125,11 +213,264 @@ func TestAggregateFromDailySourceUsesCurrentFirstSampleWithoutHistory(t *testing
|
||||
if agg.FirstEventTime != current.FirstTS || agg.LatestEventTime != current.TS {
|
||||
t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
|
||||
}
|
||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != "current_day_first_sample" {
|
||||
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
|
||||
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.T) {
|
||||
tdDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
mock.MatchExpectationsInOrder(true)
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
vin := "LMRKH9AC2R1004087"
|
||||
endpoint := "mqtt://yutong/ytforward/shln/3"
|
||||
dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
|
||||
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
|
||||
currentRows := func() *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
})
|
||||
}
|
||||
previousRows := func() *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
})
|
||||
}
|
||||
|
||||
mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(previousRows())
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, int64(4)))
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows())
|
||||
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, int64(6)))
|
||||
|
||||
aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
DateFrom: "2026-07-09",
|
||||
DateTo: "2026-07-11",
|
||||
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
||||
Location: loc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildLastDiffAggregates() error = %v", err)
|
||||
}
|
||||
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
|
||||
agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey]
|
||||
if agg == nil {
|
||||
t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
|
||||
}
|
||||
if agg.FirstKM != 100 || agg.LatestKM != 120 {
|
||||
t.Fatalf("km range = %v -> %v, want 100 -> 120", agg.FirstKM, agg.LatestKM)
|
||||
}
|
||||
if !agg.FirstEventTime.Equal(dayOneTS) || agg.QualityReason != stats.QualityReasonHistorical {
|
||||
t.Fatalf("baseline = %v reason=%q, want day-one historical baseline", agg.FirstEventTime, agg.QualityReason)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateKeys(aggregates map[string]*metricAgg) []string {
|
||||
keys := make([]string, 0, len(aggregates))
|
||||
for key := range aggregates {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestQueryRealtimeLocationLastRowsBuildsYutongSourceFromPeer(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
eventTime := time.Date(2026, 7, 12, 5, 54, 50, 0, loc)
|
||||
mock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*LEFT JOIN vehicle_realtime_snapshot s").
|
||||
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
||||
AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", eventTime, 11578.0))
|
||||
|
||||
rows, err := queryRealtimeLocationLastRows(context.Background(), db, config{Location: loc}, envelope.ProtocolYutongMQTT, "2026-07-12")
|
||||
if err != nil {
|
||||
t.Fatalf("queryRealtimeLocationLastRows() error = %v", err)
|
||||
}
|
||||
sourceRows := rows["LMRKH9AC0R1004086"]
|
||||
if len(sourceRows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
||||
}
|
||||
row := sourceRows[0]
|
||||
wantSourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong")
|
||||
if row.SourceKey != wantSourceKey {
|
||||
t.Fatalf("source key = %q", row.SourceKey)
|
||||
}
|
||||
if row.SourceCode != "yutong" || row.PlatformName != "宇通" || row.SourceKind != "PLATFORM" {
|
||||
t.Fatalf("source metadata = code:%q platform:%q kind:%q", row.SourceCode, row.PlatformName, row.SourceKind)
|
||||
}
|
||||
if row.TotalKM != 11578 || row.DeviceID != "LMRKH9AC0R1004086" || row.RawSampleCount != 1 {
|
||||
t.Fatalf("unexpected realtime fallback row: %#v", row)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.T) {
|
||||
mysqlDB, mysqlMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
tdDB, tdMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("td sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
currentTS := time.Date(2026, 7, 12, 5, 54, 50, 0, loc)
|
||||
previousTS := time.Date(2026, 7, 11, 23, 58, 0, 0, loc)
|
||||
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
||||
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
||||
AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0))
|
||||
tdMock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-12 00:00:00'.*protocol = 'YUTONG_MQTT'.*TOTAL_MILEAGE").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC0R1004086",
|
||||
"",
|
||||
"LMRKH9AC0R1004086",
|
||||
"mqtt://yutong/ytforward/shln/4",
|
||||
previousTS,
|
||||
`{"data":{"TOTAL_MILEAGE":11500000}}`,
|
||||
int64(12),
|
||||
))
|
||||
|
||||
aggregates := map[string]*metricAgg{}
|
||||
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
DateFrom: "2026-07-12",
|
||||
DateTo: "2026-07-12",
|
||||
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
||||
Location: loc,
|
||||
}, aggregates)
|
||||
if err != nil {
|
||||
t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
|
||||
}
|
||||
if added != 1 || len(aggregates) != 1 {
|
||||
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
|
||||
}
|
||||
for _, agg := range aggregates {
|
||||
if agg.FirstKM != 11500 || agg.LatestKM != 11578 {
|
||||
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
|
||||
}
|
||||
if agg.SourceKey != stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") {
|
||||
t.Fatalf("source key = %q", agg.SourceKey)
|
||||
}
|
||||
if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" {
|
||||
t.Fatalf("source metadata = code:%q platform:%q kind:%q", agg.SourceCode, agg.PlatformName, agg.SourceKind)
|
||||
}
|
||||
if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
|
||||
t.Fatalf("quality reason = %q", agg.QualityReason)
|
||||
}
|
||||
}
|
||||
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("mysql sql expectations: %v", err)
|
||||
}
|
||||
if err := tdMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("td sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *testing.T) {
|
||||
mysqlDB, mysqlMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("mysql sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
tdDB, tdMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("td sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
vin := "LMRKH9AC2R1004087"
|
||||
endpoint := "mqtt://yutong/ytforward/shln/3"
|
||||
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
|
||||
dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
|
||||
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
|
||||
|
||||
tdMock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}))
|
||||
for _, date := range []string{"2026-07-09", "2026-07-10"} {
|
||||
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
||||
WithArgs("YUTONG_MQTT", date, date).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}))
|
||||
}
|
||||
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
||||
WithArgs("YUTONG_MQTT", "2026-07-11", "2026-07-11").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
||||
AddRow(vin, endpoint, dayThreeTS, 120.0))
|
||||
|
||||
aggregates := map[string]*metricAgg{
|
||||
vin + "|2026-07-09|YUTONG_MQTT|" + sourceKey: {
|
||||
VIN: vin,
|
||||
Date: "2026-07-09",
|
||||
Protocol: envelope.ProtocolYutongMQTT,
|
||||
LatestKM: 100,
|
||||
Count: 4,
|
||||
SourceKey: sourceKey,
|
||||
DeviceID: vin,
|
||||
SourceEndpoint: endpoint,
|
||||
SourceCode: "yutong",
|
||||
PlatformName: "宇通",
|
||||
SourceKind: "PLATFORM",
|
||||
LatestEventTime: dayOneTS,
|
||||
},
|
||||
}
|
||||
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
DateFrom: "2026-07-09",
|
||||
DateTo: "2026-07-11",
|
||||
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
|
||||
Location: loc,
|
||||
}, aggregates)
|
||||
if err != nil {
|
||||
t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
|
||||
}
|
||||
if added != 1 || len(aggregates) != 2 {
|
||||
t.Fatalf("added=%d aggregates=%d, want 1 and 2", added, len(aggregates))
|
||||
}
|
||||
agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey]
|
||||
if agg == nil {
|
||||
t.Fatal("missing realtime-location fallback aggregate")
|
||||
}
|
||||
if agg.FirstKM != 100 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayOneTS) {
|
||||
t.Fatalf("fallback range = %v@%v -> %v, want 100@day-one -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
|
||||
}
|
||||
if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
|
||||
t.Fatalf("quality reason = %q", agg.QualityReason)
|
||||
}
|
||||
if err := mysqlMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("mysql sql expectations: %v", err)
|
||||
}
|
||||
if err := tdMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("td sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -140,9 +481,9 @@ func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
firstTS := time.Date(2026, 7, 8, 8, 0, 0, 0, loc)
|
||||
lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'").
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(ts)", "FIRST(parsed_json)", "LAST(ts)", "LAST(parsed_json)", "COUNT(*)",
|
||||
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC6R1004108",
|
||||
"",
|
||||
@@ -183,9 +524,9 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'").
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(ts)", "LAST(parsed_json)", "COUNT(*)",
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC6R1004108",
|
||||
"",
|
||||
@@ -215,6 +556,89 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillBeforePredicatesSearchesAllEarlierHistory(t *testing.T) {
|
||||
where := strings.Join(backfillBeforePredicates("2026-07-08"), " AND ")
|
||||
if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") {
|
||||
t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where)
|
||||
}
|
||||
if strings.Contains(where, "event_time >=") || strings.Contains(where, "ts >=") {
|
||||
t.Fatalf("pre-window predicate must not stop at the previous day: %s", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC7R1004098",
|
||||
"",
|
||||
"LMRKH9AC7R1004098",
|
||||
"mqtt://yutong/ytforward/shln/4",
|
||||
utcInstant,
|
||||
`{"yutong_mqtt.data.total_mileage":"41249000"}`,
|
||||
int64(1),
|
||||
))
|
||||
|
||||
rows, err := queryPreviousLastSourceRows(context.Background(), db, config{
|
||||
TDengineDatabase: "lingniu_vehicle_ts",
|
||||
Location: loc,
|
||||
}, envelope.ProtocolYutongMQTT, "2026-07-13")
|
||||
if err != nil {
|
||||
t.Fatalf("queryPreviousLastSourceRows() error = %v", err)
|
||||
}
|
||||
sourceRows := rows["LMRKH9AC7R1004098"]
|
||||
if len(sourceRows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(sourceRows))
|
||||
}
|
||||
want := time.Date(2026, 7, 12, 23, 59, 59, 0, loc)
|
||||
if !sourceRows[0].TS.Equal(want) || sourceRows[0].TS.Location().String() != loc.String() {
|
||||
t.Fatalf("previous event time = %s (%s), want %s (%s)", sourceRows[0].TS, sourceRows[0].TS.Location(), want, want.Location())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
|
||||
fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{
|
||||
"data": {
|
||||
"TOTAL_MILEAGE": 65423000,
|
||||
"METER_SPEED": 12.3
|
||||
},
|
||||
"root": {
|
||||
"device": "LMRKH9AC6R1004108"
|
||||
}
|
||||
}`)
|
||||
|
||||
if got := fields["yutong_mqtt.data.total_mileage"]; got == nil {
|
||||
t.Fatalf("fields missing raw yutong total mileage: %#v", fields)
|
||||
}
|
||||
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolYutongMQTT,
|
||||
VIN: "LMRKH9AC6R1004108",
|
||||
EventTimeMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
||||
ReceivedAtMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
|
||||
Fields: fields,
|
||||
}
|
||||
samples, err := stats.SamplesFromEnvelope(env, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
if err != nil {
|
||||
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
||||
}
|
||||
if len(samples) != 1 {
|
||||
t.Fatalf("samples = %d, want 1", len(samples))
|
||||
}
|
||||
if samples[0].TotalMileageKM != 65423 {
|
||||
t.Fatalf("total mileage km = %v, want 65423", samples[0].TotalMileageKM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -269,7 +693,7 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_data_source`).
|
||||
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
||||
WithArgs(
|
||||
@@ -292,18 +716,16 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
|
||||
"outside_daily_range",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(1000)).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(2500)).
|
||||
WillReturnResult(sqlmock.NewResult(1, 0))
|
||||
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
||||
WithArgs(
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
int64(1000),
|
||||
int64(2500),
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
@@ -319,6 +741,7 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
|
||||
"JT808",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
written, err := writeAggregates(context.Background(), db, aggregates, 500)
|
||||
if err != nil {
|
||||
@@ -419,6 +842,8 @@ func TestAddSamplesUsesCurrentDayFirstSampleBaseline(t *testing.T) {
|
||||
|
||||
func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
|
||||
t.Setenv("BACKFILL_METHOD", "")
|
||||
t.Setenv("BACKFILL_DAYS_BACK", "")
|
||||
t.Setenv("BACKFILL_WINDOW_DAYS", "")
|
||||
t.Setenv("BACKFILL_DATE_FROM", "2026-07-08")
|
||||
t.Setenv("BACKFILL_DATE_TO", "2026-07-08")
|
||||
t.Setenv("BACKFILL_PROTOCOLS", "JT808")
|
||||
@@ -431,4 +856,101 @@ func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
|
||||
if cfg.Method != "last_diff" {
|
||||
t.Fatalf("method = %q, want last_diff", cfg.Method)
|
||||
}
|
||||
if cfg.EventTimeFullScan {
|
||||
t.Fatal("event-time full scan should be opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillTimePredicatesUsePrimaryTimeForCoarseScanAndEventTimeForBusinessDay(t *testing.T) {
|
||||
where := strings.Join(backfillTimePredicates(config{}, "2026-07-13", "2026-07-14"), " AND ")
|
||||
for _, want := range []string{
|
||||
"ts >= '2026-07-12 00:00:00'",
|
||||
"ts < '2026-07-15 00:00:00'",
|
||||
"event_time >= '2026-07-13 00:00:00'",
|
||||
"event_time < '2026-07-14 00:00:00'",
|
||||
} {
|
||||
if !strings.Contains(where, want) {
|
||||
t.Fatalf("time predicates missing %q: %s", want, where)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillTimePredicatesAllowExplicitDeepEventTimeScan(t *testing.T) {
|
||||
where := strings.Join(backfillTimePredicates(config{EventTimeFullScan: true}, "2026-07-13", "2026-07-14"), " AND ")
|
||||
if strings.Contains(where, "ts >=") || strings.Contains(where, "ts <") {
|
||||
t.Fatalf("deep event-time scan must not apply storage-time bounds: %s", where)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"event_time >= '2026-07-13 00:00:00'",
|
||||
"event_time < '2026-07-14 00:00:00'",
|
||||
} {
|
||||
if !strings.Contains(where, want) {
|
||||
t.Fatalf("deep event-time scan missing %q: %s", want, where)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBackfillDateRangeDefaultsToToday(t *testing.T) {
|
||||
t.Setenv("BACKFILL_DATE_FROM", "")
|
||||
t.Setenv("BACKFILL_DATE_TO", "")
|
||||
t.Setenv("BACKFILL_DAYS_BACK", "")
|
||||
t.Setenv("BACKFILL_WINDOW_DAYS", "")
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
||||
if dateFrom != "2026-07-12" || dateTo != "2026-07-12" {
|
||||
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBackfillDateRangeUsesRelativeWindow(t *testing.T) {
|
||||
t.Setenv("BACKFILL_DATE_FROM", "")
|
||||
t.Setenv("BACKFILL_DATE_TO", "")
|
||||
t.Setenv("BACKFILL_DAYS_BACK", "1")
|
||||
t.Setenv("BACKFILL_WINDOW_DAYS", "3")
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
||||
if dateFrom != "2026-07-09" || dateTo != "2026-07-11" {
|
||||
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBackfillDateRangePrefersExplicitDates(t *testing.T) {
|
||||
t.Setenv("BACKFILL_DATE_FROM", "2026-07-01")
|
||||
t.Setenv("BACKFILL_DATE_TO", "2026-07-03")
|
||||
t.Setenv("BACKFILL_DAYS_BACK", "1")
|
||||
t.Setenv("BACKFILL_WINDOW_DAYS", "3")
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
|
||||
if dateFrom != "2026-07-01" || dateTo != "2026-07-03" {
|
||||
t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEnvFilesPrefersExplicitList(t *testing.T) {
|
||||
got := backfillEnvFiles("/tmp/a.env,/tmp/b.env", "/tmp/legacy.env", []string{"/tmp/default.env"})
|
||||
if got != "/tmp/a.env,/tmp/b.env" {
|
||||
t.Fatalf("env files = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEnvFilesUsesExistingDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "missing.env")
|
||||
base := filepath.Join(dir, "base.env")
|
||||
stat := filepath.Join(dir, "stat-writer.env")
|
||||
if err := os.WriteFile(base, []byte("A=1\n"), 0600); err != nil {
|
||||
t.Fatalf("write base env: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(stat, []byte("B=2\n"), 0600); err != nil {
|
||||
t.Fatalf("write stat env: %v", err)
|
||||
}
|
||||
|
||||
got := backfillEnvFiles("", "", []string{missing, base, stat})
|
||||
want := base + "," + stat
|
||||
if got != want {
|
||||
t.Fatalf("env files = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
215
go/vehicle-gateway/internal/authentication/authentication.go
Normal file
215
go/vehicle-gateway/internal/authentication/authentication.go
Normal 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))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
19
go/vehicle-gateway/internal/envelope/source.go
Normal file
19
go/vehicle-gateway/internal/envelope/source.go
Normal 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
|
||||
}
|
||||
31
go/vehicle-gateway/internal/envelope/time.go
Normal file
31
go/vehicle-gateway/internal/envelope/time.go
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
348
go/vehicle-gateway/internal/eventbus/durable_outbox_sink.go
Normal file
348
go/vehicle-gateway/internal/eventbus/durable_outbox_sink.go
Normal 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)
|
||||
}
|
||||
380
go/vehicle-gateway/internal/eventbus/durable_outbox_sink_test.go
Normal file
380
go/vehicle-gateway/internal/eventbus/durable_outbox_sink_test.go
Normal 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 }
|
||||
804
go/vehicle-gateway/internal/eventbus/durable_outbox_wal.go
Normal file
804
go/vehicle-gateway/internal/eventbus/durable_outbox_wal.go
Normal 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
|
||||
}
|
||||
229
go/vehicle-gateway/internal/eventbus/durable_outbox_wal_test.go
Normal file
229
go/vehicle-gateway/internal/eventbus/durable_outbox_wal_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
30
go/vehicle-gateway/internal/eventbus/kafka_retry.go
Normal file
30
go/vehicle-gateway/internal/eventbus/kafka_retry.go
Normal 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
|
||||
}
|
||||
34
go/vehicle-gateway/internal/eventbus/kafka_retry_test.go
Normal file
34
go/vehicle-gateway/internal/eventbus/kafka_retry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
293
go/vehicle-gateway/internal/eventbus/partitioned_async_sink.go
Normal file
293
go/vehicle-gateway/internal/eventbus/partitioned_async_sink.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
42
go/vehicle-gateway/internal/gateway/fields.go
Normal file
42
go/vehicle-gateway/internal/gateway/fields.go
Normal 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
|
||||
}
|
||||
76
go/vehicle-gateway/internal/gateway/fields_test.go
Normal file
76
go/vehicle-gateway/internal/gateway/fields_test.go
Normal 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))))
|
||||
}
|
||||
104
go/vehicle-gateway/internal/gateway/identity_metrics.go
Normal file
104
go/vehicle-gateway/internal/gateway/identity_metrics.go
Normal 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)
|
||||
}
|
||||
27
go/vehicle-gateway/internal/gateway/identity_metrics_test.go
Normal file
27
go/vehicle-gateway/internal/gateway/identity_metrics_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
773
go/vehicle-gateway/internal/identity/mapping_import.go
Normal file
773
go/vehicle-gateway/internal/identity/mapping_import.go
Normal 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(), "_")
|
||||
}
|
||||
345
go/vehicle-gateway/internal/identity/mapping_import_test.go
Normal file
345
go/vehicle-gateway/internal/identity/mapping_import_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
357
go/vehicle-gateway/internal/identity/registration_writer.go
Normal file
357
go/vehicle-gateway/internal/identity/registration_writer.go
Normal 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 ©
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
221
go/vehicle-gateway/internal/identity/registration_writer_test.go
Normal file
221
go/vehicle-gateway/internal/identity/registration_writer_test.go
Normal 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
@@ -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"))
|
||||
}
|
||||
|
||||
325
go/vehicle-gateway/internal/identity/snapshot.go
Normal file
325
go/vehicle-gateway/internal/identity/snapshot.go
Normal 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
|
||||
}
|
||||
166
go/vehicle-gateway/internal/identity/snapshot_test.go
Normal file
166
go/vehicle-gateway/internal/identity/snapshot_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
})
|
||||
|
||||
@@ -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"})
|
||||
|
||||
35
go/vehicle-gateway/internal/metrics/pending.go
Normal file
35
go/vehicle-gateway/internal/metrics/pending.go
Normal 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))
|
||||
}
|
||||
79
go/vehicle-gateway/internal/metrics/recent_latency.go
Normal file
79
go/vehicle-gateway/internal/metrics/recent_latency.go
Normal 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]
|
||||
}
|
||||
34
go/vehicle-gateway/internal/metrics/recent_latency_test.go
Normal file
34
go/vehicle-gateway/internal/metrics/recent_latency_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
27
go/vehicle-gateway/internal/observability/logger_test.go
Normal file
27
go/vehicle-gateway/internal/observability/logger_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)))...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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、remark;source_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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user