Skip to content

improvement: support strategy :context multitenancy via a tenant binder - #224

Open
C-Sinclair wants to merge 9 commits into
ash-project:mainfrom
C-Sinclair:feat/context-multitenancy
Open

improvement: support strategy :context multitenancy via a tenant binder#224
C-Sinclair wants to merge 9 commits into
ash-project:mainfrom
C-Sinclair:feat/context-multitenancy

Conversation

@C-Sinclair

@C-Sinclair C-Sinclair commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes the data layer half of #127.

  • Adds the tenant_binder option and the AshSqlite.TenantBinder behaviour. The data layer asks it for a connection once per statement.
  • Turns on can?(_, :multitenancy) (strategy :context currently fails to compile with Data layer does not support multitenancy).
  • Reads the tenant from the transaction reason, which improvement: passing tenant in the transaction reason ash#2917 added. That merged as f1602c4 and is not in a release yet, so mix.exs and mix.lock point ash at git. Restore ash_version("~> 3.34") once a release carries it.

Why not the documented workaround

Setting %{data_layer: %{repo: ...}} in a change and a preparation looks like it should work. The read path (repo.all/2) and the write path (repo.insert_all/3, through AshSql.dynamic_repo/3) both invoke the result as a module, so a repo instance raises:

** (ArgumentError) Modules (the first argument of apply) must always be an atom

That override selects between repo modules. Database-per-tenant needs a repo instance, for which Ecto binds instances with put_dynamic_repo/1.

What a tenant is here

One database file per tenant. SQLite has no schema to prefix, so the SQL is identical for every tenant and isolation comes from the connection.

  • set_tenant/3 is a no-op on the query.
  • The tenant is not passed to AshSql.repo_opts/5. Passing it reached Ecto as a table prefix and raised SQLite3 does not support table prefixes on every write.

Usage

sqlite do
  table "posts"
  repo MyApp.Repo
  tenant_binder MyApp.TenantBinder
end
defmodule MyApp.TenantBinder do
  @behaviour AshSqlite.TenantBinder

  @impl true
  def bind(tenant, _opts, fun) do
    previous = MyApp.Repo.get_dynamic_repo()
    MyApp.Repo.put_dynamic_repo(MyApp.Tenants.connection!(tenant))

    try do
      fun.()
    after
      MyApp.Repo.put_dynamic_repo(previous)
    end
  end
end

There is no default binder. AshSqlite.Verifiers.VerifyTenantBinder fails compilation for strategy :context resources without one.

Why the data layer picks the connection

flowchart LR
  A["Ash action"] --> B["AshSqlite.DataLayer"]
  B --> C["bind_tenant/4"]
  C -->|"tenant"| D["binder.bind/3"]
  C -->|"no tenant"| E["unbound/2 raises"]
  D --> F["put_dynamic_repo/1"]
  F --> G[("tenant.db")]
Loading

Every callback that issues SQL goes through bind_tenant/4. A caller cannot wrap a path it never sees:

  • Ash.count/2 never enters Ash.Actions.Read, so no preparation or around_transaction hook runs for it.
  • Ash calls atomic/3 rather than change/3 whenever it can build one statement, so a hook-installing change forces require_atomic? false.
  • A dynamic repo binding is per process. It does not survive Task.async, an Ash.load fan-out, or a background job.

bind/3 also receives the resource and a usage of :read, :write or :transaction. Only the data layer knows which a given statement is, and a binder can use it to serve reads from a replica. Binders that do not care can ignore it.

Transactions

  • transaction/4 reads the tenant from the reason, which improvement: passing tenant in the transaction reason ash#2917 put there.
  • Writes open as BEGIN IMMEDIATE. A deferred transaction that reads then writes has to upgrade its lock, and SQLite fails an upgrade immediately regardless of busy_timeout.
  • A transaction cannot span two tenants. Their rows are in separate files on separate connections, and SQLite cannot commit across databases atomically in WAL mode, even with ATTACH. A statement for a second tenant inside an open transaction raises rather than committing on its own and surviving the rollback around it.

