Files
lingniu-vehicle-ingest/go/vehicle-gateway/cmd/nats-fast-writer/main.go

965 lines
34 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/nats-io/nats.go"
"github.com/redis/go-redis/v9"
_ "github.com/taosdata/driver-go/v3/taosWS"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"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/topics"
)
func main() {
logger := observability.NewLogger("nats-fast-writer")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg := loadConfig()
conn, err := nats.Connect(cfg.NATSURL, nats.Name(cfg.NATSClientName), nats.Timeout(5*time.Second))
if err != nil {
logger.Error("nats connect failed", "error", err)
os.Exit(1)
}
defer conn.Close()
js, err := conn.JetStream()
if err != nil {
logger.Error("nats jetstream init failed", "error", err)
os.Exit(1)
}
if err := ensureStream(js, cfg); err != nil {
logger.Error("nats stream ensure failed", "stream", cfg.NATSStream, "error", err)
os.Exit(1)
}
sub, err := js.PullSubscribe(
cfg.NATSFilter,
cfg.NATSDurable,
nats.BindStream(cfg.NATSStream),
nats.ManualAck(),
nats.AckWait(cfg.AckWait),
nats.MaxDeliver(-1),
)
if err != nil {
logger.Error("nats pull consumer init failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err)
os.Exit(1)
}
var historyWriter fastAppender
var tdCheck health.Check
if cfg.TDengineEnabled {
tdDB, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
if err != nil {
logger.Error("tdengine open failed", "error", err)
os.Exit(1)
}
defer tdDB.Close()
tdDB.SetMaxOpenConns(cfg.TDengineMaxOpenConns)
tdDB.SetMaxIdleConns(cfg.TDengineMaxIdleConns)
if err := tdDB.PingContext(ctx); err != nil {
logger.Error("tdengine ping failed", "error", err)
os.Exit(1)
}
writer := history.NewWriterWithDatabase(tdDB, cfg.TDengineDatabase)
if cfg.TDengineEnsureSchema {
if err := writer.EnsureSchema(ctx, cfg.TDengineDatabase); err != nil {
logger.Error("tdengine schema bootstrap failed", "error", err)
os.Exit(1)
}
}
historyWriter = writer
tdCheck = health.Check{Name: "tdengine", Check: tdDB.PingContext}
} else {
logger.Info("nats fast writer tdengine stage disabled")
}
redisClient := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddr,
Username: cfg.RedisUsername,
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
})
defer redisClient.Close()
if err := redisClient.Ping(ctx).Err(); err != nil {
logger.Error("redis ping failed", "error", err)
os.Exit(1)
}
realtimeRepo := realtime.NewRepository(redisClient, realtime.Config{OnlineTTL: cfg.OnlineTTL})
registry := metrics.NewRegistry()
recordFastWriterConfigMetrics(registry, cfg)
registry.SetGauge("vehicle_fast_writer_tdengine_enabled", nil, boolGauge(cfg.TDengineEnabled))
healthChecks := []health.Check{
{Name: "nats", Check: func(context.Context) error {
if conn.Status() != nats.CONNECTED {
return fmt.Errorf("nats status is %s", conn.Status().String())
}
return nil
}},
{Name: "redis", Check: func(ctx context.Context) error { return redisClient.Ping(ctx).Err() }},
}
if tdCheck.Name != "" {
healthChecks = append(healthChecks, tdCheck)
}
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-fast-writer", healthChecks, registry))
logger.Info("nats fast writer started",
"stream", cfg.NATSStream,
"durable", cfg.NATSDurable,
"filter", cfg.NATSFilter,
"batch_size", cfg.BatchSize,
"fetch_wait_ms", cfg.FetchWait.Milliseconds(),
"workers", cfg.Workers,
"tdengine_enabled", cfg.TDengineEnabled,
"tdengine_max_open_conns", cfg.TDengineMaxOpenConns,
"tdengine_max_idle_conns", cfg.TDengineMaxIdleConns,
"operation_timeout_ms", cfg.OperationWait.Milliseconds())
for i := 0; i < cfg.Workers; i++ {
go runFastWorker(ctx, logger, registry, js, sub, historyWriter, realtimeRepo, cfg)
}
<-ctx.Done()
}
type config struct {
NATSURL string
NATSClientName string
NATSStream string
NATSDurable string
NATSFilter string
NATSSubjects []string
BatchSize int
FetchWait time.Duration
OperationWait time.Duration
AckWait time.Duration
StreamMaxAge time.Duration
StreamMaxBytes int64
StreamEnsureWait time.Duration
Workers int
TDengineEnabled bool
TDengineDriver string
TDengineDSN string
TDengineDatabase string
TDengineEnsureSchema bool
TDengineMaxOpenConns int
TDengineMaxIdleConns int
RedisAddr string
RedisUsername string
RedisPassword string
RedisDB int
OnlineTTL time.Duration
}
func loadConfig() config {
subjects := splitCSV(env("NATS_STREAM_SUBJECTS", strings.Join([]string{
env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)),
env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)),
env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)),
env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)),
env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)),
env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)),
}, ",")))
workers := envInt("FAST_WRITER_WORKERS", 8)
maxOpenConns := envInt("FAST_WRITER_TDENGINE_MAX_OPEN_CONNS", 1)
maxIdleConns := envInt("FAST_WRITER_TDENGINE_MAX_IDLE_CONNS", maxOpenConns)
return config{
NATSURL: env("NATS_URL", "nats://127.0.0.1:4222"),
NATSClientName: env("NATS_CLIENT_NAME", "lingniu-nats-fast-writer"),
NATSStream: env("NATS_STREAM", "VEHICLE_INGEST"),
NATSDurable: env("NATS_DURABLE", "vehicle-fast-writer"),
NATSFilter: env("NATS_FILTER", "vehicle.raw.go.>"),
NATSSubjects: subjects,
BatchSize: envInt("FAST_WRITER_BATCH_SIZE", 100),
FetchWait: time.Duration(envInt("FAST_WRITER_FETCH_WAIT_MS", 20)) * time.Millisecond,
OperationWait: time.Duration(envInt("FAST_WRITER_OPERATION_TIMEOUT_MS", 1000)) * time.Millisecond,
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 30)) * time.Second,
StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour,
StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024),
StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second,
Workers: workers,
TDengineEnabled: envBool("FAST_WRITER_TDENGINE_ENABLED", false),
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
TDengineDSN: env("TDENGINE_DSN", ""),
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
TDengineEnsureSchema: env("TDENGINE_ENSURE_SCHEMA", "true") != "false",
TDengineMaxOpenConns: maxOpenConns,
TDengineMaxIdleConns: maxIdleConns,
RedisAddr: env("REDIS_ADDR", "127.0.0.1:6379"),
RedisUsername: env("REDIS_USERNAME", ""),
RedisPassword: env("REDIS_PASSWORD", ""),
RedisDB: envInt("REDIS_DB", 0),
OnlineTTL: time.Duration(envInt("REALTIME_ONLINE_TTL_SECONDS", 60)) * time.Second,
}
}
type fastAppender interface {
AppendAll(context.Context, envelope.FrameEnvelope) error
AppendAllBatch(context.Context, []envelope.FrameEnvelope) error
}
type fastUpdater interface {
FastUpdate(context.Context, envelope.FrameEnvelope) error
}
type fastResultUpdater interface {
FastUpdateWithResult(context.Context, envelope.FrameEnvelope) (realtime.FastUpdateResult, error)
}
type fastBatchUpdater interface {
FastUpdateBatch(context.Context, []envelope.FrameEnvelope) error
}
type fastBatchResultUpdater interface {
FastUpdateBatchWithResult(context.Context, []envelope.FrameEnvelope) (realtime.FastUpdateResult, error)
}
type fastMessage struct {
subject string
data []byte
ack func() error
}
func runFastWorker(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, infoReader natsConsumerInfoReader, sub natsPullSubscription, appender fastAppender, updater fastUpdater, cfg config) {
var lastConsumerInfoAt time.Time
for {
if ctx.Err() != nil {
return
}
if time.Since(lastConsumerInfoAt) >= time.Duration(envInt("NATS_CONSUMER_METRICS_INTERVAL_SECONDS", 10))*time.Second {
lastConsumerInfoAt = time.Now()
if infoReader != nil {
info, err := infoReader.ConsumerInfo(cfg.NATSStream, cfg.NATSDurable, nats.Context(ctx))
if err != nil {
logger.Warn("nats consumer info failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err)
} else {
recordFastNATSConsumerInfoMetrics(registry, cfg, info)
}
}
}
msgs, err := sub.Fetch(cfg.BatchSize, nats.MaxWait(cfg.FetchWait))
if err != nil {
if isFastWorkerShutdownFetchError(ctx, err) {
return
}
if errors.Is(err, nats.ErrTimeout) {
continue
}
if isTransientFastFetchError(err) {
logger.Warn("nats fetch interrupted", "error", err)
time.Sleep(time.Second)
continue
}
logger.Error("nats fetch failed", "error", err)
time.Sleep(time.Second)
continue
}
fastMessages := make([]*fastMessage, 0, len(msgs))
for _, msg := range msgs {
natsMsg := msg
fastMessages = append(fastMessages, &fastMessage{
subject: natsMsg.Subject,
data: natsMsg.Data,
ack: func() error {
return natsMsg.Ack()
},
})
}
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.OperationWait)
err = processFastBatch(operationCtx, registry, appender, updater, fastMessages)
cancel()
if err != nil {
addFastMetric(registry, fastBatchSubject(fastMessages), "error")
logger.Error("fast write batch failed", "messages", len(fastMessages), "error", err)
continue
}
}
}
func isFastWorkerShutdownFetchError(ctx context.Context, err error) bool {
if err == nil {
return false
}
if ctx.Err() != nil {
return true
}
return errors.Is(err, nats.ErrConnectionClosed)
}
func isTransientFastFetchError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(text, "disconnected during fetch") ||
strings.Contains(text, "connection closed") ||
strings.Contains(text, "connection reset") ||
strings.Contains(text, "broken pipe") ||
strings.Contains(text, "temporary") ||
strings.Contains(text, "temporarily") ||
strings.Contains(text, "timeout")
}
type natsPullSubscription interface {
Fetch(int, ...nats.PullOpt) ([]*nats.Msg, error)
}
type natsConsumerInfoReader interface {
ConsumerInfo(stream string, name string, opts ...nats.JSOpt) (*nats.ConsumerInfo, error)
}
var fastWriterStageDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var fastWriterRedisE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
var fastWriterRedisE2ERecent = metrics.NewRecentLatencyByKey(512)
var fastBatchPending = metrics.PendingPairGauge{}
func processFastBatch(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, messages []*fastMessage) error {
if len(messages) == 0 {
return nil
}
for _, msg := range messages {
addFastMetric(registry, msg.subject, "received")
}
envelopes := make([]envelope.FrameEnvelope, 0, len(messages))
validMessages := make([]*fastMessage, 0, len(messages))
for _, msg := range messages {
var env envelope.FrameEnvelope
if err := json.Unmarshal(msg.data, &env); err != nil {
addFastMetric(registry, msg.subject, "invalid_json")
if msg.ack != nil {
started := time.Now()
err := msg.ack()
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
if err != nil {
addFastMetric(registry, msg.subject, "ack_error")
return fmt.Errorf("nats ack invalid json: %w", err)
}
}
continue
}
if status, err := topics.ValidateRawEnvelope(msg.subject, env); err != nil {
addFastMetric(registry, msg.subject, status)
if msg.ack != nil {
started := time.Now()
err := msg.ack()
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
if err != nil {
addFastMetric(registry, msg.subject, "ack_error")
return fmt.Errorf("nats ack mismatched raw envelope: %w", err)
}
}
continue
}
envelopes = append(envelopes, env)
validMessages = append(validMessages, msg)
}
if len(envelopes) == 0 {
return nil
}
addFastBatchPending(registry, len(messages), len(envelopes))
defer addFastBatchPending(registry, -len(messages), -len(envelopes))
subject := fastBatchSubject(validMessages)
if appender != nil {
started := time.Now()
err := appender.AppendAllBatch(ctx, envelopes)
recordFastWriterStageDuration(registry, subject, "tdengine", statusFromError(err), time.Since(started))
if err != nil {
if shouldFallbackFastBatchError(err) {
if fallbackErr := processFastMessagesIndividually(ctx, registry, appender, updater, validMessages); fallbackErr != nil {
addFastBatchFallbackMetric(registry, "tdengine", "error")
return fmt.Errorf("tdengine batch fallback: %w", fallbackErr)
}
addFastBatchFallbackMetric(registry, "tdengine", "ok")
return nil
}
addFastBatchFallbackMetric(registry, "tdengine", "skipped_transient")
if catchupErr := updateFastRedisBatchWithoutAck(ctx, registry, updater, validMessages, envelopes); catchupErr != nil {
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "error")
return fmt.Errorf("tdengine batch append: %w; redis catchup: %v", err, catchupErr)
}
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "ok")
return fmt.Errorf("tdengine batch append: %w", err)
}
}
if batchUpdater, ok := updater.(fastBatchResultUpdater); ok {
for _, group := range fastSubjectGroups(validMessages, envelopes) {
started := time.Now()
result, err := batchUpdater.FastUpdateBatchWithResult(ctx, group.envelopes)
recordFastWriterStageDuration(registry, group.subject, "redis", statusFromError(err), time.Since(started))
if err != nil {
if shouldFallbackFastBatchError(err) {
if fallbackErr := processFastMessagesIndividually(ctx, registry, nil, updater, validMessages); fallbackErr != nil {
addFastBatchFallbackMetric(registry, "redis", "error")
return fmt.Errorf("redis fast batch fallback: %w", fallbackErr)
}
addFastBatchFallbackMetric(registry, "redis", "ok")
return nil
}
addFastBatchFallbackMetric(registry, "redis", "skipped_transient")
return fmt.Errorf("redis fast batch update: %w", err)
}
recordFastWriterRedisEnvelopeMetrics(registry, group.subject, result)
recordFastWriterRedisFieldMetrics(registry, group.subject, result)
recordFastWriterRedisE2EDurationMessages(registry, group.messages, group.envelopes, group.subject)
}
for _, msg := range validMessages {
if msg.ack != nil {
started := time.Now()
err := msg.ack()
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
if err != nil {
addFastMetric(registry, msg.subject, "ack_error")
return fmt.Errorf("nats ack: %w", err)
}
}
addFastMetric(registry, msg.subject, "ok")
}
return nil
}
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
for _, group := range fastSubjectGroups(validMessages, envelopes) {
started := time.Now()
err := batchUpdater.FastUpdateBatch(ctx, group.envelopes)
recordFastWriterStageDuration(registry, group.subject, "redis", statusFromError(err), time.Since(started))
if err != nil {
if shouldFallbackFastBatchError(err) {
if fallbackErr := processFastMessagesIndividually(ctx, registry, nil, updater, validMessages); fallbackErr != nil {
addFastBatchFallbackMetric(registry, "redis", "error")
return fmt.Errorf("redis fast batch fallback: %w", fallbackErr)
}
addFastBatchFallbackMetric(registry, "redis", "ok")
return nil
}
addFastBatchFallbackMetric(registry, "redis", "skipped_transient")
return fmt.Errorf("redis fast batch update: %w", err)
}
recordFastWriterRedisE2EDurationMessages(registry, group.messages, group.envelopes, group.subject)
}
for _, msg := range validMessages {
if msg.ack != nil {
started := time.Now()
err := msg.ack()
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
if err != nil {
addFastMetric(registry, msg.subject, "ack_error")
return fmt.Errorf("nats ack: %w", err)
}
}
addFastMetric(registry, msg.subject, "ok")
}
return nil
}
for i, env := range envelopes {
msg := validMessages[i]
started := time.Now()
var result realtime.FastUpdateResult
var err error
if resultUpdater, ok := updater.(fastResultUpdater); ok {
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
} else {
err = updater.FastUpdate(ctx, env)
}
recordFastWriterStageDuration(registry, msg.subject, "redis", statusFromError(err), time.Since(started))
if err != nil {
return fmt.Errorf("redis fast update: %w", err)
}
recordFastWriterRedisEnvelopeMetrics(registry, msg.subject, result)
recordFastWriterRedisFieldMetrics(registry, msg.subject, result)
recordFastWriterRedisE2EDuration(registry, msg.subject, env)
if msg.ack != nil {
started := time.Now()
err := msg.ack()
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
if err != nil {
addFastMetric(registry, msg.subject, "ack_error")
return fmt.Errorf("nats ack: %w", err)
}
}
addFastMetric(registry, msg.subject, "ok")
}
return nil
}
func processFastMessagesIndividually(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, messages []*fastMessage) error {
for _, msg := range messages {
if err := processFastMessageWithReceived(ctx, registry, appender, updater, msg, false); err != nil {
return err
}
}
return nil
}
func processFastMessage(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, msg *fastMessage) error {
return processFastMessageWithReceived(ctx, registry, appender, updater, msg, true)
}
func processFastMessageWithReceived(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, msg *fastMessage, recordReceived bool) error {
if recordReceived {
addFastMetric(registry, msg.subject, "received")
}
var env envelope.FrameEnvelope
if err := json.Unmarshal(msg.data, &env); err != nil {
addFastMetric(registry, msg.subject, "invalid_json")
if msg.ack != nil {
started := time.Now()
err := msg.ack()
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
if err != nil {
addFastMetric(registry, msg.subject, "ack_error")
return fmt.Errorf("nats ack invalid json: %w", err)
}
}
return nil
}
if status, err := topics.ValidateRawEnvelope(msg.subject, env); err != nil {
addFastMetric(registry, msg.subject, status)
if msg.ack != nil {
started := time.Now()
err := msg.ack()
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
if err != nil {
addFastMetric(registry, msg.subject, "ack_error")
return fmt.Errorf("nats ack mismatched raw envelope: %w", err)
}
}
return nil
}
if appender != nil {
started := time.Now()
err := appender.AppendAll(ctx, env)
recordFastWriterStageDuration(registry, msg.subject, "tdengine", statusFromError(err), time.Since(started))
if err != nil {
if isTransientFastBatchError(err) {
if catchupErr := updateFastRedisSingleWithoutAck(ctx, registry, updater, msg.subject, env); catchupErr != nil {
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "error")
return fmt.Errorf("tdengine append: %w; redis catchup: %v", err, catchupErr)
}
addFastDecoupledUpdateMetric(registry, "tdengine_transient", "ok")
}
return fmt.Errorf("tdengine append: %w", err)
}
}
started := time.Now()
var result realtime.FastUpdateResult
var err error
if resultUpdater, ok := updater.(fastResultUpdater); ok {
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
} else {
err = updater.FastUpdate(ctx, env)
}
recordFastWriterStageDuration(registry, msg.subject, "redis", statusFromError(err), time.Since(started))
if err != nil {
return fmt.Errorf("redis fast update: %w", err)
}
recordFastWriterRedisEnvelopeMetrics(registry, msg.subject, result)
recordFastWriterRedisFieldMetrics(registry, msg.subject, result)
recordFastWriterRedisE2EDuration(registry, msg.subject, env)
if msg.ack != nil {
started = time.Now()
err = msg.ack()
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
if err != nil {
addFastMetric(registry, msg.subject, "ack_error")
return fmt.Errorf("nats ack: %w", err)
}
}
addFastMetric(registry, msg.subject, "ok")
return nil
}
func updateFastRedisBatchWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, messages []*fastMessage, envelopes []envelope.FrameEnvelope) error {
if len(envelopes) == 0 {
return nil
}
subject := fastBatchSubject(messages)
if batchUpdater, ok := updater.(fastBatchResultUpdater); ok {
started := time.Now()
result, err := batchUpdater.FastUpdateBatchWithResult(ctx, envelopes)
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
if err == nil {
recordFastWriterRedisEnvelopeMetrics(registry, subject, result)
recordFastWriterRedisFieldMetrics(registry, subject, result)
recordFastWriterRedisE2EDurationMessages(registry, messages, envelopes, subject)
return nil
}
if shouldFallbackFastBatchError(err) {
addFastBatchFallbackMetric(registry, "redis_catchup", "attempted")
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
}
return err
}
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
started := time.Now()
err := batchUpdater.FastUpdateBatch(ctx, envelopes)
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
if err == nil {
recordFastWriterRedisE2EDurationMessages(registry, messages, envelopes, subject)
return nil
}
if shouldFallbackFastBatchError(err) {
addFastBatchFallbackMetric(registry, "redis_catchup", "attempted")
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
}
return err
}
return updateFastRedisSinglesWithoutAck(ctx, registry, updater, messages, envelopes)
}
func updateFastRedisSinglesWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, messages []*fastMessage, envelopes []envelope.FrameEnvelope) error {
for index, env := range envelopes {
subject := fastMessageSubject(messages, index, fastBatchSubject(messages))
if err := updateFastRedisSingleWithoutAck(ctx, registry, updater, subject, env); err != nil {
return err
}
}
return nil
}
func updateFastRedisSingleWithoutAck(ctx context.Context, registry *metrics.Registry, updater fastUpdater, subject string, env envelope.FrameEnvelope) error {
started := time.Now()
var result realtime.FastUpdateResult
var err error
if resultUpdater, ok := updater.(fastResultUpdater); ok {
result, err = resultUpdater.FastUpdateWithResult(ctx, env)
} else {
err = updater.FastUpdate(ctx, env)
}
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
if err != nil {
return err
}
recordFastWriterRedisEnvelopeMetrics(registry, subject, result)
recordFastWriterRedisFieldMetrics(registry, subject, result)
recordFastWriterRedisE2EDuration(registry, subject, env)
return nil
}
func shouldFallbackFastBatchError(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
return !isTransientFastBatchError(err)
}
func isTransientFastBatchError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(text, "timeout") ||
strings.Contains(text, "temporary") ||
strings.Contains(text, "temporarily") ||
strings.Contains(text, "connection refused") ||
strings.Contains(text, "connection reset") ||
strings.Contains(text, "connection closed") ||
strings.Contains(text, "broken pipe") ||
strings.Contains(text, "bad connection") ||
strings.Contains(text, "i/o timeout") ||
text == "eof" ||
strings.Contains(text, "unexpected eof") ||
strings.Contains(text, "server is down") ||
strings.Contains(text, "network is unreachable") ||
strings.Contains(text, "no route to host")
}
func recordFastWriterConfigMetrics(registry *metrics.Registry, cfg config) {
if registry == nil {
return
}
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize))
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "fetch_wait_ms"}, float64(cfg.FetchWait.Milliseconds()))
registry.SetGauge("vehicle_fast_writer_config", metrics.Labels{"setting": "operation_timeout_ms"}, float64(cfg.OperationWait.Milliseconds()))
}
func boolGauge(value bool) float64 {
if value {
return 1
}
return 0
}
type fastSubjectGroup struct {
subject string
messages []*fastMessage
envelopes []envelope.FrameEnvelope
}
func fastSubjectGroups(messages []*fastMessage, envelopes []envelope.FrameEnvelope) []fastSubjectGroup {
if len(envelopes) == 0 {
return nil
}
fallback := fastBatchSubject(messages)
groups := make([]fastSubjectGroup, 0, len(envelopes))
indexBySubject := make(map[string]int, len(envelopes))
for index, env := range envelopes {
subject := fastMessageSubject(messages, index, fallback)
groupIndex, exists := indexBySubject[subject]
if !exists {
groupIndex = len(groups)
indexBySubject[subject] = groupIndex
groups = append(groups, fastSubjectGroup{subject: subject})
}
groups[groupIndex].envelopes = append(groups[groupIndex].envelopes, env)
if index >= 0 && index < len(messages) {
groups[groupIndex].messages = append(groups[groupIndex].messages, messages[index])
}
}
return groups
}
func fastBatchSubject(messages []*fastMessage) string {
if len(messages) == 0 {
return "unknown"
}
subject := messages[0].subject
for _, msg := range messages[1:] {
if msg.subject != subject {
return "mixed"
}
}
if strings.TrimSpace(subject) == "" {
return "unknown"
}
return subject
}
func fastMessageSubject(messages []*fastMessage, index int, fallback string) string {
if index >= 0 && index < len(messages) && strings.TrimSpace(messages[index].subject) != "" {
return messages[index].subject
}
if strings.TrimSpace(fallback) != "" {
return fallback
}
return "unknown"
}
func ensureStream(js nats.JetStreamContext, cfg config) error {
ctx, cancel := context.WithTimeout(context.Background(), cfg.StreamEnsureWait)
defer cancel()
opts := []nats.JSOpt{nats.Context(ctx)}
stream := &nats.StreamConfig{
Name: cfg.NATSStream,
Subjects: cfg.NATSSubjects,
Storage: nats.FileStorage,
Retention: nats.LimitsPolicy,
MaxAge: cfg.StreamMaxAge,
MaxBytes: cfg.StreamMaxBytes,
Duplicates: 2 * time.Minute,
}
if _, err := js.StreamInfo(cfg.NATSStream, opts...); err == nil {
_, err = js.UpdateStream(stream, opts...)
return err
}
_, err := js.AddStream(stream, opts...)
if err == nil {
return nil
}
_, updateErr := js.UpdateStream(stream, opts...)
if updateErr == nil {
return nil
}
return err
}
func addFastMetric(registry *metrics.Registry, subject string, status string) {
if registry == nil {
return
}
labels := metrics.Labels{"subject": subject, "status": status}
registry.IncCounter("vehicle_fast_writer_messages_total", labels)
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_message_unix_seconds", labels)
}
func addFastBatchFallbackMetric(registry *metrics.Registry, stage string, status string) {
if registry == nil {
return
}
registry.IncCounter("vehicle_fast_writer_batch_fallback_total", metrics.Labels{
"stage": stage,
"status": status,
})
}
func addFastDecoupledUpdateMetric(registry *metrics.Registry, reason string, status string) {
if registry == nil {
return
}
registry.IncCounter("vehicle_fast_writer_decoupled_updates_total", metrics.Labels{
"reason": reason,
"status": status,
})
}
func addFastBatchPending(registry *metrics.Registry, messages int, envelopes int) {
if registry == nil {
return
}
fastBatchPending.Add(registry, "vehicle_fast_writer_batch_pending_messages", "vehicle_fast_writer_batch_pending_envelopes", messages, envelopes)
}
func recordFastNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info *nats.ConsumerInfo) {
if registry == nil || info == nil {
return
}
labels := metrics.Labels{"stream": cfg.NATSStream, "consumer": cfg.NATSDurable}
registry.SetGauge("vehicle_fast_writer_nats_consumer_pending", labels, float64(info.NumPending))
registry.SetGauge("vehicle_fast_writer_nats_consumer_ack_pending", labels, float64(info.NumAckPending))
registry.SetGauge("vehicle_fast_writer_nats_consumer_waiting", labels, float64(info.NumWaiting))
}
func recordFastWriterStageDuration(registry *metrics.Registry, subject string, stage string, status string, elapsed time.Duration) {
if registry == nil {
return
}
labels := metrics.Labels{
"subject": subject,
"stage": stage,
"status": status,
}
registry.ObserveHistogram("vehicle_fast_writer_stage_duration_ms_histogram", labels, fastWriterStageDurationBucketsMS, float64(elapsed.Milliseconds()))
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_stage_unix_seconds", labels)
}
func recordFastWriterRedisE2EDurationMessages(registry *metrics.Registry, messages []*fastMessage, envelopes []envelope.FrameEnvelope, fallback string) {
for index, env := range envelopes {
recordFastWriterRedisE2EDuration(registry, fastMessageSubject(messages, index, fallback), env)
}
}
func recordFastWriterRedisE2EDuration(registry *metrics.Registry, subject string, env envelope.FrameEnvelope) {
if registry == nil || env.ReceivedAtMS <= 0 {
return
}
elapsedMS := time.Since(time.UnixMilli(env.ReceivedAtMS)).Milliseconds()
if elapsedMS < 0 {
elapsedMS = 0
}
labels := metrics.Labels{
"subject": subject,
}
registry.ObserveHistogram("vehicle_fast_writer_redis_e2e_duration_ms_histogram", labels, fastWriterRedisE2EDurationBucketsMS, float64(elapsedMS))
p99, samples := fastWriterRedisE2ERecent.Observe(subject, float64(elapsedMS))
registry.SetGauge("vehicle_fast_writer_redis_e2e_recent_p99_ms", labels, p99)
registry.SetGauge("vehicle_fast_writer_redis_e2e_recent_samples", labels, float64(samples))
metrics.RecordLastActivity(registry, "vehicle_fast_writer_last_redis_e2e_unix_seconds", labels)
}
func recordFastWriterRedisFieldMetrics(registry *metrics.Registry, subject string, result realtime.FastUpdateResult) {
if registry == nil || result.FieldsSeen == 0 {
return
}
addFastWriterRedisFieldMetric(registry, subject, "seen", result.FieldsSeen)
addFastWriterRedisFieldMetric(registry, subject, "written", result.FieldsWritten)
addFastWriterRedisFieldMetric(registry, subject, "skipped_stale", result.FieldsSkippedStale)
}
func recordFastWriterRedisEnvelopeMetrics(registry *metrics.Registry, subject string, result realtime.FastUpdateResult) {
if registry == nil || result.EnvelopesSeen == 0 {
return
}
addFastWriterRedisEnvelopeMetric(registry, subject, "seen", result.EnvelopesSeen)
addFastWriterRedisEnvelopeMetric(registry, subject, "updated", result.EnvelopesUpdated)
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_non_realtime", result.EnvelopesSkippedNonRealtime)
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_vin", result.EnvelopesSkippedMissingVIN)
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_vehicle_key", result.EnvelopesSkippedMissingVehicleKey)
addFastWriterRedisEnvelopeMetric(registry, subject, "skipped_missing_fields", result.EnvelopesSkippedMissingFields)
}
func addFastWriterRedisEnvelopeMetric(registry *metrics.Registry, subject string, status string, value int) {
if value <= 0 {
return
}
registry.AddCounter("vehicle_fast_writer_redis_envelopes_total", metrics.Labels{
"subject": subject,
"status": status,
}, float64(value))
}
func addFastWriterRedisFieldMetric(registry *metrics.Registry, subject string, status string, value int) {
if value <= 0 {
return
}
registry.AddCounter("vehicle_fast_writer_redis_fields_total", metrics.Labels{
"subject": subject,
"status": status,
}, float64(value))
}
func statusFromError(err error) string {
if err != nil {
return "error"
}
return "ok"
}
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
}
var parsed int
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil {
return fallback
}
return parsed
}
func envInt64(key string, fallback int64) int64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
var parsed int64
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func envBool(key string, fallback bool) bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
switch value {
case "":
return fallback
case "1", "true", "yes", "y", "on":
return true
case "0", "false", "no", "n", "off":
return false
default:
return fallback
}
}
func splitCSV(value string) []string {
var out []string
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
return out
}