feat: build vehicle data platform and production pipeline
This commit is contained in:
252
vehicle-data-platform/apps/api/cmd/alert-benchmark/main.go
Normal file
252
vehicle-data-platform/apps/api/cmd/alert-benchmark/main.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const (
|
||||
temporaryMode = "temporary"
|
||||
durableMode = "durable"
|
||||
temporaryTable = "vehicle_alert_candidate_benchmark"
|
||||
durableTable = "vehicle_alert_candidate_benchmark_durable"
|
||||
durableLock = "lingniu.vehicle-alert-candidate-benchmark"
|
||||
)
|
||||
|
||||
type benchmarkConfig struct {
|
||||
Rows int
|
||||
BatchSize int
|
||||
Mode string
|
||||
ConfirmDurable bool
|
||||
}
|
||||
|
||||
type benchmarkResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Rows int `json:"rows"`
|
||||
BatchSize int `json:"batchSize"`
|
||||
Batches int `json:"batches"`
|
||||
Transactions int `json:"transactions"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
RowsPerSec float64 `json:"rowsPerSec"`
|
||||
VerifiedRows int `json:"verifiedRows"`
|
||||
Temporary bool `json:"temporaryTable"`
|
||||
DurableWrite bool `json:"durableWrite"`
|
||||
CleanupMS int64 `json:"cleanupMs"`
|
||||
CleanupVerified bool `json:"cleanupVerified"`
|
||||
GlobalStatusDelta map[string]uint64 `json:"globalStatusDelta,omitempty"`
|
||||
GlobalStatusAvailable bool `json:"globalStatusAvailable"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "alert benchmark failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
config := benchmarkConfig{}
|
||||
flag.IntVar(&config.Rows, "rows", 10_000, "candidate rows to write")
|
||||
flag.IntVar(&config.BatchSize, "batch-size", 500, "rows per insert")
|
||||
flag.StringVar(&config.Mode, "mode", temporaryMode, "storage mode: temporary or durable")
|
||||
flag.BoolVar(&config.ConfirmDurable, "confirm-durable-write", false, "required acknowledgement for durable writes and DDL")
|
||||
flag.Parse()
|
||||
if err := config.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
|
||||
if dsn == "" {
|
||||
return fmt.Errorf("MYSQL_DSN is required")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
result, err := executeBenchmark(ctx, conn, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
|
||||
func (config benchmarkConfig) validate() error {
|
||||
config.Mode = strings.ToLower(strings.TrimSpace(config.Mode))
|
||||
if config.Rows < 1 || config.Rows > 100_000 || config.BatchSize < 1 || config.BatchSize > 1_000 {
|
||||
return fmt.Errorf("rows must be 1..100000 and batch-size 1..1000")
|
||||
}
|
||||
if config.Mode != temporaryMode && config.Mode != durableMode {
|
||||
return fmt.Errorf("mode must be temporary or durable")
|
||||
}
|
||||
if config.Mode == durableMode && !config.ConfirmDurable {
|
||||
return fmt.Errorf("durable mode creates, commits to, and drops a dedicated physical table; pass --confirm-durable-write")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBenchmark(ctx context.Context, conn *sql.Conn, config benchmarkConfig) (result benchmarkResult, err error) {
|
||||
mode := strings.ToLower(strings.TrimSpace(config.Mode))
|
||||
table := temporaryTable
|
||||
createPrefix := "CREATE TEMPORARY TABLE"
|
||||
dropPrefix := "DROP TEMPORARY TABLE IF EXISTS"
|
||||
if mode == durableMode {
|
||||
table = durableTable
|
||||
createPrefix = "CREATE TABLE"
|
||||
dropPrefix = "DROP TABLE IF EXISTS"
|
||||
locked, lockErr := acquireBenchmarkLock(ctx, conn)
|
||||
if lockErr != nil {
|
||||
return result, fmt.Errorf("acquire durable benchmark lock: %w", lockErr)
|
||||
}
|
||||
if !locked {
|
||||
return result, fmt.Errorf("another durable benchmark is already running")
|
||||
}
|
||||
defer releaseBenchmarkLock(conn)
|
||||
if _, err = conn.ExecContext(ctx, dropPrefix+" "+table); err != nil {
|
||||
return result, fmt.Errorf("remove stale durable benchmark table: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = conn.ExecContext(ctx, createPrefix+" "+table+" LIKE vehicle_alert_candidate"); err != nil {
|
||||
return result, fmt.Errorf("create %s benchmark table: %w", mode, err)
|
||||
}
|
||||
cleanupStarted := time.Time{}
|
||||
cleanupComplete := false
|
||||
defer func() {
|
||||
cleanupStarted = time.Now()
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_, cleanupErr := conn.ExecContext(cleanupCtx, dropPrefix+" "+table)
|
||||
if cleanupErr == nil {
|
||||
cleanupComplete = true
|
||||
if mode == durableMode {
|
||||
var remaining int
|
||||
cleanupErr = conn.QueryRowContext(cleanupCtx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?`, table).Scan(&remaining)
|
||||
cleanupComplete = cleanupErr == nil && remaining == 0
|
||||
}
|
||||
}
|
||||
result.CleanupMS = time.Since(cleanupStarted).Milliseconds()
|
||||
result.CleanupVerified = cleanupComplete
|
||||
if err == nil && cleanupErr != nil {
|
||||
err = fmt.Errorf("cleanup benchmark table: %w", cleanupErr)
|
||||
} else if err == nil && !cleanupComplete {
|
||||
err = fmt.Errorf("benchmark table cleanup could not be verified")
|
||||
}
|
||||
}()
|
||||
|
||||
statusBefore, statusBeforeErr := readGlobalStatus(ctx, conn)
|
||||
started := time.Now()
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
batches := 0
|
||||
for start := 0; start < config.Rows; start += config.BatchSize {
|
||||
end := min(start+config.BatchSize, config.Rows)
|
||||
query, args := buildCandidateInsert(table, start, end, now)
|
||||
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
|
||||
tx.Rollback()
|
||||
return result, fmt.Errorf("insert batch %d: %w", batches+1, err)
|
||||
}
|
||||
batches++
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
duration := time.Since(started)
|
||||
var verified int
|
||||
if err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&verified); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if verified != config.Rows {
|
||||
return result, fmt.Errorf("row verification failed: wrote %d, found %d", config.Rows, verified)
|
||||
}
|
||||
statusAfter, statusAfterErr := readGlobalStatus(ctx, conn)
|
||||
statusDelta := map[string]uint64{}
|
||||
statusAvailable := statusBeforeErr == nil && statusAfterErr == nil
|
||||
if statusAvailable {
|
||||
for key, after := range statusAfter {
|
||||
if before, ok := statusBefore[key]; ok && after >= before {
|
||||
statusDelta[key] = after - before
|
||||
}
|
||||
}
|
||||
}
|
||||
result = benchmarkResult{
|
||||
Mode: mode,
|
||||
Rows: config.Rows,
|
||||
BatchSize: config.BatchSize,
|
||||
Batches: batches,
|
||||
Transactions: 1,
|
||||
DurationMS: duration.Milliseconds(),
|
||||
RowsPerSec: float64(config.Rows) / duration.Seconds(),
|
||||
VerifiedRows: verified,
|
||||
Temporary: mode == temporaryMode,
|
||||
DurableWrite: mode == durableMode,
|
||||
GlobalStatusDelta: statusDelta,
|
||||
GlobalStatusAvailable: statusAvailable,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func acquireBenchmarkLock(ctx context.Context, conn *sql.Conn) (bool, error) {
|
||||
var acquired sql.NullInt64
|
||||
if err := conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,0)`, durableLock).Scan(&acquired); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return acquired.Valid && acquired.Int64 == 1, nil
|
||||
}
|
||||
|
||||
func releaseBenchmarkLock(conn *sql.Conn) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = conn.ExecContext(ctx, `SELECT RELEASE_LOCK(?)`, durableLock)
|
||||
}
|
||||
|
||||
func readGlobalStatus(ctx context.Context, conn *sql.Conn) (map[string]uint64, error) {
|
||||
rows, err := conn.QueryContext(ctx, `SHOW GLOBAL STATUS WHERE Variable_name IN ('Innodb_os_log_written','Binlog_cache_use','Binlog_cache_disk_use','Com_commit')`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := map[string]uint64{}
|
||||
for rows.Next() {
|
||||
var key, raw string
|
||||
if err := rows.Scan(&key, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, parseErr := strconv.ParseUint(raw, 10, 64)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
func buildCandidateInsert(table string, start, end int, now time.Time) (string, []any) {
|
||||
if table != temporaryTable && table != durableTable {
|
||||
panic("unsupported benchmark table")
|
||||
}
|
||||
values := make([]string, 0, end-start)
|
||||
args := make([]any, 0, (end-start)*7)
|
||||
for index := start; index < end; index++ {
|
||||
values = append(values, "(?,?,?,?,?,?,?)")
|
||||
args = append(args, "benchmark-rule", fmt.Sprintf("BENCH%017d", index), "JT808", now, now, float64(index%120), fmt.Sprintf("benchmark-event-%d", index))
|
||||
}
|
||||
return `INSERT INTO ` + table + `(rule_id,vin,protocol,first_matched_at,last_matched_at,latest_value,source_event_id) VALUES ` + strings.Join(values, ","), args
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildCandidateInsertMatchesBatchArguments(t *testing.T) {
|
||||
query, args := buildCandidateInsert(temporaryTable, 10, 13, time.Now())
|
||||
if strings.Count(query, "(?,?,?,?,?,?,?)") != 3 || strings.Count(query, "?") != len(args) || len(args) != 21 {
|
||||
t.Fatalf("query/args mismatch: placeholders=%d args=%d query=%s", strings.Count(query, "?"), len(args), query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchmarkConfigRequiresExplicitDurableConfirmation(t *testing.T) {
|
||||
valid := benchmarkConfig{Rows: 100_000, BatchSize: 500, Mode: durableMode, ConfirmDurable: true}
|
||||
if err := valid.validate(); err != nil {
|
||||
t.Fatalf("valid durable benchmark rejected: %v", err)
|
||||
}
|
||||
valid.ConfirmDurable = false
|
||||
if err := valid.validate(); err == nil || !strings.Contains(err.Error(), "--confirm-durable-write") {
|
||||
t.Fatalf("durable mode must require confirmation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchmarkConfigBoundsAndModes(t *testing.T) {
|
||||
for _, config := range []benchmarkConfig{
|
||||
{Rows: 0, BatchSize: 500, Mode: temporaryMode},
|
||||
{Rows: 1, BatchSize: 1001, Mode: temporaryMode},
|
||||
{Rows: 1, BatchSize: 1, Mode: "business-table"},
|
||||
} {
|
||||
if err := config.validate(); err == nil {
|
||||
t.Fatalf("invalid config accepted: %+v", config)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
vehicle-data-platform/apps/api/cmd/alert-evaluator/main.go
Normal file
52
vehicle-data-platform/apps/api/cmd/alert-evaluator/main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
if cfg.MySQLDSN == "" {
|
||||
log.Fatal("MYSQL_DSN is required")
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
db, err := platform.OpenSQL(ctx, "mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := platform.NewProductionStore(db, nil, "").WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
|
||||
service := platform.NewService(store)
|
||||
interval := cfg.AlertEvaluationInterval
|
||||
if interval < time.Second {
|
||||
interval = time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("alert evaluator started interval=%s stream_mode=%s", interval, cfg.AlertStreamMode)
|
||||
for {
|
||||
started := time.Now()
|
||||
result, err := service.EvaluateAlerts(ctx)
|
||||
if err != nil {
|
||||
log.Printf("alert evaluation failed duration=%s error=%v", time.Since(started), err)
|
||||
} else {
|
||||
log.Printf("alert evaluation completed rules=%d vehicles=%d candidates_advanced=%d duplicate_observations=%d late_observations=%d stale_evidence_skipped=%d opened=%d recovered=%d duration=%s", result.RulesEvaluated, result.VehiclesScanned, result.CandidatesAdvanced, result.DuplicateObservations, result.LateObservations, result.StaleEvidenceSkipped, result.Opened, result.Recovered, time.Since(started))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("alert evaluator stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
const alertStreamOperationTimeout = 30 * time.Second
|
||||
|
||||
type alertStreamStore interface {
|
||||
RecordAlertStreamBatch(context.Context, string, []platform.AlertStreamRecord) (platform.AlertStreamBatchResult, error)
|
||||
}
|
||||
|
||||
type kafkaMessageFetcher interface {
|
||||
FetchMessage(context.Context) (kafka.Message, error)
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
if err := validateAlertStreamConfig(cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
db, err := platform.OpenSQL(ctx, "mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := platform.NewProductionStore(db, nil, "").WithAlertStreamConfig(cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup)
|
||||
reader := kafka.NewReader(alertStreamReaderConfig(cfg))
|
||||
defer reader.Close()
|
||||
log.Printf("alert stream evaluator started mode=%s group=%s topics=%s batch_size=%d batch_wait=%s lateness=%s", cfg.AlertStreamMode, cfg.AlertStreamKafkaGroup, strings.Join(cfg.AlertStreamKafkaTopics, ","), cfg.AlertStreamBatchSize, cfg.AlertStreamBatchWait, cfg.AlertStreamLateness)
|
||||
for {
|
||||
message, err := reader.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
log.Printf("alert stream evaluator stopped")
|
||||
return
|
||||
}
|
||||
log.Printf("alert stream fetch failed error=%v", err)
|
||||
continue
|
||||
}
|
||||
messages := collectAlertStreamBatch(ctx, reader, message, cfg.AlertStreamBatchSize, cfg.AlertStreamBatchWait)
|
||||
started := time.Now()
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), alertStreamOperationTimeout)
|
||||
records := make([]platform.AlertStreamRecord, 0, len(messages))
|
||||
for _, item := range messages {
|
||||
records = append(records, platform.DecodeAlertStreamRecord(item.Topic, item.Partition, item.Offset, item.HighWaterMark, item.Value, cfg.AlertStreamLateness))
|
||||
}
|
||||
invalidCodes := alertStreamInvalidCodes(records)
|
||||
result, recordErr := store.RecordAlertStreamBatch(operationCtx, cfg.AlertStreamKafkaGroup, records)
|
||||
if recordErr == nil {
|
||||
recordErr = reader.CommitMessages(operationCtx, messages...)
|
||||
}
|
||||
cancel()
|
||||
if recordErr != nil {
|
||||
log.Printf("alert stream batch failed fetched=%d duration=%s error=%v", len(messages), time.Since(started), recordErr)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
log.Printf("alert stream batch completed mode=%s fetched=%d processed=%d valid=%d invalid=%d invalid_codes=%s late=%d replay_skipped=%d partitions=%d rules=%d candidates_advanced=%d duplicate_observations=%d late_observations=%d opened=%d recovered=%d duration=%s", cfg.AlertStreamMode, result.Fetched, result.Processed, result.Valid, result.Invalid, invalidCodes, result.Late, result.ReplaySkipped, result.Partitions, result.RulesEvaluated, result.CandidatesAdvanced, result.DuplicateObservations, result.LateObservations, result.Opened, result.Recovered, time.Since(started))
|
||||
}
|
||||
}
|
||||
|
||||
func alertStreamInvalidCodes(records []platform.AlertStreamRecord) string {
|
||||
counts := map[string]int{}
|
||||
for _, record := range records {
|
||||
if !record.Valid {
|
||||
counts[record.ErrorCode]++
|
||||
}
|
||||
}
|
||||
if len(counts) == 0 {
|
||||
return "none"
|
||||
}
|
||||
keys := make([]string, 0, len(counts))
|
||||
for key := range counts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s:%d", key, counts[key]))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func alertStreamReaderConfig(cfg config.Config) kafka.ReaderConfig {
|
||||
return kafka.ReaderConfig{
|
||||
Brokers: cfg.AlertStreamKafkaBrokers,
|
||||
GroupID: cfg.AlertStreamKafkaGroup,
|
||||
GroupTopics: cfg.AlertStreamKafkaTopics,
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
StartOffset: kafka.LastOffset,
|
||||
}
|
||||
}
|
||||
|
||||
func validateAlertStreamConfig(cfg config.Config) error {
|
||||
if cfg.AlertStreamMode != "shadow" && cfg.AlertStreamMode != "active" {
|
||||
return fmt.Errorf("ALERT_STREAM_MODE must be shadow or active")
|
||||
}
|
||||
if cfg.MySQLDSN == "" {
|
||||
return fmt.Errorf("MYSQL_DSN is required")
|
||||
}
|
||||
if len(cfg.AlertStreamKafkaBrokers) == 0 || len(cfg.AlertStreamKafkaTopics) == 0 {
|
||||
return fmt.Errorf("ALERT_STREAM_KAFKA_BROKERS and ALERT_STREAM_KAFKA_TOPICS are required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AlertStreamKafkaGroup) == "" {
|
||||
return fmt.Errorf("ALERT_STREAM_KAFKA_GROUP is required")
|
||||
}
|
||||
if cfg.AlertStreamBatchSize < 1 || cfg.AlertStreamBatchSize > 1000 {
|
||||
return fmt.Errorf("ALERT_STREAM_BATCH_SIZE must be between 1 and 1000")
|
||||
}
|
||||
if cfg.AlertStreamBatchWait < time.Millisecond || cfg.AlertStreamBatchWait > 5*time.Second {
|
||||
return fmt.Errorf("ALERT_STREAM_BATCH_WAIT_MS must be between 1 and 5000")
|
||||
}
|
||||
if cfg.AlertStreamLateness < 0 || cfg.AlertStreamLateness > 24*time.Hour {
|
||||
return fmt.Errorf("ALERT_STREAM_LATENESS_SEC must be between 0 and 86400")
|
||||
}
|
||||
for _, topic := range cfg.AlertStreamKafkaTopics {
|
||||
if _, known := alertStreamTopicProtocol(topic); !known {
|
||||
return fmt.Errorf("unsupported alert stream topic %q", topic)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func alertStreamTopicProtocol(topic string) (string, bool) {
|
||||
switch strings.TrimSpace(topic) {
|
||||
case "vehicle.fields.go.gb32960.v1":
|
||||
return "GB32960", true
|
||||
case "vehicle.fields.go.jt808.v1":
|
||||
return "JT808", true
|
||||
case "vehicle.fields.go.yutong-mqtt.v1":
|
||||
return "YUTONG_MQTT", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func collectAlertStreamBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message {
|
||||
if maxSize <= 1 {
|
||||
return []kafka.Message{first}
|
||||
}
|
||||
messages := []kafka.Message{first}
|
||||
deadline := time.Now().Add(maxWait)
|
||||
for len(messages) < 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
|
||||
}
|
||||
messages = append(messages, message)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func validAlertStreamConfig() config.Config {
|
||||
return config.Config{
|
||||
MySQLDSN: "user:pass@tcp(localhost:3306)/db?parseTime=true",
|
||||
AlertStreamMode: "shadow",
|
||||
AlertStreamKafkaBrokers: []string{"kafka-a:9092"},
|
||||
AlertStreamKafkaTopics: []string{"vehicle.fields.go.jt808.v1"},
|
||||
AlertStreamKafkaGroup: "vehicle-alert-stream-v1",
|
||||
AlertStreamBatchSize: 200,
|
||||
AlertStreamBatchWait: 100 * time.Millisecond,
|
||||
AlertStreamLateness: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertStreamConfigAcceptsReleasedModesAndRefusesUnknownTopics(t *testing.T) {
|
||||
cfg := validAlertStreamConfig()
|
||||
if err := validateAlertStreamConfig(cfg); err != nil {
|
||||
t.Fatalf("valid shadow config rejected: %v", err)
|
||||
}
|
||||
cfg.AlertStreamMode = "active"
|
||||
if err := validateAlertStreamConfig(cfg); err != nil {
|
||||
t.Fatalf("released active mode rejected: %v", err)
|
||||
}
|
||||
cfg.AlertStreamMode = "unsafe"
|
||||
if err := validateAlertStreamConfig(cfg); err == nil {
|
||||
t.Fatal("unknown mode must fail closed")
|
||||
}
|
||||
cfg = validAlertStreamConfig()
|
||||
cfg.AlertStreamKafkaTopics = []string{"vehicle.raw.go.jt808.v1"}
|
||||
if err := validateAlertStreamConfig(cfg); err == nil {
|
||||
t.Fatal("raw topic must not be accepted as a fields alert stream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAlertStreamGroupStartsAtLatestAndThenUsesCommittedOffsets(t *testing.T) {
|
||||
cfg := validAlertStreamConfig()
|
||||
reader := alertStreamReaderConfig(cfg)
|
||||
if reader.StartOffset != kafka.LastOffset {
|
||||
t.Fatalf("new production group would replay the full retained topic: start=%d", reader.StartOffset)
|
||||
}
|
||||
if reader.GroupID != cfg.AlertStreamKafkaGroup || len(reader.GroupTopics) != 1 || reader.GroupTopics[0] != cfg.AlertStreamKafkaTopics[0] {
|
||||
t.Fatalf("reader group contract drifted: %+v", reader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertStreamInvalidCodesAreAggregatedWithoutPayloads(t *testing.T) {
|
||||
records := []platform.AlertStreamRecord{{ErrorCode: "missing_vin_jt808"}, {Valid: true}, {ErrorCode: "invalid_field_name"}, {ErrorCode: "missing_vin_jt808"}}
|
||||
if got := alertStreamInvalidCodes(records); got != "invalid_field_name:1,missing_vin_jt808:2" {
|
||||
t.Fatalf("invalid code summary=%q", got)
|
||||
}
|
||||
}
|
||||
129
vehicle-data-platform/apps/api/cmd/platform-migrate/main.go
Normal file
129
vehicle-data-platform/apps/api/cmd/platform-migrate/main.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "platform migration failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
|
||||
if dsn == "" {
|
||||
return fmt.Errorf("MYSQL_DSN is required")
|
||||
}
|
||||
if len(os.Args) < 2 {
|
||||
return fmt.Errorf("usage: platform-migrate migration.sql [migration.sql ...]")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("connect mysql: %w", err)
|
||||
}
|
||||
if len(os.Args) == 2 && os.Args[1] == "--server-version" {
|
||||
var version string
|
||||
if err := db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS vehicle_platform_schema_migration (
|
||||
version VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
checksum CHAR(64) NOT NULL,
|
||||
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("ensure migration journal: %w", err)
|
||||
}
|
||||
for _, path := range os.Args[1:] {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
version := filepath.Base(path)
|
||||
sum := sha256.Sum256(contents)
|
||||
checksum := hex.EncodeToString(sum[:])
|
||||
var recorded string
|
||||
err = db.QueryRowContext(ctx, `SELECT checksum FROM vehicle_platform_schema_migration WHERE version=?`, version).Scan(&recorded)
|
||||
if err == nil {
|
||||
if recorded != checksum {
|
||||
return fmt.Errorf("migration %s was changed after being applied", version)
|
||||
}
|
||||
fmt.Printf("skipped %s (already applied)\n", path)
|
||||
continue
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("check migration %s: %w", version, err)
|
||||
}
|
||||
statements := splitSQL(string(contents))
|
||||
for index, statement := range statements {
|
||||
if _, err := db.ExecContext(ctx, statement); err != nil {
|
||||
if isResumableMigrationDDL(err, statement) {
|
||||
fmt.Printf("skipped %s statement %d (schema object already exists)\n", path, index+1)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("apply %s statement %d: %w", path, index+1, err)
|
||||
}
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO vehicle_platform_schema_migration(version,checksum) VALUES(?,?)`, version, checksum); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", version, err)
|
||||
}
|
||||
fmt.Printf("applied %s (%d statements)\n", path, len(statements))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isResumableMigrationDDL(err error, statement string) bool {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if !errors.As(err, &mysqlErr) {
|
||||
return false
|
||||
}
|
||||
upper := strings.ToUpper(strings.TrimSpace(statement))
|
||||
duplicateColumn := mysqlErr.Number == 1060 && strings.Contains(upper, "ALTER TABLE") && strings.Contains(upper, "ADD COLUMN")
|
||||
duplicateIndex := mysqlErr.Number == 1061 && strings.HasPrefix(upper, "CREATE INDEX")
|
||||
return duplicateColumn || duplicateIndex
|
||||
}
|
||||
|
||||
// splitSQL intentionally supports the platform's forward-only DDL files. Those
|
||||
// files contain no procedures or quoted semicolons; rejecting empty fragments
|
||||
// keeps deployment output deterministic without enabling multiStatements in DSN.
|
||||
func splitSQL(contents string) []string {
|
||||
lines := strings.Split(contents, "\n")
|
||||
withoutComments := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||
continue
|
||||
}
|
||||
withoutComments = append(withoutComments, line)
|
||||
}
|
||||
contents = strings.Join(withoutComments, "\n")
|
||||
fragments := strings.Split(contents, ";")
|
||||
statements := make([]string, 0, len(fragments))
|
||||
for _, fragment := range fragments {
|
||||
statement := strings.TrimSpace(fragment)
|
||||
if statement != "" {
|
||||
statements = append(statements, statement)
|
||||
}
|
||||
}
|
||||
return statements
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestSplitSQLKeepsForwardMigrationStatements(t *testing.T) {
|
||||
statements := splitSQL("-- migration\nCREATE TABLE x (id INT);\n\nINSERT INTO x VALUES (1);\n")
|
||||
if len(statements) != 2 || statements[0] != "CREATE TABLE x (id INT)" || statements[1] != "INSERT INTO x VALUES (1)" {
|
||||
t.Fatalf("unexpected statements: %#v", statements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLIgnoresSemicolonsInFullLineComments(t *testing.T) {
|
||||
statements := splitSQL("-- first clause; second clause\nCREATE TABLE x (id INT);\n-- trailing; note\n")
|
||||
if len(statements) != 1 || statements[0] != "CREATE TABLE x (id INT)" {
|
||||
t.Fatalf("comment punctuation became executable SQL: %#v", statements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyDuplicateForwardDDLIsIgnoredForMigrationResume(t *testing.T) {
|
||||
duplicate := &mysql.MySQLError{Number: 1060, Message: "Duplicate column"}
|
||||
if !isResumableMigrationDDL(duplicate, "ALTER TABLE x ADD COLUMN y INT") {
|
||||
t.Fatal("duplicate ADD COLUMN should be resumable")
|
||||
}
|
||||
if isResumableMigrationDDL(duplicate, "CREATE TABLE x (y INT)") || isResumableMigrationDDL(errors.New("duplicate"), "ALTER TABLE x ADD COLUMN y INT") {
|
||||
t.Fatal("unrelated migration errors must not be ignored")
|
||||
}
|
||||
duplicateIndex := &mysql.MySQLError{Number: 1061, Message: "Duplicate key name"}
|
||||
if !isResumableMigrationDDL(duplicateIndex, "CREATE INDEX idx_x ON x(y)") || isResumableMigrationDDL(duplicateIndex, "DROP INDEX idx_x ON x") {
|
||||
t.Fatal("only duplicate CREATE INDEX should be resumable")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user