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 itstenantclaim namesacme(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-memorycovers 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:
| Route | Does |
|---|---|
GET /_admin/stats | tenant count, bytes on disk, memory of the open ones |
GET /_admin/open | the open tenants: name, memory, frozen |
GET /_admin/tenants | names on disk |
GET /_admin/sizes | each 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>/schema | a schema description, applied as /_schema/apply applies it: what the router sends for a tenant created with a schema |
POST /_admin/tenants/<t>/query | a 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>/freeze | refuse writes and wait for the ones in flight |
POST /_admin/tenants/<t>/thaw | accept writes again |
GET /_admin/tenants/<t>/file | the whole image, graph included |
PUT /_admin/tenants/<t>/file | install an image as a new tenant |
POST /_admin/tenants/<t>/promote | a 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>/follow | the 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-corson 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.
| Route | Does |
|---|---|
GET /_shard/nodes | name, 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/tenants | name, 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/query | one read on every tenant, or those named, the answers merged as the request says (A query across tenants) |
GET /_metrics | what 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.
| Flag | What 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). |
--insecure | Allows 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). |
--studio | Serves 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. |
--replicas | Gives 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. |
--promote | Takes 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, --version | Prints 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.
| Quota | Bounds | Past it |
|---|---|---|
bytes | the tenant's file, as written | 507, the write put back whole |
rows | the documents of its collections, together | 507, the write put back whole |
streams | subscriptions, /_changes waits and Fenec-After waits while they wait: a read after a write the node holds takes none | 429, Retry-After: 1 |
held | writes held until they can write (Fenec-Wait): workers waiting for jobs | 429, Retry-After: 1 |
requests | requests in flight | 429, Retry-After: 1 |
writes | rows written a second, charged as each write lands | 429, retry_after_ms until a row's worth is back |
write_bytes | bytes written a second, charged as each lands | 429, 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.
writescounts the rows of the tenant's collections a write landed -- a put of ten rows ten, a claim that took no job, arequireunmet 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 forwritesbefore 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-connectionsis what all its tenants share:--http-max-streamscounts 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-streamsnor--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, adrop— is never refused, and neither is acompact, which gives bytes back. A tenant at its quota deletes its way under it. - A replica's sync knows them. A synced write a
507refuses 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; a429is waited out for itsRetry-After, as a move's503is (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
compactitself. - 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:
/_metricscounts refusals asfenec_quota_refused_total{quota="rows"}, the audit log writes each as aquotaevent naming the tenant, andGET /_admin/quotason a node — or/_shard/tenants?quotaon 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.
| What | When |
|---|---|
| The router renews a node's lease | as it starts, then every third of the lease while the node answers (The lease and the failover) |
| The router fails a node over | once the node has gone a tenth past its lease unrenewed |
| A node cut off from the router, whose clients still reach it, stops writing | two 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 written | for 5.5 to 7.2 s, with a 5-second lease |
| What the router has out to a node it failed over is answered | at 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 again | for 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 back | a 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.
| Status | When |
|---|---|
| 401 | A 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) |
| 403 | A 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) |
| 404 | A 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) |
| 409 | A 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) |
| 411 | At the global entry, a body sent with no length past a mebibyte |
| 413 | A query across tenants that gathers more rows or bytes than --fanout-max-rows or --fanout-max-bytes allow (A query across tenants) |
| 429 | A tenant past a rate or a share of its node: streams, held, requests, writes or write_bytes (Quotas) |
| 500 | A 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) |
| 502 | A 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) |
| 503 | A 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) |
| 504 | A 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) |
| 507 | A 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.