jonnifyelixir
F7 · Deploy to Fly / F7.3 · the database

F7 · the database · module 3

The database goes live

F7.2 made the database host resolve: runtime.exs consumes ECTO_IPV6 and Postgrex connects over Fly’s IPv6 network. A reachable database is still not a working one. On the first bring-up portal existed on portal-postgres but carried zero tables — a DATABASE_URL that is set is not a schema that exists. And the boot does not degrade on an empty database: Portal.Engine.init/1 reads the event log synchronously at startup, so a reachable-but-unmigrated database crashes the whole release on relation "events" does not exist, and Fly suspends the machine after ten restarts. This module closes the gap between “the connection string resolves” and “the boot’s database read succeeds.” A built release carries no mix, so the schema is applied release-native through Portal.Release.migrate/0.

Three dives: how a release with no mix runs its migrations through Ecto.Migrator, idempotently; the empty-database trap and the fly proxy path that applies a schema against the writable primary before the app can boot; and the synchronous boot-time database coupling — observed here, its hardening named, the engine left untouched.

Reachable and migrated

The boot has one hard precondition with two parts, and missing either looks almost the same at the crash site. Portal.Engine.init/1 folds the Portal’s state by replaying the event log, and it does so synchronously at startup: init/1 calls Portal.Engine.load/0, which calls the event store’s read_stream/1 — a real SELECT … FROM events under the connection pool’s four-second queue_timeout. If the database is unreachable the read times out; if it is reachable but unmigrated the read hits a table that does not exist. Either way init/1 raises, the child fails to start, the release exits, and Fly suspends the machine after ten restarts. Select a database condition to see what the boot’s read does.

The boot’s synchronous read · select a database condition

portal-postgres reachable, 0 tables DATABASE_URL set, never migrated read_stream/1 SELECT … FROM events order_by :seq · 4s queue_timeout Portal.Engine.init/1 raises failed to start child → exit 1 crash · Fly suspends the machine after 10 restarts

A boot survives only when the database is both reachable (F7.2’s address-family fix) and migrated (this module). The two failures crash in the same family — a child that will not start — which is exactly why the condition has to be read, not guessed.

Triggering the migration

A built release does not carry mix — it ships the compiled BEAM files and a bin/portal script, not the Mix archive — so mix ecto.migrate is not a command the deploy machine has. The schema is applied release-native instead, through Portal.Release.migrate/0, which loads :portal, starts the repository in isolation through Ecto.Migrator.with_repo, and runs every pending migration — without booting the full supervision tree. Three triggers reach that one entry point; the fourth does not exist on a release. Select a trigger to see where it runs and what it invokes.

Four triggers, one entry point · select a path

TRIGGER bin/portal eval "Portal.Release.migrate()" on a running machine via fly ssh console release-native ENTRY POINT Portal.Release.migrate/0 load :portal · Ecto.Migrator.with_repo runs only the pending versions schema_ migrations +2 versions

Whichever trigger fires, the entry point is the same Portal.Release.migrate/0, and it is idempotent: Ecto.Migrator consults schema_migrations and applies only versions not yet recorded, so a re-run on every deploy is safe. Wiring a release_command for auto-migration is the Operator’s call — F7.4 owns the stanza.

Three deep dives

In order: the release-native migration path by symbol — Portal.Release.migrate/0, rollback/2, and the idempotency that makes them safe to repeat; the empty-database trap and the fly proxy procedure that applies a schema against the writable primary; and the synchronous boot-time database coupling, documented with its hardening named as a carried item that touches the engine and so stays unbuilt here.

F7.3.1
Release-native migration
Why a release has no mix, how Portal.Release.migrate/0 loads :portal and runs Ecto.Migrator.with_repo without the supervision tree, rollback/2, the migrate-before-start contract, and idempotency through schema_migrations.
F7.3.2
The empty-DB trap & the fly proxy path
A set DATABASE_URL is not a created schema; fly proxy 15432:5432, the writable-primary check (pg_is_in_recovery() → f), the CREATE-privileged role, and the .internal-versus-.flycast host-scheme discriminator.
F7.3.3
The boot-time DB coupling
The synchronous init/1 → load/0 → read_stream/1 path, why an unreachable or empty database is a hard crash and a suspend-after-ten, the stacked precondition, and the lazy-replay hardening named as carried — below the facade, so not built here.

F7.2 resolved the host

runtime.exs consumes ECTO_IPV6, so Postgrex reaches the AAAA-only database host over IPv6 and connects.

F7.3 migrates the schema

Portal.Release.migrate/0 applies both versions, so the boot’s read_stream/1 returns instead of crashing on a missing table.

Start with release-native migration, then the empty-database trap and the proxy path, then the boot-time coupling. This is the third rung of F7; once the schema is live, F7.4 ships fly.toml and the deploy mechanics that can automate this migration. It builds on F6.08 — Auth, deployment & going live, which produced Portal.Release.migrate/0 and the two migration files this module takes live.

References

The release-native migration mechanism, the migration DSL that defines the two tables, the fly proxy path to a Postgres app, and the primary-versus-replica check.

Sources

Related in this course