global?

global? true with strategy :context is refused for now, until we have given it greater thought, as @zachdaniel suggested.

Not in this PR

#
# SPDX-License-Identifier: MIT

defmodule AshSqlite.Test.TenantBinder do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm feeling like if we are offering this as a feature that we should just "do this for them". AshPostgres context multitenancy automatically manages schemas for you and the selection of schemas.

For example, we could use something like :persistent_term to manage repos globally, stopping/starting them as needed etc. In its current form it feels like we're offering context multitenancy but with a lot of manual management still left to the end user.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree. It's something I was looking at in my experimental repo (ash_cell)
I'd love to have a great default exposed from AshSqlite instead!

My suggestion, if you're open to it: keep the tenant_binder + behaviour as an escape hatch. That way you expose the option for clients like Turso or Litestream etc to be implemented in the application layer. But we ship a default Binder as part of AshSqlite, that provides one connection/one db file per tenant, so zero code required to get that nice multitenancy behaviour!

:persistent_term is great, but I don't think it's the right shape here. Every put/erase copies the table and triggers a global GC scan across all processes to find references, activating and evicting tenants hits that costly write path. So the more tenant churn, the worse it gets.
Instead, we could just use a specific Registry, where connection processes are added and removed behind the scenes by the library.

I can take a look at extending this PR to include that implementation 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. Will look to review the changes on Friday of this week, got a busy week ahead.

@C-Sinclair
C-Sinclair marked this pull request as draft August 22, 2026 22:03
@C-Sinclair
C-Sinclair force-pushed the feat/context-multitenancy branch 2 times, most recently from cfb0221 to 6c4d27f Compare August 24, 2026 14:36
@C-Sinclair
C-Sinclair force-pushed the feat/context-multitenancy branch from 6c4d27f to b372245 Compare August 24, 2026 14:41
@impl true
def verify(dsl) do
if Ash.Resource.Info.multitenancy_strategy(dsl) == :context and
is_nil(Verifier.get_option(dsl, [:sqlite], :tenant_binder)) do

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted for a follow on PR to provide a default engine for this. That way this PR stays focused on exposing the required extension points, and #225 can focus on implementation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll therefore remove this Verifier once a default is available

assert ["globex one"] = TenantedPost |> Ash.read!(tenant: "globex") |> Enum.map(& &1.title)
end

test "aggregates are bound, which a caller could not have wrapped" do

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aggregates need to be tested separately as they don't go via Ash.Actions.Read, so preparations nor around_transaction can touch them. Fortunately, tenant_binder is at the Datalayer ...layer.

Comment thread test/support/resources/global_post.ex Outdated
Comment thread test/multitenancy_test.exs Outdated
@C-Sinclair
C-Sinclair marked this pull request as ready for review August 24, 2026 15:25
…nder

Closes the data layer half of ash-project#127. Turning on `strategy :context` currently
fails at compile time with `Data layer does not support multitenancy`.

The documented workaround -- setting `%{data_layer: %{repo: ...}}` in a change
and a preparation -- does not work. Both the read path (`repo.all/2`) and the
write path (`repo.insert_all/3`, via `AshSql.dynamic_repo/3`) invoke the result
*as a module*, so passing an instance raises `ArgumentError: Modules (the first
argument of apply) must always be an atom`. That override selects between repo
*modules*; database-per-tenant needs an *instance*, and binding an instance is
Ecto's job via `put_dynamic_repo/1`.

One database file per tenant. There is no schema to prefix, so the generated SQL
is identical for every tenant and isolation comes from which file the connection
is attached to. `set_tenant/3` is therefore a no-op on the query, and the tenant
is deliberately not passed to `AshSql.repo_opts/5` -- it reached Ecto as a table
prefix and raised `SQLite3 does not support table prefixes` on every write.

