Capstan.Snapshot.PrimaryKey (Capstan v0.2.0)

Copy Markdown View Source

The order-faithful primary-key core of the initial snapshot (C2, design Ch3/Ch4).

A chunk pages by ORDER BY pk in MySQL while the cursor-gate classifies streamed changes by k ≤ cursor in Elixir. For that pairing to be correct — no silently mis-classified change (delivered vs suppressed → gap/dup) — the PK's Elixir term-order MUST provably match MySQL's ORDER BY. This module enforces that with a positive type allowlist and supplies the two operations the coordinator needs on PK values:

  • canonical/2 — the equality form for the sink ledger. Canonicalization happens in Elixir (reconciliation-equality), NOT MySQL collation. The text-protocol string form of a PK (a chunk read) and the binlog-decoded integer/binary form (a streamed change) canonicalize to the SAME term, so the ledger reconciles a chunk row against a streamed change to the same key.
  • compare/2 — the ordering for the cursor-gate (k ≤ cursor). Over the allowlist it is exactly MySQL ORDER BY: integers order numerically, BINARY/VARBINARY order byte-wise (shorter prefix first), and composites order lexicographically as tuples (matching MySQL row-value ORDER BY).

The positive allowlist (Ch3.2 / Ch4)

ACCEPT only the types whose Elixir order provably matches MySQL ORDER BY:

  • signed/unsigned integer — TINYINT/SMALLINT/MEDIUMINT/INT/BIGINT
  • BINARY / VARBINARY (byte-ordered)
  • composites of the above (tuple compare)

REFUSE everything else with :snapshot_pk_unsupported_type:

  • the collation-ordered STRING family — CHAR/VARCHAR/TEXT (both a _bin and a _ci collation report data_type char/varchar, so collation cannot distinguish them; the whole family is refused). A collation order Elixir cannot reproduce would let the cursor-gate mis-classify a change.
  • DECIMAL/DOUBLE/FLOAT/DATE/DATETIME/temporal and any other type — their Elixir term-order diverges from MySQL.

BIGINT UNSIGNED across 2^63 (Ch3.1)

An unsigned 64-bit PK spans [0, 2^64). A signed-wrap decoder reads the high half as negative (2^63 → -2^63, 2^64-1 → -1), so it would order 2^63… before 0… and the cursor-gate would mis-classify a high-half key. canonical/2 decodes to the TRUE unsigned value: a text form parses directly, and a raw integer that arrived signed-wrapped (a negative for an unsigned column) is normalized back into [0, 2^width).

No PK → fail closed

introspect/2 reads information_schema for the PRIMARY KEY. A table with no PK accepts a UNIQUE key fallback ONLY IF every column of that key is NOT NULL — a nullable unique-key column admits NULL-keyed rows that WHERE k > cursor never selects (a silent gap, Ch3.3), so such a key is refused :snapshot_table_no_primary_key. A table with neither is refused the same way.

Rule 1

No PK value is ever logged or telemetered here. Errors are value-free atoms. The information_schema queries embed only the schema/table (structural identity — the same fields telemetry may carry), never a row value, and are escaped as SQL string literals.

Summary

Types

A canonical PK: a single value (single-column PK) or a tuple (composite PK).

The introspected key: the ordered PK columns and their order-faithful types.

An order-faithful PK column type atom (integer family, :binary, :varbinary).

Functions

Builds the canonical equality form of a PK from pk_types and the raw column values (raw_values, one per PK column in order).

Compares two canonical PK values (from canonical/2): :lt, :eq, or :gt.

Introspects the PRIMARY KEY (or a NOT-NULL-complete UNIQUE key fallback) of {schema, table} over a Capstan.Query handle.

The pure key resolution over the two information_schema resultsets — the RED-capable seam introspect/2 delegates to (so the PK / unique-key fallback / type-refusal logic is provable without a live server).

Classifies a single column's (DATA_TYPE, COLUMN_TYPE) to an order-faithful pk_type(), or refuses :snapshot_pk_unsupported_type.

Types

canonical_pk()

@type canonical_pk() :: integer() | binary() | tuple()

A canonical PK: a single value (single-column PK) or a tuple (composite PK).

key()

@type key() :: %{pk_columns: [String.t()], pk_types: [pk_type()]}

The introspected key: the ordered PK columns and their order-faithful types.

pk_type()

@type pk_type() ::
  :tinyint
  | :tinyint_unsigned
  | :smallint
  | :smallint_unsigned
  | :mediumint
  | :mediumint_unsigned
  | :int
  | :int_unsigned
  | :bigint
  | :bigint_unsigned
  | :binary
  | :varbinary

An order-faithful PK column type atom (integer family, :binary, :varbinary).

Functions

canonical(pk_types, raw_values)

@spec canonical([pk_type()], [binary() | integer()]) :: canonical_pk()

Builds the canonical equality form of a PK from pk_types and the raw column values (raw_values, one per PK column in order).

A raw value is either a text-protocol string (a chunk read) or a binlog-decoded term (a streamed change); both forms of the SAME PK canonicalize equal. Integers normalize to their true value (an unsigned column's signed-wrapped negative is unwrapped into [0, 2^width)); binaries pass through their bytes. A single-column PK returns a bare value; a composite returns a tuple in column order.

compare(a, b)

@spec compare(canonical_pk(), canonical_pk()) :: :lt | :eq | :gt

Compares two canonical PK values (from canonical/2): :lt, :eq, or :gt.

Over the allowlist this is exactly MySQL ORDER BY: Erlang term order coincides with MySQL numeric order for integers, byte-wise order for BINARY/VARBINARY, and — for same-arity composites — lexicographic tuple order for row-value ORDER BY. Both arguments must be canonical forms; the caller compares like against like (a table's PK shape is fixed).

introspect(query, arg)

@spec introspect(
  Capstan.Query.t(),
  {String.t(), String.t()}
) :: {:ok, key()} | {:error, atom()}

Introspects the PRIMARY KEY (or a NOT-NULL-complete UNIQUE key fallback) of {schema, table} over a Capstan.Query handle.

Returns {:ok, %{pk_columns: [...], pk_types: [...]}} for an order-faithful key, or a value-free {:error, reason}: :snapshot_pk_unsupported_type when a key column's type is outside the allowlist, :snapshot_table_no_primary_key when there is no PK and no NOT-NULL-complete unique key. A transport/query fault propagates its value-free reason from Capstan.Query.query/2.

resolve_key(index_rows, column_rows)

@spec resolve_key([[binary() | nil]], [[binary() | nil]]) ::
  {:ok, key()} | {:error, atom()}

The pure key resolution over the two information_schema resultsets — the RED-capable seam introspect/2 delegates to (so the PK / unique-key fallback / type-refusal logic is provable without a live server).

  • index_rowsSTATISTICS rows [INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE]
  • column_rowsCOLUMNS rows [COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE]

(all columns are NOT NULL in information_schema, so no cell is nil). Returns the same {:ok, key()} | {:error, reason} contract as introspect/2.

resolve_pk_type(data_type, column_type)

@spec resolve_pk_type(binary(), binary()) ::
  {:ok, pk_type()} | {:error, :snapshot_pk_unsupported_type}

Classifies a single column's (DATA_TYPE, COLUMN_TYPE) to an order-faithful pk_type(), or refuses :snapshot_pk_unsupported_type.

Signedness rides COLUMN_TYPE (which carries the unsigned attribute); DATA_TYPE alone cannot distinguish it. The whole string family (char/varchar/text/enum/…) and every non-integer, non-binary type are refused (their Elixir order diverges from MySQL).