This commit is contained in:
2026-06-15 23:45:14 +02:00
parent 29fd832bcd
commit 22e3bb4a18
32 changed files with 2531 additions and 362 deletions
+223 -6
View File
@@ -37,6 +37,11 @@ var (
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
// uses backticks. Our schema has no double-quoted string literals, so it's
// safe to convert every "ident" to `ident`.
reDoubleQuoteIdent = regexp.MustCompile(`"([^"\n]*)"`)
)
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
@@ -71,6 +76,9 @@ var longTextColumns = map[string]string{
// file) into the MySQL dialect. See the package note above for the rules.
func mysqlDDL(stmt string) string {
s := stmt
// SQLite double-quoted identifiers → MySQL backticks (renamed tables in the
// baseline dump, etc.).
s = reDoubleQuoteIdent.ReplaceAllString(s, "`$1`")
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
@@ -144,7 +152,11 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
conn.SetConnMaxLifetime(5 * time.Minute)
// No max lifetime: a slow server's first migration can run for minutes on a
// single connection, and reaping it mid-migration drops the selected database
// (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
@@ -152,7 +164,22 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
// Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path.
Dialect = "mysql"
if err := migrate(conn, mysqlDDL); err != nil {
fresh, err := isFreshMySQL(conn)
if err != nil {
_ = conn.Close()
Dialect = "sqlite"
return nil, fmt.Errorf("inspect database: %w", err)
}
if fresh {
// Empty database: build the whole final schema in one pass (one CREATE
// per table, no ALTERs) — far faster than replaying 21 migrations,
// especially on a server with slow DDL.
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL)
}
if err != nil {
_ = conn.Close()
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err
@@ -160,6 +187,116 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
return conn, nil
}
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator.
func isFreshMySQL(conn *sql.DB) (bool, error) {
var name string
err := conn.QueryRow("SHOW TABLES LIKE 'qso'").Scan(&name)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
return false, nil
}
// applyMySQLBaseline creates the entire current schema on a fresh MySQL database
// in a single pass, then records every migration as already applied. The schema
// is derived from the migrations themselves (replayed on a throwaway in-memory
// SQLite, whose sqlite_master holds each table's FINAL CREATE statement with all
// ALTER-added columns folded in) and translated to MySQL — so there's no second
// schema to maintain and the two backends can't drift. Future migrations apply
// incrementally on top.
func applyMySQLBaseline(conn *sql.DB) error {
mem, err := sql.Open("sqlite", "file:opslog_baseline?mode=memory&cache=shared")
if err != nil {
return fmt.Errorf("open baseline sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil); err != nil {
return fmt.Errorf("build baseline schema: %w", err)
}
var migNames []string
mrows, err := mem.Query("SELECT name FROM schema_migrations ORDER BY name")
if err != nil {
return fmt.Errorf("read baseline migrations: %w", err)
}
for mrows.Next() {
var n string
if err := mrows.Scan(&n); err != nil {
mrows.Close()
return err
}
migNames = append(migNames, n)
}
mrows.Close()
var tables, indexes []string
srows, err := mem.Query("SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'")
if err != nil {
return fmt.Errorf("read baseline schema: %w", err)
}
for srows.Next() {
var typ, s string
if err := srows.Scan(&typ, &s); err != nil {
srows.Close()
return err
}
switch typ {
case "table":
tables = append(tables, s)
case "index":
indexes = append(indexes, s)
}
}
srows.Close()
// Apply on one connection with FK checks off so table order doesn't matter.
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
if _, err := c.ExecContext(ctx, mysqlDDL(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, tb := range tables {
if _, err := c.ExecContext(ctx, mysqlDDL(tb)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline table: %w", err)
}
}
for _, ix := range indexes {
if _, err := c.ExecContext(ctx, mysqlDDL(ix)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline index: %w", err)
}
}
for _, n := range migNames {
if _, err := conn.Exec("INSERT INTO schema_migrations(name) VALUES(?)", n); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("record migration %s: %w", n, err)
}
}
return nil
}
// applyMySQLMigration executes a translated migration one statement at a time.
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
// so running statements individually (and tolerating "already exists" errors)
@@ -182,9 +319,19 @@ func applyMySQLMigration(conn *sql.DB, sqlText string) error {
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
for _, stmt := range splitStatements(sqlText) {
for _, stmt := range coalesceAddColumns(splitStatements(sqlText)) {
if _, err := c.ExecContext(ctx, stmt); err != nil {
if isIgnorableDDLError(err) {
// A coalesced multi-column ALTER fails wholesale if even one
// column already exists (partial prior apply). Re-run it column
// by column so the missing ones still get added.
if cols := decomposeAddColumns(stmt); len(cols) > 1 {
for _, one := range cols {
if _, e := c.ExecContext(ctx, one); e != nil && !isIgnorableDDLError(e) {
return fmt.Errorf("statement %q: %w", firstLine(one), e)
}
}
}
continue
}
return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
@@ -193,13 +340,65 @@ func applyMySQLMigration(conn *sql.DB, sqlText string) error {
return nil
}
var reAddColumn = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+COLUMN\s+(.+)$`)
// coalesceAddColumns merges consecutive "ALTER TABLE t ADD COLUMN …" statements
// on the same table into a single multi-column ALTER. SQLite needs one ALTER per
// column, but each is a full table operation in MySQL — and on a slow server
// that's seconds apiece, turning a ~50-column migration into minutes (long
// enough to trip connection lifetimes and stall startup). One combined ALTER is
// a single table rebuild instead of fifty.
func coalesceAddColumns(stmts []string) []string {
out := make([]string, 0, len(stmts))
for i := 0; i < len(stmts); {
m := reAddColumn.FindStringSubmatch(stmts[i])
if m == nil {
out = append(out, stmts[i])
i++
continue
}
table := m[1]
cols := []string{strings.TrimSpace(m[2])}
j := i + 1
for j < len(stmts) {
m2 := reAddColumn.FindStringSubmatch(stmts[j])
if m2 == nil || m2[1] != table {
break
}
cols = append(cols, strings.TrimSpace(m2[2]))
j++
}
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.Join(cols, ", ADD COLUMN "))
i = j
}
return out
}
// decomposeAddColumns splits a (possibly coalesced) ADD COLUMN ALTER back into
// one ALTER per column, for the idempotent fallback path.
func decomposeAddColumns(stmt string) []string {
m := reAddColumn.FindStringSubmatch(stmt)
if m == nil {
return nil
}
table := m[1]
var out []string
for _, col := range strings.Split(m[2], ", ADD COLUMN ") {
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.TrimSpace(col))
}
return out
}
// splitStatements breaks a migration file into individual SQL statements,
// dropping full-line comments and blank fragments. Our migrations never embed
// a ';' inside a string literal, so a simple split is safe.
// dropping comments (full-line and inline "-- …") and blank fragments. Our
// migrations never embed a ';' or '--' inside a string literal, so this is safe.
func splitStatements(sqlText string) []string {
var b strings.Builder
for _, line := range strings.Split(sqlText, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "--") {
if i := strings.Index(line, "--"); i >= 0 {
line = line[:i] // strip inline / full-line comment
}
if strings.TrimSpace(line) == "" {
continue
}
b.WriteString(line)
@@ -222,6 +421,24 @@ func firstLine(s string) string {
return s
}
// isAccessDenied reports whether a MySQL error is a privilege denial — e.g. a
// restricted user trying to CREATE DATABASE. Such users can still operate on a
// pre-existing database they've been granted, so the caller treats this as
// non-fatal and verifies database access separately.
func isAccessDenied(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1044, // ER_DBACCESS_DENIED_ERROR — access denied to database
1045, // ER_ACCESS_DENIED_ERROR — access denied for user
1142: // ER_TABLEACCESS_DENIED_ERROR — command denied to user
return true
}
return false
}
// isIgnorableDDLError reports whether a MySQL error means the object the
// statement creates/drops is already in the intended state — safe to skip when
// re-applying a forward-only migration.