Encryptor.Ecto.SuspensionStore (Encryptor.Ecto v0.7.2)

Copy Markdown View Source

A shared Encryptor.Vault.Suspension.Store over the host's own repo: one suspension reaches every node that reads the same table, and survives restarts.

encryptor's enc-ADR-0010 makes where a vault's suspended set is agreed a behaviour, ships the per-node table as its default, and leaves the shared store to the host (its decision 10, which also names this package as where a Repo-backed one would land). This module is that store. The vault reads it back into its per-node view on its own poll, and the gate never asks it: the hot path stays one table read on the node (enc-ADR-0010 decision 3).

Configuring it

config :my_app, MyApp.ScopedVault,
  suspension_store: {Encryptor.Ecto.SuspensionStore, repo: MyApp.Repo},
  suspension_poll_interval: 5_000
Option
:reporequiredThe Ecto.Repo the suspension table lives in
:table"encryptor_suspensions"The table to read and write
:prefixnilThe schema prefix the table lives in; the repo's default when absent

The poll interval is the vault's :suspension_poll_interval, not an option here: how often a node reads the set is the vault's decision, and this store answers whenever it is asked. Any other option is refused at vault start, which encryptor reports as {:invalid_config, :suspension_store, :init}.

Encryptor.Vault.Suspension.Store.init/2 opens no connection, as the behaviour requires. A table that was never migrated is found by the first call that reads it.

The table

Column
idthe surrogate primary key Ecto.Migration.create/2 adds by default. This module never selects it
vaultthe vault module the set belongs to, as inspect/1 spells it
selectorthe suspended scope's selector, as the host passed it to Encryptor.Vault.suspend/2. The generated migration declares it :string, which is varchar(255) on Postgres
inserted_atwhen the suspension was first written, UTC. Written here and never read

One unique index over {vault, selector} makes suspend/2 idempotent at the database: a second suspension of the same scope is a conflict the insert ignores, not a second row. It is also what keys the set by vault: enc-ADR-0010 decision 1 says two vaults never share a set, and two vault modules pointed at one table read and write disjoint rows. Renaming a vault module therefore starts it with an empty set; carry its rows across in the same deploy.

A selector longer than the column is not stored. At the generated width, varchar(255), a selector over 255 characters makes suspend/2's insert raise the database's own error (on Postgres, a Postgrex.Error whose code is string_data_right_truncation), which the vault reports as a failed write, as "What it answers" below says of every database failure. Nothing is suspended. (Postgres stores a longer value only when every character past the width is a space, and then truncates it.) A host whose selectors can be longer widens the column in its generated migration before running it, for example to :text; the file is the host's, and this module names no width.

The table arrives the way this package's other tables do, as migration source the host reviews and runs: mix encryptor.ecto.gen.suspension_store_migration. This package issues no DDL (ADR-0002 decision 9).

The selector is stored as the host wrote it. The key store keeps only a keyed reference and never the selector, because its rows sit beside every ciphertext. This table cannot: Encryptor.Vault.Suspension.Store.list/1 answers selectors, and a keyed reference cannot be turned back into one. A row names a scope an operator suspended, in the host's own database, and enc-ADR-0010 decision 8 sends an operator who needs to know which scope was suspended to exactly this table (ADR-0007 decision 1).

What it answers

A database failure is not translated: the exception raises out of the callback. The vault already treats an {:error, term}, an exit and a raise from a store as one outcome - a write that changes nothing, or a refresh that keeps the last known set - and carries the exception in the error's :engine field (enc-ADR-0010 decision 7), so a rescue here would only repeat that translation and hide which exception it was.

Summary

Types

What init/2 resolves the options into. Constant, and no connection.

Functions

The table name a host gets unless it names another.

Resolves the options for one vault. Opens no connection.

This vault's whole set, in no particular order.

Removes the selector from this vault's set. Idempotent, and :ok for one never in it.

Adds the selector to this vault's set. Idempotent.

Types

state()

@type state() :: %{
  repo: module(),
  vault: String.t(),
  table: String.t(),
  prefix: String.t() | nil
}

What init/2 resolves the options into. Constant, and no connection.

Functions

default_table()

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

The table name a host gets unless it names another.

iex> Encryptor.Ecto.SuspensionStore.default_table()
"encryptor_suspensions"

init(vault, opts)

@spec init(
  module(),
  keyword()
) :: {:ok, state()} | {:error, term()}

Resolves the options for one vault. Opens no connection.

Refusals: {:unknown_options, keys}, {:missing_config, [:suspension_store, :repo]}, and {:invalid_config, key, reason} for a :repo that is not a module, a :table that is not an unquoted identifier, or an empty :prefix.

list(state)

@spec list(state()) :: {:ok, [String.t()]} | {:error, term()}

This vault's whole set, in no particular order.

reinstate(state, selector)

@spec reinstate(state(), Encryptor.Error.selector()) :: :ok | {:error, term()}

Removes the selector from this vault's set. Idempotent, and :ok for one never in it.

suspend(state, selector)

@spec suspend(state(), Encryptor.Error.selector()) :: :ok | {:error, term()}

Adds the selector to this vault's set. Idempotent.