Skip to content

Query every tenant of a storage id with *.table - #8

Merged
jessestimpson merged 7 commits into
mainfrom
claude/cross-tenant-queries
Sep 26, 2026
Merged

jessestimpson merged 7 commits into
mainfrom
claude/cross-tenant-queries

Conversation

@jessestimpson

Copy link
Copy Markdown
Contributor

select _tenant, count(*) from customer.*.orders group by _tenant reads
every tenant of the storage id, and *.orders does the same for the
session's storage id (in the TUI, the one being browsed). Tenants of one
storage id share a schema, so one query over all of them makes sense.

Every row gets a _tenant field with its tenant's name, usable anywhere
a field is. Conditions on _tenant never reach the database. They pick
which tenants to read from the storage id's tenant list, so
where _tenant = 'acme' touches no other tenant. _tenant outside a
query across tenants is refused.

Planner.split/1, factored out of the GROUP BY planner, separates what
each tenant reads (the filtering) from what runs over all the rows
afterwards (grouping, ordering, limit, projection). Efsql.Fanout plans
each tenant's read separately, because a tenant not opened since a deploy
can lack a newly migrated index. The executor reads every tenant in a
single FoundationDB transaction on the database, which ecto_foundationdb
now supports: each tenant's async reads are started and then awaited
together, so they are pipelined and see one consistent snapshot. The
retry limit is 2, so a read that can't finish within the 5-second
transaction limit fails with a message saying how to narrow it instead
of retrying forever. At most 100 tenants are read by default, adjustable
with :max_tenants.

The TUI session also accepts tenant-qualified queries when no tenant is
active.

Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko

`select _tenant, count(*) from customer.*.orders group by _tenant` reads
every tenant of the storage id, and `*.orders` does the same for the
session's storage id (in the TUI, the one being browsed). Tenants of one
storage id share a schema, so one query over all of them makes sense.

Every row gets a `_tenant` field with its tenant's name, usable anywhere
a field is. Conditions on `_tenant` never reach the database. They pick
which tenants to read from the storage id's tenant list, so
`where _tenant = 'acme'` touches no other tenant. `_tenant` outside a
query across tenants is refused.

Planner.split/1, factored out of the GROUP BY planner, separates what
each tenant reads (the filtering) from what runs over all the rows
afterwards (grouping, ordering, limit, projection). Efsql.Fanout plans
each tenant's read separately, because a tenant not opened since a deploy
can lack a newly migrated index. The executor reads every tenant in a
single FoundationDB transaction on the database, which ecto_foundationdb
now supports: each tenant's async reads are started and then awaited
together, so they are pipelined and see one consistent snapshot. The
retry limit is 2, so a read that can't finish within the 5-second
transaction limit fails with a message saying how to narrow it instead
of retrying forever. At most 100 tenants are read by default, adjustable
with `:max_tenants`.

The TUI session also accepts tenant-qualified queries when no tenant is
active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
transaction_too_old is retryable, so erlfdb's transactional loop retries
it. A read too big for one transaction would run again, just as slowly,
and fail the same way. The retry limit of 2 still meant three full reads
before the error, and it also capped retries for genuinely transient
errors.

The reads now run in a function that rescues transaction_too_old (and
transaction_timed_out) inside the transaction and re-raises it as
Unsupported. erlfdb's loop only retries erlfdb errors, so the query
fails on its first attempt, and other errors keep FoundationDB's normal
retries. The retry limit is gone.

A new test opens a transaction with an ancient read version, which
forces transaction_too_old, runs a cross-tenant query inside it, and
checks that the transaction function ran only once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
A query across tenants reads them all in one FoundationDB transaction,
which has to finish within five seconds. With `\set tenant_batch 25` in
the CLI or TUI, or `tenant_batch: 25` for Efsql.qall/3, the tenants are
read 25 at a time, one transaction per batch, and the tenant limit no
longer applies. The plan's access node becomes {:batches, [fan_out]}, and
the executor runs the batches in turn. Grouping, ordering and the limit
still apply to all the rows together.

Batching is opt-in because the result is no longer one snapshot: each
batch sees the database at a different moment. The CLI and TUI footers
say how many transactions a result came from (Fanout.transactions/1).
The too-many-tenants and too-big-for-one-transaction errors now point to
the setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
The count already says the result spans several snapshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
With tenant_batch set, up to four batches are now read at once. Each
batch runs in its own process and transaction, and results come back in
batch order. The number is configurable with `batch_concurrency:` or the
`:efsql, :batch_concurrency` setting. The batches node carries it:
{:batches, batches, concurrency}.

Executor.map_concurrently/3 runs the batches. The first failure stops
the remaining work and is re-raised in the caller as the original
exception, throw or exit. A plain Task.async_stream would turn it into a
linked exit, which the CLI's rescue can't catch, so a "too big" batch
would have crashed the session instead of printing its message. With a
concurrency of 1, everything runs in the calling process as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
The executor adds _tenant to every row of a query across tenants, and
nothing removed it when the select list didn't name it. So
`select name from *.users` returned _tenant as well. Fanout now appends
a projection for ungrouped queries that don't select it. Grouped queries
only keep _tenant as a group field, which split/1 already projects away.

Also matches %Logical.Select{} on three struct updates that Elixir 1.19's
type checker warned about: Planner.rows/2, Fanout.plan/3 and
Parser.grouped/2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
Elixir 1.19 no longer compiles .yrl files by default and warns that the
project must list :yecc. It still compiled the grammar, but with a
warning on every build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko
@jessestimpson
jessestimpson merged commit 2d2dbec into main Sep 26, 2026
1 check passed
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