Skip to content

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

Open
C-Sinclair wants to merge 5 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 5 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.

Important

Stacked on #223 — please review and merge that first. GitHub cannot express a stacked PR across forks, so the diff below includes #223's commits as well. The commit to review here is the last one.

This PR was previously much larger. It has been split: it now ships the seam and nothing behind it, and the managed runtime that used to be in here is a follow-up.

What

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.

What context multitenancy means for SQLite

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.

The binder

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

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

There is no default binder in this PR, and strategy :context without one is refused — at compile time by a verifier, and at the first statement by the data layer, because a Spark verifier only warns.

Why the data layer rather than the caller

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. Binders that do not care can ignore it.

Interaction with transactions (#223)

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?

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.

Not in this PR

  • The runtime. AshSqlite.MultiTenancy — registry, per-tenant connections, lazy migration, LRU residency, quarantine, all_tenants/1, rename/3 — is the follow-up. That is the piece @zachdaniel asked for on the earlier version of this PR, and it is easier to review on its own.
  • Documentation. Deliberately none, here or in the follow-up. The guide is being written once the whole feature has landed, rather than in pieces that contradict each other.
  • Tenant migration tooling. mix ash_sqlite.generate_migrations has no tenant awareness, so the tenant migrations directory is yours to maintain. AshPostgres routes tenanted resources to priv/repo/tenant_migrations; matching that is not attempted here.
  • Per-tenant rollback. AshPostgres has mix ash_postgres.rollback --tenants. There is no counterpart, by choice — forward-only for now.

Tests

mix compile --warnings-as-errors clean, mix credo --strict clean, mix test 192 passing.

…tions?`

`can?(:transact)` has returned false since this data layer was split out of
ash_postgres, and the transactions guide explains why: SQLite allows one write
lock at a time, and a write attempted while another transaction holds that lock
fails immediately rather than queueing. That is a reason to make transactions
opt in, not a reason to leave them unimplemented. SQLite is fully ACID, and
without a transaction a create whose `after_action` hook fails leaves its row
behind with nothing to undo it.

Adds `write_transactions?` to the `sqlite` section, defaulting to false so
nothing changes for existing resources, and implements the callbacks it enables:

  * `transaction/4` opens write transactions as `BEGIN IMMEDIATE`. This is what
    makes `busy_timeout` effective. A deferred transaction takes no lock until
    its first write, so a read-then-write has to upgrade partway through -- and
    SQLite cannot make an upgrade wait, because the snapshot already read from
    may be stale by the time the lock frees. It fails immediately regardless of
    `busy_timeout`. `BEGIN IMMEDIATE` has nothing to upgrade. Read-only
    transactions stay deferred, since they never take the write lock.

  * `in_transaction?/1` answers rather than raising when the repo has no running
    process. `Ecto.Repo.in_transaction?/0` resolves the current dynamic repo
    through the registry and raises when it is absent, which happens whenever
    the repo was reached through `put_dynamic_repo/1` and started under no name
    of its own. Ash asks this before opening a transaction, so it has to be an
    answer.

  * `prefer_transaction_for_atomic_updates?/1` is false. An atomic update is a
    single statement and so already atomic; wrapping it would hold the one write
    lock across the surrounding work and buy nothing.

Refs ash-project#91, and supersedes the work in ash-project#95.

Two notes on what is deliberately not here.

The option composes with functional repos rather than replacing them: the
read/write pool split discussed in ash-project#91 is `repo fn _, :mutate -> WriteRepo; _,
:read -> ReadRepo end` plus this flag on the write side, and the guide documents
the pair.

There is no verifier rejecting `transaction? true` on a resource that has not
opted in, which ash-project#91 proposed and ash-project#95 implemented. It cannot work as specified:
Ash *derives* `transaction? true` from the action rather than only taking it
from the author -- an action using `manage_relationship` gets it automatically --
so the check fires on resources nobody annotated. Compiling it against this
suite flags four existing test resources (Comment, Device, Manager, Post), none
of which mentions `transaction?` at all, and the error asks the author to change
an action they did not write. On a released version it would break existing
applications at compile time on upgrade. Discoverability is handled in the guide
instead.
#
# 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
…they get

Ash now clears the derived `transaction? true` on a resource whose data
layer cannot transact, so a resource without `write_transactions?` reflects
`transaction? false` instead of naming a transaction that never opens.
…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`.

## What context multitenancy means for SQLite

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.

## The binder

`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.

## Why the data layer rather than the caller

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.

## Interaction with transactions

`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?

`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.

## Documentation

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 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.


multitenancy do
strategy(:context)
global?(true)

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.

Welcome to Footgun territory! 🦶🔫

This Resource will just reuse whatever Repo pid is bound, so a downstream application would need to take care with this multitenancy flag. Tempting to verify against it for now tbh.

global? needs some thinking about with how it will work with the database-per-tenant this change unlocks.


assert bound("acme", fn -> global_titles() end) == ["acme's own"]
assert bound("globex", fn -> global_titles() end) == ["globex's own"]
end

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.

This is the confusing behaviour

@C-Sinclair
C-Sinclair marked this pull request as ready for review August 24, 2026 15:25
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