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 | ||
|---|---|---|
:repo | required | The Ecto.Repo the suspension table lives in |
:table | "encryptor_suspensions" | The table to read and write |
:prefix | nil | The 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 | |
|---|---|
id | the surrogate primary key Ecto.Migration.create/2 adds by default. This module never selects it |
vault | the vault module the set belongs to, as inspect/1 spells it |
selector | the 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_at | when 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
Encryptor.Vault.Suspension.Store.suspend/2answers:okfor a new suspension and for one already in the set, and{:error, {:unsupported_selector, selector}}for a selector that is not a non-empty string -:defaultincluded. A string column cannot hold:defaultapart from a scope whose selector is the string"default", and a scoped vault, the one a suspension is for, has no:defaultscope to suspend.Encryptor.Vault.Suspension.Store.reinstate/2answers:ok, including for a selector never suspended and for one this store cannot hold.Encryptor.Vault.Suspension.Store.list/1answers this vault's rows.
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
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
Functions
@spec default_table() :: String.t()
The table name a host gets unless it names another.
iex> Encryptor.Ecto.SuspensionStore.default_table()
"encryptor_suspensions"
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.
This vault's whole set, in no particular order.
@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.
@spec suspend(state(), Encryptor.Error.selector()) :: :ok | {:error, term()}
Adds the selector to this vault's set. Idempotent.