Files
lingniu-vehicle-ingest/go/vehicle-gateway/cmd/gateway/main.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
2026-10-26 18:05:59 +08:00

793 lines
34 KiB
Go

package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/gateway"
"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/protocol/gb32960"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/jt808"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
)
func main() {
logger := observability.NewLogger("vehicle-gateway")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
registry := metrics.NewRegistry()
sink, err := buildSink(ctx, logger, registry)
if err != nil {
logger.Error("build sink failed", "error", err)
os.Exit(1)
}
defer sink.Close()
resolver, jt808DeviceTokens, closeResolver, err := buildIdentityResolver(ctx, logger, registry)
if err != nil {
logger.Error("build identity resolver failed", "error", err)
os.Exit(1)
}
defer closeResolver()
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-gateway", nil, registry))
publishUnified := envBool("PUBLISH_UNIFIED_ENABLED", false)
delegateFields := envBool("FIELDS_DERIVE_FROM_RAW_ENABLED", strings.TrimSpace(os.Getenv("NATS_URL")) != "")
gb32960Authenticator, gb32960AuthMode, gb32960CredentialCount, err := buildGB32960Authenticator()
if err != nil {
logger.Error("build gb32960 authenticator failed", "error", err)
os.Exit(1)
}
jt808AuthCode := env("JT808_REGISTER_AUTH_CODE", "g7gps")
jt808Authenticator, jt808AuthMode, err := buildJT808Authenticator(jt808AuthCode, jt808DeviceTokens)
if err != nil {
logger.Error("build jt808 authenticator failed", "error", err)
os.Exit(1)
}
recordAuthenticationConfig(registry, envelope.ProtocolGB32960, gb32960AuthMode, gb32960CredentialCount)
recordAuthenticationConfig(registry, envelope.ProtocolJT808, jt808AuthMode, int(boolMetric(strings.TrimSpace(jt808AuthCode) != "")))
logger.Info("protocol authentication configured", "gb32960_mode", gb32960AuthMode, "gb32960_accounts", gb32960CredentialCount, "jt808_mode", jt808AuthMode)
protocols := []gateway.TCPProtocol{
{
Protocol: envelope.ProtocolGB32960,
Addr: env("GB32960_TCP_ADDR", ":32960"),
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
ParseForPlatform: gb32960.ParseFrameForPlatform,
Authenticate: gb32960Authenticator,
Respond: gb32960.AutoResponse,
},
{
Protocol: envelope.ProtocolJT808,
Addr: env("JT808_TCP_ADDR", ":808"),
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
Authenticate: jt808Authenticator,
Respond: jt808.NewAutoResponder(jt808AuthCode).Respond,
},
}
var wg sync.WaitGroup
errs := make(chan error, len(protocols))
for _, protocol := range protocols {
server, err := gateway.NewTCPServer(gateway.TCPServerConfig{
Protocol: protocol,
Sink: sink,
Resolver: resolver,
Logger: logger,
Metrics: registry,
ReadBufferSize: envInt("TCP_READ_BUFFER_BYTES", 64*1024),
IdleTimeout: time.Duration(envInt("TCP_IDLE_TIMEOUT_SECONDS", 180)) * time.Second,
MaxConnections: envInt("TCP_MAX_CONNECTIONS", 120_000),
PublishUnified: publishUnified,
DelegateFields: delegateFields,
})
if err != nil {
logger.Error("build tcp server failed", "protocol", protocol.Protocol, "error", err)
os.Exit(1)
}
wg.Add(1)
go func() {
defer wg.Done()
if err := server.ListenAndServe(ctx); err != nil {
errs <- err
}
}()
}
if envBool("YUTONG_MQTT_ENABLED", false) {
client, err := gateway.NewMQTTClient(gateway.MQTTClientConfig{
EndpointName: env("YUTONG_MQTT_ENDPOINT", env("YUTONG_MQTT_ENDPOINT_NAME", "yutong")),
Broker: env("YUTONG_MQTT_URI", ""),
ClientID: env("YUTONG_MQTT_CLIENT_ID", "lingniu-go-yutong-mqtt"),
Username: env("YUTONG_MQTT_USERNAME", ""),
Password: env("YUTONG_MQTT_PASSWORD", ""),
Topics: splitCSV(env("YUTONG_MQTT_TOPICS", env("YUTONG_MQTT_TOPIC", "/ytforward/shln/+"))),
QoS: byte(envInt("YUTONG_MQTT_QOS", 2)),
CleanSession: envBool("YUTONG_MQTT_CLEAN_SESSION", false),
KeepAlive: time.Duration(envInt("YUTONG_MQTT_KEEP_ALIVE_SECONDS", 20)) * time.Second,
ConnectTimeout: time.Duration(envInt("YUTONG_MQTT_CONNECTION_TIMEOUT_SECONDS", 10)) * time.Second,
TLSCACertPath: env("YUTONG_MQTT_TLS_CA_PEM", ""),
TLSClientCertPath: env("YUTONG_MQTT_TLS_CLIENT_PEM", ""),
TLSClientKeyPath: env("YUTONG_MQTT_TLS_CLIENT_KEY", ""),
TLSHostnameVerification: envBool("YUTONG_MQTT_TLS_HOSTNAME_VERIFICATION_ENABLED", true),
Sink: sink,
Resolver: resolver,
Logger: logger,
Metrics: registry,
PublishUnified: publishUnified,
DelegateFields: delegateFields,
})
if err != nil {
logger.Error("build yutong mqtt client failed", "error", err)
os.Exit(1)
}
if err := client.Start(ctx); err != nil {
logger.Error("start yutong mqtt client failed", "error", err)
os.Exit(1)
}
logger.Info("yutong mqtt client started")
}
logger.Info("vehicle gateway started", "fields_derive_from_raw_enabled", delegateFields)
select {
case <-ctx.Done():
case err := <-errs:
logger.Error("vehicle gateway listener failed", "error", err)
stop()
}
wg.Wait()
}
func buildGB32960Authenticator() (authentication.Authenticator, authentication.Mode, int, error) {
mode, err := authentication.ParseMode(os.Getenv("GB32960_AUTH_MODE"), authentication.ModeObserve)
if err != nil {
return nil, "", 0, err
}
credentials, err := loadGB32960Credentials()
if err != nil {
return nil, "", 0, err
}
if mode == authentication.ModeEnforce && len(credentials) == 0 {
return nil, "", 0, fmt.Errorf("GB32960_AUTH_MODE=enforce requires configured platform credentials")
}
return authentication.NewGB32960PlatformAuthenticator(mode, credentials), mode, len(credentials), nil
}
func buildJT808Authenticator(authCode string, deviceTokens authentication.JT808DeviceTokenProvider) (authentication.Authenticator, authentication.Mode, error) {
mode, err := authentication.ParseMode(os.Getenv("JT808_AUTH_MODE"), authentication.ModeObserve)
if err != nil {
return nil, "", err
}
if mode == authentication.ModeEnforce && strings.TrimSpace(authCode) == "" && deviceTokens == nil {
return nil, "", fmt.Errorf("JT808_AUTH_MODE=enforce requires a configured or device token provider")
}
return authentication.NewJT808Authenticator(mode, authCode, deviceTokens), mode, nil
}
func loadGB32960Credentials() (map[string][]string, error) {
payload := strings.TrimSpace(os.Getenv("GB32960_PLATFORM_CREDENTIALS_JSON"))
if path := strings.TrimSpace(os.Getenv("GB32960_PLATFORM_CREDENTIALS_FILE")); path != "" {
contents, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read gb32960 credentials file: %w", err)
}
payload = strings.TrimSpace(string(contents))
}
if payload == "" {
return map[string][]string{}, nil
}
rawCredentials := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(payload), &rawCredentials); err != nil {
return nil, fmt.Errorf("parse gb32960 platform credentials: %w", err)
}
credentials := make(map[string][]string, len(rawCredentials))
for username, rawPassword := range rawCredentials {
trimmed := strings.TrimSpace(username)
if trimmed == "" {
continue
}
var passwords []string
var single string
if err := json.Unmarshal(rawPassword, &single); err == nil {
passwords = []string{single}
} else if err := json.Unmarshal(rawPassword, &passwords); err != nil {
return nil, fmt.Errorf("parse gb32960 credentials for account %q: expected string or string array", trimmed)
}
for _, password := range passwords {
if password != "" {
credentials[trimmed] = append(credentials[trimmed], password)
}
}
if len(credentials[trimmed]) == 0 {
delete(credentials, trimmed)
}
}
return credentials, nil
}
func recordAuthenticationConfig(registry *metrics.Registry, protocol envelope.Protocol, mode authentication.Mode, credentialCount int) {
if registry == nil {
return
}
registry.SetGauge("vehicle_gateway_authentication_mode", metrics.Labels{
"protocol": string(protocol),
"mode": string(mode),
}, 1)
registry.SetGauge("vehicle_gateway_authentication_credentials", metrics.Labels{
"protocol": string(protocol),
}, float64(credentialCount))
}
func buildIdentityResolver(ctx context.Context, logger *slog.Logger, registry *metrics.Registry) (identity.Resolver, authentication.JT808DeviceTokenProvider, func(), error) {
dsn := env("IDENTITY_MYSQL_DSN", strings.TrimSpace(os.Getenv("MYSQL_DSN")))
if strings.TrimSpace(dsn) == "" {
logger.Warn("identity mysql dsn is empty; using noop identity resolver")
return identity.NoopResolver{}, nil, func() {}, nil
}
db, err := sql.Open("mysql", dsn)
if err != nil {
logger.Error("identity mysql configuration failed; continuing with unresolved identities", "error", err)
return identity.NoopResolver{}, nil, func() {}, nil
}
db.SetMaxOpenConns(envInt("IDENTITY_MYSQL_MAX_OPEN_CONNS", 16))
db.SetMaxIdleConns(envInt("IDENTITY_MYSQL_MAX_IDLE_CONNS", 8))
db.SetConnMaxLifetime(time.Duration(envInt("IDENTITY_MYSQL_CONN_MAX_LIFETIME_SECONDS", 300)) * time.Second)
db.SetConnMaxIdleTime(time.Duration(envInt("IDENTITY_MYSQL_CONN_MAX_IDLE_SECONDS", 60)) * time.Second)
table := env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding")
cacheMaxEntries := envInt("IDENTITY_LOOKUP_CACHE_MAX_ENTRIES", 300000)
cacheCleanupInterval := time.Duration(envInt("IDENTITY_LOOKUP_CACHE_CLEANUP_INTERVAL_SECONDS", 60)) * time.Second
staleLookupTTLSeconds := envInt("IDENTITY_STALE_LOOKUP_TTL_SECONDS", 3600)
registrationGatewayWritesEnabled := envBool("JT808_REGISTRATION_GATEWAY_WRITES_ENABLED", false)
resolver := identity.NewMySQLResolverWithOptions(db, table, identity.MySQLResolverOptions{
SnapshotOnlyLookups: envBool("IDENTITY_SNAPSHOT_ONLY_ENABLED", true),
LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second,
LocationTouchRetryInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_RETRY_INTERVAL_SECONDS", 5)) * time.Second,
RegistrationWriteAttempts: envInt("JT808_REGISTRATION_WRITE_RETRY_ATTEMPTS", 2),
RegistrationWriteRetryDelay: time.Duration(envInt("JT808_REGISTRATION_WRITE_RETRY_DELAY_MS", 20)) * time.Millisecond,
LookupCacheTTL: time.Duration(envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600)) * time.Second,
StaleLookupTTL: time.Duration(staleLookupTTLSeconds) * time.Second,
CacheCleanupInterval: cacheCleanupInterval,
MaxCacheEntries: cacheMaxEntries,
SourceCodeLookup: envBool("IDENTITY_SOURCE_CODE_LOOKUP_ENABLED", true),
AsyncRegistrationWrites: envBool("JT808_REGISTRATION_ASYNC_WRITE_ENABLED", true),
RegistrationWriteQueueSize: envInt("JT808_REGISTRATION_WRITE_QUEUE_SIZE", 100000),
RegistrationWriteWorkers: envInt("JT808_REGISTRATION_WRITE_WORKERS", 4),
RegistrationWriteTimeout: time.Duration(envInt("JT808_REGISTRATION_WRITE_TIMEOUT_MS", 5000)) * time.Millisecond,
RegistrationEnqueueTimeout: time.Duration(envInt("JT808_REGISTRATION_WRITE_ENQUEUE_TIMEOUT_MS", 50)) * time.Millisecond,
DisableRegistrationWrites: !registrationGatewayWritesEnabled,
OnRegistrationWriteResult: func(result identity.RegistrationWriteResult) {
recordJT808RegistrationWriteResult(registry, result)
},
OnRegistrationWriteError: func(err error) {
logger.Warn("jt808 registration async write failed", "error", err)
},
})
startIdentityDatabaseMaintenance(
ctx,
logger,
db,
resolver,
envBool("IDENTITY_MYSQL_ENSURE_SCHEMA", false),
time.Duration(envInt("IDENTITY_MYSQL_PING_TIMEOUT_MS", 3000))*time.Millisecond,
time.Duration(envInt("IDENTITY_MYSQL_SCHEMA_TIMEOUT_SECONDS", 10))*time.Second,
)
startIdentitySnapshotRefresh(
ctx,
logger,
registry,
resolver,
time.Duration(envInt("IDENTITY_SNAPSHOT_REFRESH_INTERVAL_SECONDS", 60))*time.Second,
time.Duration(envInt("IDENTITY_SNAPSHOT_REFRESH_TIMEOUT_SECONDS", 10))*time.Second,
)
startIdentityCacheMetrics(ctx, registry, resolver, time.Duration(envInt("IDENTITY_CACHE_METRICS_INTERVAL_SECONDS", 30))*time.Second)
resolveTimeout := time.Duration(envInt("IDENTITY_RESOLVE_TIMEOUT_MS", 50)) * time.Millisecond
registry.SetGauge("vehicle_gateway_jt808_registration_gateway_writes_enabled", nil, boolMetric(registrationGatewayWritesEnabled))
logger.Info("identity mysql resolver enabled", "table", table, "snapshot_only_enabled", envBool("IDENTITY_SNAPSHOT_ONLY_ENABLED", true), "snapshot_refresh_interval_seconds", envInt("IDENTITY_SNAPSHOT_REFRESH_INTERVAL_SECONDS", 60), "lookup_cache_ttl_seconds", envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600), "stale_lookup_ttl_seconds", staleLookupTTLSeconds, "lookup_cache_max_entries", cacheMaxEntries, "lookup_cache_cleanup_interval_seconds", cacheCleanupInterval.Seconds(), "source_code_lookup_enabled", envBool("IDENTITY_SOURCE_CODE_LOOKUP_ENABLED", true), "resolve_timeout_ms", resolveTimeout.Milliseconds(), "jt808_registration_gateway_writes_enabled", registrationGatewayWritesEnabled, "jt808_registration_location_touch_retry_interval_seconds", envInt("JT808_REGISTRATION_LOCATION_TOUCH_RETRY_INTERVAL_SECONDS", 5), "jt808_registration_write_retry_attempts", envInt("JT808_REGISTRATION_WRITE_RETRY_ATTEMPTS", 2), "jt808_registration_write_retry_delay_ms", envInt("JT808_REGISTRATION_WRITE_RETRY_DELAY_MS", 20), "jt808_registration_async_write_enabled", envBool("JT808_REGISTRATION_ASYNC_WRITE_ENABLED", true), "jt808_registration_write_queue_size", envInt("JT808_REGISTRATION_WRITE_QUEUE_SIZE", 100000), "jt808_registration_write_workers", envInt("JT808_REGISTRATION_WRITE_WORKERS", 4), "jt808_registration_write_timeout_ms", envInt("JT808_REGISTRATION_WRITE_TIMEOUT_MS", 5000), "jt808_registration_write_enqueue_timeout_ms", envInt("JT808_REGISTRATION_WRITE_ENQUEUE_TIMEOUT_MS", 50))
return identity.TimeoutResolver{Delegate: resolver, Timeout: resolveTimeout}, resolver, func() {
_ = resolver.Close()
_ = db.Close()
}, nil
}
type identitySnapshotRefresher interface {
RefreshSnapshot(context.Context) (identity.SnapshotRefreshResult, error)
}
type identityDatabasePinger interface {
PingContext(context.Context) error
}
type identitySchemaEnsurer interface {
EnsureSchema(context.Context) error
}
func startIdentityDatabaseMaintenance(ctx context.Context, logger *slog.Logger, db identityDatabasePinger, schema identitySchemaEnsurer, ensureSchema bool, pingTimeout time.Duration, schemaTimeout time.Duration) {
if db == nil {
return
}
if pingTimeout <= 0 {
pingTimeout = 3 * time.Second
}
if schemaTimeout <= 0 {
schemaTimeout = 10 * time.Second
}
go func() {
pingCtx, cancelPing := context.WithTimeout(ctx, pingTimeout)
err := db.PingContext(pingCtx)
cancelPing()
if err != nil {
logger.Warn("identity mysql unavailable; ingress remains enabled and snapshot refresh will retry", "error", err)
return
}
if !ensureSchema || schema == nil {
return
}
schemaCtx, cancelSchema := context.WithTimeout(ctx, schemaTimeout)
err = schema.EnsureSchema(schemaCtx)
cancelSchema()
if err != nil {
logger.Warn("identity mysql schema check failed; ingress remains enabled", "error", err)
}
}()
}
func startIdentitySnapshotRefresh(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, refresher identitySnapshotRefresher, interval time.Duration, timeout time.Duration) {
if refresher == nil {
return
}
if interval <= 0 {
interval = time.Minute
}
if timeout <= 0 {
timeout = 10 * time.Second
}
refresh := func() {
refreshCtx, cancel := context.WithTimeout(ctx, timeout)
result, err := refresher.RefreshSnapshot(refreshCtx)
cancel()
if err != nil {
recordIdentitySnapshotRefresh(registry, identity.SnapshotRefreshResult{}, "error")
logger.Warn("identity snapshot refresh failed; keeping last known good snapshot", "error", err)
return
}
recordIdentitySnapshotRefresh(registry, result, "ok")
logger.Info("identity snapshot refreshed", "binding_entries", result.BindingEntries, "identifier_entries", result.IdentifierEntries, "registration_entries", result.RegistrationEntries, "source_entries", result.SourceEntries)
}
go func() {
refresh()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
refresh()
}
}
}()
}
func recordIdentitySnapshotRefresh(registry *metrics.Registry, result identity.SnapshotRefreshResult, status string) {
if registry == nil {
return
}
registry.IncCounter("vehicle_gateway_identity_snapshot_refresh_total", metrics.Labels{"status": status})
if status != "ok" {
return
}
for _, item := range []struct {
kind string
value int
}{
{kind: "binding", value: result.BindingEntries},
{kind: "identifier", value: result.IdentifierEntries},
{kind: "registration", value: result.RegistrationEntries},
{kind: "source", value: result.SourceEntries},
} {
registry.SetGauge("vehicle_gateway_identity_snapshot_entries", metrics.Labels{"kind": item.kind}, float64(item.value))
}
registry.SetGauge("vehicle_gateway_identity_snapshot_last_success_unix_seconds", nil, float64(result.RefreshedAt.Unix()))
}
type identityCacheStatsReporter interface {
CacheStats() identity.CacheStats
}
func startIdentityCacheMetrics(ctx context.Context, registry *metrics.Registry, reporter identityCacheStatsReporter, interval time.Duration) {
if registry == nil || reporter == nil {
return
}
if interval <= 0 {
interval = 30 * time.Second
}
recordIdentityCacheStats(registry, reporter.CacheStats())
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
recordIdentityCacheStats(registry, reporter.CacheStats())
}
}
}()
}
func recordIdentityCacheStats(registry *metrics.Registry, stats identity.CacheStats) {
if registry == nil {
return
}
for _, item := range []struct {
name string
value int
max int
}{
{name: "lookup", value: stats.LookupEntries, max: stats.MaxEntries},
{name: "registration", value: stats.RegistrationEntries, max: stats.MaxEntries},
{name: "source_code", value: stats.SourceCodeEntries, max: stats.MaxEntries},
{name: "location_touch", value: stats.LocationTouchEntries, max: stats.MaxEntries},
{name: "location_touch_failure", value: stats.LocationTouchFailureEntries, max: stats.MaxEntries},
{name: "registration_write_queue", value: stats.RegistrationWriteQueueDepth, max: stats.RegistrationWriteQueueCap},
{name: "snapshot_binding", value: stats.SnapshotBindingEntries, max: stats.MaxEntries},
{name: "snapshot_identifier", value: stats.SnapshotIdentifierEntries, max: stats.MaxEntries},
{name: "snapshot_registration", value: stats.SnapshotRegistrationEntries, max: stats.MaxEntries},
{name: "snapshot_source", value: stats.SnapshotSourceEntries, max: stats.MaxEntries},
} {
registry.SetGauge("vehicle_gateway_identity_cache_entries", metrics.Labels{"cache": item.name}, float64(item.value))
registry.SetGauge("vehicle_gateway_identity_cache_max_entries", metrics.Labels{"cache": item.name}, float64(item.max))
}
ready := 0.0
if stats.SnapshotReady {
ready = 1
}
registry.SetGauge("vehicle_gateway_identity_snapshot_ready", nil, ready)
if !stats.SnapshotRefreshedAt.IsZero() {
registry.SetGauge("vehicle_gateway_identity_snapshot_last_success_unix_seconds", nil, float64(stats.SnapshotRefreshedAt.Unix()))
}
}
func recordJT808RegistrationWriteResult(registry *metrics.Registry, result identity.RegistrationWriteResult) {
if registry == nil {
return
}
mode := strings.TrimSpace(result.Mode)
if mode == "" {
mode = "unknown"
}
status := strings.TrimSpace(result.Status)
if status == "" {
status = "unknown"
}
registry.IncCounter("vehicle_gateway_jt808_registration_write_total", metrics.Labels{
"mode": mode,
"status": status,
})
metrics.RecordLastActivity(registry, "vehicle_gateway_last_jt808_registration_write_unix_seconds", metrics.Labels{
"mode": mode,
"status": status,
})
}
func buildSink(ctx context.Context, logger *slog.Logger, registry *metrics.Registry) (eventbus.Sink, error) {
if strings.TrimSpace(os.Getenv("NATS_URL")) != "" {
natsConfig := natsSinkConfigFromEnv()
sink, err := eventbus.NewNATSSink(natsConfig)
if err != nil {
return nil, err
}
outboxRuntime := natsOutboxConfigFromEnv(registry, func(err error) {
logger.Warn("nats durable outbox publish failed", "error", err)
})
if outboxRuntime.Enabled {
outbox, err := eventbus.NewDurableOutboxSink(sink, outboxRuntime.Config)
if err != nil {
_ = sink.Close()
return nil, err
}
go outbox.ReplayLoop(ctx, outboxRuntime.ReplayInterval)
logger.Info("nats durable outbox enabled",
"dir", outboxRuntime.Config.Directory,
"fsync", outboxRuntime.Config.SyncWrites,
"replay_interval_ms", outboxRuntime.ReplayInterval.Milliseconds(),
"replay_batch_size", outboxRuntime.Config.ReplayBatchSize,
"close_timeout_ms", outboxRuntime.Config.CloseTimeout.Milliseconds(),
"wal_segment_bytes", outboxRuntime.Config.WALSegmentBytes,
"wal_segment_age_ms", outboxRuntime.Config.WALSegmentAge.Milliseconds(),
"wal_append_queue_size", outboxRuntime.Config.WALAppendQueue,
"wal_commit_batch_size", outboxRuntime.Config.WALCommitBatch,
"wal_commit_interval_ms", outboxRuntime.Config.WALCommitWait.Milliseconds(),
"max_inflight", natsConfig.AsyncMaxPending,
"ack_timeout_ms", natsConfig.AsyncAckTimeout.Milliseconds(),
)
return outbox, nil
}
var out eventbus.Sink = eventbus.NewRetryingSink(sink, eventbus.RetryConfig{
Attempts: envInt("NATS_PUBLISH_ATTEMPTS", 3),
Backoff: time.Duration(envInt("NATS_PUBLISH_BACKOFF_MS", 100)) * time.Millisecond,
AttemptTimeout: time.Duration(envInt("NATS_PUBLISH_TIMEOUT_MS", 3000)) * time.Millisecond,
})
spoolDir := strings.TrimSpace(os.Getenv("NATS_SPOOL_DIR"))
if spoolDir != "" {
replayBatchSize := envInt("NATS_SPOOL_REPLAY_BATCH_SIZE", 200)
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{
Directory: spoolDir,
ReplayBatchSize: replayBatchSize,
Metrics: registry,
Name: "nats",
})
interval := time.Duration(envInt("NATS_SPOOL_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond
go durable.ReplayLoop(ctx, interval, func(err error) {
logger.Warn("nats spool replay failed", "error", err)
})
logger.Info("nats durable spool enabled", "dir", spoolDir, "replay_interval_ms", interval.Milliseconds(), "replay_batch_size", replayBatchSize)
out = durable
}
if envBool("NATS_ASYNC_ENABLED", true) {
queueSize := envInt("NATS_ASYNC_QUEUE_SIZE", envInt("KAFKA_ASYNC_QUEUE_SIZE", 100000))
workers := envInt("NATS_ASYNC_WORKERS", envInt("KAFKA_ASYNC_WORKERS", 8))
enqueueTimeout := time.Duration(envInt("NATS_ASYNC_ENQUEUE_TIMEOUT_MS", envInt("KAFKA_ASYNC_ENQUEUE_TIMEOUT_MS", 1000))) * time.Millisecond
timeout := time.Duration(envInt("NATS_ASYNC_PUBLISH_TIMEOUT_MS", envInt("KAFKA_ASYNC_PUBLISH_TIMEOUT_MS", 30000))) * time.Millisecond
if envBool("NATS_PARTITIONED_ASYNC_ENABLED", true) {
rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers := partitionedAsyncConfigFromEnv("NATS", queueSize, workers)
rawEnqueueTimeout, derivedEnqueueTimeout := partitionedAsyncEnqueueTimeoutsFromEnv("NATS", enqueueTimeout)
out = eventbus.NewPartitionedAsyncSink(out, eventbus.PartitionedAsyncConfig{
RawQueueSize: rawQueueSize,
DerivedQueueSize: derivedQueueSize,
RawWorkers: rawWorkers,
DerivedWorkers: derivedWorkers,
RawEnqueueTimeout: rawEnqueueTimeout,
DerivedEnqueueTimeout: derivedEnqueueTimeout,
EnqueueTimeout: enqueueTimeout,
OperationTimeout: timeout,
Metrics: registry,
Name: "nats",
OnError: func(err error) {
logger.Warn("nats partitioned async publish failed", "error", err)
},
})
logger.Info("nats partitioned async publish enabled", "raw_queue_size", rawQueueSize, "derived_queue_size", derivedQueueSize, "raw_workers", rawWorkers, "derived_workers", derivedWorkers, "raw_enqueue_timeout_ms", rawEnqueueTimeout.Milliseconds(), "derived_enqueue_timeout_ms", derivedEnqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
} else {
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
QueueSize: queueSize,
Workers: workers,
EnqueueTimeout: enqueueTimeout,
OperationTimeout: timeout,
Metrics: registry,
Name: "nats",
OnError: func(err error) {
logger.Warn("nats async publish failed", "error", err)
},
})
logger.Info("nats async publish enabled", "queue_size", queueSize, "workers", workers, "enqueue_timeout_ms", enqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
}
}
logger.Info("nats jetstream sink enabled", "url", env("NATS_URL", ""), "unified_subject", natsSinkConfigFromEnv().UnifiedSubject, "publish_unified_enabled", envBool("PUBLISH_UNIFIED_ENABLED", false), "publish_fields_enabled", !envBool("FIELDS_DERIVE_FROM_RAW_ENABLED", true))
return out, nil
}
brokers := splitCSV(os.Getenv("KAFKA_BROKERS"))
if len(brokers) == 0 {
logger.Warn("KAFKA_BROKERS is empty; using log sink")
return eventbus.NewLogSink(logger), nil
}
sink, err := eventbus.NewKafkaSink(eventbus.KafkaConfig{
Brokers: brokers,
RawTopics: map[envelope.Protocol]string{
envelope.ProtocolGB32960: env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960),
envelope.ProtocolJT808: env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808),
envelope.ProtocolYutongMQTT: env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT),
},
FieldsTopics: map[envelope.Protocol]string{
envelope.ProtocolGB32960: env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960),
envelope.ProtocolJT808: env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808),
envelope.ProtocolYutongMQTT: env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT),
},
UnifiedTopic: env("KAFKA_TOPIC_UNIFIED", topics.Unified),
})
if err != nil {
return nil, err
}
var out eventbus.Sink = eventbus.NewRetryingSink(sink, eventbus.RetryConfig{
Attempts: envInt("KAFKA_PUBLISH_ATTEMPTS", 3),
Backoff: time.Duration(envInt("KAFKA_PUBLISH_BACKOFF_MS", 100)) * time.Millisecond,
AttemptTimeout: time.Duration(envInt("KAFKA_PUBLISH_TIMEOUT_MS", 3000)) * time.Millisecond,
})
spoolDir := strings.TrimSpace(os.Getenv("KAFKA_SPOOL_DIR"))
if spoolDir != "" {
replayBatchSize := envInt("KAFKA_SPOOL_REPLAY_BATCH_SIZE", 200)
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{
Directory: spoolDir,
ReplayBatchSize: replayBatchSize,
Metrics: registry,
Name: "kafka",
})
interval := time.Duration(envInt("KAFKA_SPOOL_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond
go durable.ReplayLoop(ctx, interval, func(err error) {
logger.Warn("kafka spool replay failed", "error", err)
})
logger.Info("kafka durable spool enabled", "dir", spoolDir, "replay_interval_ms", interval.Milliseconds(), "replay_batch_size", replayBatchSize)
out = durable
}
if envBool("KAFKA_ASYNC_ENABLED", true) {
queueSize := envInt("KAFKA_ASYNC_QUEUE_SIZE", 100000)
workers := envInt("KAFKA_ASYNC_WORKERS", 8)
enqueueTimeout := time.Duration(envInt("KAFKA_ASYNC_ENQUEUE_TIMEOUT_MS", 1000)) * time.Millisecond
timeout := time.Duration(envInt("KAFKA_ASYNC_PUBLISH_TIMEOUT_MS", 30000)) * time.Millisecond
if envBool("KAFKA_PARTITIONED_ASYNC_ENABLED", true) {
rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers := partitionedAsyncConfigFromEnv("KAFKA", queueSize, workers)
rawEnqueueTimeout, derivedEnqueueTimeout := partitionedAsyncEnqueueTimeoutsFromEnv("KAFKA", enqueueTimeout)
out = eventbus.NewPartitionedAsyncSink(out, eventbus.PartitionedAsyncConfig{
RawQueueSize: rawQueueSize,
DerivedQueueSize: derivedQueueSize,
RawWorkers: rawWorkers,
DerivedWorkers: derivedWorkers,
RawEnqueueTimeout: rawEnqueueTimeout,
DerivedEnqueueTimeout: derivedEnqueueTimeout,
EnqueueTimeout: enqueueTimeout,
OperationTimeout: timeout,
Metrics: registry,
Name: "kafka",
OnError: func(err error) {
logger.Warn("kafka partitioned async publish failed", "error", err)
},
})
logger.Info("kafka partitioned async publish enabled", "raw_queue_size", rawQueueSize, "derived_queue_size", derivedQueueSize, "raw_workers", rawWorkers, "derived_workers", derivedWorkers, "raw_enqueue_timeout_ms", rawEnqueueTimeout.Milliseconds(), "derived_enqueue_timeout_ms", derivedEnqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
} else {
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
QueueSize: queueSize,
Workers: workers,
EnqueueTimeout: enqueueTimeout,
OperationTimeout: timeout,
Metrics: registry,
Name: "kafka",
OnError: func(err error) {
logger.Warn("kafka async publish failed", "error", err)
},
})
logger.Info("kafka async publish enabled", "queue_size", queueSize, "workers", workers, "enqueue_timeout_ms", enqueueTimeout.Milliseconds(), "publish_timeout_ms", timeout.Milliseconds())
}
}
return out, nil
}
func partitionedAsyncConfigFromEnv(prefix string, queueSize int, workers int) (rawQueueSize int, derivedQueueSize int, rawWorkers int, derivedWorkers int) {
derivedQueueDefault := queueSize / 2
if derivedQueueDefault <= 0 {
derivedQueueDefault = 1
}
derivedWorkersDefault := workers / 2
if derivedWorkersDefault <= 0 {
derivedWorkersDefault = 1
}
rawQueueSize = envInt(prefix+"_ASYNC_RAW_QUEUE_SIZE", queueSize)
derivedQueueSize = envInt(prefix+"_ASYNC_DERIVED_QUEUE_SIZE", derivedQueueDefault)
rawWorkers = envInt(prefix+"_ASYNC_RAW_WORKERS", workers)
derivedWorkers = envInt(prefix+"_ASYNC_DERIVED_WORKERS", derivedWorkersDefault)
return rawQueueSize, derivedQueueSize, rawWorkers, derivedWorkers
}
func partitionedAsyncEnqueueTimeoutsFromEnv(prefix string, enqueueTimeout time.Duration) (rawEnqueueTimeout time.Duration, derivedEnqueueTimeout time.Duration) {
rawEnqueueTimeout = time.Duration(envInt(prefix+"_ASYNC_RAW_ENQUEUE_TIMEOUT_MS", int(enqueueTimeout/time.Millisecond))) * time.Millisecond
derivedEnqueueTimeout = time.Duration(envInt(prefix+"_ASYNC_DERIVED_ENQUEUE_TIMEOUT_MS", 50)) * time.Millisecond
return rawEnqueueTimeout, derivedEnqueueTimeout
}
func natsSinkConfigFromEnv() eventbus.NATSConfig {
return eventbus.NATSConfig{
URL: env("NATS_URL", ""),
Name: env("NATS_CLIENT_NAME", "lingniu-vehicle-gateway"),
AsyncMaxPending: envInt("NATS_OUTBOX_MAX_INFLIGHT", 10_000),
AsyncAckTimeout: time.Duration(envInt("NATS_OUTBOX_ACK_TIMEOUT_MS", 3000)) * time.Millisecond,
RawSubjects: map[envelope.Protocol]string{
envelope.ProtocolGB32960: env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)),
envelope.ProtocolJT808: env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)),
envelope.ProtocolYutongMQTT: env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)),
},
FieldsSubjects: map[envelope.Protocol]string{
envelope.ProtocolGB32960: env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)),
envelope.ProtocolJT808: env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)),
envelope.ProtocolYutongMQTT: env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)),
},
UnifiedSubject: env("NATS_SUBJECT_UNIFIED", env("KAFKA_TOPIC_UNIFIED", topics.Unified)),
}
}
type natsOutboxRuntimeConfig struct {
Enabled bool
Config eventbus.DurableOutboxConfig
ReplayInterval time.Duration
}
func natsOutboxConfigFromEnv(registry *metrics.Registry, onError func(error)) natsOutboxRuntimeConfig {
directory := strings.TrimSpace(os.Getenv("NATS_OUTBOX_DIR"))
return natsOutboxRuntimeConfig{
Enabled: envBool("NATS_DURABLE_OUTBOX_ENABLED", false),
Config: eventbus.DurableOutboxConfig{
Directory: directory,
ReplayBatchSize: envInt("NATS_OUTBOX_REPLAY_BATCH_SIZE", 1000),
SyncWrites: envBool("NATS_OUTBOX_FSYNC", true),
CloseTimeout: time.Duration(envInt("NATS_OUTBOX_CLOSE_TIMEOUT_MS", 5000)) * time.Millisecond,
WALSegmentBytes: int64(envInt("NATS_OUTBOX_WAL_SEGMENT_BYTES", 16<<20)),
WALSegmentAge: time.Duration(envInt("NATS_OUTBOX_WAL_SEGMENT_AGE_MS", 5000)) * time.Millisecond,
WALAppendQueue: envInt("NATS_OUTBOX_WAL_APPEND_QUEUE_SIZE", 100_000),
WALCommitBatch: envInt("NATS_OUTBOX_WAL_COMMIT_BATCH_SIZE", 256),
WALCommitWait: time.Duration(envInt("NATS_OUTBOX_WAL_COMMIT_INTERVAL_MS", 1)) * time.Millisecond,
Metrics: registry,
Name: "nats-outbox",
OnError: onError,
},
ReplayInterval: time.Duration(envInt("NATS_OUTBOX_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond,
}
}
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 out int
for _, r := range value {
if r < '0' || r > '9' {
return fallback
}
out = out*10 + int(r-'0')
}
if out <= 0 {
return fallback
}
return out
}
func envBool(key string, fallback bool) bool {
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
if value == "" {
return fallback
}
return value == "1" || value == "true" || value == "yes" || value == "on"
}
func boolMetric(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
}