chore(db): update schema version in the same transaction as migration (#10321)

Just to be entirely sure that if the migration succeeds the schema
version is always also updated. Currently if a migration succeeds but a
later migration doesn't, the changes of the migration apply but the
version stays - if the migration is breaking/non-idempotent, it will
fail when it tries to rerun it next time (otherwise it's just a
pointless re-execution).

Unfortunately with the current `db.runScripts` it wasn't that easy to
do, so I had to do quite a bit of refactoring. I am also ensuring the
right order of transactions now, though I assume that was already the
case lexicographically - can't hurt to be safe.
This commit is contained in:
Simon Frei
2025-08-30 13:18:31 +02:00
committed by GitHub
parent 2306c6d989
commit 4459438245
2 changed files with 68 additions and 27 deletions
+64 -24
View File
@@ -7,11 +7,13 @@
package sqlite package sqlite
import ( import (
"cmp"
"database/sql" "database/sql"
"embed" "embed"
"io/fs" "io/fs"
"net/url" "net/url"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -42,6 +44,7 @@ type baseDB struct {
tplInput map[string]any tplInput map[string]any
} }
//nolint:noctx
func openBase(path string, maxConns int, pragmas, schemaScripts, migrationScripts []string) (*baseDB, error) { func openBase(path string, maxConns int, pragmas, schemaScripts, migrationScripts []string) (*baseDB, error) {
// Open the database with options to enable foreign keys and recursive // Open the database with options to enable foreign keys and recursive
// triggers (needed for the delete+insert triggers on row replace). // triggers (needed for the delete+insert triggers on row replace).
@@ -89,27 +92,36 @@ func openBase(path string, maxConns int, pragmas, schemaScripts, migrationScript
ver, _ := db.getAppliedSchemaVersion() ver, _ := db.getAppliedSchemaVersion()
if ver.SchemaVersion > 0 { if ver.SchemaVersion > 0 {
filter := func(scr string) bool { type migration struct {
scr = filepath.Base(scr) script string
nstr, _, ok := strings.Cut(scr, "-") version int
}
migrations := make([]migration, 0, len(migrationScripts))
for _, script := range migrationScripts {
base := filepath.Base(script)
nstr, _, ok := strings.Cut(base, "-")
if !ok { if !ok {
return false continue
} }
n, err := strconv.ParseInt(nstr, 10, 32) n, err := strconv.ParseInt(nstr, 10, 32)
if err != nil { if err != nil {
return false continue
} }
return int(n) > ver.SchemaVersion migrations = append(migrations, migration{
script: script,
version: int(n),
})
} }
for _, script := range migrationScripts { slices.SortFunc(migrations, func(m1, m2 migration) int { return cmp.Compare(m1.version, m2.version) })
if err := db.runScripts(script, filter); err != nil { for _, m := range migrations {
if err := db.applyMigration(m.version, m.script); err != nil {
return nil, wrap(err) return nil, wrap(err)
} }
} }
} }
// Set the current schema version, if not already set // Set the current schema version, if not already set
if err := db.setAppliedSchemaVersion(currentSchemaVersion); err != nil { if err := setAppliedSchemaVersion(currentSchemaVersion, db.sql); err != nil {
return nil, wrap(err) return nil, wrap(err)
} }
@@ -204,6 +216,7 @@ func (f failedStmt) Get(_ any, _ ...any) error { return f.err }
func (f failedStmt) Queryx(_ ...any) (*sqlx.Rows, error) { return nil, f.err } func (f failedStmt) Queryx(_ ...any) (*sqlx.Rows, error) { return nil, f.err }
func (f failedStmt) Select(_ any, _ ...any) error { return f.err } func (f failedStmt) Select(_ any, _ ...any) error { return f.err }
//nolint:noctx
func (s *baseDB) runScripts(glob string, filter ...func(s string) bool) error { func (s *baseDB) runScripts(glob string, filter ...func(s string) bool) error {
scripts, err := fs.Glob(embedded, glob) scripts, err := fs.Glob(embedded, glob)
if err != nil { if err != nil {
@@ -223,24 +236,51 @@ nextScript:
continue nextScript continue nextScript
} }
} }
bs, err := fs.ReadFile(embedded, scr) if err := s.execScript(tx, scr); err != nil {
if err != nil { return wrap(err)
return wrap(err, scr)
}
// SQLite requires one statement per exec, so we split the init
// files on lines containing only a semicolon and execute them
// separately. We require it on a separate line because there are
// also statement-internal semicolons in the triggers.
for _, stmt := range strings.Split(string(bs), "\n;") {
if _, err := tx.Exec(s.expandTemplateVars(stmt)); err != nil {
return wrap(err, stmt)
}
} }
} }
return wrap(tx.Commit()) return wrap(tx.Commit())
} }
//nolint:noctx
func (s *baseDB) applyMigration(ver int, script string) error {
tx, err := s.sql.Begin()
if err != nil {
return wrap(err)
}
defer tx.Rollback() //nolint:errcheck
if err := s.execScript(tx, script); err != nil {
return wrap(err)
}
if err := setAppliedSchemaVersion(ver, tx); err != nil {
return wrap(err)
}
return wrap(tx.Commit())
}
//nolint:noctx
func (s *baseDB) execScript(tx *sql.Tx, scr string) error {
bs, err := fs.ReadFile(embedded, scr)
if err != nil {
return wrap(err, scr)
}
// SQLite requires one statement per exec, so we split the init
// files on lines containing only a semicolon and execute them
// separately. We require it on a separate line because there are
// also statement-internal semicolons in the triggers.
for _, stmt := range strings.Split(string(bs), "\n;") {
if _, err := tx.Exec(s.expandTemplateVars(stmt)); err != nil {
return wrap(err, stmt)
}
}
return nil
}
type schemaVersion struct { type schemaVersion struct {
SchemaVersion int SchemaVersion int
AppliedAt int64 AppliedAt int64
@@ -251,11 +291,11 @@ func (s *schemaVersion) AppliedTime() time.Time {
return time.Unix(0, s.AppliedAt) return time.Unix(0, s.AppliedAt)
} }
func (s *baseDB) setAppliedSchemaVersion(ver int) error { func setAppliedSchemaVersion(ver int, execer sqlx.Execer) error {
_, err := s.stmt(` _, err := execer.Exec(`
INSERT OR IGNORE INTO schemamigrations (schema_version, applied_at, syncthing_version) INSERT OR IGNORE INTO schemamigrations (schema_version, applied_at, syncthing_version)
VALUES (?, ?, ?) VALUES (?, ?, ?)
`).Exec(ver, time.Now().UnixNano(), build.LongVersion) `, ver, time.Now().UnixNano(), build.LongVersion)
return wrap(err) return wrap(err)
} }
+4 -3
View File
@@ -2,7 +2,8 @@ These SQL scripts are embedded in the binary.
Scripts in `schema/` are run at every startup, in alphanumerical order. Scripts in `schema/` are run at every startup, in alphanumerical order.
Scripts in `migrations/` are run when a migration is needed; the must begin Scripts in `migrations/` are run when a migration is needed; they must begin
with a number that equals the schema version that results from that with a number that equals the schema version that results from that
migration. Migrations are not run on initial database creation, so the migration. Only one script per schema version must exist.
scripts in `schema/` should create the latest version. Migrations are not run on initial database creation, so the scripts in `schema/`
should create the latest version.