PhoenixKit.Migrations.Repair.CommentPolicy (phoenix_kit v2.3.0)

Copy Markdown View Source

Pure decision logic for the version-comment policy, spec §6.4 R1-R6. Every function here is DB-free — the caller (PhoenixKit.Migrations.Repair) does the actual reading/writing and passes in already-fetched values.

Why "floor" here is PhoenixKit.Migrations.Postgres.initial_version/0, not a fixed number

§6.4's R3 ("0 < comment < floor → hard BelowFloorError") is written for the post-squash world, where floor is a real number greater than 1. The chain is squashed at floor 135, so initial_version/0 is 135 and classify/3's :below_floor branch is LIVE: every install still at V01..V134 lands in it. (It was dormant while the floor was 1 — no integer satisfies 0 < comment < 1 — which is why the tests drive it with synthetic floors like 121 rather than whatever initial_version/0 happens to compile to.)

R1 ("never lower the comment")

Not a branch here — it is a postcondition every function in this module upholds by construction: classify/3 never proposes writing a comment at all (that is PhoenixKit.Migrations.Repair's job, driven by should_heal_comment?/2 and adopt_outcome/2), and both of those only ever return a version >= what triggered them ({:stale_low, target} has target > comment by marker_cross_check/2's own guard; adopt_outcome/2 only fires from the NULL-comment branch, where any stamp is a raise).

R2/R5 as one computation

R2's "stale-LOW comment" (schema ahead of what the comment claims) and R5's "lying-HIGH ... comment ahead of schema" warning are opposite readings of the same number: the highest version whose objects are all structurally present (highest_fully_present_version/1, the generalized form of PhoenixKit.Migrations.Postgres.heal_version_comment/2's single hardcoded {83, ...} marker probe — spec's own word for it is "generalized"). marker_cross_check/2 compares that number against the comment and reports whichever side is warranted (or :consistent when they agree).

Summary

Types

Which R-branch a given raw comment falls into.

Outcome of comparing the comment against the highest fully-present version.

Functions

R4's --adopt gate: stamp floor iff the post-apply verify pass came back fully clean (zero :missing, zero error-severity divergences, every floor-level data invariant held) — verify_clean? is that single boolean, computed by the caller from a PhoenixKit.Migrations.Repair.Report.t(). Never partially adopts.

Classifies a raw comment against floor/current into one of R3-R6's branches (R1/R2 are cross-cutting, not classification outcomes — see moduledoc). :absent (table doesn't exist) and a phantom 0 comment (never written by any real migration, but treated defensively the same as nil) both fall outside R1-R6's own enumeration; :not_installed/:adopt_required make that explicit rather than leaving them undefined.

