Sharding reference

Where to look a thing up: the routes of a tenant node and of the router, the router's flags, what each quota bounds, the tokens, a lease's timings, the statuses a client meets, and what the deploy kit holds. The steps are in the tutorial and the guides it lists.

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).
  • Archived as a whole by fenec archive --tenants, one connection for every tenant (Archiving a node).
How a --dir node opens, closes and shuts down tenants, and its /_admin/ routes

--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>/querya read, {query, params} as /query takes it, answered as /query answers it; a statement that writes is refused (403). What the router sends each tenant for a query across tenants
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. {"lineage", "history"}, as the router sends them, refuse a copy of another lineage, or one lacking that history that has not heard from the node it follows since it was last told to follow it (409); without them, a copy with no write and no word from its primary since it opened or was told to follow is refused; {"force": true} promotes it whatever it holds, an audit event
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 — in the region you name, if you name one (Regions) — unless you name the node.
  • A page on another origin needs --http-cors on the router, as it would on a node: fenec-shard --http-cors https://app.example.
The directory, the node and tenant routes, and CORS through the router

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, address, region and zone of each node
PUT /_shard/nodes/<n>{addr, token, region, zone}; checked against the node before it is recorded; a region or zone left out is the one recorded
DELETE /_shard/nodes/<n>refused while the node holds a tenant
GET /_shard/tenantsname, node, state and region of each tenant; with ?bytes, its bytes on disk, asked of each node; with ?region=<r>, one region's
GET /_shard/tenants/<t>one tenant: its node, state, region and zone, and the node holding its copy
PUT /_shard/tenants/<t>place and create; {region} to place it in a region, {node} to choose the node, otherwise the node with the fewest bytes on disk; {schema}, FenecQL create collection text, applied there before the tenant is answered (A tenant with its schema)
DELETE /_shard/tenants/<t>delete on the node, then forget
POST /_shard/tenants/<t>/move{to}, {region} or both; see Moving a tenant and Regions
POST /_shard/queryone read on every tenant, or those named, the answers merged as the request says (A query across tenants)
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 or at its thread limit (marked Fenec-Unserved: the request never ran, so the global entry and a relaying router ask the region's next router), 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.

Router flags

Every flag of fenec-shard, as fenec-shard --help says it, with the part of these pages that explains it. A token is also read from the environment (Tokens). The site's build holds this table to the program's usage: a flag the usage has and this table lacks fails it.

FlagWhat it does
-l, --listen <address>Where the router listens. Port 0 takes one the system picks: the listening on: http://<address> line on stderr names it. Default 127.0.0.1:8090.
-d, --directory <path>The directory file: which node holds which tenant (The router). Default shard.fenec.
--token <value>Requires Authorization: Bearer <value> on /_shard/. Data requests pass their own Authorization through to the nodes, which check it against --http-token (Tokens).
--insecureAllows a non-loopback address without --token. Off by default.
--http-cors <origin>Access-Control-Allow-Origin on every answer the router gives, its own and the nodes' it forwards, whose CORS headers it replaces, and a browser's preflight answered here (the router's CORS rule).
--studioServes fenec studio, the admin pages, at /_studio/: a tenant's collections and rows in a browser, through this router, with the token pasted in. Keep it on a private network. Off by default.
--entry-token <value>The global entry's ENTRY_TOKEN: a request whose Fenec-Client header carries the entry's mark for its address, made within 60 s of this clock, is that client's (One URL for every region).
--audit <path>Appends a JSON line to this file for each request refused for its token and each /_shard/ request that changes something.
--auth-delay <ms>How long a request refused for its token waits, doubled for each one from the same address within a minute, 5 s at most; 0 turns it off. Default 100.
--max-connections <n>Ceiling on concurrent connections. Default 1000.
--max-body <MiB>Request body ceiling. Default 64.
--upstream-timeout <s>Connect and read bound towards a node. Default 60.
--fanout-timeout <s>The longest a query across tenants runs, every call to a node within it; a request may ask for less (timeout_ms) (A query across tenants). Default 30.
--fanout-max-rows <n>Rows such a query gathers from its tenants at most: past it the query is refused (413), never answered with what fitted. Default 100000.
--fanout-max-bytes <MiB>Bytes of the tenants' answers it gathers at most, refused past it likewise. Default 64.
--fanout-concurrency <n>Calls to the nodes it has out at once, the tenants taken node by node in turn. Default 16.
--replicasGives each tenant created on a node in no pair a replica on another node of its region — one in another zone first, then the one holding the fewest. The nodes need --replication-token, the same one (Replicas of their own). Off by default.
--default-region <name>The region a tenant created naming neither a region nor a node is placed in. Without it, such a tenant goes to the node with the fewest bytes whatever its region (Regions).
--auto-failover <s>Leases each node the tenants it holds for this long, renewed every third of it, and fails a node over on its own once it has gone a tenth past it unrenewed. The nodes need --lease. 5 is a start (Automatic failover). Off by default.
--otlp-endpoint <url>Sends OpenTelemetry traces, OTLP over HTTP as JSON, to this collector. No TLS. Also read from OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and OTEL_EXPORTER_OTLP_ENDPOINT. Off by default.
--otlp-header <k=v>A header on every post, such as an API key; repeatable. Also read from OTEL_EXPORTER_OTLP_HEADERS.
--trace-sample <ratio>The share of the requests that start a trace here that are kept, 0 to 1. A request whose traceparent was sampled is traced whatever the ratio. The service is named by OTEL_SERVICE_NAME (fenec-shard by default). Default 1 with an endpoint.
--replication-token <value>Serves the directory to standby routers at /_replication, and presents this to a primary (A standby router).
--replica-of <url>Follows the primary router at http://host:port: the directory arrives from it, /_shard/ changes are refused here, and forwarding goes on as ever.
--promoteTakes writes on a standby's directory file: its history forks here. A standby's file opens only with --replica-of or this. Off by default.
--replication-buffer <MiB>Directory writes kept for standbys that fall behind. Default 8.
--region <name>Keeps this region's own directory in --directory: its nodes and tenants, leased and failed over by this router with no word from another region. Needs --global and --replication-token (A region that keeps its own directory).
--global <url>The global router, http://host:port, repeatable or comma-separated, the one keeping the global directory first: a region's router follows its directory from it and sends it the changes it takes.
--global-directory <path>The region's router's copy of the global directory. Default: the --directory file with .global before .fenec.
--near <region>The region this router stands in: a read its client marks (Fenec-Read: replica) of a tenant with a read replica there goes to it, every other request home. A region's router (--region) is near its region already (Read replicas).
--replica-timeout <ms>How long a read replica has to answer such a read, past the wait its Fenec-After asks for; one that does not is let be for 5 s, its region's reads going home. Default 2000.
-V, --versionPrints fenec-shard <version> on stdout and exits 0, before the directory is opened or the address bound. Releases up to 0.1.12 have no such flag: the first line of --help, on stderr, names theirs.

Quotas: what each bounds and what a refusal says

The quotas of a tenant: what each one bounds, and what a write past it is answered.

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 waits while they wait: a read after a write the node holds takes none429, 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.

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"}'
The three tokens, and JWTs on a node

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 without the tenant claim is refused on a --dir node unless it is started with --jwt-unbound-tenants, which takes such a token for every tenant it serves; one holding the claim as null, "", a number or a list of no tenant's name is refused even then, since its issuer meant to bind it; --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).

Every token is also read from the environment, which keeps it off a command line every user of the machine reads with ps: a node's from FENEC_HTTP_TOKEN, FENEC_ADMIN_TOKEN and FENEC_REPLICATION_TOKEN, a router's from FENEC_SHARD_TOKEN, FENEC_SHARD_REPLICATION_TOKEN and FENEC_SHARD_ENTRY_TOKEN, an archiver's from FENEC_REPLICATION_TOKEN. A variable set and empty, as an environment file leaves a line it does not use, is no token; a token given empty as a flag is refused at the start. A request carrying no Authorization reads as the empty token, so an empty FENEC_SHARD_TOKEN, taken for one, let anyone into /_shard/ -- and past the check that keeps a router with no token off a public address. The kit's systemd units hand every one over from an environment file (Deploying it).

Lease timings

What happens when, in fractions of the lease (--auto-failover <s>), and what a lease of 5 seconds comes to. How a lease holds, and why, is under Leases in the design.

WhatWhen
The router renews a node's leaseas it starts, then every third of the lease while the node answers (The lease and the failover)
The router fails a node overonce the node has gone a tenth past its lease unrenewed
A node cut off from the router, whose clients still reach it, stops writingtwo thirds of a lease after its last renewal at the most: its lease runs from the grant before that one (Automatic failover)
A lost node's tenants cannot be writtenfor 5.5 to 7.2 s, with a 5-second lease
What the router has out to a node it failed over is answeredat the failover, not at --upstream-timeout (Requests out to a node failed over)
A write a standby router sent on before the leading router's grant reached the new primary is asked againfor two thirds of a lease or --upstream-timeout, whichever is sooner, and only while its client is still there
A write sent with no Idempotency-Key and answered 500 with "outcome": "unknown" is read backa lease and a tenth (--auto-failover × 1.1) after the answer, and not before; one sent with a key is sent again with the same key, which the node refuses while its lease has lapsed and answers once the tenant settles (A write answered after the lease lapsed)

Refusals and their statuses

The statuses a client meets from the router and the nodes, and where each is explained. A refusal's body says why; a 503 or a 429 says when to come back, as Retry-After and as retry_after in the body.

StatusWhen
401A request to the router's /_shard/ without its --token, a user's token or a node's data token included (A query across tenants); a node's /_admin/ asked without its --admin-token; where the node was started with none, /_admin/ is off and answers 404 (The node)
403A token whose tenant claim does not name the tenant under /t/<tenant>/ (Tokens); a statement that writes, in a query across tenants, which every node refuses before it runs; a write sent marked as a read, Fenec-Read: replica (Read replicas)
404A tenant the router's directory, or a node, does not hold: a request never creates one (The node); at the global entry, every path but /t/<tenant>/... and, where the entry is told to, /_shard/ (One URL for every region); a node named as another's standby that the router does not know (Get started)
409A tenant created that exists; a change asked of a standby router's /_shard/, which answers it naming the primary (A standby router); a region with no node named for a tenant, or a node and a region that disagree (Regions); a copy a node refuses to promote because it is not its tenant as the directory knows it, unless forced (Replicas of tenants); a node's refusal of a grant, a promotion or the re-pointing of replicas from a router that a newer one superseded, naming who holds the node (held_by) (A region that keeps its own directory)
411At the global entry, a body sent with no length past a mebibyte
413A query across tenants that gathers more rows or bytes than --fanout-max-rows or --fanout-max-bytes allow (A query across tenants)
429A tenant past a rate or a share of its node: streams, held, requests, writes or write_bytes (Quotas)
500A write that landed while its node's lease ran and was answered after it lapsed, with "outcome": "unknown" in the body (A write answered after the lease lapsed)
502A node the router could not reach, or a write sent whole whose connection ended before an answer, which the node may have run, so a client sends it again only with an Idempotency-Key (How the router forwards a request); a region out of reach (A region that keeps its own directory)
503A write to a tenant while it is moved (Moving a tenant); a write to a node whose lease has lapsed (Automatic failover); a standby router whose directory has not arrived (A standby router); a connection past a router's --max-connections or at its thread limit, marked Fenec-Unserved (the router's CORS rule); a create, a delete or a move that needs a region that cannot be reached, nothing done by half (A region that keeps its own directory)
504A read sent after a position (Fenec-After) that the tenant's home has not reached by the end of its wait, or at once where a failover lost the write it names; a replica's 504 is sent home and never seen (Read replicas)
507A write that would take a tenant past its bytes or rows quota, put back whole (Quotas)

Metrics

The router's GET /_metrics, a route in the table above, is in Prometheus's text format. Each series, its labels and the alerts on them are on Monitoring.

Deploying it

The repository's deploy/ is a kit for running everything on these pages on machines of your own: systemd units, their environment files, alert rules, and the operator's runbook. make drill breaks a deployment of two regions on purpose, in Docker, and holds it to losing no acknowledged write.

make deploy-check   # the units under a real systemd, the alert rules under promtool
make drill          # two regions: a node killed and back, a router killed, the global router cut off, a restore
  • A unit for each part: a tenant node, a region's router and its standby, the global router and its standby, a node's archiver and its copy to a bucket (rclone), and the Cloudflare Tunnel the global entry reaches a router through. Each runs sandboxed as its role's user -- a node, the routers, the archiver and the bucket copy each their own, so none reads another's tokens -- its tokens in an environment file only root reads. The tunnel lets a tenant's paths alone through, and Cloudflare Access with a service token the entry holds says who; the entry passes no cookie Access sets on to its clients, and the routers no Access header on to a node. The bucket copy refuses a new disk's first copy over the bucket's archive.
  • Alerts for Prometheus and for Grafana: nodes and routers down, a region's primary router told from a standby, failovers and late leases, tenants without a replica, copies in their primary's zone, read replicas given up, connections near their ceiling, the bucket copy gone stale or unreadable by node_exporter, quota refusals, the entry's marks not believed, refusals and errors.
  • The drill's figures, lease 2 s, three runs on one laptop -- what they saw, not bounds: a node cut off the network and killed, as a machine lost, was failed over 1.9 to 2.6 s after the kill, the other tenants of its region never more than 18 ms between two answered writes, none of 41 577 acknowledged writes was lost, and a tenant restored from its node's archive held exactly the rows acknowledged by the moment it was restored to.
What make deploy-check and make drill run

make deploy-check boots systemd in two privileged containers, installs every unit and environment file as the runbook installs them, and runs them: systemd-analyze verify on each, a region, two nodes and a tenant made through the global router, rows written through the router and its standby, no token on any process's command line or in an environment another role's user can read, the limits and the sandbox in force (systemd-analyze security puts each unit at 1.2), a node's stop syncing within its timeout, a sealed archive verified with the key the unit hands its archiver and copied to a bucket by real rclone while its tenant is written, a real node_exporter scraped for the copy's success, a new disk's archive refused over the bucket's and copied once the bucket is copied back, and a standby router promoted and its unit switched as the runbook does it. cloudflared's own matcher is asked where the tunnel sends 28 paths, every spelling of a tenant's replication stream among them. promtool checks the alert rules and runs their tests, and a test of fenec-shard holds every metric and label value they read to a real scrape of every kind of process.

make drill runs each process in a container of its own on one Docker network -- two regions, each a router keeping its own directory and that router's standby, two nodes in two zones under --lease and --replicas, an archiver for each node, and the global router and its standby -- every token in the processes' environments and no port fixed. A writer a tenant puts rows by id, the next only once the one before was answered, while the drill cuts the global routers off the network (each region writes on), cuts the node holding the most primaries off the network and kills it, as a machine lost, and starts it again, kills a region's router and promotes its standby as an operator would, and marks a moment it later restores a tenant to from its node's archive. Then every acknowledged write is read back from each tenant's primary and its replica, and the times are printed. The router fails a killed node over a lease and a tenth after its last renewal and the promotions after: 1.92, 2.56 and 1.90 s after the kill in three runs at a lease of 2 s. Its tenants' writers write again once the request each had in flight to the dead node is let go of, at the router's upstream timeout -- 4 s in the drill, twice its lease, as the kit's routers run at twice theirs: 3.90, 3.97 and 3.93 s. The tenants whose replica it held went 17, 18 and 17 ms between two answered writes at most, the rest 16, 18 and 17; a reviewer's runs on another machine saw 28-30 ms, so these are what three runs saw, not bounds. The runbook says what each alert means, the order to bring a deployment up in, how to rotate each token and upgrade with no downtime, and what is not measured yet.