Server
Run fenecdb as a server: one process over one file, reached over HTTP from any language.
Start and query
-
Start the server on a file.
fenec-server --file data.fenec --http 127.0.0.1:8080 -
Send it a query.
curl -s -X POST localhost:8080/query \ -d '{"query": "get articles select title near embed $1 limit 5", "params": [[0.1, 0.2]]}'
Everything the server does goes over that one listener: queries and the REST routes, subscriptions, replication, change data capture, metrics and the admin routes of a tenant node. The requests are on HTTP endpoint, and each language's way of sending them on Your language.
Details
One process writes one file. Two processes over the same file corrupt it,
which is why everything a server does — HTTP, replication, the graph keeper,
the sweeper of rows past their time (@ttl),
the --follow mirror — runs inside this one process rather than
as programs of its own.
One thread per connection. Read-only statements run at the same time under a shared lock; writes take the exclusive one. HNSW search buffers are thread-local, so the same index can be searched in parallel — eight concurrent full scans on eight cores measured 4.3×.
A /batch is one block: its writes land in the file as one
record, or none of them do, and a replica applies it whole. There are no
interactive transactions held open across requests — the database has one
writer, and a transaction a client held open would hold every other write
with it. An Idempotency-Key makes a write, or a
/batch, safe to retry (HTTP endpoint).
With --replica-of the process follows another
fenec-server as a read replica: it serves every read and refuses
every write with 403. The flags, promotion and what a failover loses are on Replication. With --dir <path>
instead of --file it serves a directory of tenant files, one per
tenant under /t/<tenant>/; that mode, its
--admin-token and --idle-close, and the router in
front of it are on Tenants and sharding.
Flags
| Flag | What it does |
|---|---|
-f, --file <path> | The database file. In memory without it. The server holds <path>.lock while it runs, so a second process over the file — another server, fenec mcp --file, an app through the native library — is refused rather than let corrupt it. |
--dir <path> | One file per tenant in this directory (Tenants and sharding). |
--http <address> | Where to listen. Default 127.0.0.1:8080. |
--http-token <value> | Requires Authorization: Bearer <value>. Also read from FENEC_HTTP_TOKEN, since argv shows up in ps. |
--jwt-secret, --jwt-secret-file, --jwt-keys, --jwt-require-exp, --jwt-max-age, --jwt-tenant-claim, --jwt-unbound-tenants, --policy | Tokens held to rules: which collections, which rows (Security). |
--sync off|always|<ms> | When writes reach the disk. Default 250 (Durability). |
--no-checkpoint | Writes no checkpoint on shutdown. |
--no-mmap | Reads the file into memory instead of mapping it. |
--warm all|off|<list> | Builds the hash, text, ordered and sparse indexes after the open, beside the first requests, rather than on the first read of each. Default all for a file, off for a node's tenants (Warm-up). |
--auto-compact <ratio>|off | Compacts a file on its own once this share of it is dead, beside the queries. Default 0.5 (Compaction). |
--max-connections, --idle-timeout, --max-memory | Limits. |
--http-cors, --http-read-only, --http-max-streams, --http-keepalive, --changes, --idempotency-ttl | The HTTP endpoint's options (HTTP endpoint). |
--replication-token, --replica-of, --promote, --replication-buffer, --cdc | Replication and change data capture. |
--admin-token, --idle-close, --lease | A tenant node's (Tenants and sharding). |
--follow <postgres://...>, --follow-* | Mirrors a PostgreSQL table into the file and serves it (Import). |
--metrics, --slow-ms, --audit, --auth-delay | Monitoring and the audit log. |
--otlp-endpoint, --otlp-header, --trace-sample | OpenTelemetry traces to a collector; off unless an endpoint is given (Tracing). |
--studio, --studio-connect | fenec studio, the admin pages, at /_studio/; off unless given (Studio). |
--mcp, --mcp-write, --mcp-memory, --mcp-max-rows, --mcp-max-bytes, --mcp-calls-per-sec | MCP for agents at POST /_mcp, each tool held to the request's token; off unless given (Agents and MCP). |
--insecure | Allows a non-loopback address without a token. |
--ping | Asks the server whether it is up and exits (health checks). |
fenec-server --help prints every flag with its default.
Security
Give the server a token, or give each user a JSON Web Token that names what they may read and write.
fenec-server --file data.fenec --http 0.0.0.0:8080 --http-token "$TOKEN"There is no TLS yet. On a network, put a TLS terminator such as Caddy, nginx or a load balancer in front.
Details
--http-token is the server's own token: whoever sends it reads
and writes everything. The comparison is constant time. Listening on a
non-loopback address with no token is refused outright; it can be opened
deliberately with --insecure.
With --jwt-secret (HS256, at least 32 bytes) or
--jwt-keys (a JWKS file: oct keys for HS256,
RSA keys for an identity provider's RS256, a token's
kid picking the key, the file read again as it changes) the
server also takes JSON Web Tokens, each held to the rules of
--policy: which collections, to read, insert, update or delete,
which rows (where owner = $jwt.sub, where room in
$jwt.rooms), which fields of them a read shows (read(pickup,
run_at) where run_at <= now(),
Fields a read shows), and what an
update may make of a row (where
status = "ready" and new.status = "cancelled"). A token that may only read is the reporting login a
dashboard needs, one that may only insert a client appending events, and an
append-only collection takes no update or delete from any token
but the server's. The rules are ANDed into every statement
a token runs, and every document it writes is checked against them as well
(Scoped access).
--mint-token '{"sub": "ada"}' prints a token signed with the
secret, for testing, which expires in an hour unless the claims name an
exp. A token with no exp is refused, since it
would be good for ever (--jwt-require-exp off takes it), and
--jwt-max-age <s> refuses one whose exp lies
further ahead than that. On a --dir node a token reaches only
the tenant its tenant claim names
(Tokens). Pass the secret with --jwt-secret-file or
FENEC_JWT_SECRET — argv shows up in ps
output.
A request refused for its token waits before it is answered — 100 ms,
twice as long for each refusal from the same address within a minute, 5 s at
most (--auth-delay; 0 turns it off). A token the server
verifies clears its address's count; an answer that looked at no token,
whatever Authorization it carried, clears nothing. --audit
<path> appends a JSON line for each refused request, each change
of the schema and each admin request (The
audit log).
Durability
Choose how often writes reach the disk with --sync.
--sync | What it means |
|---|---|
250 (default) | Flushed every 250 ms. A crash loses at most that much. |
always | Each write is answered once it is on disk. |
off | Flushed only on shutdown. |
Details
Writes accumulate in a 1 MB buffer. Under always every write
waits for an fsync, about 3.7 ms on macOS for a write alone, and
writes arriving together share one; 250 costs next to nothing;
off nothing. A /batch lands with one
fsync however many writes it holds.
Under always the fsync runs after the write has
let go of the exclusive lock, and the bytes reach the file there too. Readers
therefore do not wait on the disk, and writes that arrive while one
fsync runs are made durable together by the next. One client
still gets one write per fsync. The sync of 250
takes the same path, for a single database and for each tenant of a
--dir node: held through the fsync, a tenant's lock
had its reads and writes wait up to 27 ms once an interval.
SIGINT, SIGTERM and SIGHUP are caught
and a final sync runs before exit. The exclusive lock is held until
exit, so no write can be accepted between the sync and the exit and no write is
left that looked successful to the client but never reached the disk. Under
kill -9 you get exactly what the chosen policy promised, and no
more.
When the disk refuses a write or a sync, the request fails with 500 —
under always, before the client is told it succeeded — and every
later write is refused until the process restarts; reads go on answering. The
sync is not retried: after a failed fsync the kernel may already
have dropped the pages, so a retry that succeeds would prove nothing.
The shutdown checkpoint. After the sync, if a vector index exists,
a checkpoint is written: the HNSW graph lands in the file and the next open
does not rebuild it — 9 ms instead of 3.4 s at 100 000 × 128. Vectors still
waiting to be linked after a crash are written as waiting, and linked after
the next open. The cost is that shutdown takes longer on a large database:
the image is written into the new file as it is produced, so it is a copy on
disk rather than one in memory. --no-checkpoint turns it off.
The order is deliberate. The sync comes first, so even if the process is
killed while the checkpoint is being written, the data is already on disk and
only the graph is lost. The rewrite goes to a side file and is renamed, so a
half-written checkpoint cannot corrupt anything.
Warm-up
An open builds no hash, text, ordered or sparse index: the first query
that reads one builds it, and pays for it. fenec-server builds
them after the open instead, beside the first requests, one at a time: the
port opens as soon as before, and the first search finds its index built.
--warm products,orders.status names the collections and fields
to warm, --warm off none.
Details
Each index is built under the read lock on its own, so reads go on beside
it, a read that needs the index being built waits for that build rather than
starting its own, and a write waits out one index at most (a
@text index of 100 000 products: 141 ms). The thread starts once
the HTTP endpoint is up, whose start takes the write lock. Over 100 000
products with a text, two hash and an ordered index, on an M1: the first
answer 16 ms after the start either way, the indexes built 208 ms after it,
and the first match 1.4 ms warm against 166 ms cold; the process
held 99 MB against 88 for a cold one that had only searched, and 25 before
any read.
It is on for a file because an index is declared to be read, and a
first query that builds one is the slowest a server answers: a shop's
first search page took 2.68 s to its largest paint against 1.40 s warm. The
memory is what the reads would have taken anyway; only an index nothing
ever reads is held for nothing, and --warm names the rest. A
node's tenants open on their first request and close when idle, and
warming each would build every index of every tenant a request touches, so
there it is off unless asked. The native library has the same call,
fenec_warm, and the Rust API Database::warm and
warm_index.
Compaction
An update writes the whole record again at the end of the file, and a
delete writes a tombstone; the version left behind is dead until a
compact writes the live records into a new file. The server
runs one on its own: every 5 s it looks at each file it serves, the
database, each open tenant and a replica alike, and compacts one once half
of it is dead and the dead part is at least 64 MB -- or sooner, once a
third of what the compact writes is dead, where the file has reached three
quarters of the memory the system has for its pages or the server is
waiting for the disk, so that its reads stay in memory. The compact runs
beside the queries, and takes the write lock only to put the new file in
place.
| Flag | Default | What it does |
|---|---|---|
--auto-compact <ratio> | 0.5 | The share of the file that may be dead before a compact runs. |
--auto-compact off | Compacts only on compact. |
Details
Left alone, a file of updated rows grows by a record a write, and its live records end up a few to a page among dead ones: under YCSB's updates, a million records of 1 KB took the file to 4.7 to 6.5 GB on an 8 GB machine, and one read in five then waited for the disk. With the default ratio the file stays under about twice what the last compact left it at, and each compact writes the live records once for as many bytes of updates.
What it costs, measured with make compact-bench: a million
1 KB records, five million updates of a field at keys drawn at random from
one thread, buffered, while another reads whole records. With
auto-compaction the file went from 1.03 GB to between 2.1 and 3.0 GB and
back to 1.1 to 1.6 GB three or four times; each compact took 8 s at the
median with no lock held and held the write lock 6.1 to 12.6 ms to put its
file in place. The reads while a compact ran had a p99 of 1.3 to 5.3 ms.
The updates ran at about 30 000 a second through a compact, against 110 000
to 170 000 while the file was smaller: on this 8 GB machine, beside its
other programs, the page cache holds about 2 GB of the file, the size at
which a compact comes due, and past it an update reads its row from the disk
whether a compact runs or not -- 28 000 to 46 000 a second with
auto-compaction off. At 250 000 records, whose file fits, the updates ran at
153 000 a second through a compact of 0.3 s, and at no less than 83 000 in
its worst tenth of a second, against 180 000 with none. Once the updates
were done the reads ran at 435 000 a second, p99 2.2 µs, from the first
second. Without it the file ended at 6.2 GB, and the reads after the updates
started at 19 000 a second and took 25 s to reach 400 000, their p99 0.11 ms
over those 30 s. Durable, an fsync a write from 16 writers, 100 000 records
updated 400 000 times at about 900 a second: the file went from 103 to
211 MB and back three times, each compact held the write lock 13.7 to
14.8 ms -- two F_FULLFSYNCs of this Mac's among it -- and ended
at 190 MB against 515 without it; that file fits the page cache either way,
and the reads ran alike, p99 2.2 µs.
Before the file outgrows the page cache. fenecdb keeps no cache of
its own: a document is read where it lies in the mapped file, so what the
reads need in memory is every page of the file that holds a live record --
and under updates at keys drawn at random that is every page until a
compact (a million 1 KB records updated until the file stood at 2.1 GB
left one on every 16 KB page and on 94% of 4 KB ones). Once the file
outgrows what the system has for it, every read and update waits for the
disk in turn. So a file is also compacted once a third of what the
compact writes is dead, where its live records fit in that memory and the
file has reached three quarters of it -- which Linux reports exactly, its
available memory and a container's limit -- or the server takes 500 major
faults a second, which is how macOS shows it: under pressure it compresses
the other programs' memory and reports more room than it gives the file.
That costs up to four bytes written for each byte updated, against two at
half the file dead. A compact whose new file would not fit beside the old
one writes it past the page cache and reads it in after the swap, so the
old file, which the reads need until then, stays in memory. Measured with
make compact-bench in a container held to 2 GB
(docker run --memory 2g): at half the file dead the file stood
over 2 GB for 69 to 74% of the run, the five million updates took 358 to 511
s, 3 500 to 4 100 a second at the median, and the process took 811 000 to
900 000 major faults; with the memory rule, 80 to 100 s and 34 000 to
39 000, the file at most 1.9 GB, the reads during a compact p99 0.44 to
0.49 ms against 4.6 to 6.5, for 3.0 to 3.2 bytes written a byte updated
against 1.9 to 2.0. On this Mac beside 1 GB of memory held by another
program, 191 to 260 s against 82 to 535: macOS gives a growing file more
of the machine as it compresses the other programs' memory, which it did
in the fastest run at half the file dead and not in the slowest, and a
file kept smaller is not given it -- steadier, never the best case. A file
that fits in memory is compacted as before.
The compact copies what it reads under the read lock, writes the live
records into a side file with no lock held, and copies the writes made
meanwhile in rounds -- a few thousand documents looked up under the read
lock at a time and copied once it is let go -- until a round finds fewer
than a thousand; it reads every page of the new file in before the swap,
asking for them ahead in large reads, so no reader waits for one after it --
unless the two files would not fit in memory together, when it writes the
new one past the page cache and reads it in once the old one is gone.
The write lock is then held for the last of those writes, the side file's
fsync and the rename. The server looks for a compact due every 5 s, and a
look waits for a write under way rather than pass a compact over. A replica
compacts its own file the same way: a compact changes no document, and an
image its primary sends meanwhile fails the compact rather than be written
over. /_metrics has the file's size,
what a compact would give back of it and the compacts run
(Monitoring), and each is logged with the
file before and after and how long it held the lock.
Limits and health checks
| Flag | Default | What it does |
|---|---|---|
--max-connections | 100 | Refuses connections past it. |
--max-memory <MiB> | off | Refuses writes that would grow the data past it; reads go on. |
--idle-timeout <s> | 60 | Closes a quiet keep-alive connection (0 = never). |
--http-max-streams | 64 | Ceiling on a database's subscriptions, counted apart; on a tenant node, each tenant's. |
fenec-server --ping --http 127.0.0.1:8080 # exits 0 when the server is upDetails
One thread per connection means unlimited connections would leave the stack
memory ceiling to the client. Past --max-connections a client
gets 503; --idle-timeout closes a quiet connection. A
subscription holds a thread for as long as it lasts, so subscriptions are
counted apart as well, under --http-max-streams, a database's
own: on a tenant node each tenant's, where --max-connections is
what all of them share, and each tenant's streams and held writes take a
quarter of it at most unless its quota says otherwise
(quotas).
--max-memory puts a ceiling on the data footprint. Above it,
requests that grow the data are refused with 507, while reads,
del and compact keep working: hitting a ceiling with
no way out would be no better than a cgroup OOM. The point is to bring the
OOM forward: nobody warns you about a process felled by SIGKILL,
and both the last sync and the checkpoint are lost with it. The measurement
happens before the statement, so the overshoot is at most one
statement.
--ping asks GET /_health on the
--http address and exits 0 when it answers. The route needs no
token and takes no lock: every query takes the database lock first, so during
a long compact a probe that ran one would wait too and a healthy
server would look dead. Because the listener binds after the file is
opened, "healthy" also means "the documents are read".
What the server is doing is at /_metrics, in Prometheus's
text format: --metrics <address> opens a listener for it
alone, and --slow-ms <ms> logs every statement at least
that slow with its text, a JSON line on stderr. The metrics, a Grafana
dashboard, the log and the Datadog Agent's configuration are in
Monitoring and Datadog and
other monitoring systems.
Run it in a container
The image is under 3 MB: the binary and nothing else.
docker run -d --name fenecdb -v fenecdata:/data \
-p 127.0.0.1:8080:8080 -e FENEC_HTTP_TOKEN=a-token \
ghcr.io/fenecdb/fenec-serverThe image's command is --http 0.0.0.0:8080 --file
/data/data.fenec --sync 250.
- Set a token. Without one it refuses to listen beyond localhost.
- Run one instance per file. Two writers corrupt it: on Kubernetes,
a
StatefulSetwith one replica. - Write every flag out when you override the command, or the data file is dropped.
Details
The Dockerfile is two-stage: a static musl build copied into
scratch. There are no dependencies and the binary runs its own
health check (--ping), so the runtime image holds nothing but the
binary — no shell, no package manager, no libc. The image is 2.99 MB on
amd64 and 2.44 MB on arm64 — since no target is written in, both come out of
the same file. make docker builds it, and
docker compose up -d starts the compose file's.
The memory limit comes from the data. With no page cache there is
no knob to tune: the file is mapped and what the process holds is the
indexes, which is what --max-memory counts. Below that the
cgroup sends SIGKILL, the clean shutdown never runs and up to
one sync interval is lost. Set --max-memory to a third of the
container limit to bring that forward.
The restart cost depends on how it stopped.
docker stop sends SIGTERM and a checkpoint is
written, so the next open takes 9 ms. After kill -9 or a
cgroup OOM the graph of the last checkpoint is restored, and the vectors
written since it go in unlinked: the server answers once the documents
are read, and links them on a thread beside its queries,
near measuring each of them until then. The only checkpoint
the server writes on its own is the one at shutdown, but it appends its
graphs to the file as they change — a graph that changed in 10 000 nodes,
once the file has grown by three times its record, logged as
1 graph written into the file, with writes held off for
the 5 ms that takes at 100 000 × 768 — so what waits is what came
after the last of them: at 100 000 × 768 at most 10 000 vectors, linked
in 2.6 s with near at 1.56 ms p50 meanwhile. With no graph
in the file every vector waits: at 100 000 × 768 never checkpointed the
first near was answered 1.23 s after the start, against
67.7 s when the open linked them first, and linking takes 19 s.
fenec_vectors_unlinked counts down to 0.
Overriding CMD drops --file, and the container
comes up with an in-memory database. There is no shell in the image, so
docker exec ... sh does not work; what is left is
docker logs and docker run --rm
ghcr.io/fenecdb/fenec-server --help.
Two containers loading the same file and writing it corrupt it. On
Kubernetes that means a StatefulSet rather than a
Deployment, replicas: 1, strategy:
Recreate and a ReadWriteOnce volume — a rolling update
keeps two pods up together for a moment. There is a connection ceiling but no
queue: an excess connection does not wait, it gets 503.
More
- Mirror a PostgreSQL table.
fenec-server --follow postgres://...keeps a copy as the table commits, and serves it. See Import. - Embed it instead. The same engine runs inside a Rust program with no server at all (Embedded Rust), and in a browser tab (JavaScript).