Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## v0.7.7 (TBD)
## v0.7.7 (2026-08-29)

### Enhancements

Expand All @@ -10,6 +10,11 @@
more than one field is present, basic crud operations work as expected. Indexes, watches,
order_by, and a Versionstamp key field all throw an Unsupported exception for now. Thanks to
@fire for the contribution.
* `Ecto.Adapters.FoundationDB.transactional/2` now accepts a database, from db/1, in place of a
tenant. Repo operations inside name their tenant with `:prefix`, so one FDB transaction can
span the tenants of several Repos sharing a database. `DirectoryTenant` only.
* Added the `:migrate` option to `EctoFoundationDB.Tenant.open/3` and `open!/3`. When `false`,
opening the tenant skips the migration step, which is useful on read-only code paths.

### Bug fixes

Expand Down
41 changes: 36 additions & 5 deletions lib/ecto/adapters/foundationdb.ex
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,8 @@ defmodule Ecto.Adapters.FoundationDB do
Note: If you're looking for PubSub-like functionality pushed from the database itself, please see
[Watches](#module-watches).

### Single-Tenant Repo

For a single-tenant Repo (one configured with `:tenant_id`), you can use `Repo.transactional/1`,
which automatically uses the configured tenant:

Expand All @@ -440,6 +442,23 @@ defmodule Ecto.Adapters.FoundationDB do
`Repo.transactional/1` raises `EctoFoundationDB.Exception.IncorrectTenancy` if called on
a multi-tenant Repo.

### Transactions across Repos and Tenants

EctoFoundationDB supports transactions involving many Repos and Tenants. When doing so, your top-level
`transactional` call accepts the FDB database object.

```elixir
db = FoundationDB.db(MyApp.OrgRepo)

FoundationDB.transactional(db, fn ->
MyApp.OrgRepo.insert!(%Org{...}, prefix: org_tenant)
MyApp.MemberRepo.insert!(%Member{...}, prefix: member_tenant)
end)
```

Nested calls to `transactional` operate within a *single transaction*. See `transactional/2` for the
nesting rules.

## Migrations

At first glance, EctoFoundationDB migrations may look similar to that of `:ecto_sql`,
Expand Down Expand Up @@ -993,6 +1012,9 @@ defmodule Ecto.Adapters.FoundationDB do
* `:migrator` - A module that implements the `EctoFoundationDB.Migrator`
behaviour. Required when using any indexes. Defaults to `nil`. When `nil`,
your Repo module is assumed to be the Migrator.
* `:migrate` - When set to `false`, opening a tenant skips the migration step.
Defaults to `true`. Intended to be given to `EctoFoundationDB.Tenant.open/3`
on read-only code paths. See `EctoFoundationDB.Tenant.open/3` for the caveats.

## Advanced options

Expand Down Expand Up @@ -1153,19 +1175,28 @@ defmodule Ecto.Adapters.FoundationDB do
@doc """
Executes the given function in a transaction on the database.

If you provide an arity-0 function, your function will be executed in
a newly spawned process. This is to ensure that EctoFoundationDB can
safely manage the process dictionary.
If you provide an arity-1 function, it receives the FoundationDB transaction, which you
can use with the `:erlfdb` API directly.

Please be aware of the
[limitations that FoundationDB](https://apple.github.io/foundationdb/developer-guide.html#transaction-basics)
imposes on transactions.

For example, a transaction must complete
[within 5 seconds](https://apple.github.io/foundationdb/developer-guide.html#long-running-transactions).

## Nested calls to `transactional`

Nested calls to `transactional` operate within a *single transaction*.

|inside|`transactional(tenant, …)`|`transactional(db, …)`|
|---|---|---|
|nothing|opens on the tenant|opens on the database|
|a tenant transaction|same tenant joins; another raises|joins, giving a tenant-free scope|
|a database transaction|any tenant of that database joins|joins|
"""
@spec transactional(Tenant.t(), function()) :: any()
def transactional(tenant, fun), do: Tx.transactional_external(tenant, fun)
@spec transactional(Tenant.t() | Database.t(), function()) :: any()
def transactional(tenant_or_db, fun), do: Tx.transactional(tenant_or_db, fun)

@impl Ecto.Adapter
defmacro __before_compile__(_env) do
Expand Down
15 changes: 12 additions & 3 deletions lib/ecto/adapters/foundationdb/ecto_adapter_async.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@ defmodule Ecto.Adapters.FoundationDB.EctoAdapterAsync do
import Ecto.Query

def async_insert_all!(_module, repo, schema, list, opts) do
{tx?, tenant} = Tx.in_tenant_tx?()
tenant =
case Tx.fetch_tenant(opts[:prefix]) do
{:ok, tenant} ->
tenant

if not tx?,
do: raise(Unsupported, "`Repo.async_insert_all!` must be called within a transaction")
:error ->
raise Unsupported, """
`Repo.async_insert_all!` must be called within a transaction on a tenant.

Use `Repo.transactional(tenant, fn -> ... end)`, or provide the option \
`prefix: tenant` when inside a transaction on a database.
"""
end

if Fields.composite_pk?(schema) do
raise Unsupported, """
Expand Down
31 changes: 11 additions & 20 deletions lib/ecto_foundationdb/indexer/schema_metadata.ex
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ defmodule EctoFoundationDB.Indexer.SchemaMetadata do
end

def watch_by(queryable, indexed_values, name, opts \\ []) do
{tenant, tx} = assert_tenant_tx!()
{tenant, tx} = assert_tenant_tx!(opts[:prefix])

{schema, query_opts} =
case queryable do
Expand Down Expand Up @@ -413,28 +413,19 @@ defmodule EctoFoundationDB.Indexer.SchemaMetadata do
defp decode_counter(:not_found), do: 0
defp decode_counter(x), do: :binary.decode_unsigned(x, :little)

defp assert_tenant_tx!() do
tenant =
case Tx.in_tenant_tx?() do
{true, tenant} ->
tenant

{false, _} ->
raise Unsupported, """
SchemaMetadata functions must be executed for a specific tenant.
"""
end
defp assert_tenant_tx!(prefix \\ nil) do
case Tx.fetch_tenant(prefix) do
{:ok, tenant} ->
{tenant, Tx.get()}

tx =
if Tx.in_tx?() do
Tx.get()
else
:error ->
raise Unsupported, """
SchemaMetadata functions must be executed within a transaction.
"""
end
SchemaMetadata functions must be executed within a transaction on a specific tenant.

{tenant, tx}
Use `Repo.transactional(tenant, fn -> ... end)`, or provide the option \
`prefix: tenant` when inside a transaction on a database.
"""
end
end

defp with_schema_metadata_indexes(md = %Metadata{indexes: indexes}) do
Expand Down
105 changes: 38 additions & 67 deletions lib/ecto_foundationdb/layer/tx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,20 @@ defmodule EctoFoundationDB.Layer.Tx do
alias EctoFoundationDB.Layer.Fields
alias EctoFoundationDB.Layer.Pack
alias EctoFoundationDB.Layer.PrimaryKVCodec
alias EctoFoundationDB.Layer.Tx.Context
alias EctoFoundationDB.Layer.TxInsert
alias EctoFoundationDB.Schema
alias EctoFoundationDB.Tenant

@tenant :__ectofdbtxcontext__
@tx :__ectofdbtx__
def in_tx?(), do: not is_nil(Context.current())
def get(), do: Context.tx()

def in_tenant_tx?() do
tenant = Process.get(@tenant)
flag = in_tx?() and tenant.__struct__ == Tenant
{flag, tenant}
end

def in_tx?(), do: not is_nil(Process.get(@tx))
def get(), do: Process.get(@tx)
defdelegate fetch_tenant(prefix), to: Context

def safe?(nil) do
case in_tenant_tx?() do
{true, tenant} ->
{true, tenant}

{false, _} ->
{false, :missing_tenant}
case Context.tenant() do
nil -> {false, :missing_tenant}
tenant -> {true, tenant}
end
end

Expand All @@ -52,69 +43,49 @@ defmodule EctoFoundationDB.Layer.Tx do
end
end

def transactional_external(tenant, fun) do
nil = Process.get(@tenant)
nil = Process.get(@tx)

:erlfdb.transactional(
Tenant.txobj(tenant),
fn tx ->
Process.put(@tenant, tenant)
Process.put(@tx, tx)

try do
cond do
is_function(fun, 0) -> fun.()
is_function(fun, 1) -> fun.(tx)
end
after
Process.delete(@tx)
Process.delete(@tenant)
end
end
)
end

def transactional(nil, fun) do
case Process.get(@tx, nil) do
case Context.current() do
nil ->
raise IncorrectTenancy, """
FoundationDB Adapter has no transactional context to execute on.
"""

tx ->
%Context{tx: tx} ->
fun.(tx)
end
end

def transactional(context, fun) do
case Process.get(@tenant, nil) do
nil ->
try do
Process.put(@tenant, context)

:erlfdb.transactional(Tenant.txobj(context), fn tx ->
Process.put(@tx, tx)
fun.(tx)
end)
after
Process.delete(@tx)
Process.delete(@tenant)
end

^context ->
tx = Process.get(@tx, nil)
fun.(tx)
# A transaction on a tenant or on a database. When one is already open, the two
# policies decide between them whether this work can join it.
def transactional(tenant_or_db, fun) do
incoming = Context.new(tenant_or_db)

orig ->
raise IncorrectTenancy, """
FoundationDB Adapter encountered a transaction where the original transaction context \
#{inspect(orig)} did not match the prefix on a struct or query within the transaction: \
#{inspect(context)}.
case Context.current() do
nil -> open(incoming, fun)
ambient -> join(ambient, incoming, fun)
end
end

This can be encountered when a struct read from one tenant is provided to a transaction from \
another. In these cases, the prefix must explicitly be removed from the struct metadata.
"""
defp open(context = %Context{}, fun) do
:erlfdb.transactional(Context.txobj(context), fn tx ->
run(%{context | tx: tx}, fun)
end)
end

defp join(ambient, incoming, fun) do
run(Context.join!(ambient, incoming), fun)
end

defp run(context = %Context{tx: tx}, fun) do
displaced = Context.enter(context)

try do
cond do
is_function(fun, 0) -> fun.()
is_function(fun, 1) -> fun.(tx)
end
after
Context.restore(displaced)
end
end

Expand Down
78 changes: 78 additions & 0 deletions lib/ecto_foundationdb/layer/tx/context.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
defmodule EctoFoundationDB.Layer.Tx.Context do
@moduledoc false
# The transactional context of the calling process: which transaction is open, and
# under what policy the work inside it is allowed to happen. There is exactly one
# context in the process dictionary at a time. Nesting swaps it and puts back what
# it displaced, so an inner scope can never strip the outer one of its context.
alias EctoFoundationDB.Database
alias EctoFoundationDB.Layer.Tx.Context
alias EctoFoundationDB.Layer.Tx.Policy
alias EctoFoundationDB.Tenant

defstruct [:policy, :tenant, :db, :tx]

@type t() :: %Context{
policy: module(),
tenant: Tenant.t() | nil,
db: Database.t() | nil,
tx: :erlfdb.transaction() | nil
}

@key :__ectofdbtx__

@spec new(Tenant.t() | Database.t()) :: t()
def new(tenant = %Tenant{}), do: %Context{policy: Policy.Tenant, tenant: tenant}
def new(db = {:erlfdb_database, _}), do: %Context{policy: Policy.Db, db: db}

@spec current() :: t() | nil
def current(), do: Process.get(@key)

@doc """
Installs `context` and returns the one it displaced, to be given to `restore/1`.
"""
@spec enter(t()) :: t() | nil
def enter(context = %Context{}), do: Process.put(@key, context)

@spec restore(t() | nil) :: :ok
def restore(nil), do: then(Process.delete(@key), fn _ -> :ok end)
def restore(context = %Context{}), do: then(Process.put(@key, context), fn _ -> :ok end)

@spec tx() :: :erlfdb.transaction() | nil
def tx() do
case current() do
nil -> nil
%Context{tx: tx} -> tx
end
end

@spec tenant() :: Tenant.t() | nil
def tenant() do
case current() do
%Context{policy: Policy.Tenant, tenant: tenant} -> tenant
_ -> nil
end
end

@spec txobj(t()) :: Policy.txobj()
def txobj(context = %Context{policy: policy}), do: policy.txobj(context)

@doc """
Answers what context the work described by `incoming` runs under, given that
`ambient` is already open. Raises if the two cannot share a transaction.
"""
@spec join!(t(), t()) :: t()
def join!(ambient = %Context{policy: policy}, incoming = %Context{}),
do: policy.join!(ambient, incoming)

@doc """
The tenant that one operation runs on: the `:prefix` the caller provided, else the
one the open transaction is bound to. `:error` when there is neither.
"""
@spec fetch_tenant(Tenant.t() | nil) :: {:ok, Tenant.t()} | :error
def fetch_tenant(prefix) do
case current() do
nil -> :error
context = %Context{policy: policy} -> policy.fetch_tenant(context, prefix)
end
end
end
Loading
Loading