Tenants and sharding

When one machine is not enough, give each tenant a file of its own, spread the files over nodes, and put fenec-shard in front.

Get started

Start two nodes and a router, tell the router about the nodes, create a tenant, and use it through the router.

# two nodes, each a directory of tenant files
fenec-server --dir /data/n1 --http 127.0.0.1:8081 --admin-token a1
fenec-server --dir /data/n2 --http 127.0.0.1:8082 --admin-token a2

# the router, and its directory of which tenant lives where
fenec-shard --listen 127.0.0.1:8090 --directory shard.fenec --token root

curl -X PUT localhost:8090/_shard/nodes/n1 -H 'Authorization: Bearer root' \
     -d '{"addr":"127.0.0.1:8081","token":"a1"}'
curl -X PUT localhost:8090/_shard/nodes/n2 -H 'Authorization: Bearer root' \
     -d '{"addr":"127.0.0.1:8082","token":"a2"}'
curl -X PUT localhost:8090/_shard/tenants/acme -H 'Authorization: Bearer root'
# {"tenant":"acme","node":"n1"}

curl -X POST localhost:8090/t/acme/query -d '{"query":"create collection notes (title text)"}'
curl -X POST localhost:8090/t/acme/notes -d '{"title":"hello"}'
curl localhost:8090/t/acme/notes
  • Same API. Under /t/<tenant> everything works as on a single database; a client only changes its base URL.
Details

A single fenecdb is bounded by one machine's memory and one writer. Past that, the data is split by tenant: each tenant is a file of its own, a node holds many of them, and fenec-shard knows which node holds which.

Below /t/<tenant> the surface is exactly the HTTP endpoint of a single database — routes, /query, /batch, subscriptions. A client changes its base URL and nothing else; the sync layer included:

const db = await sync({ url: 'http://127.0.0.1:8090/t/acme', shapes: [...] });

A file per tenant

Each tenant is its own file, chosen by the request path and never by the query. Tenants cannot see each other's rows, and ids, change numbers, search ranking and lookup all stay per tenant.

Details

The tenant is picked by the request path, never by the query text, so nothing rewrites a query to add tenant = .... Two tenants sharing a collection would then read each other's rows. A file each keeps them apart by construction, and the same decision settles four things a shared file would have had to solve:

  • Ids are counted per file, so they never collide and a moved tenant keeps its own.
  • The change sequence behind /changes is one number per file, so a client's cursor stays a plain number.
  • BM25 statistics — document count, average length, document frequency — are the tenant's own, which is what ranking inside a tenant should mean.
  • lookup never crosses a file, so it stays one bucket probe per parent.

Why tenants and not hash partitioning

A tenant lives whole on one node, so every query runs where all its data is and means what it means on a single database. The cost: one tenant has to fit in one node's memory.

Details

With a tenant whole on one node, every query runs where all of its data is: near, match, order, count and lookup keep their single-database meaning, and the router never parses a query. Spreading one collection across nodes would need a merge for every read shape — and global statistics before match could rank anything, because per-node IDF gives scores that do not compare and a merged order that is quietly wrong. That is a different feature, and the case for it is a single tenant that outgrows a node. Until then, a tenant has to fit one node's memory.

The node: fenec-server --dir

A node is one fenec-server serving a directory of tenant files instead of one file. The path names the tenant: /t/acme/ is acme.fenec.

fenec-server --dir /data/n1 --http 127.0.0.1:8081 --admin-token a1
curl -X POST 127.0.0.1:8081/t/acme/query -d '{"query": "get notes limit 5"}'
  • Opened on first use, closed when idle (--idle-close, 300 s by default).
  • Unknown tenants are 404. A request never creates one.
  • /_admin/ is how the router drives the node; it needs --admin-token.
  • A user's token is bound to its tenant. A JSON Web Token reaches /t/acme/ only when its tenant claim names acme (Tokens).
Details

