@@ -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+
22124func TestNew_SQLiteConcurrentClaimsDoNotBusy (t * testing.T ) {
23125 t .Parallel ()
24126
0 commit comments