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

Copy Markdown View Source

Where a pass records how far it got, and what it refuses to do about the table it records into.

ADR-0002 decision 6: after each batch the migrator records {plan, schema, field, last_id, counts, started_at, updated_at} in a checkpoint table, and run/2 with resume: true starts after the recorded cursor. Decision 5 is what makes that merely a performance record: the pass probes every row before rewriting it, so losing a checkpoint costs a re-scan and never correctness.

The key carries the prefix

ADR-0002 proposed amendment 6 (status: proposed, ece-l6t) adds the prefix to the key, and this module is built to that text. Without it, a caller looping run/2 over prefixes - which is exactly what the record tells a host with several to do - has every prefix sharing one checkpoint row, so the second prefix resumes at the first's cursor and silently skips every row below it. Probe-first idempotence does not cover that: it makes a re-visited row safe, and this is a row never visited.

A prefix of nil - the repo's own default prefix - is stored as the empty string rather than as NULL, because a NULL component defeats the unique index in every adapter that treats NULLs as distinct, which is the same silent-skip failure by a different route.

The table is the host's, and this package will not create it

ADR-0002 decision 9: no CREATE TABLE, at runtime or from a task. The table arrives as generated migration source the host reads, commits, and runs with its own mix ecto.migrate (mix encryptor.ecto.gen.migration, ece-5qb). So preflight/2 checks and refuses, naming the generator; it is the one place where creating the table would be convenient, which is exactly why the line is drawn here.

checkpoint: :none keeps the alternative available as a documented degraded mode: no checkpoint at all, every run a full scan, which decision 5 makes correct rather than merely tolerable.

Its shape

One row per field per prefix, with a unique index over {plan, schema, field, prefix}:

Column
plan, schema, fieldthe plan module, the schema module, and the field, as text
prefixthe schema prefix visited; "" for the repo's default
last_idthe primary key of the last row of the last committed batch, rendered as text
countsthe classification counts so far, as a map
started_at, updated_atwhen the pass began and when this row was last written

last_id is text because ADR-0002 proposed amendment 4 admits both integer and binary primary keys and one checkpoint table serves both. Rendering and parsing are this module's (render_cursor/2, parse_cursor/2), and the key's type travels with it so the value is parsed back at exactly the type it was compared at.

A dry run never writes one

record/5 is called only in :write mode. A dry run that recorded a cursor would let a later write-mode run resume past rows it never wrote - a silent skip produced by the rehearsal, which is the one thing a rehearsal must not do. Reading a checkpoint in a dry run is fine and is what makes the rehearsal resemble the run.

Summary

Types

Which row of the checkpoint table a field's pass owns.

Functions

The table name a host gets unless it names another.

The cursor a previous pass over this field and prefix recorded, if any.

Parses a stored last_id back into a cursor, or nil when it cannot.

The empty string a default prefix is stored as.

Confirms the checkpoint table exists, and refuses when it does not.

Records one batch's cursor and counts, in the transaction that wrote it.

Renders a primary key as the text the checkpoint row stores.

Types

key()

@type key() :: %{
  plan: module(),
  schema: module(),
  field: atom(),
  prefix: String.t() | nil
}

Which row of the checkpoint table a field's pass owns.

Functions

default_table()

@spec default_table() :: String.t()

The table name a host gets unless it names another.

iex> Encryptor.Ecto.Migrator.Checkpoint.default_table()
"encryptor_ecto_migration_checkpoints"

fetch_cursor(repo, table, key, keyset_key)

@spec fetch_cursor(module(), String.t(), key(), Encryptor.Ecto.Migrator.Keyset.key()) ::
  term() | nil

The cursor a previous pass over this field and prefix recorded, if any.

A row whose last_id cannot be parsed back at the key's type is treated as absent: the pass re-scans, which decision 5 makes correct, rather than paging from a cursor nobody can vouch for.

parse_cursor(text, arg)

@spec parse_cursor(String.t(), Encryptor.Ecto.Migrator.Keyset.key()) :: term() | nil

Parses a stored last_id back into a cursor, or nil when it cannot.

iex> alias Encryptor.Ecto.Migrator.Checkpoint
iex> Checkpoint.parse_cursor("42", {:id, :integer})
42
iex> Checkpoint.parse_cursor("not a number", {:id, :integer})
nil

prefix_text(prefix)

@spec prefix_text(String.t() | nil) :: String.t()

The empty string a default prefix is stored as.

iex> Encryptor.Ecto.Migrator.Checkpoint.prefix_text(nil)
""
iex> Encryptor.Ecto.Migrator.Checkpoint.prefix_text("tenant_a")
"tenant_a"

preflight(repo, table)

@spec preflight(module(), String.t()) :: :ok | {:error, String.t()}

Confirms the checkpoint table exists, and refuses when it does not.

Returns :ok, or {:error, message} naming the generator. The refusal is a message rather than a CREATE TABLE for the reason ADR-0002 decision 9 gives; the caller raises it, once, before the first batch of the first field.

record(repo, table, key, last_id, counts)

@spec record(module(), String.t(), key(), String.t(), map()) :: :ok

Records one batch's cursor and counts, in the transaction that wrote it.

Written by the same transaction as the batch it describes, so the two are consistent by construction - a cursor written beside a transaction rather than inside it disagrees with the rows whenever a crash lands between them.

render_cursor(cursor, arg)

@spec render_cursor(term(), Encryptor.Ecto.Migrator.Keyset.key()) :: String.t()

Renders a primary key as the text the checkpoint row stores.

iex> alias Encryptor.Ecto.Migrator.Checkpoint
iex> Checkpoint.render_cursor(42, {:id, :integer})
"42"
iex> Checkpoint.render_cursor(<<1, 2>>, {:id, :binary})
"0102"