Configuration
Vyrtel runs with no configuration at all. When you need to change something, use any combination of:
- a TOML file (
--config vyrtel.toml, or./vyrtel.tomlif present), VYRTEL_*environment variables,- command-line flags,
in increasing order of precedence. vyrtel config prints the effective
configuration; GET /api/v1/system/config returns it (without secrets).
Unknown keys are rejected so typos do not silently do nothing, and so are
unknown environment variables that look like
settings.
Sizes accept B, KB, MB, GB (powers of 1024; MiB etc. also work).
Durations accept ms, s, m, h, d, w, combinations like 1h30m,
or a bare number of seconds.
Example
[server]
bind = "0.0.0.0:8080"
shutdown_timeout = "8s"
[storage]
path = "./data"
max_memory = "512MB"
segment_target_size = "64MB"
segment_max_age = "5m"
durability = "normal"
[retention]
logs = "30d"
traces = "14d"
metrics = "30d"
[ingest]
max_request_size = "1MB"
queue_capacity = 10000
[auth]
enabled = true
admin_password = "change-me" # or VYRTEL_AUTH_ADMIN_PASSWORD
Reference
[server]
| Key | Default | Env | Description |
|---|---|---|---|
bind |
0.0.0.0:8080 |
VYRTEL_SERVER_BIND |
Listen address for the UI, API and OTLP/HTTP. |
shutdown_timeout |
8s |
VYRTEL_SERVER_SHUTDOWN_TIMEOUT |
After a stop signal, how long accepted work may take to drain before Vyrtel reports what is unfinished and exits with status 1. Must be positive. See stopping and upgrading. |
max_connections |
256 |
VYRTEL_SERVER_MAX_CONNECTIONS |
Connections held open at once, across every listener. More wait up to 2 s for a slot (at most 64 wait), then are refused. 1 to 65536. See connections and transport limits. |
header_timeout |
10s |
VYRTEL_SERVER_HEADER_TIMEOUT |
How long a connection may take to send a complete request head, from when it is accepted or finishes a response. Also how long an idle keep-alive connection stays open. Positive, at most 1h. |
body_timeout |
1m |
VYRTEL_SERVER_BODY_TIMEOUT |
How long a request body may take to arrive in full, from its head; then 408. Positive, at most 1h. |
write_timeout |
30s |
VYRTEL_SERVER_WRITE_TIMEOUT |
How long one write may stay blocked because the client is not reading; then the connection closes. Positive, at most 1h. |
connection_buffer |
64KB |
VYRTEL_SERVER_CONNECTION_BUFFER |
Cap on each connection's read buffer, which holds the request head (larger heads get 431), and on its write buffer. 8KB to 408KB. |
[storage]
| Key | Default | Env | Description |
|---|---|---|---|
path |
./data |
VYRTEL_STORAGE_PATH |
Data directory. Everything persistent lives here. |
max_memory |
512MB |
VYRTEL_STORAGE_MAX_MEMORY |
Global memory budget (see below). Minimum 64MB; with a derived ingest limit, at least 219MB for 1MB requests (ingest memory). |
segment_target_size |
64MB |
VYRTEL_STORAGE_SEGMENT_TARGET_SIZE |
Seal the active segment at this uncompressed in-memory size (capped by the memory budget). Compressed segment files are typically 5–15× smaller. |
segment_max_age |
5m |
VYRTEL_STORAGE_SEGMENT_MAX_AGE |
Seal the active segment after this long, even if small. |
durability |
normal |
VYRTEL_STORAGE_DURABILITY |
normal: acknowledge after writing to the OS, fsync every fsync_interval. strict: fsync before every acknowledgement (group-committed). See Crash recovery. |
fsync_interval |
1s |
VYRTEL_STORAGE_FSYNC_INTERVAL |
WAL fsync period in normal mode. |
compaction |
true |
VYRTEL_STORAGE_COMPACTION |
Merge small segments (e.g. sealed by age on quiet systems). |
storage.zstd_level, block_events and segment_max_events were removed in
0.1.2: segments are always compressed at level 3 in blocks of up to 1024
events, and seal by segment_target_size and segment_max_age. A
configuration that still sets one is refused at startup, naming it.
[retention]
| Key | Default | Env |
|---|---|---|
logs |
30d |
VYRTEL_RETENTION_LOGS |
traces |
14d |
VYRTEL_RETENTION_TRACES |
metrics |
30d |
VYRTEL_RETENTION_METRICS |
Use "forever" (or 0) to keep data indefinitely. Retention deletes whole
segments whose newest event is older than the period; it runs every 30
seconds. Because segments are deleted as a unit, data may outlive the period
by up to one segment's time span.
[ingest]
| Key | Default | Env | Description |
|---|---|---|---|
max_request_size |
1MB |
VYRTEL_INGEST_MAX_REQUEST_SIZE |
Maximum ingest body size, both after gzip decoding and on the wire (inclusive). Larger requests get 413; see request size and encoding. Was 16MB before 0.1.2. |
queue_capacity |
10000 |
VYRTEL_INGEST_QUEUE_CAPACITY |
Events waiting to be written, per signal. When full, requests get 429. Also the most events (OTLP: log records, spans or metric data points) one request may carry: a larger batch gets 413 as soon as the count is known, before it is decoded (decoding limits). |
ack_timeout |
30s |
– | Give up waiting for a write acknowledgement (503). |
max_concurrent |
0 |
VYRTEL_INGEST_MAX_CONCURRENT |
Ingest requests in progress at once, from reading the body until the write is acknowledged. More get 429 without their body being read. 0 derives it from the memory budget (ingest memory); an explicit value is always honoured. |
Status codes and retry behaviour are in the API reference.
Ingest memory
Admission counts requests, not bytes, so the derived limit budgets every
admitted request as if it were a maximum-size one with a high-amplification
format. With ingest.max_concurrent = 0:
ingest budget = 15% of storage.max_memory
per request = 20 × max_request_size
fixed overhead = 12.75 MB
max_concurrent = floor((ingest budget − fixed overhead) ÷ per request), at most 1024
The defaults (512MB, 1MB requests) give (76.8 MB − 12.75 MB) ÷ 20 MB = 3
requests, an estimated 72.75 MB at most. GET /api/v1/system/info reports
the effective limit and the estimate under ingest
(maxConcurrent, concurrencySource, budgetBytes,
estimatedPeakBytes).
What is counted. Per admitted request: the body as it is read and
gzip-decoded, the parse working set and the parsed batch, which stays
admitted in the storage queue until its write is acknowledged. The fixed
overhead covers rejected bodies being drained so their clients read the 429
(at most 32 at once, each through its connection's read buffer; the
overhead assumes the largest server.connection_buffer, 408 KB, so it
holds whatever that is set to). The rest of the process is budgeted elsewhere and not counted
here: the WAL record and the active segment a batch is written into (the
active segments budget), batches still queued after their client went away
(bounded by queue_capacity), the buffers of connections that are not
draining a rejected body (the
transport estimate), and the runtime.
So the ingest budget bounds what admission owns; it is not a ceiling on
all memory used to receive data over the network. Every open connection has
buffers of its own, whether its request is admitted, rejected and waiting
to be drained (up to 256 are kept), or idle between keep-alive requests.
Those are bounded separately, by server.max_connections and
server.connection_buffer
(connections and transport limits).
Why 20× holds. Decoding is limited so that no request goes past it,
whatever its shape (decoding limits): the decoders
may hold at most 17 × max_request_size of decoded data, and the rest
covers the body itself (held twice while a body read in parts is joined)
and what their accounting does not see. The amplification test measures
every fixture's peak heap per byte of decoded body and asserts it is
within 20×, with no exemptions
(details and full table).
Representative batches of native JSON, NDJSON and CLEF peak at 3.8–4.4×,
OTLP/JSON and OTLP/protobuf logs, traces and metrics at 8.4–11.9×, and the
highest accepted fixture (native events of ~24 bytes) at 15.6×; gzip makes
no measurable difference. Shapes that would decode past the limit are
refused part-way, at no more than 18.1×.
Decoding limits
Every ingest request is decoded within three limits, enforced while it is parsed and never after: what the request has decoded so far is the only memory at stake when a limit is reached.
| Limit | Value | Over it |
|---|---|---|
| Events per request | queue_capacity (default 10,000) |
413 payload_too_large, "limit": "maxEvents" |
| Decoded data per request | 17 × max_request_size (default 17 MB) |
413 payload_too_large, "limit": "maxDecodedBytes" |
| Nesting of values | 32 levels | 400 invalid_payload, "limit": "maxDepth" |
- Events are native events, OTLP log records, spans or metric data
points. The count is taken before anything is built: native bodies are
counted by a scan of their lines or array elements, OTLP/protobuf bodies
by a scan of the wire format, and OTLP/JSON records and the events of a
{"events": [...]}envelope whose key is escaped or repeated as they are reached. A batch the queue could never hold is refused as soon as its count is known, never after it has been parsed. An object is a batch only ifeventsis its only key (repeated, the last one counts); an ordinary event with aneventsproperty is one event whatever the order of its keys, and is held to the decoded-data limit like any other. - Decoded data is the decoders' own estimate of the heap they hold:
the parsed JSON tree or OTLP structures, and the events built from them,
each charged before or as it is allocated and credited back once freed.
OTLP/protobuf bodies are measured by the same wire scan before
prostdecodes anything. It is what tells shapes apart that cost far more than their size: a 1 MB body of empty objects ({}), of tiny OTLP/protobuf log records, of properties nested several levels deep or of arrays of small numbers is refused, while ordinary batches use a fraction of it. - Nesting counts objects and arrays inside one native event (the event
is level 0), or levels of OTLP
arrayValue/kvlistValueinside one attribute or body. OTLP key/value lists cost several levels each of the JSON parser's (128) or protobuf decoder's (100) own nesting limits, which are reached at about 30 levels and reported the same way.
OTLP resources are shared by all of their records, so a large resource costs memory once per request rather than once per record.
A refused request stores nothing: native batches are all-or-nothing as always, and an OTLP request over a limit is refused as a whole; it never gets a partial success response, which stays for records that cannot be represented. Its admission slot is released at once. The response names the limit and how to split the batch (API reference); retrying the same request cannot succeed.
Both 413 limits scale with your settings: raising queue_capacity admits
larger batches by count, and raising max_request_size raises the decoded
data allowance with it (and the per-request estimate, so fewer requests
are admitted at once from the same budget).
Too little memory for one request. If not even one maximum-size request
fits, Vyrtel refuses to start rather than admit more than the budget. For
example vyrtel --memory-limit 128MB exits with status 2 and:
vyrtel: configuration error: ingest budget too small: one 1 MB request needs an estimated 32.8 MB (20× the request size, plus 12.8 MB fixed ingest overhead) but the ingest share of storage.max_memory is 19.2 MB; increase storage.max_memory to at least 219MB, reduce ingest.max_request_size to at most 330KB, or set ingest.max_concurrent explicitly (see docs/configuration.md#ingest-memory)
At the 64MB minimum max_memory the ingest share is smaller than the fixed
overhead alone, so a derived limit is never valid there; set
max_concurrent explicitly.
Explicit limits. A positive max_concurrent is used as given, even
above 1024 or above the budget. When max_concurrent × per request + fixed overhead exceeds the ingest budget, startup logs one warning with the
numbers, for example VYRTEL_INGEST_MAX_CONCURRENT=8:
WARN server::app: ingest.max_concurrent = 8 with ingest.max_request_size = 1 MB may need an estimated 172.8 MB of ingest memory under load, more than the 76.8 MB ingest budget; the limit is honoured. Lower ingest.max_concurrent or ingest.max_request_size, raise storage.max_memory, or set ingest.max_concurrent = 0 to derive it (see docs/configuration.md#ingest-memory) max_concurrent=8 max_request_bytes=1048576 ingest_budget_bytes=80530635 estimated_peak_bytes=181141504
Values above 2,305,843,009,213,693,951 (the admission semaphore's limit) are rejected.
Common configurations. Derived limits before 0.1.2
(ingest budget ÷ max_request_size, at least 2) and now:
max_memory |
max_request_size |
Ingest budget | Before | Now | Estimated peak now |
|---|---|---|---|---|---|
| 64 MB | 1 MB | 9.6 MB | 9 | invalid | — |
| 128 MB | 1 MB | 19.2 MB | 19 | invalid | — |
| 256 MB | 1 MB | 38.4 MB | 38 | 1 | 32.8 MB |
| 512 MB | 16 MB | 76.8 MB | 4 | invalid | — |
| 512 MB | 4 MB | 76.8 MB | 19 | invalid | — |
| 512 MB | 1 MB | 76.8 MB | 76 | 3 | 72.8 MB |
| 512 MB | 256 KB | 76.8 MB | 307 | 12 | 72.8 MB |
| 1 GB | 4 MB | 153.6 MB | 38 | 1 | 92.8 MB |
| 1 GB | 1 MB | 153.6 MB | 153 | 7 | 152.8 MB |
| 2 GB | 4 MB | 307.2 MB | 76 | 3 | 252.8 MB |
| 2 GB | 1 MB | 307.2 MB | 307 | 14 | 292.8 MB |
| 4 GB | 16 MB | 614.4 MB | 38 | 1 | 332.8 MB |
| 8 GB | 16 MB | 1.2 GB | 76 | 3 | 972.8 MB |
Bold rows are the old and new defaults. The old limits ignored parsing entirely; under the same overload test the old defaults peaked at 495–715 MB of ingest heap against the 76.8 MB budget (results).
Upgrading to 0.1.2
max_request_sizenow defaults to1MB(was16MB). Clients that send larger batches get413 payload_too_large: lower their batch size (for example the OpenTelemetry Collector'ssend_batch_max_size, or a Serilog sink's batch limit), or raisemax_request_sizetogether withmax_memory(16MB requests need at least 2219MB for one derived slot).- With
max_concurrent = 0, fewer requests are admitted at once (3 instead of 4 at the defaults; see the table), so busy producers may see more429s. They are retryable withRetry-After. - Configurations whose ingest budget cannot hold one request now fail at
startup with the message above, instead of starting with 2 slots. This
includes
max_memorybelow 219MB with 1MB requests, and avyrtel.tomlcopied from an older example that setsmax_request_size = "16MB"with 512MB. Fix it by raisingmax_memory, loweringmax_request_size, or settingmax_concurrentexplicitly (which also restores the old behaviour exactly, with a startup warning if it is over budget). - Explicit
max_concurrentvalues behave exactly as before. - Requests are now decoded within decoding limits.
Batches with more events than
queue_capacitywere already refused with 413; they are now refused before they are parsed, and the error names the limit ("limit": "maxEvents"). Bodies that would decode into more than 17 ×max_request_size(for example tens of thousands of empty or near-empty events, or deeply nested values) get413with"limit": "maxDecodedBytes", and values nested more than 32 levels deep get400with"limit": "maxDepth"; both used to be accepted. Ordinary application, Serilog/CLEF and OpenTelemetry SDK and Collector batches are unaffected. The earlier advice to keep producers that might send such batches behind a limit in front of ingestion no longer applies.
[query]
| Key | Default | Env | Description |
|---|---|---|---|
timeout |
30s |
VYRTEL_QUERY_TIMEOUT |
Execution time of one request, not counting the wait for a slot (504 query_timeout). Must be positive. |
max_concurrent |
4 |
VYRTEL_QUERY_MAX_CONCURRENT |
Requests executing at once. A cancelled query keeps its slot until it has stopped. |
queue_timeout |
10s |
– | How long a request waits for a free slot, then 503 unavailable. 0s refuses at once when busy. |
max_scan_bytes |
4GB |
– | Compressed segment bytes one request may read, across all its scans (422 query_too_expensive). |
max_results |
1000 |
– | Maximum page size (limit). Must be positive. |
max_trace_spans |
50000 |
– | Spans one trace may return from GET /api/v1/traces/{id} (422 query_result_limit). Must be positive. |
max_metric_names |
10000 |
– | Names GET /api/v1/metrics returns: the first by name, with truncated: true beyond. Must be positive. |
max_live_streams |
64 |
– | Concurrent live-tail connections. |
How the limits are enforced, where execution checks them, and what happens
when a client disconnects mid-query: Execution limits and
cancellation.
/api/v1/system/info reports query.inFlight and how many queries were
rejected, cancelled, timedOut, tooExpensive or resultLimited
since the process started.
Query budget changes in 0.1.2
- New keys
query.queue_timeout(the previously fixed 10 s wait) andquery.max_trace_spans(the previously fixed 50,000-span cap). The defaults keep the old values. query.timeoutandquery.max_scan_bytesnow cover the whole request. Trace search pages and the facet sample each had their own budget before, so a trace search could run for up to 20 ×timeoutbefore failing.- A scan that reads past
max_scan_bytesnow fails at that block. Before, the check ran only before each read, so the last block could go over. - Queries are checked every 256 rows as well as at each block, including recent (unsealed) data, which had no checks before; a client disconnecting now stops its query (before, it ran to the end).
- Metric discovery (
GET /api/v1/metrics) and facet counting now check the budget too: both stop on disconnect and attimeout. Discovery keeps at most the newquery.max_metric_names(default 10,000) names, the first by name, and its response gainstruncated. Before, it listed every distinct name with no limit on time or memory. - A trace with more than
max_trace_spansspans gets422 query_result_limitinstead of the first 50,000 spans presented as the whole trace. Trace search summaries gaintruncated. - A metric query needing more than 10,000 points per series gets
422 query_result_limitinstead of series that silently stopped at the 10,000th point. query.timeout = 0,query.max_results = 0andquery.max_trace_spans = 0(and the newmax_metric_names = 0) are refused at startup; before, they made every query fail.504,422and the new limit errors carry the limit (limitMs,limitBytes,limit). Codes and statuses of existing errors are unchanged; the 503 message for busy query slots now names the slot count.
[auth]
| Key | Default | Env | Description |
|---|---|---|---|
admin_username |
admin |
VYRTEL_AUTH_ADMIN_USERNAME |
|
admin_password |
– | VYRTEL_AUTH_ADMIN_PASSWORD |
Hashed (Argon2id) into the metadata store at startup. If unset on first start, a random password is generated and printed once. |
session_ttl |
7d |
– | Login session lifetime. |
Security note. The UI and API always need a login, but until an ingest-scoped API key exists anyone who can reach the port can write data. Create one (Settings → API keys) before exposing Vyrtel beyond a trusted network: ingestion then requires a valid key, until the last ingest key is revoked. Put Vyrtel behind TLS (a reverse proxy) — it serves plain HTTP itself.
Authentication changes in 0.1.2
auth.enabledis gone: the UI and API always require a login or an admin API key, and a fresh install generates and prints an admin password once. A configuration that still setsauth.enabled(orVYRTEL_AUTH_ENABLED) is refused at startup with what to do instead, so an upgrade never silently changes what it meant.- Ingestion follows the API keys: open while no ingest-scoped key exists,
and requiring a valid key while one does. An install that had
auth.enabled = trueand ingest keys keeps requiring them with no change. There is no setting for it;auth.ingest_api_keyis refused likeauth.enabled. GET /api/v1/auth/mestill returnsauthEnabled, now alwaystrue;GET /api/v1/system/infoandGET /api/v1/api-keysaddingestKeyRequired.
[alerts]
| Key | Default | Env | Description |
|---|---|---|---|
enabled |
true |
VYRTEL_ALERTS_ENABLED |
Run the background alert evaluator. |
webhook_timeout |
10s |
– | Per-attempt webhook timeout. |
[log] — Vyrtel's own logs
| Key | Default | Env | Description |
|---|---|---|---|
level |
info |
VYRTEL_LOG_LEVEL |
tracing filter, e.g. debug or info,storage=debug. |
format |
text |
VYRTEL_LOG_FORMAT |
text or json. Written to stderr; never ingested into Vyrtel itself. |
Command line
vyrtel [OPTIONS] [COMMAND]
Commands:
serve Run the server (default)
healthcheck Exit 0 if the server answers /health (used by Docker)
config Print the effective configuration
check-data-dir Check, reading only, whether this version can start the
data directory and what starting it would change
(see Upgrading between pre-1.0 releases)
Options:
-c, --config <PATH> TOML config file
--data-dir <PATH> storage.path
--bind <ADDR> server.bind
--memory-limit <SIZE> storage.max_memory
--log-level <FILTER> log.level
--log-format <FORMAT> text | json
--accept-incomplete-restore
start without missing parts of a restored data
directory (see Backup and restore)
-h, --help
-V, --version
Environment variables
The Env column of each table above lists every setting Vyrtel reads from
the environment; every other setting is
set in the TOML file. A variable under a section's prefix
(VYRTEL_SERVER_, VYRTEL_STORAGE_, VYRTEL_RETENTION_,
VYRTEL_INGEST_, VYRTEL_QUERY_, VYRTEL_AUTH_, VYRTEL_ALERTS_,
VYRTEL_LOG_) that is not one of them stops Vyrtel at startup (exit
status 1) instead of being ignored, so a misspelt VYRTEL_AUTH_ADMIN_PASSWORD
never starts a server with a setting other than the one meant:
vyrtel: unknown environment variable VYRTEL_AUTH_ENABLE: not a setting Vyrtel reads from the environment. Remove it, or set the option in the configuration file instead (see docs/configuration.md#environment-variables for the 27 supported variables)
Other VYRTEL_* names, such as those the test and release scripts use,
are not settings and are ignored. OBSERVER_* variables (Observer 0.1.0)
have not been read since 0.1.1.
Memory budget
max_memory is split between the consumers that hold data in memory. Each
budget is enforced by its owner; nothing grows without bound.
| Consumer | Share | Enforcement |
|---|---|---|
| Active segments | 40% | Split logs 50% / traces 30% / metrics 20%. Each signal seals at the smaller of segment_target_size and half its share (the active buffer plus one buffer being sealed must fit). If sealing falls behind, writers wait and ingestion returns 429. |
| Index cache | 15% | LRU of segment index regions (Bloom filters, zone maps); evicts when full. Segment summaries are always resident and small. |
| Ingest | 15% | Unless ingest.max_concurrent is set, admits (share − 12.75 MB) ÷ (20 × max_request_size) requests at once (at most 1024; startup fails if that is 0), each from body read to acknowledgement; more get 429 (ingest memory). Event queues are bounded by queue_capacity. |
| Queries | 20% | max_concurrent queries; each holds at most one page of results plus one decoded block at a time. |
| Background | 10% | Compaction input is capped at a third of this share. |
Live tail keeps a ring of the last 64 committed batches for slow clients; clients that fall further behind are told how many batches they missed.
Connections are budgeted outside max_memory, by their own limits; see
connections and transport limits.
Connections and transport limits
Vyrtel bounds what clients can make it hold before any request is routed, authenticated or admitted, so idle, slow and excessive connections cannot exhaust memory, tasks or file descriptors ahead of ingest and query admission. These limits are enforced by the server itself; a reverse proxy in front is optional extra protection, not a requirement.
| Setting | Default | Bounds |
|---|---|---|
server.max_connections |
256 |
Connections holding a slot at once. 1 to 65536. |
| (fixed) waiting | 64 |
Connections waiting for a slot at once, each for at most 2 s. |
server.header_timeout |
10s |
From accept, or from the end of a response, until the next request head is complete. Ends idle keep-alive connections too. |
server.body_timeout |
1m |
From a request head until the end of its body. |
server.write_timeout |
30s |
One write blocked because the client is not reading. |
server.connection_buffer |
64KB |
Bytes in each connection's read buffer (request head plus any pipelined requests) and in its write buffer. 8KB to 408KB. |
| (fixed) headers | 100 |
Headers in one request head. |
Every value is finite: zero, values out of range and values that overflow
(for example a duration of 18446744073709551615w) are refused at startup,
so no setting turns a limit off. Durations need a unit or are seconds;
1h is the longest. Settings are read at startup only; there is no reload.
One budget per process. Vyrtel listens on one address, server.bind.
The limits are owned by the process, not the listener: if a build serves
more than one listener, all of them share the same slots and waiting places,
so the aggregate is bounded by the same numbers. GET /api/v1/system/info
reports them under connections
(fields); the startup banner shows max_connections and
the buffer estimate.
What happens to a connection
- Accepted. The kernel completes the TCP handshake and queues the
connection in the listen backlog; Vyrtel accepts from it continuously,
whatever the connection's state. The backlog is 1024 (also capped by the
kernel's
net.core.somaxconn, 4096 by default on Linux 5.4 and later). It is the kernel's, not Vyrtel's: a burst of connections faster than Vyrtel accepts (1,000 at once on 2 CPUs, in the Linux runs) or running out of file descriptors overflows it. Linux then drops the handshake (ListenOverflowsin/proc/net/netstat) unlessnet.ipv4.tcp_abort_on_overflow = 1. With SYN cookies, the default, the client may believe it is connected while the server has no record of it: a client that sends data gets a reset, an idle one waits until its own timeout. Vyrtel holds nothing for such connections. Clients should use connect and response timeouts, as the OpenTelemetry exporters do. - Admitted or waiting. With a slot free, the connection takes it before any task is spawned for it or any buffer is allocated. Otherwise it waits for one, holding only its socket (no buffers), at most 64 at a time and each for at most 2 s, first come first served. A slot frees as soon as a connection ends.
- Refused. With no slot after 2 s, or 64 already waiting, Vyrtel tries
one non-blocking write of a fixed
503 server_busyresponse (format) and closes the socket. It reads nothing, allocates nothing and does not wait. If the client had already sent its request, closing with unread data makes the kernel send a reset, which can arrive before the 503. So a client may see the 503, a closed connection or a reset, and should treat all three alike: retry with backoff. No readable 503 is promised before HTTP is parsed. - Served. HTTP/1.1, one request at a time. Pipelined requests are
answered in order; the read buffer holds at most
connection_bufferbytes of them and the rest wait in the kernel (TCP flow control). Ingest admission (ingest.max_concurrent), query admission (query.max_concurrent) and live-tail streams (query.max_live_streams) apply on top, unchanged. - Closed. At EOF, a reset, a protocol error, a deadline below, the end
of a
Connection: closeexchange, or shutdown. Its slot, buffers, task and file descriptor are released whichever way it ends.
| Deadline or limit | Outcome |
|---|---|
No complete head within header_timeout |
Closed without a response (counted in headerTimeouts). Idle keep-alive connections end so. |
Head over connection_buffer |
431 Request Header Fields Too Large, closed (protocolErrors). |
| More than 100 headers | 431, closed (protocolErrors). |
Body not complete within body_timeout |
408 request_timeout, closed (bodyTimeouts). Any ingest slot it held is released. |
A write blocked for write_timeout |
Closed, the response cut off (writeTimeouts). |
| Malformed head, HTTP/2 preface | 400 or closed (protocolErrors). |
A request that is being handled (a query running, an ingest write waiting
for its acknowledgement) is bounded by its own limits (query.timeout,
ingest.ack_timeout), not by these deadlines.
Protocols. HTTP/1.1 only. HTTP/2 (prior knowledge or h2c upgrade),
WebSockets and other upgrades are not served, so HTTP/2 stream multiplexing
does not apply. TLS is not terminated by Vyrtel.
Live tail. A live-tail stream (Server-Sent Events) holds its connection
slot for as long as it is open; there is no maximum duration. At most
query.max_live_streams (64) are open at once, so keep that below
max_connections (Vyrtel warns at startup otherwise) to leave slots for
ingest and queries. Streams send a keep-alive comment every 15 s, so
header_timeout and write_timeout do not end a stream whose client
reads. A client that stops reading is closed once a write stays blocked for
write_timeout; that can take a while, because the kernel's socket buffers
fill first. Until then the server holds at most connection_buffer of
events for it; it falls behind and is told how many batches it missed
(lagged) rather than having them queued. At shutdown a stream sends what
it has already matched, then shutdown.
Memory accounting
Measured with the counting allocator in tests/overload/transport.rs
(release build, Linux x86_64 and Windows 11 alike, within 1%):
| Connection state | Heap per connection (64 KB buffer) |
|---|---|
| Idle (accepted, nothing sent) | 18 KB |
| Request head filling the read buffer, never finished | 122 KB |
| Pipelining requests, never reading responses | 48 KB |
| Waiting for a slot (estimated; too small to separate) | under 2 KB |
| Closed | 0 |
A read buffer that grows to its limit can have up to twice the limit
allocated, so the estimate Vyrtel reports (estimatedPeakBytes, and the
banner) is:
per connection = 3 × connection_buffer + 16 KB
transport peak = max_connections × per connection + 64 × 2 KB
That is 52.1 MB at the defaults (256 × 208 KB) and 4.1 MB with
max_connections = 64 and connection_buffer = "16KB". It is on top of
storage.max_memory, and is reached only if every slot holds a full read
buffer at once; typical idle connections cost 18 KB each. Request and
response bodies are not part of it: ingest admission and the query budgets
bound those.
Kernel memory. Each socket also has kernel send and receive buffers,
which are neither heap nor counted in RSS, but are charged to a container's
memory limit (the sock line of its cgroup memory.stat). Linux grows them
up to net.ipv4.tcp_rmem and net.ipv4.tcp_wmem (6 MB and 4 MB at most by
default) for connections that move data quickly, so many connections
uploading or downloading at once can hold far more kernel memory than
heap: in the overload test, 200 connections that sent requests without
reading responses held about 1 GB of socket buffers, client and server
sides together, against 24 MB of server heap. On a small host, lower the
maximums (for example sysctl -w net.ipv4.tcp_rmem="4096 131072 1048576" net.ipv4.tcp_wmem="4096 16384 1048576") and leave headroom in the container
limit, or lower max_connections.
File descriptors. Vyrtel needs one per connection (slots and waiting
places, max_connections + 64), about 25 for the listener, the runtime, the
WAL and the metadata database, and one per segment file it has open, which
grows with the data kept (storage keeps each sealed segment open for reads).
In the 30-minute Linux soaks, segment files added about 120 (normal) and
190 (constrained, smaller segments) descriptors. So allow
max_connections + 64 + 25 + segments, with room for growth: 1024 covers
the defaults for a modest data directory; size it from
ls /proc/$(pidof vyrtel)/fd | wc -l on a running instance. Raise
ulimit -n (systemd LimitNOFILE=, Docker --ulimit nofile=) if you raise
max_connections, and check it where Vyrtel actually runs: many systems
default to 1024.
Running out shows as acceptErrors and a cannot accept connections
warning; Vyrtel pauses accepting for 250 ms and retries.
Choosing the limits
The defaults come from the measurements above and the Linux runs in
docs/benchmarks/transport:
max_connections = 256. OpenTelemetry SDKs and Collectors, log shippers and browsers reuse a handful of keep-alive connections each, so 256 serves dozens of producers and UI users. At 18 KB each, 256 idle connections cost 4.5 MB; the worst case is the 52.1 MB above.connection_buffer = 64KB. Larger than the request heads common proxies accept (8 to 32 KB), and ingest throughput is unchanged from the previous 408 KB limit (see the steady-load comparison in the report). A smaller buffer lowers the worst case linearly.header_timeout = 10s. Ends idle keep-alive connections and slow heads quickly enough that a flood of idle connections turns over every 10 s, while still allowing clients on slow links.body_timeout = 1m. A 1 MB ingest body (the defaultmax_request_size) at 17 KB/s. An admitted ingest body holds its ingest slot until it arrives or this passes: in the Linux floods, bodies stalled on purpose held all 3 default ingest slots for the full minute while other producers got429. Where untrusted clients can reach ingest and uploads are fast, lower it (for example10s).write_timeout = 30s. A client that has not read for 30 s while its socket buffers are full has gone away.
A low-memory example. A 384 MiB container:
[server]
max_connections = 64 # 64 × (3 × 16 KB + 16 KB) + 128 KB = 4.1 MB at most
connection_buffer = "16KB" # heads up to 16 KB
[storage]
max_memory = "256MB" # ingest derives 1 slot for 1 MB requests (see ingest memory)
[query]
max_live_streams = 8 # below max_connections
The same limits from the environment (max_live_streams has no variable,
so Vyrtel warns that it is not below max_connections):
docker run --memory 384m --ulimit nofile=1024 -p 8080:8080 \
-e VYRTEL_STORAGE_MAX_MEMORY=256MB -e VYRTEL_SERVER_MAX_CONNECTIONS=64 \
-e VYRTEL_SERVER_CONNECTION_BUFFER=16KB ghcr.io/rowan-smith/vyrtel
With waves of 1,000 simultaneous connection attempts every minute for 30 minutes, alongside steady ingest and queries, this configuration held at most 64 connections and 64 waiting, peaked at 92 MiB RSS (130 MiB for the whole container, kernel buffers and page cache included) and settled at 77 MiB afterwards, with every acknowledged event stored (report).
Behind a reverse proxy or load balancer. Vyrtel then sees the proxy's
upstream connections, not the clients': set max_connections at least as
large as the proxy's upstream connection pool, and the proxy's upstream
idle timeout below header_timeout (nginx: keepalive_timeout 5s; in the
upstream block), or raise header_timeout above it. Otherwise Vyrtel may
close an idle upstream connection just as the proxy reuses it, and the
proxy answers 502. Proxy limits on clients (rate, per-IP connections,
TLS) apply first and complement these.
Not covered. Fairness between clients or tenants (one client can take
every slot, each for up to header_timeout at a time), bandwidth limits,
and defence against distributed floods. Use network-level controls for
those. Rejected ingest bodies are drained with their own limits
(ingest limits).
Connection limit changes in 0.1.2
- Connections are now limited (
server.max_connections, default 256); before, every connection was accepted and kept. Beyond the limit, connections wait up to 2 s, then are refused with503 server_busyor closed. Deployments with more than 256 concurrent clients that each hold a connection open, or a proxy with a larger upstream pool, should raise it. - Idle keep-alive connections close after
server.header_timeout(10 s); before, they stayed open until the client closed them. Clients reconnect transparently; a proxy whose upstream idle timeout is longer may log occasional502s until it is set below 10 s. - Request heads larger than 64 KB get
431(before: about 408 KB), slow bodies get408 request_timeoutafter 1 minute (before: no limit), and connections whose client stops reading for 30 s close (before: no limit). - A live-tail client that is not reading is buffered for less (64 KB rather than about 408 KB), so it skips more events; it reconnects as before.
GET /api/v1/system/infohas a newconnectionsobject, and the startup banner aConnectionsline.
Docker and bind mounts
The image (ghcr.io/rowan-smith/vyrtel, linux/amd64 and linux/arm64)
runs as the unprivileged nonroot user, uid/gid 65532, and keeps all data
in /data (VYRTEL_STORAGE_PATH=/data, declared as a volume). Each image
carries its version in the org.opencontainers.image.version label:
docker image inspect --format '{{.Config.User}} {{index .Config.Labels "org.opencontainers.image.version"}}' ghcr.io/rowan-smith/vyrtel:0.1.2
# nonroot:nonroot 0.1.2
Pin a version tag (0.1.2, or 0.1 for patch updates) in production
rather than latest, so a recreated container never upgrades by surprise
(upgrading).
Named volumes work out of the box: a new volume copies the image's
/data, owned by 65532. The data lives in the volume, not the container,
so stopping, removing and recreating the container (for example to change
an environment variable or upgrade the image) keeps every acknowledged
event, dashboard, alert and API key:
docker run -d --name vyrtel -p 8080:8080 -v vyrtel-data:/data ghcr.io/rowan-smith/vyrtel:0.1.2
docker stop vyrtel && docker rm vyrtel
docker run -d --name vyrtel -p 8080:8080 -v vyrtel-data:/data ghcr.io/rowan-smith/vyrtel:0.1.2
Without -v, Docker creates an anonymous volume per container, and
recreating the container starts with an empty one.
Bind mounts on Linux keep the host directory's owner and mode, and a
directory that docker run creates for a missing path is owned by root.
Give the directory to uid/gid 65532 first:
mkdir -p vyrtel-data && sudo chown -R 65532:65532 vyrtel-data
docker run -d --name vyrtel -p 8080:8080 -v ./vyrtel-data:/data ghcr.io/rowan-smith/vyrtel:0.1.2
If the directory is not writable, Vyrtel exits with status 1 before creating anything and says why:
vyrtel: locking the data directory: I/O error on /data/.lock: Permission denied (os error 13): Permission denied (os error 13)
hint: the data directory /data must be writable by the user running Vyrtel.
Vyrtel runs as uid 65532 (gid 65532); /data is owned by uid 0 (gid 0) with mode 555.
The Docker image runs as uid/gid 65532. Named volumes get that owner automatically;
for a bind mount, give the host directory to that user first, e.g.
sudo chown -R 65532:65532 ./vyrtel-data
See docs/configuration.md#docker-and-bind-mounts.
Run the chown on the host directory, then start the container again.
-R matters when the directory already holds files written by another
user, for example by an older container run as root. The same hint is
printed outside Docker, naming the user Vyrtel runs as, and by
vyrtel check-data-dir when it cannot read the directory
(check-data-dir only reads, so it does not detect a directory that is
readable but not writable). A read-only volume or mount (:ro) is refused
too, with mount it read-write (no :ro) in place of the chown advice.
Docker Desktop on macOS and Windows does not enforce host ownership on bind
mounts, so no chown is needed there. On SELinux hosts, also add :Z to
the mount (-v ./vyrtel-data:/data:Z). Running the image as another user
(--user) is possible but the directory must then be writable by that
user. Rootless Docker, user-namespace remapping, Kubernetes and Windows
containers are not tested.
Every release runs these steps on amd64 and arm64 before the image is published (container image checks).
Docker changes in 0.1.2
- Images carry
org.opencontainers.image.version(and title, description and licence labels). Images you build yourself are labelleddevunless you pass--build-arg VYRTEL_VERSION=<version>. - The permission hint names the directory, its owner and mode, the uid
Vyrtel runs as and the exact
chown -R 65532:65532fix, and a read-only mount gets its own advice. Before, it was one line pointing at this page. The exit status (1) is unchanged. - Nothing else changes for existing deployments: the user (65532),
/data, the port and the healthcheck are as before, so existing named volumes and correctly owned bind mounts keep working with the new image.
Stopping and upgrading
Stop Vyrtel with a stop signal, then start the new version on the same data directory. Nothing has to be flushed or exported by hand.
| Platform | Stop signals | Sent by |
|---|---|---|
| Linux, macOS | SIGTERM, SIGINT (Ctrl+C) |
docker stop, systemctl stop, Kubernetes, kill |
| Windows | Ctrl+C, Ctrl+Break, closing the console window, system shutdown | the console, service wrappers such as NSSM or WinSW |
What happens
From the first stop signal, Vyrtel:
- Refuses new work. The listener closes, so new connections are
refused. A request that still arrives on an open keep-alive connection
gets
503 shutting_downwithRetry-After: 1andConnection: close(ingest, queries and live tail; the body is not read)./readyreturns503 {"status": "draining"}so a load balancer stops routing to it;/healthstays200until exit. - Ends live-tail streams. Each stream sends the events it has already
matched, then a
shutdownevent withretry: 2000, and ends normally.EventSourceclients reconnect after two seconds. Browsers give up if a reconnect is answered with an HTTP error (a proxy's503, say), so the web UI then opens a new stream itself, backing off from 1 to 30 seconds; it shows connecting meanwhile and live again once reconnected. - Drains accepted work. Requests already admitted run to their
response: each ingest request is written to the WAL, acknowledged and
answered (
200), and queries in progress complete. - Flushes storage. Each signal writes out its queue and fsyncs its WAL. Unsealed data is not sealed into segments; the next start replays it from the WAL. If this final fsync fails, the shutdown is not clean (see below).
- Stops background jobs (alert evaluation) and saves query statistics, then lets a seal or compaction already in progress finish.
- Records the inventory of WAL and segment files in
vyrtel.json, which a restored backup is checked against. - Exits with status 0, after logging
shutdown complete.
All of this shares one deadline, server.shutdown_timeout (default 8s),
counted from the first signal. An idle server stops in milliseconds; a
request waiting for its acknowledgement holds the drain for at most
ingest.ack_timeout, and a slow upload for as long as the client takes to
send it.
When the deadline passes, the signal is repeated, or the flush fails
Status 0 means every step above finished. Otherwise Vyrtel logs an
ERROR, prints the same text to stderr and exits with status 1, in
every step alike: when the deadline passes, at once on a second stop
signal (Ctrl+C twice, kill -TERM twice), when the final WAL fsync
fails, and when the inventory cannot be written:
vyrtel: shutdown incomplete: server.shutdown_timeout (1s) passed while waiting for in-flight requests; unfinished: 1 ingest request not acknowledged (the client gets no response and should retry). Acknowledged events are already in the WAL and are replayed on the next start; raise server.shutdown_timeout if draining regularly takes longer
The message names the step that did not finish: waiting for in-flight requests, storage was writing queued batches and flushing the WAL,
background jobs were stopping and query statistics were being saved,
a seal or compaction was finishing, or the data directory inventory was being recorded. The last three say every accepted request was already
written and flushed; an interrupted seal is redone from the WAL on the next
start, and without an inventory a backup of the directory cannot be checked
for missing files (start and stop Vyrtel again before backing it up).
A failed inventory write is reported the same way, with the error:
vyrtel: shutdown incomplete: every accepted request was written and the WAL flushed, but the data directory inventory could not be recorded (No space left on device (os error 28)). The directory starts normally, but a backup of it cannot be checked for missing or truncated files when it is restored; fix the storage problem, then start and stop Vyrtel again before backing it up (docs/configuration.md#backup-and-restore)
The message then lists only the kinds of work that were unfinished:
| Unfinished work | What it means |
|---|---|
N ingest requests not acknowledged |
Their clients get no response, only a closed connection. Retry them: each batch is all-or-nothing, but one already written to the WAL just before the exit is replayed, so a retry can duplicate it (as after a 503 unavailable). |
N queued events not written (logs N, …) |
Events of those requests that were still waiting for the writer, per signal. |
N live-tail streams still open |
A client stopped reading and its connection's buffers are full, so its stream could not end. Nothing is lost. |
Acknowledged events are never affected. A request is acknowledged only
after its batch is in the WAL, so the next start replays it whether the
drain finished or not (in normal durability an OS crash or power loss
straight after exiting can still lose up to fsync_interval, exactly as
for a running server; see crash recovery).
After an incomplete shutdown, start Vyrtel as usual; recovery may report a
walTail quarantine if the process exited while writing a record.
A failed final flush (full disk, I/O error) is reported with the fault
for each affected signal, as GET /api/v1/system/storage would show it:
vyrtel: shutdown incomplete: the final WAL flush failed (logs: walSync noSpace on wal/logs/000000000001.wal: No space left on device). Events acknowledged since the last successful fsync reached the operating system but may not be on disk: the next start replays them, but an OS crash or power loss before the OS writes them out can lose them; fix the storage problem (docs/configuration.md#storage-failures)
This only matters in normal durability (strict fsyncs before every
acknowledgement). The process stopped normally, so nothing is lost unless
the host crashes before the OS writes its cache out; fix the volume as for
any storage failure before relying on the data.
If drains regularly hit the deadline, raise server.shutdown_timeout (and
the orchestrator's grace period with it), or look for clients holding
uploads or live-tail streams open.
Grace periods
The orchestrator must wait longer than server.shutdown_timeout before it
force-kills the process, or the diagnostic is never printed. Leave at least
two seconds of margin:
| Runs under | Default grace period | With a longer shutdown_timeout |
|---|---|---|
docker run / docker stop |
10 s | docker run --stop-timeout 35 … or docker stop -t 35 vyrtel |
| Docker Compose | 10 s | stop_grace_period: 35s on the service |
| Kubernetes | 30 s (terminationGracePeriodSeconds) |
raise terminationGracePeriodSeconds |
| systemd | 90 s (TimeoutStopSec) |
usually enough |
The default of 8s fits inside Docker's 10 seconds. On Windows, closing
the console window or shutting the system down gives a process only a few
seconds (about 5 for a closed console) before Windows terminates it
whatever it is doing; acknowledged events are still safe, but prefer Ctrl+C
or Ctrl+Break, and configure service wrappers to send one of them and wait
longer than shutdown_timeout.
Upgrade runbook
- Stop the running version:
docker stop vyrtel,systemctl stop vyrtel, or Ctrl+C. If you run more than one instance behind a load balancer,/readyturns503as soon as the drain starts. - Check the last lines it logged:
shutdown completeand exit status 0 means everything accepted was acknowledged.shutdown incomplete(exit status 1) lists what was not; see above. - Check that the new version can read the data directory, with the new
binary or image and before starting it:
vyrtel check-data-dir --data-dir /var/lib/vyrtel/data, ordocker run --rm -v vyrtel-data:/data ghcr.io/rowan-smith/vyrtel:0.1.2 check-data-dir. It changes nothing. Exit status 1 means the new version cannot start it: before 1.0 that is the case whenever the new release has another layout (0.1.2 reads no directory an earlier release wrote). Stay on the current version, or follow Upgrading between pre-1.0 releases to start the new one on a new data directory. Take a backup of the stopped directory either way. - Replace the binary or image (
docker pull, new package) and read the release notes for configuration changes, such as Upgrading to 0.1.2. - Start it on the same data directory (or, for a new layout, on the new
one). Each signal logs a
recovered streamline with the WAL records it replayed. After a clean stop,GET /api/v1/system/storageshows an emptyrecovery.quarantinedlist. - Check
GET /readyis200and that recent data is queryable.
Zero-downtime upgrades of a single process are not supported: between the old process exiting and the new one listening, connections are refused. Producers should buffer and retry (the OpenTelemetry SDKs and Collector do).
Example (Linux, from the repository root, using the shared fixture):
vyrtel --data-dir ./data &
curl -s -X POST localhost:8080/api/v1/events \
-H 'Content-Type: application/x-ndjson' --data-binary @tests/fixtures/logs.ndjson
# {"accepted":8}
kill -TERM $!; wait $!; echo "exit $?"
# exit 0 (stderr: "stop requested; draining accepted work for up to 8s …", "shutdown complete")
vyrtel --data-dir ./data &
curl -s -X POST localhost:8080/api/v1/query/logs/count \
-H 'Content-Type: application/json' -d '{"query": ""}'
# {"count":8,"diagnostics":{…}}
The integration tests in tests/integration/shutdown.rs reproduce the
deadline and repeated-signal cases by holding an upload open.
Shutdown changes in 0.1.2
- Shutdown now has a deadline,
server.shutdown_timeout(default8s). Before, Vyrtel waited for in-flight requests without limit, so a stuck upload ended only when the orchestrator killed the process, with no record of what was unfinished. If your drains need longer, raise it together with the grace period. - A shutdown that does not finish in time, is interrupted by a second
stop signal, or whose final WAL fsync fails exits with status 1 and
an
ERRORline startingshutdown incomplete:. Before, a failed final fsync was only logged and the exit status was 0. Supervisors that treat a non-zero exit as a crash may restart or alert on it; that is intended. - Ingest, query and live-tail requests that reach a stopping server get
503 shutting_down, and/readyreturns503 {"status": "draining"}. - Live-tail streams end with a
shutdownevent (retry: 2000) instead of just closing. Clients that ignore unknown event types are unaffected. - A second stop signal now ends the drain at once; before, it was ignored.
- On Windows, Ctrl+Break, closing the console and system shutdown now stop Vyrtel gracefully too; before, only Ctrl+C did.
Crash recovery and backups
Vyrtel needs no repair step after a crash: start it again on the same data
directory. Startup replays the write-ahead log, finishes any interrupted
sealing or compaction and logs one recovered stream line per signal.
What survives (full table in the acknowledgement contract):
| Durability | Process killed / crashed | OS crash or power loss |
|---|---|---|
normal |
every acknowledged request survives | up to fsync_interval (1 s) of acknowledged requests may be lost |
strict |
every acknowledged request survives | every acknowledged request survives |
Either way each surviving event is visible exactly once with its original
id, and a request is never half-applied. Choose strict when a lost second
of telemetry on power failure is unacceptable; it costs one fsync per group
of concurrent requests.
Checking a recovery. After an unclean stop, look for these at startup:
WARN recovery found damaged files; see the quarantine directory— damage was found. Each damaged item has its ownERROR/WARNline withsignal,kind,reasonandquarantined_to.GET /api/v1/system/storage→signals.<signal>.recoverylists the same items (quarantined) and how much was replayed.- A startup that fails with an I/O error (permission denied, device errors) is deliberate: Vyrtel refuses to set aside files it merely could not read. Fix the permissions or disk and start again; nothing has been changed.
Quarantine. Damaged files are moved to data/quarantine/<signal>/ and
never deleted by Vyrtel. A torn final WAL record after a crash (walTail)
is normal after power loss in normal mode and loses only that record;
whole segment or wal items mean the disk or something outside Vyrtel
altered the files. See Quarantine for what
each kind lost. Copy the files somewhere for diagnosis if needed, then
delete them; the directory's size is reported as quarantineBytes.
Backups. See Backup and restore.
Recovery health
Every start reports, per signal, what recovery found and what is in quarantine. Signed-in users see it in three places:
- Settings → Storage shows a warning above the storage totals when any
signal is not
clean: which signals may be incomplete, how many bytes of WAL were discarded, how many items were quarantined this start and how many files are inquarantine/<signal>/now (or why it could not be read), with each item's kind, reason and quarantine file name. After a clean start nothing is shown. GET /api/v1/system/storage→ top-levelrecovery(overall status and the affected signals) andsignals.<signal>.recovery(counts, discarded and quarantined bytes, the quarantine directory and each item). See System.GET /api/v1/system/info→recovery, the same overall status.
| Status | Meaning | What to do |
|---|---|---|
clean |
Nothing was discarded or quarantined, and quarantine/<signal>/ is empty. Replaying the WAL is normal and does not change this. |
Nothing. |
damaged |
This start quarantined a segment, a WAL file or a WAL tail, or discarded WAL bytes. | Read the reasons; see Quarantine for what each kind lost. |
unknown |
This start found nothing new, but quarantine/<signal>/ (or something in it) could not be read, so whether earlier evidence is there is not known. quarantineDir.error gives the reason. |
Make the directory readable by the user Vyrtel runs as; the status updates on the next request, no restart needed. |
quarantineRetained |
This start found nothing new, but quarantine/<signal>/ still holds files from an earlier start, including one whose recovery was itself interrupted after setting a file aside. |
Copy the files off if you need them, then delete them. |
The overall status is the worst of the three signals (damaged, then
unknown, then quarantineRetained, then clean). Not being able to look
is never reported as clean. A missing quarantine/<signal>/ is clean:
it is created only when recovery first sets something aside.
Limitations. Vyrtel does not repair quarantined data or guess what it
held. Events in a quarantined segment come back only if the WAL it was
sealed from still existed; events in a quarantined WAL file or tail are not
queryable. A damaged signal stays damaged until the next start, even
after you delete the files, because it describes what this start found.
The per-item list covers only this start; earlier items show up as the file
count and size in quarantineDir.
What is never shown. Quarantine file names are relative to the data
directory, reasons name the damage (incomplete record at offset 48213 (torn write)) and never file contents, and credentials never appear. /health
and unauthenticated /ready say nothing about recovery: quarantine does not
make an instance unready, since every readable event is served.
Example. This is what tests/integration/recovery_health.rs does: tear
the last WAL record of a stopped server as a crash mid-write would, then
start it again.
vyrtel --data-dir /tmp/vyrtel-rel11 &
for m in committed torn; do
curl -s -H 'content-type: application/json' -d "{\"message\":\"$m\"}" localhost:8080/api/v1/events
done # {"accepted":1} twice
kill -9 %1
wal=$(ls /tmp/vyrtel-rel11/wal/logs/*.wal | tail -1)
truncate -s -5 "$wal" # tear the final record
vyrtel --data-dir /tmp/vyrtel-rel11 &
curl -s localhost:8080/api/v1/system/storage | jq -c '.recovery, .signals.logs.recovery.status'
# {"status":"damaged","signals":["logs"]}
# "damaged"
Only committed is queryable afterwards. Restart once more and the status
is quarantineRetained; stop, delete /tmp/vyrtel-rel11/quarantine, start
again, and it is clean. Sign in first as in
Verification queries.
Recovery health changes in 0.1.2
signals.<signal>.recoveryin/api/v1/system/storagegainsstatus,walFilesReplayed,segmentsQuarantined,walFilesQuarantined,walTailsQuarantined,quarantinedBytesandquarantineDir; the response and/api/v1/system/infogain a top-levelrecoverysummary. Existing fields are unchanged.- Settings → Storage warns when a signal is
damagedorquarantineRetained. - Files left in quarantine by an earlier or interrupted recovery are now reported. Before, only items quarantined by the current start were listed.
- A quarantine directory that cannot be read is reported as
unknown, with the reason, never asclean. quarantineBytesis now the sum of thequarantine/<signal>/directories (one scan per request instead of two); files placed directly inquarantine/by hand are no longer counted.
Backup and restore
A backup is a copy of the whole data directory taken while Vyrtel is stopped. Restored on any host, it brings back every log, trace and metric, and every dashboard, alert, saved query, API key, user and session. Vyrtel has no export or import step.
What the data directory holds
| Entry | Contents | In a backup |
|---|---|---|
vyrtel.json |
Manifest: the layout identity, the data-directory format, the Vyrtel version that last wrote it and, while stopped after a clean shutdown, the inventory of WAL and segment files with their sizes |
required |
metadata/metadata.db |
Dashboards, alerts and their state, saved queries, API keys (hashed), users (Argon2id), sessions (hashed), query statistics | required |
metadata/metadata.db-wal, -shm |
SQLite journal; present only while running or after a crash | if present |
wal/<signal>/ |
Write-ahead logs: the newest telemetry, not yet sealed | required |
segments/<signal>/ |
Sealed telemetry | required |
quarantine/ |
Damaged files recovery set aside (quarantine) | optional |
tmp/, indexes/, .lock |
Scratch space and the process lock; recreated at start | not needed |
The configuration file and VYRTEL_* environment variables are not in the
data directory. Back them up separately. Whether ingestion needs a key is
not configuration: it follows the restored API keys.
Protect backups like credentials. They hold the password and key hashes, session tokens (hashed) and alert webhook URLs in plain text. A session or API key in a restored backup works again until it expires or is revoked, so after restoring a backup that may have leaked, revoke the API keys and change the admin password.
Back up a stopped server
Stop Vyrtel (
docker stop vyrtel,systemctl stop vyrtel, Ctrl+C) and check that it loggedshutdown completeand exited with status 0. A clean shutdown records every WAL and segment file and its size invyrtel.json(inventory), so the restored copy is checked for missing or truncated files when it starts. After an incomplete shutdown (status 1, including an inventory that could not be written or did not finish beforeserver.shutdown_timeout) or a crash there is no inventory: the backup is still consistent and restores like one taken after a crash, but a file left out of it cannot be detected. Start and stop Vyrtel cleanly once before backing up if you can.Copy the whole data directory, for example:
tar -C /var/lib/vyrtel/data -czf vyrtel-backup-$(date +%F).tar.gz .For a Docker named volume:
docker run --rm -v vyrtel-data:/data -v "$PWD":/backup busybox tar -C /data -czf /backup/vyrtel-backup.tar.gz .Start Vyrtel again. Producers that buffer and retry (the OpenTelemetry SDKs and Collector do) resend what they could not deliver meanwhile.
Without stopping, back up from an atomic filesystem snapshot (LVM, ZFS, btrfs, cloud volume snapshots). It restores like a power loss: recovery applies and the contract holds. Do not copy the directory file by file while Vyrtel runs: sealing can move events from a WAL into a segment between two copies, so the copy can miss or duplicate them. Scheduling backups and online snapshots taken by Vyrtel itself are not provided.
Restore
Install the Vyrtel version that took the backup, or a later one of the same layout (
vyrtel check-data-diron the restored copy says whether it can start it). Before 1.0 a release reads only data directories of its own layout and refuses any other, unchanged (see Upgrading between pre-1.0 releases).Stop Vyrtel if it is running on the target directory, and move any existing data directory aside.
Unpack the backup as the data directory (
storage.path), and make it writable by the user Vyrtel runs as (uid 65532 in the Docker image, see bind mounts):mkdir -p /var/lib/vyrtel/data tar -C /var/lib/vyrtel/data -xzf vyrtel-backup-2026-10-11.tar.gzStart Vyrtel with the same
[auth]settings.auth.admin_passwordmay be left out: the restored admin user keeps its password, and no new one is generated. If it is set, it replaces the stored password, exactly as on any start.Check the startup log: one
recovered streamline per signal, and norecovery found damaged fileswarning for a backup of a stopped server.Run the verification queries below.
Verification queries
Run these before taking the backup and after restoring it; the answers
must match. Log in first and pass the
session cookie (-b cookies below), or replace it with an admin API key
(-H "Authorization: Bearer $ADMIN_KEY").
curl -s -c cookies -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}' localhost:8080/api/v1/auth/login
for s in logs traces metrics; do # event counts per signal
curl -s -b cookies -H 'Content-Type: application/json' \
-d '{"query":""}' localhost:8080/api/v1/query/$s/count
done
curl -s -b cookies localhost:8080/api/v1/traces/<trace id> # a known trace, span by span
curl -s -b cookies localhost:8080/api/v1/metrics # metric names
curl -s -b cookies localhost:8080/api/v1/dashboards # dashboards and panels
curl -s -b cookies localhost:8080/api/v1/alerts # alert definitions
curl -s -b cookies localhost:8080/api/v1/saved-queries
curl -s -b cookies localhost:8080/api/v1/api-keys # names, prefixes, scopes
curl -s -b cookies localhost:8080/api/v1/system/storage # recovery.quarantined is [] per signal
Then check authentication: an ingest request with a restored API key
returns 200, one without a key returns 401, and the admin login works
with the password the backed-up instance had.
Counts cover every stored event whatever its age, but retention keeps
running after the restore: segments older than [retention] are deleted as
on any start, so restore with the same retention, and compare counts soon
after starting. An alert's evaluation state (OK, FIRING) is restored and
then updated by its next evaluation.
What startup checks
Before Vyrtel creates, locks or changes anything in the data directory, it checks that the directory is complete and that this version can read it. If not, it exits with status 1, prints why and changes nothing, so you can fix the restore and start again:
| Found | Message (after vyrtel: data directory <path>) |
What to do |
|---|---|---|
| A WAL or segment file in the inventory is missing | is incomplete: wal/logs/000000000003.wal is missing |
Restore the whole directory again |
| A WAL or segment file in the inventory has another size (a truncated copy) | is incomplete: segments/logs/000000000002.seg has 4096 bytes, 81920 at the last clean shutdown |
Restore the whole directory again |
A required entry is missing (vyrtel.json present) |
is incomplete: wal/metrics is missing |
Restore the whole directory again |
WAL or segment files but no metadata/metadata.db (vyrtel.json present) |
is incomplete: metadata/metadata.db is missing |
Restore the whole directory again |
WAL or segment files and metadata/metadata.db of this layout, but no vyrtel.json |
is incomplete: vyrtel.json is missing |
Restore vyrtel.json from the same backup |
metadata/metadata.db is empty (no schema) next to vyrtel.json |
is incomplete: metadata/metadata.db is empty (it has no schema) |
Restore it from the same backup |
metadata/metadata.db but a signal's wal/ or segments/ directory is missing (no vyrtel.json) |
is incomplete: wal/logs is missing; … |
Restore the whole directory again |
vyrtel.json is not valid JSON, has an unknown key or no layout/format it can use, or its inventory lists something other than WAL and segment files |
has an unusable vyrtel.json: … |
Restore vyrtel.json from the same backup |
A directory of another layout: no vyrtel.json and a metadata database or telemetry without this layout's identity (0.1.1, Observer 0.1.0), a vyrtel.json with another layout or none, or a WAL, segment or metadata file of another layout mixed in |
is not in the layout Vyrtel 0.1.2 reads: there is no vyrtel.json, and metadata/metadata.db has no layout identity (…), … vyrtel.json has layout 0.1.3, written by Vyrtel 0.1.3, … wal/logs/000000000004.wal is a WAL file with no layout identity … |
See Upgrading between pre-1.0 releases |
| A file of this layout in a format version it does not have (its header or footer, any checksummed WAL record or event batch, the metadata schema) | cannot be read by Vyrtel 0.1.2: segments/logs/000000000004.seg uses segment format version 2, … |
Start it with the build that wrote it |
metadata/metadata.db is not an SQLite database |
has an unreadable metadata/metadata.db: … |
Restore it from the same backup |
An incomplete directory lists every problem found (the first ten, then how
many more), separated by ;. For example, a backup that left out one WAL
file:
vyrtel: data directory /var/lib/vyrtel/data is incomplete: wal/logs/000000000003.wal is missing. Vyrtel did not start and changed nothing in it. If it is a restored backup, restore the whole data directory again; to start without the missing parts on purpose, start once with --accept-incomplete-restore (see docs/configuration.md#backup-and-restore)
Continuing without the missing parts. If the rest of the backup is all
there is, start once with --accept-incomplete-restore
(vyrtel --accept-incomplete-restore, or append it to the container's
command). Vyrtel logs one WARN starting without part of the data directory line per problem and starts without them: events in missing WAL
and segment files are gone, missing directories are created, and a missing
or empty metadata database starts with no dashboards, alerts, keys or
users (with no auth.admin_password, a new admin password is generated
and printed), and a missing vyrtel.json is
written again. The flag only covers missing, truncated and empty parts;
another layout or format is always refused. It is not a
configuration setting on purpose, so it is not left on by accident: once
started, the directory is complete as it is, and later starts need no flag.
A directory without any of Vyrtel's entries (empty, or a new volume with
only lost+found) is a new data directory. One from before 0.1.2 (no
vyrtel.json) is refused, unchanged: see
Upgrading between pre-1.0 releases.
Damaged files are not refused: recovery quarantines them as after a crash, and the rest starts. A file of another layout or format is never mistaken for damage, so it is never quarantined. (A file without Vyrtel's magic bytes at all, such as one Observer 0.1.0 wrote, mixed into a directory of this layout, cannot be told from damage and is quarantined: moved, never deleted.)
Limitations.
- Missing WAL and segment files are detected only through the inventory a
clean shutdown records. A directory without one — a backup taken after a
crash or an incomplete shutdown,
or a filesystem snapshot of a running server — is checked for its
directories and metadata only, so a file left
out of such a backup starts without its events. Startup logs
INFO data directory has no inventory from a clean shutdownin that case. - The inventory holds names and sizes, not checksums: a file with the right size but wrong bytes is found by recovery's own checksums instead (every WAL record and segment chunk has one) and quarantined, not refused. Checksumming every file at every start would read the whole data directory.
- The metadata database is not in the inventory (SQLite changes its files as it closes). A missing or empty database is refused; one replaced by a different, valid database is not detected.
- The inventory describes the directory as the last clean shutdown left it, and the next start removes it before changing anything.
- The check reads every version recovery reads but not the data: per WAL record a few bytes, per segment its header, footer and summary. It runs twice per start (before and after taking the lock), so it adds a little startup time on directories with large WALs; see measurements.
vyrtel.json'swrittenByis updated by every version that starts the directory, so it names the newest version that has used it.
Restore drill
scripts/backup/restore-drill.sh
runs this procedure end to end against a built binary on Linux or macOS:
it populates an instance with the shared fixtures
(all three signals in sealed segments and in WALs, a dashboard with a
panel, an alert, a saved query, an API key), stops it, takes a tar
backup, deletes the original, restores the backup into a new directory,
starts it without an admin password configured and compares every
verification query. It then checks the API key, anonymous refusal and
login; that copies without metadata/metadata.db, with an empty one,
without one WAL file and without one segment file each exit with status 1
and are left unchanged; and that --accept-incomplete-restore starts the
copy without the WAL file, after which it starts without the flag. CI runs
it on every change:
cargo build --release -p server
scripts/backup/restore-drill.sh target/release/vyrtel
Backup and restore changes in 0.1.2
- Data directories get a manifest,
vyrtel.json, with the layout identity. Include it in backups. 0.1.2 does not start directories that earlier versions wrote; see Upgrading between pre-1.0 releases. - A clean shutdown records the WAL and segment files and their sizes in
vyrtel.json; a start refuses the directory if any of them is missing or has another size. - A data directory with WAL or segment files, or a manifest, but no
metadata database of its layout or an empty one, now refuses to start. Before, Vyrtel
created an empty metadata database: dashboards, alerts, saved queries
and API keys were lost without a message, and with authentication
enabled and no
auth.admin_password, a new admin password was generated. - New
--accept-incomplete-restoreflag to start such a directory anyway. - A segment or WAL file of another layout, or in a format version this
release cannot read, now stops startup and is left in place. Before, it
was moved to
quarantine/as if damaged, and its events were not visible. - A metadata database of another layout now stops startup. Before, it was opened as is.
Upgrading between pre-1.0 releases
Before 1.0, Vyrtel makes no promise that a release reads data an earlier
release wrote. Each data directory carries a layout identity
("layout" in vyrtel.json, and the same identity in every WAL, segment
and metadata file; see storage format). A
release reads only directories of its own layout. Any other directory is
refused with exit status 1 before anything in it changes: it is not
migrated, converted, quarantined, reset or deleted, and it stays readable
by the release that wrote it. Releases that keep a layout read each other's
directories; a release that changes one says so in its notes. This policy
is reassessed before 1.0. The 0.1.2 audit that introduced it is in
docs/compatibility/0.1.2.md.
| Release | Writes layout | Starts data directories written by |
|---|---|---|
| 0.1.0 (Observer) | Observer's own (no identity) | 0.1.0 |
| 0.1.1 | none (no identity, no vyrtel.json) |
0.1.1 (it checks nothing: never start it on newer data) |
| 0.1.2 | 0.1.2 |
layout 0.1.2 only; refuses every directory above unchanged |
Builds of 0.1.2 made before its layout reset (a vyrtel.json with
"format": 1 or 2 and no "layout") are not 0.1.2 releases and their
directories are refused the same way.
Before switching: vyrtel check-data-dir
Run the new version's check-data-dir on the stopped data directory.
It reads only (no lock file, no journal, nothing created, safe even if it
is the wrong directory) and runs the same checks as a start:
- Exit status 0: the new version can start it. It prints what it found and what the first start changes.
- Exit status 1: it cannot. It prints the same message a start would (see What startup checks) and changes nothing.
- Exit status 2: the configuration could not be loaded.
--data-dir, -c and VYRTEL_STORAGE_PATH choose the directory as they do
for a start, and --accept-incomplete-restore shows what such a start
would accept. For a data directory written by 0.1.1:
$ vyrtel check-data-dir --data-dir /var/lib/vyrtel/data
vyrtel: data directory /var/lib/vyrtel/data is not in the layout Vyrtel 0.1.2 reads: there is no vyrtel.json, and metadata/metadata.db has no layout identity (it has schema_migrations: Vyrtel 0.1.1 or earlier, or Observer, wrote it). Vyrtel 0.1.2 reads layout 0.1.2 only, and before 1.0 a release reads no data directory of another layout. Vyrtel did not start and changed nothing in it. Keep running the Vyrtel version that wrote it, or start Vyrtel 0.1.2 on a new, empty data directory (see docs/configuration.md#upgrading-between-pre-10-releases)
$ echo $?
1
A directory 0.1.2 wrote, once stopped cleanly:
data directory /var/lib/vyrtel/data
vyrtel.json: layout 0.1.2, format 1, last written by Vyrtel 0.1.2
clean-shutdown inventory: every WAL and segment file is present with its size
metadata/metadata.db: layout 0.1.2, schema version 1
Vyrtel 0.1.2 (layout 0.1.2, data-directory format 1, metadata schema 1) can start it.
Starting it changes no file formats.
Moving to a release with another layout
There is no in-place upgrade, export or import between layouts. The old directory stays exactly as it was, so this is always reversible:
- Stop the old version cleanly (exit status 0) and take a backup of the stopped data directory. Keep it until you no longer need its data.
- Decide what to keep from the old instance:
- Telemetry: keep the old version running on the old directory (on another port, read-only use) until its retention has passed, and query it there. It cannot be moved into the new layout.
- Dashboards, alerts and saved queries: read them from the old
instance (
GET /api/v1/dashboards,/api/v1/alerts,/api/v1/saved-queries) and create them again through the new one's API or UI. - API keys and users: only their hashes are stored, so they cannot
be carried over. Create new keys on the new instance and update the
producers that use them; set
auth.admin_password(or note the generated one) on its first start.
- Start the new version on a new, empty data directory (another
storage.pathor volume).check-data-diron it reportsStarting it will create a new data directory in this layout. - Point producers at the new instance. Producers that buffer and retry (the OpenTelemetry SDKs and Collector) resend what they could not deliver meanwhile.
- Delete the old directory yourself once nothing needs it. No Vyrtel release deletes, resets or overwrites a data directory it refused.
Going back is the same procedure in reverse: stop the new version and
start the old one on its own, untouched directory (or its backup). Never
start 0.1.1 or Observer 0.1.0 on a directory a newer release wrote: they do
not check layout identities, and may take its files for damage and move
them to quarantine/, or fail part-way through their own metadata
migration.
Interrupted first start
A first start creates the signal directories, then the metadata database
(its schema and identity in one transaction), then vyrtel.json, then the
first WAL. Interrupted at any point, it is finished by the next start of the
same version: a directory without vyrtel.json but with no telemetry, and a
metadata database that is absent, empty or of this layout, is accepted and
recorded (check-data-dir: Starting it will finish setting it up and record the layout in vyrtel.json.). A creation that fails rather than
stops, for example on a full disk, exits with status 1:
vyrtel: opening metadata store: creating the metadata schema failed (nothing was created): database or disk is full
Fix the cause and start again.
Reproducing the refusal
tests/fixtures/datadir/v0.1.1/data and v0.1.0/data are data
directories the 0.1.1 and Observer 0.1.0 releases wrote and stopped
cleanly (fixtures). From the
repository root (Linux, macOS):
cp -R tests/fixtures/datadir/v0.1.1/data /tmp/vyrtel-old
# git keeps no empty directories; the release left these in place:
mkdir -p /tmp/vyrtel-old/{wal,segments}/{logs,traces,metrics}
find /tmp/vyrtel-old -type f -exec sha256sum {} + | sort > /tmp/before
vyrtel check-data-dir --data-dir /tmp/vyrtel-old # exit 1, as above
vyrtel --data-dir /tmp/vyrtel-old # exit 1, the same message
find /tmp/vyrtel-old -type f -exec sha256sum {} + | sort | diff /tmp/before - && echo unchanged
vyrtel --data-dir /tmp/vyrtel-new # the documented way forward
tests/integration/layouts.rs runs this for every committed fixture,
compares every file's SHA-256 before and after, and also refuses WAL,
segment and metadata files of 0.1.1 mixed into a directory of this layout.
Compatibility changes in 0.1.2
Breaking changes; see docs/compatibility/0.1.2.md for the full inventory.
- Data directories from earlier versions are refused. 0.1.2 starts no directory that 0.1.1, Observer 0.1.0 or an earlier 0.1.2 build wrote; it exits with status 1 and changes nothing. Start it on a new directory as described above. Before, 0.1.2 builds recorded a manifest in such a directory and kept reading its old files.
- Every persisted format is at version 1 of layout
0.1.2, and every file carries the layout identity: the WAL header grew to 32 bytes, the segment header to 40 and its footer to 64, the event codec of WAL batches is 1 (with the per-batch resource table), and the metadata database has anapplication_idand avyrtel_layouttable instead ofschema_migrations. Readers of the earlier layouts are gone. vyrtel.jsonhas a"layout"key, and a manifest with an unknown key is refused instead of ignored.vyrtel check-data-dirno longer prints migration steps or which releases can read a directory: there are no migrations.--accept-incomplete-restoreno longer starts WAL and segment files that neither a manifest nor a metadata database of this layout identifies.- A WAL or segment file of Observer 0.1.0 is no longer recognised by its magic bytes. A directory Observer wrote is refused as a whole; such a file copied into a directory of this layout is quarantined as damage (moved, never deleted).
GET /api/v1/live(an undocumented duplicate of/api/v1/live/logs) is removed; it answers404 not_found. Thespansspelling of thetracessignal in/api/v1/query/{signal}/…and?signal=is removed; it answers404(unknown signal: use logs, traces or metrics) or400.- An environment variable under a settings prefix that is not a setting
(for example
VYRTEL_AUTH_ENABLE) stops startup instead of being ignored (Environment variables).
Storage failures
When the data volume fills up, turns read-only or denies access, Vyrtel stops acknowledging writes instead of pretending they were stored. There is nothing to configure; this section covers what operators see and do. Details are in Storage write failures.
What clients see. Ingest requests get 503 with Retry-After: 1:
{
"error": {
"code": "storage_write_failed",
"reason": "noSpace",
"outcome": "notStored",
"message": "the request was not stored: no space left on the storage device. Nothing in it was acknowledged; retry once storage is writable"}}
With "outcome": "notStored" nothing in the request is stored, so retrying it is safe. "outcome": "unknown" is rare:
the fsync failed, then so did the fsync that would have made removing the request durable. The request is not
acknowledged and not visible, but after a power loss it may come back, and a retry can store it twice. Requests that got
a 2xx before the failure stay stored.
How to notice.
GET /readyreturns503 {"status": "degraded", "storageWritable": false, ...}when a test write todata/tmp/fails (full or read-only volume), and lists the affectedsignalswhile a signal's WAL or sealing keeps failing. Point your orchestrator's readiness probe at it.GET /healthstays 200: the process is alive and queries still work.GET /api/v1/system/storageshowswriteProbeand, per signal,ingestHealthyandfaults. Each fault has the operation, the reason, the file relative to the data directory, the OS error text, when it was first and last seen, and how often it occurred. Host paths and secrets are never included.- The log has one
ERROR storage write failedline per new fault, withsignal,operation,reasonandpath, and anINFO storage write path recoveredline when it clears.
What to do. Free space (shorten [retention], remove files that are
not Vyrtel's, grow the volume), or fix the mount and permissions so the user Vyrtel runs as can write the whole data
directory. Vyrtel retries by itself: within about a second of storage working again the fault clears and readiness
returns, even if no requests arrive. A restart is not needed but is safe, and
recovery brings back every acknowledged event. Do not delete files under
wal/ or segments/ to make room; they hold acknowledged telemetry. If
the disk fills up during a backup or restore, start that backup or restore
again; the rules above still apply.