Skip to content

Commit da10192

Browse files
committed
feat: skip snapshot restore if unchanged (mtime+size check)
1 parent 9c4238d commit da10192

4 files changed

Lines changed: 118 additions & 20 deletions

File tree

cmd/test.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import (
99
)
1010

1111
var (
12-
testCwd string
13-
testRunFilter string
14-
testFormat string
15-
testOutputPath string
16-
testCommit bool
17-
testNoRestore bool
12+
testCwd string
13+
testRunFilter string
14+
testFormat string
15+
testOutputPath string
16+
testCommit bool
17+
testNoRestore bool
18+
testForceRestore bool
1819

1920
testCmd = &cobra.Command{
2021
Use: "test [flags]",
@@ -24,7 +25,7 @@ var (
2425
fmt.Print(err.Error())
2526
os.Exit(1)
2627
}
27-
regresql.Test(testCwd, testRunFilter, testFormat, testOutputPath, testCommit, testNoRestore)
28+
regresql.Test(testCwd, testRunFilter, testFormat, testOutputPath, testCommit, testNoRestore, testForceRestore)
2829
},
2930
}
3031
)
@@ -38,4 +39,5 @@ func init() {
3839
testCmd.Flags().StringVarP(&testOutputPath, "output", "o", "", "Output file path (default: stdout)")
3940
testCmd.Flags().BoolVar(&testCommit, "commit", false, "Commit transactions instead of rollback (use with caution)")
4041
testCmd.Flags().BoolVar(&testNoRestore, "no-restore", false, "Skip snapshot restore before test")
42+
testCmd.Flags().BoolVar(&testForceRestore, "force-restore", false, "Force snapshot restore even if unchanged")
4143
}

cmd/update.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import (
99
)
1010

1111
var (
12-
updateCwd string
13-
updateRunFilter string
14-
updateCommit bool
15-
updateNoRestore bool
12+
updateCwd string
13+
updateRunFilter string
14+
updateCommit bool
15+
updateNoRestore bool
16+
updateForceRestore bool
1617

1718
// updateCmd represents the update command
1819
updateCmd = &cobra.Command{
@@ -23,7 +24,7 @@ var (
2324
fmt.Print(err.Error())
2425
os.Exit(1)
2526
}
26-
regresql.Update(updateCwd, updateRunFilter, updateCommit, updateNoRestore)
27+
regresql.Update(updateCwd, updateRunFilter, updateCommit, updateNoRestore, updateForceRestore)
2728
},
2829
}
2930
)
@@ -35,4 +36,5 @@ func init() {
3536
updateCmd.Flags().StringVar(&updateRunFilter, "run", "", "Run only queries matching regexp (matches file names and query names)")
3637
updateCmd.Flags().BoolVar(&updateCommit, "commit", false, "Commit transactions instead of rollback (use with caution)")
3738
updateCmd.Flags().BoolVar(&updateNoRestore, "no-restore", false, "Skip snapshot restore before update")
39+
updateCmd.Flags().BoolVar(&updateForceRestore, "force-restore", false, "Force snapshot restore even if unchanged")
3840
}

regresql/regresql.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package regresql
33
import (
44
"fmt"
55
"os"
6+
"time"
67
)
78

