Working with tenants
How to give a tenant its schema and a quota, move it to another node, archive a node's tenants and ask one question of all of them. Its calls to the router name it as localhost:8090, with the token root, as the tutorial does.
A tenant with its schema
A tenant is created empty. Applying its schema afterwards took the nodes' data token, which reaches every tenant on every node, so the app that creates tenants -- one an organisation signs up -- held it. Hand the schema to the router instead, and the app needs the router's token alone:
curl -X PUT localhost:8090/_shard/tenants/acme -H 'Authorization: Bearer root' \
-d '{"schema":"create collection notes (owner text @hash, title text)"}'
# {"tenant":"acme","node":"n1","schema":{"kind":"schema","applied":true,...}}- Applied on the node with the admin token the router holds for
it (
POST /_admin/tenants/<t>/schema), as/_schema/applyapplies a description: one block, synced as a write is. - With its schema or not at all: a schema that does not read is refused (400) before anything is placed, and one the node refuses takes the tenant back off the node and out of the directory.
- Under leases it is applied once the node's lease names the tenant, since the node takes no write before; a replica follows it as it follows every write.
Quotas
Tenants share a node: its disk, its memory, its threads. A quota keeps one tenant from taking all of them. Set defaults on the node, and a tenant's own through the router, which sends it wherever the tenant goes.
# every tenant on this node, unless its own quota says otherwise
fenec-server --dir /data/n1 --admin-token a1 \
--tenant-max-bytes 1G --tenant-max-rows 1000000 \
--tenant-max-streams 50 --tenant-max-held 200 --tenant-max-requests 16 \
--tenant-writes-per-sec 500 --tenant-write-bytes-per-sec 8M
# a tenant on a plan of its own: set as it is created ...
curl -X PUT localhost:8090/_shard/tenants/acme -H 'Authorization: Bearer root' \
-d '{"schema":"create collection notes (title text)","quota":{"bytes":104857600,"rows":50000}}'
# ... or changed later: the whole set, 0 for none, left out for the node's
curl -X PUT localhost:8090/_shard/tenants/acme/quota -H 'Authorization: Bearer root' \
-d '{"bytes":1073741824,"rows":0,"writes":100}'
# what it is held to, and what it uses
curl localhost:8090/_shard/tenants/acme/quota -H 'Authorization: Bearer root'
# {"tenant":"acme","open":true,"set":{"bytes":1073741824,"rows":0,"writes":100},
# "quota":{"bytes":1073741824,"rows":0,"streams":50,...},
# "usage":{"bytes":48211,"rows":312,"streams":1,"requests":0,"held":0}}What each quota bounds, and what a write past one is answered, are in the reference; why a quota bounds what it does, and what it costs, in the design.
Moving a tenant
Move a tenant to another node with one call. Reads keep working throughout; writes are asked to retry for the moment the copy takes.
curl -X POST localhost:8090/_shard/tenants/acme/move -H 'Authorization: Bearer root' \
-d '{"to":"n2"}'
# {"tenant":"acme","from":"n1","to":"n2","bytes":58685634,"ms":267,"source_removed":true}- Writes during a move get 503 with
Retry-After: 1, and"retry_after": 1in the body, which a replica's sync waits out (Sync). - Subscribers reconnect and, if they were caught up, carry on without a reseed.
- A failed move puts everything back on the source.
The steps of a move, and how it resumes
- The directory marks the tenant
moving; it is still routed to the source. - The source freezes it: writes in flight finish, new ones get
503 with
Retry-After: 1. Reads go on being served. - The image is copied from the source and installed on the target.
- The directory points the tenant at the target, in one statement.
- The source copy is deleted — once every standby
router keeping up has been sent the new placement, a second at most,
so one that forwards from it sends the tenant's requests to the target
rather than to a node that no longer holds it. Its open subscriptions end with an
errorevent, and the client reconnects through the router to the target. A client that had seen everything resumes from its cursor without a reseed: the change sequence travels in the image and the source was frozen, so nothing happened in between. One that was behind is reseeded, as after any restart — the target has the sequence but not the history before it.
The router refuses nothing itself during a move, so there is no window in
which the tenant is nowhere — only one in which writes are asked to retry. If a
step before the flip fails, the source is thawed and the directory put back. A
router that stops mid-move comes back with the tenant still served from the
source and marked moving; moving it again replaces whatever copy the
interrupted attempt left on the target, which is stale by definition, since the
directory never pointed at it.
Archiving a node
Every tenant of a node is archived by one process over one connection to the node — not a connection, a thread and an open tenant each — an archive a tenant under a root, which restores a tenant or the whole node to a moment.
fenec archive --tenants http://n1:8081 /backups/n1 --token "$REPL" --image-every 1h --keep 7d
fenec verify --tenants /backups/n1
fenec restore --tenants /backups/n1 /data/n1 --to 2026-09-22T10:14:59Z- Moved or deleted: a tenant that leaves the node is no longer followed and its archive is kept, noted gone, and noted back if it returns; the target node's archiver takes it up from its image. A restore of the node to a moment it was away leaves it out, as it does a tenant deleted before.
- Restored in place: a tenant restored on its primary's node keeps its lineage, and fails over as before once its copies have taken the restored file (automatic failover).
- Replicas and standbys can be archived the same way: their tenants' writes are the primary's, numbered alike, and a promotion goes on in the same archive.
The node needs --replication-token. What it costs and how
the archive follows tenants made, closed, moved, restored and promoted:
Archiving every tenant of a
node.
A query across tenants
An operator's question about every tenant — how many orders each took since yesterday, the twenty newest sign-ups of all of them — is one request to the router, which runs the read on each tenant and merges the answers as the request says.
# each tenant's count, a row each with its tenant
curl -X POST localhost:8090/_shard/query -H 'Authorization: Bearer root' \
-d '{"query":"get orders select count() as n where at >= $1","params":["2026-10-11T00:00:00Z"]}'
# {"rows":[{"tenant":"acme","n":31},{"tenant":"globex","n":7}],"tenants":2,"answered":2,"failed":[],"ms":2}
# the twenty newest of all of them: each tenant's twenty, merged
curl -X POST localhost:8090/_shard/query -H 'Authorization: Bearer root' \
-d '{"query":"get users select email, created order created desc limit 20",
"merge":{"order":["created desc"],"limit":20}}'
# counts added up by day, over the tenants whose names start with eu-
curl -X POST localhost:8090/_shard/query -H 'Authorization: Bearer root' \
-d '{"query":"get orders select bucket(at, 1d) as day, count() as n group day",
"merge":{"sum":["n"],"group":["day"]},"prefix":"eu-"}'
# the first twenty names of all of them in Turkish's order, a field
# declared `name text collate tr nocase`
curl -X POST localhost:8090/_shard/query -H 'Authorization: Bearer root' \
-d '{"query":"get people select name order name limit 20",
"merge":{"order":[{"column":"name","collate":"tr","nocase":true}],"limit":20}}'- The router's token alone. A user's token, bound to its tenant, and the nodes' data token are refused (401).
- Reads only. A statement that writes is refused on every node (403) and writes nothing.
- Never cut. A tenant that fails or times out fails the query,
named;
"partial": trueanswers the others' rows with the failures listed. The rows and bytes gathered are bounded, and a query past either is refused (413). - Each answer is its tenant's own. Tenants are read at different moments: there is no one moment across them.
How a query across tenants is merged, and its cost
Tenants are the isolation boundary, and this crosses it, so it is a
/_shard/ endpoint behind the router's --token. A
JSON Web Token is refused there as any token other than the router's is:
the router holds no keys, and a token bound to one tenant must not reach
the others through it. The router asks each tenant through its node's
POST /_admin/tenants/<t>/query with the node's admin
token, which it holds already: a token that can take a tenant's whole
image (GET /_admin/tenants/<t>/file) learns nothing more
by reading it, where the nodes' data token, which nothing else here needs,
would reach every tenant and write. The node refuses a statement that
writes before it runs, and runs the rest in read-only mode besides.
The router still parses no FenecQL. The statement and its parameters go to every node as they came, and what is done with the rows is said in the request, never read out of the query:
| Member | Does |
|---|---|
query, params | the statement each tenant runs, as /query takes it |
tenants or prefix | the tenants named, or those whose names start so; every tenant in the directory without either. A name the directory does not hold is refused (404), nothing asked |
merge | none: the rows one after another, tenants in name order, each with its tenant's name first; {"order": [..], "limit": n}; {"sum": [..], "min": [..], "max": [..], "group": [..]} (below) |
column | the name of the tenant's column, tenant by default |
partial | answer the rows of the tenants that answered, the failures listed beside them |
timeout_ms | the query's one timeout, under the router's --fanout-timeout (30 s) |
Three merges, each exact from the tenants' answers alone:
- One after another, the default: every row, its tenant named.
- An order: a k-way merge by the named columns, each
ascordesc, of rows each tenant gives in that order. With each tenant's ownorder ... limit 20, the first twenty of the merge are the first twenty of all of them. The router checks each tenant's rows come in the order, and a tenant whose do not — its statement orders otherwise — fails, never merged wrong. Values compare as the engine compares them, text by its bytes; a timestamp travels as ISO text, which leaves its milliseconds out where they are 0, so a text that is one compares as its time. Ties go in tenant name order. A key whose text is not ordered by its bytes says how it is (below). - A fold:
sum,minandmaxof the named columns, by thegroupcolumns, groups in order. These are the aggregates that combine across tenants: a count is a sum of counts. An average of averages is not an average, so there is none: sum its sum and its count. Every column of a row must be grouped by or folded, or the tenant fails: a column the merge would drop is an answer cut. A sum of whole numbers past 64 bits is refused, as the engine refuses one. Amin,maxorgroupcolumn may be an object naming its text order as an order's key is: a tenant'sminover a collated field is the collation's least, so the least of those is too, and anocasegroup is one for every text folding to it, under the first tenant's text.
A column whose text the tenants compare otherwise than by its bytes is
an object in place of its name: {"column": "name", "collate": "tr",
"nocase": true, "desc": true} where "name desc" would
be. The router reads no schema, so what the field declares —
collate tr or und, nocase — or what
the statement's order name collate tr names is said again
here, and the column compares by the engine's own rule: the collation over
the case folds, the texts that fold to one tied, and a text read as text
even where it looks like a timestamp. "nocase": true folds as
a field in the column's collation does, Turkish's under tr
(I the capital of ı) and Unicode's otherwise;
where the statement's collate names another than the field's
own, "nocase" names the field's, as "nocase":
"und" for a plain nocase field ordered collate
tr. A collation other than tr or und is
refused (400), named, and desc is an order's alone. Not told,
the router compares bytes: the tenants whose rows come in another order
fail, and a min or max is the bytes' least or
greatest of the tenants' own.
A tenant that fails — its node unreachable, its call past the timeout,
an answer that does not fit the merge, or one the node refused — is named
with its node, the status and why. Without "partial": true
the query fails with every failure named; the first stops the asking, and
the answer says how many tenants were not asked. Its status is the
tenants' own where they all failed alike — a statement every node refused
is 400 or 403 — and 502 otherwise. A tenant moved while the query runs is
asked again on the node the directory names now; one deleted meanwhile is
a failure. The tenants come from the directory as the query begins: one
created after that is not in it.
The rows gathered from the tenants together are bounded by
--fanout-max-rows (100 000) and the bytes of their answers by
--fanout-max-bytes (64 MiB), counted as the answers arrive: a
query past either is refused (413), its partial rows or not, and the
asking stops. The calls go out over the router's connections to the nodes,
--fanout-concurrency (16) at once, the tenants taken node by
node in turn, and each tenant's answer is read for the merge on the thread
that fetched it, beside the calls still out.
Each tenant's answer is one statement's, as /query's is.
Tenants are separate databases read at different moments, so two answers
are of no one moment, and a write landing in one while the query runs may
or may not be in its answer. A read that opens a tenant opens it on its
node, which closes it again once idle.
A query across tenants is a span of the router's trace, a span a node
under it, and each tenant's read on its node under that node's span, all
with the request's id; the router counts them in
fenec_router_fanouts_total and two more (Monitoring), no label naming a tenant.
Each is a line of the router's audit log; the nodes' reads for it are not,
as a read is not.
Over 100 tenants of 200 rows on three nodes, on one machine
(make shard-bench ARGS=fanout):
| a count, summed | the 20 newest, merged | the 20 first names, collate tr nocase | |
|---|---|---|---|
POST /_shard/query | 1.15–1.23 ms | 1.35–1.39 ms | 1.33–1.48 ms |
| each tenant through the router, in turn | 4.28–5.16 ms | 5.11–5.45 ms | 4.34–5.62 ms |
| each tenant through the router, 16 at once | 1.92–1.98 ms | 1.86–2.17 ms | 1.90–2.21 ms |
p50 over 300 rounds in three runs, the names' in six beside other work on the machine; the client asking 16 at once merges nothing. A merge in a collation over case folds costs what one by a timestamp does. A request forwarded to one tenant costs what it did.