PostgreSQL server
fenec-pg speaks the PostgreSQL v3 wire protocol,
so psql, psycopg, node-postgres, JDBC and pgbouncer all connect. What is
compatible is the transport, not the language.
fenec-pg --listen 127.0.0.1:5433 --file data.fenec
psql -h 127.0.0.1 -p 5433 -U fenec
# fenec=# get articles select title near embed '[0.1, 0.2]' limit 5;fenecdb does not speak SQL. It does work with the connection tooling of the
PostgreSQL ecosystem: vectors use the same text representation as pgvector
([1,2,3]) and errors map to SQLSTATE codes
(42P01, 42601, 42804,
57014).
Sessions
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×.
Resource ceilings
One thread per connection means unlimited connections would leave the stack memory ceiling to the client.
| Flag | Default | Behaviour above the ceiling |
|---|---|---|
--max-connections | 100 | 53300, PostgreSQL's sorry, too many clients already |
--idle-timeout <s> | off | 57P05 on a session that has gone quiet |
--max-message <MiB> | 64 | the length is read before the body, so an oversized body is never allocated |
| startup packet | 10 000 B | the same as PostgreSQL; the only allocation before authentication |
--max-memory <MiB> | off | 53200 on statements that grow the data |
--max-memory puts a ceiling on the data footprint. Above it,
statements that grow the data are rejected 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, whose
body --max-message bounds as well.
Durability
Writes accumulate in a 1 MB buffer. --sync decides when they
reach the disk.
--sync | Meaning | Cost |
|---|---|---|
always | fsync after every write statement | ~3 ms/write |
250 (default) | periodic; at most one interval is at risk | negligible |
off | on shutdown only | none |
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. Sessions
waiting on the lock do not wait for nothing — they come back with
57P01. Under kill -9 you get exactly what the chosen
policy promised, and no more.
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 — 110 ms instead of 10.2 s at 100 000 × 128.
The cost is that the whole image is produced in memory right then (peak
about 3× the file) and that shutdown takes longer on a large database.
--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.
Health checks
fenec-pg --ping # 0 means upIt connects, authenticates and exits — the same depth as
pg_isready, and it deliberately runs no query. Every query takes
the database lock first, so during a long compact the probe would
wait too and a healthy server would look dead. Measured at roughly 100 ms.
Because the listener binds after the file is opened, "healthy" also means "the index is ready" — a start period has to be long enough to cover the graph build.
Query cancellation
CancelRequest is a real cancellation: the pending lock is
released and the query returns 57014 at about 1 ms, with the
connection still usable. A cancel arriving while idle, or one carrying the
wrong secret key, is ignored.
Authentication
With --password, the default is SCRAM-SHA-256 (RFC
7677): the password never crosses the wire and the exchange cannot be replayed.
--auth cleartext exists for clients that cannot speak it, and
--user restricts the user name.
Pass the password with --password-file or the
FENECPG_PASSWORD environment variable —
argv shows up in ps output.
There is no TLS. SSLRequest is refused with
N, and a client connecting remotely gets a
NoticeResponse saying the connection is unencrypted. Listening
on a non-loopback address without authentication is refused outright; it can
be opened deliberately with --insecure. On an open network, put
it behind a TLS terminator such as stunnel or nginx stream.
Extended protocol
Describe is answered without running the query: the parameter
count comes from the largest $n in the statement, and the columns
and type OIDs from the schema — with near, _score is
reported too. Parameter types are left unspecified; the client sends text and
the server infers. RowDescription goes exactly once, on
Execute when Describe is skipped.
Both directions
The same protocol code runs the other way round. fenec import
connects to a real PostgreSQL server and streams
COPY ... TO STDOUT. The server half of SCRAM lives in
scram.rs and the client half in client.rs; both are
hand-written, so they test each other. See Import.
Plugins
The plugin system takes after PostgreSQL's extension model: scalar functions, write hooks and transport adapters can be registered.
db.install_plugin(&PgPlugin)?; // pg_version(), pg_typeof(), to_pgvector()Containers
The Dockerfile is two-stage: a static musl build copied into
scratch. There are no dependencies and the binary runs its own
health check, so the runtime image holds nothing but the binary — no shell, no
package manager, no libc. The image is 1.55 MB, and since no target is
written in, arm64 and amd64 come out of the same file.
make docker # build the image
make docker-run PGPASS=secret # 127.0.0.1:5433, named volume
FENECPG_PASSWORD=secret docker compose up -dThree things behave differently in a container:
- A password is mandatory. The default
CMDlistens on0.0.0.0, and with authentication off a non-loopback bind errors out and exits. GiveFENECPG_PASSWORD, a Docker secret works too, or add--insecuredeliberately. - The memory limit comes from the data. With no page cache there is
no knob to tune: open peaks at about 2× the file and
compact/checkpointat about 3×. Below that the cgroup sendsSIGKILL, the clean shutdown never runs and up to one sync interval is lost. Set--max-memoryto a third of the container limit to bring that forward. - The restart cost depends on how it stopped.
docker stopsendsSIGTERMand a checkpoint is written, so the next open takes 110 ms. Afterkill -9or a cgroup OOM the graph is rebuilt instead: 10.2 s at 100 000 × 128.
Every default has to be written out by hand. Overriding
CMD drops --file and the container comes up with an
in-memory database.
docker run -d --name fenecdb -v fenecdata:/data \
-p 127.0.0.1:5433:5433 -p 127.0.0.1:8080:8080 \
-e FENECPG_PASSWORD=secret fenecdb \
--listen 0.0.0.0:5433 --file /data/data.fenec --sync 250 \
--http 0.0.0.0:8080 --http-token a-tokenThere is no shell in the image, so docker exec ... sh does not
work; what is left is docker logs and
docker run --rm fenecdb --help.
A single instance, always. Two containers loading the same file
into memory 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 pool: an excess
connection does not wait, it gets 53300. Put pgbouncer in front
to queue.