1443 lines
50 KiB
Go
1443 lines
50 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/segmentio/kafka-go"
|
|
_ "github.com/taosdata/driver-go/v3/taosWS"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/history"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
|
)
|
|
|
|
const (
|
|
defaultThrottleCacheRetention = 72 * time.Hour
|
|
defaultThrottleCacheCleanupInterval = 10 * time.Minute
|
|
)
|
|
|
|
func main() {
|
|
role := realtimeRoleFromEnv()
|
|
apiEnabled := realtimeRoleAllowsAPI(role)
|
|
writerEnabled := realtimeRoleAllowsWriter(role)
|
|
redisProjectorEnabled := env("REALTIME_REDIS_PROJECTOR_ENABLED", "true") != "false"
|
|
serviceName := realtimeServiceName(role)
|
|
logger := observability.NewLogger(serviceName)
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
mux := http.NewServeMux()
|
|
registry := metrics.NewRegistry()
|
|
metrics.RegisterServiceInfo(registry, serviceName)
|
|
registry.SetGauge("vehicle_realtime_role_info", metrics.Labels{"role": role}, 1)
|
|
registry.SetGauge("vehicle_realtime_writer_enabled", nil, boolGauge(writerEnabled))
|
|
registry.SetGauge("vehicle_realtime_redis_projector_enabled", nil, boolGauge(redisProjectorEnabled))
|
|
healthChecks := []health.Check{}
|
|
var client *redis.Client
|
|
var repository *realtime.Repository
|
|
if realtimeRoleNeedsRedis(apiEnabled, writerEnabled, redisProjectorEnabled) {
|
|
client = redis.NewClient(&redis.Options{
|
|
Addr: env("REDIS_ADDR", "127.0.0.1:6379"),
|
|
Username: env("REDIS_USERNAME", ""),
|
|
Password: env("REDIS_PASSWORD", ""),
|
|
DB: envInt("REDIS_DB", 0),
|
|
})
|
|
defer client.Close()
|
|
if err := client.Ping(ctx).Err(); err != nil {
|
|
logger.Error("redis ping failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
repository = realtime.NewRepository(client, realtime.Config{
|
|
OnlineTTL: time.Duration(envInt("ONLINE_TTL_SECONDS", envInt("REALTIME_ONLINE_TTL_SECONDS", 60))) * time.Second,
|
|
})
|
|
healthChecks = append(healthChecks, health.Check{Name: "redis", Check: func(ctx context.Context) error { return client.Ping(ctx).Err() }})
|
|
} else {
|
|
logger.Info("redis dependency disabled by role", "role", role, "redis_projector_enabled", redisProjectorEnabled)
|
|
}
|
|
mux.Handle("/metrics", metrics.NewHandler(registry))
|
|
if apiEnabled {
|
|
mux.Handle("/openapi.json", realtime.NewOpenAPIHandler())
|
|
mux.Handle("/swagger-ui/", realtime.NewSwaggerUIHandler())
|
|
mux.Handle("/api/realtime/vehicles/", realtime.NewHandler(repository))
|
|
mux.Handle("/api/realtime/online", realtime.NewOnlineIndexHandler(repository))
|
|
mux.Handle("/api/debug/pipeline", realtime.NewPipelineDebugHandler(repository))
|
|
} else {
|
|
logger.Info("realtime query api disabled by role", "role", role)
|
|
}
|
|
closeStats := func() {}
|
|
closeRealtimeUpdaters := func() {}
|
|
var redisUpdater realtimeUpdater
|
|
if writerEnabled && redisProjectorEnabled {
|
|
redisUpdater = storeMetricUpdater{
|
|
store: "redis",
|
|
delegate: fastRealtimeUpdater{repository: repository},
|
|
registry: registry,
|
|
}
|
|
} else {
|
|
logger.Info("realtime redis kafka projector disabled; redis query api still enabled")
|
|
}
|
|
var updater realtimeUpdater = redisUpdater
|
|
if dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN")); dsn != "" {
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
logger.Error("stats mysql open failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
logger.Error("stats mysql ping failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
healthChecks = append(healthChecks, health.Check{Name: "mysql", Check: db.PingContext})
|
|
closeStats = func() { _ = db.Close() }
|
|
bindingTable := env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding")
|
|
plateCacheMaxEntries := envInt("PLATE_CACHE_MAX_ENTRIES", 200000)
|
|
plateResolver := realtime.NewCachedPlateResolverWithMaxEntries(
|
|
realtime.NewBindingPlateResolver(db, bindingTable),
|
|
time.Duration(envInt("PLATE_CACHE_TTL_SECONDS", 600))*time.Second,
|
|
plateCacheMaxEntries,
|
|
)
|
|
plateResolver.SetStatsObserver(func(stats realtime.PlateCacheStats) {
|
|
recordPlateCacheStats(registry, stats)
|
|
})
|
|
registry.SetGauge("vehicle_realtime_plate_cache_max_entries", nil, float64(plateCacheMaxEntries))
|
|
snapshotWriter := realtime.NewSnapshotWriterWithPlateResolver(db, plateResolver)
|
|
if writerEnabled && env("MYSQL_REALTIME_SNAPSHOT_ENABLED", "true") != "false" {
|
|
if err := snapshotWriter.EnsureSchema(ctx); err != nil {
|
|
_ = db.Close()
|
|
logger.Error("realtime snapshot mysql schema bootstrap failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
mysqlDelegate := realtimeUpdater(retryRealtimeUpdater{
|
|
delegate: snapshotWriter,
|
|
attempts: envInt("MYSQL_REALTIME_RETRY_ATTEMPTS", 3),
|
|
delay: time.Duration(envInt("MYSQL_REALTIME_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
|
})
|
|
mysqlStoreUpdater := realtimeUpdater(storeMetricUpdater{store: "mysql", delegate: mysqlDelegate, registry: registry})
|
|
mysqlUpdater := mysqlStoreUpdater
|
|
minInterval := time.Duration(envInt("MYSQL_REALTIME_MIN_INTERVAL_MS", 10000)) * time.Millisecond
|
|
if redisProjectorEnabled && env("MYSQL_REALTIME_ASYNC_ENABLED", "true") != "false" {
|
|
asyncUpdater := newAsyncSecondaryRealtimeUpdater(
|
|
nil,
|
|
mysqlStoreUpdater,
|
|
envInt("MYSQL_REALTIME_ASYNC_QUEUE_SIZE", 20000),
|
|
envInt("MYSQL_REALTIME_ASYNC_WORKERS", 4),
|
|
registry,
|
|
logger,
|
|
)
|
|
closeRealtimeUpdaters = asyncUpdater.Close
|
|
mysqlUpdater = asyncUpdater
|
|
}
|
|
throttleRetention := time.Duration(envInt("MYSQL_REALTIME_THROTTLE_CACHE_RETENTION_HOURS", 72)) * time.Hour
|
|
throttleCleanupInterval := time.Duration(envInt("MYSQL_REALTIME_THROTTLE_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * time.Second
|
|
registry.SetGauge("vehicle_realtime_throttle_cache_retention_seconds", metrics.Labels{"store": "mysql"}, throttleRetention.Seconds())
|
|
registry.SetGauge("vehicle_realtime_throttle_cache_cleanup_interval_seconds", metrics.Labels{"store": "mysql"}, throttleCleanupInterval.Seconds())
|
|
mysqlUpdater = newThrottledRealtimeUpdaterWithCache(mysqlUpdater, minInterval, "mysql", registry, throttleRetention, throttleCleanupInterval)
|
|
updater = composeRealtimeUpdater(redisUpdater, mysqlUpdater)
|
|
logger.Info("realtime mysql snapshot enabled", "table", "vehicle_realtime_snapshot", "plate_binding_table", bindingTable, "plate_cache_ttl_seconds", envInt("PLATE_CACHE_TTL_SECONDS", 600), "plate_cache_max_entries", plateCacheMaxEntries, "async_enabled", redisProjectorEnabled && env("MYSQL_REALTIME_ASYNC_ENABLED", "true") != "false", "min_interval_ms", minInterval.Milliseconds(), "throttle_cache_retention_seconds", throttleRetention.Seconds(), "throttle_cache_cleanup_interval_seconds", throttleCleanupInterval.Seconds())
|
|
}
|
|
if apiEnabled {
|
|
metricHandler := stats.NewMetricHandler(stats.NewMetricRepository(db))
|
|
mux.Handle("/api/stats/daily-metrics", metricHandler)
|
|
mux.Handle("/api/stats/daily-metrics/sources", metricHandler)
|
|
mux.Handle("/api/stats/daily-metrics/sources/quality", metricHandler)
|
|
mux.Handle("/api/stats/daily-metrics/sources/selection", metricHandler)
|
|
mux.Handle("/api/stats/daily-metrics/diagnostics", metricHandler)
|
|
mux.Handle("/api/stats/daily-metrics/diagnostics/summary", metricHandler)
|
|
mux.Handle("/api/stats/daily-metrics/diagnostics/reasons", metricHandler)
|
|
mux.Handle("/api/stats/daily-metrics/diagnostics/field-status", metricHandler)
|
|
dataSourceHandler := stats.NewDataSourceHandler(stats.NewDataSourceRepository(db))
|
|
mux.Handle("/api/stats/data-sources", dataSourceHandler)
|
|
mux.Handle("/api/stats/data-sources/", dataSourceHandler)
|
|
mux.Handle("/api/realtime/snapshots", realtime.NewSnapshotQueryHandler(realtime.NewSnapshotQueryRepository(db)))
|
|
mux.Handle("/api/realtime/locations", realtime.NewLocationQueryHandler(realtime.NewLocationQueryRepository(db)))
|
|
logger.Info("stats mysql query enabled")
|
|
}
|
|
} else {
|
|
mysqlUnavailable := func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": "MYSQL_DSN is not configured"})
|
|
}
|
|
if apiEnabled {
|
|
mux.HandleFunc("/api/stats/daily-metrics", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/daily-metrics/sources", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/daily-metrics/sources/quality", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/daily-metrics/sources/selection", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/daily-metrics/diagnostics", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/daily-metrics/diagnostics/summary", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/daily-metrics/diagnostics/reasons", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/daily-metrics/diagnostics/field-status", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/data-sources", mysqlUnavailable)
|
|
mux.HandleFunc("/api/stats/data-sources/", mysqlUnavailable)
|
|
mux.HandleFunc("/api/realtime/snapshots", mysqlUnavailable)
|
|
mux.HandleFunc("/api/realtime/locations", mysqlUnavailable)
|
|
logger.Warn("MYSQL_DSN is empty; stats query api disabled")
|
|
}
|
|
}
|
|
defer closeStats()
|
|
defer closeRealtimeUpdaters()
|
|
var realtimeConsumers sync.WaitGroup
|
|
if brokers := splitCSV(os.Getenv("KAFKA_BROKERS")); writerEnabled && len(brokers) > 0 {
|
|
if updater == nil {
|
|
logger.Warn("KAFKA_BROKERS is configured but realtime kafka projector has no enabled store")
|
|
} else {
|
|
kafkaTopics := kafkaTopicsFromEnv()
|
|
workerCount := envInt("REALTIME_WORKERS", 3)
|
|
if err := validateRealtimeConsumerConfig(kafkaTopics, workerCount); err != nil {
|
|
logger.Error("invalid realtime writer config", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
metrics.RegisterKafkaConsumerInfo(registry, "vehicle-realtime-api", env("KAFKA_GROUP", "go-realtime-api"), kafkaTopics)
|
|
registry.SetGauge("vehicle_realtime_config", metrics.Labels{"setting": "workers"}, float64(workerCount))
|
|
for workerID := 1; workerID <= workerCount; workerID++ {
|
|
realtimeConsumers.Add(1)
|
|
go func(id int) {
|
|
defer realtimeConsumers.Done()
|
|
consumeKafkaWorker(
|
|
ctx,
|
|
logger,
|
|
registry,
|
|
updater,
|
|
brokers,
|
|
kafkaTopics,
|
|
envInt("REALTIME_BATCH_SIZE", 100),
|
|
time.Duration(envInt("REALTIME_BATCH_WAIT_MS", 20))*time.Millisecond,
|
|
time.Duration(envInt("REALTIME_FAILURE_RETRY_DELAY_MS", 100))*time.Millisecond,
|
|
id,
|
|
)
|
|
}(workerID)
|
|
}
|
|
}
|
|
} else if writerEnabled {
|
|
logger.Warn("KAFKA_BROKERS is empty; realtime api will serve existing redis data only")
|
|
} else {
|
|
logger.Info("realtime kafka projector disabled by role", "role", role)
|
|
}
|
|
defer func() {
|
|
stop()
|
|
realtimeConsumers.Wait()
|
|
}()
|
|
closeHistory := func() {}
|
|
if apiEnabled {
|
|
if dsn := strings.TrimSpace(os.Getenv("TDENGINE_DSN")); dsn != "" {
|
|
driver := env("TDENGINE_DRIVER", "taosWS")
|
|
db, err := sql.Open(driver, dsn)
|
|
if err != nil {
|
|
logger.Error("history tdengine open failed", "driver", driver, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
logger.Error("history tdengine ping failed", "driver", driver, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
healthChecks = append(healthChecks, health.Check{Name: "tdengine", Check: db.PingContext})
|
|
closeHistory = func() { _ = db.Close() }
|
|
database := env("TDENGINE_DATABASE", history.DefaultDatabase)
|
|
rawFrameHandler := history.NewRawFrameHandler(history.NewRawFrameRepository(db, database))
|
|
mux.Handle("/api/history/raw-frames", rawFrameHandler)
|
|
mux.Handle("/api/history/raw-frames/query", rawFrameHandler)
|
|
mux.Handle("/api/history/locations", history.NewLocationHandler(history.NewLocationRepository(db, database)))
|
|
logger.Info("history query enabled", "driver", driver, "database", database)
|
|
} else {
|
|
historyUnavailable := func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": "TDENGINE_DSN is not configured"})
|
|
}
|
|
mux.HandleFunc("/api/history/raw-frames", historyUnavailable)
|
|
mux.HandleFunc("/api/history/raw-frames/query", historyUnavailable)
|
|
mux.HandleFunc("/api/history/locations", historyUnavailable)
|
|
logger.Warn("TDENGINE_DSN is empty; history query api disabled")
|
|
}
|
|
} else {
|
|
logger.Info("tdengine dependency disabled by role", "role", role)
|
|
}
|
|
defer closeHistory()
|
|
healthHandler := health.NewHandler(serviceName, healthChecks)
|
|
mux.Handle("/healthz", healthHandler)
|
|
mux.Handle("/readyz", healthHandler)
|
|
|
|
server := &http.Server{
|
|
Addr: env("HTTP_ADDR", ":20210"),
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = server.Shutdown(shutdownCtx)
|
|
}()
|
|
|
|
logger.Info("realtime service started", "service", serviceName, "addr", server.Addr, "role", role)
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Error("http server failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
type realtimeLogger interface {
|
|
Info(string, ...any)
|
|
Error(string, ...any)
|
|
Warn(string, ...any)
|
|
}
|
|
|
|
func consumeKafkaWorker(ctx context.Context, logger realtimeLogger, registry *metrics.Registry, updater realtimeUpdater, brokers []string, kafkaTopics []string, batchSize int, batchWait time.Duration, retryDelay time.Duration, workerID int) {
|
|
reader := kafka.NewReader(realtimeReaderConfig(brokers, kafkaTopics))
|
|
defer reader.Close()
|
|
workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)}
|
|
registry.SetGauge("vehicle_realtime_worker_active", workerLabels, 1)
|
|
defer registry.SetGauge("vehicle_realtime_worker_active", workerLabels, 0)
|
|
logger.Info("realtime kafka consumer started", "worker", workerID, "topics", strings.Join(kafkaTopics, ","), "batch_size", batchSize, "batch_wait_ms", batchWait.Milliseconds())
|
|
for {
|
|
message, err := reader.FetchMessage(ctx)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
logger.Error("kafka fetch failed", "error", err)
|
|
continue
|
|
}
|
|
batch := collectRealtimeBatch(ctx, reader, message, batchSize, batchWait)
|
|
processRealtimeBatchReliablyForWorker(ctx, logger, registry, updater, reader, batch, retryDelay, workerLabels)
|
|
}
|
|
}
|
|
|
|
func validateRealtimeConsumerConfig(kafkaTopics []string, workers int) error {
|
|
if len(kafkaTopics) == 0 {
|
|
return errors.New("KAFKA_TOPICS must include raw topics")
|
|
}
|
|
for _, topic := range kafkaTopics {
|
|
if _, ok := topics.ProtocolForKnownRawTopic(topic); !ok {
|
|
return fmt.Errorf("realtime-writer consumes known raw topics only, got %q", topic)
|
|
}
|
|
}
|
|
if workers <= 0 {
|
|
return errors.New("REALTIME_WORKERS must be greater than zero")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func realtimeReaderConfig(brokers []string, kafkaTopics []string) kafka.ReaderConfig {
|
|
return kafka.ReaderConfig{
|
|
Brokers: brokers,
|
|
GroupID: env("KAFKA_GROUP", "go-realtime-api"),
|
|
GroupTopics: kafkaTopics,
|
|
MinBytes: 1,
|
|
MaxBytes: 10e6,
|
|
StartOffset: kafka.LastOffset,
|
|
}
|
|
}
|
|
|
|
func kafkaTopicsFromEnv() []string {
|
|
defaultTopics := strings.Join([]string{topics.RawGB32960, topics.RawJT808, topics.RawYutongMQTT}, ",")
|
|
return splitCSV(env("KAFKA_TOPICS", defaultTopics))
|
|
}
|
|
|
|
const kafkaMessageOperationTimeout = 30 * time.Second
|
|
|
|
type realtimeUpdater interface {
|
|
Update(context.Context, envelope.FrameEnvelope) error
|
|
}
|
|
|
|
type realtimeBatchUpdater interface {
|
|
UpdateBatch(context.Context, []envelope.FrameEnvelope) error
|
|
}
|
|
|
|
type fastRealtimeUpdater struct {
|
|
repository *realtime.Repository
|
|
}
|
|
|
|
func (u fastRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
return u.repository.FastUpdate(ctx, env)
|
|
}
|
|
|
|
func (u fastRealtimeUpdater) UpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
|
|
if u.repository == nil || len(envs) == 0 {
|
|
return nil
|
|
}
|
|
return u.repository.FastUpdateBatch(ctx, envs)
|
|
}
|
|
|
|
type storeMetricUpdater struct {
|
|
store string
|
|
delegate realtimeUpdater
|
|
registry *metrics.Registry
|
|
}
|
|
|
|
var realtimeStoreUpdateDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
|
|
|
func (u storeMetricUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if u.delegate == nil {
|
|
return nil
|
|
}
|
|
started := time.Now()
|
|
err := u.delegate.Update(ctx, env)
|
|
elapsed := time.Since(started)
|
|
status := "ok"
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
if u.registry != nil {
|
|
labels := metrics.Labels{
|
|
"store": u.store,
|
|
"protocol": string(env.Protocol),
|
|
"status": status,
|
|
}
|
|
elapsedMS := float64(elapsed.Milliseconds())
|
|
u.registry.IncCounter("vehicle_realtime_store_updates_total", labels)
|
|
u.registry.SetGauge("vehicle_realtime_store_update_duration_ms", labels, elapsedMS)
|
|
u.registry.ObserveHistogram("vehicle_realtime_store_update_duration_ms_histogram", labels, realtimeStoreUpdateDurationBucketsMS, elapsedMS)
|
|
metrics.RecordLastActivity(u.registry, "vehicle_realtime_last_store_update_unix_seconds", labels)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (u storeMetricUpdater) UpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
|
|
if u.delegate == nil || len(envs) == 0 {
|
|
return nil
|
|
}
|
|
batchDelegate, ok := u.delegate.(realtimeBatchUpdater)
|
|
if !ok {
|
|
for _, env := range envs {
|
|
if err := u.Update(ctx, env); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
started := time.Now()
|
|
err := batchDelegate.UpdateBatch(ctx, envs)
|
|
elapsed := time.Since(started)
|
|
status := statusFromError(err)
|
|
if u.registry != nil {
|
|
elapsedMS := float64(elapsed.Milliseconds())
|
|
counts := realtimeProtocolCounts(envs)
|
|
for protocol, count := range counts {
|
|
labels := metrics.Labels{
|
|
"store": u.store,
|
|
"protocol": string(protocol),
|
|
"status": status,
|
|
}
|
|
u.registry.AddCounter("vehicle_realtime_store_updates_total", labels, float64(count))
|
|
u.registry.IncCounter("vehicle_realtime_store_batch_updates_total", labels)
|
|
u.registry.SetGauge("vehicle_realtime_store_update_duration_ms", labels, elapsedMS)
|
|
u.registry.ObserveHistogram("vehicle_realtime_store_update_duration_ms_histogram", labels, realtimeStoreUpdateDurationBucketsMS, elapsedMS)
|
|
metrics.RecordLastActivity(u.registry, "vehicle_realtime_last_store_update_unix_seconds", labels)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func realtimeProtocolCounts(envs []envelope.FrameEnvelope) map[envelope.Protocol]int {
|
|
counts := make(map[envelope.Protocol]int)
|
|
for _, env := range envs {
|
|
counts[env.Protocol]++
|
|
}
|
|
return counts
|
|
}
|
|
|
|
type retryRealtimeUpdater struct {
|
|
delegate realtimeUpdater
|
|
attempts int
|
|
delay time.Duration
|
|
}
|
|
|
|
func (u retryRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if u.delegate == nil {
|
|
return nil
|
|
}
|
|
attempts := u.attempts
|
|
if attempts <= 0 {
|
|
attempts = 1
|
|
}
|
|
var err error
|
|
for attempt := 1; attempt <= attempts; attempt++ {
|
|
err = u.delegate.Update(ctx, env)
|
|
if err == nil || !isTransientMySQLWriteError(err) || attempt == attempts {
|
|
return err
|
|
}
|
|
if u.delay <= 0 {
|
|
continue
|
|
}
|
|
timer := time.NewTimer(u.delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (u retryRealtimeUpdater) UpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
|
|
if u.delegate == nil || len(envs) == 0 {
|
|
return nil
|
|
}
|
|
attempts := u.attempts
|
|
if attempts <= 0 {
|
|
attempts = 1
|
|
}
|
|
var err error
|
|
for attempt := 1; attempt <= attempts; attempt++ {
|
|
err = updateRealtimeBatch(ctx, u.delegate, envs)
|
|
if err == nil || !isTransientMySQLWriteError(err) || attempt == attempts {
|
|
return err
|
|
}
|
|
if u.delay <= 0 {
|
|
continue
|
|
}
|
|
timer := time.NewTimer(u.delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
type throttledRealtimeUpdater struct {
|
|
delegate realtimeUpdater
|
|
interval time.Duration
|
|
store string
|
|
registry *metrics.Registry
|
|
cacheRetention time.Duration
|
|
cleanupInterval time.Duration
|
|
mu sync.Mutex
|
|
lastMS map[string]int64
|
|
lastCleanupMS int64
|
|
lastDeleted int
|
|
}
|
|
|
|
func newThrottledRealtimeUpdater(delegate realtimeUpdater, interval time.Duration, store string, registry *metrics.Registry) realtimeUpdater {
|
|
return newThrottledRealtimeUpdaterWithCache(delegate, interval, store, registry, defaultThrottleCacheRetention, defaultThrottleCacheCleanupInterval)
|
|
}
|
|
|
|
func newThrottledRealtimeUpdaterWithCache(delegate realtimeUpdater, interval time.Duration, store string, registry *metrics.Registry, cacheRetention time.Duration, cleanupInterval time.Duration) realtimeUpdater {
|
|
if delegate == nil || interval <= 0 {
|
|
return delegate
|
|
}
|
|
if cacheRetention < 0 {
|
|
cacheRetention = 0
|
|
}
|
|
if cleanupInterval < 0 {
|
|
cleanupInterval = 0
|
|
}
|
|
return &throttledRealtimeUpdater{
|
|
delegate: delegate,
|
|
interval: interval,
|
|
store: store,
|
|
registry: registry,
|
|
cacheRetention: cacheRetention,
|
|
cleanupInterval: cleanupInterval,
|
|
lastMS: map[string]int64{},
|
|
}
|
|
}
|
|
|
|
func (u *throttledRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if u.delegate == nil {
|
|
return nil
|
|
}
|
|
pass, status := u.shouldPass(env)
|
|
u.record(status, env)
|
|
u.recordCacheMetrics()
|
|
if !pass {
|
|
return nil
|
|
}
|
|
return u.delegate.Update(ctx, env)
|
|
}
|
|
|
|
func (u *throttledRealtimeUpdater) UpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
|
|
for _, env := range envs {
|
|
if err := u.Update(ctx, env); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *throttledRealtimeUpdater) shouldPass(env envelope.FrameEnvelope) (bool, string) {
|
|
if !envelope.IsRealtimeTelemetryFrame(env) {
|
|
return false, "skipped_non_realtime"
|
|
}
|
|
vin := strings.TrimSpace(env.VIN)
|
|
if vin == "" {
|
|
return false, "skipped_missing_vin"
|
|
}
|
|
eventMS, ok := envelope.NormalizedEventTimeMS(env)
|
|
if !ok || eventMS <= 0 {
|
|
eventMS = env.ReceivedAtMS
|
|
}
|
|
if eventMS <= 0 {
|
|
eventMS = time.Now().UnixMilli()
|
|
}
|
|
key := string(env.Protocol) + "\x00" + vin
|
|
intervalMS := u.interval.Milliseconds()
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
if u.lastMS == nil {
|
|
u.lastMS = map[string]int64{}
|
|
}
|
|
u.cleanupLocked(throttleCleanupTimeMS(env, eventMS))
|
|
if last := u.lastMS[key]; last > 0 && eventMS < last+intervalMS {
|
|
return false, "skipped_interval"
|
|
}
|
|
u.lastMS[key] = eventMS
|
|
return true, "passed"
|
|
}
|
|
|
|
func (u *throttledRealtimeUpdater) cleanupLocked(nowMS int64) {
|
|
if u.cacheRetention <= 0 || nowMS <= 0 {
|
|
return
|
|
}
|
|
intervalMS := u.cleanupInterval.Milliseconds()
|
|
if intervalMS > 0 && u.lastCleanupMS > 0 && nowMS < u.lastCleanupMS+intervalMS {
|
|
return
|
|
}
|
|
u.lastCleanupMS = nowMS
|
|
cutoff := nowMS - u.cacheRetention.Milliseconds()
|
|
deleted := 0
|
|
for key, last := range u.lastMS {
|
|
if last > 0 && last < cutoff {
|
|
delete(u.lastMS, key)
|
|
deleted++
|
|
}
|
|
}
|
|
u.lastDeleted = deleted
|
|
}
|
|
|
|
func throttleCleanupTimeMS(env envelope.FrameEnvelope, fallbackMS int64) int64 {
|
|
if env.ReceivedAtMS > 0 {
|
|
return env.ReceivedAtMS
|
|
}
|
|
if fallbackMS > 0 {
|
|
return fallbackMS
|
|
}
|
|
return time.Now().UnixMilli()
|
|
}
|
|
|
|
func (u *throttledRealtimeUpdater) record(status string, env envelope.FrameEnvelope) {
|
|
if u.registry == nil {
|
|
return
|
|
}
|
|
u.registry.IncCounter("vehicle_realtime_throttle_total", metrics.Labels{
|
|
"store": u.store,
|
|
"protocol": string(env.Protocol),
|
|
"status": status,
|
|
})
|
|
}
|
|
|
|
func (u *throttledRealtimeUpdater) recordCacheMetrics() {
|
|
if u.registry == nil {
|
|
return
|
|
}
|
|
u.mu.Lock()
|
|
entries := len(u.lastMS)
|
|
lastDeleted := u.lastDeleted
|
|
lastCleanupMS := u.lastCleanupMS
|
|
u.mu.Unlock()
|
|
labels := metrics.Labels{"store": u.store}
|
|
u.registry.SetGauge("vehicle_realtime_throttle_cache_entries", labels, float64(entries))
|
|
u.registry.SetGauge("vehicle_realtime_throttle_cache_last_cleanup_deleted", labels, float64(lastDeleted))
|
|
if lastCleanupMS > 0 {
|
|
u.registry.SetGauge("vehicle_realtime_throttle_cache_last_cleanup_unix_seconds", labels, float64(lastCleanupMS)/1000)
|
|
}
|
|
}
|
|
|
|
func isTransientMySQLWriteError(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, "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")
|
|
}
|
|
|
|
type compositeRealtimeUpdater struct {
|
|
primary realtimeUpdater
|
|
secondary realtimeUpdater
|
|
}
|
|
|
|
func composeRealtimeUpdater(primary realtimeUpdater, secondary realtimeUpdater) realtimeUpdater {
|
|
if primary == nil {
|
|
return secondary
|
|
}
|
|
if secondary == nil {
|
|
return primary
|
|
}
|
|
return compositeRealtimeUpdater{primary: primary, secondary: secondary}
|
|
}
|
|
|
|
func (u compositeRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if u.primary != nil {
|
|
if err := u.primary.Update(ctx, env); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if u.secondary == nil {
|
|
return nil
|
|
}
|
|
return u.secondary.Update(ctx, env)
|
|
}
|
|
|
|
func (u compositeRealtimeUpdater) UpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
|
|
if u.primary != nil {
|
|
if err := updateRealtimeBatch(ctx, u.primary, envs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return updateRealtimeBatch(ctx, u.secondary, envs)
|
|
}
|
|
|
|
type asyncSecondaryRealtimeUpdater struct {
|
|
primary realtimeUpdater
|
|
secondary realtimeUpdater
|
|
queue chan envelope.FrameEnvelope
|
|
registry *metrics.Registry
|
|
logger realtimeLogger
|
|
closeOnce sync.Once
|
|
closed chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func newAsyncSecondaryRealtimeUpdater(primary realtimeUpdater, secondary realtimeUpdater, queueSize int, workers int, registry *metrics.Registry, logger realtimeLogger) *asyncSecondaryRealtimeUpdater {
|
|
if queueSize <= 0 {
|
|
queueSize = 20000
|
|
}
|
|
if workers <= 0 {
|
|
workers = 4
|
|
}
|
|
updater := &asyncSecondaryRealtimeUpdater{
|
|
primary: primary,
|
|
secondary: secondary,
|
|
queue: make(chan envelope.FrameEnvelope, queueSize),
|
|
registry: registry,
|
|
logger: logger,
|
|
closed: make(chan struct{}),
|
|
}
|
|
updater.recordQueueCapacity()
|
|
updater.wg.Add(workers)
|
|
for i := 0; i < workers; i++ {
|
|
go updater.run()
|
|
}
|
|
return updater
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
|
if u.primary != nil {
|
|
if err := u.primary.Update(ctx, env); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if u.secondary == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-u.closed:
|
|
u.recordQueueMetric(env, "closed")
|
|
u.recordQueueDepth(env)
|
|
return nil
|
|
default:
|
|
}
|
|
select {
|
|
case u.queue <- env:
|
|
u.recordQueueMetric(env, "queued")
|
|
u.recordQueueDepth(env)
|
|
return nil
|
|
case <-u.closed:
|
|
u.recordQueueMetric(env, "closed")
|
|
u.recordQueueDepth(env)
|
|
return nil
|
|
default:
|
|
u.recordQueueMetric(env, "dropped")
|
|
u.recordQueueDepth(env)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) UpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
|
|
if u.primary != nil {
|
|
if err := updateRealtimeBatch(ctx, u.primary, envs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if u.secondary == nil {
|
|
return nil
|
|
}
|
|
for _, env := range envs {
|
|
if err := u.enqueueSecondary(env); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) enqueueSecondary(env envelope.FrameEnvelope) error {
|
|
select {
|
|
case <-u.closed:
|
|
u.recordQueueMetric(env, "closed")
|
|
u.recordQueueDepth(env)
|
|
return nil
|
|
default:
|
|
}
|
|
select {
|
|
case u.queue <- env:
|
|
u.recordQueueMetric(env, "queued")
|
|
u.recordQueueDepth(env)
|
|
return nil
|
|
case <-u.closed:
|
|
u.recordQueueMetric(env, "closed")
|
|
u.recordQueueDepth(env)
|
|
return nil
|
|
default:
|
|
u.recordQueueMetric(env, "dropped")
|
|
u.recordQueueDepth(env)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) run() {
|
|
defer u.wg.Done()
|
|
for {
|
|
select {
|
|
case env := <-u.queue:
|
|
u.updateSecondary(env)
|
|
case <-u.closed:
|
|
for {
|
|
select {
|
|
case env := <-u.queue:
|
|
u.updateSecondary(env)
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) updateSecondary(env envelope.FrameEnvelope) {
|
|
u.recordQueueDepth(env)
|
|
secondaryCtx, cancel := context.WithTimeout(context.Background(), kafkaMessageOperationTimeout)
|
|
if err := u.secondary.Update(secondaryCtx, env); err != nil && u.logger != nil {
|
|
u.logger.Error("async realtime secondary update failed", "protocol", env.Protocol, "vin", env.VIN, "event_id", env.StableEventID(), "error", err)
|
|
}
|
|
cancel()
|
|
u.recordQueueDepth(env)
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) recordQueueMetric(env envelope.FrameEnvelope, status string) {
|
|
if u.registry == nil {
|
|
return
|
|
}
|
|
u.registry.IncCounter("vehicle_realtime_async_queue_total", metrics.Labels{
|
|
"store": "mysql",
|
|
"protocol": string(env.Protocol),
|
|
"status": status,
|
|
})
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) recordQueueDepth(env envelope.FrameEnvelope) {
|
|
if u.registry == nil {
|
|
return
|
|
}
|
|
u.registry.SetGauge("vehicle_realtime_async_queue_depth", metrics.Labels{
|
|
"store": "mysql",
|
|
"protocol": string(env.Protocol),
|
|
}, float64(len(u.queue)))
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) recordQueueCapacity() {
|
|
if u.registry == nil {
|
|
return
|
|
}
|
|
u.registry.SetGauge("vehicle_realtime_async_queue_capacity", metrics.Labels{
|
|
"store": "mysql",
|
|
}, float64(cap(u.queue)))
|
|
}
|
|
|
|
func (u *asyncSecondaryRealtimeUpdater) Close() {
|
|
u.closeOnce.Do(func() {
|
|
close(u.closed)
|
|
})
|
|
u.wg.Wait()
|
|
}
|
|
|
|
type kafkaMessageCommitter interface {
|
|
CommitMessages(context.Context, ...kafka.Message) error
|
|
}
|
|
|
|
type kafkaMessageFetcher interface {
|
|
FetchMessage(context.Context) (kafka.Message, error)
|
|
}
|
|
|
|
type realtimeBatchItem struct {
|
|
message kafka.Message
|
|
env envelope.FrameEnvelope
|
|
valid bool
|
|
processed bool
|
|
}
|
|
|
|
type realtimeBatchOutcome struct {
|
|
commitMessages []kafka.Message
|
|
retryMessages []kafka.Message
|
|
}
|
|
|
|
func collectRealtimeBatch(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 processRealtimeBatch(ctx context.Context, logger interface {
|
|
Error(string, ...any)
|
|
Warn(string, ...any)
|
|
}, registry *metrics.Registry, updater realtimeUpdater, committer kafkaMessageCommitter, messages []kafka.Message) realtimeBatchOutcome {
|
|
return processRealtimeBatchForWorker(ctx, logger, registry, updater, committer, messages, nil)
|
|
}
|
|
|
|
func processRealtimeBatchForWorker(ctx context.Context, logger interface {
|
|
Error(string, ...any)
|
|
Warn(string, ...any)
|
|
}, registry *metrics.Registry, updater realtimeUpdater, committer kafkaMessageCommitter, messages []kafka.Message, workerLabels metrics.Labels) realtimeBatchOutcome {
|
|
if len(messages) == 0 {
|
|
return realtimeBatchOutcome{}
|
|
}
|
|
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
|
defer cancel()
|
|
|
|
setRealtimeBatchPendingForWorker(registry, workerLabels, len(messages), 0)
|
|
defer setRealtimeBatchPendingForWorker(registry, workerLabels, 0, 0)
|
|
items := make([]realtimeBatchItem, 0, len(messages))
|
|
envs := make([]envelope.FrameEnvelope, 0, len(messages))
|
|
for _, message := range messages {
|
|
itemIndex := len(items)
|
|
items = append(items, realtimeBatchItem{message: message})
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_messages_total", message, "received")
|
|
addRealtimeLagMetric(registry, message)
|
|
var env envelope.FrameEnvelope
|
|
if err := json.Unmarshal(message.Value, &env); err != nil {
|
|
addRealtimeMetric(registry, "vehicle_realtime_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 := topics.ValidateRawEnvelope(message.Topic, env); err != nil {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_messages_total", message, status)
|
|
logger.Warn("skip mismatched realtime 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[itemIndex].processed = true
|
|
continue
|
|
}
|
|
if status := realtimeProjectionSkipStatus(env); status != "" {
|
|
addRealtimeMetric(registry, "vehicle_realtime_updates_total", message, status)
|
|
items[itemIndex].processed = true
|
|
continue
|
|
}
|
|
items[itemIndex].env = env
|
|
items[itemIndex].valid = true
|
|
envs = append(envs, env)
|
|
}
|
|
setRealtimeBatchPendingForWorker(registry, workerLabels, len(messages), len(envs))
|
|
if len(envs) > 0 {
|
|
started := time.Now()
|
|
err := updateRealtimeBatch(messageCtx, updater, envs)
|
|
recordRealtimeBatchFlush(registry, statusFromError(err), len(messages), len(envs), time.Since(started))
|
|
if err != nil {
|
|
logger.Error("realtime batch update failed; falling back to per-message isolation", "messages", len(messages), "valid_messages", len(envs), "error", err)
|
|
if !fallbackRealtimeBatch(messageCtx, logger, registry, updater, items) {
|
|
committed, commitErr := commitRealtimeProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
|
|
return realtimeFailureOutcome(messages, committed, commitErr)
|
|
}
|
|
} else {
|
|
for index := range items {
|
|
if items[index].valid {
|
|
items[index].processed = true
|
|
addRealtimeMetric(registry, "vehicle_realtime_updates_total", items[index].message, "ok")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err := committer.CommitMessages(messageCtx, messages...); err != nil {
|
|
for _, message := range messages {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "error")
|
|
}
|
|
first := messages[0]
|
|
logger.Error("kafka batch commit failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
|
|
return realtimeBatchOutcome{commitMessages: messages}
|
|
}
|
|
for _, message := range messages {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "ok")
|
|
}
|
|
return realtimeBatchOutcome{}
|
|
}
|
|
|
|
func realtimeFailureOutcome(messages []kafka.Message, committed []kafka.Message, commitErr error) realtimeBatchOutcome {
|
|
outcome := realtimeBatchOutcome{
|
|
retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed),
|
|
}
|
|
if commitErr != nil {
|
|
outcome.commitMessages = committed
|
|
}
|
|
return outcome
|
|
}
|
|
|
|
func processRealtimeBatchReliably(ctx context.Context, logger interface {
|
|
Error(string, ...any)
|
|
Warn(string, ...any)
|
|
}, registry *metrics.Registry, updater realtimeUpdater, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) {
|
|
processRealtimeBatchReliablyForWorker(ctx, logger, registry, updater, committer, messages, retryDelay, nil)
|
|
}
|
|
|
|
func processRealtimeBatchReliablyForWorker(ctx context.Context, logger interface {
|
|
Error(string, ...any)
|
|
Warn(string, ...any)
|
|
}, registry *metrics.Registry, updater realtimeUpdater, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) {
|
|
defer registry.SetGauge("vehicle_realtime_retry_pending_messages", workerLabels, 0)
|
|
pending := messages
|
|
for len(pending) > 0 {
|
|
outcome := processRealtimeBatchForWorker(ctx, logger, registry, updater, committer, pending, workerLabels)
|
|
if len(outcome.commitMessages) > 0 {
|
|
registry.IncCounter("vehicle_realtime_batch_retries_total", metrics.Labels{"reason": "commit_error"})
|
|
if !retryRealtimeCommit(ctx, logger, registry, committer, outcome.commitMessages, retryDelay) {
|
|
return
|
|
}
|
|
}
|
|
pending = outcome.retryMessages
|
|
registry.SetGauge("vehicle_realtime_retry_pending_messages", workerLabels, float64(len(pending)))
|
|
if len(pending) == 0 {
|
|
return
|
|
}
|
|
registry.IncCounter("vehicle_realtime_batch_retries_total", metrics.Labels{"reason": "write_error"})
|
|
if !waitForRealtimeRetry(ctx, retryDelay) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func retryRealtimeCommit(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 {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "ok")
|
|
}
|
|
return true
|
|
}
|
|
for _, message := range messages {
|
|
addRealtimeMetric(registry, "vehicle_realtime_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_realtime_batch_retries_total", metrics.Labels{"reason": "commit_error"})
|
|
if !waitForRealtimeRetry(ctx, retryDelay) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func waitForRealtimeRetry(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 fallbackRealtimeBatch(ctx context.Context, logger interface {
|
|
Error(string, ...any)
|
|
Warn(string, ...any)
|
|
}, registry *metrics.Registry, updater realtimeUpdater, items []realtimeBatchItem) bool {
|
|
for index := range items {
|
|
if !items[index].valid {
|
|
continue
|
|
}
|
|
if err := updater.Update(ctx, items[index].env); err != nil {
|
|
message := items[index].message
|
|
addRealtimeMetric(registry, "vehicle_realtime_updates_total", message, "error")
|
|
logger.Error("realtime fallback update failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", items[index].env.StableEventID(), "error", err)
|
|
return false
|
|
}
|
|
items[index].processed = true
|
|
addRealtimeMetric(registry, "vehicle_realtime_updates_total", items[index].message, "ok")
|
|
}
|
|
return true
|
|
}
|
|
|
|
func commitRealtimeProcessedPrefixAfterFailure(ctx context.Context, logger interface {
|
|
Error(string, ...any)
|
|
Warn(string, ...any)
|
|
}, registry *metrics.Registry, committer kafkaMessageCommitter, items []realtimeBatchItem) ([]kafka.Message, error) {
|
|
committable := realtimeProcessedPrefixMessagesByPartition(items)
|
|
if len(committable) == 0 {
|
|
return nil, nil
|
|
}
|
|
if err := committer.CommitMessages(ctx, committable...); err != nil {
|
|
for _, message := range committable {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "error")
|
|
}
|
|
first := committable[0]
|
|
logger.Error("kafka realtime processed-prefix commit failed after batch update failure", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(committable), "error", err)
|
|
return committable, err
|
|
}
|
|
for _, message := range committable {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "ok")
|
|
}
|
|
return committable, nil
|
|
}
|
|
|
|
func realtimeProcessedPrefixMessagesByPartition(items []realtimeBatchItem) []kafka.Message {
|
|
type partitionKey struct {
|
|
topic string
|
|
partition int
|
|
}
|
|
groups := map[partitionKey][]realtimeBatchItem{}
|
|
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 updateRealtimeBatch(ctx context.Context, updater realtimeUpdater, envs []envelope.FrameEnvelope) error {
|
|
if updater == nil || len(envs) == 0 {
|
|
return nil
|
|
}
|
|
if batchUpdater, ok := updater.(realtimeBatchUpdater); ok {
|
|
return batchUpdater.UpdateBatch(ctx, envs)
|
|
}
|
|
for _, env := range envs {
|
|
if err := updater.Update(ctx, env); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func processRealtimeMessage(ctx context.Context, logger interface {
|
|
Error(string, ...any)
|
|
Warn(string, ...any)
|
|
}, registry *metrics.Registry, updater realtimeUpdater, committer kafkaMessageCommitter, message kafka.Message) {
|
|
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
|
defer cancel()
|
|
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_messages_total", message, "received")
|
|
addRealtimeLagMetric(registry, message)
|
|
var env envelope.FrameEnvelope
|
|
if err := json.Unmarshal(message.Value, &env); err != nil {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_messages_total", message, "invalid_json")
|
|
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
|
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "error")
|
|
logger.Error("kafka commit invalid envelope failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
|
return
|
|
}
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "ok")
|
|
return
|
|
}
|
|
if status, err := topics.ValidateRawEnvelope(message.Topic, env); err != nil {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_messages_total", message, status)
|
|
logger.Warn("skip mismatched realtime 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 {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "error")
|
|
logger.Error("kafka commit mismatched realtime raw envelope failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "status", status, "error", err)
|
|
return
|
|
}
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "ok")
|
|
return
|
|
}
|
|
if status := realtimeProjectionSkipStatus(env); status != "" {
|
|
addRealtimeMetric(registry, "vehicle_realtime_updates_total", message, status)
|
|
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "error")
|
|
logger.Error("kafka commit skipped realtime envelope failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "status", status, "error", err)
|
|
return
|
|
}
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "ok")
|
|
return
|
|
}
|
|
if err := updater.Update(messageCtx, env); err != nil {
|
|
addRealtimeMetric(registry, "vehicle_realtime_updates_total", message, "error")
|
|
logger.Error("realtime projector update failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
|
return
|
|
}
|
|
addRealtimeMetric(registry, "vehicle_realtime_updates_total", message, "ok")
|
|
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "error")
|
|
logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
|
return
|
|
}
|
|
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "ok")
|
|
}
|
|
|
|
func realtimeProjectionSkipStatus(env envelope.FrameEnvelope) string {
|
|
if !envelope.IsRealtimeTelemetryFrame(env) {
|
|
return "skipped_non_realtime"
|
|
}
|
|
if strings.TrimSpace(env.VIN) == "" {
|
|
return "skipped_missing_vin"
|
|
}
|
|
vehicleKey := strings.TrimSpace(env.VehicleKey())
|
|
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
|
|
return "skipped_missing_vehicle_key"
|
|
}
|
|
if len(env.ParsedFields) == 0 {
|
|
return "skipped_missing_fields"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func addRealtimeMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
labels := metrics.Labels{
|
|
"topic": message.Topic,
|
|
"status": status,
|
|
}
|
|
registry.IncCounter(name, labels)
|
|
switch name {
|
|
case "vehicle_realtime_kafka_messages_total":
|
|
metrics.RecordLastActivity(registry, "vehicle_realtime_last_message_unix_seconds", labels)
|
|
case "vehicle_realtime_updates_total":
|
|
metrics.RecordLastActivity(registry, "vehicle_realtime_last_update_unix_seconds", labels)
|
|
case "vehicle_realtime_kafka_commits_total":
|
|
metrics.RecordLastActivity(registry, "vehicle_realtime_last_commit_unix_seconds", labels)
|
|
}
|
|
}
|
|
|
|
func recordPlateCacheStats(registry *metrics.Registry, stats realtime.PlateCacheStats) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
registry.SetGauge("vehicle_realtime_plate_cache_entries", nil, float64(stats.Entries))
|
|
registry.SetGauge("vehicle_realtime_plate_cache_max_entries", nil, float64(stats.MaxEntries))
|
|
registry.SetGauge("vehicle_realtime_plate_cache_evictions_total", nil, float64(stats.Evictions))
|
|
}
|
|
|
|
func addRealtimeLagMetric(registry *metrics.Registry, message kafka.Message) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
registry.SetKafkaLag("vehicle_realtime_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark)
|
|
}
|
|
|
|
var realtimeBatchFlushDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
|
|
|
func recordRealtimeBatchFlush(registry *metrics.Registry, status string, messages int, validMessages int, elapsed time.Duration) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
labels := metrics.Labels{"status": status}
|
|
elapsedMS := float64(elapsed.Milliseconds())
|
|
registry.IncCounter("vehicle_realtime_batch_flush_total", labels)
|
|
registry.AddCounter("vehicle_realtime_batch_messages_total", labels, float64(messages))
|
|
registry.AddCounter("vehicle_realtime_batch_valid_messages_total", labels, float64(validMessages))
|
|
registry.SetGauge("vehicle_realtime_batch_flush_duration_ms", labels, elapsedMS)
|
|
registry.ObserveHistogram("vehicle_realtime_batch_flush_duration_ms_histogram", labels, realtimeBatchFlushDurationBucketsMS, elapsedMS)
|
|
}
|
|
|
|
func setRealtimeBatchPending(registry *metrics.Registry, messages int, validMessages int) {
|
|
setRealtimeBatchPendingForWorker(registry, nil, messages, validMessages)
|
|
}
|
|
|
|
func setRealtimeBatchPendingForWorker(registry *metrics.Registry, workerLabels metrics.Labels, messages int, validMessages int) {
|
|
if registry == nil {
|
|
return
|
|
}
|
|
registry.SetGauge("vehicle_realtime_batch_pending_messages", workerLabels, float64(messages))
|
|
registry.SetGauge("vehicle_realtime_batch_pending_valid_messages", workerLabels, float64(validMessages))
|
|
}
|
|
|
|
func statusFromError(err error) string {
|
|
if err != nil {
|
|
return "error"
|
|
}
|
|
return "ok"
|
|
}
|
|
|
|
func realtimeRoleFromEnv() string {
|
|
return normalizeRealtimeRole(env("REALTIME_ROLE", "combined"))
|
|
}
|
|
|
|
func normalizeRealtimeRole(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "api", "query", "read", "readonly", "read-only":
|
|
return "api"
|
|
case "writer", "projector", "consumer", "write":
|
|
return "writer"
|
|
case "combined", "all", "both", "":
|
|
return "combined"
|
|
default:
|
|
return "combined"
|
|
}
|
|
}
|
|
|
|
func realtimeRoleAllowsAPI(role string) bool {
|
|
switch normalizeRealtimeRole(role) {
|
|
case "writer":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func realtimeRoleAllowsWriter(role string) bool {
|
|
switch normalizeRealtimeRole(role) {
|
|
case "api":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func realtimeRoleNeedsRedis(apiEnabled bool, writerEnabled bool, redisProjectorEnabled bool) bool {
|
|
return apiEnabled || (writerEnabled && redisProjectorEnabled)
|
|
}
|
|
|
|
func realtimeServiceName(role string) string {
|
|
if normalizeRealtimeRole(role) == "writer" {
|
|
return "vehicle-realtime-writer"
|
|
}
|
|
return "vehicle-realtime-api"
|
|
}
|
|
|
|
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 boolGauge(value bool) float64 {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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
|
|
}
|