`tenant_binder` names a module implementing `AshSqlite.TenantBinder`, which is
asked for a connection once per statement:

    sqlite do
      table "posts"
      repo MyApp.Repo
      tenant_binder MyApp.TenantBinder
    end

This PR ships the seam and nothing behind it -- there is no default binder, and
`strategy :context` without one is refused. A managed runtime that supplies one
is a follow-up, so that this can be reviewed on its own.

Every entry point would otherwise have to call `put_dynamic_repo/1` before Ash
runs, and some cannot:

- `Ash.count/2` never enters `Ash.Actions.Read`, so no preparation or
  `around_transaction` hook runs for it.
- Ash calls `atomic/3` rather than `change/3` whenever it can build one
  statement, so a hook-installing change forces `require_atomic? false`.
- The binding is ambient, so it does not survive `Task.async`, an `Ash.load`
  fan-out, or a background job.

`bind/3` also receives the resource and a `usage` of `:read`, `:write` or
`:transaction`. Only the data layer can say which, and it is what lets a binder
serve reads from a replica while writes go to the owner.

`transaction/4` never receives the tenant: Ash calls it above the data layer and
the reason it builds does not name one. `AshSqlite.Transformers.CarryTenant`
adds a change that puts it in the changeset context, implementing `atomic/3` as
well as `change/3` so it does not force actions off the atomic path.

A transaction cannot span two tenants -- separate files on separate connections,
and SQLite cannot commit atomically across databases in WAL mode even with
`ATTACH`. A statement for another tenant inside an open transaction is refused
rather than committing on its own and surviving the rollback around it.

`global? true` is honoured: such a resource is not required to carry a tenant.
Ecto binds per repo *module*, though, so a global resource sharing a repo with
tenanted ones reads from whichever tenant the process last bound. The tests say
so rather than leaving it to be discovered.

Deliberately none in this PR. The guide is being written once the whole feature
has landed, rather than in pieces that contradict each other.
@C-Sinclair
C-Sinclair force-pushed the feat/context-multitenancy branch from 9887a48 to 7f5064f Compare September 7, 2026 15:33
…n the caller's binding

`global?` means one copy of the rows. For a schema-based data layer that falls
out for free -- the global table sits in a schema the connection a tenanted
statement already holds can reach. One SQLite database per tenant has no such
connection, so it has to be chosen, and nothing chose it.

Two halves were wrong, and both are fixed here:

  * A tenantless statement ran on whatever the calling process had bound. A
    `global?` resource sharing a repo module with tenanted ones therefore read
    whichever tenant was bound last, and which one depended on what that process
    had done before.
  * A statement *with* a tenant was bound to it, so a write with `tenant: "acme"`
    landed in acme's file and every tenant accumulated its own copy of a table
    that is supposed to have exactly one.

A `global?` resource now binds its repo module's own named instance, explicitly,
and ignores the tenant. Which database holds the global rows follows from `repo`:
sharing the tenanted module puts them in that module's configured database,
naming another module puts them there. Neither depends on the caller.

## Why not a tenant binder callback

The obvious shape was an optional `bind_global/2` on `AshSqlite.TenantBinder`,
letting a binder pick the connection for a tenantless statement. It is the wrong
seam. A binder picks a connection *instance* within one repo module; shared rows
are not another instance of a tenant's database but another database, which Ash
and Ecto already address as a repo module and resolve through
`AshSqlite.DataLayer.Info.repo/2`. Routing by binding would have put connection
selection in two places with no rule for which wins, and would still have needed
the answer `repo` already gives.

The binder contract is unchanged and no binder needs updating.

## Why the shared repo is checked at runtime

