Skip to content

Commit 26b8a87

Browse files
authored
Merge pull request #374 from docker/migration-broken-fix
fix: error looking for database down migrations
2 parents 690dd04 + 28adae1 commit 26b8a87

File tree

6 files changed

+377
-39
lines changed

6 files changed

+377
-39
lines changed

docs/database/README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ Application Startup
4040
4141
pkg/db/New() called
4242
43-
Check for pending migrations
44-
4543
[Acquire File Lock] ← Prevents concurrent migrations
4644
47-
Run migrations sequentially
45+
Check current database version
46+
47+
Database version > available migrations?
48+
├─ Yes → Return error (database from newer version)
49+
└─ No → Run pending migrations sequentially
4850
4951
[Release File Lock]
5052
@@ -78,6 +80,17 @@ for a race condition if two process concurrently attempt to run the migrations.
7880

7981
**The Solution**: OS-level file locking ensures only one process runs migrations at a time. Other processes wait, then see migrations already complete.
8082

83+
### Database Version Compatibility
84+
85+
#### Running Older Version After Newer Version
86+
87+
If you run an older version of the tool after having used a newer version, the database may be at a higher schema version than the older tool expects. In this case:
88+
89+
- The tool detects the database is ahead of available migrations
90+
- An error is returned to prevent potential data corruption:
91+
```
92+
database version 8 (~/.docker/mcp/mcp-toolkit.db) is ahead of the current application version. Please upgrade to the latest version
93+
```
8194

8295
## Testing
8396

pkg/db/db.go

Lines changed: 96 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"embed"
77
"errors"
88
"fmt"
9+
"io/fs"
910
"os"
1011
"path/filepath"
1112
"time"
@@ -41,7 +42,9 @@ type dao struct {
4142
var migrations embed.FS
4243

4344
type options struct {
44-
dbFile string
45+
dbFile string
46+
migrationsFS fs.FS
47+
migrationsPath string
4548
}
4649

4750
type Option func(o *options) error
@@ -53,6 +56,14 @@ func WithDatabaseFile(dbFile string) Option {
5356
}
5457
}
5558

