@@ -67,9 +67,30 @@ func (s *Store) Close() { s.pool.Close() }
6767// Ping checks the Postgres connection is alive — used by the readiness probe.
6868func (s * Store ) Ping (ctx context.Context ) error { return s .pool .Ping (ctx ) }
6969
70+ /*
71+ * migrationLock is an arbitrary constant identifying the advisory lock migrations serialise on.
72+ *
73+ * Any int64 works as long as nothing else in this database picks the same one; advisory locks share
74+ * a single namespace per database.
75+ */
76+ const migrationLock int64 = 0x50524f5641 // "PROVA"
77+
7078// Migrate applies the embedded SQL migrations (backend/migrations/*.sql) in filename order. Each
71- // file is idempotent (IF NOT EXISTS), so re-running on every boot — and across replicas — is safe.
72- // These are the same files you can run by hand against a managed database (e.g. Supabase).
79+ // file is idempotent (IF NOT EXISTS), so re-running on every boot is safe. These are the same files
80+ // you can run by hand against a managed database (e.g. Supabase).
81+ //
82+ // ---------------------------------------------------------------------------
83+ // WHY THE LOCK
84+ // ---------------------------------------------------------------------------
85+ // `api` and `indexer` are separate containers that boot together, and both migrate. "IF NOT EXISTS"
86+ // is not a substitute for serialising them: two sessions running CREATE TABLE IF NOT EXISTS at the
87+ // same instant both find nothing, both create, and the loser fails with a duplicate-key error on
88+ // pg_type. That failure is not cosmetic — main.go leaves the store nil when Migrate returns an
89+ // error, and an indexer with no store starts no folder. Deposits then sit at "confirming" forever
90+ // while the API looks perfectly healthy, because the API happened to win the race.
91+ //
92+ // pg_advisory_lock makes the second container wait rather than collide. It is released when the
93+ // connection is returned, so a crash mid-migration cannot leave it held.
7394func (s * Store ) Migrate (ctx context.Context ) error {
7495 entries , err := fs .ReadDir (migrations .FS , "." )
7596 if err != nil {
@@ -82,12 +103,30 @@ func (s *Store) Migrate(ctx context.Context) error {
82103 }
83104 }
84105 sort .Strings (names )
106+
107+ // One connection for the whole run: an advisory lock belongs to the session that took it, so
108+ // taking it on one pooled connection and migrating on another would protect nothing.
109+ conn , err := s .pool .Acquire (ctx )
110+ if err != nil {
111+ return fmt .Errorf ("acquire migration connection: %w" , err )
112+ }
113+ defer conn .Release ()
114+
115+ if _ , err := conn .Exec (ctx , `SELECT pg_advisory_lock($1)` , migrationLock ); err != nil {
116+ return fmt .Errorf ("take migration lock: %w" , err )
117+ }
118+ // Explicit unlock so the lock goes back immediately rather than whenever the pooled connection
119+ // happens to be recycled. Releasing the connection would also drop it.
120+ defer func () {
121+ _ , _ = conn .Exec (context .WithoutCancel (ctx ), `SELECT pg_advisory_unlock($1)` , migrationLock )
122+ }()
123+
85124 for _ , name := range names {
86125 sqlBytes , rerr := migrations .FS .ReadFile (name )
87126 if rerr != nil {
88127 return fmt .Errorf ("read migration %s: %w" , name , rerr )
89128 }
90- if _ , eerr := s . pool .Exec (ctx , string (sqlBytes )); eerr != nil {
129+ if _ , eerr := conn .Exec (ctx , string (sqlBytes )); eerr != nil {
91130 return fmt .Errorf ("apply migration %s: %w" , name , eerr )
92131 }
93132 }
0 commit comments