Skip to content

improvement: manage tenant databases, so strategy :context needs no tenant_repo of its own - #225

Draft
C-Sinclair wants to merge 4 commits into
ash-project:mainfrom
C-Sinclair:feat/multitenancy-engine
Draft

C-Sinclair wants to merge 4 commits into
ash-project:mainfrom
C-Sinclair:feat/multitenancy-engine

Conversation

@C-Sinclair

@C-Sinclair C-Sinclair commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

#224 added the tenant_repo option and no implementation of it. This PR adds the runtime behind it, so that strategy :context needs no tenant repo of your own. That is what you asked for, @zachdaniel: context multitenancy that manages its own storage rather than leaving it to the reader.

children = [
  MyApp.Repo,
  {AshSqlite.MultiTenancy,
   repo: MyApp.Repo,
   dir: "priv/tenants",
   migrations_path: "priv/repo/tenant_migrations"}
]

That is the whole setup. A resource needs only strategy :context. AshSqlite.MultiTenancy.TenantRepo becomes its default tenant_repo, so the compile-time requirement #224 imposes goes away. Naming your own still overrides it, which is what keeps Turso, Litestream or a replica-aware router implementable outside this library.

What's in the box? 📦

  • Registry — one connection per tenant, and only one. A second is refused rather than racing.
  • Connection — opens the file, migrates it, and refuses to serve until both have happened. A migration that raises stops the connection instead of serving an unmigrated database.
  • Manager — activation, an LRU bound on residency that will exceed itself rather than close a tenant with a statement in flight, quarantine for tenants that cannot open, and seal/unseal for draining a node.
  • Database — the tenant-to-filename mapping. Escaped rather than sanitised, so it is injective: sanitising maps a:b and a_b onto one file, which quietly puts two tenants in one database. Also why ../../etc/x stays one file inside the directory.
  • Migrations — compiles a migration directory once per boot rather than once per tenant.
  • TenantRepo — implements with_repo/4 as well as repo/3, so a tenant is held open for the length of each statement and eviction cannot close it underneath one.

Not in this PR

  • Documentation. Deliberately none, as in improvement: support strategy :context multitenancy via a tenant repo #224. The guide covering the whole feature is a separate piece of work.
  • Tenant migration generation. mix ash_sqlite.generate_migrations has no tenant awareness, so the directory passed as :migrations_path is yours to maintain. AshPostgres routes tenanted resources to priv/repo/tenant_migrations; matching that is not attempted here.
  • Mix tasks. No --tenants / --only-tenants / --except-tenants. MultiTenancy.migrate_all/3 is the runtime equivalent.
  • Per-tenant rollback. AshPostgres has mix ash_postgres.rollback --tenants. There is no counterpart, by choice. It is forward-only for now.
  • Tenant lifecycle DSL. AshPostgres has manage_tenant do ... end. Creation here is implicit, because activation makes the file. rename/3 and delete/2 exist as primitives, but nothing calls them from a resource yet.

@C-Sinclair
C-Sinclair force-pushed the feat/multitenancy-engine branch from b224ec9 to 41cca2e Compare August 24, 2026 14:43
@C-Sinclair
C-Sinclair force-pushed the feat/multitenancy-engine branch from 41cca2e to 76866fc Compare August 24, 2026 15:54
@C-Sinclair
C-Sinclair marked this pull request as ready for review August 27, 2026 00:32
@C-Sinclair
C-Sinclair force-pushed the feat/multitenancy-engine branch 4 times, most recently from d0dd7ab to 1d6b0be Compare September 8, 2026 10:57
@C-Sinclair
C-Sinclair marked this pull request as draft October 10, 2026 20:48
… `tenant_repo` of its own

Follows ash-project#224, which added the `tenant_repo` option and no implementation of it.
This PR adds the runtime, so that `strategy :context` needs no tenant repo of
your own -- what @zachdaniel asked for on ash-project#224: context multitenancy that manages
its own storage rather than leaving it to the reader.

    children = [
      MyApp.Repo,
      {AshSqlite.MultiTenancy, repo: MyApp.Repo, dir: "priv/tenants",
       migrations_path: "priv/repo/tenant_migrations"}
    ]

`AshSqlite.MultiTenancy.TenantRepo` becomes the default `tenant_repo` for a
`strategy :context` resource, so `VerifyTenantRepo`'s compile-time requirement to
name one goes with it. Naming your own still overrides it, which is what keeps
Turso, Litestream or a replica-aware router implementable outside this library.
The default implements `with_repo/4` as well as `repo/3`, so the tenant is held
open for the length of each statement.

Named after `AshPostgres.MultiTenancy` rather than inventing a word for it.

