PhoenixKit.ObanSchema (phoenix_kit v2.52.2)

Copy Markdown View Source

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

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.

What check/2 found at one prefix.

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

staged()

@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.

status()

@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; version is :infinity when 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 — no oban_jobs at this prefix (a fresh install: core's baseline installs the library's version itself)
  • {:unversioned, comment} — oban_jobs exists 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

target()

@type target() :: {module(), String.t()}

A schema to check: the repo it lives on, and its prefix.

Functions

check(repo, prefix)

@spec check(module(), String.t()) :: status()

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}.

check_all(host_repo, targets)

@spec check_all(module(), [target()]) :: [{String.t(), status()}]

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.

describe(arg1)

@spec describe(status()) :: String.t()

One line describing a status, for the tasks' output.

iex> PhoenixKit.ObanSchema.describe({:behind, 13, 14})
"schema v13, Oban expects v14"

generatable_prefix?(prefix)

@spec generatable_prefix?(String.t()) :: boolean()

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).

migration_source(namespace, prefix, from, to)

@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.

migration_suffix(prefix, from, to)

@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"

migrations_namespace(repo)

@spec migrations_namespace(module()) :: String.t()

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"

stage(host_repo, targets, dir, timestamp)

@spec stage(module(), [target()], Path.t(), (non_neg_integer() -> String.t())) :: [
  staged()
]

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.

targets(host_repo, phoenix_kit_prefix, oban_config)

@spec targets(module(), String.t(), term()) :: [target()]

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"}]

warn_if_behind(opts \\ [])

@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.

write_migration(dir, namespace, prefix, arg, timestamp)

@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.