513 lines
17 KiB
Go
513 lines
17 KiB
Go
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
|
|
}
|