Skip to content

Commit 6976224

Browse files
authored
fix(db): harden migration integrity (#12)
* fix(db): harden migration integrity * fix(db): serialize migration execution * fix(db): recover from a migration lock left by a killed run
1 parent d022abc commit 6976224

27 files changed

Lines changed: 1823 additions & 254 deletions

cmd/synaps3/main.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ func migrateCommand() *cli.Command {
159159
return &cli.Command{
160160
Name: "migrate",
161161
Usage: "run database migrations and exit",
162+
Flags: []cli.Flag{
163+
&cli.BoolFlag{
164+
Name: "force-unlock",
165+
Usage: "release a migration lock left by a killed run, then exit; only use it when no migration is in progress",
166+
},
167+
},
162168
Action: func(ctx context.Context, cmd *cli.Command) error {
163169
if cmd.Args().Len() > 0 {
164170
return fmt.Errorf("unexpected argument %q, migrate takes no positional arguments", cmd.Args().First())
@@ -167,6 +173,9 @@ func migrateCommand() *cli.Command {
167173
if err != nil {
168174
return err
169175
}
176+
if cmd.Bool("force-unlock") {
177+
return runForceUnlock(ctx, src)
178+
}
170179
return runMigrate(ctx, src)
171180
},
172181
}
@@ -241,6 +250,20 @@ func runMigrate(ctx context.Context, src config.Source) error {
241250
return nil
242251
}
243252

253+
func runForceUnlock(ctx context.Context, src config.Source) error {
254+
_, database, err := loadConfigAndDB(ctx, src)
255+
if err != nil {
256+
return err
257+
}
258+
defer func() { _ = database.Close() }()
259+
260+
if err := db.ForceUnlockMigrations(ctx, database); err != nil {
261+
return err
262+
}
263+
slog.Info("migration lock released")
264+
return nil
265+
}
266+
244267
func runServe(ctx context.Context, src config.Source) error {
245268
cfg, database, err := loadConfigAndDB(ctx, src)
246269
if err != nil {

internal/db/db.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,28 @@ package db
33
import (
44
"context"
55
"database/sql"
6+
"errors"
67
"fmt"
78
"log/slog"
89
"net/url"
910
"os"
1011
"path/filepath"
1112
"runtime"
1213
"strings"
14+
"time"
1315

1416
"github.com/strahe/synaps3/internal/config"
1517
"github.com/strahe/synaps3/internal/db/migrations"
1618
"github.com/uptrace/bun"
1719
"github.com/uptrace/bun/dialect/pgdialect"
1820
"github.com/uptrace/bun/dialect/sqlitedialect"
19-
"github.com/uptrace/bun/migrate"
2021

2122
_ "github.com/jackc/pgx/v5/stdlib"
2223
_ "modernc.org/sqlite"
2324
)
2425

26+
const migrationUnlockTimeout = 5 * time.Second
27+
2528
// New creates a Bun database connection based on the provided configuration.
2629
func New(cfg config.DatabaseConfig) (*bun.DB, error) {
2730
var (
@@ -59,12 +62,23 @@ func New(cfg config.DatabaseConfig) (*bun.DB, error) {
5962
}
6063

6164
// RunMigrations initialises the Bun migrator and applies all pending migrations.
62-
func RunMigrations(ctx context.Context, db *bun.DB) error {
63-
migrator := migrate.NewMigrator(db, migrations.Migrations)
65+
func RunMigrations(ctx context.Context, db *bun.DB) (retErr error) {
66+
migrator := migrations.NewMigrator(db)
6467

6568
if err := migrator.Init(ctx); err != nil {
6669
return fmt.Errorf("initializing migrator: %w", err)
6770
}
71+
if err := migrator.Lock(ctx); err != nil {
72+
return fmt.Errorf("locking migrator: %w; if no migration is running, "+
73+
"clear a lock left by a killed run with `synaps3 migrate --force-unlock`", err)
74+
}
75+
defer func() {
76+
unlockCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), migrationUnlockTimeout)
77+
defer cancel()
78+
if err := migrator.Unlock(unlockCtx); err != nil {
79+
retErr = errors.Join(retErr, fmt.Errorf("unlocking migrator: %w", err))
80+
}
81+
}()
6882

6983
group, err := migrator.Migrate(ctx)
7084
if err != nil {
@@ -80,6 +94,18 @@ func RunMigrations(ctx context.Context, db *bun.DB) error {
8094
return nil
8195
}
8296

97+
// ForceUnlockMigrations releases a migration lock left by a killed run.
98+
func ForceUnlockMigrations(ctx context.Context, db *bun.DB) error {
99+
migrator := migrations.NewMigrator(db)
100+
if err := migrator.Init(ctx); err != nil {
101+
return fmt.Errorf("initializing migrator: %w", err)
102+
}
103+
if err := migrator.Unlock(ctx); err != nil {
104+
return fmt.Errorf("releasing migration lock: %w", err)
105+
}
106+
return nil
107+
}
108+
83109
// Ping verifies the database connection is alive.
84110
func Ping(ctx context.Context, db *bun.DB) error {
85111
return db.PingContext(ctx)

internal/db/db_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,113 @@ import (
1414
"time"
1515

1616
"github.com/strahe/synaps3/internal/config"
17+
"github.com/strahe/synaps3/internal/db/migrations"
1718
"github.com/strahe/synaps3/internal/db/repository"
1819
"github.com/strahe/synaps3/internal/model"
1920
"github.com/uptrace/bun"
2021
)
2122

23+
type migrationLockBarrier struct {
24+
locked chan struct{}
25+
release chan struct{}
26+
once sync.Once
27+
}
28+
29+
func (h *migrationLockBarrier) BeforeQuery(ctx context.Context, _ *bun.QueryEvent) context.Context {
30+
return ctx
31+
}
32+
33+
func (h *migrationLockBarrier) AfterQuery(ctx context.Context, event *bun.QueryEvent) {
34+
if event.Err != nil || event.Operation() != "INSERT" || !strings.Contains(event.Query, "bun_migration_locks") {
35+
return
36+
}
37+
h.once.Do(func() {
38+
close(h.locked)
39+
select {
40+
case <-h.release:
41+
case <-ctx.Done():
42+
}
43+
})
44+
}
45+
46+
func TestForceUnlockMigrationsClearsALockLeftByAKilledRun(t *testing.T) {
47+
cfg := config.DatabaseConfig{
48+
Driver: "sqlite",
49+
DSN: "file:" + filepath.Join(t.TempDir(), "force-unlock.db"),
50+
MaxOpenConns: 2,
51+
MaxIdleConns: 2,
52+
}
53+
db, err := New(cfg)
54+
if err != nil {
55+
t.Fatalf("New() error = %v", err)
56+
}
57+
t.Cleanup(func() { _ = db.Close() })
58+
ctx := context.Background()
59+
60+
if err := RunMigrations(ctx, db); err != nil {
61+
t.Fatalf("RunMigrations() error = %v", err)
62+
}
63+
// A killed run never reaches its deferred unlock.
64+
if err := migrations.NewMigrator(db).Lock(ctx); err != nil {
65+
t.Fatalf("acquiring the leaked lock: %v", err)
66+
}
67+
err = RunMigrations(ctx, db)
68+
if err == nil || !strings.Contains(err.Error(), "--force-unlock") {
69+
t.Fatalf("RunMigrations() error = %v, want the stale-lock remediation", err)
70+
}
71+
72+
if err := ForceUnlockMigrations(ctx, db); err != nil {
73+
t.Fatalf("ForceUnlockMigrations() error = %v", err)
74+
}
75+
if err := RunMigrations(ctx, db); err != nil {
76+
t.Fatalf("RunMigrations() after force unlock = %v, want success", err)
77+
}
78+
}
79+
80+
func TestRunMigrationsSerializesConcurrentRunnersAndUnlocksAfterCancellation(t *testing.T) {
81+
cfg := config.DatabaseConfig{
82+
Driver: "sqlite",
83+
DSN: "file:" + filepath.Join(t.TempDir(), "migration-lock.db") + "?_pragma=journal_mode(WAL)",
84+
MaxOpenConns: 2,
85+
MaxIdleConns: 2,
86+
}
87+
db, err := New(cfg)
88+
if err != nil {
89+
t.Fatalf("New() error = %v", err)
90+
}
91+
t.Cleanup(func() { _ = db.Close() })
92+
93+
barrier := &migrationLockBarrier{locked: make(chan struct{}), release: make(chan struct{})}
94+
db.AddQueryHook(barrier)
95+
ctx, cancel := context.WithCancel(context.Background())
96+
firstResult := make(chan error, 1)
97+
go func() {
98+
firstResult <- RunMigrations(ctx, db)
99+
}()
100+
101+
select {
102+
case <-barrier.locked:
103+
case <-time.After(5 * time.Second):
104+
cancel()
105+
close(barrier.release)
106+
t.Fatal("first migration runner did not acquire the lock")
107+
}
108+
if err := RunMigrations(context.Background(), db); err == nil || !strings.Contains(err.Error(), "already locked") {
109+
cancel()
110+
close(barrier.release)
111+
t.Fatalf("concurrent RunMigrations() error = %v, want migration lock conflict", err)
112+
}
113+
114+
cancel()
115+
close(barrier.release)
116+
if err := <-firstResult; err == nil {
117+
t.Fatal("cancelled RunMigrations() succeeded")
118+
}
119+
if err := RunMigrations(context.Background(), db); err != nil {
120+
t.Fatalf("RunMigrations() after cancelled owner = %v, want released lock", err)
121+
}
122+
}
123+
22124
func TestNew_SQLiteConcurrentClaimsDoNotBusy(t *testing.T) {
23125
t.Parallel()
24126

0 commit comments

Comments
 (0)