--dir serves a directory of <tenant>.fenec files instead of one file. It is still one process per directory — the single-writer rule is per file, and one process owning all of them is what keeps it. --http is required. The tenant is looked up for every request — so one created a moment ago is there, one closed as idle reopens, one deleted meanwhile answers 404, and one frozen for a move refuses writes while its reads go on.

  • Opened on first use. Open cost is proportional to the file, so a node does not spend its start-up on tenants nobody asks for. A tenant that has not been created is 404 — a request never brings one into being, or a typo would make an empty database.
  • Closed when idle. --idle-close <s> (default 300) syncs, checkpoints when there is a vector index, and closes a tenant nothing has touched for that long. One with an open request or subscription is never closed.
  • --max-memory covers the open tenants together. Opening one more over the ceiling first closes idle tenants, least recently used first. With everything busy the open goes ahead anyway: refusing a read because other tenants are busy would turn a memory warning into an outage. A write that grows a tenant is refused once that tenant alone is past the ceiling, over either listener.
  • Shutdown syncs every open tenant and checkpoints the ones with a graph, holding their write locks until exit.

The router drives the node through /_admin/, which answers only to --admin-token and is off (404) without it — a client allowed to read and write a tenant must not be able to delete it or take another tenant's image:

RouteDoes
GET /_admin/statstenant count, bytes on disk, memory of the open ones
GET /_admin/openthe open tenants: name, memory, frozen
GET /_admin/tenantsnames on disk
GET /_admin/sizeseach tenant's bytes on disk, which the router's GET /_shard/tenants?bytes joins into its list
PUT /_admin/tenants/<t>create an empty tenant (409 if it exists)
DELETE /_admin/tenants/<t>end its streams, close it, remove the file
POST /_admin/tenants/<t>/schemaa schema description, applied as /_schema/apply applies it: what the router sends for a tenant created with a schema
POST /_admin/tenants/<t>/freezerefuse writes and wait for the ones in flight
POST /_admin/tenants/<t>/thawaccept writes again
GET /_admin/tenants/<t>/filethe whole image, graph included
PUT /_admin/tenants/<t>/fileinstall an image as a new tenant
POST /_admin/tenants/<t>/promotea replica's file takes writes: its history forks there
POST /_admin/tenants/<t>/followthe file follows {"from": "http://host:port"}, kept in <t>.follows beside it, or without it the node's --replica-of

An image is replayed in memory before it is written, so a truncated transfer is refused rather than installed, and the file appears by rename, never half-written. Tenant names are [a-z0-9_-], 1 to 64 characters: they become file names, and that set means the same thing on every file system.

The router: fenec-shard

The router looks up which node holds a tenant and passes the request there unchanged. Its only state is a small directory of nodes and tenants, managed under /_shard/.

  • New tenants go to the node with the least data on disk, unless you name one.
  • A page on another origin needs --http-cors on the router, as it would on a node: fenec-shard --http-cors https://app.example.
Details

The router reads a request's head and body, looks the tenant up, and forwards it byte for byte — the original target, not a re-encoding of it. An answer with a length is read and passed back on a kept-alive connection; one without is a subscription, copied as it arrives until either side closes, so the stream format lives in one place, the node.

Connections to a node are kept and reused, and each is looked at before a request goes out on it: one the node closed for its silence (--idle-timeout) is let go of. A request that fails before it is wholly sent goes again on a fresh connection. One sent whole whose connection ends before an answer goes again only where running it twice is harmless — a GET, HEAD or OPTIONS, or a write with an Idempotency-Key, which the node makes once (Retrying a write). Any other write is answered 502, since the node may have run it: a client that sends it again should send it with a key.

Its only state is the directory: two collections in a fenecdb file of its own, nodes and tenants. Every change is written and synced before it takes effect, and a tenant's node and state change in one statement, so there is no moment on disk where a tenant belongs to both nodes or to neither. The node admin tokens stay in it; GET /_shard/nodes does not return them.

RouteDoes
GET /_shard/nodesname and address of each node
PUT /_shard/nodes/<n>{addr, token}; checked against the node before it is recorded
DELETE /_shard/nodes/<n>refused while the node holds a tenant
GET /_shard/tenantsname, node and state of each tenant; with ?bytes, its bytes on disk, asked of each node
PUT /_shard/tenants/<t>place and create; {node} to choose, otherwise the node with the fewest bytes on disk; {schema}, FenecQL create collection text, applied there before the tenant is answered (below)
DELETE /_shard/tenants/<t>delete on the node, then forget
POST /_shard/tenants/<t>/move{to}; see below
GET /_metricswhat the router answered and forwarded, the nodes' time and the ones it could not reach, and its moves, in Prometheus's text format (Monitoring)

