Skip to content

Commit 7dc4b8f

Browse files
committed
feat: improve indexer error handling and add advisory lock for migrations
1 parent 1c22cbc commit 7dc4b8f

2 files changed

Lines changed: 57 additions & 8 deletions

File tree

backend/cmd/api/main.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,22 @@ func main() {
3838
// Indexer role — reconcile on-chain events into history. Run exactly one of these across the
3939
// fleet (RUN_MODE=indexer), so scaling the API doesn't spawn duplicate indexers.
4040
if cfg.RunsIndexer() {
41-
if idx != nil {
42-
go idx.Run(ctx)
43-
logger.Info("indexer started", "rpc", cfg.SorobanRPCURL)
44-
} else {
45-
logger.Warn("indexer role selected but store unavailable — indexer not started")
41+
/*
42+
* An indexer with no store does nothing at all, so refuse to run as one.
43+
*
44+
* This used to log a warning and carry on: the process stayed up, healthy to Docker, and
45+
* quietly indexed nothing and folded nothing. Deposits sat at "confirming" indefinitely
46+
* while the API — which had won the migration race and did have a store — looked perfectly
47+
* fine. Exiting hands the decision to the restart policy, which will bring it back once
48+
* Postgres is reachable, and makes the failure visible instead of silent.
49+
*/
50+
if idx == nil {
51+
logger.Error("indexer role selected but the store is unavailable — exiting so this is " +
52+
"visible rather than running as an indexer that indexes nothing")
53+
os.Exit(1)
4654
}
55+
go idx.Run(ctx)
56+
logger.Info("indexer started", "rpc", cfg.SorobanRPCURL)
4757
// The pool indexer runs alongside it. Exactly one replica: leaf indices are assigned in
4858
// queue order, and two writers racing on the same range would corrupt that ordering.
4959
if poolIdx != nil {

backend/internal/store/store.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,30 @@ func (s *Store) Close() { s.pool.Close() }
6767
// Ping checks the Postgres connection is alive — used by the readiness probe.
6868
func (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.
7394
func (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

Comments
 (0)