Repository navigation
improvement: manage tenant databases, so strategy :context needs no tenant_repo of its own - #225
C-Sinclair wants to merge 4 commits into
Conversation
b224ec9 to
41cca2e
Compare
41cca2e to
76866fc
Compare
d0dd7ab to
1d6b0be
Compare
… `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.
1d6b0be to
e3f5765
Compare
strategy :context works out of the boxstrategy :context needs no tenant_repo of its own
…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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
|
👋 I feel like this implementation is a lot more complex than we should need.
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 |
|
Full transparency, I did not read 100% of this code yet 😓 Mostly dealing w/ the shape of it 😄 |
#224 added the
tenant_repooption and no implementation of it. This PR adds the runtime behind it, so thatstrategy :contextneeds 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.That is the whole setup. A resource needs only
strategy :context.AshSqlite.MultiTenancy.TenantRepobecomes its defaulttenant_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 mapsa:banda_bonto one file, which quietly puts two tenants in one database. Also why../../etc/xstays one file inside the directory.Migrations— compiles a migration directory once per boot rather than once per tenant.TenantRepo— implementswith_repo/4as well asrepo/3, so a tenant is held open for the length of each statement and eviction cannot close it underneath one.Not in this PR
strategy :contextmultitenancy via a tenant repo #224. The guide covering the whole feature is a separate piece of work.mix ash_sqlite.generate_migrationshas no tenant awareness, so the directory passed as:migrations_pathis yours to maintain. AshPostgres routes tenanted resources topriv/repo/tenant_migrations; matching that is not attempted here.--tenants/--only-tenants/--except-tenants.MultiTenancy.migrate_all/3is the runtime equivalent.mix ash_postgres.rollback --tenants. There is no counterpart, by choice. It is forward-only for now.manage_tenant do ... end. Creation here is implicit, because activation makes the file.rename/3anddelete/2exist as primitives, but nothing calls them from a resource yet.