A `global?` resource needs a repo module started under its own name *and* holding a
`database:`. A repo module serving only tenants needs neither -- it is reached
through `Ecto.Repo.put_dynamic_repo/1` -- so adding `global? true` to a resource on
one is an easy mistake, and both halves are checked because neither implies the
other:

  * Unstarted, the statement fails with Ecto's own "could not lookup Ecto repo",
    which names the repo but not the reason a `global?` resource wanted it.
  * Started with no `database:` -- which Ecto allows -- the statement waits out the
    pool timeout and then reports that requests are arriving faster than they can be
    served. Measured at roughly six seconds, and nothing in it points at the
    missing configuration.

Neither check can be a transformer. A repo's `database:` is very often set in
`config/runtime.exs`, which is the recommended shape for a release, and a
transformer runs long before that file is evaluated -- so it would reject exactly
the configuration it should accept. They run instead when a global statement first
needs the connection, where the answer is knowable.

Nothing changes for a repo module with no `global?` resource on it.
`AshSqlite.UnconfiguredRepo` is configured with nothing whatsoever -- no
`database:`, no pool, no name -- and stays that way, with a test that says so.

## Tests

`AshSqlite.TenantRepo` gains a database and is started under its own name --
which is what a shared database is here -- while deliberately continuing to serve
the tenanted resources, so the tests are pointed at the footgun this fixes: a
global resource sharing a repo module with tenanted ones, still reading one copy.

`AshSqlite.Test.UnstartedGlobalPost` covers both ways to have no shared database,
on a repo module that has neither a name nor a database of its own.

The three tests that pinned the old behaviour are replaced rather than adjusted.
They asserted that a tenantless read followed the process binding and that a
tenanted write landed in the tenant's file; both are now wrong on purpose.
The rationale behind these decisions belongs in the commit messages, where it
already is in full, rather than repeated in four-to-eight line blocks beside the
code. Kept to one or two lines each: what is not obvious from reading the
function, and nothing about alternatives considered or how it was measured.

Comment-only; no behaviour change.
@C-Sinclair
C-Sinclair force-pushed the feat/context-multitenancy branch from 7f5064f to 1290ebc Compare September 7, 2026 15:48
Comment thread documentation/dsls/DSL-AshSqlite.DataLayer.md Outdated
Comment thread lib/data_layer/info.ex Outdated
Comment thread lib/changes/carry_tenant.ex Outdated
Comment thread lib/transformers/carry_tenant.ex Outdated
Comment thread lib/verifiers/verify_tenant_binder.ex Outdated
Comment thread lib/data_layer.ex Outdated
Comment thread lib/data_layer.ex
Comment thread lib/data_layer.ex

# Ash calls this above the data layer, so there is no changeset to read the
# tenant off.
tenant = reason_tenant(reason)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have Ash do this if needed then, setting something into the transaction metadata you get here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could then leverage that to support Ash.transaction(...., tenant: "tenant")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in ash-project/ash#2917
I added optional(:tenant) => term() added Ash.DataLayer.transaction_reason/0, so we can pluck it out here!

Comment thread lib/data_layer.ex Outdated
Comment thread lib/data_layer.ex Outdated
Comment thread lib/data_layer.ex Outdated
Comment thread lib/data_layer.ex
Comment thread lib/data_layer.ex
Comment thread lib/data_layer.ex Outdated
Comment thread lib/data_layer.ex Outdated
Comment thread lib/tenant_binder.ex
#
# SPDX-License-Identifier: MIT

defmodule AshSqlite.TenantBinder do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can probably make this a simpler name. Like AshSqlite.TenantToRepo or something. Lets try to make the module and function themselves make it more obvious what they are meant to do.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good shout. AshSqlite.TenantToRepo doesn't quite describe it tho, as you're ultimately trying to give a way of wrapping every data layer connection with app layer code. Its not quite a repo being returned, its an active connection for that particular query.

