diff --git a/CHANGELOG.md b/CHANGELOG.md index dc29b56..48d3334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v0.7.7 (TBD) +## v0.7.7 (2026-08-29) ### Enhancements @@ -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 diff --git a/lib/ecto/adapters/foundationdb.ex b/lib/ecto/adapters/foundationdb.ex index 06acd4c..f4cd13e 100644 --- a/lib/ecto/adapters/foundationdb.ex +++ b/lib/ecto/adapters/foundationdb.ex @@ -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: @@ -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`, @@ -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 @@ -1153,9 +1175,8 @@ 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) @@ -1163,9 +1184,19 @@ defmodule Ecto.Adapters.FoundationDB do 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 diff --git a/lib/ecto/adapters/foundationdb/ecto_adapter_async.ex b/lib/ecto/adapters/foundationdb/ecto_adapter_async.ex index 0201336..fd54557 100644 --- a/lib/ecto/adapters/foundationdb/ecto_adapter_async.ex +++ b/lib/ecto/adapters/foundationdb/ecto_adapter_async.ex @@ -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, """ diff --git a/lib/ecto_foundationdb/indexer/schema_metadata.ex b/lib/ecto_foundationdb/indexer/schema_metadata.ex index 2b64f74..8d39cf3 100644 --- a/lib/ecto_foundationdb/indexer/schema_metadata.ex +++ b/lib/ecto_foundationdb/indexer/schema_metadata.ex @@ -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 @@ -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 diff --git a/lib/ecto_foundationdb/layer/tx.ex b/lib/ecto_foundationdb/layer/tx.ex index a99cea1..6396a5c 100644 --- a/lib/ecto_foundationdb/layer/tx.ex +++ b/lib/ecto_foundationdb/layer/tx.ex @@ -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 @@ -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 diff --git a/lib/ecto_foundationdb/layer/tx/context.ex b/lib/ecto_foundationdb/layer/tx/context.ex new file mode 100644 index 0000000..5f67e17 --- /dev/null +++ b/lib/ecto_foundationdb/layer/tx/context.ex @@ -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 diff --git a/lib/ecto_foundationdb/layer/tx/policy.ex b/lib/ecto_foundationdb/layer/tx/policy.ex new file mode 100644 index 0000000..54bf096 --- /dev/null +++ b/lib/ecto_foundationdb/layer/tx/policy.ex @@ -0,0 +1,33 @@ +defmodule EctoFoundationDB.Layer.Tx.Policy do + @moduledoc false + # A transaction is opened either on a tenant or on a database, and that choice + # decides two things: what erlfdb object the transaction is opened on, and which + # nested work may join it. Everything else about a transaction is common, so the + # process state itself is handled by `EctoFoundationDB.Layer.Tx.Context`. + alias EctoFoundationDB.Layer.Tx.Context + alias EctoFoundationDB.Tenant + + @type txobj() :: :erlfdb.database() | :erlfdb.tenant() + + @doc "The erlfdb object that `:erlfdb.transactional/2` is opened on." + @callback txobj(context :: Context.t()) :: txobj() + + @doc """ + The context that `incoming` work runs under, given that `ambient` is already open. + + Returns `ambient` when the incoming work adds nothing to the context. Raises + `EctoFoundationDB.Exception.IncorrectTenancy` when the two cannot share a transaction. + """ + @callback join!(ambient :: Context.t(), incoming :: Context.t()) :: Context.t() + + @doc """ + The tenant that one operation runs on, given the ambient context and the `:prefix` + the caller provided (`nil` when it provided none). + + Returns `:error` when the context cannot supply a tenant and the caller named none. + Raises `EctoFoundationDB.Exception.IncorrectTenancy` when the named tenant cannot do + work in this transaction. + """ + @callback fetch_tenant(context :: Context.t(), prefix :: Tenant.t() | nil) :: + {:ok, Tenant.t()} | :error +end diff --git a/lib/ecto_foundationdb/layer/tx/policy/db.ex b/lib/ecto_foundationdb/layer/tx/policy/db.ex new file mode 100644 index 0000000..946bdf5 --- /dev/null +++ b/lib/ecto_foundationdb/layer/tx/policy/db.ex @@ -0,0 +1,56 @@ +defmodule EctoFoundationDB.Layer.Tx.Policy.Db do + @moduledoc false + # A transaction bound to a database, with no tenant of its own. Tenants of that + # database join it as the work requires, which is what lets one transaction span + # the tenants of several Repos. + alias EctoFoundationDB.Exception.IncorrectTenancy + alias EctoFoundationDB.Layer.Tx.Context + alias EctoFoundationDB.Layer.Tx.Policy + alias EctoFoundationDB.Tenant + alias EctoFoundationDB.Tenant.DirectoryTenant + alias EctoFoundationDB.Tenant.ManagedTenant + + @behaviour Policy + + @impl true + def txobj(%Context{db: db}), do: db + + # A joining tenant must write its keys through this transaction object. A + # DirectoryTenant's txobj is the database itself, so this equality also rejects a + # ManagedTenant, whose keys are prefixed by FDB at the transaction level instead. + @impl true + def join!( + ambient = %Context{db: db}, + incoming = %Context{policy: Policy.Tenant, tenant: tenant} + ) do + if Tenant.txobj(tenant) == db do + %Context{incoming | db: db, tx: ambient.tx} + else + raise IncorrectTenancy, """ + FoundationDB Adapter encountered a database-level transaction that cannot be shared with \ + the tenant #{inspect(tenant)}. + + A transaction opened on a database can only be joined by tenants of that same database, \ + and only when they use the #{inspect(DirectoryTenant)} backend. A #{inspect(ManagedTenant)} \ + holds its own transaction context, so it must use `Repo.transactional/2` instead. + """ + end + end + + def join!(ambient = %Context{db: db}, %Context{policy: __MODULE__, db: db}), do: ambient + + def join!(%Context{db: db}, %Context{policy: __MODULE__, db: other}) do + raise IncorrectTenancy, """ + FoundationDB Adapter encountered a transaction on the database #{inspect(db)} that cannot \ + be shared with work on the database #{inspect(other)}. + + A transaction belongs to a single database. All Repos taking part in it must be configured \ + with the same `:cluster_file`. + """ + end + + @impl true + def fetch_tenant(_context, nil), do: :error + + def fetch_tenant(ambient, prefix), do: {:ok, join!(ambient, Context.new(prefix)).tenant} +end diff --git a/lib/ecto_foundationdb/layer/tx/policy/tenant.ex b/lib/ecto_foundationdb/layer/tx/policy/tenant.ex new file mode 100644 index 0000000..5ab2c4f --- /dev/null +++ b/lib/ecto_foundationdb/layer/tx/policy/tenant.ex @@ -0,0 +1,60 @@ +defmodule EctoFoundationDB.Layer.Tx.Policy.Tenant do + @moduledoc false + # A transaction bound to one tenant. Work inside it must belong to that same + # tenant, so that a struct or query carrying a foreign prefix is caught instead + # of being written into the wrong keyspace. + alias EctoFoundationDB.Exception.IncorrectTenancy + alias EctoFoundationDB.Layer.Tx.Context + alias EctoFoundationDB.Layer.Tx.Policy + alias EctoFoundationDB.Tenant + alias EctoFoundationDB.Tenant.DirectoryTenant + alias EctoFoundationDB.Tenant.ManagedTenant + + @behaviour Policy + + @impl true + def txobj(%Context{tenant: tenant}), do: Tenant.txobj(tenant) + + @impl true + def join!(ambient = %Context{tenant: tenant}, %Context{ + policy: __MODULE__, + tenant: tenant + }), + do: ambient + + # Dropping to the database scope is allowed when this tenant already writes its + # keys through the database itself, which is to say a DirectoryTenant. It gives + # the nested work a scope that can span tenants. A ManagedTenant is rejected here + # for the same reason it cannot join a database transaction. + def join!(ambient = %Context{tenant: tenant}, incoming = %Context{policy: Policy.Db, db: db}) do + if Tenant.txobj(tenant) == db do + %{incoming | tx: ambient.tx} + else + raise IncorrectTenancy, """ + FoundationDB Adapter encountered a transaction on the tenant #{inspect(tenant)} that \ + cannot be shared with work on the database #{inspect(db)}. + + A transaction on a tenant can only be joined by work on that tenant's own database, \ + and only when the tenant uses the #{inspect(DirectoryTenant)} backend. A \ + #{inspect(ManagedTenant)} holds its own transaction context, so work within it must \ + name the tenant. + """ + end + end + + def join!(%Context{tenant: ambient_tenant}, %Context{policy: __MODULE__, tenant: tenant}) do + raise IncorrectTenancy, """ + FoundationDB Adapter encountered a transaction where the original transaction context \ + #{inspect(ambient_tenant)} did not match the prefix on a struct or query within the transaction: \ + #{inspect(tenant)}. + + 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. + """ + end + + @impl true + def fetch_tenant(%Context{tenant: tenant}, nil), do: {:ok, tenant} + + def fetch_tenant(ambient, prefix), do: {:ok, join!(ambient, Context.new(prefix)).tenant} +end diff --git a/lib/ecto_foundationdb/options.ex b/lib/ecto_foundationdb/options.ex index 4fd0234..1795d73 100644 --- a/lib/ecto_foundationdb/options.ex +++ b/lib/ecto_foundationdb/options.ex @@ -7,6 +7,7 @@ defmodule EctoFoundationDB.Options do | {:storage_delimiter, String.t()} | {:open_tenant_callback, function()} | {:migrator, module()} + | {:migrate, boolean()} | {:cluster_file, :erlfdb.cluster_filename()} | {:migration_step, integer()} | {:max_single_value_size, integer()} @@ -43,6 +44,9 @@ defmodule EctoFoundationDB.Options do def get(options, :migrator), do: Keyword.get(options, :migrator, nil) + def get(options, :migrate), + do: Keyword.get(options, :migrate, true) + def get(options, :migration_step), do: Keyword.get(options, :migration_step, @migration_step) diff --git a/lib/ecto_foundationdb/tenant.ex b/lib/ecto_foundationdb/tenant.ex index 926d81f..957523a 100644 --- a/lib/ecto_foundationdb/tenant.ex +++ b/lib/ecto_foundationdb/tenant.ex @@ -84,6 +84,11 @@ defmodule EctoFoundationDB.Tenant do When opening tenants with a repo, all migrations are automatically performed. This can cause open/2 to take a significant amount of time. Tenants can be kept open indefinitely, with any number of database transactions issued upon them. + + Provide the option `migrate: false` to skip the migration step. This is useful on + read-only code paths, where the cost of the migration check isn't warranted. Take + care: a tenant opened this way may be missing indexes that your queries expect, so + only do so when you know the tenant has already been migrated. """ @spec open(Ecto.Repo.t(), id(), Options.t()) :: t() def open(repo, id, options \\ []) when byte_size(id) > 0 do @@ -102,6 +107,8 @@ defmodule EctoFoundationDB.Tenant do When opening tenants with a repo, all migrations are automatically performed. This can cause open/2 to take a significant amount of time. Tenants can be kept open indefinitely, with any number of database transactions issued upon them. + + Provide the option `migrate: false` to skip the migration step. See `open/3`. """ @spec open!(Ecto.Repo.t(), id(), Options.t()) :: t() def open!(repo, id, options \\ []) when byte_size(id) > 0 do @@ -208,6 +215,10 @@ defmodule EctoFoundationDB.Tenant do end defp handle_open(repo, tenant, options) do - Migrator.up(repo, tenant, options) + if Options.get(options, :migrate) do + Migrator.up(repo, tenant, options) + else + :ok + end end end diff --git a/mix.exs b/mix.exs index be17ef0..530b89a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule EctoFoundationdb.MixProject do def project do [ app: :ecto_foundationdb, - version: "0.7.6", + version: "0.7.7", description: "FoundationDB adapter for Ecto", elixir: "~> 1.15", start_permanent: Mix.env() == :prod, diff --git a/test/ecto/integration/fdb_api_counting_test.exs b/test/ecto/integration/fdb_api_counting_test.exs index 8fd77e7..1d48ac3 100644 --- a/test/ecto/integration/fdb_api_counting_test.exs +++ b/test/ecto/integration/fdb_api_counting_test.exs @@ -186,6 +186,25 @@ defmodule Ecto.Integration.FdbApiCountingTest do ] = calls end + test "open new tenant with migrations skipped", context do + id = Ecto.UUID.autogenerate() + + {calls, _tenant} = + with_erlfdb_calls(context.test, fn -> + Tenant.open!(TinyRepo, id, migrate: false) + end) + + Tenant.clear_delete!(TinyRepo, id) + + # `migrate: false` skips the Migrator, so TinyRepo's index migration does not + # run and nothing but the tenant directory is touched. + assert [ + # -- db_open!: create the tenant directory, then open it -- + {EctoFoundationDB.Tenant.DirectoryTenant, {:erlfdb_directory, :create}}, + {:erlfdb_directory_cache, {:erlfdb_directory, :open}} + ] = calls + end + test "open existing tenant", context = %{tenant_id: tenant_id} do {calls, _tenant} = with_erlfdb_calls(context.test, fn -> diff --git a/test/ecto/integration/multi_repo_transaction_test.exs b/test/ecto/integration/multi_repo_transaction_test.exs new file mode 100644 index 0000000..7641b1e --- /dev/null +++ b/test/ecto/integration/multi_repo_transaction_test.exs @@ -0,0 +1,195 @@ +defmodule Ecto.Integration.MultiRepoTransactionTest do + use ExUnit.Case, async: true + + alias Ecto.Adapters.FoundationDB + alias EctoFoundationDB.Sandbox + alias EctoFoundationDB.Schemas.User + + defmodule OrgRepo do + @moduledoc false + use Ecto.Repo, otp_app: :ecto_foundationdb, adapter: Ecto.Adapters.FoundationDB + end + + defmodule MemberRepo do + @moduledoc false + use Ecto.Repo, otp_app: :ecto_foundationdb, adapter: Ecto.Adapters.FoundationDB + end + + def open_shared_db(_repo), do: Sandbox.open_db(Ecto.Integration.TestRepo) + + # The Repos are started for the whole module so that they outlive each test's + # `on_exit`, which needs them for the tenant checkin. + setup_all do + Application.put_env(:ecto_foundationdb, OrgRepo, + open_db: &__MODULE__.open_shared_db/1, + storage_id: "MultiRepoTransactionTest.Org" + ) + + Application.put_env(:ecto_foundationdb, MemberRepo, + open_db: &__MODULE__.open_shared_db/1, + storage_id: "MultiRepoTransactionTest.Member" + ) + + {:ok, _} = OrgRepo.start_link() + {:ok, _} = MemberRepo.start_link() + + :ok + end + + setup do + org = TenantForCase.setup(OrgRepo, log: false) + member = TenantForCase.setup(MemberRepo, log: false) + + on_exit(fn -> + TenantForCase.exit(OrgRepo, org[:tenant_id]) + TenantForCase.exit(MemberRepo, member[:tenant_id]) + end) + + [org_tenant: org[:tenant], member_tenant: member[:tenant]] + end + + test "transaction spans tenants from multiple repos", context do + org_tenant = context[:org_tenant] + member_tenant = context[:member_tenant] + + db = FoundationDB.db(OrgRepo) + assert db == FoundationDB.db(MemberRepo) + + {alice, bob} = + FoundationDB.transactional(db, fn -> + alice = OrgRepo.insert!(%User{name: "Alice"}, prefix: org_tenant) + bob = MemberRepo.insert!(%User{name: "Bob"}, prefix: member_tenant) + + assert %User{name: "Alice"} = OrgRepo.get(User, alice.id, prefix: org_tenant) + assert %User{name: "Bob"} = MemberRepo.get(User, bob.id, prefix: member_tenant) + + assert nil == MemberRepo.get(User, alice.id, prefix: member_tenant) + + {alice, bob} + end) + + assert %User{name: "Alice"} = OrgRepo.get(User, alice.id, prefix: org_tenant) + assert %User{name: "Bob"} = MemberRepo.get(User, bob.id, prefix: member_tenant) + + carol_id = Ecto.UUID.generate() + dave_id = Ecto.UUID.generate() + + assert_raise RuntimeError, "tx abort", fn -> + FoundationDB.transactional(db, fn -> + OrgRepo.insert!(%User{id: carol_id, name: "Carol"}, prefix: org_tenant) + MemberRepo.insert!(%User{id: dave_id, name: "Dave"}, prefix: member_tenant) + raise "tx abort" + end) + end + + assert nil == OrgRepo.get(User, carol_id, prefix: org_tenant) + assert nil == MemberRepo.get(User, dave_id, prefix: member_tenant) + end + + test "repo transactions nest inside a database transaction", context do + org_tenant = context[:org_tenant] + member_tenant = context[:member_tenant] + + db = FoundationDB.db(OrgRepo) + + {alice, bob} = + FoundationDB.transactional(db, fn -> + alice = + OrgRepo.transactional(org_tenant, fn -> + OrgRepo.insert!(%User{name: "Alice"}) + end) + + bob = + MemberRepo.transactional(member_tenant, fn -> + MemberRepo.insert!(%User{name: "Bob"}) + end) + + {alice, bob} + end) + + assert %User{name: "Alice"} = OrgRepo.get(User, alice.id, prefix: org_tenant) + assert %User{name: "Bob"} = MemberRepo.get(User, bob.id, prefix: member_tenant) + + carol_id = Ecto.UUID.generate() + + assert_raise RuntimeError, "tx abort", fn -> + FoundationDB.transactional(db, fn -> + OrgRepo.transactional(org_tenant, fn -> + OrgRepo.insert!(%User{id: carol_id, name: "Carol"}) + end) + + raise "tx abort" + end) + end + + assert nil == OrgRepo.get(User, carol_id, prefix: org_tenant) + end + + test "a tenant transaction nests inside a tenant transaction", context do + org_tenant = context[:org_tenant] + + alice = + OrgRepo.transactional(org_tenant, fn -> + OrgRepo.transactional(org_tenant, fn -> + OrgRepo.insert!(%User{name: "Alice"}) + end) + end) + + assert %User{name: "Alice"} = OrgRepo.get(User, alice.id, prefix: org_tenant) + end + + test "a database transaction nests inside a tenant transaction", context do + org_tenant = context[:org_tenant] + member_tenant = context[:member_tenant] + + db = FoundationDB.db(OrgRepo) + + {alice, bob} = + OrgRepo.transactional(org_tenant, fn -> + alice = OrgRepo.insert!(%User{name: "Alice"}) + + bob = + FoundationDB.transactional(db, fn -> + MemberRepo.insert!(%User{name: "Bob"}, prefix: member_tenant) + end) + + {alice, bob} + end) + + assert %User{name: "Alice"} = OrgRepo.get(User, alice.id, prefix: org_tenant) + assert %User{name: "Bob"} = MemberRepo.get(User, bob.id, prefix: member_tenant) + + dave_id = Ecto.UUID.generate() + + assert_raise RuntimeError, "tx abort", fn -> + OrgRepo.transactional(org_tenant, fn -> + FoundationDB.transactional(db, fn -> + MemberRepo.insert!(%User{id: dave_id, name: "Dave"}, prefix: member_tenant) + end) + + raise "tx abort" + end) + end + + assert nil == MemberRepo.get(User, dave_id, prefix: member_tenant) + end + + test "an operation takes its tenant from the prefix in a database transaction", context do + org_tenant = context[:org_tenant] + member_tenant = context[:member_tenant] + + db = FoundationDB.db(OrgRepo) + + future = + FoundationDB.transactional(db, fn -> + OrgRepo.async_insert_all!(User, [%User{name: "Alice"}], + prefix: org_tenant, + conflict_target: [] + ) + end) + + assert [alice] = OrgRepo.await(future) + assert %User{name: "Alice"} = OrgRepo.get(User, alice.id, prefix: org_tenant) + assert nil == MemberRepo.get(User, alice.id, prefix: member_tenant) + end +end