This guide is for a host whose encrypted writes and reads do not all happen
in the request that knows the scope. A request fans work out to a Task, a
request enqueues a background job that runs later, and an event projector
builds a read model from events that belong to many scopes. Each of those
runs in a process that never set a scope, and each needs one.
The worked host is a catalog service for libraries. Each library is a scope:
its patrons' email addresses are encrypted under its own key. The request
registers patrons, a background job sends a patron an overdue notice, and a
projector turns loan events into a read model the catalog's screens query.
Everything below compiles and runs:
test/encryptor/ecto/scope_in_jobs_guide_test.exs runs this guide's code
against a real vault and real tables.
It assumes one scoped vault, Library.Vault, whose scope is the library,
configured as in the ## Configuring it section of
Encryptor.Ecto.KeyStore, or as the customer vault of the
two-vaults guide.
Step 1. Know where the scope stops
Encryptor.Ecto.Scope keeps the scope in the process dictionary of the
process that called put/1 or wrap/2. It is kept there because an
Ecto.Type callback runs in the caller's process and receives the value and
the type's params, never the struct, so a process-scoped store is the one
channel that reaches it (ADR-0001 decision 5a).
A process dictionary belongs to one process. A process you start has a
dictionary of its own, and the entries of the process that started it are
not copied into it, so a Task starts with no scope even when its caller
had one. A background job is further away still: the enqueuing process
writes the job's arguments to a table, and a queue process - later, perhaps
on another node - reads them back. Only the arguments make that trip.
So the scope does not propagate, and this package does not pretend it does
(ADR-0001 decision 5b). A write or a read of a scoped field from a process
that never set a scope raises Encryptor.Ecto.MissingScopeError; it never
falls back to a default scope (decision 5c). Every place work crosses into
another process is a place the host carries the scope across by hand, and
Encryptor.Ecto.Scope's moduledoc lists the boundaries.
The field type used for patrons reads the process scope, the default:
defmodule Library.Encrypted.String do
use Encryptor.Ecto.String, vault: Library.Vault
enddefmodule Library.Patron do
use Ecto.Schema
schema "patrons" do
field :library_id, :string
field :email, Library.Encrypted.String
end
endStep 2. Carry the scope into a Task
Read the scope in the caller, before the process boundary, and re-establish
it inside the new process with Encryptor.Ecto.Scope.wrap/2:
defmodule Library.Patrons do
alias Encryptor.Ecto.Scope
alias Library.Patron
alias Library.Repo
def register(email) do
Repo.insert!(%Patron{library_id: Scope.fetch!(), email: email})
end
def register_all_async(emails) do
scope = Scope.fetch!()
Task.async(fn ->
Scope.wrap(scope, fn -> Enum.map(emails, ®ister/1) end)
end)
end
endscope = Scope.fetch!() runs in the caller, where the scope is set; it
raises there, at the boundary, when the caller has none. The closure carries
the string into the new process, and wrap/2 sets it for the duration of
the function. wrap/2 restores what was there before, so the same shape is
safe in a pooled process that runs many units of work.
The same two lines serve Task.Supervisor.async_nolink/3,
Task.async_stream/3, and a GenServer call that writes on someone else's
behalf: read in the caller, pass the string, wrap/2 on the far side.
Step 3. Carry the scope into a background job
A job cannot capture a closure. Put the scope in the job's arguments when it
is enqueued, and re-establish it in perform/1:
defmodule Library.OverdueNotices do
alias Encryptor.Ecto.Scope
alias Library.Patron
alias Library.Repo
def job_args(patron_id) do
%{"library_id" => Scope.fetch!(), "patron_id" => patron_id}
end
def perform(%{"library_id" => library_id, "patron_id" => patron_id}) do
Scope.wrap(library_id, fn ->
%Patron{email: email} = Repo.get!(Patron, patron_id)
{:ok, email}
end)
end
endjob_args/1 runs in the request, where the scope is set. The arguments are
a map with string keys and JSON values, which is what survives the JSON
round trip a job queue puts arguments through. perform/1 answers the
address the notice goes to; a real one hands it to the mailer inside the
same wrap/2.
With Oban, the worker is one line of delegation, and enqueueing builds the arguments in the request:
defmodule Library.Workers.OverdueNotice do
use Oban.Worker, queue: :notices
@impl Oban.Worker
def perform(%Oban.Job{args: args}), do: Library.OverdueNotices.perform(args)
endpatron_id
|> Library.OverdueNotices.job_args()
|> Library.Workers.OverdueNotice.new()
|> Oban.insert()Keeping the work in a plain module rather than in the worker means the job can be tested, and run from a console, without a queue. The scope travels as the library id, a routing identifier the host already stores in plaintext columns; it is not a secret, and it is the only thing the job needs to resolve the key.
Step 4. Feed a projector's resolver from the event
A projector is different in kind. It writes rows for every library, one event after another, in one process: inline, in the process of the command that emitted the event, and on a rebuild, in a single process replaying the whole event log. The scope of each write is the library on the event, not the scope of the process doing the writing.
A resolver cannot read the library id off the row it is writing. An
Ecto.Type callback never sees the struct (ADR-0001 decision 5, the first
of its "Alternatives considered"), and
Encryptor.Ecto.ScopeContext.resolve/2 is handed the field's declared
vault, table and column and nothing about the row. So the code that holds
the event puts the library id where the resolver reads it - the arrangement
Encryptor.Ecto.Migrator.RowScope makes for a migration plan that says
scope_from :some_column.
The resolver keeps the value in a process key of its own, not in
Encryptor.Ecto.Scope:
defmodule Library.LoanScope do
@behaviour Encryptor.Ecto.ScopeContext
@key {__MODULE__, :library_id}
def with_library(library_id, fun) when is_binary(library_id) and is_function(fun, 0) do
previous = Process.get(@key)
Process.put(@key, library_id)
try do
fun.()
after
if previous, do: Process.put(@key, previous), else: Process.delete(@key)
end
end
@impl Encryptor.Ecto.ScopeContext
def resolve(_operation, _params) do
case Process.get(@key) do
library_id when is_binary(library_id) -> {:ok, library_id}
nil -> {:error, :no_library_for_projection}
end
end
endA key of its own is what makes the inline case safe. The command's process
has a process scope of its own, and the projector neither reads it nor
overwrites it: a projection written inside a request is under the event's
library whatever the request's scope is, and the request's scope is
untouched afterwards. with_library/2 restores what was there before, in an
after, so a raising write cannot leave one event's library behind for the
next event. resolve/2 answers {:error, _} when nothing is set, and the
type then raises Encryptor.Ecto.MissingScopeError rather than writing a
row under a key that is not its own.
The type names the resolver, and the read model names the type:
defmodule Library.Encrypted.LoanString do
use Encryptor.Ecto.String,
vault: Library.Vault,
scope: Library.LoanScope
enddefmodule Library.LoanView do
use Ecto.Schema
schema "loan_views" do
field :library_id, :string
field :title, :string
field :patron_email, Library.Encrypted.LoanString
end
endThe projector hands each event's library id to the resolver, and the read side hands it the library id it filters by:
defmodule Library.LoanProjector do
import Ecto.Query, only: [from: 2]
alias Library.LoanScope
alias Library.LoanView
alias Library.Repo
def project(%{"type" => "loan_recorded", "library_id" => library_id} = event) do
LoanScope.with_library(library_id, fn ->
Repo.insert!(%LoanView{
library_id: library_id,
title: event["title"],
patron_email: event["patron_email"]
})
end)
end
def replay(events), do: Enum.each(events, &project/1)
def list(library_id) do
LoanScope.with_library(library_id, fn ->
Repo.all(from(v in LoanView, where: v.library_id == ^library_id))
end)
end
endreplay/1 walks events from many libraries in one process, and each write
is under its own event's library. list/1's filter is not optional: the
scope is one value per load, so a query whose rows span libraries raises
Encryptor.Ecto.DecryptError on the first row of another library.
Both types name the same vault and resolve to the same library id, so a projection row is under its library's key like that library's patron rows, and a shred of the library's key reaches both.
Step 5. Run the projector in a process of its own
A projector usually runs as a handler process of its own: subscribed to the event log, started under the host's supervision tree, and on whichever node the tree put it. Nothing that process inherits carries a scope. It never set one, and a process dictionary crosses neither a process nor a node, so the scope of the process that emitted an event, or of the process that asks for a projection row, is not there to read. The handler reads the scope off the data it is handed instead: off the event for a write, and off the row for a read-back.
A write needs nothing new. The event carries its library id, and Step 4's
project/1 hands it to the resolver. A read-back is the case Step 4 does
not cover: the caller has a projection row's id and nothing else, so no
library id arrives with the request. The row carries one, in its own
plaintext library_id column. Read that column first, in a query that
selects nothing encrypted and so needs no scope, and then load the row
inside with_library/2 under the library it named:
defmodule Library.LoanViews do
import Ecto.Query, only: [from: 2]
alias Library.LoanScope
alias Library.LoanView
alias Library.Repo
def fetch!(id) do
library_id = Repo.one!(from(v in LoanView, where: v.id == ^id, select: v.library_id))
LoanScope.with_library(library_id, fn -> Repo.get!(LoanView, id) end)
end
endThe handler takes each event the log delivers as an {:event, event}
message and answers read-backs by call:
defmodule Library.LoanProjectionHandler do
use GenServer
alias Library.LoanProjector
alias Library.LoanViews
def start_link(opts), do: GenServer.start_link(__MODULE__, :ok, opts)
def fetch_view(server, id), do: GenServer.call(server, {:fetch_view, id})
@impl GenServer
def init(:ok), do: {:ok, nil}
@impl GenServer
def handle_info({:event, event}, state) do
LoanProjector.project(event)
{:noreply, state}
end
@impl GenServer
def handle_call({:fetch_view, id}, _from, state) do
{:reply, LoanViews.fetch!(id), state}
end
endThe handler sets no scope of its own and needs none: each write is under its event's library and each read-back under its row's, whichever process sent the message and whatever that process's scope was. The same code runs unchanged on every node, because everything it reads the scope from - the event and the row - is data that crosses the node with it.
On more than one node
The steps above hold on a cluster as they stand: the scope travels in a closure, in a job's arguments, on an event and in a row's own column, and never in a process dictionary. One piece of a vault's state is different in kind, and that is whether a scope is suspended.
By default a vault keeps its suspended set in a table on the node the vault
runs on: Encryptor.Vault.Suspension.Store.Ets is the default
:suspension_store. Under that default a suspension is per node.
Encryptor.Vault.suspend/2 stops the library on the node it ran on and
nowhere else, a handler process on another node goes on writing and reading
that library's rows, and the suspension is lost when the vault restarts. A
host running on more than one node would have to suspend on each, and again
after every deploy.
A deployment on more than one node configures its vault with
Encryptor.Ecto.SuspensionStore, a store over the host's own repo that
every node reads:
config :library, Library.Vault,
suspension_store: {Encryptor.Ecto.SuspensionStore, repo: Library.Repo},
suspension_poll_interval: 5_000A suspension then holds on the node that wrote it once the table has it,
and every other node reads the table back on the vault's own poll, every
:suspension_poll_interval milliseconds: the interval bounds how long a
node goes on serving a library after it was suspended elsewhere. The
suspension also survives restarts. The table arrives as migration source the
host reviews and runs, from mix encryptor.ecto.gen.suspension_store_migration;
Encryptor.Ecto.SuspensionStore's moduledoc covers its options, its table
and what it answers.
What this guide checked
test/encryptor/ecto/scope_in_jobs_guide_test.exs compiles every module
block above except the Oban worker, with the repo, the vault and the
Library prefix swapped for the test suite's own, and fails when a block and
the tested code differ. The Oban worker is checked to delegate to
Library.OverdueNotices.perform/1, since Oban is not a dependency of this
package. The test then shows that a new process starts with no scope, runs
the Task of Step 2 and the job of Step 3 in processes of their own - the
job's arguments through a JSON round trip first - and replays interleaved
events of two libraries through the projector of Step 4, with no process
scope at all and inline under the other library's process scope. It starts
the handler of Step 5 as a process of its own, delivers it events of two
libraries from a process with no scope and from one under the other library's
scope, and reads each row back through the handler by its id alone. The
section on more than one node is not run: a test suite on one node cannot
show a second node's view, and Encryptor.Ecto.SuspensionStore has tests of
its own.