Placement goes by disk rather than memory: memory counts only the open tenants, and which ones are open depends on who asked last.

A page on another origin — an app, the sync layer, FenecHttp.live, the studio served by a node — reaches its tenants through the router once the router has --http-cors <origin>, read as fenec-server reads it (Security): * or one origin. The router answers a browser's preflight itself, before it looks the tenant up, and puts the CORS headers on every answer it gives. Its own — an unknown tenant's 404, a standby's 503, an unreachable node's 502, the 503 past --max-connections, the 401 of /_shard/ without its token — reach the page as the refusals they are; without the headers a browser hands the page a network error for each, and the sync layer backs off from what was an answer. On a node's answer the router's headers take the place of the node's, so the page is told the rule the router's preflight told it, and never two Access-Control-Allow-Origins, which a browser refuses whole. The 503s carry their wait as Retry-After and as retry_after in the body, as a quota's refusal does. Without the flag the router sends none of its own and passes a node's through as the node wrote them, and a preflight goes to the node.

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/apply applies 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}}
QuotaBoundsPast it
bytesthe tenant's file, as written507, the write put back whole
rowsthe documents of its collections, together507, the write put back whole
streamssubscriptions, /_changes waits and Fenec-After waits429, Retry-After: 1
heldwrites held until they can write (Fenec-Wait): workers waiting for jobs429, Retry-After: 1
requestsrequests in flight429, Retry-After: 1
writesrows written a second, charged as each write lands429, retry_after_ms until a row's worth is back
write_bytesbytes written a second, charged as each lands429, Retry-After until the debt is paid

A refusal names its quota in the body and in a header, and every SDK's error carries it typed (quota, retryAfter). A 429 says its wait twice: in whole seconds, rounded up, in Retry-After and retry_after, as HTTP has it, and to the millisecond in retry_after_ms, which every SDK and a replica's sync read first -- a bucket of 600 rows a second refills a row in 1.7 ms, and fifty couriers told to come back in a second waited fifty seconds between them:

HTTP/1.1 429 Too Many Requests
Fenec-Quota: writes
Retry-After: 1

{"error":"the tenant writes past its quota of 600 rows a second","quota":"writes","retry_after":1,"retry_after_ms":2}
HTTP/1.1 507 Insufficient Storage
Fenec-Quota: rows

{"error":"the write would take the tenant past its quota of 50000 rows: delete rows, or raise the quota","quota":"rows"}
  • A write that writes nothing costs nothing. writes counts the rows of the tenant's collections a write landed -- a put of ten rows ten, a claim that took no job, a require unmet or a held claim looking none -- and lets a write through while the bucket holds a row's worth, so one of many rows goes through and the next waits out the debt. Charged as a write started, 45 claims held at once by a fleet on a small plan were refused for writes before any for being held. A held claim the rate refuses as it is to run stays held, and runs once a row's worth is back. The node's own landings -- the sweep of rows past their @ttl, a compact -- are not the tenant's rate.
  • A tenant waits in a share of the node. Every stream and every held write is a connection and its thread, and a node's --max-connections is what all its tenants share: --http-max-streams counts each tenant's own subscriptions, and nothing counted a tenant's held writes, so a hundred workers of one tenant waiting for jobs took every connection of a default node and its neighbours were answered 503. Where the node sets neither --tenant-max-streams nor --tenant-max-held, each is a quarter of --max-connections -- 25 of a default node's 100 -- so no one tenant's dashboards and workers hold more than half; a node serving a fleet raises --max-connections (each is about 50 KB of memory) and sets its plans' quotas.
  • Deletes always land. A write that only takes away — a del, a drop — is never refused, and neither is a compact, which gives bytes back. A tenant at its quota deletes its way under it.
  • A replica's sync knows them. A synced write a 507 refuses is put back on the replica and the app told, the quota named, and the writes behind it -- a delete that makes the room among them -- go on; a 429 is waited out for its Retry-After, as a move's 503 is (Sync).
  • A full file is compacted by the node once an eighth of its quota is dead, where a file of its own waits for 64 MB: deleted rows become room without the tenant running compact itself.
  • Never passed by a write that adds. Bytes and rows are asked as the write lands, its record built, under the write lock: writers at once cannot take the file a byte past its quota, nor the rows one past theirs.
  • It goes with the tenant. The router keeps each tenant's quota in its directory and sends it to the tenant's node, its standby and its replica; a move sends it to the target before the directory points there, and a failover finds it on the copy it promotes.
  • Seen by quota, not by tenant: /_metrics counts refusals as fenec_quota_refused_total{quota="rows"}, the audit log writes each as a quota event naming the tenant, and GET /_admin/quotas on a node — or /_shard/tenants?quota on the router, which the studio's admin view shows — lists every tenant's usage against its quota.
