Keeps a host's Oban schema in step with the Oban library it runs.
Why
PhoenixKit creates Oban's tables once, by delegating to
Oban.Migration.up/1 from its V135 baseline — which installs whatever
version the Oban of that day shipped. Oban's schema is versioned separately
(the version is the COMMENT on oban_jobs), and core's open {:oban, "~> 2.20"}
pin lets a host move to a newer Oban without anything migrating its schema.
That is not harmless. Schema version 14 added a suspended value to the
oban_job_state enum (it ships with Oban 2.21), and from Oban 2.24 the
unique-insert check looks for jobs in that state by default. Against a version-13 schema every unique insert fails
with invalid input value for enum oban_job_state: "suspended". Cron inserts
a minute's jobs in one transaction, so one unique cron worker rolls back
every job scheduled for that minute.
How it fits together
mix phoenix_kit.updatechecks the schema (check/2) and, when it is behind the library, writes a host migration (write_migration/5) that runsOban.Migration.up/1to the library's version — applied together with the rest of the update's migrations.mix phoenix_kit.doctorandmix phoenix_kit.statusreport the version.- The application logs one warning at boot when the running Oban's schema
is behind (
warn_if_behind/1), for hosts that never run the updater.
Postgres only: Oban's MySQL and SQLite engines are outside what core
installs, and check/2 says so rather than guessing.
Which schema, on which repo
Two places are checked (targets/3): PhoenixKit's prefix on the host repo
(where the baseline created the tables, and the repo mix ecto.migrate
runs), and the prefix the host's Oban config runs at, on the repo that
config names (repo:). When Oban runs on a repo other than the one the
updater writes migrations for, no file is written for it — a migration
there would run against the wrong database — and the updater prints the
migration to add to that repo instead.
Summary
Types
One checked target, as stage/4 returns it. file is what happened to the
migration: written, found from an earlier run, refused (a schema name that
cannot go into a filename, or a schema on another repo than the one the
migrations are written for), failed to write, or nil when nothing was due.
A schema to check: the repo it lives on, and its prefix.
Functions
Reads the Oban schema version at prefix on repo and compares it with
the version the loaded Oban library expects.
Checks every target. Returns {label, status} pairs for the tasks' output:
the label is the prefix, with the repo in front when it is not host_repo.
One line describing a status, for the tasks' output.
Whether prefix can be written into a migration's filename and module
name. Oban accepts any schema name; a generated file only takes plain ones
(\A/\z, not ^/$: a trailing newline must not pass).
The source of the host migration bringing prefix from from to to.
The filename suffix of the migration that brings prefix from from to
to — the part after the timestamp, which is what makes a second run find
the first run's file instead of writing another.
The module namespace for a repo's migrations, as mix ecto.gen.migration
names them.
Checks every target and writes the step-up migration into dir — the
migrations directory of host_repo — for each one on host_repo that is
behind. That is what mix phoenix_kit.update does before it migrates,
independent of whether PhoenixKit's own chain had anything to do.
The schemas worth checking: PhoenixKit's prefix on the host repo (where its
baseline created Oban's tables), and the prefix the host's Oban config runs
at — on the repo that config names, defaulting to the host repo — when that
is a different schema. Oban defaults to "public" when its config names no
prefix. A config that is not a keyword list (built at runtime) adds nothing.
Logs one warning when the schema the running Oban instance uses is behind the library. Called once per boot, after the host has had time to start Oban; reads the repo and prefix from Oban itself, so it checks what is actually in use. Best-effort: silent without Oban, in testing mode, or when the database cannot answer, and never raises.
Writes the migration into dir unless a file for the same step is already
there (an earlier run wrote it and it was never applied — a second copy
would carry a duplicate module name, which Ecto refuses outright).
Types
@type staged() :: %{ repo: module(), prefix: String.t(), status: status(), file: {:created, Path.t()} | {:exists, Path.t()} | :not_generatable | :other_repo | {:error, String.t()} | nil }
One checked target, as stage/4 returns it. file is what happened to the
migration: written, found from an earlier run, refused (a schema name that
cannot go into a filename, or a schema on another repo than the one the
migrations are written for), failed to write, or nil when nothing was due.
@type status() :: {:current, pos_integer() | :infinity} | {:behind, non_neg_integer(), pos_integer()} | {:ahead, pos_integer(), pos_integer()} | :no_table | {:unversioned, String.t() | nil} | {:unsupported_adapter, module()} | {:error, term()}
What check/2 found at one prefix.
{:current, version}— the schema matches the library;versionis:infinitywhen the comment is∞, which Oban itself reads as "never migrate this schema" (Oban.Migrations.Postgres.migrated_version/1){:behind, migrated, expected}— the library expects a newer schema{:ahead, migrated, expected}— the schema is newer than the library (a newer Oban migrated it, then the dependency moved back):no_table— nooban_jobsat this prefix (a fresh install: core's baseline installs the library's version itself){:unversioned, comment}—oban_jobsexists but its comment is not a version number; nothing can be generated safely from it{:unsupported_adapter, adapter}— the repo is not Postgres{:error, reason}— the database could not be asked
A schema to check: the repo it lives on, and its prefix.
Functions
Reads the Oban schema version at prefix on repo and compares it with
the version the loaded Oban library expects.
Never raises: an unreachable database is {:error, reason}.
Checks every target. Returns {label, status} pairs for the tasks' output:
the label is the prefix, with the repo in front when it is not host_repo.
One line describing a status, for the tasks' output.
iex> PhoenixKit.ObanSchema.describe({:behind, 13, 14})
"schema v13, Oban expects v14"
Whether prefix can be written into a migration's filename and module
name. Oban accepts any schema name; a generated file only takes plain ones
(\A/\z, not ^/$: a trailing newline must not pass).
@spec migration_source(String.t(), String.t(), non_neg_integer(), pos_integer()) :: String.t()
The source of the host migration bringing prefix from from to to.
namespace is the module namespace of the repo's migrations
("MyApp.Repo.Migrations"). The prefix must pass generatable_prefix?/1
(it goes into the module name) — anything else raises ArgumentError. create_schema: false because the schema already holds oban_jobs — and a low-privilege
role cannot CREATE SCHEMA, even IF NOT EXISTS. down passes from + 1:
Oban.Migration.down/1 reverts every version down to and including the one
it is given.
@spec migration_suffix(String.t(), non_neg_integer(), pos_integer()) :: String.t()
The filename suffix of the migration that brings prefix from from to
to — the part after the timestamp, which is what makes a second run find
the first run's file instead of writing another.
iex> PhoenixKit.ObanSchema.migration_suffix("public", 13, 14)
"phoenix_kit_update_oban_v13_to_v14.exs"
iex> PhoenixKit.ObanSchema.migration_suffix("auth", 13, 14)
"phoenix_kit_update_oban_auth_v13_to_v14.exs"
The module namespace for a repo's migrations, as mix ecto.gen.migration
names them.
iex> PhoenixKit.ObanSchema.migrations_namespace(MyApp.Repo)
"MyApp.Repo.Migrations"
Checks every target and writes the step-up migration into dir — the
migrations directory of host_repo — for each one on host_repo that is
behind. That is what mix phoenix_kit.update does before it migrates,
independent of whether PhoenixKit's own chain had anything to do.
A behind schema on another repo gets no file (:other_repo): it would be
applied to host_repo's database. timestamp is called with the entry's
index for each file written. A failed write is returned, not raised, so one
schema cannot take the rest of the update down with it.
The schemas worth checking: PhoenixKit's prefix on the host repo (where its
baseline created Oban's tables), and the prefix the host's Oban config runs
at — on the repo that config names, defaulting to the host repo — when that
is a different schema. Oban defaults to "public" when its config names no
prefix. A config that is not a keyword list (built at runtime) adds nothing.
iex> PhoenixKit.ObanSchema.targets(MyApp.Repo, "auth", repo: MyApp.Repo, prefix: "auth")
[{MyApp.Repo, "auth"}]
iex> PhoenixKit.ObanSchema.targets(MyApp.Repo, "auth", repo: MyApp.Repo)
[{MyApp.Repo, "auth"}, {MyApp.Repo, "public"}]
iex> PhoenixKit.ObanSchema.targets(MyApp.Repo, "public", repo: MyApp.JobsRepo)
[{MyApp.Repo, "public"}, {MyApp.JobsRepo, "public"}]
iex> PhoenixKit.ObanSchema.targets(MyApp.Repo, "public", repo: {MyApp.JobsRepo, []})
[{MyApp.Repo, "public"}, {MyApp.JobsRepo, "public"}]
iex> PhoenixKit.ObanSchema.targets(MyApp.Repo, "public", nil)
[{MyApp.Repo, "public"}]
@spec warn_if_behind(keyword()) :: :ok
Logs one warning when the schema the running Oban instance uses is behind the library. Called once per boot, after the host has had time to start Oban; reads the repo and prefix from Oban itself, so it checks what is actually in use. Best-effort: silent without Oban, in testing mode, or when the database cannot answer, and never raises.
@spec write_migration( Path.t(), String.t(), String.t(), {non_neg_integer(), pos_integer()}, String.t() ) :: {:created, Path.t()} | {:exists, Path.t()}
Writes the migration into dir unless a file for the same step is already
there (an earlier run wrote it and it was never applied — a second copy
would carry a duplicate module name, which Ecto refuses outright).
timestamp is the migration version to put in front of a new file. The
prefix must pass generatable_prefix?/1, as for migration_source/4.