defmodule Mix.Tasks.PhoenixKit.Doctor do
@moduledoc """
Diagnoses PhoenixKit installation, migration, and runtime issues.
Runs a comprehensive suite of checks covering database connectivity, pool
configuration, PgBouncer detection, migration state, lock conflicts, and
application configuration. Prints a clear pass/fail report with actionable
remediation steps.
## Usage
$ mix phoenix_kit.doctor
$ mix phoenix_kit.doctor --prefix=auth
$ mix phoenix_kit.doctor --exit-code
## Options
* `--prefix` - Database schema prefix. When omitted, resolves from
`config :phoenix_kit, :prefix`, then `"public"` — the same resolution
`mix phoenix_kit.update` / `--status` use, so a prefixed install is
diagnosed against the schema it actually lives in.
* `--exit-code` - Exit non-zero when any check FAILED. Without it this task
prints "N failures" and still exits 0, so a deploy script that runs it
cannot act on the result — the same silent success `mix phoenix_kit.status
--exit-code` exists to remove. Warnings never fail the run; they are
advisory by construction and several fire on healthy installs. Off by
default so deploys that run this purely for its report keep passing.
## Checks Performed
1. **Repo Detection** — Can we find and start the Ecto repo?
2. **DB Connectivity** — Can we execute a simple query?
3. **Pool Configuration** — Pool size, checkout timeout, queue settings
4. **PgBouncer Detection** — Is PgBouncer between app and PostgreSQL?
5. **Migration State** — PhoenixKit version (COMMENT), schema_migrations alignment
6. **Module Schema Versions** — Modules owning their own chain, vs what their code expects
7. **Schema Drift** — Columns a migration should have added but the DB lacks
8. **Pending Migrations** — Migration files not yet recorded in schema_migrations
9. **UUID Column Types** — Detects varchar uuid columns that crash Ecto on startup
10. **UUID Primary Keys** — Detects primary keys that are not the expected uuid type
11. **NULL UUIDs in FK Sources** — Detects NULL uuids that cause infinite backfill loops
12. **Orphaned FK References** — Detects orphaned rows behind an existing FK
constraint, whether it is already VALID (a trigger-bypassed write, a
bulk load, direct catalog surgery) or still NOT VALID (would block its
own VALIDATE), and existing constraints still sitting NOT VALID with
nothing currently blocking them — V176 validates those in place; this
just tells you before it does. Two boundaries on what "checked" means
here: discovery reads `pg_constraint`, so a relationship with no FK
constraint declared at all is outside this check's scope and is not
examined; and discovery matches both the owning table and the
referenced table to the schema being checked (`--prefix`), so a FK
whose referenced table lives in a different schema is outside scope
too, even though the owning table itself was checked
13. **Schema-Declared Relations Without a DB FK** — Every `belongs_to`
PhoenixKit's own Ecto schemas declare, cross-referenced against
`pg_constraint` for a matching foreign key. Reports the COUNT found
with no DB-level FK — informational, not a failure: some are
intentional (a federated/soft reference cannot carry a FK across an
optional module boundary, see V179/V180). This is the complement to
check 12's own stated gap above: check 12 only ever sees a
relationship that already HAS a declared FK constraint; this one
finds relationships Ecto declares that never got one. Derived
entirely from what the schema itself declares (`owner_key` on the
`belongs_to`), never guessed from a column name — so it also cannot
see a soft reference that isn't declared as a `belongs_to` at all
(e.g. a plain field, or a polymorphic `*_uuid`/`*_type` pair).
14. **Lock Conflicts** — Any blocked or long-running queries?
15. **Orphaned Connections** — Idle-in-transaction or stuck connections
16. **Oban Configuration** — Queues and plugins that consume pool connections
17. **Oban Cron Queues** — Does every crontab worker have its queue configured?
18. **PhoenixKit Supervisor** — What's running (update_mode vs full)?
19. **Child Start Order** — Does the Repo start before PhoenixKit/Oban in application.ex?
20. **Update Mode** — Is update_mode active?
21. **daisyUI Version** — Is the host's vendored daisyUI recent enough?
22. **User Dashboard (deprecated)** — Is the host still on the retired dashboard?
23. **Sitemap Discoverability** — Is the sitemap actually reachable?
24. **Crawler Visibility** — noindex on a production-looking host, or a
staging-looking host left indexable
25. **Demo Auth Pages** — Are the demo auth routes still exposed?
26. **Manifest Repair (dry-run)** — `PhoenixKit.Migrations.Repair.verify/1`
runs read-only against the generated
`PhoenixKit.Migrations.ExpectedSchema` manifest as an additional,
non-fatal check (never `:fail`). Passes and says so if the manifest
has been removed or overridden away in this checkout.
27. **Git Hooks** — is `.githooks/pre-commit` enabled via
`core.hooksPath`? Only runs inside a checkout of phoenix_kit itself
(`.githooks/pre-commit` is a phoenix_kit-repo convention, not
something installed into a consuming host app) — silently skipped
otherwise.
"""
use Mix.Task
alias PhoenixKit.Install.ChildOrder
alias PhoenixKit.Install.PrefixConfig
alias PhoenixKit.Integrations.Encryption
alias PhoenixKit.Migrations.ExpectedSchema.Resolver
alias PhoenixKit.Migrations.Modules, as: MigrationModules
alias PhoenixKit.Migrations.Postgres
alias PhoenixKit.Migrations.Repair
alias PhoenixKit.Migrations.Repair.Report
alias PhoenixKit.Modules.Crawlers
alias PhoenixKit.Modules.Crawlers.Bots
alias PhoenixKit.Modules.Sitemap.RouteResolver
alias PhoenixKit.Utils.Routes
@shortdoc "Diagnoses PhoenixKit installation, migration, and runtime issues"
@switches [prefix: :string, exit_code: :boolean, fingerprint: :boolean]
@aliases [p: :prefix]
# The longest timeout/1 any worker PhoenixKit ships declares
# (Storage.Workers.SyncFilesJob). A Lifeline rescue_after at or below this is
# unsafe by construction — see check_lifeline_plugin/2.
@lifeline_min_rescue_after :timer.minutes(30)
@impl Mix.Task
def run(argv) do
{opts, _argv, _errors} = OptionParser.parse(argv, switches: @switches, aliases: @aliases)
# Start app with minimal footprint (same approach as phoenix_kit.update)
Mix.Task.run("app.config")
# Resolve the prefix AFTER app.config loads config, so a configured
# non-public prefix is honored — same resolution the updater/status use
# (--prefix flag → config :phoenix_kit, :prefix → "public"). Reading
# opts[:prefix] || "public" here queries the version marker at public and
# reports a prefixed install as "not installed".
prefix = PrefixConfig.resolve_prefix(opts)
# Snapshot the host's Oban config BEFORE cap_repo_pool_size/1 zeroes its
# queues/plugins (it does that to conserve connections in update_mode) —
# otherwise the Oban Configuration check reports "0 queues, 0 plugins".
oban_config = Application.get_env(Mix.Project.config()[:app], Oban)
cap_repo_pool_size(2)
Application.put_env(:phoenix_kit, :update_mode, true)
Mix.Task.run("app.start")
header("PhoenixKit Doctor")
results =
[
run_check("Repo Detection", fn -> check_repo_detection() end),
run_check("DB Connectivity", fn -> check_db_connectivity() end),
run_check("Pool Configuration", fn -> check_pool_config() end),
run_check("PgBouncer Detection", fn -> check_pgbouncer() end),
run_check("Migration State", fn -> check_migration_state(prefix) end),
run_check("Module Schema Versions", fn -> check_module_schema_versions(prefix) end),
run_check("Schema Drift", fn -> check_schema_drift(prefix) end),
run_check("Pending Migrations", fn -> check_pending_migrations() end),
run_check("UUID Column Types", fn -> check_uuid_column_types(prefix) end),
run_check("UUID Primary Keys", fn -> check_uuid_primary_keys(prefix) end),
run_check("NULL UUIDs in FK Sources", fn -> check_null_uuids(prefix) end),
run_check("Orphaned FK References", fn -> check_orphaned_fk_refs(prefix) end),
run_check("Schema-Declared Relations Without a DB FK", fn ->
check_schema_declared_relations_without_fk(prefix)
end),
run_check("Lock Conflicts", fn -> check_lock_conflicts() end),
run_check("Orphaned Connections", fn -> check_orphaned_connections() end),
run_check("Oban Configuration", fn -> check_oban_config(oban_config) end),
run_check("Oban Cron Queues", fn -> check_cron_queues(oban_config) end),
run_check("PhoenixKit Supervisor", fn -> check_supervisor_state() end),
run_check("Child Start Order", fn -> check_child_order() end),
run_check("Update Mode", fn -> check_update_mode() end),
run_check("daisyUI Version", fn -> check_daisyui() end),
run_check("User Dashboard (deprecated)", fn -> check_user_dashboard_deprecation() end),
run_check("Sitemap Discoverability", fn -> check_sitemap_serving() end),
run_check("Crawler Visibility", fn -> check_crawler_visibility(prefix) end),
run_check("Demo Auth Pages", fn -> check_demo_routes() end),
run_check("Manifest Repair (dry-run)", fn -> check_manifest_repair(prefix) end),
run_check("Integration Key", fn -> check_integration_key(opts[:fingerprint] || false) end)
] ++ git_hooks_check()
IO.puts("")
summary(results)
maybe_halt(results, opts[:exit_code] || false)
end
@doc """
The process exit status `--exit-code` should produce: `1` when any check
failed, `0` otherwise.
Public because `run/1` is not a unit-test seam (it starts the app and needs a
real database) — this is the pure decision behind the flag, in the same shape
as `Mix.Tasks.PhoenixKit.Status.exit_code/2` and
`Mix.Tasks.PhoenixKit.Repair.exit_code/1`.
Only `:fail` gates. A `:warn` is advisory by construction — several fire on
perfectly healthy installs (a capped pool under `update_mode`, an unreadable
`application.ex`) — and gating on them would make the flag unusable, which is
how a task ends up back at "reports a problem and exits 0".
"""
@spec exit_code([{String.t(), {:pass | :warn | :fail, String.t()}}]) :: 0 | 1
def exit_code(results) do
if Enum.any?(results, fn {_name, {status, _detail}} -> status == :fail end), do: 1, else: 0
end
@doc """
The "Git Hooks" verdict, as a pure function of what could actually be observed.
Public for the same reason `exit_code/1` is: `run/1` is not a unit-test seam,
so the decision is tested on its own.
The point of the three-way inputs is that this check must be able to say *"I
could not tell"* instead of guessing. A check that reports "hook not
installed" when it merely failed to look is worse than no check: it is
confidently wrong, and it sends the reader to fix something that is not
broken.
That distinction is not free, and the obvious implementation gets it wrong:
`git config --get core.hooksPath` exits **1 both when the key is unset and
when the current directory is not a git repository at all** (verified, not
assumed). So repository-ness is probed separately, and only inside a
repository is exit 1 read as the fact "not configured".
* `:repo` — `{:ok, common_dir}` when git answered, `:unknown` otherwise
(not a repository, git missing, anything else).
* `:hooks_path` — `{:ok, value}` | `:unset` (a fact) | `:unknown` (a gap).
* `:tracked?` — whether `.githooks/pre-commit` exists in this checkout.
* `:shadow` — `{:ok, path}` for a leftover hook in the **common** hooks dir
(worktrees do not have their own), `:none`, or `:unknown`.
"""
@spec git_hooks_verdict(map()) :: {:pass | :warn, String.t()}
def git_hooks_verdict(%{repo: :unknown}) do
{:warn,
"Could not check — this is not a git repository, or git is unavailable.\n" <>
" This is NOT the same as \"the hook is not installed\": nothing was verified."}
end
def git_hooks_verdict(%{tracked?: false}) do
{:warn,
".githooks/pre-commit is missing from this checkout, so there is nothing to enable.\n" <>
" Expected the tracked hook at .githooks/pre-commit."}
end
def git_hooks_verdict(%{hooks_path: :unknown}) do
{:warn,
"Could not read core.hooksPath, so it is unknown whether the hook runs.\n" <>
" This is NOT the same as \"the hook is not installed\": nothing was verified."}
end
def git_hooks_verdict(%{hooks_path: :unset}) do
{:warn,
"The tracked hook is NOT running: core.hooksPath is not set.\n" <>
" Fix: git config core.hooksPath .githooks"}
end
def git_hooks_verdict(%{hooks_path: {:ok, path}}) when path != ".githooks" do
{:warn,
"The tracked hook is NOT running: core.hooksPath points at #{inspect(path)}.\n" <>
" Fix: git config core.hooksPath .githooks"}
end
def git_hooks_verdict(%{shadow: {:ok, path}}) do
{:warn,
"Enabled, but #{path} still exists. core.hooksPath wins, so that copy is\n" <>
" dead code that will mislead the next reader. Delete it."}
end
def git_hooks_verdict(%{shadow: :unknown}) do
{:warn, "Enabled via core.hooksPath, but could not check for a stale copy in the hooks dir."}
end
def git_hooks_verdict(%{}), do: {:pass, "tracked hook enabled via core.hooksPath"}
# Printing "N failures" and exiting 0 makes this task unusable as a deploy
# gate — and it now owns a check (Module Schema Versions) whose whole point is
# to catch an install nothing else reports on. Opt-in for the same reason
# `mix phoenix_kit.status --exit-code` is: an existing pipeline that runs
# doctor for its report must not start failing on an upgrade.
#
# `exit({:shutdown, code})` rather than `Mix.raise/1`, matching
# `mix phoenix_kit.repair`: the failures are already printed above in full,
# and re-raising would bury them under a second copy.
defp maybe_halt(_results, false), do: :ok
defp maybe_halt(results, true) do
case exit_code(results) do
0 -> :ok
1 -> exit({:shutdown, 1})
end
end
# ── Check implementations (return {:pass|:warn|:fail, detail}) ──────
defp check_integration_key(show_fingerprint?) do
integration_key_result(Encryption.key_report(), show_fingerprint?)
end
@doc """
The "Integration Key" verdict, rendered from one complete report.
Takes the report and a display choice, and **nothing else**. That is the whole
point of the signature: this check previously received the advice, a
fingerprint note and a storage location as separate arguments, each computed
without reference to the others, and three consecutive rounds of fixes each
produced a message that contradicted itself — a fingerprint note beside "no
key resolved at all", a storage location for a key stored nowhere, a claim of
a weaker key where none existed.
There is no longer an argument through which those pieces could disagree: the
fingerprint and the tier that produced it are one term inside the report, and
they are absent together when there is no key.
Public as a test seam, for the same reason `exit_code/1` is: the defects lived
in this rendering, where tests over the diagnosis could not reach them.
"""
@spec integration_key_result(Encryption.key_report(), boolean()) ::
{:pass | :warn | :fail, String.t()}
def integration_key_result(report, show_fingerprint?) do
detail =
[report.summary, report.consequence, report.action]
|> Enum.reject(&(&1 == ""))
|> Enum.concat(fingerprint_lines(report, show_fingerprint?))
|> Enum.concat(key_store_lines(report))
|> Enum.join(".\n ")
{severity_status(report.severity), detail}
end
# An unintended plaintext store is a FAIL, not a warning: `--exit-code` exists
# to stop a deploy, and credentials written in the clear without anyone
# choosing that is what it should stop for. Encryption switched off on purpose
# stays a warning — the operator already knows.
defp severity_status(:ok), do: :pass
defp severity_status(:warn), do: :warn
defp severity_status(:fail), do: :fail
# No key means no line at all, in EITHER flag state. The flag chooses between
# two ways of describing a fingerprint that exists; it cannot conjure one.
defp fingerprint_lines(%{fingerprint: :none}, _show), do: []
defp fingerprint_lines(%{fingerprint: {:ok, _value, _tier}}, false),
do: ["Fingerprint hidden — pass --fingerprint to show"]
defp fingerprint_lines(%{fingerprint: {:ok, value, tier}}, true),
do: ["Fingerprint #{value} (#{tier})"]
# Absent when nothing is configured, rather than printed as "no key store
# configured" underneath a message about a key that is stored nowhere.
#
# The state is printed with it. A bare path reads as "your key is saved here",
# and for two of the three states it is not: the store may hold nothing yet,
# or hold something nobody can read. That line sat under a `{:dedicated, :ok}`
# verdict looking like confirmation of a backup that did not exist.
defp key_store_lines(%{key_store: nil}), do: []
defp key_store_lines(%{key_store: {:holding, location}}), do: ["Key store: #{location}"]
defp key_store_lines(%{key_store: {:no_secret_yet, location}}),
do: ["Key store: #{location} (configured, holds no secret yet)"]
defp key_store_lines(%{key_store: {:unreadable, location}}),
do: ["Key store: #{location} (configured, could not be read)"]
defp key_store_lines(%{key_store: {:shadowed, location}}),
do: [
"Key store: #{location} (configured, holds a DIFFERENT secret — not a copy of the key in use)"
]
# `.githooks/pre-commit` is a phoenix_kit-core-repo convention (see
# AGENTS.md) — nothing installs or copies it into a consuming host app, so
# the check is meaningless (and permanently unfixable) run from one. Scope
# it to a checkout of phoenix_kit itself, the same way `oban_config` above
# already distinguishes "the host's app" from `:phoenix_kit`.
defp git_hooks_check do
if Mix.Project.config()[:app] == :phoenix_kit do
[run_check("Git Hooks", fn -> check_git_hooks() end)]
else
[]
end
end
defp check_git_hooks do
git_hooks_verdict(gather_git_hooks_state())
end
defp gather_git_hooks_state do
tracked? = File.exists?(".githooks/pre-commit")
case git_cmd(["rev-parse", "--git-common-dir"]) do
{:ok, common} ->
%{
repo: {:ok, common},
hooks_path: hooks_path_config(),
tracked?: tracked?,
shadow: shadow_hook(common)
}
:error ->
%{repo: :unknown, hooks_path: :unknown, tracked?: tracked?, shadow: :unknown}
end
end
defp hooks_path_config do
case System.cmd("git", ["config", "--get", "core.hooksPath"], stderr_to_stdout: true) do
{out, 0} -> {:ok, String.trim(out)}
# Only meaningful because the caller already established we are inside a
# repository — outside one, git answers 1 to this as well.
{_, 1} -> :unset
_ -> :unknown
end
rescue
_ -> :unknown
end
# Worktrees share the common git dir, so the stale copy lives there, not in a
# per-worktree ".git/hooks" — hardcoding that path reports a clean tree in
# every worktree of a repo that still has one.
defp shadow_hook(common_dir) do
path = Path.join(common_dir, "hooks/pre-commit")
if File.exists?(path), do: {:ok, path}, else: :none
end
defp git_cmd(args) do
case System.cmd("git", args, stderr_to_stdout: true) do
{out, 0} -> {:ok, String.trim(out)}
_ -> :error
end
rescue
# git absent from PATH raises ErlangError :enoent. That is a gap in our
# knowledge, never evidence about the hook.
_ -> :error
end
defp check_repo_detection do
app = Mix.Project.config()[:app]
repos = Application.get_env(app, :ecto_repos, [])
if repos == [] do
{:fail, "No :ecto_repos configured for :#{app}"}
else
repo = hd(repos)
info =
Enum.join(
[
"app: :#{app}",
"repo: #{inspect(repo)}",
"adapter: #{inspect(repo.__adapter__())}"
],
", "
)
{:pass, info}
end
end
defp check_db_connectivity do
repo = get_repo!()
case repo.query("SELECT 1 AS ok", [], timeout: 5_000) do
{:ok, %{rows: [[1]]}} ->
{:pass, "Connected"}
{:error, %{message: msg}} ->
{:fail, "Query failed: #{msg}"}
{:error, reason} ->
{:fail, "Query failed: #{inspect(reason)}"}
end
end
defp check_pool_config do
app = Mix.Project.config()[:app]
repo = get_repo!()
config = Application.get_env(app, repo, [])
pool_size = config[:pool_size] || 10
queue_target = config[:queue_target] || 50
queue_interval = config[:queue_interval] || 1000
info =
Enum.join(
[
"pool_size: #{pool_size}",
"queue_target: #{queue_target}ms",
"queue_interval: #{queue_interval}ms"
],
", "
)
cond do
pool_size > 20 ->
{:warn, "pool_size=#{pool_size} is high — may saturate PgBouncer. #{info}"}
pool_size < 2 ->
{:warn, "pool_size=#{pool_size} is very low. #{info}"}
true ->
{:pass, info}
end
end
defp check_pgbouncer do
app = Mix.Project.config()[:app]
repo = get_repo!()
config = Application.get_env(app, repo, [])
port =
cond do
config[:port] -> config[:port]
config[:url] -> extract_port_from_url(config[:url])
true -> 5432
end
hostname = config[:hostname] || extract_host_from_url(config[:url]) || "localhost"
if port != 5432 or String.contains?(to_string(hostname), "pgbouncer") do
{:warn,
"Likely PgBouncer (port=#{port}, host=#{hostname}). " <>
"DDL migrations should use @disable_ddl_transaction true"}
else
{:pass, "Direct PostgreSQL (port=#{port}, host=#{hostname})"}
end
end
defp check_migration_state(prefix) do
repo = get_repo!()
escaped_prefix = String.replace(prefix, "'", "\\'")
# Source 1: COMMENT ON TABLE (set by each V*.up migration)
comment_version = get_comment_version(repo, escaped_prefix)
# Source 2: migrated_version_runtime (what phoenix_kit.status uses)
runtime_version =
try do
opts = %{prefix: prefix, escaped_prefix: escaped_prefix}
Postgres.migrated_version_runtime(opts)
rescue
_ -> :error
end
# Source 3: Code's latest version
latest_version = Postgres.current_version()
lines = [
"COMMENT ON TABLE: V#{comment_version}",
"migrated_version_runtime: #{if runtime_version == :error, do: "ERROR", else: "V#{runtime_version}"}",
"Code latest: V#{latest_version}"
]
info = Enum.join(lines, "\n ")
# Detect discrepancies
discrepancy =
runtime_version != :error and runtime_version != comment_version
cond do
discrepancy ->
{:warn,
"DISCREPANCY between version sources!\n #{info}\n " <>
"The COMMENT was updated by a migration that didn't commit to schema_migrations " <>
"(killed process or missing @disable_ddl_transaction true)."}
comment_version == 0 ->
{:warn, "PhoenixKit not installed.\n #{info}"}
comment_version < latest_version ->
{:warn, "Needs migration.\n #{info}"}
comment_version == latest_version ->
{:pass, info}
true ->
{:warn, "DB version > code version.\n #{info}"}
end
end
# Columns a given migration version adds. If the version marker claims that
# version (or higher) but the column is missing at the prefix, the install
# drifted — the marker is ahead of the actual schema (e.g. a version renumber
# that crossed an upgrade, or an earlier prefix-confused migration run). A
# query that selects the column then crashes at runtime, and re-running the
# migrator is a no-op because the marker already covers that version.
@expected_columns [
{150, "phoenix_kit_users_tokens", "browser"},
{150, "phoenix_kit_users_tokens", "os"}
]
# Core's marker says nothing about a module that owns its own chain, so every
# check above can pass while a module's tables sit versions behind the code
# querying them. That gap presents as an undefined-column 500 on the module's
# admin page — no migration error, and `doctor` previously gave the install a
# clean bill of health, which is the worst possible moment to be reassuring.
defp check_module_schema_versions(prefix) do
modules = MigrationModules.list(prefix: prefix)
failed = MigrationModules.failed(modules)
pending = MigrationModules.pending(modules)
cond do
modules == [] ->
{:pass, "No installed module owns migrations."}
pending != [] ->
{:fail,
"Behind: #{describe_module_versions(pending)}#{unreadable_suffix(failed)}. " <>
"Run mix phoenix_kit.update --yes (mix ecto.migrate alone does not write these)."}
failed != [] ->
{:warn,
"Version unreadable for #{Enum.map_join(failed, ", ", & &1.name)} — " <>
"their tables may be behind and nothing can tell. See mix phoenix_kit.status --verbose."}
true ->
{:pass, "#{length(modules)} module(s), all at the version their code expects."}
end
end
defp describe_module_versions(entries) do
Enum.map_join(entries, ", ", fn entry ->
"#{entry.name} V#{entry.installed} (code expects V#{entry.target})"
end)
end
# Behind and unreadable are not alternatives — one run can hold both, and the
# `cond` above reaches the `failed` branch only when nothing is pending. Left
# to it, an install with one module behind and another whose coordinator
# raised reported only the first, and the unreadable module vanished from the
# report entirely. It is the one an operator cannot discover any other way,
# which is why `StatusReport.next_action/3` and the status tree both surface
# it first; the severity stays `:fail` because a behind module is actionable.
defp unreadable_suffix([]), do: ""
defp unreadable_suffix(failed),
do: "; version unreadable for #{Enum.map_join(failed, ", ", & &1.name)}"
defp check_schema_drift(prefix) do
repo = get_repo!()
escaped_prefix = String.replace(prefix, "'", "\\'")
marker = get_comment_version(repo, escaped_prefix)
if marker == 0 do
{:pass, "PhoenixKit not installed at prefix #{inspect(prefix)} — nothing to check."}
else
missing =
@expected_columns
|> Enum.filter(fn {min_version, _t, _c} -> marker >= min_version end)
|> Enum.reject(fn {_v, table, column} ->
column_exists?(repo, escaped_prefix, table, column)
end)
report_schema_drift(missing, marker, prefix)
end
end
defp report_schema_drift([], marker, _prefix),
do: {:pass, "Columns expected at V#{marker} are present."}
defp report_schema_drift(missing, marker, prefix) do
names = Enum.map_join(missing, ", ", fn {v, t, c} -> "#{t}.#{c} (V#{v})" end)
lowest = missing |> Enum.map(fn {v, _t, _c} -> v end) |> Enum.min()
p = if prefix == "public", do: "public.", else: "#{prefix}."
{:fail,
"Marker says V#{marker} but these columns are missing: #{names}. The install drifted " <>
"(marker ahead of schema). Roll the marker back one version and re-run the migrator — " <>
"the column adds are idempotent (add_if_not_exists), so this is safe:\n" <>
" COMMENT ON TABLE #{p}phoenix_kit IS '#{lowest - 1}';\n" <>
" mix phoenix_kit.update#{prefix_flag(prefix)}"}
end
defp column_exists?(repo, escaped_prefix, table, column) do
query = """
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = '#{escaped_prefix}'
AND table_name = '#{table}'
AND column_name = '#{column}'
)
"""
case repo.query(query, [], log: false) do
{:ok, %{rows: [[true]]}} -> true
_ -> false
end
end
defp prefix_flag("public"), do: ""
defp prefix_flag(prefix), do: " --prefix=#{prefix}"
defp check_pending_migrations do
repo = get_repo!()
migrations_path = Path.join(["priv", "repo", "migrations"])
migration_files =
if File.dir?(migrations_path) do
migrations_path
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".exs"))
|> Enum.map(fn f ->
case Integer.parse(f) do
{version, _rest} -> {version, f}
:error -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.sort()
else
[]
end
recorded =
case repo.query("SELECT version FROM schema_migrations ORDER BY version", []) do
{:ok, %{rows: rows}} -> Enum.map(rows, fn [v] -> v end) |> MapSet.new()
_ -> MapSet.new()
end
pending =
Enum.reject(migration_files, fn {version, _name} -> MapSet.member?(recorded, version) end)
phoenix_kit_pending =
Enum.filter(pending, fn {_v, name} -> String.contains?(name, "phoenix_kit") end)
# Also check for duplicate PhoenixKit migration files (same version range)
pk_files =
Enum.filter(migration_files, fn {_v, name} -> String.contains?(name, "phoenix_kit") end)
duplicates = find_duplicate_migration_ranges(pk_files)
detail_parts = []
detail_parts =
if pending != [] do
pk_names = Enum.map_join(phoenix_kit_pending, "\n ", fn {_v, n} -> n end)
detail_parts ++
[
"#{length(pending)} pending (#{length(phoenix_kit_pending)} PhoenixKit):\n #{pk_names}"
]
else
detail_parts ++ ["All #{length(migration_files)} files recorded in schema_migrations"]
end
detail_parts =
if duplicates != "" do
detail_parts ++ ["DUPLICATE ranges detected:\n #{duplicates}"]
else
detail_parts
end
detail = Enum.join(detail_parts, "\n ")
cond do
duplicates != "" ->
{:warn, detail}
pending == [] ->
{:pass, detail}
true ->
{:warn, detail}
end
end
defp find_duplicate_migration_ranges(pk_files) do
# Extract version ranges from filenames like "phoenix_kit_update_v49_to_v71.exs"
ranges =
Enum.map(pk_files, fn {_v, name} ->
case Regex.run(~r/phoenix_kit_\w+_v(\d+)_to_v(\d+)/, name) do
[_, from, to] -> {String.to_integer(from), String.to_integer(to), name}
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
# Find overlapping ranges
overlaps =
for {from1, to1, name1} <- ranges,
{from2, to2, name2} <- ranges,
name1 < name2,
max(from1, from2) < min(to1, to2),
do: "#{name1} overlaps #{name2}"
Enum.join(overlaps, "\n ")
end
# A missing primary key is what the varchar column actually COST, and the type
# check could not see it: a table can have a perfectly typed uuid column and
# still have no key. Reported by a host whose phoenix_kit_email_events had
# both problems and whose doctor run named only the first.
defp check_uuid_primary_keys(prefix) do
repo = get_repo!()
query = """
SELECT t.table_name
FROM information_schema.tables t
WHERE t.table_schema = $1
AND t.table_name LIKE 'phoenix\\_kit\\_%'
AND t.table_type = 'BASE TABLE'
AND EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_name = t.table_name
AND c.table_schema = t.table_schema
AND c.column_name = 'uuid'
)
AND NOT EXISTS (
SELECT 1
FROM pg_constraint pc
JOIN pg_class pcl ON pcl.oid = pc.conrelid
JOIN pg_namespace pn ON pn.oid = pcl.relnamespace
WHERE pcl.relname = t.table_name
AND pn.nspname = t.table_schema
AND pc.contype = 'p'
)
ORDER BY t.table_name
"""
case repo.query(query, [prefix], log: false) do
{:ok, %{rows: []}} ->
{:pass, "Every phoenix_kit table with a uuid column has a primary key"}
{:ok, %{rows: rows}} ->
tables = Enum.map_join(rows, "\n ", fn [t] -> t end)
{:fail,
"#{length(rows)} table(s) have a uuid column but NO primary key:\n #{tables}\n " <>
"Fix: mix phoenix_kit.repair_uuid (or upgrade — V163 repairs this automatically)"}
_ ->
{:warn, "Could not check (phoenix_kit tables may not exist yet)"}
end
end
# Pre-migration: check for varchar/text uuid columns that should be native uuid type.
# A varchar uuid column on phoenix_kit_settings crashes the Ecto schema loader on startup,
# blocking migrations from even running.
defp check_uuid_column_types(prefix) do
repo = get_repo!()
escaped_prefix = String.replace(prefix, "'", "\\'")
query = """
SELECT table_name, data_type
FROM information_schema.columns
WHERE table_name LIKE 'phoenix_kit_%'
AND column_name = 'uuid'
AND table_schema = '#{escaped_prefix}'
AND data_type IN ('character varying', 'text', 'character')
ORDER BY table_name
"""
case repo.query(query, [], log: false) do
{:ok, %{rows: []}} ->
{:pass, "All uuid columns are native uuid type"}
{:ok, %{rows: rows}} ->
tables =
Enum.map_join(rows, "\n ", fn [table, dtype] ->
"#{table} (#{dtype})"
end)
# The remedy is a task, not a single ALTER. The ALTER alone restores the
# TYPE and leaves the column nullable, without a UUIDv7 default and
# without a primary key — anyone following it literally ends up in a
# state that still fails this doctor. Reported by a host who did.
{:fail,
"#{length(rows)} table(s) have a varchar uuid column:\n #{tables}\n " <>
"These break any Ecto schema that maps to them, and cannot carry a uuid primary key.\n " <>
"Fix: mix phoenix_kit.repair_uuid (or upgrade — V163 repairs this automatically)"}
_ ->
{:warn, "Could not check (phoenix_kit tables may not exist yet)"}
end
end
# Pre-migration: check for NULL uuid values in tables that are FK sources.
# NULL source UUIDs cause the V56 batched backfill loop to run forever.
defp check_null_uuids(prefix) do
repo = get_repo!()
escaped_prefix = String.replace(prefix, "'", "\\'")
# Key FK source tables whose uuid column must not be NULL
source_tables = [
"phoenix_kit_users",
"phoenix_kit_user_roles",
"phoenix_kit_entities",
"phoenix_kit_email_logs",
"phoenix_kit_shop_carts",
"phoenix_kit_shop_products",
"phoenix_kit_shop_categories",
"phoenix_kit_shop_shipping_methods",
"phoenix_kit_payment_options",
"phoenix_kit_billing_profiles",
"phoenix_kit_orders",
"phoenix_kit_invoices",
"phoenix_kit_payment_methods",
"phoenix_kit_subscriptions",
"phoenix_kit_subscription_types",
"phoenix_kit_subscription_plans",
"phoenix_kit_referral_codes",
"phoenix_kit_ai_endpoints",
"phoenix_kit_ai_prompts",
"phoenix_kit_sync_connections"
]
problems =
Enum.reduce(source_tables, [], fn table, acc ->
exists_query = """
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = '#{table}'
AND column_name = 'uuid'
AND table_schema = '#{escaped_prefix}'
)
"""
case repo.query(exists_query, [], log: false) do
{:ok, %{rows: [[true]]}} ->
table_name = prefix_table_name(table, prefix)
count_query = "SELECT count(*)::integer FROM #{table_name} WHERE uuid IS NULL"
case repo.query(count_query, [], log: false) do
{:ok, %{rows: [[count]]}} when count > 0 ->
[{table, count} | acc]
_ ->
acc
end
_ ->
acc
end
end)
if problems == [] do
{:pass, "No NULL uuids in FK source tables"}
else
detail =
Enum.map_join(Enum.reverse(problems), "\n ", fn {table, count} ->
"#{table}: #{count} rows with NULL uuid"
end)
{:fail,
"NULL uuids found (will cause infinite loop in V56 backfill):\n #{detail}\n " <>
"Fix: UPDATE
SET uuid = gen_random_uuid() WHERE uuid IS NULL"}
end
end
# This used to check 4 hardcoded (table, fk_col, ref_table, ref_col)
# pairs out of 70 canonical relationships (231 total FK constraints on a
# calibration install) and printed PASS — which reads as "everything is
# fine" but meant only "the four pairs we happened to list are fine".
# Two real orphans on a live site sat outside the four and were
# invisible to this check the whole time.
#
# Fixed by discovering every FK constraint straight from `pg_constraint`
# instead of a list in code: self-maintaining (a constraint added by a
# future migration is covered without touching this file) and exhaustive
# by construction rather than by upkeep. Coverage is now part of the
# result text itself, and zero coverage — empty schema, wrong --prefix,
# a catalog query that itself failed — can never read as PASS, no matter
# which of those reasons caused it ("clean" and "never looked" used to
# print byte-identical text).
#
# Exposed (not `defp`) and `@doc false`, same reason as the other pure
# decision functions in this module: `get_repo!/0` resolves the same
# `PhoenixKit.Test.Repo` under `mix test` that it resolves under a real
# `mix phoenix_kit.doctor` run, so this is a real end-to-end seam for the
# whole discover -> probe -> classify -> report pipeline against a live
# connection, not just its pieces in isolation.
@doc false
def check_orphaned_fk_refs(prefix) do
repo = get_repo!()
escaped_prefix = String.replace(prefix, "'", "\\'")
case discover_fk_constraints(repo, escaped_prefix) do
{:error, reason} ->
{:warn,
"could not enumerate foreign key constraints in schema #{inspect(prefix)} " <>
"(#{inspect(reason)}) — coverage is zero, which is not the same as clean. " <>
"Fix catalog access (pg_constraint/pg_class) and re-run."}
{:ok, {[], []}} ->
{:warn,
"no foreign key constraints found in schema #{inspect(prefix)} — coverage is " <>
"zero: either nothing is installed here, or --prefix names the wrong schema. " <>
"This is not the same as clean."}
{:ok, {constraints, skipped_multi}} ->
{orphaned, not_validated, probe_failed} =
Enum.reduce(constraints, {[], [], []}, fn fk, acc -> probe_fk(repo, fk, prefix, acc) end)
multi_column_entries =
Enum.map(skipped_multi, fn {table, conname, ref_table, col_count} ->
{table, conname, ref_table, :multi_column, col_count, nil}
end)
total = length(constraints) + length(skipped_multi)
report_orphaned_fk_refs(
Enum.reverse(orphaned),
Enum.reverse(not_validated),
Enum.reverse(probe_failed) ++ multi_column_entries,
total
)
end
end
# Every single-column foreign key constraint in the schema, read straight
# from the catalog — not filtered to any PhoenixKit-owned naming
# convention, because an orphan on a host app's own table blocks that
# table's own VALIDATE just as surely as one on a phoenix_kit_* table.
# Multi-column FKs are enumerated separately rather than silently
# excluded: `check_orphaned_fk_refs/1` folds them into the report as
# "not checked", so the coverage count in the result line still accounts
# for every constraint that exists, not just the ones this query knows
# how to probe.
#
# Both `n.nspname` (owning table) and `fn.nspname` (referenced table) are
# constrained to `escaped_prefix` — a FK from this schema INTO a table
# living in a different one is excluded entirely, not counted, not folded
# into "not checked". Deliberate, not an oversight: the probe query below
# qualifies the referenced table with this same `escaped_prefix` via
# `prefix_table_name/2`, so a referenced table actually living elsewhere
# would be probed under the wrong schema-qualified name. Supporting a
# cross-schema referenced table for real means carrying its own schema
# through this function's return shape (not just its bare `relname`) and
# threading it into every place that currently assumes `escaped_prefix`
# covers both sides — `probe_fk/4` and `fk_probe_cost_context/4` included.
# Out of scope here; the moduledoc's check 12 entry names this boundary.
@doc false
def discover_fk_constraints(repo, escaped_prefix) do
query = """
SELECT
t.relname AS table_name,
ft.relname AS ref_table,
c.conname,
c.convalidated,
array_length(c.conkey, 1) AS col_count,
(SELECT a.attname FROM pg_attribute a
WHERE a.attrelid = c.conrelid AND a.attnum = c.conkey[1]) AS fk_col,
(SELECT fa.attname FROM pg_attribute fa
WHERE fa.attrelid = c.confrelid AND fa.attnum = c.confkey[1]) AS ref_col
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_class ft ON ft.oid = c.confrelid
JOIN pg_namespace fn ON fn.oid = ft.relnamespace
WHERE c.contype = 'f'
AND n.nspname = '#{escaped_prefix}'
AND fn.nspname = '#{escaped_prefix}'
ORDER BY t.relname, fk_col
"""
case repo.query(query, [], log: false) do
{:ok, %{rows: rows}} ->
{single, multi} =
Enum.split_with(rows, fn [_, _, _, _, col_count, _, _] -> col_count == 1 end)
constraints =
Enum.map(single, fn [table, ref_table, conname, convalidated, _one, fk_col, ref_col] ->
%{
table: table,
fk_col: fk_col,
ref_table: ref_table,
ref_col: ref_col,
conname: conname,
convalidated: convalidated
}
end)
skipped_multi =
Enum.map(multi, fn [table, ref_table, conname, _validated, col_count, _fk, _ref] ->
{table, conname, ref_table, col_count}
end)
{:ok, {constraints, skipped_multi}}
{:error, reason} ->
{:error, reason}
end
end
# Per-constraint time budget: a check that never finishes must never read
# as clean, but it also must not be allowed to hang the whole doctor run
# waiting on one giant table. A server-side cancellation on expiry surfaces
# as `{:error, %Postgrex.Error{postgres: %{code: :query_canceled}}}` — but
# verified live against `PhoenixKit.Test.Repo.query/3` (the exact call
# `probe_fk/4` makes, through the DBConnection pool/ownership layer, not a
# raw `Postgrex.query/3` against a bare connection), the timeout more often
# surfaces ONE layer up instead: DBConnection itself drops the request from
# its queue and closes the checked-out connection, returning
# `{:error, %DBConnection.ConnectionError{reason: :closed}}` with no
# `Postgrex.Error` involved at all. Both shapes are handled the same way —
# a timeout is just one more reason a probe can fail, never a silent scope
# reduction. A `--full` flag is deliberately not offered, for this exact
# reason: breadth (every constraint) is never negotiable, only depth (how
# long we wait per constraint) is.
@fk_probe_timeout_ms 5_000
defp probe_fk(repo, fk, prefix, {orph, nv, pf}) do
%{table: table, fk_col: fk_col, ref_table: ref_table, ref_col: ref_col, convalidated: valid?} =
fk
table_name = prefix_table_name(table, prefix)
ref_name = prefix_table_name(ref_table, prefix)
orphan_query = """
SELECT count(*)::integer FROM #{table_name} t
WHERE t.#{fk_col} IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM #{ref_name} r WHERE r.#{ref_col} = t.#{fk_col})
"""
count_result =
case repo.query(orphan_query, [], log: false, timeout: @fk_probe_timeout_ms) do
{:ok, %{rows: [[c]]}} ->
{:ok, c}
{:error, %Postgrex.Error{postgres: %{code: :query_canceled}} = reason} ->
{:probe_failed, timeout_probe_failure(reason, repo, table, fk_col, prefix)}
{:error, %DBConnection.ConnectionError{reason: :closed} = reason} ->
{:probe_failed, timeout_probe_failure(reason, repo, table, fk_col, prefix)}
{:error, reason} ->
{:probe_failed, fk_probe_failure_reason(reason)}
other ->
{:probe_failed, other}
end
validation = if valid?, do: :validated, else: {:not_valid, fk.conname}
classify_fk_check(table, fk_col, ref_table, count_result, validation, {orph, nv, pf})
end
defp timeout_probe_failure(reason, repo, table, fk_col, prefix) do
fk_probe_failure_reason(reason) <> fk_probe_cost_context(repo, table, fk_col, prefix)
end
# A query cancelled by the timeout above, a connection closed out from
# under it by the pool, and every other Postgrex/DBConnection error all
# reach this function — it only exists to make the two timeout shapes say
# "time limit exceeded" instead of a raw Postgres cancellation code or a
# "tcp recv: closed" pool message, so the doctor's report uses
# operator-facing language ("не проверено (превышен предел)") rather than
# leaking a database/pool error that means the same thing.
#
# Exposed (not `defp`) and `@doc false`, same reason as the other pure
# decision functions in this module: directly testable without a repo.
@doc false
def fk_probe_failure_reason(%Postgrex.Error{postgres: %{code: :query_canceled}}) do
"time limit exceeded (#{@fk_probe_timeout_ms}ms) — not checked, not clean"
end
def fk_probe_failure_reason(%DBConnection.ConnectionError{reason: :closed}) do
"time limit exceeded (#{@fk_probe_timeout_ms}ms) — not checked, not clean"
end
def fk_probe_failure_reason(reason), do: reason
# Cost of an expensive check must be measured and
# printed, not guessed at ("table likely large") or silently dropped.
# `n_live_tup` is planner statistics (a `pg_stat_user_tables` read), not a
# table scan, so this stays cheap even on the table whose real scan just
# timed out. Index presence is checked as "leads some index"
# (`indkey[0]` — `int2vector` subscripts are 0-based, unlike the
# `conkey`/`confkey` smallint[] arrays used in `discover_fk_constraints/2`)
# — a composite index where the FK column isn't first doesn't help this
# query's plan, so it doesn't count as indexed here either.
#
# Exposed (not `defp`) and `@doc false`, same reason as
# `discover_fk_constraints/2` and `fk_validation_state/5`: takes `repo`
# explicitly, so it's a real unit-test seam against a live connection
# without starting the whole app.
@doc false
def fk_probe_cost_context(repo, table, fk_col, prefix) do
query = """
SELECT
(SELECT n_live_tup FROM pg_stat_user_tables
WHERE schemaname = $1 AND relname = $2) AS row_estimate,
EXISTS (
SELECT 1 FROM pg_index i
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = i.indkey[0]
WHERE n.nspname = $1 AND t.relname = $2 AND a.attname = $3
) AS fk_col_indexed
"""
case repo.query(query, [prefix, table, fk_col], log: false) do
{:ok, %{rows: [[row_estimate, indexed?]]}} ->
rows_text = if row_estimate, do: "~#{row_estimate} rows", else: "row count unknown"
idx_text = if indexed?, do: "indexed", else: "NOT indexed"
" (#{table}.#{fk_col}: #{rows_text}, #{idx_text})"
{:error, _reason} ->
""
end
end
# Either probe failing must never read as "clean" — a failed probe is a
# missing answer, not a passing one. Checked before either success shape,
# so a probe failure on ANY of orphan-count or validation-state routes
# straight to `probe_failed`, never falls through to the branches below
# that assume both reads succeeded.
#
# Exposed (not `defp`) and `@doc false`, same reason as `V176.validate_one/7`:
# a pure decision function, directly testable without touching a real repo.
@doc false
def classify_fk_check(table, fk_col, ref, {:probe_failed, reason}, _validation, {orph, nv, pf}) do
{orph, nv, [{table, fk_col, ref, :orphan_count, reason, nil} | pf]}
end
# The orphan-count probe already succeeded by the time this clause can
# match (a failed one is caught by the clause above regardless of
# `validation`) — `count` is a real measurement, not a placeholder, and is
# carried into the probe_failed tuple so report_orphaned_fk_refs/3 can
# print it instead of losing it behind "could not check".
def classify_fk_check(
table,
fk_col,
ref,
{:ok, count},
{:probe_failed, reason},
{orph, nv, pf}
) do
{orph, nv, [{table, fk_col, ref, :validation_state, reason, count} | pf]}
end
# Existing NOT VALID constraint blocking VALIDATE, not creation.
def classify_fk_check(table, fk_col, ref, {:ok, count}, {:not_valid, _conname}, {orph, nv, pf})
when count > 0 do
{[{table, fk_col, ref, count, :validate} | orph], nv, pf}
end
# No constraint at all — the original "blocks creation" case.
def classify_fk_check(table, fk_col, ref, {:ok, count}, :absent, {orph, nv, pf})
when count > 0 do
{[{table, fk_col, ref, count, :create} | orph], nv, pf}
end
# A VALID, enforced constraint — Postgres itself should make this state
# unreachable through ordinary SQL, but "should" is not "does": a bulk load
# with triggers off, a restore from an inconsistent backup, or direct
# catalog surgery can all leave orphaned rows behind a constraint that
# still reads as fully validated. Before this clause, `count > 0` here fell
# through to the generic catch-all below and was silently discarded — the
# single most common `validation` shape (most real FKs are validated, not
# `NOT VALID`) was exactly the one this function couldn't report on.
def classify_fk_check(table, fk_col, ref, {:ok, count}, :validated, {orph, nv, pf})
when count > 0 do
{[{table, fk_col, ref, count, :existing_orphan} | orph], nv, pf}
end
# Constraint present, NOT VALID, but nothing currently blocking it — a
# nudge, not a failure: V176 validates this on its own.
def classify_fk_check(table, fk_col, ref, {:ok, _count}, {:not_valid, _conname}, {orph, nv, pf}) do
{orph, [{table, fk_col, ref} | nv], pf}
end
# Narrowed to `{:ok, 0}` deliberately: this is the ONLY combination that
# means "clean". Widening it back to `{:ok, _count}` is exactly the bug
# already fixed for `:validated` — a future `validation` shape reaching
# here with `count > 0` must raise `FunctionClauseError`, not silently
# discard real orphans the way the old wildcard catch-all did.
def classify_fk_check(_table, _fk_col, _ref, {:ok, 0}, _validated_or_absent_and_clean, acc) do
acc
end
# A third urgency tier, alongside `:warn` and `:fail`. Before this fix, a
# probe failure with zero actual orphans still returned :fail — the same
# red as real broken data, even though nothing is actually broken. Real
# orphans (last clause below) still win :fail outright, checked or not; a
# probe failure on its own is "investigate coverage", not "fix broken
# data", and gets its own color for it.
@doc false
def report_orphaned_fk_refs([], [], [], total) do
{:pass,
"No orphaned FK references found (checked #{total} of #{total} foreign key constraints)"}
end
def report_orphaned_fk_refs([], not_validated, [], total) do
detail =
Enum.map_join(not_validated, "\n ", fn {table, fk_col, ref} ->
"#{table}.#{fk_col} → #{ref}"
end)
{:warn,
"Foreign key(s) exist but were never validated (no orphaned rows blocking it right " <>
"now), checked #{total} of #{total}:\n #{detail}\n V176 validates these " <>
"automatically on the next migration run; or ALTER TABLE VALIDATE CONSTRAINT " <>
" by hand."}
end
def report_orphaned_fk_refs([], not_validated, probe_failed, total) when probe_failed != [] do
covered = total - length(probe_failed)
nv_note =
case not_validated do
[] ->
""
list ->
"\n Also unvalidated with nothing currently blocking it: " <>
Enum.map_join(list, ", ", fn {t, c, r} -> "#{t}.#{c} → #{r}" end)
end
{:warn,
"Could not check #{length(probe_failed)} of #{total} foreign key constraints — " <>
"coverage is incomplete, which is not the same as clean:\n " <>
Enum.join(fk_probe_lines(probe_failed), "\n ") <>
nv_note <>
"\n No orphaned rows among the #{covered} successfully checked." <>
retry_suggestion(
probe_failed,
" Fix DB connectivity/permissions (or the time limit, " <>
"for a slow table) and re-run for full coverage."
)}
end
# Falls through here whenever `orphaned != []` — real broken data, so this
# is :fail regardless of what else is going on. A probe failure elsewhere
# in the same run is still worth surfacing (it means coverage is
# incomplete on TOP of the confirmed damage), so its detail is still
# appended rather than swallowed by the more urgent finding.
def report_orphaned_fk_refs(orphaned, not_validated, probe_failed, total) do
detail = Enum.join(fk_orphan_lines(orphaned) ++ fk_probe_lines(probe_failed), "\n ")
coverage_note =
if probe_failed == [] do
" (checked #{total} of #{total})"
else
" (checked #{total - length(probe_failed)} of #{total} — #{length(probe_failed)} more not checked)"
end
nv_note =
case not_validated do
[] ->
""
list ->
"\n Also unvalidated with nothing currently blocking it: " <>
Enum.map_join(list, ", ", fn {t, c, r} -> "#{t}.#{c} → #{r}" end)
end
{:fail,
"Orphaned FK refs / unverifiable FK state found#{coverage_note}:\n " <>
"#{detail}#{nv_note}\n V164/V176 never delete rows to force a constraint " <>
"through — clean orphaned rows up by hand and re-run the migration chain." <>
retry_suggestion(
probe_failed,
" For a probe failure, fix DB connectivity or " <>
"permissions on pg_constraint and re-run doctor."
)}
end
# A composite FK in `probe_failed` (see `discover_fk_constraints/2`) was never
# probed at all — no re-run ever turns that into a pass, only a manual
# `ALTER TABLE ... VALIDATE CONSTRAINT` does, which `fk_probe_lines/1` already
# says per entry. Suggesting a re-run regardless — the old unconditional text
# — told the operator to retry something that can never succeed. Only worth
# it when at least one entry is a genuine probe failure a re-run could
# actually resolve.
defp retry_suggestion(probe_failed, text) do
if Enum.any?(probe_failed, fn entry -> elem(entry, 3) != :multi_column end) do
text
else
""
end
end
defp fk_orphan_lines(orphaned) do
Enum.map(orphaned, fn
{table, fk_col, ref, count, :validate} ->
"#{table}.#{fk_col} → #{ref}: #{count} orphaned row(s) — constraint already exists " <>
"NOT VALID, this blocks VALIDATE"
{table, fk_col, ref, count, :create} ->
"#{table}.#{fk_col} → #{ref}: #{count} orphaned row(s) — no constraint yet, this " <>
"blocks its creation"
{table, fk_col, ref, count, :existing_orphan} ->
"#{table}.#{fk_col} → #{ref}: #{count} orphaned row(s) — constraint IS validated but " <>
"orphans exist anyway (likely written via a trigger-bypass path: bulk load, " <>
"replica catch-up, direct catalog edit) — investigate how, then clean up by hand"
end)
end
defp fk_probe_lines(probe_failed) do
Enum.map(probe_failed, fn
{table, fk_col, ref, :validation_state, reason, count} ->
"#{table}.#{fk_col} → #{ref}: #{count} orphaned row(s) measured, but could not check " <>
"whether the constraint is validated (validation_state probe failed: " <>
"#{inspect(reason)}) — a failed probe is not a pass, treat as unverified"
# A composite FK was never probed at all — a deliberate scope
# exclusion (see `discover_fk_constraints/2`), not a failed attempt.
# `conname` sits in the tuple's fk_col-shaped slot for every other
# `probe_failed` entry, so it needs its own clause: the generic one
# below would print it as if it were a column name and call it a
# "probe failed", both wrong for a check that was never run. There is
# also no re-run that turns this into :pass — only a manual
# VALIDATE CONSTRAINT does.
{table, conname, ref, :multi_column, col_count, nil} ->
"#{table} → #{ref}: composite FK #{conname} (#{col_count} columns) — not supported " <>
"by this check; verify manually via ALTER TABLE ... VALIDATE CONSTRAINT #{conname}"
{table, fk_col, ref, kind, reason, nil} ->
"#{table}.#{fk_col} → #{ref}: could not check (#{kind} probe failed: #{inspect(reason)}) " <>
"— a failed probe is not a pass, treat as unverified"
end)
end
# `convalidated` for the constraint enforcing this exact (column ->
# ref_table.uuid) shape, matched by shape via conkey/confkey — not by
# name, so a constraint adopted under a differently-named twin (V164's own
# documented case) is still found. Returns `:absent` if no such FK exists
# at all — a state discover_fk_constraints/2 has no way to report, since
# bulk discovery only ever enumerates constraints that exist. Carries the
# identical same-schema restriction as discover_fk_constraints/2 (see its
# comment): `ref_table` is assumed to live in `escaped_prefix` too.
#
# Not called from the doctor's own run: probe_fk/4 reads `convalidated`
# straight off each constraint discover_fk_constraints/2 already found, one
# round trip cheaper per check than looking each one up again by shape.
# Kept anyway, deliberately: it is the one existing primitive that can
# answer "does this specific expected relationship have a declared FK at
# all", independent of naming — exactly what closing check 12's current
# gap (a relationship with no FK constraint declared at all goes
# unexamined, see its moduledoc entry) would need, given a source of
# expected (table, fk_col, ref_table) candidates. No such source exists
# yet, so this stays unwired until one does.
#
# Exposed (not `defp`) and `@doc false` so the test suite can force a real
# probe failure (a malformed identifier producing a genuine Postgres
# syntax error) and assert it does NOT collapse into `:absent` — that
# collapse is exactly what let `mix phoenix_kit.doctor` print PASS for a
# check it never actually ran.
@doc false
def fk_validation_state(repo, table, fk_col, ref_table, escaped_prefix) do
query = """
SELECT c.conname, c.convalidated
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_class ft ON ft.oid = c.confrelid
JOIN pg_namespace fn ON fn.oid = ft.relnamespace
WHERE c.contype = 'f'
AND n.nspname = '#{escaped_prefix}'
AND t.relname = '#{table}'
AND ft.relname = '#{ref_table}'
AND fn.nspname = '#{escaped_prefix}'
AND array_length(c.conkey, 1) = 1
AND (SELECT a.attname FROM pg_attribute a
WHERE a.attrelid = c.conrelid AND a.attnum = c.conkey[1]) = '#{fk_col}'
LIMIT 1
"""
case repo.query(query, [], log: false) do
{:ok, %{rows: [[_conname, true]]}} -> :validated
{:ok, %{rows: [[conname, false]]}} -> {:not_valid, conname}
{:ok, %{rows: []}} -> :absent
# A query error (timeout, missing pg_constraint privilege, connection
# drop) is NOT the same fact as "no such constraint" — collapsing both
# into :absent is how a diagnostic ends up printing PASS for a state it
# never actually looked at. Distinguished so the caller can fail (or at
# least warn) instead of silently reading this as clean.
{:error, reason} -> {:probe_failed, reason}
other -> {:probe_failed, other}
end
end
# Check 12 above only ever examines a relationship that already HAS a
# declared FK constraint — its own moduledoc entry names this boundary
# ("a relationship with no FK constraint declared at all is outside this
# check's scope"). `belongs_to` is a source of exactly the (table,
# owner_key, ref_table) triple a check like that would need — every one
# PhoenixKit's own Ecto schemas declare names its target unambiguously
# via `owner_key`/`related`, without guessing from a column name. This
# check resolves that triple itself and cross-references it against
# `pg_constraint` directly (a bulk, two-query scan — see
# `discover_schema_declared_relations_without_fk/2` — not a per-candidate
# call to `fk_validation_state/5`, which answers a related but different
# question, a constraint's VALIDATE state, that this check does not use).
#
# It found two real gaps on a live install: phoenix_kit_activities's
# actor_uuid and target_uuid, both declared in PhoenixKit.Activity.Entry
# with no matching foreign key in the database. Reported as advisory
# (warn, not fail) — this can be intentional, a federated reference
# (V179/V180) cannot carry a FK across an optional module boundary.
#
# A polymorphic pair (a *_uuid column with a sibling *_type
# discriminator) structurally cannot appear in this check's findings:
# Ecto has no way to declare belongs_to against a type that varies per
# row, so there is nothing to filter for, unlike a name-based scan.
#
# Exposed (not `defp`) and `@doc false`, same reason as the other pure
# decision functions in this module: a real end-to-end seam against a
# live repo, directly testable without going through `run/1`.
@doc false
def check_schema_declared_relations_without_fk(prefix) do
repo = get_repo!()
case discover_schema_declared_relations_without_fk(repo, prefix) do
{:error, reason} ->
{:warn,
"could not enumerate belongs_to relations or foreign keys in schema " <>
"#{inspect(prefix)} (#{inspect(reason)}) — coverage is zero, which is not the " <>
"same as clean. Fix catalog access (pg_constraint/information_schema) and re-run."}
{:ok, {0, _missing}} ->
{:warn,
"no belongs_to-declared relation found to check in schema #{inspect(prefix)} — " <>
"coverage is zero: either PhoenixKit's own schemas' tables aren't installed here " <>
"yet, or --prefix names the wrong schema. This is not the same as clean."}
{:ok, {total, []}} ->
{:pass,
"Every belongs_to PhoenixKit's schemas declare has a matching DB foreign key " <>
"(checked #{total} of #{total})"}
{:ok, {total, missing}} ->
detail =
Enum.map_join(missing, "\n ", fn {table, column} -> "#{table}.#{column}" end)
{:warn,
"#{length(missing)} of #{total} relation(s) declared via `belongs_to` in " <>
"PhoenixKit's own Ecto schemas have no matching database foreign key:\n " <>
"#{detail}\n This is advisory, not a failure — some are intentional (a " <>
"federated/soft reference cannot carry a FK across an optional module boundary, " <>
"see V179/V180). Derived from declared `belongs_to` associations only, cross-" <>
"referenced against pg_constraint — not exhaustive: a soft reference held as a " <>
"plain field (no `belongs_to` at all, e.g. a polymorphic pair) is invisible to " <>
"this scan too."}
end
end
# Returns `{:ok, {total_candidates, missing}}`, `missing` a `[{table,
# column}]` for every `belongs_to` owner_key that exists as a real column
# in this schema but has no `pg_constraint` FK matching its declared
# target table — or `{:error, reason}` if either catalog query itself
# fails. `total_candidates` is every belongs_to owner_key that DOES exist
# as a real column here (checked against `information_schema.columns`,
# not just `pg_constraint` membership, so a schema module for a
# not-yet-installed module — table absent entirely — is correctly
# excluded rather than miscounted as "declared, no FK"), surfaced so a
# caller can tell "checked N, all clean" apart from "checked nothing" —
# an empty `existing_columns` (wrong --prefix, nothing installed yet)
# would otherwise filter every candidate out and report the same `[]`
# `missing` as a genuinely clean install, printing PASS for a schema
# this check never actually looked at.
#
# The FK side is matched on the full (table, column, ref_table) triple,
# not just (table, column): a column carrying a single-column FK to the
# WRONG table (or a table caught up in a multi-column constraint via
# `array_length(c.conkey, 1) = 1` below) must still show up as missing
# its DECLARED relation, not be waved through because some other FK
# happens to touch the same column.
@doc false
def discover_schema_declared_relations_without_fk(repo, prefix) do
escaped_prefix = String.replace(prefix, "'", "\\'")
with {:ok, %{rows: fk_rows}} <-
repo.query(
"""
SELECT t.relname, a.attname, ft.relname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_class ft ON ft.oid = c.confrelid
JOIN pg_namespace fn ON fn.oid = ft.relnamespace
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f'
AND n.nspname = '#{escaped_prefix}'
AND fn.nspname = '#{escaped_prefix}'
AND array_length(c.conkey, 1) = 1
""",
[],
log: false
),
{:ok, %{rows: col_rows}} <-
repo.query(
"SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = '#{escaped_prefix}'",
[],
log: false
) do
declared_fk_relations =
MapSet.new(fk_rows, fn [table, col, ref_table] -> {table, col, ref_table} end)
existing_columns = MapSet.new(col_rows, fn [table, col] -> {table, col} end)
{:ok, modules} = :application.get_key(:phoenix_kit, :modules)
candidates =
modules
# `function_exported?/3` checks only what's already loaded in THIS
# process — for a schema module nothing has called yet, it reads
# false even though `Code.ensure_loaded?/1` would trigger the load
# and it would work fine one line later. Without the ensure_loaded?
# first, this filtered out nearly every schema.
|> Enum.filter(&(Code.ensure_loaded?(&1) and function_exported?(&1, :__schema__, 1)))
|> Enum.flat_map(&belongs_to_owner_columns/1)
|> Enum.uniq()
|> Enum.filter(fn {table, col, _ref_table} ->
MapSet.member?(existing_columns, {table, col})
end)
|> Enum.sort()
missing =
candidates
|> Enum.reject(&MapSet.member?(declared_fk_relations, &1))
|> Enum.map(fn {table, col, _ref_table} -> {table, col} end)
{:ok, {length(candidates), missing}}
end
end
defp belongs_to_owner_columns(schema) do
table = schema.__schema__(:source)
schema.__schema__(:associations)
|> Enum.map(&schema.__schema__(:association, &1))
|> Enum.filter(&match?(%Ecto.Association.BelongsTo{}, &1))
|> Enum.map(fn assoc ->
{table, Atom.to_string(assoc.owner_key), assoc.related.__schema__(:source)}
end)
end
defp check_lock_conflicts do
repo = get_repo!()
query = """
SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND pid != pg_backend_pid()
AND wait_event_type = 'Lock'
"""
case repo.query(query, []) do
{:ok, %{rows: [[0]]}} ->
{:pass, "No lock conflicts"}
{:ok, %{rows: [[count]]}} ->
detail_query = """
SELECT pid, age(now(), query_start)::text, left(query, 80)
FROM pg_stat_activity
WHERE datname = current_database()
AND pid != pg_backend_pid()
AND wait_event_type = 'Lock'
ORDER BY query_start LIMIT 5
"""
details =
case repo.query(detail_query, []) do
{:ok, %{rows: rows}} ->
Enum.map_join(rows, "\n ", fn [pid, dur, q] ->
"PID #{pid} (#{dur}): #{q}"
end)
_ ->
"Could not fetch details"
end
{:fail, "#{count} queries waiting on locks:\n #{details}"}
_ ->
{:warn, "Could not check (may not have pg_stat_activity access)"}
end
end
defp check_orphaned_connections do
repo = get_repo!()
query = """
SELECT state, count(*)::integer, max(age(now(), state_change))::text
FROM pg_stat_activity
WHERE datname = current_database()
AND pid != pg_backend_pid()
GROUP BY state ORDER BY state
"""
case repo.query(query, []) do
{:ok, %{rows: rows}} ->
info =
Enum.map_join(rows, ", ", fn [state, count, oldest] ->
"#{state || "null"}: #{count} (oldest: #{oldest})"
end)
idle_in_tx =
Enum.find(rows, fn [state, _, _] ->
state in ["idle in transaction", "idle in transaction (aborted)"]
end)
if idle_in_tx do
[_state, count, oldest] = idle_in_tx
{:fail,
"#{count} idle-in-transaction (oldest: #{oldest}). " <>
"These block DDL. Kill: SELECT pg_terminate_backend(pid) ... All: #{info}"}
else
{:pass, info}
end
_ ->
{:warn, "Could not query pg_stat_activity"}
end
end
# Reports the Oban config snapshotted in run/1 BEFORE cap_repo_pool_size/1
# zeroed its queues/plugins — reading it live here would always show 0/0.
defp check_oban_config(nil), do: {:pass, "Oban not configured"}
defp check_oban_config(config) when is_list(config) do
# `queues: false` / `plugins: false` is Oban's documented way to disable
# either wholesale (standard in test config, and used by hosts that run
# jobs on a separate node) — normalize so this check reports instead of
# raising into run_check/2's rescue as a bogus FAIL.
raw_plugins = Keyword.get(config, :plugins, [])
queues = config |> Keyword.get(:queues, []) |> normalize_oban_list()
plugins = normalize_oban_list(raw_plugins)
base =
"#{length(queues)} queues, #{length(plugins)} plugins. Each active queue uses 1 pool connection."
# Plugins off on purpose (web-only node, test config). Nagging about
# Lifeline here is a false positive, and the remedy it recommends would
# rewrite config.exs for a node that must not run plugins at all.
if raw_plugins == false do
{:pass, base <> " Oban plugins are disabled on this node (plugins: false)."}
else
check_lifeline_plugin(plugins, base)
end
end
defp check_oban_config(_other), do: {:pass, "Oban configured (non-keyword config)"}
defp normalize_oban_list(value) when is_list(value), do: value
defp normalize_oban_list(_value), do: []
# Lifeline's presence is necessary but not sufficient: it rescues purely by
# elapsed time with no liveness check, so a rescue_after at or below the
# longest job the host can run re-executes that job concurrently with the
# still-live original. Oban's own docs advertise :timer.minutes(5) as the
# "more aggressive period" example, so a too-low value is an easy thing for a
# host to copy in — validate the value, not just the entry.
defp check_lifeline_plugin(plugins, base) do
case lifeline_entry(plugins) do
nil ->
{:warn,
base <>
" Oban.Plugins.Lifeline is not configured — a job orphaned in :executing by a hard " <>
"crash (kill -9, OOM, node failure) is never rescued back to :available. Run " <>
"mix phoenix_kit.update to add {Oban.Plugins.Lifeline, rescue_after: :timer.minutes(60)}."}
{:ok, rescue_after}
when is_integer(rescue_after) and rescue_after <= @lifeline_min_rescue_after ->
{:warn,
base <>
" Oban.Plugins.Lifeline is configured with rescue_after: #{div(rescue_after, 60_000)} " <>
"minutes, at or below the longest job PhoenixKit ships " <>
"(Storage.Workers.SyncFilesJob, #{div(@lifeline_min_rescue_after, 60_000)} minutes). " <>
"Lifeline rescues purely by elapsed time and never checks whether the executing node " <>
"is alive, so a job still running at that mark is rescued and executes a second time " <>
"concurrently. Raise it above your longest-running job (60 minutes is Oban's default)."}
_ ->
{:pass, base}
end
end
# `{:ok, rescue_after}` where rescue_after is nil when unset — an unset value
# means Oban's own 60-minute default, which is safe.
defp lifeline_entry(plugins) do
Enum.find_value(plugins, fn
Oban.Plugins.Lifeline -> {:ok, nil}
{Oban.Plugins.Lifeline, opts} when is_list(opts) -> {:ok, Keyword.get(opts, :rescue_after)}
{Oban.Plugins.Lifeline, _opts} -> {:ok, nil}
_ -> nil
end)
end
# A cron entry inserts a job whether or not anything is configured to run it,
# and Oban only fetches for queues this node lists in `queues:`. So a crontab
# worker whose queue is missing produces one job per tick that stays
# :available forever — Pruner deletes terminal states only, so nothing ever
# clears them.
#
# PhoenixKit shipped exactly this between 2025-12-28 and 1.7.63: the
# ProcessScheduledJobsWorker entry went into the generated config without its
# :scheduled_jobs queue. Hosts installed in that window are still affected,
# because the installer's *upgrade* path only ever added the entry — one such
# host was found with 21,337 orphaned rows and climbing at ~1,440/day.
#
# Checking the whole crontab rather than that one worker is the point: the
# next instance of this mistake is then a warning on the next doctor run
# instead of something a host has to discover by reading its own oban_jobs
# table.
@doc """
Reports crontab entries whose queue this node does not run.
Public so it can be unit-tested directly against config keyword lists, for
the same reason as `exit_code/1`: it is the pure decision inside a task whose
`run/1` needs a live app and a database.
"""
@spec check_cron_queues(keyword() | nil | term()) :: {:pass | :warn, String.t()}
def check_cron_queues(nil), do: {:pass, "Oban not configured"}
def check_cron_queues(config) when is_list(config) do
raw_queues = Keyword.get(config, :queues, [])
raw_plugins = Keyword.get(config, :plugins, [])
queues = normalize_oban_list(raw_queues)
cond do
# `testing: :inline | :manual` makes Oban itself overwrite both plugins
# and queues with [] (Oban.Config.normalize_opts/1), so no cron ever
# fires and nothing can accumulate. Reading the host's declared values
# and warning would describe a config Oban is not going to use.
Keyword.get(config, :testing, :disabled) in [:inline, :manual] ->
{:pass, "Oban is in testing mode — it runs no queues and no plugins."}
# `plugins: false` is Oban's documented way to turn plugins off
# wholesale: no Cron, so no entries to check.
raw_plugins == false ->
{:pass, "Oban plugins are disabled on this node (plugins: false)."}
# Oban documents an empty list and `false` as the same thing — "prevents
# any queues from starting on init". A web-only node says one or the
# other, and there every entry would look orphaned, so the honest answer
# is "not applicable" rather than a warning per crontab line.
raw_queues == false or queues == [] ->
{:pass, "Oban runs no queues on this node — jobs are executed elsewhere."}
true ->
config
|> crontab_entries()
|> check_entry_queues(queues)
end
end
def check_cron_queues(_other), do: {:pass, "Oban configured (non-keyword config)"}
# `flat_map` rather than "find the Cron plugin", and the top-level key as
# well as the plugin: Oban still accepts `crontab:` directly on the Oban
# config and promotes it into a Cron plugin itself
# (`Oban.Config.crontab_to_plugin/1`). A host using that form has entries the
# plugins list never mentions, and stopping at the first Cron plugin would
# have missed them entirely — reporting a clean bill of health on precisely
# the config this check exists to catch.
defp crontab_entries(config) do
plugin_entries =
config
|> Keyword.get(:plugins, [])
|> normalize_oban_list()
|> Enum.flat_map(fn
{Oban.Plugins.Cron, opts} when is_list(opts) -> Keyword.get(opts, :crontab, [])
_ -> []
end)
plugin_entries ++ Keyword.get(config, :crontab, [])
end
defp check_entry_queues([], _queues),
do: {:pass, "No Oban.Plugins.Cron crontab entries to check."}
defp check_entry_queues(entries, queues) do
configured = MapSet.new(Keyword.keys(queues), &to_string/1)
orphans =
for entry <- entries,
{:ok, worker, queue} <- [entry_queue(entry)],
not MapSet.member?(configured, queue),
uniq: true,
do: {worker, queue}
case orphans do
[] ->
{:pass, "#{length(entries)} crontab entries, every queue configured."}
orphans ->
detail =
Enum.map_join(orphans, ", ", fn {worker, queue} ->
"#{inspect(worker)} → #{queue}"
end)
{:warn,
"#{length(entries)} crontab entries. These fire into queues this node does not run, so " <>
"each tick inserts a job nothing will execute and Pruner never clears it (terminal " <>
"states only): #{detail}. Add the queue to `queues:` in config.exs, or drop the " <>
"crontab entry — `mix phoenix_kit.update` adds the ones PhoenixKit ships. Check what " <>
"has already collected first (`SELECT count(*) FROM oban_jobs WHERE state = " <>
"'available'`): configuring the queue releases the whole backlog at once, and for " <>
"ProcessScheduledJobsWorker that first sweep publishes every overdue scheduled post " <>
"and sends every overdue broadcast. `Oban.cancel_all_jobs/1` over the queue clears " <>
"them without running them."}
end
end
# Mirrors Oban.Plugins.Cron.build_changeset/4: the entry's own opts win over
# the worker's, and a worker declaring no queue falls back to Oban.Job's
# "default". (Cron uses Worker.merge_opts/2, whose only special case is
# :unique — for :queue it is a plain Keyword.merge.)
#
# Queues compare as strings because Oban accepts either form — `queue: :foo`
# in a worker and `foo: 10` in `queues:` are the same queue — and it avoids
# minting atoms from config while checking it.
defp entry_queue({expr, worker}), do: entry_queue({expr, worker, []})
defp entry_queue({_expr, worker, opts}) when is_atom(worker) and is_list(opts) do
# A crontab may name a worker from a dependency that is not loaded in the
# doctor's VM. Skipping is right: we cannot read its queue, and guessing
# would report a host as broken for a module we simply failed to load.
if Code.ensure_loaded?(worker) and function_exported?(worker, :__opts__, 0) do
queue =
worker.__opts__()
|> Keyword.merge(opts)
|> Keyword.get(:queue, :default)
{:ok, worker, to_string(queue)}
else
:skip
end
end
defp entry_queue(_other), do: :skip
defp check_supervisor_state do
case Process.whereis(PhoenixKit.Supervisor) do
nil ->
{:warn, "PhoenixKit.Supervisor not running"}
pid ->
children = Supervisor.which_children(pid)
names = Enum.map(children, fn {id, _, _, _} -> id end)
{:pass, "#{length(children)} children: #{inspect(names)}"}
end
end
# Reads the host application.ex and verifies the Repo starts BEFORE
# PhoenixKit.Supervisor and Oban — a child listed before the Repo crashes the
# app at boot (PhoenixKit reads Settings from the DB; Oban needs the pool).
# The runtime supervisor check above can't catch this (by the time doctor
# runs, everything has already started), so we read the source order.
defp check_child_order do
repo = get_repo!()
case host_application_source() do
{:ok, path, source} ->
where = Path.relative_to_cwd(path)
case ChildOrder.check(source, repo) do
{:ok, detail} ->
{:pass, "#{detail} (#{where})"}
{:misordered, mods} ->
names = Enum.map_join(mods, ", ", &inspect/1)
{:fail,
"#{names} start BEFORE #{inspect(repo)} in #{where}. PhoenixKit.Supervisor " <>
"reads Settings from the database and Oban needs the connection pool, so both " <>
"must be listed AFTER your Repo. Move #{inspect(repo)} above them in the " <>
"children list to fix the boot crash."}
:no_repo_in_children ->
{:warn,
"Couldn't find #{inspect(repo)} in the children list of #{where} — verify " <>
"PhoenixKit.Supervisor and Oban are started after your Repo."}
:no_children ->
{:warn, "Couldn't locate a children list in #{where} to verify start order."}
end
:error ->
{:warn, "Couldn't locate your application.ex to verify child start order."}
end
end
defp check_update_mode do
update_mode = Application.get_env(:phoenix_kit, :update_mode, false)
if update_mode do
{:warn, "update_mode=true (doctor runs in update_mode to minimize DB connections)"}
else
{:pass, "update_mode=false (normal operation)"}
end
end
# ── Helpers ──────────────────────────────────────────────────────────
defp get_repo! do
app = Mix.Project.config()[:app]
case Application.get_env(app, :ecto_repos, []) do
[repo | _] -> repo
[] -> raise "No :ecto_repos configured for :#{app}"
end
end
# Locate the host's application.ex — first via the compiled application
# module's source path, then the conventional lib//application.ex.
defp host_application_source do
app = Mix.Project.config()[:app]
candidates =
[
case Application.spec(app, :mod) do
{mod, _args} -> module_source(mod)
_ -> nil
end,
Path.join(["lib", "#{app}", "application.ex"])
]
|> Enum.reject(&is_nil/1)
Enum.find_value(candidates, :error, fn path ->
case File.read(path) do
{:ok, source} -> {:ok, path, source}
_ -> nil
end
end)
end
defp module_source(mod) do
with {:module, _} <- Code.ensure_loaded(mod),
source when not is_nil(source) <- mod.module_info(:compile)[:source] do
to_string(source)
else
_ -> nil
end
rescue
_ -> nil
end
defp cap_repo_pool_size(pool_size) do
app = Mix.Project.config()[:app]
repos = Application.get_env(app, :ecto_repos, [])
Enum.each(repos, fn repo ->
current = Application.get_env(app, repo, [])
updated = Keyword.put(current, :pool_size, pool_size)
Application.put_env(app, repo, updated)
end)
# Disable Oban queues to save connections
case Application.get_env(app, Oban) do
nil ->
:ok
config ->
updated = config |> Keyword.put(:queues, []) |> Keyword.put(:plugins, [])
Application.put_env(app, Oban, updated)
end
rescue
_ -> :ok
end
defp get_comment_version(repo, escaped_prefix) do
table_query = """
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'phoenix_kit' AND table_schema = '#{escaped_prefix}'
)
"""
case repo.query(table_query, [], log: false) do
{:ok, %{rows: [[true]]}} ->
version_query = """
SELECT pg_catalog.obj_description(pg_class.oid, 'pg_class')
FROM pg_class
LEFT JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_class.relname = 'phoenix_kit'
AND pg_namespace.nspname = '#{escaped_prefix}'
"""
case repo.query(version_query, [], log: false) do
{:ok, %{rows: [[version]]}} when is_binary(version) -> String.to_integer(version)
_ -> 0
end
_ ->
0
end
end
defp prefix_table_name(table_name, "public"), do: "public.#{table_name}"
defp prefix_table_name(table_name, prefix), do: "#{prefix}.#{table_name}"
defp extract_port_from_url(nil), do: nil
defp extract_port_from_url(url) when is_binary(url) do
case URI.parse(url) do
%URI{port: port} when is_integer(port) -> port
_ -> nil
end
end
defp extract_port_from_url(_), do: nil
defp extract_host_from_url(nil), do: nil
defp extract_host_from_url(url) when is_binary(url) do
case URI.parse(url) do
%URI{host: host} when is_binary(host) -> host
_ -> nil
end
end
defp extract_host_from_url(_), do: nil
# The host owns assets/vendor/daisyui.js (scaffolded by phx.new, upgraded
# manually). PhoenixKit's modals rely on daisyUI >= the minimum for correct
# modal scrollbar-gutter handling — this check is where a host finds out
# it's behind (install/update print the same warning).
defp check_daisyui do
alias PhoenixKit.Install.DaisyUI
minimum = DaisyUI.minimum_version()
case DaisyUI.check() do
:ok ->
{:pass, "daisyUI #{DaisyUI.installed_version(DaisyUI.host_path())} (>= #{minimum})"}
{:outdated, version} ->
{:warn,
"Vendored daisyUI is #{version}; PhoenixKit is designed against #{minimum}+ " <>
"(modal scrollbar-gutter handling). Update assets/vendor/daisyui.js + " <>
"daisyui-theme.js from https://github.com/saadeghi/daisyui/releases and rebuild assets."}
:unversioned ->
{:warn,
"assets/vendor/daisyui.js carries no version marker — cannot verify it against " <>
"PhoenixKit's designed-for minimum (#{minimum})."}
:missing ->
{:warn,
"No assets/vendor/daisyui.js — custom daisyUI setup? PhoenixKit is designed " <>
"against daisyUI #{minimum}+; make sure your setup matches."}
end
end
# The user dashboard (/dashboard) is deprecated. It still works unchanged, so
# this is advisory: WARN while it's enabled (a heads-up that it's going away
# in favor of the unified /admin panel), PASS once a host has disabled it.
# install/update print the same advisory (see PhoenixKit.Install.Deprecations).
defp check_user_dashboard_deprecation do
if PhoenixKit.Config.user_dashboard_enabled?() do
{:warn,
"The user dashboard (/dashboard) is deprecated. It still works and needs no " <>
"action now, but will be removed in a future release — its functionality is " <>
"moving into the unified admin panel (/admin), which shows sections per the " <>
"viewer's permissions."}
else
{:pass, "User dashboard disabled — nothing to migrate."}
end
end
# Three demo LiveViews (/test-current-user, /test-redirect-if-auth,
# /test-ensure-auth) were written into every host by early versions of the
# installer. They were never documented, never refreshed by
# `mix phoenix_kit.update`, and `phoenix_kit_hello_world` does the job they
# were for — properly, and as a versioned package. The generator is gone, but
# deleting it does nothing for the hosts that already have them, which is what
# this check is for. One of the three publicly reports whether you are logged
# in, so this is worth saying out loud rather than leaving to archaeology.
@demo_routes ["/test-current-user", "/test-redirect-if-auth", "/test-ensure-auth"]
defp check_demo_routes do
case RouteResolver.get_router() do
nil ->
{:pass, "No router resolved — nothing to check."}
router ->
paths = MapSet.new(router.__routes__(), & &1.path)
case Enum.filter(@demo_routes, &MapSet.member?(paths, &1)) do
[] ->
{:pass, "No demo auth pages routed."}
found ->
{:warn,
"This app still routes PhoenixKit's old demo auth pages: #{Enum.join(found, ", ")}. " <>
"They were scaffolded by an early installer, are undocumented and unmaintained, " <>
"and one of them reports publicly whether the visitor is logged in. Remove the " <>
"demo scope from your router and delete the matching " <>
"*Web.PhoenixKitLive.Test*Live modules. For a worked example of a PhoenixKit " <>
"module, use phoenix_kit_hello_world instead."}
end
end
rescue
_ -> {:pass, "Could not introspect routes — skipping."}
end
# The two crawler-visibility footguns, in both directions: a production host
# carrying the global noindex directive (the silent SEO killer — the switch
# was for staging and someone shipped it), and a staging-looking host that is
# indexable (Google will happily index a dev box; both directions have
# happened here). Heuristic on the hostname, so it PASSes with a note when
# the host cannot be determined rather than guessing.
#
# Settings are read via direct SQL, NOT PhoenixKit.Settings: the doctor runs
# in update_mode, which short-circuits every Settings read to its default —
# through that API this check would report "disabled" on every install.
defp check_crawler_visibility(prefix) do
cond do
crawler_setting?(prefix, "crawlers_module_enabled", false) == false ->
{:pass, "Crawlers module disabled — no directives active."}
crawler_setting?(prefix, "crawlers_no_index", false) ->
case host_flavor(prefix) do
{:staging, host} ->
{:pass, "Global noindex is ON for #{host}, which looks like staging — as intended."}
{:production, host} ->
{:warn,
"The global noindex directive is ON and #{host} does not look like a staging " <>
"host. If this is production, every page is telling search engines to drop " <>
"it. Turn it off in Settings → Crawlers."}
:unknown ->
{:pass,
"Global noindex is ON (host undetermined — if this deployment is " <>
"production, turn it off in Settings → Crawlers)."}
end
true ->
case host_flavor(prefix) do
{:staging, host} ->
{:warn,
"#{host} looks like a staging/dev deployment and is INDEXABLE — search " <>
"engines will index it. Consider the noindex toggle in Settings → Crawlers."}
_ ->
blocked =
Enum.reject(
Bots.group_keys(),
&crawler_setting?(prefix, Crawlers.group_setting_key(&1), true)
)
summary =
case blocked do
[] -> "all bot groups allowed"
keys -> "blocked bot groups: #{Enum.join(keys, ", ")}"
end
{:pass, "Site indexable, noindex off; #{summary}."}
end
end
rescue
_ -> {:pass, "Crawler settings unreadable (no database?) — skipping."}
catch
:exit, _ -> {:pass, "Crawler settings unreadable (no database?) — skipping."}
end
# A boolean settings row, read straight from the table (see moduledoc of the
# check above for why). Missing row → the given default.
defp crawler_setting?(prefix, key, default) do
repo = get_repo!()
p = if prefix == "public", do: "public.", else: "#{prefix}."
case repo.query!("SELECT value FROM #{p}phoenix_kit_settings WHERE key = $1", [key]) do
%{rows: [[value] | _]} -> value == "true"
_ -> default
end
end
# :staging / :production by hostname shape, :unknown when no URL is
# configured. Label-based matching, not substring — "device.com" must not
# read as a dev box, while "max-dev2.example" must. The site_url setting is
# read via SQL (update_mode, as above) with the endpoint config as fallback.
defp host_flavor(prefix) do
url = configured_site_url(prefix) || endpoint_url()
with url when is_binary(url) and url != "" <- url,
%URI{host: host} when is_binary(host) and host != "" <- URI.parse(url) do
# DNS names are case-insensitive; the label list is lowercase.
host = String.downcase(host)
if staging_host?(host), do: {:staging, host}, else: {:production, host}
else
_ -> :unknown
end
rescue
_ -> :unknown
end
defp configured_site_url(prefix) do
repo = get_repo!()
p = if prefix == "public", do: "public.", else: "#{prefix}."
case repo.query!("SELECT value FROM #{p}phoenix_kit_settings WHERE key = 'site_url'", []) do
%{rows: [[value] | _]} when is_binary(value) and value != "" -> value
_ -> nil
end
rescue
_ -> nil
end
defp endpoint_url do
Routes.base_url()
rescue
_ -> nil
end
@staging_labels ~w(localhost local staging stage dev develop development test testing demo preview sandbox)
defp staging_host?(host) do
cond do
host in ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"] -> true
String.ends_with?(host, ".local") -> true
String.contains?(host, "ngrok") or String.ends_with?(host, "lvh.me") -> true
ip_address?(host) -> true
true -> host |> String.split([".", "-"]) |> Enum.any?(&staging_label?/1)
end
end
defp staging_label?(label) do
# Strip a trailing ordinal so "dev2" and "staging3" match while "device"
# (whose stem is not in the list) does not.
base = String.replace(label, ~r/\d+$/, "")
base != "" and base in @staging_labels
end
defp ip_address?(host) do
match?({:ok, _}, :inet.parse_address(String.to_charlist(host)))
end
# Which layer answers GET /sitemap.xml, and whether robots.txt points at it.
#
# Three layers can claim that path and nothing tells a host which one won:
# Plug.Static runs before the router, host routes declared before
# `phoenix_kit_routes()` bind first, and PhoenixKit is last. A host that
# reported "the sitemap 404s" had simply never been told any of that.
defp check_sitemap_serving do
case {static_sitemap_file(), sitemap_route_owner()} do
{path, _} when is_binary(path) ->
{:warn,
"#{path} exists, and Plug.Static runs before the router — that file is served, " <>
"not PhoenixKit's generated sitemap. Delete it to use the generated one."}
{nil, nil} ->
{:warn,
"No route answers GET /sitemap.xml. PhoenixKit declares one, so either " <>
"phoenix_kit_routes() is missing from your router or a host route matched " <>
"first and was removed."}
{nil, owner} ->
{:pass, "GET /sitemap.xml is served by #{inspect(owner)}." <> robots_hint()}
end
end
defp static_sitemap_file do
Enum.find(["priv/static/sitemap.xml", "priv/static/sitemap.xml.gz"], &File.exists?/1)
end
# Same router the sitemap source itself introspects, so what this reports is
# what actually generates.
defp sitemap_route_owner do
case RouteResolver.get_router() do
nil ->
nil
router ->
case Enum.find(router.__routes__(), &(&1.verb == :get and &1.path == "/sitemap.xml")) do
%{plug: plug} -> plug
_ -> nil
end
end
rescue
_ -> nil
end
# robots.txt is host policy — PhoenixKit deliberately does not generate one.
# Without a Sitemap: line, crawlers only find the sitemap by guessing.
defp robots_hint do
path = "priv/static/robots.txt"
cond do
not File.exists?(path) ->
" No priv/static/robots.txt — consider adding one with a `Sitemap:` line."
File.read!(path) =~ ~r/^\s*sitemap:/im ->
""
true ->
" priv/static/robots.txt has no `Sitemap:` line — add " <>
"`Sitemap: https://yourdomain/sitemap.xml` so crawlers find it."
end
rescue
_ -> ""
end
# Additional, non-fatal check (task ask, spec §6.1's third manifest
# consumer): when the generated manifest exists, run
# `PhoenixKit.Migrations.Repair.verify/1` (read-only) against it and fold
# the result into doctor's report. Deliberately capped at `:warn` — this
# check exists to surface repair-relevant information during a routine
# diagnostic pass, not to make `mix phoenix_kit.doctor` fail a deploy gate
# over something `mix phoenix_kit.repair` itself reports in full. Wrapped
# in its own rescue (on top of `run_check/2`'s) so a bug in this brand-new
# code path can never turn into a `:fail` here either.
defp check_manifest_repair(prefix) do
case Resolver.resolve() do
{:error, :not_generated} ->
{:pass, Resolver.not_generated_message()}
{:ok, _module} ->
manifest_repair_result(prefix)
end
rescue
e -> {:warn, "Manifest repair check raised: #{Exception.message(e)}"}
end
defp manifest_repair_result(prefix) do
case Repair.verify(prefix: prefix) do
{:ok, report} ->
summary = Report.summary(report)
if Report.exit_code(report) == 0 do
{:pass, "clean — #{summary.total} finding(s), all info-level"}
else
{:warn,
"#{summary.total} finding(s): #{inspect(summary.by_severity)} — run mix phoenix_kit.repair for details"}
end
{:error, reason} ->
{:warn, Repair.error_message(reason)}
end
end
# ── Display ─────────────────────────────────────────────────────────
defp header(title) do
IO.puts("\n#{IO.ANSI.bright()}#{IO.ANSI.cyan()}#{title}#{IO.ANSI.reset()}")
IO.puts(String.duplicate("─", 60))
end
defp run_check(name, fun) do
result =
try do
fun.()
rescue
e -> {:fail, "Exception: #{Exception.message(e)}"}
end
display_check(name, result)
{name, result}
end
defp display_check(name, {:pass, detail}) do
IO.puts(" #{IO.ANSI.green()}PASS#{IO.ANSI.reset()} #{name}")
if detail, do: IO.puts(" #{IO.ANSI.faint()}#{detail}#{IO.ANSI.reset()}")
end
defp display_check(name, {:warn, detail}) do
IO.puts(" #{IO.ANSI.yellow()}WARN#{IO.ANSI.reset()} #{name}")
if detail, do: IO.puts(" #{IO.ANSI.yellow()}#{detail}#{IO.ANSI.reset()}")
end
defp display_check(name, {:fail, detail}) do
IO.puts(" #{IO.ANSI.red()}FAIL#{IO.ANSI.reset()} #{name}")
if detail, do: IO.puts(" #{IO.ANSI.red()}#{detail}#{IO.ANSI.reset()}")
end
defp summary(results) do
pass = Enum.count(results, fn {_, {status, _}} -> status == :pass end)
warn = Enum.count(results, fn {_, {status, _}} -> status == :warn end)
fail = Enum.count(results, fn {_, {status, _}} -> status == :fail end)
total = length(results)
IO.puts(
"#{IO.ANSI.bright()}Summary#{IO.ANSI.reset()}: #{pass}/#{total} passed, #{warn} warnings, #{fail} failures"
)
if fail > 0 do
IO.puts(
"#{IO.ANSI.red()}Fix the FAIL items above before running migrations.#{IO.ANSI.reset()}"
)
end
end
end