package main import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log/slog" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" _ "github.com/go-sql-driver/mysql" "github.com/segmentio/kafka-go" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/health" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics" ) const identityBatchOperationTimeout = 30 * time.Second func main() { logger := observability.NewLogger("vehicle-identity-writer") ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() cfg := loadConfig() if err := cfg.Validate(); err != nil { logger.Error("invalid identity writer config", "error", err) os.Exit(1) } db, err := sql.Open("mysql", cfg.MySQLDSN) if err != nil { logger.Error("mysql open failed", "error", err) os.Exit(1) } defer db.Close() db.SetMaxOpenConns(cfg.MySQLMaxOpenConns) db.SetMaxIdleConns(cfg.MySQLMaxIdleConns) db.SetConnMaxLifetime(cfg.MySQLConnMaxLifetime) if err := db.PingContext(ctx); err != nil { logger.Error("mysql ping failed", "error", err) os.Exit(1) } if cfg.EnsureSchema { if err := identity.EnsureJT808RegistrationSchema(ctx, db); err != nil { logger.Error("jt808 registration schema bootstrap failed", "error", err) os.Exit(1) } } registry := metrics.NewRegistry() metrics.RegisterKafkaConsumerInfo(registry, "vehicle-identity-writer", cfg.KafkaGroup, []string{cfg.KafkaTopic}) registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize)) registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "batch_wait_ms"}, float64(cfg.BatchWait.Milliseconds())) registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "location_touch_interval_seconds"}, cfg.LocationTouchInterval.Seconds()) registry.SetGauge("vehicle_identity_writer_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers)) health.Start(ctx, logger, health.NewServer(cfg.HealthAddr, "vehicle-identity-writer", []health.Check{ {Name: "mysql", Check: db.PingContext}, }, registry)) store := identity.NewJT808RegistrationStore(db) logger.Info("identity writer started", "group", cfg.KafkaGroup, "topic", cfg.KafkaTopic, "workers", cfg.Workers, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait.Milliseconds(), "location_touch_interval_seconds", cfg.LocationTouchInterval.Seconds()) var workers sync.WaitGroup for workerID := 1; workerID <= cfg.Workers; workerID++ { workers.Add(1) go func(id int) { defer workers.Done() runIdentityConsumer(ctx, logger, registry, store, cfg, id) }(workerID) } workers.Wait() } func runIdentityConsumer( ctx context.Context, logger *slog.Logger, registry *metrics.Registry, store registrationBatchStore, cfg config, workerID int, ) { reader := kafka.NewReader(kafka.ReaderConfig{ Brokers: cfg.KafkaBrokers, GroupID: cfg.KafkaGroup, GroupTopics: []string{cfg.KafkaTopic}, MinBytes: 1, MaxBytes: 10e6, StartOffset: cfg.StartOffset, }) defer reader.Close() // Kafka keeps one phone on one partition. Per-worker throttling avoids a // global hot lock; a rebalance can only cause one harmless idempotent touch. projector := identity.NewJT808RegistrationProjector(cfg.Location, cfg.LocationTouchInterval) workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)} registry.SetGauge("vehicle_identity_writer_worker_active", workerLabels, 1) defer registry.SetGauge("vehicle_identity_writer_worker_active", workerLabels, 0) logger.Info("identity kafka consumer started", "worker", workerID, "topic", cfg.KafkaTopic) for { first, err := reader.FetchMessage(ctx) if err != nil { if ctx.Err() != nil { return } logger.Error("kafka fetch failed", "error", err) if !waitForRetry(ctx, cfg.RetryDelay) { return } continue } batch := collectIdentityBatch(ctx, reader, first, cfg.BatchSize, cfg.BatchWait) if !processIdentityBatchReliablyForWorker(ctx, logger, registry, projector, store, reader, batch, cfg.RetryDelay, workerLabels) { return } } } type registrationBatchStore interface { UpsertBatch(context.Context, []identity.JT808RegistrationFact) error } type registrationProjector interface { ProjectBatch([]envelope.FrameEnvelope) []identity.JT808RegistrationFact MarkPersisted([]identity.JT808RegistrationFact) } type kafkaMessageFetcher interface { FetchMessage(context.Context) (kafka.Message, error) } type kafkaMessageCommitter interface { CommitMessages(context.Context, ...kafka.Message) error } const ( identityFailureWrite = "write_error" identityFailureCommit = "commit_error" ) type identityBatchFailure struct { reason string err error } func (e *identityBatchFailure) Error() string { return e.err.Error() } func (e *identityBatchFailure) Unwrap() error { return e.err } func collectIdentityBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message { if maxSize <= 1 { return []kafka.Message{first} } if maxWait <= 0 { maxWait = 20 * time.Millisecond } batch := []kafka.Message{first} deadline := time.Now().Add(maxWait) for len(batch) < maxSize { remaining := time.Until(deadline) if remaining <= 0 { break } fetchCtx, cancel := context.WithTimeout(ctx, remaining) message, err := fetcher.FetchMessage(fetchCtx) cancel() if err != nil { break } batch = append(batch, message) } return batch } func processIdentityBatch( ctx context.Context, logger *slog.Logger, registry *metrics.Registry, projector registrationProjector, store registrationBatchStore, committer kafkaMessageCommitter, messages []kafka.Message, ) error { return processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, true, nil) } func processIdentityBatchAttempt( ctx context.Context, logger *slog.Logger, registry *metrics.Registry, projector registrationProjector, store registrationBatchStore, committer kafkaMessageCommitter, messages []kafka.Message, recordReceived bool, ) error { return processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, recordReceived, nil) } func processIdentityBatchAttemptForWorker( ctx context.Context, logger *slog.Logger, registry *metrics.Registry, projector registrationProjector, store registrationBatchStore, committer kafkaMessageCommitter, messages []kafka.Message, recordReceived bool, workerLabels metrics.Labels, ) error { if len(messages) == 0 { return nil } operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), identityBatchOperationTimeout) defer cancel() registry.SetGauge("vehicle_identity_writer_batch_pending_messages", workerLabels, float64(len(messages))) defer registry.SetGauge("vehicle_identity_writer_batch_pending_messages", workerLabels, 0) envelopes := make([]envelope.FrameEnvelope, 0, len(messages)) for _, message := range messages { if recordReceived { recordIdentityMessage(registry, message, "received") registry.SetKafkaLag("vehicle_identity_writer_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark) } var env envelope.FrameEnvelope if err := json.Unmarshal(message.Value, &env); err != nil { if recordReceived { recordIdentityMessage(registry, message, "invalid_json") logger.Warn("skip invalid identity raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err) } continue } if status, err := topics.ValidateRawEnvelope(message.Topic, env); err != nil { if recordReceived { recordIdentityMessage(registry, message, status) logger.Warn("skip mismatched identity raw envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "error", err) } continue } envelopes = append(envelopes, env) } facts := projector.ProjectBatch(envelopes) registry.SetGauge("vehicle_identity_writer_batch_pending_facts", workerLabels, float64(len(facts))) defer registry.SetGauge("vehicle_identity_writer_batch_pending_facts", workerLabels, 0) if len(facts) > 0 { started := time.Now() if err := store.UpsertBatch(operationCtx, facts); err != nil { recordIdentityWrite(registry, "error", len(facts), time.Since(started)) return &identityBatchFailure{reason: identityFailureWrite, err: fmt.Errorf("upsert jt808 registration facts: %w", err)} } projector.MarkPersisted(facts) recordIdentityWrite(registry, "ok", len(facts), time.Since(started)) } if err := committer.CommitMessages(operationCtx, messages...); err != nil { for _, message := range messages { recordIdentityCommit(registry, message, "error") } return &identityBatchFailure{reason: identityFailureCommit, err: fmt.Errorf("commit identity kafka batch: %w", err)} } for _, message := range messages { recordIdentityCommit(registry, message, "ok") } return nil } func processIdentityBatchReliably( ctx context.Context, logger *slog.Logger, registry *metrics.Registry, projector registrationProjector, store registrationBatchStore, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, ) bool { return processIdentityBatchReliablyForWorker(ctx, logger, registry, projector, store, committer, messages, retryDelay, nil) } func processIdentityBatchReliablyForWorker( ctx context.Context, logger *slog.Logger, registry *metrics.Registry, projector registrationProjector, store registrationBatchStore, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels, ) bool { defer registry.SetGauge("vehicle_identity_writer_retry_pending_messages", workerLabels, 0) recordReceived := true for { err := processIdentityBatchAttemptForWorker(ctx, logger, registry, projector, store, committer, messages, recordReceived, workerLabels) if err == nil { return true } if ctx.Err() != nil { return false } reason := identityFailureReason(err) registry.SetGauge("vehicle_identity_writer_retry_pending_messages", workerLabels, float64(len(messages))) registry.IncCounter("vehicle_identity_writer_batch_retries_total", metrics.Labels{"reason": reason}) logger.Error("identity batch failed; retrying without fetching newer offsets", "messages", len(messages), "reason", reason, "error", err) if reason == identityFailureCommit { return retryIdentityCommit(ctx, logger, registry, committer, messages, retryDelay) } if !waitForRetry(ctx, retryDelay) { return false } recordReceived = false } } func retryIdentityCommit( ctx context.Context, logger *slog.Logger, registry *metrics.Registry, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, ) bool { for { if !waitForRetry(ctx, retryDelay) { return false } operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), identityBatchOperationTimeout) err := committer.CommitMessages(operationCtx, messages...) cancel() if err == nil { for _, message := range messages { recordIdentityCommit(registry, message, "ok") } return true } for _, message := range messages { recordIdentityCommit(registry, message, "error") } registry.IncCounter("vehicle_identity_writer_batch_retries_total", metrics.Labels{"reason": identityFailureCommit}) logger.Error("identity kafka commit retry failed", "messages", len(messages), "error", err) } } func identityFailureReason(err error) string { var failure *identityBatchFailure if errors.As(err, &failure) && failure.reason != "" { return failure.reason } return identityFailureWrite } var identityWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000} func recordIdentityMessage(registry *metrics.Registry, message kafka.Message, status string) { labels := metrics.Labels{"topic": message.Topic, "status": status} registry.IncCounter("vehicle_identity_writer_kafka_messages_total", labels) metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_message_unix_seconds", labels) } func recordIdentityWrite(registry *metrics.Registry, status string, facts int, elapsed time.Duration) { labels := metrics.Labels{"status": status} registry.IncCounter("vehicle_identity_writer_batches_total", labels) registry.AddCounter("vehicle_identity_writer_facts_total", labels, float64(facts)) registry.ObserveHistogram("vehicle_identity_writer_write_duration_ms_histogram", labels, identityWriteDurationBucketsMS, float64(elapsed.Milliseconds())) metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_write_unix_seconds", labels) } func recordIdentityCommit(registry *metrics.Registry, message kafka.Message, status string) { labels := metrics.Labels{"topic": message.Topic, "status": status} registry.IncCounter("vehicle_identity_writer_kafka_commits_total", labels) metrics.RecordLastActivity(registry, "vehicle_identity_writer_last_commit_unix_seconds", labels) } func waitForRetry(ctx context.Context, delay time.Duration) bool { if delay <= 0 { delay = time.Second } timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } type config struct { KafkaBrokers []string KafkaTopic string KafkaGroup string MySQLDSN string MySQLMaxOpenConns int MySQLMaxIdleConns int MySQLConnMaxLifetime time.Duration EnsureSchema bool HealthAddr string Location *time.Location LocationTouchInterval time.Duration BatchSize int BatchWait time.Duration RetryDelay time.Duration StartOffset int64 Workers int } func loadConfig() config { location, err := time.LoadLocation(env("LOCAL_TZ", "Asia/Shanghai")) if err != nil { location = time.FixedZone("Asia/Shanghai", 8*60*60) } startOffset := kafka.LastOffset if strings.EqualFold(env("KAFKA_START_OFFSET", "last"), "first") { startOffset = kafka.FirstOffset } return config{ KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")), KafkaTopic: env("KAFKA_TOPIC", topics.RawJT808), KafkaGroup: env("KAFKA_GROUP", "go-identity-writer"), MySQLDSN: env("MYSQL_DSN", ""), MySQLMaxOpenConns: envInt("MYSQL_MAX_OPEN_CONNS", 8), MySQLMaxIdleConns: envInt("MYSQL_MAX_IDLE_CONNS", 4), MySQLConnMaxLifetime: time.Duration(envInt("MYSQL_CONN_MAX_LIFETIME_SECONDS", 300)) * time.Second, EnsureSchema: envBool("MYSQL_ENSURE_SCHEMA", true), HealthAddr: env("HEALTH_ADDR", "127.0.0.1:20217"), Location: location, LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second, BatchSize: envInt("IDENTITY_WRITER_BATCH_SIZE", 500), BatchWait: time.Duration(envInt("IDENTITY_WRITER_BATCH_WAIT_MS", 20)) * time.Millisecond, RetryDelay: time.Duration(envInt("IDENTITY_WRITER_RETRY_DELAY_MS", 1000)) * time.Millisecond, StartOffset: startOffset, Workers: envInt("IDENTITY_WRITER_WORKERS", 3), } } func (c config) Validate() error { if len(c.KafkaBrokers) == 0 { return fmt.Errorf("KAFKA_BROKERS is required") } if strings.TrimSpace(c.MySQLDSN) == "" { return fmt.Errorf("MYSQL_DSN is required") } protocol, ok := topics.ProtocolForKnownRawTopic(c.KafkaTopic) if !ok || protocol != string(envelope.ProtocolJT808) { return fmt.Errorf("identity writer consumes JT808 raw topic only, got %q", c.KafkaTopic) } if c.BatchSize <= 0 { return fmt.Errorf("IDENTITY_WRITER_BATCH_SIZE must be positive") } if c.Workers <= 0 { return fmt.Errorf("IDENTITY_WRITER_WORKERS must be positive") } return nil } func env(key string, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value } func envInt(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func envBool(key string, fallback bool) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed } func splitCSV(value string) []string { parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) for _, item := range parts { if trimmed := strings.TrimSpace(item); trimmed != "" { out = append(out, trimmed) } } return out }