Details

What is bounded is what is known exactly and cheaply. A file's bytes are the engine's count of what it appended, read with no system call; rows are each store's count, a sum over the collections (those whose names start with _ are the server's: idempotency keys, consumers' places); streams and requests are counters a request takes and gives back; the rates are a token bucket each. A tenant with no quota touches none of it. Left out: the memory an open tenant holds (memory_bytes counts what has been built, and a read builds an index on first use — a quota on it would refuse a write because of a search, and the same data holds less after a restart; --max-memory closes idle tenants for the node as a whole), a statement's time (the engine cannot stop a running scan; requests in flight bound how many cores one tenant's long reads take), and rows per collection (a tenant's own design: @ttl on a log).

The block is refused before it lands. A quota asked before a write runs would let that write past it by whatever it adds, and a set over every row adds the whole collection again. The engine asks the quota as a block lands, with its record's length to the byte and its documents counted (Database::set_limit); one past it is put back as a failed one is. A block larger than 16 MB spills into the file before it lands, and each spill is asked first: let through and refused after, its bytes would stay in the file, dead and past the quota. The file passes its quota only by what a write that takes away adds — a delete's tombstones, a drop's record — and by what no client writes: the graphs a server keeps in its file's tail. The next compact gives both back. A rate is not asked at the landing: a write lands while its bucket holds a row's worth or any bytes, and its rows and bytes are charged as it lands -- the rows of the tenant's collections it wrote, not the server's own, such as an Idempotency-Key's -- so a write larger than a second's worth goes through and the next waits out the debt.

A refusal tells the quota, not what the tenant holds: a scoped token's user is told the tenant is full, not how many rows the other users make. Only the admin token reads usage.

The router enforces none of it. It holds no keys and parses no query, and a tenant lives whole on one node, so the node's count is the tenant's. A quota a node does not take — a node down when it was set — is logged, and sent again as that copy is promoted.

What it costs. A tenant with no quota touches none of it: against the code before quotas, in turns, a request to a tenant took 19.5–21.7 µs straight to the node and 43.7–46.0 µs through the router, against 19.9–21.8 and 43.6–45.7. A tenant with every quota answered as one with none over HTTP — reads 20.9 µs either way, writes 23.7 against 23.6 — and in process a put held to bytes and rows took 593 ns against 566, 618 with a bytes-a-second bucket besides (make quota-bench).

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": 1 in 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.
Details
  1. The directory marks the tenant moving; it is still routed to the source.
  2. The source freezes it: writes in flight finish, new ones get 503 with Retry-After: 1. Reads go on being served.
  3. The image is copied from the source and installed on the target.
  4. The directory points the tenant at the target, in one statement.
  5. The source copy is deleted. Its open subscriptions end with an error event, 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.

What it costs

The router adds about 21 µs a request on one machine: one extra round trip. Moving a 58.7 MB tenant took under 0.3 s.

Details

Node and router in one process on loopback, one keep-alive client, 5 000 requests after 500 of warm-up (make shard-bench):

 p50p95p99
straight to the node23.1 µs31.5 µs38.1 µs
through the router44.7 µs53.2 µs64.0 µs

About 21 µs per request, which is one more loopback round trip; across a network the extra hop is whatever that network charges for one. Moving a tenant of 100 000 × 128 with an HNSW index — a 58.7 MB image — took 188–267 ms end to end over three runs, and the first query on the target about 70 ms, most of it opening the file: the graph travels in the image, so nothing is rebuilt.

What it does not do