How about tenant_connection for the DSL & then AshSqlite.TenantConnection as the module name? The callback on the behaviour could then be with_connection(tenant, opts, fun)
Shows that you're providing a wrapper over the connection which provides the tenant. Tenant could just be one of the opts as well if we wanted it not tied just to multitenancy 🤔

Or maybe, to be more generic connection_source in DSL & AshSqlite.ConnectionSource mod, same callback?

The `tenant_binder` option, `AshSqlite.TenantBinder`, `set_tenant/3` and the
verifier all explained themselves by contrast with a schema-based data layer --
"no schemas", "rather than a query prefix". A reader of ash_sqlite does not need
the comparison, so each one now says what SQLite does and what the option is for.

The same sentence appeared in four places. `AshSqlite.DataLayer.Info.tenant_binder/1`
loses its second paragraph as well, which said what the `nil` in the first already
says. The DSL cheat sheet is regenerated from the option's new doc.
…an guess

`global?` allows a resource to be used with or without a tenant. Each tenant has
its own database file here, so a statement carrying no tenant names no database,
and there is no shared connection to fall back to.

The previous commit chose one reading: a `global? true` resource bound its repo
module's own named instance and ignored the tenant. That is not the reading a
schema-based data layer implements, where such a resource writes to the public
schema as well as the tenant's. Rather than settle the question inside this PR,
`AshSqlite.Verifiers.VerifyGlobalMultitenancy` refuses the combination, and the
error names the alternative: shared tables on a resource with no multitenancy,
pointing at a repo module of its own.

What this removes from the data layer: `bind_global/2`, `verify_shared_repo!/2`
and `shared_repo_error/3`, the `global?` branch of `bind_tenant/4`, and the
`multitenancy_global?/1` term in `tenant_required?/1`, which no `:context`
resource can satisfy any more. `AshSqlite.TenantRepo` goes back to having no
`database:` and no named instance, because only a `global?` resource needed
either.

`unbound/2` no longer suggests `global? true` in its error, and the comment on
`without_binder/3` no longer claims a Spark verifier only warns. A `DslError`
from a verifier is reported by `Module.ParallelChecker`, so `mix compile` fails;
that guard is reached only by a resource built at runtime.
Ash does not send mixed tenants into one bulk operation, so `changesets_tenant/1`
does not need to check for them. It mapped every changeset's tenant, took the
unique set and raised on more than one. Now it matches the first changeset.

The raise it drops was never reachable through Ash, and no test covered it.
Depends on ash-project/ash#2917, which adds `:tenant` to the transaction reason.
Until that merges, the nine tests that reach `transaction/4` through an Ash action
fail with "carried no tenant". Every other test passes.

`reason_tenant/1` becomes `reason[:tenant]`. It previously looked in three places,
because no path named the tenant and each forwarded something different: the
single-record paths a data layer context, the bulk paths a whole changeset
context, and a read its query.

`AshSqlite.Changes.CarryTenant` and `AshSqlite.Transformers.CarryTenant` are
deleted. They existed only to put the tenant somewhere `transaction/4` could reach
it, which is what @zachdaniel objected to, and Ash naming it removes the reason for
them to exist.

`without_binder/3` goes too. It raised when a `strategy :context` resource had no
binder, which `AshSqlite.Verifiers.VerifyTenantBinder` already fails the compile
for. Its branch stays as `fun.()`, because a resource with multitenancy this data
layer does not resolve to a connection, such as `strategy :attribute`, reaches
that clause with a tenant and nothing to bind.
ash-project/ash#2917 merged as f1602c4 and puts `:tenant` on the transaction
reason, which `AshSqlite.DataLayer.transaction/4` reads. It is not in a release
yet: v3.33.1 was cut the day before it merged.

The nine tests that reach `transaction/4` through an Ash action now pass, and
the whole suite is green at 203.

`ash_version/1` still takes ASH_VERSION, so `local` and a version number keep
working. Restore `ash_version("~> 3.34")` once a release carries the change,
as 4400623 did after 627d7c5.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants