← Back to list

How GoFr Handles Migrations in Multi-Instance Deployments

Why running three replicas of your service used to be a database minefield — and how GoFr quietly fixes it.

Gursewaksingh · 2026-04-28 13:03 · 0 claps · 5.1 min read
#migration #gofr
Open on Medium ↗
Wiki topics: 🔧 · Data Engineering 🏃 · Running & Endurance

How GoFr Handles Migrations in Multi-Instance Deployments

Why running three replicas of your service used to be a database minefield — and how GoFr quietly fixes it.

The problem nobody warns you about

You’ve shipped your Go service. It’s containerized, it’s healthy, and your platform team has helpfully scaled it to three replicas behind a load balancer. Life is good.

Then you push a release that includes a new database migration.All three pods boot at roughly the same time. All three see an unapplied migration. All three try to run it.

What happens next depends on luck:

  • If you’re fortunate, two of them fail loudly with a duplicate-key error on the migration tracking table, the orchestrator restarts them, and you spend the afternoon explaining why deploys are flaky.
  • If you’re unfortunate, two of them succeed at different parts of the migration concurrently — one creates the table, another tries to seed it before the index exists, a third leaves your schema_migrations row half-written. Now your database is in a state nobody designed.

This is the classic race condition at the heart of multi-instance migrations, and most teams discover it the hard way. The traditional fixes are all manual: run migrations as a separate Kubernetes Job before the rollout, gate deploys on a CI step, or write a custom leader-election wrapper. They work, but they push complexity onto you.

GoFr takes a different stance: migrations should just be safe to run, even when N pods boot simultaneously, with zero changes to your code.

What GoFr actually does

When you call a.Migrate(migrations.All()) inside main.go, GoFr doesn't just iterate through your migration map and execute pending ones. Before any migration runs, it goes through a coordination dance using a distributed lock.

The flow looks like this:

  1. Each instance starts up and reaches the Migrate call.
  2. Each instance attempts to acquire a shared lock — backed by either your SQL database or Redis.
  3. One instance wins the lock. The others block and wait.
  4. The winner runs the pending migrations inside a transaction, updates the migration history table, and releases the lock.
  5. The waiting instances wake up, see that the migrations they were going to run are already recorded as complete, and continue their startup without re-running anything.

The whole thing is invisible from your application’s perspective. You write the same migration code you’d write for a single-instance app, and GoFr handles the coordination underneath.

The lock mechanism, in detail

The lock isn’t a hand-rolled cron-style file lock — it’s a proper distributed primitive that survives crashes and long-running migrations.

For SQL backends (MySQL, PostgreSQL, SQLite), GoFr uses a dedicated table called gofr_migration_locks. Acquiring the lock is a conditional insert; releasing it is a delete. Because the operation is atomic at the database level, two pods can race for the same row and exactly one will win.

For Redis, GoFr uses the classic SETNX pattern (set-if-not-exists) with a TTL. This is the same primitive that powers libraries like Redlock and is well-understood as a coordination mechanism.

In both cases, the lock has a TTL of 15 seconds. That number matters more than it looks: if the pod holding the lock crashes mid-migration, the lock automatically expires after 15 seconds and another instance can pick up where things left off. You don’t end up with a permanently stuck deployment because someone OOM-killed pod 1.

But 15 seconds is also short — shorter than many real migrations. A schema change on a multi-million-row table can easily take minutes. To handle that, GoFr runs a heartbeat every 5 seconds that refreshes the lock TTL while the migration is still running. So as long as the holder is alive and making progress, it keeps the lock; the moment it dies, the lock expires within 15 seconds.

The waiting instances retry every 500 milliseconds, indefinitely. There’s no max-retry timeout that would cause a pod to give up and crash-loop. They just patiently wait.

The fast path

There’s one more nice detail: if the migrations are already complete when an instance starts up — say, you’re scaling up an existing deployment from 3 to 5 replicas — GoFr doesn’t bother acquiring the lock at all. It checks the migration history first, sees there’s nothing to do, and continues startup immediately.