89
/*
@@ -92,7 +93,7 @@ case and add a value for each parameter. `)
9293
Update updates the expected files from the queries and their parameters.
9394
Each query runs in its own transaction that rolls back (unless commit is true).
9495
*/
95-
func Update(root string, runFilter string, commit, noRestore bool) {
96+
func Update(root string, runFilter string, commit, noRestore, forceRestore bool) {
9697
config, err := ReadConfig(root)
9798
ignorePatterns := []string{}
9899
if err == nil {
@@ -107,7 +108,7 @@ func Update(root string, runFilter string, commit, noRestore bool) {
107108
os.Exit(3)
108109
}
109110

110-
autoRestore(config, root, noRestore)
111+
autoRestore(config, root, noRestore, forceRestore)
111112

112113
if err := TestConnectionString(config.PgUri); err != nil {
113114
fmt.Print(err.Error())
@@ -135,7 +136,7 @@ the regresql update command again to reset the expected output files.
135136
`)
136137
}
137138

138-
func autoRestore(cfg config, root string, noRestore bool) {
139+
func autoRestore(cfg config, root string, noRestore, forceRestore bool) {
139140
if noRestore || !ShouldAutoRestore(cfg.Snapshot) {
140141
return
141142
}
@@ -144,22 +145,53 @@ func autoRestore(cfg config, root string, noRestore bool) {
144145
fmt.Printf("Error: snapshot file not found: %s\n\nRun 'regresql snapshot build' to create a snapshot, or use '--no-restore' to skip\n", snapshotPath)
145146
os.Exit(1)
146147
}
147-
fmt.Printf("Restoring snapshot: %s\n", snapshotPath)
148+
149+
snapshotsDir := GetSnapshotsDir(root)
150+
targetDB := cfg.Snapshot.RestoreDatabase
151+
152+
if !forceRestore {
153+
needsRestore, reason := NeedsRestore(snapshotsDir, snapshotPath, targetDB)
154+
if !needsRestore {
155+
state, _ := ReadRestoreState(snapshotsDir)
156+
fmt.Printf("Skipping restore: snapshot unchanged since %s (restored in %.1fs)\n\n",
157+
state.RestoredAt.Local().Format("2006-01-02 15:04:05"),
158+
float64(state.DurationMillis)/1000)
159+
return
160+
}
161+
fmt.Printf("Restoring snapshot: %s (%s)\n", snapshotPath, reason)
162+
} else {
163+
fmt.Printf("Restoring snapshot: %s (forced)\n", snapshotPath)
164+
}
165+
166+
start := time.Now()
148167
opts := RestoreOptions{
149168
InputPath: snapshotPath,
150169
Clean: true,
151-
TargetDatabase: cfg.Snapshot.RestoreDatabase,
170+
TargetDatabase: targetDB,
152171
}
153172
if err := RestoreSnapshot(cfg.PgUri, opts); err != nil {
154173
fmt.Printf("Error: failed to restore snapshot: %s\n", err)
155174
os.Exit(1)
156175
}
157-
fmt.Println()
176+
duration := time.Since(start)
177+
178+
stat, _ := os.Stat(snapshotPath)
179+
state := &RestoreState{
180+
SnapshotPath: snapshotPath,
181+
SnapshotMtime: stat.ModTime(),
182+
SnapshotSize: stat.Size(),
183+
Database: targetDB,
184+
RestoredAt: time.Now().UTC(),
185+
DurationMillis: duration.Milliseconds(),
186+
}
187+
WriteRestoreState(snapshotsDir, state)
188+
189+
fmt.Printf("Restored in %.1fs\n\n", duration.Seconds())
158190
}
159191

160192
// Test runs regression tests for all queries.
161193
// Each query runs in its own transaction that rolls back (unless commit is true).
162-
func Test(root, runFilter, formatName, outputPath string, commit, noRestore bool) {
194+
func Test(root, runFilter, formatName, outputPath string, commit, noRestore, forceRestore bool) {
163195
config, err := ReadConfig(root)
164196
ignorePatterns := []string{}
165197
if err == nil {
@@ -177,7 +209,7 @@ func Test(root, runFilter, formatName, outputPath string, commit, noRestore bool
177209
// Cache config for plan quality analysis
178210
SetGlobalConfig(config)
179211

180-
autoRestore(config, root, noRestore)
212+
autoRestore(config, root, noRestore, forceRestore)
181213

182214
// Validate schema hasn't changed since last snapshot build
183215
if err := ValidateSchemaHash(root); err != nil {

regresql/snapshot.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ type (
3636
FixturesUsed []string `yaml:"fixtures_used,omitempty"`
3737
}
3838

39+
RestoreState struct {
40+
SnapshotPath string `yaml:"snapshot_path"`
41+
SnapshotMtime time.Time `yaml:"snapshot_mtime"`
42+
SnapshotSize int64 `yaml:"snapshot_size"`
43+
Database string `yaml:"database"`
44+
RestoredAt time.Time `yaml:"restored_at"`
45+
DurationMillis int64 `yaml:"duration_millis"`
46+
}
47+
3948
SnapshotFormat string
4049

4150
SnapshotOptions struct {
@@ -77,6 +86,7 @@ const (
7786
DefaultSnapshotPath = "snapshots/default.dump"
7887
DefaultSnapshotFormat = FormatCustom
7988
SnapshotMetadataFile = ".regresql-snapshot.yaml"
89+
RestoreStateFile = ".regresql-restore-state.yaml"
8090
)
8191

8292
// RestoreTool returns the appropriate PostgreSQL tool for restoring this format.
@@ -430,6 +440,58 @@ func ShouldAutoRestore(cfg *SnapshotConfig) bool {
430440
return *cfg.AutoRestore
431441
}
432442

443+
func ReadRestoreState(snapshotsDir string) (*RestoreState, error) {
444+
statePath := filepath.Join(snapshotsDir, RestoreStateFile)
445+
data, err := os.ReadFile(statePath)
446+
if err != nil {
447+
return nil, err
448+
}
449+
var state RestoreState
450+
if err := yaml.Unmarshal(data, &state); err != nil {
451+
return nil, err
452+
}
453+
return &state, nil
454+
}
455+
456+
func WriteRestoreState(snapshotsDir string, state *RestoreState) error {
457+
statePath := filepath.Join(snapshotsDir, RestoreStateFile)
458+
data, err := yaml.Marshal(state)
459+
if err != nil {
460+
return err
461+
}
462+
return os.WriteFile(statePath, data, 0o644)
463+
}
464+
465+
func NeedsRestore(snapshotsDir, snapshotPath, targetDB string) (bool, string) {
466+
state, err := ReadRestoreState(snapshotsDir)
467+
if err != nil {
468+
return true, "no previous restore state"
469+
}
470+
471+
if state.SnapshotPath != snapshotPath {
472+
return true, "snapshot path changed"
473+
}
474+
475+
if state.Database != targetDB {
476+
return true, "target database changed"
477+
}
478+
479+
stat, err := os.Stat(snapshotPath)
480+
if err != nil {
481+
return true, "failed to stat snapshot"
482+
}
483+
484+
if stat.Size() != state.SnapshotSize {
485+
return true, "snapshot size changed"
486+
}
487+
488+
if !stat.ModTime().Equal(state.SnapshotMtime) {
489+
return true, "snapshot modified"
490+
}
491+
492+
return false, ""
493+
}
494+
433495
// replaceDatabase returns a new connection string with a different database
434496
func replaceDatabase(pguri, newDB string) (string, error) {
435497
u, err := url.Parse(pguri)

0 commit comments

Comments
 (0)