No queries across tenants, no tenant bigger than a node, and only one router makes changes at a time. Failover is never guessed.

Details
  • Failover is decided on a lease, not a guess. A router cannot tell a node that is gone from one it cannot reach, and guessing would make two primaries for one tenant. Without --auto-failover someone who knows the node is gone calls the failover; with it, the router promotes a node's tenants only once the node's own lease has certainly lapsed, and the node stops its writes as it does (Automatic failover).
  • No query across tenants. Each request names one.
  • No tenant larger than a node. A tenant has to fit one node's memory, with the same 2× open and 3× checkpoint peaks as any file.
  • One router takes changes at a time. A standby follows its directory and forwards from the same placements, but only the primary places, moves and deletes; promoting the standby is a decision someone makes, not a vote the routers hold.

Replicas of tenants

Pair a node with a standby node, and every tenant on it is copied there as it is written. When the node is lost, one call promotes its tenants on the standby.

fenec-server --dir /data/n1 --http 127.0.0.1:8081 --admin-token a1 \
         --replication-token r1 --sync always
fenec-server --dir /data/n2 --http 127.0.0.1:8082 --admin-token a2 \
         --replication-token r1 --replica-of http://127.0.0.1:8081

curl -X PUT localhost:8090/_shard/nodes/n1 \
  -d '{"addr":"127.0.0.1:8081","token":"a1","standby":"n2"}'
  • Failover: POST /_shard/nodes/<n>/failover.
  • Use --sync always on the primary so a failover loses no write.
Details

Every tenant file has a feed of its own, so a node's tenants are followed by another node's — the standby — tenant by tenant, through /t/<tenant>/_replication.

The router creates a tenant on both, so the replica has a file to follow into, and deletes it from both; a move takes the replica with it. Recording a pair gives the standby a following copy of every tenant already on the node. A replica node opens every tenant it holds at start and keeps them open — a follower that is not running is a replica falling behind — so --idle-close does not apply to them. No tenant is placed on a standby or moved to one: it would open there as a replica of a tenant the other node does not have.

A tenant's role is its file's, not the node's. On a node started with --replica-of, a replica's file follows and a new one follows, but a primary's file stays a primary — a tenant a failover promoted there included, whatever the node restarts with. Were every file on such a node made to follow, a restart with the same flags would undo the failover, and the old primary's copy would wipe the writes taken since.

When a node is gone, POST /_shard/nodes/<n>/failover promotes its tenants on the standby, one at a time, and points the directory at it. They are separate databases: there is nothing to make atomic between them, so the answer lists what was promoted and what was not.

curl -X POST localhost:8090/_shard/nodes/n1/failover
{"node":"n1","to":"n2","promoted":["acme","beta"],"failed":[]}

Measured with make shard-bench, node, standby and router in one process on loopback: a write through the router is answered in 2.93 ms (--sync always on the primary, which is an fsync a write) and is on the standby 0.089 ms later, 0.448 ms at p99 — timed by reading the standby's own copy. A failover of 20 tenants took 60 ms, 3.0 ms each, and the first write on the promoted node 0.18 ms.

A promoted tenant's history forks where the replica stood, so what the primary had not sent is gone — the same trade a database's failover makes, and --sync always on the primary is what makes it none. A failover that moved every tenant ends the pair; one that left a tenant behind keeps it, so a second call can take it. The old node rejoins as the standby of the new one: point its --replica-of there and record the pair the other way round. The router then has each of its tenants follow (POST /_admin/tenants/<t>/follow) — its own files are a primary's, and follow only when told to — and the writes it took that were never sent are gone.

fenec-server --dir /data/n1 --http 127.0.0.1:8081 --admin-token a1 \
         --replication-token r1 --replica-of http://127.0.0.1:8082
curl -X PUT localhost:8090/_shard/nodes/n2 \
  -d '{"addr":"127.0.0.1:8082","token":"a2","standby":"n1"}'
{"node":"n2","unreplicated":[]}

A tenant file that still follows on a node that follows nobody — a failover that left it behind, and the node restarted without --replica-of — refuses writes, says so in the log, and is promoted where it stands with POST /_admin/tenants/<t>/promote.

Replicas of their own