- `AshSqlite.MultiTenancy.Registry` -- one connection per tenant, and only one.
- `AshSqlite.MultiTenancy.Connection` -- opens the file, migrates it, and refuses
  to serve until both have happened.
- `AshSqlite.MultiTenancy.Manager` -- activation, an LRU bound on residency that
  will exceed itself rather than close a tenant mid-statement, quarantine for
  tenants that cannot open, and seal/unseal for draining a node.
- `AshSqlite.MultiTenancy.Database` -- the tenant-to-filename mapping, escaped
  rather than sanitised so that it is injective: sanitising maps `a:b` and `a_b`
  onto one file, which puts two tenants in one database.
- `AshSqlite.MultiTenancy.Migrations` -- compiles a migration directory once per
  boot rather than once per tenant.

`all_tenants/1` answers what AshPostgres asks the application for through
`Repo.all_tenants/0`; here it is derived, because a tenant is a file. Residents
are unioned in, since SQLite creates the file on the first write.

`rename/3` moves a tenant's database, sidecars included. Without it a renamed
tenant keeps none of its data: the new name addresses a file that does not
exist, and the next request quietly creates an empty one. The sidecars are not
belt-and-braces -- a write is still in the WAL when the rename happens.

No documentation in this PR, as with ash-project#224. The guide covering both is a
separate piece of work.
Four defects in the tenant runtime, each found by probing rather than reading,
and each with the test that fails without the fix.

`Binds.forget/2` deleted the closing mark along with the bind count, so the
`held_open_by_caller?` guard in `close/3` was defeated by the line above it.
`rename/3` and `delete/2` both document that they hold a tenant closed across
the file operation; neither did, from `close/3`'s return until their own `after`.

Measured, for rename: a write addressed to `acme` in that window commits into
`globex`'s database. For delete it is worse -- an activation there leaves the
tenant *resident* across the unlink, serving reads and accepting writes against
an inode with no directory entry, all of it discarded when the connection
closes. `all_tenants/1` unions residents, so `migrate_all/3` would then migrate
a tenant that no longer exists.

`forget/2` now drops only the binds and the last-used mark. The closing mark is
lifecycle state owned by whoever took it, and `close/3` already clears its own.
`delete/2` now takes a mark and holds it across the close and the unlink, as
`rename/3` does -- it never took one at all, so the `forget/2` change alone left
it broken.

`await_quiescence/3` counted down and then closed regardless. `rename/3` calls
`close/3` without `force`, so a busy tenant had its file moved out from under a
live statement, which keeps the old inode and commits into the destination.

`close/3` now returns `{:error, :busy}`; `force: true` closes regardless, which
is what eviction and `delete/2` want once they know nothing is bound. `rename/3`
propagates it. `migrate_all/3` treats it as success and leaves the tenant
resident -- `close_after?` frees residency, it is not part of migrating -- and
forwards `:grace_ms`.

`grace_ms` is a deadline rather than a loop count. `Process.sleep(1)` sleeps at
least a millisecond, so it meant between one and several times what it said.

`Binds.bound/2` checked the closing mark and then incremented, so a bind could
slip between an eviction's choice of candidate and its close. It now increments
first and backs out if it lost: the two are separate ETS objects and no single
operation covers both, but publishing the increment before the check means a
closer can never read zero for a bind that goes on to proceed, which is the
direction that loses data.

That only holds because eviction cooperates, so `evict_if_needed/1` marks its
candidate closing *before* re-reading the count, and backs out if a bind got in.
Neither half is sufficient alone.

`put_dynamic_repo/1` binds one repo *module*. The default tenant repo binds the
mutate repo, while reads resolve `repo(resource, :read)` -- so a `repo` function
returning different modules issued its reads unbound, against whatever database
that module was configured with. `VerifyRepo` returns early for any function
repo, so nothing checked it.

`VerifyTenantRepo` refuses the split, but only for a resource using the default
tenant repo. A tenant repo of its own is told whether each statement is a `:read`
or a `:mutate`, so routing reads to a replica is something only it can do
correctly, and forbidding that outright would be wrong.

`connection_for/2` on a repo with no fleet running raised `unknown registry:
MyApp.Repo.TenantRegistry`, naming an implementation detail instead of the
omission. It now names what is missing and shows the child spec, while
reraising untouched anything from a fleet that is actually running.

Tests promoted from the probes that found these: concurrent activation of one
cold tenant sharing a connection, statements surviving eviction churn under
contention, isolation holding across that churn, a tenant reused after a delete,
and the Ash-level create/read/update/destroy and bulk-create paths, which had no
coverage through Ash at all.
Same pass as the previous commit, over the runtime and its tests. Also removes
two comments that had been left duplicated by earlier edits, in
`AshSqlite.MultiTenancy.Registry` and the fleet test's bound-tenant helper.

