Skip to content

Commit f27c0e1

Browse files
committed
fix(db): recover from a migration lock left by a killed run
1 parent 4d90ae6 commit f27c0e1

4 files changed

Lines changed: 119 additions & 1 deletion

File tree

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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ func RunMigrations(ctx context.Context, db *bun.DB) (retErr error) {
6969
return fmt.Errorf("initializing migrator: %w", err)
7070
}
7171
if err := migrator.Lock(ctx); err != nil {
72-
return fmt.Errorf("locking migrator: %w", err)
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)
7374
}
7475
defer func() {
7576
unlockCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), migrationUnlockTimeout)
@@ -93,6 +94,18 @@ func RunMigrations(ctx context.Context, db *bun.DB) (retErr error) {
9394
return nil
9495
}
9596

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+
96109
// Ping verifies the database connection is alive.
97110
func Ping(ctx context.Context, db *bun.DB) error {
98111
return db.PingContext(ctx)

internal/db/db_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ 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"
@@ -42,6 +43,40 @@ func (h *migrationLockBarrier) AfterQuery(ctx context.Context, event *bun.QueryE
4243
})
4344
}
4445

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+
4580
func TestRunMigrationsSerializesConcurrentRunnersAndUnlocksAfterCancellation(t *testing.T) {
4681
cfg := config.DatabaseConfig{
4782
Driver: "sqlite",

internal/db/migrations/schema_integrity_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,16 @@ func testLegacyMigrationUpgradePreservesDataAndIsIdempotent(t *testing.T, db *bu
9595
}
9696
markAppliedMigration(t, ctx, migrator, "2026040501", 1)
9797
seedLegacyMigrationData(t, db)
98+
walletBefore := readLegacyWalletRow(t, db)
9899
if _, err := migrator.Migrate(ctx); err != nil {
99100
t.Fatalf("upgrade legacy schema: %v", err)
100101
}
101102

103+
// 2026062201 rebuilds wallet_operations on SQLite by copying rows.
104+
if walletAfter := readLegacyWalletRow(t, db); walletAfter != walletBefore {
105+
t.Fatalf("wallet row changed across the upgrade:\n before=%s\n after =%s", walletBefore, walletAfter)
106+
}
107+
102108
var generation int
103109
var current bool
104110
if err := db.NewRaw("SELECT generation, is_current FROM storage_data_sets WHERE id = 1").Scan(ctx, &generation, &current); err != nil {
@@ -177,6 +183,12 @@ func seedLegacyMigrationData(t *testing.T, db *bun.DB) {
177183
`INSERT INTO storage_data_sets
178184
(id, bucket_id, provider_id, copy_index, status)
179185
VALUES (1, 1, '101', 0, 'ready')`,
186+
`INSERT INTO wallet_operations
187+
(id, type, client_request_id, amount, status, tx_hash, last_error,
188+
lease_until, started_at, submitted_at, completed_at, created_at, updated_at)
189+
VALUES (1, 'fund', 'legacy-fund', '1234', 'confirmed', '0xfeed', 'boom',
190+
'2026-01-01 01:00:00', '2026-01-02 02:00:00', '2026-01-03 03:00:00',
191+
'2026-01-04 04:00:00', '2026-01-05 05:00:00', '2026-01-06 06:00:00')`,
180192
}
181193
for _, statement := range statements {
182194
if _, err := db.ExecContext(ctx, statement); err != nil {
@@ -185,6 +197,41 @@ func seedLegacyMigrationData(t *testing.T, db *bun.DB) {
185197
}
186198
}
187199

200+
func readLegacyWalletRow(t *testing.T, db *bun.DB) string {
201+
t.Helper()
202+
var (
203+
opType, requestID, amount, status string
204+
txHash, lastError *string
205+
lease, started, submitted, done *time.Time
206+
created, updated time.Time
207+
)
208+
if err := db.NewRaw(`SELECT type, client_request_id, amount, status, tx_hash, last_error,
209+
lease_until, started_at, submitted_at, completed_at, created_at, updated_at
210+
FROM wallet_operations WHERE id = 1`).
211+
Scan(context.Background(), &opType, &requestID, &amount, &status, &txHash, &lastError,
212+
&lease, &started, &submitted, &done, &created, &updated); err != nil {
213+
t.Fatalf("read wallet row: %v", err)
214+
}
215+
return fmt.Sprintf("%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s",
216+
opType, requestID, amount, status, derefString(txHash), derefString(lastError),
217+
formatTime(lease), formatTime(started), formatTime(submitted), formatTime(done),
218+
formatTime(&created), formatTime(&updated))
219+
}
220+
221+
func derefString(v *string) string {
222+
if v == nil {
223+
return "<nil>"
224+
}
225+
return *v
226+
}
227+
228+
func formatTime(v *time.Time) string {
229+
if v == nil {
230+
return "<nil>"
231+
}
232+
return v.UTC().Format(time.RFC3339Nano)
233+
}
234+
188235
func countDomainTables(ctx context.Context, db *bun.DB) (int, error) {
189236
query := `SELECT COUNT(*) FROM sqlite_schema
190237
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'

0 commit comments

Comments
 (0)