fenecdb

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.

FlagDefaultBehaviour above the ceiling
--max-connections10053300, PostgreSQL's sorry, too many clients already
--idle-timeout <s>off57P05 on a session that has gone quiet
--max-message <MiB>64the length is read before the body, so an oversized body is never allocated
startup packet10 000 Bthe same as PostgreSQL; the only allocation before authentication
--max-memory <MiB>off53200 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.

--syncMeaningCost
alwaysfsync after every write statement~3 ms/write
250 (default)periodic; at most one interval is at risknegligible
offon shutdown onlynone

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 up

It 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 -d

Three things behave differently in a container:

  • A password is mandatory. The default CMD listens on 0.0.0.0, and with authentication off a non-loopback bind errors out and exits. Give FENECPG_PASSWORD, a Docker secret works too, or add --insecure deliberately.
  • 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/checkpoint at about 3×. 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 110 ms. After kill -9 or 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-token

There 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.