What a pass did: one classification count per class, per-field cursors, and the failures it found.
ADR-0002 decision 7 fixes the classification and decision 11 fixes what
happens to a row that fits none of it. A report is returned on both arms
of Encryptor.Ecto.Migrator.run/2, so a run that halted still says
everything it did before halting - an operator reading a failed six-hour
pass needs the counts more than the caller needs an empty error.
The classification
| Class | Meaning |
|---|---|
:null | The column is NULL; nothing to do |
:already_target | The probe succeeded; the row is in the target state |
:migratable | The probe failed and the from load succeeded |
:migratable_unverified | The same, for a field whose from: cipher does not authenticate (ADR-0004 decision 3) |
:undecryptable | Neither side loads; it needs an operator decision |
concurrent is counted separately rather than as a class: a row the
application rewrote between the migrator's read and its write was already
counted once, when it was read (ADR-0002 decision 4).
Adding a class is additive, deliberately
The classes live in one list (classes/0), every counter is initialised
from it, and nothing in this module or in the engine pattern-matches on the
full set. ADR-0002 proposed amendment 2's fifth class,
:migratable_unverified, arrived exactly that way: an entry in that list,
plus the one line in Encryptor.Ecto.Migrator.Pass that decides when a
migratable row is counted under it. A sixth would be the same, and this
module stays arranged so that it is.
:migratable_unverified is counted in place of :migratable for every row
of a field that declared source_authenticated: false, in a dry run, a
write and a verification alike - the probe failed and the from load
succeeded, but nothing authenticated the bytes it read, and no later pass
ever will. validate: does not upgrade it: a host's own check on the
plaintext is the strongest control available (ADR-0004 decision 3c) and it
is still not an authentication tag.
Nothing here carries a value
A failure records the schema, the field, the primary key and the reason -
never the plaintext, never the ciphertext bytes, never key material
(ADR-0002 decision 11, ADR-0001 decision 6). The reasons the engine records
are the ones Encryptor.Ecto.Migrator.Source composes, which are already
reduced to module names and atoms for the same reason.
The failure list is bounded at 100 entries while failure_count keeps
counting: a pass over a table whose key was destroyed produces one failure
per row, and a report that tried to hold all of them would exhaust the
memory of the process holding every plaintext in the database.
Summary
Types
How a row was classified (ADR-0002 decision 7).
What a cursor belongs to: a schema, a field, and the prefix that was visited.
One row that could not be read from either side.
Functions
Every class a row can be given, in the order a report prints them.
Counts one row into a class.
Counts one row the application rewrote while the migrator held it.
How many failures a report holds before it stops holding them.
Stamps the finish time. Idempotent in effect; the last stamp wins.
A report for a pass that is about to start, with every class at zero.
Whether the pass found nothing an operator has to decide about.
Records how far one field's pass got, in the prefix it was visiting.
Records one failure, keeping the list bounded and the count exact.
Whether every row a verification saw was :already_target or :null.
Types
@type class() ::
:null
| :already_target
| :migratable
| :migratable_unverified
| :undecryptable
How a row was classified (ADR-0002 decision 7).
What a cursor belongs to: a schema, a field, and the prefix that was visited.
The prefix component is ADR-0002 proposed amendment 6: without it, a caller
looping run/2 over prefixes has the second prefix resume at the first's
cursor and silently skip every row below it.
One row that could not be read from either side.
:id is the row's primary key, which is not a secret and is the only way an
operator finds the row again.
@type t() :: %Encryptor.Ecto.Migrator.Report{ concurrent: non_neg_integer(), counts: %{required(class()) => non_neg_integer()}, cursors: %{required(cursor_key()) => term()}, failure_count: non_neg_integer(), failures: [failure()], finished_at: DateTime.t() | nil, mode: Encryptor.Ecto.Migrator.pass_mode(), started_at: DateTime.t() }
Functions
@spec classes() :: [class()]
Every class a row can be given, in the order a report prints them.
iex> Encryptor.Ecto.Migrator.Report.classes()
[:null, :already_target, :migratable, :migratable_unverified, :undecryptable]
Counts one row into a class.
Counts one row the application rewrote while the migrator held it.
Not a class: the row was already counted when it was read (ADR-0002 decision 4).
@spec failure_limit() :: pos_integer()
How many failures a report holds before it stops holding them.
iex> Encryptor.Ecto.Migrator.Report.failure_limit()
100
Stamps the finish time. Idempotent in effect; the last stamp wins.
@spec new(Encryptor.Ecto.Migrator.pass_mode()) :: t()
A report for a pass that is about to start, with every class at zero.
Zeroed rather than empty so that a report always names every class: an
operator reading undecryptable 0 has been told something, and one reading
a map with no :undecryptable key has to know the class exists to notice it
is missing.
Whether the pass found nothing an operator has to decide about.
This is the {:ok, report} / {:error, report} arm and the task family's
exit code, and it is deliberately about failures rather than about work
done: a pass that rewrote nothing because everything was already migrated is
a success (ADR-0002 decision 11).
iex> alias Encryptor.Ecto.Migrator.Report
iex> Report.ok?(Report.new(:dry_run))
true
Records how far one field's pass got, in the prefix it was visiting.
Records one failure, keeping the list bounded and the count exact.
The row is also counted :undecryptable, because the classification is
about rows and the failure list is about what an operator has to go and look
at.
Whether every row a verification saw was :already_target or :null.
This is Encryptor.Ecto.Migrator.verify/2's arm and
mix encryptor.ecto.verify's exit code, and it is a stricter question
than ok?/1: a pass that found a thousand readable legacy rows and failed
on none of them is a successful dry run and a failed verification (ADR-0002
decision 10). It is also ADR-0004 decision 5's primary signal that the mixed
window has closed and that dropping legacy: is due.
Written as "every class except those two is zero" rather than as a match on
the classes that are allowed to be non-zero, so that a class added later
counts against a verification by default. ADR-0002 proposed amendment 2's
:migratable_unverified is the class that property was written for: had it
needed listing here to be noticed, it would have arrived as a verification
that passes while rows nothing authenticated sit in the table, which is the
exact claim ADR-0004 decision 3a exists to stop the evidence making.
The ok?/1 conjunct is deliberately redundant: record_failure/2 counts
every failure :undecryptable as well, so the class check already catches
one. It is written out because decision 10's exit code is about both - "no
failures" and "every row in the target state" - and a reader who has to
derive the first from the second has to hold record_failure/2 in their
head to know this function is right.
iex> alias Encryptor.Ecto.Migrator.Report
iex> Report.verified?(Report.count(Report.new(:verify), :already_target))
true
iex> Report.verified?(Report.count(Report.new(:verify), :migratable))
false
iex> Report.verified?(Report.count(Report.new(:verify), :migratable_unverified))
false