Instead of an idle standby, fenec-shard --replicas gives each tenant its own replica on another node, so every node holds both primaries and replicas and a lost node's tenants spread over the rest.

fenec-server --dir /data/n1 --http 127.0.0.1:8081 --admin-token a1 --replication-token r1 --sync always
fenec-server --dir /data/n2 --http 127.0.0.1:8082 --admin-token a2 --replication-token r1 --sync always
fenec-server --dir /data/n3 --http 127.0.0.1:8083 --admin-token a3 --replication-token r1 --sync always
fenec-shard --replicas

curl -X PUT localhost:8090/_shard/tenants/acme -d '{"node":"n1"}'
{"tenant":"acme","node":"n1","replica_on":"n2"}
  • After a failover, POST /_shard/replicas gives every tenant without a replica a new one.
Details

A standby waits idle, and takes every tenant of its node at once when the node goes. With --replicas the router gives each tenant a replica of its own instead, on another node in no pair: the one holding the fewest replicas of that tenant's node, then the fewest tenants and replicas altogether. Each node's tenants then follow on all the others, and every node holds primaries and replicas both.

The replica's node is told which node to follow (POST /_admin/tenants/acme/follow with {"from": "http://127.0.0.1:8081"}) and keeps it in acme.follows beside the file, so the tenant follows again when the node starts over. The replication token is the nodes', one for the cluster. A move takes the replica along: the copy goes on following the tenant on its new node, since the image carried its history, and when the tenant moves onto the node holding its replica, that copy goes and another is placed. A delete removes both.

A failover of a node in no pair promotes each of its tenants on its own replica, one at a time, and routes it there. The tenants whose replicas it held are named as unreplicated. POST /_shard/replicas gives every tenant without a replica one, on a node holding a copy of it first: a node back from a failover has its old primaries follow the nodes that took them over, and nothing of theirs is left lying there.

curl -X POST localhost:8090/_shard/nodes/n1/failover
{"node":"n1","promoted":[{"tenant":"acme","to":"n2"},{"tenant":"beta","to":"n3"}],"failed":[],"unreplicated":["gamma"]}
curl -X POST localhost:8090/_shard/replicas
{"replicated":[{"tenant":"acme","replica":"n1"},{"tenant":"beta","replica":"n1"},{"tenant":"gamma","replica":"n1"}],"failed":[]}

Measured with make shard-bench, three nodes and the router in one process, 30 tenants: each is created with its replica in 33 ms, the replicas 10 a node, and a write is on its replica 0.179 ms after it was answered, 0.421 ms at p99. A failover of one node's 10 tenants took 57 ms, 5 onto each of the other two, and the repair after it gave 20 tenants a replica in 176 ms, all on the returning node's copies. Placed by the fewest altogether alone, every tenant of one node followed on the same other: a tie went by name each time, and its failover put them all there.

Automatic failover

With --auto-failover the router fails a node over by itself. It works on leases: a node stops writing when its lease runs out, and only after that does the router promote its tenants elsewhere, so a tenant never has two primaries.

fenec-server --dir /data/n1 --http 127.0.0.1:8081 --admin-token a1 \
         --replication-token r1 --sync always --lease
fenec-shard --listen 127.0.0.1:8090 --directory shard.fenec --replicas --auto-failover 5
  • Nodes need --lease; a node without it is never failed over automatically.
  • With a 5-second lease, a lost node's tenants cannot be written for 5.5 to 7.2 s.
Details

With fenec-shard --auto-failover <s> the router fails a node over on its own. It leases each node the tenants it places there for that long, renews the lease every third of it while the node answers, and fails over a node that has gone a tenth past its lease unrenewed. A node started with --lease writes a tenant only while its lease names the tenant and has not lapsed, and starts with none.

The router still cannot tell a node that is gone from one cut off from it while its own clients reach it, and it does not have to. A node that hears from the router no more stops taking writes when its lease lapses, by its own clock, and the router promotes its tenants elsewhere only a tenth of a lease later, measured from the answer that came back rather than from the grant it sent — so no tenant has two primaries, whichever it was. What such a node took from clients of its own before its lease lapsed, and had not sent its replicas, goes with the failover as a crash takes it; a client going through the router loses nothing that way, since the router could not reach the node either. A write is refused as a replica's is: 503 with Retry-After.