59+
func WithMigrations(filesystem fs.FS, path string) Option {
60+
return func(o *options) error {
61+
o.migrationsFS = filesystem
62+
o.migrationsPath = path
63+
return nil
64+
}
65+
}
66+
5667
func New(opts ...Option) (DAO, error) {
5768
var o options
5869
for _, opt := range opts {
@@ -80,19 +91,70 @@ func New(opts ...Option) (DAO, error) {
8091
db.SetMaxIdleConns(1)
8192
db.SetConnMaxLifetime(0)
8293

83-
migDriver, err := iofs.New(migrations, "migrations")
94+
migrationsFS := o.migrationsFS
95+
if migrationsFS == nil {
96+
migrationsFS = &migrations
97+
}
98+
99+
migrationsPath := o.migrationsPath
100+
if migrationsPath == "" {
101+
migrationsPath = "migrations"
102+
}
103+
104+
err = runMigrations(o.dbFile, db, migrationsFS, migrationsPath)
84105
if err != nil {
85106
return nil, err
86107
}
87108

109+
sqlxDb := sqlx.NewDb(db, "sqlite")
110+
111+
return &dao{db: sqlxDb}, nil
112+
}
113+
114+
func (d *dao) Close() error {
115+
return d.db.Close()
116+
}
117+
118+
func DefaultDatabaseFilename() (string, error) {
119+
homeDir, err := user.HomeDir()
120+
if err != nil {
121+
return "", err
122+
}
123+
return filepath.Join(homeDir, ".docker", "mcp", "mcp-toolkit.db"), nil
124+
}
125+
126+
func ensureDirectoryExists(path string) {
127+
dir := filepath.Dir(path)
128+
if _, err := os.Stat(dir); os.IsNotExist(err) {
129+
_ = os.MkdirAll(dir, 0o755)
130+
}
131+
}
132+
133+
func txClose(tx *sqlx.Tx, err *error) {
134+
if err == nil || *err == nil {
135+
return
136+
}
137+
138+
if txerr := tx.Rollback(); txerr != nil {
139+
log.Logf("failed to rollback transaction: %v", txerr)
140+
}
141+
}
142+
143+
func runMigrations(dbFile string, db *sql.DB, migrationsFS fs.FS, migrationsPath string) error {
144+
migDriver, err := iofs.New(migrationsFS, migrationsPath)
145+
if err != nil {
146+
return err
147+
}
148+
defer migDriver.Close()
149+
88150
driver, err := msqlite.WithInstance(db, &msqlite.Config{})
89151
if err != nil {
90-
return nil, err
152+
return err
91153
}
92154

93155
mig, err := migrate.NewWithInstance("iofs", migDriver, "sqlite", driver)
94156
if err != nil {
95-
return nil, err
157+
return err
96158
}
97159

98160
// Use file locking to prevent concurrent migrations across processes
@@ -102,7 +164,7 @@ func New(opts ...Option) (DAO, error) {
102164
//
103165
// Note: The lock file persists on disk after Unlock() - this is intentional.
104166
// flock.Unlock() only releases the lock and closes the file descriptor
105-
lockFile := filepath.Join(filepath.Dir(o.dbFile), ".mcp-toolkit-migration.lock")
167+
lockFile := filepath.Join(filepath.Dir(dbFile), ".mcp-toolkit-migration.lock")
106168
fileLock := flock.New(lockFile)
107169

108170
// Try to acquire the lock with a 5 second timeout
@@ -111,53 +173,51 @@ func New(opts ...Option) (DAO, error) {
111173

112174
locked, err := fileLock.TryLockContext(ctx, 100*time.Millisecond)
113175
if err != nil {
114-
return nil, fmt.Errorf("failed to acquire migration lock: %w", err)
176+
return fmt.Errorf("failed to acquire migration lock: %w", err)
115177
}
116178
if !locked {
117-
return nil, fmt.Errorf("timeout waiting for migration lock")
179+
return fmt.Errorf("timeout waiting for migration lock")
118180
}
119181
defer func() {
120182
if err := fileLock.Unlock(); err != nil {
121183
log.Logf("failed to unlock migration lock: %v", err)
122184
}
123185
}()
124186

125-
// Now safely run migrations with the lock held
126-
err = mig.Up()
127-
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
128-
return nil, fmt.Errorf("failed to run migrations: %w", err)
129-
}
187+
// Now that we have the lock, check the current migration state
188+
version, dirty, err := mig.Version()
130189

131-
sqlxDb := sqlx.NewDb(db, "sqlite")
190+
// If ErrNilVersion, the database is fresh (no migrations run yet)
191+
// In this case, proceed with running migrations
192+
isFreshDatabase := errors.Is(err, migrate.ErrNilVersion)
132193

133-
return &dao{db: sqlxDb}, nil
134-
}
135-
136-
func (d *dao) Close() error {
137-
return d.db.Close()
138-
}
139-
140-
func DefaultDatabaseFilename() (string, error) {
141-
homeDir, err := user.HomeDir()
142-
if err != nil {
143-
return "", err
194+
if err != nil && !isFreshDatabase {
195+
return fmt.Errorf("failed to get migration version: %w", err)
144196
}
145-
return filepath.Join(homeDir, ".docker", "mcp", "mcp-toolkit.db"), nil
146-
}
147197

148-
func ensureDirectoryExists(path string) {
149-
dir := filepath.Dir(path)
150-
if _, err := os.Stat(dir); os.IsNotExist(err) {
151-
_ = os.MkdirAll(dir, 0o755)
198+
// Check if database is in dirty state (migration was interrupted)
199+
if dirty {
200+
return fmt.Errorf("database is in dirty state at version %d, manual intervention required", version)
152201
}
153-
}
154202

155-
func txClose(tx *sqlx.Tx, err *error) {
156-
if err == nil || *err == nil {
157-
return
203+
// For fresh databases, always run migrations
204+
if !isFreshDatabase {
205+
// Check if database version is ahead of available migrations
206+
// This happens when running older code against a database that was upgraded by newer code
207+
_, _, err = migDriver.ReadUp(version)
208+
if errors.Is(err, os.ErrNotExist) {
209+
return fmt.Errorf("database version %d (%s) is ahead of the current application version. Please upgrade to the latest version", version, dbFile)
210+
}
211+
if err != nil {
212+
return fmt.Errorf("failed to read migration file for version %d: %w", version, err)
213+
}
158214
}
159215

160-
if txerr := tx.Rollback(); txerr != nil {
161-
log.Logf("failed to rollback transaction: %v", txerr)
216+
// Now safely run migrations with the lock held
217+
err = mig.Up()
218+
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
219+
return fmt.Errorf("failed to run migrations: %w", err)
162220
}
221+
222+
return nil
163223
}

0 commit comments

Comments
 (0)