Encryptor.Ecto.Migrator.Report (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

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

ClassMeaning
:nullThe column is NULL; nothing to do
:already_targetThe probe succeeded; the row is in the target state
:migratableThe probe failed and the from load succeeded
:migratable_unverifiedThe same, for a field whose from: cipher does not authenticate (ADR-0004 decision 3)
:undecryptableNeither 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.

t()

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

class()

@type class() ::
  :null
  | :already_target
  | :migratable
  | :migratable_unverified
  | :undecryptable

How a row was classified (ADR-0002 decision 7).

cursor_key()

@type cursor_key() :: {module(), atom(), String.t() | nil}

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.

failure()

@type failure() :: %{schema: module(), field: atom(), id: term(), reason: term()}

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.

t()

@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

classes()

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

count(report, class)

@spec count(t(), class()) :: t()

Counts one row into a class.

count_concurrent(report)

@spec count_concurrent(t()) :: t()

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

failure_limit()

@spec failure_limit() :: pos_integer()

How many failures a report holds before it stops holding them.

iex> Encryptor.Ecto.Migrator.Report.failure_limit()
100

finish(report)

@spec finish(t()) :: t()

Stamps the finish time. Idempotent in effect; the last stamp wins.

new(mode)

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.

ok?(report)

@spec ok?(t()) :: boolean()

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

put_cursor(report, schema, field, prefix, cursor)

@spec put_cursor(t(), module(), atom(), String.t() | nil, term()) :: t()

Records how far one field's pass got, in the prefix it was visiting.

record_failure(report, failure)

@spec record_failure(t(), failure()) :: t()

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.

verified?(report)

@spec verified?(t()) :: boolean()

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