feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View 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
}

View File

@@ -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)
}
}
}

View 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:
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View 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
}

View File

@@ -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")
}
}