The lease names tenants, not the node. A node failed over comes back with the files of tenants served elsewhere now, primaries' files still; a lease over the whole node would have them take writes again the moment it was renewed. Its new lease names none of them, and the router runs a repair as the node answers, which has those copies follow the tenants' new primaries. The list rides with a grant only when it changed, and a create or a move grants the node its new one at once, so a tenant takes writes as soon as it is placed. A router that starts leasing — a restart, or a standby promoted — counts every node as granted then: its predecessor may have granted a lease just before it stopped. A node started without --lease answers that it takes none, and is never failed over on its own.

Measured with make shard-bench, three nodes and the router in one process and a lease of a second: a node cut off stopped taking writes 697 ms after — its last renewal came a third of a lease before — and its 10 tenants took writes on their replicas again 1.29 to 1.33 s after, the lease and a tenth, the next round and 49 ms of promotions. With a lease of five seconds, a node lost costs its tenants' writes 5.5 to 7.2 s.

A standby router

A second router can follow the first one's directory and forward requests too. Only the primary router makes changes; promote the standby when the primary is gone.

fenec-shard --listen 127.0.0.1:8090 --directory shard.fenec --replication-token r1
fenec-shard --listen 127.0.0.1:8091 --directory standby.fenec --replication-token r1 \
            --replica-of http://127.0.0.1:8090
  • Promote: POST /_replication/promote on the standby.
Details

The directory is a fenecdb file, so it replicates the way any other does. A second router follows it and forwards from the same placements; the primary is the one that changes it.

Both forward /t/<tenant>/...; the standby's /_shard/ answers reads and refuses changes with 409, since its directory takes no write of its own. When the primary is gone, POST /_replication/promote on the standby forks its directory's history and it starts taking them — the same promotion a replica of a database gets, and the same rule after it: the old primary rejoins with --replica-of pointed at the new one, or its file is refused.

A standby is behind by whatever the last directory change has not reached it yet, which is one write and a sync: placements change when someone places, moves or deletes a tenant, not when clients read and write. Nothing of the tenants' own data goes through the router's directory, so a standby that is a second behind still forwards every request correctly. One whose directory has not arrived at all — just started, its primary not yet reached — answers 503 with Retry-After rather than 404: it knows of no tenant, which is not knowing there is none.

Each change the primary makes reaches the standby's maps through the change ring, as a subscription's rows do: the placements it put or deleted since the last one, 4 µs at 100 000 tenants, where reading every map again took 38 ms under the router's write lock. An image adopted, or more changes than the ring holds, and the maps are read again whole.

Tokens

There are three tokens: one for data, one per node for the router, and one for managing the router. A user's JSON Web Token is bound to the tenant its tenant claim names:

fenec-server --dir /data/n1 --http 127.0.0.1:8081 --admin-token a1 \
         --jwt-secret-file jwt.secret --policy policy.txt
fenec-server --jwt-secret-file jwt.secret --mint-token '{"sub":"alice","tenant":"acme"}'
Details

Three, each for a different job. Data requests carry whatever the nodes' --http-token expects, and the router passes Authorization through untouched. Each node has its own --admin-token, which only the router holds. The router's --token guards /_shard/; like the nodes, it refuses to listen on a non-loopback address without one unless given --insecure.

With --jwt-secret or --jwt-keys a node also takes JSON Web Tokens held to its --policy, and the policy is the node's, not a tenant's: notes read,write where owner = $jwt.sub matches alice's rows in every tenant's file alike. So on a --dir node a token reaches /t/<t>/ only when its tenant claim names <t>, a text or a list holding it, and is refused with 403 elsewhere: on every route under the prefix, the statements, REST, /batch, a subscription, /_changes, /_schema and the statement counts. The check comes before the tenant is looked up, so a refused token learns nothing of which tenants exist. A token naming no tenant is refused on a --dir node unless it is started with --jwt-unbound-tenants, which takes such a token for every tenant it serves; --jwt-tenant-claim <name> reads another claim than tenant. The node's own --http-token and --admin-token are not bound. The router forwards Authorization as it came and holds no keys, so the node is what enforces it, behind the router or reached directly. What a scoped token writes to a tenant is checked against its rules as on a single file (Scoped access).