This means the locking overhead only applies during actual deploys with new migrations. Steady-state pod restarts and autoscaling events pay zero coordination cost.

What this looks like in practice

Imagine a Kubernetes Deployment with three replicas:

services:
  app:
    image: myapp:latest
    replicas: 3

You push a release that includes one new migration. Here’s the timeline:

  • Instance 1 starts, acquires the lock, begins running the migration.
  • Instance 2 starts ~200ms later, tries the lock, fails, starts polling every 500ms.
  • Instance 3 starts another 100ms after that, also begins polling.
  • Instance 1 finishes the migration (let’s say it took 4 seconds), writes the row to gofr_migrations, releases the lock.
  • Instance 2’s next poll succeeds. It checks the migration table, sees the migration is already done, skips it, and moves on with startup.
  • Instance 3 does the same.

Total added latency for instances 2 and 3: roughly the duration of the migration itself. No errors. No half-applied state. No manual job to gate the deploy on.

If instance 1 had crashed at second 3 of the migration, the lock would have expired around second 18 (TTL refreshed once at second 5, then no further refreshes). Instance 2 would have acquired it and re-run the migration from scratch — which is safe because GoFr migrations run inside a transaction and are tracked by version number.

Why this is a big deal

The reason this matters isn’t that distributed locks are clever — they’re not, they’re decades-old infrastructure. It’s that migration coordination has historically been the application developer’s problem, even though it’s a generic, solved infrastructure concern.

Most teams end up writing the same boilerplate over and over: a Kubernetes Job that runs migrations before the Deployment rolls out, a CI step that runs migrate up against production, a Helm chart hook, a custom init container. Each of these works, each has its own failure modes, and each adds operational complexity.

By baking coordination into the framework itself, GoFr makes the right behavior the default. You don’t have to remember to set up a pre-deploy job. You don’t have to document for new team members that “you can’t just scale this thing without thinking about migrations.” It just works, the same way HTTP routing or logging just works.

Things to keep in mind

A few caveats worth flagging:

  • Single-instance deployments behave identically. There’s no performance penalty for running with one replica; the lock is acquired and released so quickly it’s invisible.
  • PubSub backends don’t store migration state. Migration version tracking only happens in SQL or Redis, because PubSub systems like Kafka or Redis Streams persist messages even after consumption — using them as the source of truth would risk replaying old migrations from stale state.
  • For Cassandra, GoFr supports migrations but doesn’t guarantee atomicity for individual DML commands. If you need atomicity, use batch operations (NewBatch, BatchQuery, ExecuteBatch) or wrap your statements in BEGIN BATCH / APPLY BATCH.

Wrapping up

Multi-instance migration safety isn’t a flashy feature. You won’t see it on a marketing slide next to “10x faster!” benchmarks. But it’s the kind of detail that separates frameworks that look good in a demo from frameworks that survive production.

GoFr’s approach — automatic distributed locks, heartbeat-extended TTLs, indefinite retries, and a fast path for the no-op case — covers the failure modes that bite teams most often: simultaneous startup races, mid-migration crashes, and long-running schema changes. And it does all of this without asking you to write a single line of coordination code.

If you’ve ever been burned by a flaky migration during a rolling deploy, you’ll appreciate why this is the default.

Reference: GoFr documentation — Multi-Instance Deployments


메타데이터
post_id
aad09af9cc6f
slug
how-gofr-handles-migrations-in-multi-instance-deployments-aad09af9cc6f
url
https://medium.com/@gursewaksingh3789/how-gofr-handles-migrations-in-multi-instance-deployments-aad09af9cc6f
canonical_url
https://medium.com/@gursewaksingh3789/how-gofr-handles-migrations-in-multi-instance-deployments-aad09af9cc6f
author_url
https://medium.com/@gursewaksingh3789
status
ok
fetched_at
2026-06-20 20:29:01