Comment-only; no behaviour change.
@C-Sinclair
C-Sinclair force-pushed the feat/multitenancy-engine branch from 1d6b0be to e3f5765 Compare October 10, 2026 21:04
@C-Sinclair C-Sinclair changed the title improvement: manage tenant databases, so strategy :context works out of the box improvement: manage tenant databases, so strategy :context needs no tenant_repo of its own Oct 10, 2026
…it with `&&`

Dialyzer reports the `&&` as a guard that can never succeed, because `Process.sleep/1` always returns `:ok`.
first opened.
"""
@spec all_tenants(module()) :: [String.t()]
def all_tenants(repo) do

@C-Sinclair C-Sinclair Oct 10, 2026 •

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.

AshPostgres asks the application for this through Repo.all_tenants/0. Here it is derived from the files in :dir, because a tenant is a file.

We also include active residents, because SQLite only creates the file on the first write, so a tenant activated but hasn't written yet would end up being skipped by migrate_all/3.

:ok | {:error, :busy | :no_database | :target_exists | File.posix()}
def rename(_repo, tenant, tenant), do: :ok

def rename(repo, from, 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.

Without rename/3, a renamed tenant keeps none of its data: the new name addresses a missing file, and the next request creates an empty one. Both names are marked closing for the whole move, and close/3 returns {:error, :busy} while a statement is in flight, so nothing can write into either file mid-move.


# Between a write and a checkpoint the sidecars hold committed data the database
# file does not, so a move must take them along.
defp move(source, target) 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.

The sidecars move too, because committed writes sit in the -wal file until a checkpoint. The test "carries the tenant's data to the new name" asserts the -wal file exists before renaming, so it fails if the sidecars are left behind.

# database, unbound, with nothing raising. Only checked for the default: a tenant
# repo of its own sees `type` and can bind a replica deliberately.
@impl true
def verify(dsl) 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.

put_dynamic_repo/1 binds one repo module, and the default tenant repo binds the :mutate one. A repo function returning a different module for :read would issue reads unbound, so this fails the compile. A resource naming its own tenant_repo is exempt, because it sees each statement's type and can bind a replica itself.

@zachdaniel

Copy link
Copy Markdown
Contributor

👋 I feel like this implementation is a lot more complex than we should need.

  1. We can start a dynamic supervisor from the repo's child spec based on configuration, so possibly no need for a new supervisor, and read tenants: [dir: "...."] from the repo config so all config is one place.
  2. If we just have one process per tenant that owns the lifecycle (i.e a single genserver) then I think we can collapse all of the registry/connection/connection supervisor/binds/manager stuff into a single place? The genserver could stop on idle.
  3. If we do that we can get rid of all of the LRU logic. If someone needs a hard limit we can cap it via max_children on the dynamic supervisor.
  4. Cut quarantine, as it makes any transient failure prevent a retry from fixing it.
  5. Cut seal/unseal logic, I can see a case for it but I think its extra complexity not necessarily needed at least for an implementation, and it could also be implemented at the action level or even more likely at the routing/ingestion layer if you need to drain connections.
  6. I'd say we can let them handle deleting, ash_postgres doesn't delete tenant schemas for example. Just too risky to even have that code around. We can have renaming logic on an opt-in basis. And nothing actually calls the rename stuff either, maybe simpler to work the feature set in later?
  7. I see we're limiting to a single connection, but with the change to have people raise their repo timeout higher than the busywait, I don't think that is a hard requirement anymore? We could serialize writes and allow parallel reads? Not sure.
  8. Couldn't we do something like put @behaviour AshSqlite.TenantRepo on AshSqlite.MultiTenancy. Info.tenant_repo/1 then defaults to {AshSqlite.MultiTenancy, []}
  9. Couldn't the registry just be via: {Registry, keys: :unique}
  10. MultiTenancy.Database exist only to serve all_tenants which is only for migrate_all. In AshPostgres we provide users a snippet of how to do this themselves considering that its hard to predict what they actually want in practice. If I'm reading the code right, opening a tenant database already migrates it, so can we just get rid of that rigging and let them do it themselves if they need to?
  11. MultiTenancy.Migrations could probably be replaced by a few lines of code in the supervisor's init and just stored there.
  12. I think a lot of the configuration could be simplified (like an encryption key) by just having the user do something like this in their repo:
defmodule MyApp.Repo do
  use AshSqlite.Repo, otp_app: :my_app

  def init(type, config) do
    if tenant = config[:tenant] do
      super(type, Keyword.put(config, :key, MyApp.Keys.fetch!(tenant)))
    else
      super(type, config)
    end
  end
end

@zachdaniel

Copy link
Copy Markdown
Contributor

Full transparency, I did not read 100% of this code yet 😓 Mostly dealing w/ the shape of it 😄

This branch has not been deployed

No deployments
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