Runbook for deploying and running a Version 1 VialKeeper host. Applications
consume the host through the versioned HTTP /v1 API; see README.md
for the client-facing route examples. Elixir modules are internal services, not
an application integration surface.
VialKeeper's product data model, structured query, subscriptions, views, replication, federation, materialized views, and shadow read semantics are core. Full-text search is a supported, rebuildable sidecar. Deployment, configuration, lifecycle, security, maintenance, observability, shadow control, and the required shipped console are administration. This runbook owns the administration procedures; it does not define alternate core semantics.
Production and staging run an assembled OTP release. mix is for local
development and CI only.
VIAL_KEEPER_ROOT/
host.toml # listener, auth, TLS, limits, federation, …
registrations.json # routing only (UUID → relative path)
notes.vialkeeper/ # one bundle per logical database
<backend data> # engine-owned durable artifact (turso.db by default)
blobs/
tmp/
notes.vialkeeper.lease # transient exclusive ownership (not data)
_derived/… # optional derived DBs from materialized views
Pinned toolchain: Elixir 1.20.2 / OTP 29.0.4 (mise.toml). Rust stable
toolchain required (the vendored native/tantivy_ex and the storage driver
compile NIFs at build time).
export MIX_ENV=prod
mix release.buildOutput: _build/prod/rel/vial_keeper/ (includes ERTS). Copy that tree to the
target host (same OS/ABI as the build machine).
/opt/vial_keeper/bin/vial_keeper eval \
'IO.inspect(VialKeeper.Diagnostics.runtime(), pretty: true)'Diagnostics.runtime/0 reports Mix app version and runtime / selected-backend
identity from the BEAMs. This eval invocation is an operator-only release
diagnostic; it does not make the underlying Elixir module a supported client
API and it does not read git metadata.
export VIAL_KEEPER_ROOT=/var/lib/vialkeeper
/opt/vial_keeper/bin/vial_keeper daemon # background
# or: /opt/vial_keeper/bin/vial_keeper start/opt/vial_keeper/bin/vial_keeper pid
/opt/vial_keeper/bin/vial_keeper remote # remote console
/opt/vial_keeper/bin/vial_keeper stopOn first start in an empty root, VialKeeper creates the directory and a fully
commented host.toml from priv/host.toml. Existing host.toml is never
overwritten.
Default listener: loopback only 127.0.0.1:4000. Binding a non-loopback
address requires [auth] enabled = true or [tls] enabled = true, unless
you set [security] allow_insecure_remote = true (risky).
Stop with bin/vial_keeper stop or SIGTERM. Open databases close; each runtime
releases its ownership lease. Prove SIGTERM closes bundles before the process
exits: after stop, no .lease holders remain and -wal/-shm are absent on a
clean close.
Ship deploy/vialkeeper.service with the OTP release. Create a dedicated
vialkeeper user and group. Install the unit so systemd supervises the release
foreground command (bin/vial_keeper start):
sudo useradd --system --home /var/lib/vialkeeper --shell /usr/sbin/nologin vialkeeper
sudo install -d -o vialkeeper -g vialkeeper -m 0750 /var/lib/vialkeeper
sudo ./deploy/install-release.sh /path/to/rel/vial_keeper 2026-08-29T120000Z
sudo systemctl status vialkeeperUnit settings of note:
User/Group=vialkeeperEnvironment=VIAL_KEEPER_ROOT=/var/lib/vialkeeperTimeoutStartSec/TimeoutStopSec= 120LimitNOFILE=65536ProtectSystem=strictwithReadWritePaths=/var/lib/vialkeeper- Restart policy
on-failure
Runtime package on Debian: libncurses6 (required by the clean-host restore
drill). Prefer the Debian portable CI artifact (debian_portable_release
job), not the ubuntu-latest host-built release, when the production OS is
Debian.
Optional helper: deploy/install-release.sh copies the release under
/opt/vial_keeper-releases/<id>, symlinks /opt/vial_keeper, restarts the
unit, and optionally health-checks GET /v1/databases when VIALKEEPER_TOKEN
is set. Do not auto-deploy VialKeeper on every application commit.
mix run --no-haltSame supervision tree. Do not use Mix as the production entrypoint.
All host config is one editable file under the database root. Edit, then restart.
| Section | Purpose |
|---|---|
[listener] |
ip, port |
[limits] |
Host ceilings (bodies, batches, subscriptions, views, …) |
[admission] |
Fair scheduling weights / reserved slots per work class |
[federation] |
Cross-DB query bounds + [[federation.saved_query]] |
[web_ui] |
Embedded admin console on/off |
[auth] |
Bearer tokens (SHA-256 digests) |
[tls] |
HTTPS cert/key paths (relative to root) |
[security] |
allow_insecure_remote |
[observability] |
otlp_endpoint (empty = no exporter, no network) |
[storage] |
engine: "turso" (default) or "sqlite" |
Template with defaults: priv/host.toml.
[storage].engine selects the storage engine for every database on the host.
A host without a [storage] table, or without engine, runs Turso.
| Engine | Concurrent document writes | Bundle artifact |
|---|---|---|
turso (default) |
up to 16 (writer_pool_size caps it) |
turso.db |
sqlite |
1 | the SQLite artifact |
Bundles are engine-specific. A bundle belongs to the engine that created it:
opening or registering it on a host running the other engine fails with
unsupported_format ("bundle was created by the … storage engine"), and
nothing converts a bundle between engines. To move data to the other engine,
create a database on a host running that engine and replicate into it. An
unknown engine name fails startup with a host.toml: error.
When [auth] enabled = true:
- Every
/v1request needsAuthorization: Bearer <token>. - Every
/ui/fragments/…and/ui/actions/…request needs the same header. /uishell and/ui/assets/…stay anonymous so the browser can collect a token. They expose no database state.
Generate a token:
/opt/vial_keeper/bin/vial_keeper token
# token: <64-char hex> ← clients send this
# digest: <64-char hex> ← paste into host.toml[auth]
enabled = true
tokens = ["<digest>"]Restart. Clients send the raw token, never the digest.
curl -H "Authorization: Bearer <token>" http://127.0.0.1:4000/v1/databasesRotate with zero downtime: add the new digest, restart, remove the old digest,
restart. There is no runtime revocation API. Failures always return
unauthorized (HTTP 401) with the same message.
Remote replication to an auth-enabled target: put the raw token in the job
endpoint as auth_token. The replication wire requires Accept-Encoding: zstd.
JSON bodies use Content-Encoding: zstd and x-vialkeeper-uncompressed-length.
Generic HTTP compression is disabled on the listener so public /v1 JSON is
never auto-compressed.
When [tls] enabled = true, the listener serves HTTPS only (no parallel
plaintext port). Paths are relative to the database root:
[tls]
enabled = true
certfile = "cert.pem"
keyfile = "key.pem"Self-signed for local tests:
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout "$VIAL_KEEPER_ROOT/key.pem" -out "$VIAL_KEEPER_ROOT/cert.pem" \
-subj "/CN=localhost"Use a real CA in production. Replication uses TLS when base_url is https://….
Clients never send absolute filesystem paths. Create/register take relative
paths that must not escape the root (.., symlinks).
notes.vialkeeper/
├── <backend data> # metadata, revisions, indexes, views, jobs, …
├── blobs/ # attachment representations (<prefix>/<digest>.blob)
└── tmp/ # incomplete uploads and Tantivy search generations (not authoritative)
registrations.json is routing only:
{
"version": 1,
"databases": [
{"uuid": "…", "path": "notes.vialkeeper", "database_kind": "ordinary"}
]
}database_kind is a reconstructible hint. Backend metadata inside the bundle
wins on open. Unregistered bundles under the root stay inert — VialKeeper does
not auto-adopt them.
| Action | How |
|---|---|
| Create | POST /v1/databases {"path":"notes.vialkeeper"} |
| Register existing | POST /v1/registrations {"path":"…"} |
| List / info | GET /v1/databases, GET /v1/databases/:uuid |
| Close | POST /v1/databases/:uuid/close |
| Unregister | DELETE /v1/registrations/:uuid (bundle files kept) |
Public identity is the UUID, never the path. Duplicate UUID →
duplicate_database_uuid. Missing file after registration →
database_unavailable (manifest entry is not silently dropped).
This section is the operator procedure for copying, moving, and restoring a closed bundle. Keep the complete bundle and its verification artifacts together throughout the procedure.
flowchart LR
A[Stop writers / disable continuous deps] --> B[Close database]
B --> C[Copy whole .vialkeeper dir]
C --> D[Offline integrity-check + manifest]
D --> E[Place under destination root]
E --> F[POST /v1/registrations]
F --> G[Integrity-check again]
- Stop writes. Disable continuous replication that needs the DB open. If this DB is a source for an enabled materialized view, disable that materialization first. If it is itself derived, disable its materializer first.
POST /v1/databases/:uuid/close. Live subscriptions end withclosed. Confirm no attachment upload/download/GC is still running.- Copy the complete closed
.vialkeeperdirectory with ordinary OS tools. Omit.lease;tmp/may be omitted (rebuildable search cache and incomplete uploads). - Immediately run the offline logical and physical integrity check and generate a sidecar
backup manifest
notes.vialkeeper.manifest.jsonnext to the copy (never insideblobs/ortmp/) that records generation id, UTC time, source UUID and relative path,Diagnostics.runtime/0, storage versions, bundle sizes, SHA-256 of the backend artifact and ofblobs/, and that post-close integrity result. Do not treat a failing bundle as a verified generation. SeeBackup generation manifest (MAINT-006)below andVialKeeper.Backup.Manifest. Keep generations off the live host and off live replica disks. Ordinary backup rotation and longer archive retention are separate clocks. - At the destination (relocation) or on a clean restore host: place the bundle, then register the relative path.
- Traffic addresses the same UUID (document routes auto-open, or open via catalog). After restore, run integrity-check again, read a sample of documents and attachments, rebuild full-text indexes if search is required, and inspect replication jobs / materializations before enabling them.
A backup generation is a closed-bundle copy plus a sidecar JSON manifest
that makes the generation auditable. The manifest is stored next to the
bundle directory, never inside it. For a bundle notes.vialkeeper the
manifest is notes.vialkeeper.manifest.json in the same parent directory.
When a generation directory contains one bundle, the same file may be named
manifest.json inside the generation directory as long as it is not placed
under blobs/ or tmp/.
The manifest format is versioned. Version 1 is the only version in V1 and
is defined by VialKeeper.Backup.Manifest.
Required fields:
manifest_version—1generation_id— UUID v4 for this generation (operator ormixgenerated)created_at— ISO8601 UTC (e.g.2026-08-27T14:20:00Z)database_uuid— source database UUID (db_meta.database_uuid)source_path— relative bundle path as registered (e.g.notes.vialkeeper)bundle_bytes— total bytes of the bundle directorydiagnostics—VialKeeper.Diagnostics.runtime/0map (at leastapp_version)storage—file_format_version,logical_schema_version,revision_algorithm_version,canonicalization_version,replication_protocol_majorfromdb_metaartifacts.sqlite—relative_path(backend artifact),bytes,sha256(lowercase hex of the file)artifacts.blobs—count,bytes,sha256(tree hash over sorteddigest:bytes:sha256lines), andentrieswith per-blobdigest,bytes,sha256,relative_pathintegrity— the offline logical and physical integrity result taken from the closed copy;okmust betrue
Example (truncated):
{
"manifest_version": 1,
"generation_id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-08-27T14:20:00Z",
"database_uuid": "123e4567-e89b-12d3-a456-426614174000",
"source_path": "notes.vialkeeper",
"bundle_bytes": 1234567,
"diagnostics": {
"app_version": "0.1.0",
"elixir": "1.20.2",
"otp": "29",
"protocol_major": 1,
"storage_backend": "<storage-backend>",
"backend": {"engine": "sqlite", "sqlite": "3.45.0"}
},
"storage": {
"file_format_version": 1,
"logical_schema_version": 1,
"revision_algorithm_version": 1,
"canonicalization_version": 1,
"replication_protocol_major": 1
},
"artifacts": {
"sqlite": {"relative_path": "<backend-artifact>", "bytes": 123456, "sha256": "abc..."},
"blobs": {"count": 2, "bytes": 9876, "sha256": "def...", "entries": [
{"digest": "abc...", "bytes": 123, "sha256": "fff...", "relative_path": "blobs/ab/abc....blob"}
]}
},
"integrity": {"ok": true, "checked_at": "2026-08-27T14:19:55Z"}
}Operator procedure with the Mix task (offline, no product API):
# after POST /v1/databases/<uuid>/close and copying the closed bundle
mix vialkeeper.backup.manifest /backups/2026-08-27T142000Z/notes.vialkeeper \
--source-path notes.vialkeeper
# writes /backups/2026-08-27T142000Z/notes.vialkeeper.manifest.json
# verify the physical inventory later from an assembled release
/opt/vial_keeper/bin/vial_keeper eval 'IO.inspect(VialKeeper.Backup.Manifest.verify("/backups/.../notes.vialkeeper", "/backups/.../notes.vialkeeper.manifest.json"))'Manifest generation runs the complete logical and physical integrity check
against the immutable closed copy. Later physical verification checks that
the bundle has no .lease or hot journal, that
bundle_bytes, sqlite bytes/sha, and blobs count/bytes/tree-hash plus
per-entry hashes match the files, and that manifest_version, UUIDs, and
integrity.ok are valid. Storage versions are recorded for audit; a full
logical re-check is POST /v1/databases/:uuid/integrity-check after restore.
Retain generations off the live VIAL_KEEPER_ROOT and off live replica disks.
A generation that has never been restored is unverified. Checksums prove the
copy; only a restore drill proves the bits open.
A copy keeps the original UUID. Two copies of the same UUID on one host are rejected. Copying is backup/relocation, not cloning.
A replica is a different UUID that receives replicated revisions and
blobs. Logical deletes replicate. Jobs, views, indexes, FTS generations, and
host.toml do not. Do not use replication as protection from application
mistakes; keep independent backups.
Do not copy an active crash-recoverable bundle piecemeal: keep every backend-owned recovery artifact with the durable data until recovery finishes. Reopen, close, then copy. Engine-specific journal pairing is documented in lib/vial_keeper/storage/turso/BACKEND.md (log and WAL sidecars) and lib/vial_keeper/storage/sqlite/BACKEND.md.
Both engines run disk databases with synchronous=NORMAL (SQLite adds a
64 MiB WAL autocheckpoint). Application crash recovers every acknowledged
commit. OS/power loss can drop a suffix of acknowledged commits. Clean close
is the portability point with no journal sidecars.
Derived bundles are often created as
_derived/<slug>--<short-uuid>.derived.vialkeeper. Path and suffix are
operator hints; database_kind = derived is authoritative. A clean derived
bundle can be moved/renamed and re-registered; materialization state stays
inside the bundle. Back up derived bundles if rematerializing from sources
is unacceptable.
mix check.full includes MAINT-007 release evidence:
test/end_to_end/clean_host_restore_drill_test.exs. It requires a working
Docker or Podman daemon (docker info / podman info); the gate fails
closed when neither engine responds.
The drill seeds a closed bundle on a production release daemon, copies it to a
fresh destination root, and restores it inside a glibc container that has only
the OTP release and a bind-mounted VIAL_KEEPER_ROOT (not an overlay data
directory). Restored-data assertions use HTTP /v1 only: register,
integrity-check, sample document and attachment round-trip, full-text rebuild
and query, and replication jobs present but not enabled. An operator-only
release eval also verifies that Diagnostics.runtime/0 reports a non-empty
app_version. The test prints restore_ms for RTO evidence.
First run on a machine may pull container images. When the host-built release is not ABI-compatible with the pinned restore image, the test builds a portable release inside the matching Elixir builder container before the drill proceeds.
V1 opens only V1. A foreign, future, empty, or partial bundle is
unsupported_format (HTTP 409, not retryable) and is not rewritten. There is
no in-place migrator and no in-place downgrade. This is not a compatibility
promise.
Before any later format change:
- Take a verified closed-bundle backup generation (MAINT-006) and keep it off the live host.
- Upgrade the release only after that generation exists.
- Rollback is: run the previous release against that pre-upgrade generation. Do not open a migrated file with the old binary.
- If open returns
unsupported_format, restore the pre-upgrade generation. Do not repair the backend metadata artifact in place.
When [web_ui] enabled = true (default):
http://127.0.0.1:4000/ui
Server-rendered HTMX. Assets are compiled into the release (no CDN, no
runtime frontend directory). With auth enabled, enter the raw bearer token in
the console; it stays in browser sessionStorage and is sent as
Authorization: Bearer on state-bearing requests. No server UI session /
cookie. Logout or closing the tab clears the token.
The console calls the same project-owned application services as /v1; it is
not a second data API. Status pages use bounded HTMX polling (no second
WebSocket stack).
Each open database holds exclusive ownership through the selected storage
backend. A second owner fails immediately with database_in_use (HTTP 409,
retryable).
Safe recovery:
- Confirm no live VialKeeper process owns the DB (
bin/vial_keeper pid, process list). A healthy owner always holds the lease while open. - After a crash, a leftover
.leasefile with no live exclusive lock can be reopened normally. Do not delete.leasewhile another process may still hold the lock. - Prefer letting the crashed BEAM die, then retry open. Only remove a stale
.leasefile when you are sure nothing has the database open. - Never delete or rewrite backend data artifacts to “clear” a lease.
Both storage engines implement ownership with the same exclusive SQLite sidecar lease; see BACKEND.md.
curl -X POST http://127.0.0.1:4000/v1/databases/$UUID/integrity-check \
-H 'content-type: application/json' -d '{}'Checks logical integrity (revision ancestry, attachment manifests vs physical
blobs, winners, changes references, index consistency) plus any backend
physical probes. Failures → integrity_violation. Rebuild a bad logical
index with the index rebuild endpoint after you inspect details.
Unreferenced physical blobs are reclaimable orphans, not proof that a committed revision is missing bytes.
curl -X POST http://127.0.0.1:4000/v1/databases/$UUID/compact \
-H 'content-type: application/json' -d '{}'Runs compact-retention for the database (subject to its retention config).
May schedule attachment GC afterward. Attachment GC is also available from
the Web UI maintenance actions (not a separate public /v1 GC route).
Per-database retention defaults (GET/PUT …/config):
"retention": {
"mode": "disabled",
"history_depth": 0,
"peer_expiry_ms": 86400000,
"schedule": "disabled"
}mode is disabled or stable_frontier. Host ceilings still bound values.
Uploads: POST …/attachments/upload with application/octet-stream.
Downloads: POST …/attachments/get with {id, revision?, name}.
On disk every blob is one file, blobs/<2-hex-prefix>/<digest>.blob: the
stored payload (raw bytes or a Zstandard frame, chosen once at ingest by a
compressibility probe) followed by a fixed 92-byte trailer carrying the
format version, encoding, logical/payload lengths, logical digest, and
payload checksum. Downloads always return the original logical bytes;
integrity_check validates both the payload checksum and the decoded
logical digest. Do not edit or truncate these files — a payload or trailer
mismatch makes the blob unreadable and replication of it fails cleanly.
Database config keys (host-bounded):
attachments.max_attachment_bytesattachments.max_concurrent_attachment_readsattachments.max_concurrent_attachment_writes
Admission is separate from database-owner admission. Overrun → retryable
attachment_overloaded. Oversized upload → payload_too_large (no revision
reference). Changing limits does not rewrite stored blobs.
Batch uploads stream independent blobs with bounded concurrency. The product
default is 16 writers, capped by that database's
attachments.max_concurrent_attachment_writes (ordinary database default 4).
Raise the database write limit when a host should allow the batch default.
A blob is live if a retained revision manifest references it or an unexpired pending upload protects it. After compact removes a manifest, GC deletes orphan files under an exclusive coordinator barrier. Crash during GC can leave orphans — rerun GC after recovery.
Each open disk database has a bounded snapshot read pool beside the writer.
Classified reads use up to read_pool_size readonly connections (FIFO, extra
waits capped by read_queue_limit).
Document writes (put, delete, conflict resolution and bulk writes) run on a
writer pool of up to writer_pool_size writer connections when the storage
backend supports more than one writer; extra writes wait in a FIFO queue
capped by write_queue_limit. The effective pool size is
min(writer_pool_size, backend writer limit), and the pool exists only when
that is above one. The Turso engine supports 16 concurrent writers; a write
that conflicts with another writer on the same row is retried inside the pool
and reaches the client only as database_overloaded once its retries run
out. The SQLite engine supports one writer, so with SQLite every write goes
through DatabaseOwner. Writes to the same document always
run one at a time, in arrival order. A full write queue returns the retryable
database_overloaded error.
Other writes (configuration, imports, index, view and derived-view
maintenance, checkpoints, jobs, peer positions and pending blobs) and
exclusive work go through one DatabaseAdmission permit onto
DatabaseOwner. While a writer pool exists, they first pause it and wait for
running document writes. Exclusive commands (compact, integrity, rebuild,
live-digest, blob cleanup, close) also drain in-flight snapshots first.
Writes may commit out of order. The changes feed and current_sequence only
advance past a sequence once every lower sequence has committed or rolled
back, so feed readers never skip a write. A successful write response means
the changes feed already includes that write. Sequences are not contiguous: a
write that fails or changes nothing leaves its reserved sequence unused, and
reopening a database may skip up to 4096 sequences.
| Class | Typical work |
|---|---|
foreground |
Client document / query / changes / admin |
subscription |
Live-query snapshots and shared changes reads |
replication |
Local revision / checkpoint work for jobs |
maintenance |
Compact-retention and background maintenance |
[admission] sets positive service weights and reserved queue slots for
writer work. Scheduling is deterministic weighted round-robin (FIFO inside
a class). Total active+queued writer work is capped by [limits] admission_limit.
Slow attachment streams, network blob transfers, long waits, and heartbeats
do not hold an owner permit after their short metadata step.
database_overloaded means admission capacity for that class/request is full
(retryable).
Persistent jobs live in the owning database bundle. Workers are transient.
# Inspect / control under:
# /v1/databases/:uuid/replicationsJob create body (shape):
{
"mode": "continuous",
"direction": "push",
"enabled": true,
"endpoint": {
"kind": "remote",
"database_uuid": "…",
"base_url": "https://peer:4000",
"auth_token": "raw-token-if-needed"
}
}Local endpoint: {"kind":"local","database_uuid":"…"}.
| State | Meaning |
|---|---|
idle |
Registered / waiting |
handshake / install_boundaries |
Compatibility + retention boundaries |
bootstrap |
Snapshot when incremental history is unavailable |
read_changes / diff |
Bound work and missing revisions |
transfer |
Concurrent chain/blob fetch before import |
import |
One atomic target import after bytes are durable |
checkpoint_target / checkpoint_source |
Durable checkpoints in order |
report_peer |
Safe-position / peer report |
waiting |
Continuous job caught up |
backoff |
Retryable failure; jittered retry |
completed |
One-shot finished |
failed |
Non-retryable or cancelled |
Per-DB replication knobs (host-bounded):
max_concurrent_chain_fetches, max_concurrent_blob_transfers,
max_transfer_bytes_in_flight. Byte budget uses original attachment lengths
for admission only; it does not change blob identity or compression.
Enabled continuous jobs resume after catalog startup. Cancellation is cooperative: transfer tasks stop; an already committing import/checkpoint is allowed to finish.
Remote wire behavior: every non-empty JSON request/response between peers is
one bounded Zstandard frame (Content-Encoding: zstd plus
x-vialkeeper-uncompressed-length); attachment payloads transfer as the stored
.blob representation byte-for-byte
(application/vnd.vialkeeper.blob-representation, no HTTP Content-Encoding),
so the target never re-encodes. Troubleshooting: a peer that answers with
plain uncompressed JSON on wire routes is not an VialKeeper replication
endpoint (or an intermediary rewrote the response) — the job fails with
replication_incompatible; malformed frames, wrong declared lengths, and
over-limit bodies are rejected deterministically with invalid_request /
payload_too_large and never advance checkpoints; plain 5xx pages from load
balancers stay retryable.
What replicates vs what stays local: see README.md.
Shadow control is disabled by default. A source host can manage a
generation-fenced read-only shadow through its source-side
/v1/databases/:uuid/shadow route when [shadow_controller].enabled = true.
The request is asynchronous: PUT persists desired state first, then
removes any superseded route and queues local reconciliation, while GET
returns redacted desired/observed state. Disable persists the fenced
disabled definition before the matching route is removed. Source close
drops only the local route and does not wait for the worker; unregister
also converts desired state to disabled cleanup. After a host restart the
in-memory route table starts empty and reconcilers resume from durable
desired state when the controller is enabled. The source route accepts only
ordinary source databases and does not expose tokens or managed filesystem
paths.
The worker side is configured independently:
[shadow_worker]
enabled = true
storage_root = "shadows" # relative to the database root
control_token_digests = ["<sha256-digest>"]
allowed_attachment_roots = ["/srv/vialkeeper/cas"]
allowed_source_origins = ["https://source.example"]control_token_digests authenticates only /v1/control-plane/...; it is
intentionally separate from [auth].tokens. The control plane accepts and
returns bounded Zstandard JSON frames, and its routes are not part of the
ordinary public shadow API. Managed bundles are derived from the exact source
UUID, shadow UUID, and generation; the worker keeps a journal and validates
that the bundle path remains beneath storage_root before inspect or destroy.
For a remote worker, add a named [[shadow_controller.location]] with
kind = "remote", control_base_url, control_bearer_token,
control_timeout_ms, and read_timeout_ms. Local locations omit remote
fields. The source's source_base_url and source_bearer_token are used for
source-side worker operations and must be kept as secrets. Attachment roots
are existing external CAS directories; VialKeeper does not copy them into the
shadow bundle.
The worker lifecycle is bootstrapping until a later replication proof marks
the exact generation ready. A failed or incompatible control request leaves
the source route unready and never makes a partially provisioned generation
public.
Public document and attachment reads default to eventual when a ready shadow
route exists. Send x-vialkeeper-read-consistency: primary to bypass the shadow
explicitly; eventual may also be sent explicitly. The source keeps an exact
in-memory route snapshot containing the source/shadow UUIDs, generation, operation ID,
and endpoint; a route is never selected by UUID alone. Shadow point and bulk
read responses include x-vialkeeper-read-served-by: shadow and the durable
x-vialkeeper-source-watermark. Source-served responses, including fallback and
explicit primary consistency, identify source and omit the watermark.
A lagging document, revision, or attachment miss falls back the request (the whole original batch for bulk-get) and keeps the ready route. Transport, protocol, identity, or store failure falls back once, removes only the matching generation snapshot, and asks the reconciler to re-inspect. Routing stays off while the source is closed or unregistered. Attachment reads use the external CAS as a read-only physical representation stream; the worker never writes or copies those bytes into the managed shadow bundle.
POST /v1/databases/:uuid/query/stream (NDJSON). Host ceilings:
max_query_subscriptionsmax_query_subscription_membersmax_query_subscription_buffered_eventsmax_query_subscription_heartbeat_ms
Slow clients that exhaust delivery credit get subscription_overloaded;
other subscriptions continue. Subscription runtime state is not durable and
is not restored after reopen. Database close terminates streams with
closed.
Definitions and materialization live in the owning database bundle and do
not protocol-replicate. Host ceilings: max_views_per_database,
max_view_batch_changes, max_view_consistent_wait_ms.
Ad-hoc: POST /v1/federation/query. Bounds in [federation]:
max_sources, max_concurrent_sources, max_candidates, max_execution_ms.
Saved queries (no derived DB) live only in host.toml:
[[federation.saved_query]]
name = "open-tasks"
sources = ["123e4567-e89b-12d3-a456-426614174000"]
query_json = '{"selector":{"/state":"open"},"sort":[{"path":"/value","direction":"asc"}],"limit":50}'Edit file → restart. List/execute over HTTP. Bookmarks are not allowed inside saved definitions.
Federation is strict: one bad source fails the whole request. No atomic cross-DB snapshot.
Lifecycle: POST/GET /v1/materialized-views, plus
/:uuid/{refresh,rebuild,enable,disable}.
flowchart TB
S1[Source ordinary DB] --> W[Materializer worker]
S2[Source ordinary DB] --> W
W --> D[Derived bundle<br/>read-only generated docs]
- External put/delete/bulk/resolve, attachment mutation, and replication
import on the derived DB →
derived_database_read_only. - Derived DBs remain readable / queryable / indexable; they may be a replication source.
- If a source is down, last committed derived result stays readable; status reports stale / source-unavailable.
- Disable materializers before closing sources or the derived DB.
- While
rebuilding, do not treat status ascurrent.
Host ceilings: max_materialized_view_sources,
max_materialized_view_concurrent_sources,
max_materialized_view_batch_documents,
max_materialized_view_retry_delay_ms.
Important [limits] keys (see priv/host.toml for defaults):
admission_limit— active + queued owner ops per open DBread_pool_size— concurrent snapshot readers per open disk DB (1..32, default4)read_queue_limit— queued classified reads waiting for a reader (1..4096, default128)writer_pool_size— concurrent document writers per open disk DB (1..64, default8); the effective size is capped by the storage engine's writer limit (16 for Turso, 1 for SQLite)write_queue_limit— queued document writes waiting for a writer (1..4096, default128)max_open_databases,max_replication_workers- Replication chain-fetch / blob-transfer / batch / in-flight-byte ceilings
- Live-subscription and local-view ceilings
- Materialized-view ceilings
max_request_bytes,max_document_bytes,max_document_id_bytesmax_bulk_operations,max_changes_batch,max_query_resultsmax_query_execution_ms— ceiling for one query (default5000)max_search_rebuild_ms— ceiling for one full-text index rebuild oncreate_index/rebuild_index(default300000, five minutes). Raise this for large corpora; it is independent of the query budget. Restart after editinghost.toml.max_search_rebuild_batch_documents— maximum winning documents submitted to Tantivy in one rebuild batch (default500). Lower this to reduce per-call memory and latency; raise it only when the writer budget allows it.max_search_writer_memory_bytes— Tantivy writer memory budget (default50000000, with Tantivy's 15 MB minimum). This bounds the native indexing writer; it is independent of the rebuild timeout.max_search_candidates— maximum full-text candidates retained before selector, predicate, or sort post-filtering (default10000). Queries that exceed this bound fail withresource_limitinstead of silently truncating results.max_json_nesting_depth- Attachment size and concurrency ceilings
Federation bounds are under [federation] (host-level: no single source DB
owns an ad-hoc federated request).
| Code | Typical cause |
|---|---|
invalid_request |
Unknown JSON fields, bad shape |
unauthorized |
Missing / wrong bearer token |
database_in_use |
Lease held / second owner |
database_not_closable |
Active work / continuous dependency |
database_overloaded |
Owner admission or snapshot read-pool queue is full |
subscription_overloaded |
Live subscription / buffer saturated |
attachment_overloaded |
Attachment read/write/GC saturated |
view_not_found / view_name_conflict |
Local view lifecycle |
view_not_caught_up |
Consistent view wait expired |
derived_database_read_only |
Mutation/import on derived DB |
database_unavailable / database_closed |
Missing bundle / closed runtime |
duplicate_database_uuid |
Two registrations for one UUID |
revision_conflict / checkpoint_conflict |
CAS / leaf races |
bookmark_stale |
Continue after source/plan change |
resource_limit / payload_too_large |
Size / count caps |
integrity_violation |
Integrity check or corrupt revision |
replication_already_running |
Worker exclusivity on same job id |
history_truncated |
Changes cursor behind retention floor |
Backend exception names and engine query text are not public. Use the envelope:
code, message, retryable, details.
OTLP is opt-in. Empty otlp_endpoint means no exporter and no collector
network connection. Without an exporter the host records no spans of its own
(their only destination would be discarded); metrics instrumentation and the
performance probes are unaffected.
[observability]
otlp_endpoint = "http://localhost:4318"Restart the release. Protocol: OTLP HTTP protobuf. Spans batch asynchronously; metrics export about every 30 seconds. Instrumentation does not block the hot path.
| Event | Span | Metric |
|---|---|---|
| Database open | vial_keeper.database.open |
….open.count |
| Database command | vial_keeper.database.command |
….command.duration |
| Admission overload | — | vial_keeper.database.overload.count |
| Admission wait | — | vial_keeper.database.admission.wait |
| Read pool occupancy / wait / exclusive drain | — | vial_keeper.database.read_pool.active, ….queued, ….wait, ….quiesce.duration |
| Changes read | vial_keeper.changes.read |
….duration |
| Query execute | vial_keeper.query.execute |
….duration |
| Index build | vial_keeper.index.build |
….duration |
| Search cache rebuild | vial_keeper.search.rebuild |
….count + ….duration |
| Search rebuild batch / refresh / query | vial_keeper.search.rebuild.batch, ….refresh, ….query |
matching count + duration |
| Replication batch / transfer / checkpoint | matching vial_keeper.replication.* |
matching |
| Replication remote wire | — | vial_keeper.replication.wire.bytes + ….wire.codec.duration |
| Subscription update / open / overload | ….subscription.update |
matching counters |
| View update / query | vial_keeper.view.* |
matching |
| Federation query | vial_keeper.federation.query |
….duration |
| Derived view batch | vial_keeper.derived_view.batch |
….duration |
| HTTP request | vial_keeper.http.request |
….duration |
| Shadow read / route fallback | vial_keeper.shadow.* |
count + duration |
| Attachment read / write / GC | vial_keeper.attachment.* |
count + duration |
error.code uses stable atoms from VialKeeper.Error (e.g.
revision_conflict). Expected domain errors leave span status UNSET; only
internal_error (and unknown adapter failures) mark ERROR.
Owned by VialKeeper.Observability.Attributes. Includes db.uuid (never the
filesystem path), command.type, error.code, outcome, HTTP method/route
template/status, index/replication/admission/subscription/view/federation
ids and bounded counts, plus trigger (compact and search-rebuild), plus the bounded remote-wire dimensions (direction,
payload_kind, endpoint_kind, encoding, operation). Never recorded:
document bodies, document IDs, attachment names/digests/bytes, search text,
revision bodies, full remote URLs, tokens, or raw codec error text.
- Inbound HTTP: W3C
traceparent/tracestateextracted. With no exporter configured the caller's context is still carried through the request, so a host that exports nothing does not break a trace passing through it. - Outbound replication: current context injected so push jobs share one
trace_idacross hosts. - Finch client telemetry is bridged to OTel. Bandit server is not bridged
(would double-span with
vial_keeper.http.request).
GET /v1/observability/snapshot exists only when application env
:observability_dashboard is true. That flag is not a host.toml key;
OTLP collection is configured via otlp_endpoint only.
Every node keeps in-memory performance counters for its hot paths: HTTP
routing and encoding, the Documents/Changes/Query services, catalog
routing, read-pool and owner hops, storage entry points, mutation and SQLite
phases, and (optionally) individual SQLite calls and JSON/term codecs. Each
probe holds a total duration and a latency histogram; nothing leaves the node
unless you read it, and no customer data is recorded (probe names are a closed
vocabulary).
- Reading them: the observability snapshot above includes a
performance_probesobject with the enabledtiers, the histogrambucket_bounds_ns, and per-probecount,total_ns,mean_ns, and histogram upper bounds for p50 and p99 (p50_le_ns,p99_le_ns;nullpast the last bound). Counters accumulate from boot; take two snapshots and subtract to look at a window. - Tiers:
standardprobes (a handful per request) are on by default.detailprobes (per SQLite statement and per encode/decode, dozens per request) are compiled in but off. Turn them on while investigating and off afterwards, from a release shell:bin/vial_keeper rpc 'VialKeeper.Probe.enable(:detail)'(anddisable(:detail)). Changing tiers is a one-off, node-wide operation; do not script it per request. The initial tiers come from application env:performance_probe_tiers(default[:standard]). - Cost: a disabled probe is one constant-term read; an enabled probe adds
two clock reads and two atomic counter increments. The layer-ladder
benchmark (
bench/README.md) measures and reports this overhead for each layer. Application env:performance_probes, falseat compile time removes the probes from the build entirely.
[ ] Build release on matching OS/ABI
[ ] Set VIAL_KEEPER_ROOT; first start creates host.toml
[ ] Bind non-loopback only with auth and/or TLS
[ ] Paste token digests into [auth]; clients use raw tokens
[ ] Put TLS cert/key under the database root when enabled
[ ] Register bundles explicitly; do not rely on auto-scan
[ ] Close before offline copy; copy whole .vialkeeper; skip .lease
[ ] Integrity-check before treating a copy as a backup generation
[ ] Keep backups off the live host and off replica disks; replica is not a backup
[ ] Take a verified closed-bundle backup before any format-changing upgrade
[ ] Restore drills: clean host, register, integrity-check, sample reads
[ ] Disable materialized views before closing their sources
[ ] Point otlp_endpoint only if a collector is ready
[ ] Prefer Diagnostics.runtime/0 + integrity-check for support dumps
TREC-COVID FTS, Simple Wikipedia stress, PMC, and Open Images torture are opt-in Mix commands.
They are not part of the release or the ExUnit gate. All source objects,
generated manifests, work databases, caches, and reports stay under the
repo-local bench root. The standard location is
tmp/bench/vialkeeper/ in the checkout. Anything outside tmp/bench/ is
rejected.
First-use workflow (run from the repository root):
mix bench.data configure --root tmp/bench/vialkeeper
mix bench.data status
mix bench.data prepare trec-covid
mix bench.ftsstatus prints free space and each dataset's expected source size, local
size, and estimated VialKeeper working space (source plus a second copy
inside bundles, plus a 10 GiB / 15% reserve). The default standard profiles
are sized to expose ingest, FTS rebuild, and attachment bottlenecks without
running the hour-scale corpora (20,000 TREC docs, 20,000 Simple Wikipedia
articles, 400 Open Images JPEGs). Use --profile smoke for a tiny subset of
the same code path. Re-run mix bench.data prepare after a scale change;
a READY fixture whose recorded selection_count is stale is replaced.
Torture, stress, FTS, and dataset prepare print phase progress (10% steps plus
a 30-second heartbeat). --stall-timeout-ms (default 300000) aborts a
countable phase that stops completing work, with a diagnostic dump.
Prepare and the runners do not accept --root. Cleanup is one named dataset
at a time (mix bench.data clean trec-covid). Details, Git vs external
files, and the other two suites: bench/README.md.
Product semantics live in shared services and storage ports. A backend replacement should touch only:
lib/vial_keeper/storage/ports/contracts and the new backend tree- backend-owned bundle artifact / ownership / capability code
- physical tests under
test/physical/<backend>/
Do not reimplement mutation, replication, retention, query, view, or derived materialization algorithms inside the backend. For the current SQLite implementation notes, see lib/vial_keeper/storage/sqlite/BACKEND.md.