Detects the "chain up/1 advanced the comment mid-repair" race (spec's concurrency rule, S18): compares the raw comment read right after the advisory lock was acquired against a second raw read taken right before the final verify pass. Any difference — not just an increase — aborts, since a decrease would mean a down/1 ran concurrently, an equally unsafe race.

Whether a report-only verify pass counts as "clean" for adopt_outcome/2's verify_clean? argument: zero :missing findings and zero error-severity findings. (data_invariants_hold? is threaded separately by the caller — invariant failures already surface as error-severity findings in a well-formed report, but PhoenixKit.Migrations.Repair computes this from the finding list directly so the gate does not depend on that always being true.)

Generalizes PhoenixKit.Migrations.Postgres.heal_version_comment/2's single {83, marker_query} entry to the whole manifest: the highest version for which every manifest object with that since is structurally present, given a per-since-bucket presence result. 0 if nothing is present at all (or the input is empty).

The R2/R5 cross-check (see moduledoc). highest is highest_fully_present_version/1's result.

Whether --heal-comment should stamp the comment forward, given the R2/R5 cross-check and whether the flag was passed. Only {:stale_low, _} is ever healed — :consistent and {:ahead_of_schema, _} never propose writing anything (R1: healing "ahead of schema" would mean lowering the comment toward highest, which R1 forbids outright; R2's normal missing-object healing is what addresses that case instead).

Types

branch()

@type branch() ::
  :not_installed
  | {:below_floor, pos_integer()}
  | :adopt_required
  | :comment_unreadable
  | {:in_range, pos_integer()}
  | {:above_current, pos_integer()}

Which R-branch a given raw comment falls into.

cross_check()

@type cross_check() ::
  :consistent | {:stale_low, pos_integer()} | {:ahead_of_schema, pos_integer()}

Outcome of comparing the comment against the highest fully-present version.

raw_comment()

@type raw_comment() :: :absent | nil | non_neg_integer()

Raw comment as read by PhoenixKit.Migrations.Repair.Probe.raw_comment/2.

Functions

adopt_outcome(bool, floor)

@spec adopt_outcome(verify_clean? :: boolean(), floor :: pos_integer()) ::
  {:stamp, pos_integer()} | :no_stamp

R4's --adopt gate: stamp floor iff the post-apply verify pass came back fully clean (zero :missing, zero error-severity divergences, every floor-level data invariant held) — verify_clean? is that single boolean, computed by the caller from a PhoenixKit.Migrations.Repair.Report.t(). Never partially adopts.

iex> CommentPolicy.adopt_outcome(true, 121)
{:stamp, 121}
iex> CommentPolicy.adopt_outcome(false, 121)
:no_stamp

classify(comment, floor, current)

@spec classify(raw_comment(), floor :: pos_integer(), current :: pos_integer()) ::
  branch()

Classifies a raw comment against floor/current into one of R3-R6's branches (R1/R2 are cross-cutting, not classification outcomes — see moduledoc). :absent (table doesn't exist) and a phantom 0 comment (never written by any real migration, but treated defensively the same as nil) both fall outside R1-R6's own enumeration; :not_installed/:adopt_required make that explicit rather than leaving them undefined.

iex> CommentPolicy.classify(:absent, 121, 151)
:not_installed
iex> CommentPolicy.classify(nil, 121, 151)
:adopt_required
iex> CommentPolicy.classify(0, 121, 151)
:adopt_required
iex> CommentPolicy.classify(90, 121, 151)
{:below_floor, 90}
iex> CommentPolicy.classify(135, 121, 151)
{:in_range, 135}
iex> CommentPolicy.classify(200, 121, 151)
{:above_current, 200}

concurrent_migration?(same, same)

@spec concurrent_migration?(raw_comment(), raw_comment()) :: boolean()

Detects the "chain up/1 advanced the comment mid-repair" race (spec's concurrency rule, S18): compares the raw comment read right after the advisory lock was acquired against a second raw read taken right before the final verify pass. Any difference — not just an increase — aborts, since a decrease would mean a down/1 ran concurrently, an equally unsafe race.

iex> CommentPolicy.concurrent_migration?(135, 135)
false
iex> CommentPolicy.concurrent_migration?(135, 142)
true
iex> CommentPolicy.concurrent_migration?(nil, nil)
false
iex> CommentPolicy.concurrent_migration?(nil, 121)
true

floor_verify_clean?(findings)

@spec floor_verify_clean?([PhoenixKit.Migrations.Repair.Report.finding()]) ::
  boolean()

Whether a report-only verify pass counts as "clean" for adopt_outcome/2's verify_clean? argument: zero :missing findings and zero error-severity findings. (data_invariants_hold? is threaded separately by the caller — invariant failures already surface as error-severity findings in a well-formed report, but PhoenixKit.Migrations.Repair computes this from the finding list directly so the gate does not depend on that always being true.)

iex> CommentPolicy.floor_verify_clean?([%{kind: :pending, severity: :info}])
true
iex> CommentPolicy.floor_verify_clean?([%{kind: :missing, severity: :repairable}])
false
iex> CommentPolicy.floor_verify_clean?([%{kind: :wrong_shape, severity: :error}])
false

highest_fully_present_version(presence_by_since)

@spec highest_fully_present_version([{pos_integer(), boolean()}]) :: non_neg_integer()

Generalizes PhoenixKit.Migrations.Postgres.heal_version_comment/2's single {83, marker_query} entry to the whole manifest: the highest version for which every manifest object with that since is structurally present, given a per-since-bucket presence result. 0 if nothing is present at all (or the input is empty).

presence_by_since need not be sorted or exhaustive — every distinct since bucket the caller actually probed, in any order. Correctness depends on the caller having probed every since value from the smallest up to (at least) the answer; a caller that skips a low since bucket entirely gets a result as if that bucket were fully present, which is why PhoenixKit.Migrations.Repair.Probe's caller probes the whole manifest, not just "since above the comment".

iex> CommentPolicy.highest_fully_present_version([{53, true}, {114, true}, {137, false}, {142, true}])
114
iex> CommentPolicy.highest_fully_present_version([{53, true}])
53
iex> CommentPolicy.highest_fully_present_version([{53, false}])
0
iex> CommentPolicy.highest_fully_present_version([])
0

marker_cross_check(comment, highest)

@spec marker_cross_check(comment :: pos_integer(), highest :: non_neg_integer()) ::
  cross_check()

The R2/R5 cross-check (see moduledoc). highest is highest_fully_present_version/1's result.

iex> CommentPolicy.marker_cross_check(135, 140)
{:stale_low, 140}
iex> CommentPolicy.marker_cross_check(135, 130)
{:ahead_of_schema, 130}
iex> CommentPolicy.marker_cross_check(135, 135)
:consistent

should_heal_comment?(arg1, arg2)

@spec should_heal_comment?(cross_check(), heal_comment_requested? :: boolean()) ::
  {:heal, pos_integer()} | :no_heal

Whether --heal-comment should stamp the comment forward, given the R2/R5 cross-check and whether the flag was passed. Only {:stale_low, _} is ever healed — :consistent and {:ahead_of_schema, _} never propose writing anything (R1: healing "ahead of schema" would mean lowering the comment toward highest, which R1 forbids outright; R2's normal missing-object healing is what addresses that case instead).

iex> CommentPolicy.should_heal_comment?({:stale_low, 140}, true)
{:heal, 140}
iex> CommentPolicy.should_heal_comment?({:stale_low, 140}, false)
:no_heal
iex> CommentPolicy.should_heal_comment?(:consistent, true)
:no_heal
iex> CommentPolicy.should_heal_comment?({:ahead_of_schema, 130}, true)
:no_heal