Files
lingniu-vehicle-ingest/go/vehicle-gateway/cmd/realtime-api/main.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
}