Testing
Testing is part of the implementation. Everything below runs in CI
(.github/workflows/ci.yml).
cargo test --workspace --all-features # all Rust tests (~220, many property-based; includes crash and overload tests)
cd web && npm test # frontend unit tests
cd web && npm run e2e # Playwright (needs a built server, see below)
cargo bench -p storage -p query # Criterion benchmarks
node --test scripts/release/*.test.mjs # release gate (see docs/releasing.md)
Unit tests
Next to the code (#[cfg(test)] modules). Highlights:
| Area | Where |
|---|---|
| Query tokenizer, parser (precedence, errors, positions), AST display | crates/query/src/{lexer,parser}.rs |
| Comparison evaluation and type coercion rules | crates/query/src/eval.rs |
| Field resolution | crates/query/src/fields.rs |
| Timestamp parsing (RFC 3339, Unix units, precision) | crates/telemetry/src/time.rs |
| Varints, zigzag, bounds checks | crates/storage/src/encoding.rs |
| Event codec, versioning (only version 1), garbage/truncation handling; resource tables (one entry per distinct resource, shared on decode) | crates/storage/src/codec.rs |
| WAL encoding/decoding, CRC validation, torn records; streamed appends write the same bytes as whole ones; the layout identity in the header (a 0.1.1 header, another identity or one cut short refused, never replayed) | crates/storage/src/wal.rs |
| Segment header and footer layout identity, checked before the version | crates/storage/src/segment/format.rs |
| Layout identity tag and its descriptions in errors | crates/telemetry/src/layout.rs |
| Segment write/read, metadata, corruption detection | crates/storage/src/segment/mod.rs |
| Bloom filters, bitmaps, HyperLogLog, canonical index keys | crates/storage/src/index/ |
| Configuration parsing, env overrides, memory budgets | crates/server/src/config.rs |
| OTLP mapping (protobuf + JSON), native/CLEF parsing, templates | crates/ingest/src/ |
Decoding limits (REL-16): events, decoded bytes and depth at limit − 1 / limit / limit + 1 for every format; shared OTLP resources; the protobuf pre-scan covers what prost allocates and accepts what it accepts |
crates/ingest/tests/limits.rs, crates/ingest/src/{limits,jsontree,native}.rs |
Real heap of native decoding (a per-thread counting allocator): an escaped-key envelope over the event limit is refused without building its tree; [] allocates nothing and arrays of exactly 4, 8 or 16 elements no growth step beyond them |
crates/ingest/tests/alloc.rs |
| Metadata store (schema and identity created in one transaction, an interrupted creation leaves nothing, CRUD, alert transitions, read-only identity inspection, 0.1.1-style, foreign, other-layout and other-schema databases refused with every byte unchanged) | crates/metadata/tests/store.rs, crates/metadata/src/lib.rs |
Data-directory check: new and interrupted first-start directories accepted; missing parts, inventory files missing or truncated, an empty metadata database refused as incomplete; manifests, metadata and telemetry of another layout or without one, and unidentified telemetry, refused and never accepted; accept_incomplete returns the incomplete problems instead; the inventory is dropped on start; the check-data-dir report |
crates/server/src/datadir.rs |
| Environment variables under a settings prefix that are not settings stop startup (TC2) | crates/server/src/config.rs |
Property tests (proptest)
| Property | Where |
|---|---|
encode(events) → decode() == events and a segment body's head + body round trip, for arbitrary logs, spans, metrics |
storage::codec |
| arbitrary bytes never panic the decoder; truncation is always detected | storage::codec |
| random WAL records → replay → identical records; a cut at any byte replays exactly the complete records before it | storage::wal |
| segment writing → reading → identical event set (random block sizes) | storage::segment |
| arbitrary valid AST → display → parse → identical AST; parser never panics | crates/query/tests/roundtrip.rs |
Event generators live behind the telemetry/testing feature so all crates
share them.
Crash-recovery tests
crates/storage/tests/recovery.rs simulates crashes by leaving files in the
states an interrupted process would:
- empty WAL; valid WAL replayed after restart (and ids continue);
- truncated final WAL record → dropped, tail preserved in quarantine, subsequent appends clean;
- invalid checksum → replay stops there, nothing after it is interpreted;
- temporary segment file left in
tmp/→ deleted; - partial/damaged segment in
segments/→ quarantined, rebuilt from its WAL; - restart after segment commit but before WAL deletion → no duplicates;
- restart during compaction (output committed, inputs still present) → no duplicates;
- several WALs at startup → older ones sealed;
- retention across restart, queue-full backpressure, strict durability, data-directory locking;
- quarantine evidence: a torn or corrupt final WAL record is preserved byte
for byte under
quarantine/<signal>/, the report names signal, kind and reason, and a second restart neither re-quarantines nor duplicates; damaged segments and WALs with bad or foreign headers are moved whole; reasons never contain host paths; - an unreadable-but-undamaged WAL or segment (mode 000 on Unix, an exclusive share lock on Windows) stops startup with an I/O error naming the file, leaves it byte-for-byte in place and quarantines nothing; once readable, the acknowledged events come back (skipped when running as root, which bypasses permissions);
- a file in a newer format is not damage (REL-06): a segment whose header
and checksummed footer say version 2, a WAL whose header says version 3,
and a checksummed WAL record with an unknown record version or batch
codec each stop startup with
UnsupportedFormatnaming the file, with every file in the data directory unchanged;check_formatsfinds the header cases without opening storage; a segment whose header alone disagrees with its footer, and an unknown record version without a valid checksum, are still quarantined as damage.
Process-kill tests
crates/storage/tests/crash.rs kills a real process mid-write. The test
binary re-invokes itself as a child that ingests with several concurrent
producers and keeps a durable client-side ledger (one fsynced line per
acknowledged batch). The child is stopped either at a named crash point —
VYRTEL_CRASH_AT=<point>[:<n>] makes the crash-points build abort() the
n-th time it reaches point, which is as abrupt as kill -9 — or by
kill/TerminateProcess after a random delay. The parent then recovers the
directory and checks, in both strict and normal durability:
- every ledgered batch is visible exactly once with the ids it was acknowledged with;
- batches are whole; per producer they are contiguous and exceed the ledger by at most the one batch in flight;
- sealing/compaction crashes leave visible counts equal to the acknowledged dataset, with nothing quarantined;
- a second recovery is identical and new ids do not collide.
| Test | Crash points |
|---|---|
kill_before_wal_sync |
wal.before_sync — records written, not synced or acknowledged |
kill_after_wal_sync_before_ack |
wal.before_ack — records synced (strict), not acknowledged |
kill_after_ack |
wal.after_ack |
kill_during_rotation_and_seal |
rotate.after_wal_create, segment.before_rename, segment.after_rename, seal.before_wal_delete |
kill_during_compaction |
compact.before_input_delete, compact.mid_input_delete, segment.before_rename (compaction output) |
kill_during_recovery_seal |
a crash during rotation, then recovery.before_wal_delete while recovering from it |
random_kills_under_load |
four kill/restart cycles on one directory at random moments (seed printed; VYRTEL_CRASH_SEED replays) |
The points are listed in crates/storage/src/crash.rs; without the
crash-points feature they compile to nothing. Run just these tests with:
cargo test -p storage --features crash-points --test crash
tests/integration/crash.rs does the same end to end: it kills the real
vyrtel binary after HTTP acknowledgements (both durability modes),
restarts it and checks every acknowledged event through the query API, plus
the recovery section of /api/v1/system/storage.
tests/integration/recovery_health.rs covers what operators see (REL-11):
a clean restart is clean although the WAL was replayed; a torn WAL record
is damaged for that signal only, with per-kind counts and discarded bytes,
and neither the storage nor the info response contains the record's bytes,
the admin password, the API key or the data directory, while /health and
/ready are unchanged; the next start is quarantineRetained; and after the
quarantine directory is deleted it is clean again. A segment left in
quarantine by an interrupted recovery is reported as quarantineRetained
for the right signal. A quarantine directory that cannot be listed (a file
in its place everywhere; mode 000 on Unix, skipped as root) is unknown
with the reason, never clean, and is reported again once readable.
web/tests/unit/recovery-health.test.tsx checks the
Settings → Storage warning, and web/tests/e2e/storage.spec.ts that a clean
start shows none.
Storage write-failure tests
crates/storage/tests/faults.rs injects storage errors at named points in
the write path. With the test-only fault-injection feature,
storage::fault::inject(dir, point, fault) makes point fail for files
below dir until the returned guard is dropped. The points are
wal.append, wal.sync, wal.create, segment.write, segment.sync and
probe. The faults are full disk, permission denied, read-only and short
write (half the bytes, then an error). Without the feature the checks
compile to nothing. The tests check that:
- a failed WAL append (each fault, both durability modes) gets
WriteFailed, is not visible, and leaves no torn record: after a restart exactly the acknowledged events are back and nothing is quarantined; - a failed
strictfsync rejects the batch, cuts it from the WAL and fsyncs that cut before replying (notStored). The order is checked withstorage::fault::inject::record(dir), which lists the fault points reached, because a process restart cannot show lost filesystem metadata the way a power loss would; - if the cut's fsync fails too, the reply is
WriteUncertain(unknown), the idle writer fsyncs the cut on its own once storage works (no request needed), and any later acknowledgement waits for that fsync; - a WAL fault clears without new requests once storage works, but not while writes in the WAL directory still fail;
- after a failed background fsync,
normalmode waits for an fsync before acknowledging, and rejects requests while it keeps failing; - a failing seal (segment write or fsync) makes new batches fail fast once
the writer has to wait, and every acknowledged batch is sealed after a
restart; batches already queued when the writer starts waiting (a burst
of 200 without pauses) are answered within 2 s, stored or
WriteFailedwith the seal's fault, instead of waiting for their acknowledgement timeout, and release their queue reservation; - the same, deterministically (REL-17), in both durability modes for
segment.writeandsegment.syncfaults: a batch submitted from the previous batch's acknowledgement callback, which runs on the writer thread after the commit and before the writer checks rotation, passes the check insubmitand still getsWriteFailedwithin 2 s; so does a batch the writer took in the same group as a rotation that has to wait for the seal. Queued rotations are kept: once sealing works they finish and ingest resumes with the faults cleared, and a shutdown during the stall takes under 5 s and answers themUnavailable; - a failed WAL creation stalls rotation, and ingest resumes by itself once storage works;
- faults name the operation, the reason and a data-directory-relative path, clear once that part of the write path works again, and never contain the host path;
- the write probe reports full, read-only and permission-denied storage, and on Unix a real read-only directory (skipped as root).
cargo test -p storage --features fault-injection --test faults
Storage correctness (indexed vs brute force)
crates/query/tests/correctness.rs generates messy datasets (the same field
as int/string/float, nested vs dotted keys, arrays, booleans as strings,
resource fallbacks), stores them across several segments with tiny blocks
and only a few zone-mapped fields, then runs hundreds of random queries
and asserts that:
- indexed
countandsearchreturn exactly the brute-force result set; - cursor pagination (both directions, random page sizes) walks exactly that set;
- histogram totals equal counts.
It also guards against vacuous passes: many queries must match something and many must actually have been pruned by indexes. False positives are fine; false negatives fail the test.
Integration tests
tests/integration/ (built as the server crate's integration test)
starts a real server on an ephemeral port with a temporary data directory and
talks HTTP to it:
- start → ingest → query → restart → query the same events;
kill -9of thevyrtelbinary after acknowledgements → restart → every acknowledged event exactly once; quarantined WAL tails reported by the storage API;- ingest → rotate segment → query (diagnostics show segment pruning);
- OTLP logs (JSON and gzip protobuf) queried through the native API;
- OTLP spans → trace search → trace by id → correlated logs;
- OTLP metrics → names → sum/count/max/avg with group-by and filters;
- retention → restart → verify retained/deleted data;
- API errors: invalid JSON, invalid events, invalid queries (with position), oversized requests, unsupported media types, unknown endpoints;
- ingest size and encoding (
ingest_limits.rs): decoded sizes of limit−1, limit and limit+1 bytes on native JSON, NDJSON, OTLP JSON and OTLP protobuf, plain and gzip (only the first two stored); a gzip bomb gets 413 while its upload is still in progress; an oversizedContent-Lengthgets 413 before the body is read; clients that keep sending 8 MB against the 64 KB limit (withContent-Length, chunked, and gzip past the wire limit) read the 413, not a broken pipe, and their next request succeeds; empty gzip members cannot exceed the limit on the wire; truncated, corrupt and trailing-garbage gzip getinvalid_encoding, unsupported encodings a structured 415; concatenated members are all stored; rejected native batches (malformed final record, truncated gzip, over the limit) store nothing; OTLPpartialSuccessis the documented partial outcome. Unit tests inroutes/ingest.rsshow decoding stops after a small fraction of a 256 MB bomb; - ingest admission (
admission.rs): a request holds its slot while parsed and queued, excess requests (plain and gzip) get 429 +Retry-Afterpromptly without their bodies being read, every error frees the slot, and so does a client disconnecting while the body is read, while it is parsed and while the write awaits acknowledgement (the writer thread is parked withstall_writerto hold a request there); withmax_concurrent = 0the derived limit (3 at the defaults) is what is enforced and whatsystem/inforeports, and an explicit limit over the estimated budget is honoured (REL-14); - decoding limits (
decode_limits.rs, REL-16): for native JSON, the{"events":[...]}envelope, NDJSON, CLEF and OTLP logs, traces and metrics in protobuf and JSON, each plain and gzip:queue_capacity − 1andqueue_capacityevents stored, one more a 413maxEventsbefore parsing; a 64 KB body that would decode past 17× (tiny native events, OTLP records of empty values) a 413maxDecodedByteswhile an ordinary one of the same size is stored; values nested 32 levels stored, 33 a 400maxDepth. Refused requests store nothing, free their slot and the next request succeeds; OpenTelemetry SDK (512) and Collector (8,192) default batches are accepted at the default limits; - storage write failures (
storage_faults.rs, needs--features fault-injection): native, NDJSON and OTLP logs, traces and metrics get503 storage_write_failedwith a reason and store nothing while the WAL cannot write;/readyis 503, names the signals and recovers without a restart, and also with no new traffic at all;outcomeisnotStored, orunknownwhen the rollback's fsync is denied too; a failing write probe (full or read-only) makes/readyreportstorageWritable: false;/api/v1/system/storageshows the fault without the host path; after a short write and a denied fsync, a restart brings back exactly the pre-fault dataset (logs.ndjsonplus one event) with nothing quarantined; - query budgets and cancellation (
query_budgets.rs, REL-12; see below); - missing auth, bad auth, ingest-only keys, sessions, logout, revocation;
- pagination, time ranges, empty results, facets, histograms;
- live stream (SSE) delivery and filtering, and that open streams do not block shutdown;
- graceful shutdown (
shutdown.rs, REL-05): with the queue full and the writer parked, a stop request waits for all 4 admitted requests, refuses new work (closed listener or503 shutting_down) and exits cleanly; all 43 acknowledged events are back exactly once after a restart; an idle stop takes well under 2 s; whenserver.shutdown_timeout(500 ms) passes with a request unacknowledged, or a second stop request arrives, shutdown gives up on time andIncompleteShutdownnames the request and its 5 queued events; the deadline also bounds a stuck final storage flush; a seal blocked at the deadline, or ended by a second stop request, is reported as the unfinished phase and its events are back after a restart; a second stop request or the deadline also ends a blocked background job (app.rsunit tests); with--features fault-injection, a failed final WAL fsync innormaldurability is reported with its fault instead of a clean shutdown (storage_faults.rs); live-tail streams, including one whose client has not read 300 events, end withshutdownandretry: 2000and a clean end of body, and a client reconnecting to the same address after a restart receives new events. On Unix theprocesstests send real SIGTERMs to the binary: an upload in flight is answered200before exit status 0; past a 1 s deadline it exits with status 1 within about a second and prints the diagnostic; a second SIGTERM exits at once. Unit tests inapp.rscheck each refused route and the diagnostic text, andweb/tests/unit/live-tail.test.tsthe UI's reconnection and backoff; - dashboards, saved queries, alerts firing/resolving with webhook delivery, the background evaluator;
- backup and restore (
backup.rs, REL-06): a stopped instance with all three signals (in segments and in the WAL), a dashboard with a panel, an alert, a saved query, an active and a revoked API key and an admin session is copied, the original deleted, and the copy started elsewhere withoutauth.admin_password; every verification query answers as before, the restored key ingests, the revoked key and anonymous requests get 401, the old session is valid, and nothing is quarantined. Backups withoutmetadata/metadata.db(with and without a manifest), with an empty one, without a signal's WAL directory, without one populated.waland one.segfile listed in the clean-shutdown inventory, with a truncated.seg, with a newer or unreadable manifest, a newer segment or WAL format, or a newer metadata schema are refused byApp::buildwith the documented message, and every file and directory is unchanged (no lock file, notmp/). The real binary exits with status 1 and prints the reason.--accept-incomplete-restore(StartOptions) starts a copy without a WAL file, with the segment events and the configuration kept, and the next start needs no flag; a running directory has no inventory. A directory without a manifest (0.1.1) starts and gets one; a new volume holding onlylost+foundstarts as new. A start refused because another process holds the lock changes nothing, so the inventory still refuses a later start that is missing a WAL file. With--features fault-injection(storage_faults.rs): a failed manifest write at shutdown isIncompleteShutdown(phaseInventory, the I/O error, no host path, no partial inventory) and the next clean shutdown records it; a manifest write stalled past a 500 msshutdown_timeoutends the shutdown within 3 s, reported as the deadline in phaseInventory; - layouts (
layouts.rs, COMPAT-GATE-0.1.2), against the data directories the 0.1.0 (Observer) and 0.1.1 releases wrote (tests/fixtures/datadir/, restored byte for byte from the sizes and SHA-256 sums in eachfixture.json). Neither has this build's layout identity, so for each,vyrtel check-data-direxits 1, a start and a start with--accept-incomplete-restoreare refused, all with what to do, and the SHA-256 of every file is the same before and after (nothing locked, created, migrated, quarantined or truncated, so every event the release acknowledged is still where it put it). The documented way forward runs on that disposable copy: a new data directory beside it starts, takes the same telemetry and answers the same counts after a restart, and the old copy is still byte for byte unchanged. 0.1.1's WAL, segment and metadata files mixed into a directory of this layout are each refused before any change, named; taken out again, the directory starts with every event it acknowledged. This layout's own directories restart and restore from a stopped copy as they are (check-data-dir: no format changes). A fixture of this layout (from 0.1.2 on) must answer every verification query as its release did. With--features fault-injection, every write point of a first start fails once in turn: a failedvyrtel.jsonwrite leaves no telemetry file and a directorycheck-data-diraccepts as an interrupted first start, and in every case the next start completes. Backups (backup.rs): a manifest of another layout or without one, a segment, WAL or metadata database in a format version this layout does not have, and a WAL of another layout are refused with every file unchanged; telemetry with neither a manifest nor a metadata database of this layout is refused even with--accept-incomplete-restore; a backup missing onlyvyrtel.jsonis incomplete and starts on request. Storage (crates/storage/tests/recovery.rs): 0.1.1's WAL and segment files, also cut inside the identity, and files with another identity under a valid checksum stopcheck_formatsandStorage::openwith nothing changed, while a header identity the checksummed footer contradicts is damage (quarantined). A segment whose header and footer say version 1 but whose checksummed summary says 2 is refused before anything changes (segment_summary_in_a_newer_format_is_found_before_startup, where a summary failing its checksum is still quarantined as damage). The WAL scan believes an unknown record or codec version only when the record's checksum, streamed over a 200 KB record, holds (wal.rsrecord_format_scan_reads_prefixes_and_believes_only_checksums). New fixtures are written by the ignoredlayouts::make_datadir_fixturetest with a released binary (tests/fixtures/datadir/README.md). - removed aliases (
api.rsremoved_aliases_answer_actionably, TC2):GET /api/v1/live, thespanssignal in/api/v1/query/{signal}/…and?signal=spansanswer404/400with what to use; unknown API paths point todocs/api.md. The web UI's stored columns recover from stale or unreadable values (web/tests/unit/lib.test.ts).
Fixtures shared with E2E live in tests/fixtures/.
Query budget and cancellation tests
cargo test -p query --test budgets # engine level
cargo test -p server --test integration query_budgets # over HTTP
Slow queries come from the engine's checkpoint hook
(Engine::set_checkpoint_hook), not from big datasets, so the tests are
deterministic and take under a second. The engine fixture is 2,000 logs
(every seventh an Error, event i at BASE + i s) and 30 traces of 10
spans, in 16-event blocks, sealed into three segments per signal plus an
unsealed tail. The HTTP tests ingest a few events (and
tests/fixtures/otlp-traces.json, one trace of three spans) into a real
server.
| Acceptance criterion / case | Tests |
|---|---|
| AC1 runtime or result budget → explicit limit error | budgets.rs: timeout_then_a_small_query_is_complete (every request kind gets Timeout(20)), scan_byte_budget_boundary, trace_span_budget_boundary, metric_point_budget_boundary; query_budgets.rs: timeout_is_an_explicit_error_and_the_server_stays_usable (504 query_timeout, limitMs), scan_and_result_budgets_are_explicit_errors (422 query_too_expensive/query_result_limit, 400 for limit over max_results) |
| AC2 client disconnect stops work at checkpoints | budgets.rs: cancelled_requests_return_only_the_error, cancel_during_scan_stops_at_the_next_block, cancel_during_aggregation_stops_within_one_check_interval, cancel_at_a_row_checkpoint_stops_every_aggregation, cancel_between_trace_search_phases, summary_only_counts_still_check_segments, facet_counting_after_the_scan_checks_the_budget, facet_counting_checks_within_one_event, metric_name_discovery_stops_when_cancelled_during_enumeration; query_budgets.rs: client_disconnect_cancels_the_query_and_frees_its_slot (raw socket closed mid-query), metric_name_discovery_is_cancelled_on_disconnect_and_frees_its_slot |
| AC3 concurrent queries and ingestion stay bounded | query_budgets.rs: query_slots_bound_blocking_work_while_ingest_continues — 2 slots held, 8 more queries get 503 promptly, 20 concurrent ingests all 200, never more than 2 queries executing; then both held queries complete |
| AC4 resources released, no partial result as complete | budgets.rs: later_queries_are_complete_after_cancellation_and_limits, hook_can_be_removed; query_budgets.rs: the disconnect test (slot back, next answer ["a", "b", "c"]), trace search summaries report truncated |
| TC-02 cancel at scan and aggregation, budgets at boundaries | the AC2 rows above; boundaries: exactly max_scan_bytes succeeds and one byte less fails (count, facets, trace search across its pages and assembly); exactly max_trace_spans and 10,000 metric points succeed, one more fails; metric_name_budget_boundary (max_metric_names of 301, 300, 299 and 1 over 300 names: the first names by name, truncated only below 300); budget.rs unit tests (zero timeout, cancellation wins over other limits) |
| TC-03 small query right after a rejection | timeout_then_a_small_query_is_complete, timeout_is_an_explicit_error_and_the_server_stays_usable, client_disconnect_cancels_the_query_and_frees_its_slot |
| TC-04 regression | cargo test --workspace --all-features, query/tests/correctness.rs (indexed results equal brute force) unchanged |
Config validation (config.rs query_budgets) covers defaults, parsing
and refusing zero timeout, max_results, max_trace_spans and
max_metric_names. The engine fixture also holds 600 metric points (two
each of 300 names, m-000 to m-299).
Transport limit tests
cargo test -p server --test integration transport # behaviour over real sockets
cargo test -p server --lib transport config::tests::transport_limits config::tests::durations_that_overflow_are_errors
cargo test -p server --release --test overload transport -- --nocapture # heap, see below
tests/integration/transport.rs drives a real server with raw TCP sockets,
so a test controls exactly which bytes arrive and when. Limits are set
small (2 to 16 connections, deadlines of 300 to 500 ms) and every wait is on
the server's own counters (AppState::transport), not a sleep; the only
time that passes is the deadline under test.
| Acceptance criterion / case | Tests |
|---|---|
| AC1, TC1 finite admission: C−1, C, C+1, waiting cap, refusal | connections_beyond_the_limit_wait_briefly_then_are_refused (C = 4: four idle connections admitted one by one, the fifth waits and gets the first freed slot and is served; with 64 waiting the next is refused at once with the fixed 503 or a close; the 64 are refused after 2 s; the server then ingests and queries) |
| AC1, AC6, TC1 ≥1,000 attempts and reconnect waves | a_thousand_simultaneous_attempts_never_exceed_the_limits (C = 16: a wave of 1,000 simultaneous attempts, half idle and half keep-alive GETs, then three of 300; a 1 ms sampler never sees more than 16 active or 64 waiting; every attempt is admitted or refused; each wave returns to 0 active) |
| AC1, TC5 listeners share one budget | listeners_share_one_connection_budget (a second listener on the same Transport: 2 + 1 connections fill C = 3, the next on the second listener waits) |
| AC2, AC4, TC2 nothing routed before a head; deadlines; parser bounds | incomplete_idle_and_oversized_request_heads_are_closed (silent socket closed after header_timeout; a head sent one byte every 20 ms closed, never routed; a keep-alive connection reused twice then closed when idle; a 16 KB head with an 8 KB buffer and 101 headers get 431) |
| AC4, TC3 slow bodies | slow_request_bodies_get_408_and_release_their_slots (ingest and query bodies stalled after 10 bytes get 408 request_timeout; the ingest slot is released); transport.rs body_frames_available_after_the_deadline_are_refused (paused clock: a frame already buffered when the deadline passes is refused, not read) and a_body_that_already_ended_does_not_time_out |
| AC4, TC3 clients that stop reading | clients_that_stop_reading_are_closed_on_the_write_timeout (pipelined 1 MB query responses to a socket with a 4 KB receive buffer that never reads; closed on write_timeout) |
| AC2, TC3 pipelining and protocols | pipelined_requests_are_served_in_order_and_http2_is_not_served (20 pipelined requests answered in order on one connection; an HTTP/2 preface is a protocol error; an h2c upgrade is answered as HTTP/1.1) |
| AC5, TC4 every ending releases its slot | every_way_a_connection_ends_releases_its_slot (C = 2: resets with a waiter behind them, EOF mid-head, malformed input, a handler error on a kept-alive connection, abandoned responses; 0 active after each, then ingest and query work) |
| AC5, TC4 shutdown while saturated | shutdown_while_saturated_is_bounded (every slot held and one waiting: the stop finishes in under 2 s, idle connections just close, the waiting one is refused) |
| AC2 observability | system_info_reports_connection_limits_and_outcomes (connections object; [server] in /api/v1/system/config) |
| AC3, TC5 configuration | config.rs transport_limits (finite defaults; minimum and maximum of each setting accepted; zero, out of range, negative, fractional, non-numeric and overflowing values refused from TOML and the environment; an unrepresentable size saturates and fails validation); durations_that_overflow_are_errors; transport.rs unit tests (the fixed 503 is well formed; the estimate saturates; body timeouts are found through error wrappers). No reload exists, so none is tested |
| TC7 regression | cargo test --workspace --all-features; shutdown.rs live_streams_end_cleanly_and_reconnect_after_restart now asserts that a slow consumer gets an in-order prefix, then shutdown; the REL-02 flood test sets max_connections above its 600 connections so that it still exercises ingest admission |
Transport heap
connection_heap_stays_within_the_transport_estimate, in the overload
binary (counting allocator; clients on the uncounted runtime), holds all
200 slots of a server with the default 64 KB buffer: first idle, then with
request heads that fill each read buffer and never end, then floods the
full server with 1,000 more attempts, closes everything, and finally holds
every slot with clients that pipeline requests and never read. It asserts
heap per connection within 3 × connection_buffer + 16 KB, peak growth
within TransportLimits::estimated_peak, never more than 200 active or 64
waiting, at most C + 64 + 16 new runtime tasks, and at most 512 KB
retained after the connections close and after the stalled ones are closed
by write_timeout.
| Measured (release) | Linux x86_64 (WSL2, Rust 1.96) | Windows 11 x86_64 |
|---|---|---|
| Heap per idle connection | 18.0 KB | 18.1 KB |
| Heap per full read buffer | 122.3 KB | 122.4 KB |
| Heap per stalled reader | 47.7 KB | 21.4 KB |
| Flood of 1,000: waiting / refused | 64 / 936 | 64 / 936 |
| New runtime tasks | 264 | 264 |
| Peak heap growth / estimate | 23.99 / 40.75 MB | 24.03 / 40.75 MB |
| Retained after close / after timeouts | 63.1 KB / 63.0 KB | 65.1 KB / 65.1 KB |
On Linux the test needs several GB of memory: the 200 stalled readers'
kernel socket buffers (client and server sides, autotuned up to
tcp_rmem/tcp_wmem) peaked at about 1 GB in a container limited to
6 GB, against 24 MB of heap; with a 1 GB limit the container was killed.
That memory is the kernel's, which is why the
configuration docs account for it
separately.
Transport soak on Linux
scripts/bench/transport-soak.sh runs a release vyrtel and
connload (tools/loadgen/src/connload.rs) in separate containers with
their own CPU and memory limits, samples the server process every second
from /proc and its cgroup (RSS, threads, file descriptors, sockets, CPU
time, the listen backlog and the kernel's ListenOverflows, established
sockets, cgroup memory and kernel socket buffers), then waits for idle, stops the server with docker stop
and records the shutdown time and exit status. connload keeps steady
ingest (4 keep-alive senders of 200-event NDJSON batches every 50 ms) and
count queries (2 senders, every second), opens waves of simultaneous
connections of four kinds (idle, a head trickled one byte every 500 ms, a
body that stalls after 10 bytes, a normal GET /health), samples
/api/v1/system/info, and at the end checks that every acknowledged event
is stored.
# Release binaries on a volume (any Linux host with Docker):
docker volume create rel18-target
docker run --rm -v "$PWD:/src:ro" -v rel18-target:/target -e CARGO_TARGET_DIR=/target -w /src \
rust:1.96-bookworm cargo build --release --locked -p server -p loadgen
# Steady load only, for a baseline/candidate comparison (repeat 3×; for the
# baseline, build the prior commit onto another volume and set SERVER_VOLUME):
IMAGE=rust:1.96-bookworm scripts/bench/transport-soak.sh out/steady-1 --no-waves --duration 60s
# 1,000 simultaneous attempts every 30 s for 3 minutes, normal and constrained:
IMAGE=rust:1.96-bookworm scripts/bench/transport-soak.sh out/flood-normal-1 --duration 180s --attempts 1000
IMAGE=rust:1.96-bookworm SERVER_MEMORY=384m NOFILE=1024 \
SERVER_ENV="-e VYRTEL_STORAGE_MAX_MEMORY=256MB -e VYRTEL_SERVER_MAX_CONNECTIONS=64 -e VYRTEL_SERVER_CONNECTION_BUFFER=16KB" \
scripts/bench/transport-soak.sh out/flood-constrained-1 --duration 180s --attempts 1000
# The 30-minute soak:
IMAGE=rust:1.96-bookworm IDLE_AFTER=60 scripts/bench/transport-soak.sh out/soak --duration 30m --wave-interval 60s
Each run directory holds resources.csv, resources.json,
samples.csv, summary.json, connload.log and server.log. The budgets
were declared before the candidate was measured in
docs/benchmarks/transport/budgets.md; the
results, per budget with PASS/FAIL, are in
docs/benchmarks/transport/2026-10-17-wsl2.
They come from a developer laptop's WSL2 VM, not the release sign-off host:
#170 repeats them on the
final candidate.
Restore drill
scripts/backup/restore-drill.sh runs the published
backup and restore procedure against
a built binary (Linux, macOS): it populates an instance from
tests/fixtures, stops it with SIGTERM, takes a tar backup, deletes the
original, restores into a new directory, starts without an admin password,
diffs every verification query against its answers before the backup,
checks the restored API key, anonymous refusal and login, and checks 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. CI runs it against the release build in the backend job.
cargo build --release -p server && scripts/backup/restore-drill.sh target/release/vyrtel
Startup format check
The startup check reads every format version recovery reads before
anything changes (REL-07), and runs twice per start (before and after
taking the lock). It reads each WAL record's 5-byte prefix and first
payload byte and skips the rest, computes a record's checksum only when a
version is unknown, and holds at most 64 KiB of a file at once; a segment
costs its header, footer and a streamed checksum of its summary.
scripts/bench/startup-preflight.sh starts each binary on the same stopped
data directories, all telemetry in the WAL, and reports the median of 5
starts: time until /health answers, bytes read (rchar) and peak memory
(VmHWM) at that point, and check-data-dir time and peak memory.
Linux x86_64 (rust:1-bookworm in Docker on Windows 11, release builds,
warm page cache), --memory-limit 4GB:
| WAL | Build | Startup | Read | Peak memory | check-data-dir |
|---|---|---|---|---|---|
| 82 MB, 120 records of about 680 KB | before REL-07 (98ba4be) |
569 ms | 81 MiB | 376 MiB | n/a |
first REL-07 check, full replay (15aabd4) |
535 ms | 243 MiB | 377 MiB | 10 ms, 6 MiB | |
| prefix scan (this version) | 533 ms | 95 MiB | 377 MiB | <10 ms, 7 MiB | |
| 15 MB, 20,000 records of about 750 B | before REL-07 | 124 ms | 14 MiB | 77 MiB | n/a |
| first REL-07 check | 125 ms | 42 MiB | 77 MiB | <10 ms, 6 MiB | |
| prefix scan (this version) | 129 ms | 41 MiB | 77 MiB | <10 ms, 7 MiB |
With large records the check now reads about 7 MiB per pass instead of the whole WAL. With small records the 64 KiB read buffer covers many records, so each pass still reads the file once (from the page cache): about 5 ms on 15 MB here. Startup times vary by ±50 ms between runs, more than any difference between builds, and peak memory is recovery's. The full replay also allocated each record's payload (up to 256 MiB for one record); the scan never holds more than 64 KiB. Run it with:
scripts/bench/startup-preflight.sh ./vyrtel-before ./vyrtel-after
Overload memory test
tests/overload/main.rs (the server crate's overload test, its own
process because it installs a counting global allocator) holds the ingest
path to a memory budget under sustained overload. Fixed limits:
max_memory = 64MB, max_request_size = 2MB, ingest.max_concurrent = 2;
32 clients send 1.79 MB NDJSON batches (half gzip-compressed, 0.05 MB on the
wire) for 4 s, retrying 429s after 5 ms. It asserts that:
- peak heap growth stays below
max_memory; - no more than 2 requests are ever in flight and the server keeps accepting;
- every rejection is a 429 within 1 s and no client sees a reset connection.
cargo test -p server --test overload -- --nocapture
One run on the Windows 11 laptop used for the benchmarks below (test profile), before and after REL-02 moved admission from "while the body is read" to "until the write is acknowledged":
| Metric | Before | After |
|---|---|---|
| Peak heap growth | 298.4 MB (4.7× budget) | 55.3 MB (< 64 MB) |
| Slowest 429 | 1.1 s | 161 ms |
| Connection resets | 223 | 0 |
| Batches accepted in 4 s | 14 | 40 |
Five consecutive runs after the change peaked at 54.3–55.8 MB. Parsing one 1.79 MB body alone peaks at +8.3 MB, and one request end to end at +10.6 MB.
flood_of_rejected_slow_uploads_is_bounded, in the same binary, holds the
only ingest slot and opens 600 uploads that announce 1 MB, send 64 KB and
stall. All are rejected at admission. It asserts that rejected bodies kept
for discarding stop at the 256 cap (32 being read), that the runtime gains
no more than one task per connection plus that cap, that kept clients read
the 429, that peak heap growth stays below max_memory, and that everything
is released after the 10 s discard timeout. One run: 256 discard tasks,
512 new runtime tasks, 18.2 MB peak heap growth; without the cap, 1,200 new
tasks and 38.2 MB.
The two REL-02 tests set ingest.max_concurrent explicitly, so they are
unaffected by the derived sizing below (and log its over-budget warning).
Ingest amplification and derived admission
REL-14 replaced the derived ingest limit with one budgeted from measured
parse amplification (formula), and
REL-16 made it hold for every body shape by limiting decoding
(decoding limits). Three tests in the
same overload binary back them. Clients run on a separate
runtime whose allocations the counting allocator skips, so only the
server's heap is measured. Run them on a release build; the figures below
are from one Windows 11 x86_64 laptop (cargo test --release, Rust 1.96),
so expect other machines to differ somewhat.
cargo test -p server --release --test overload -- --nocapture --test-threads 1
# the pre-REL-14 comparison (needs ~2 GB of memory):
cargo test -p server --release --test overload pre_rel14 -- --nocapture --ignored
Fixtures (tests/overload/fixtures.rs). Every body is generated from
seed 0x5EED0014 (xorshift), so reruns are byte-identical. Each one is
filled to just under the target decoded size (2 MB by default;
VYRTEL_AMPLIFICATION_TARGET=<bytes> changes it) and sent plain and gzip.
OTLP fixtures are built once as protobuf structs and encoded both ways, so
both encodings carry the same records. Each fixture states whether the
server accepts it or refuses it at the request-size limit (REL-16), and the
test holds it to that.
| Fixture | Record shape | Events in 2 MB |
|---|---|---|
| native JSON / NDJSON representative | app event, level, service, trace ids, 5 properties (~330 B) | 6,161 |
| CLEF representative | Serilog request log, template and 7 properties, 10% with @x |
5,977 |
| NDJSON string-heavy | 12 KB of strings per event | 173 |
| native JSON / NDJSON nested properties | 6 levels, ~40 small values | 7,973 |
| native JSON / NDJSON many small events | {"message":"…"} (~24 B) |
87,844 |
| NDJSON empty objects | {} |
699,050 |
| NDJSON wide arrays | 500 one-digit numbers in one property | 2,016 |
| OTLP logs representative | SDK resource (7 attributes), body, 4 attributes, trace ids | 9,271 protobuf / 4,089 JSON |
| OTLP logs many small records | body only (~20 B) | 137,999 / 13,775 |
| OTLP logs large shared resource | 200-attribute resource, tiny records | 183,361 / 14,055 |
| OTLP logs large resource, representative records | 200-attribute resource shared by SDK-like records | 9,238 / 4,064 |
| OTLP logs empty array values | 1,000 empty AnyValues in one attribute |
1,039 / 661 |
| OTLP traces representative | 5 attributes, 1 in 8 with an event, 1 in 10 failed | 8,768 / 3,485 |
| OTLP metrics representative | gauges and sums (2 points), histograms (11 buckets) | 11,819 / 5,362 data points |
Before REL-16 the fixture builder sized fixtures from a 64-record probe, so those dominated by a large resource came out far below the target (0.08 MB of protobuf instead of 2 MB); it now searches for the largest record count that fits.
Per-format peaks (amplification_per_format_is_within_the_admission_estimate).
For every fixture and encoding it measures, as multiples of the decoded
body: parse, decoding alone in-process over a body already in memory with
the server's decoding limits; admission, from sending the request until
its batch is in the storage queue with the writer parked, or until its 413
(body read and gzip-decoded, parse, parsed batch: what an admitted request
owns); and end to end, until the response, which adds the WAL record and
the active segment (storage's budget). A fresh server per request keeps
earlier writes out of the window. Requests are exactly as large as the
fixture and queue_capacity is effectively unlimited, so the
decoded-bytes limit (17 × max_request_size) is the one exercised. It
asserts, for every fixture with no exemptions, that the admission and
end-to-end peaks are at or under the 20× estimate and that the fixture is accepted (200) or
refused (413 maxDecodedBytes, nothing queued) as it expects.
| Fixture | Admission ×body, 2 MB plain / gzip | 1 MB plain / gzip | End to end ×body (2 MB) | Status |
|---|---|---|---|---|
| native JSON representative | 3.83 / 3.83 | 3.89 / 3.89 | 3.83 | 200 |
| NDJSON representative | 3.83 / 3.83 | 3.89 / 3.89 | 3.83 | 200 |
| CLEF representative | 4.32 / 4.32 | 4.38 / 4.38 | 4.32 | 200 |
| NDJSON string-heavy | 2.10 / 2.12 | 2.16 / 2.24 | 2.10 | 200 |
| native JSON / NDJSON many small events | 15.51 / 15.51 | 15.64 / 15.64 | 15.51 | 200 |
| OTLP protobuf logs representative | 8.46 / 8.46 | 8.52 / 8.52 | 8.46 | 200 |
| OTLP JSON logs representative | 9.64 / 9.64 | 9.70 / 9.70 | 9.64 | 200 |
| OTLP JSON logs many small records | 13.14 / 13.14 | 13.22 / 13.19 | 13.14 | 200 |
| OTLP JSON logs large shared resource | 13.35 / 13.34 | 13.43 / 13.40 | 13.35 | 200 |
| OTLP protobuf logs large resource, representative records | 8.40 / 8.40 | 8.45 / 8.45 | 8.40 | 200 |
| OTLP JSON logs large resource, representative records | 9.65 / 9.65 | 9.72 / 9.72 | 9.65 | 200 |
| OTLP protobuf traces representative | 10.57 / 10.57 | 10.63 / 10.63 | 10.57 | 200 |
| OTLP JSON traces representative | 11.06 / 11.06 | 11.12 / 11.12 | 11.06 | 200 |
| OTLP protobuf metrics representative | 9.35 / 9.35 | 9.41 / 9.41 | 9.35 | 200 |
| OTLP JSON metrics representative | 11.82 / 11.82 | 11.88 / 11.88 | 11.82 | 200 |
| native JSON / NDJSON nested properties | 18.06 / 18.06 | 18.11 / 18.11 | 18.06 | 413 |
| NDJSON wide arrays | 18.05 / 18.05 | 18.11 / 18.11 | 18.05 | 413 |
| OTLP JSON logs empty array values | 18.06 / 18.03 | 18.12 / 18.06 | 18.06 | 413 |
| NDJSON empty objects | 1.12 / 2.05 | 1.24 / 2.11 | 1.12 | 413 |
| OTLP protobuf logs many small records | 1.12 / 2.11 | 1.24 / 2.29 | 1.12 | 413 |
| OTLP protobuf logs large shared resource | 1.12 / 2.11 | 1.24 / 2.22 | 1.12 | 413 |
| OTLP protobuf logs empty array values | 1.12 / 2.05 | 1.24 / 2.11 | 1.12 | 413 |
The highest accepted fixture is 15.6× (tiny native events), the highest representative one 11.9× (OTLP/JSON metrics), and no refused request goes past 18.1×: 20× keeps a margin of at least 10% over every shape. Representative batches use far less than before REL-16 (native 9.4× → 3.9×, OTLP 12.5–16.0× → 8.5–11.9×): native events are converted as they are parsed instead of after the whole body is, the batch is allocated once at its counted size, and OTLP records share their resource. Shapes that cannot fit are refused while they decode: native JSON and OTLP/JSON when the charged tree and events reach the limit (about 17× plus the body); NDJSON of empty objects when its counted events alone cannot fit, and OTLP/protobuf by the pre-scan, both before anything is decoded (1.1×, the body; 2.1× with gzip, the body joined from its parts). Before REL-16 these shapes were accepted at 30–1,700×, 213 MB for one 1 MB request of empty objects.
Writing adds nothing measurable: the end-to-end column equals admission. The WAL record is streamed to the file in 64 KiB pieces as it is encoded, and a batch stores each distinct resource once (storage format). Before, the writer encoded the whole batch, every event with its own copy of the resource, and then copied it into the record, holding the encoding twice: up to 105× end to end for a 200-attribute resource over tiny records, 72× over SDK-like records, and 26.6× for tiny native events.
WAL size (batches as 0.1.1 wrote them, every resource inline, against the current batch with its resource table; measured before the layout reset renumbered the current codec to 1). Each accepted 1 MB fixture was decoded and encoded both ways in-process (release build, median of 11 encodings):
| Fixture | 0.1.1 MB | Current MB | Current / 0.1.1 | Encode ms, 0.1.1 / current |
|---|---|---|---|---|
| native JSON / NDJSON representative | 0.63 | 0.63 | 1.00 | 0.26–0.46 / 0.25–0.30 |
| CLEF representative | 1.05 | 1.05 | 1.00 | 0.62 / 0.53 |
| NDJSON string-heavy | 0.99 | 0.99 | 1.00 | 0.36 / 0.44 |
| native JSON / NDJSON many small events | 1.25 | 1.25 | 1.00 | 1.35–2.28 / 1.98–2.95 |
| OTLP protobuf logs representative | 1.93 | 1.04 | 0.54 | 1.17 / 0.95 |
| OTLP JSON logs representative | 0.85 | 0.46 | 0.54 | 0.98 / 0.42 |
| OTLP JSON logs many small records | 1.78 | 0.45 | 0.26 | 2.32 / 0.81 |
| OTLP JSON logs large shared resource | 46.32 | 0.35 | 0.01 | 49.11 / 0.79 |
| OTLP protobuf logs large resource, representative records | 31.22 | 0.98 | 0.03 | 30.45 / 1.16 |
| OTLP JSON logs large resource, representative records | 13.69 | 0.43 | 0.03 | 12.45 / 0.30 |
| OTLP protobuf traces representative | 2.04 | 1.20 | 0.59 | 2.17 / 1.56 |
| OTLP JSON traces representative | 0.81 | 0.48 | 0.59 | 0.72 / 0.59 |
| OTLP protobuf metrics representative | 2.27 | 1.14 | 0.50 | 2.44 / 1.43 |
| OTLP JSON metrics representative | 1.03 | 0.51 | 0.50 | 0.92 / 0.39 |
OTLP batches repeat an SDK resource (7 attributes) on every record in 0.1.1's batches, so the resource table halves their WAL and its write time; a large shared resource goes from dominating the WAL to costing once per batch. Native events have no resource and encode the same, about 0.6 ms per MB slower for tiny events (the per-event resource index). Segments are unchanged: their bodies keep the resource inline and are compressed per block.
Sustained overload (derived_admission_holds_the_ingest_budget_under_overload).
Default and constrained derived configurations, three consecutive runs
each. 4 × limit + 16 clients cycle through every fixture at the
request-size limit, the high-amplification shapes among them, plain and
gzip, retrying 429s after 5 ms. A body over the configuration's decoding
limits (decided in-process with the same limits) must get 413, any other
body 200 or 429; with the default queue_capacity most high-amplification
bodies are refused by event count, the rest by decoded size. Each run has
three phases: 1 s of warm-up with every slot held by the test, so all
clients are rejected and their connections' read buffers reach working
size (reported, not counted: per-connection HTTP overhead); 2 s with every
writer parked, so admitted requests hold their slots with their batches
queued and unacknowledged while everyone else is rejected and drained; and
2 s with the writers running. In the parked phase storage allocates
nothing, so all heap growth is attributed to ingest; it includes the
server's connection and runtime buffers, which over-counts. It must stay
within the ingest budget. Process heap growth over both phases includes the
WAL and active segments and must stay within max_memory.
| Config | Run | Limit | Clients | Ingest budget | Conn. buffers | Ingest peak (parked) | 429s parked | 200s / 429s / 413s | Process heap peak | RSS before / parked / after |
|---|---|---|---|---|---|---|---|---|---|---|
| default (512MB, 1MB) | 1 | 3 | 28 | 76.8 MB | 2.2 MB | 30.1 MB | 3,703 | 39 / 6,678 / 21 | 126.1 MB | 61.5 / 84.6 / 199.2 MB |
| default (512MB, 1MB) | 2 | 3 | 28 | 76.8 MB | 2.2 MB | 40.7 MB | 3,679 | 17 / 6,681 / 32 | 70.3 MB | 54.9 / 101.8 / 144.0 MB |
| default (512MB, 1MB) | 3 | 3 | 28 | 76.8 MB | 2.2 MB | 29.7 MB | 3,678 | 15 / 6,482 / 13 | 61.4 MB | 129.5 / 133.7 / 102.7 MB |
| constrained (128MB, 256KB) | 1 | 1 | 20 | 19.2 MB | 1.5 MB | 4.5 MB | 2,771 | 42 / 5,188 / 66 | 19.4 MB | 69.4 / 70.2 / 75.0 MB |
| constrained (128MB, 256KB) | 2 | 1 | 20 | 19.2 MB | 1.5 MB | 4.6 MB | 2,792 | 30 / 5,091 / 55 | 19.1 MB | 32.2 / 41.4 / 67.8 MB |
| constrained (128MB, 256KB) | 3 | 1 | 20 | 19.2 MB | 1.6 MB | 3.3 MB | 2,735 | 50 / 5,262 / 55 | 23.6 MB | 66.7 / 66.9 / 74.8 MB |
Every run saturated at exactly the derived limit, returned only 200s, 429s and the expected 413s (no resets) and accepted a request afterwards. With the high-amplification shapes in the mix, the default runs peak at 30–41 MB of ingest heap, under both the 72.75 MB estimate and the 76.8 MB budget (36–43 MB before REL-16 with representative bodies only), and process heap at 61–126 MB (102–142 MB before; streaming the WAL record removed the encoded batch's two copies). RSS is the whole test process (clients, fixtures and earlier runs included) and is reported for context only.
Decoding throughput (AC4). The tolerance declared for REL-16 is a
regression of at most 10% in median decode time per accepted fixture. Each
accepted 1 MB fixture was decoded 21 times in-process by main (090669d)
and by REL-16, three alternating rounds, release build, same laptop; the
medians:
| Fixture | Events | main ms |
REL-16 ms | Change |
|---|---|---|---|---|
| native JSON representative | 3,075 | 15.88 | 14.50 | −8.7% |
| NDJSON representative | 3,075 | 15.14 | 13.06 | −13.7% |
| CLEF representative | 2,970 | 12.64 | 11.96 | −5.3% |
| NDJSON string-heavy | 86 | 0.39 | 0.41 | +5.7% |
| NDJSON many small events | 43,810 | 34.05 | 27.96 | −17.9% |
| native JSON many small events | 43,810 | 31.11 | 27.24 | −12.4% |
| OTLP protobuf logs representative | 4,539 | 17.32 | 18.23 | +5.2% |
| OTLP JSON logs representative | 2,008 | 20.71 | 21.58 | +4.2% |
| OTLP JSON logs many small records | 6,594 | 22.15 | 18.99 | −14.3% |
| OTLP JSON logs large shared resource | 2,872 | 49.57 | 7.79 | −84.3% |
| OTLP protobuf traces representative | 4,297 | 21.69 | 22.40 | +3.3% |
| OTLP JSON traces representative | 1,716 | 23.17 | 22.65 | −2.2% |
| OTLP protobuf metrics representative | 5,825 | 12.21 | 10.08 | −17.4% |
| OTLP JSON metrics representative | 2,644 | 20.59 | 19.05 | −7.5% |
| total | 296.61 | 235.88 | −20.5% |
Every fixture is within the tolerance. The OTLP/protobuf pre-scan costs a few percent; streaming native parsing and shared resources save more. (The large-shared-resource row is the pre-REL-16 fixture size, 0.42 MB.)
Before and after. pre_rel14_defaults_under_the_same_overload runs the
same harness with the old defaults (16MB requests, 4 slots) and
queue_capacity = 200000, so that 16MB batches are parsed and queued rather
than refused for exceeding the queue.
| Before (512MB, 16MB, 4 slots) | After (512MB, 1MB, 3 slots) | |
|---|---|---|
| Ingest heap peak, writers parked (76.8 MB budget) | 494.7–714.7 MB | 36.2–43.4 MB |
Process heap peak (512 MB max_memory) |
540.0–825.5 MB | 102.3–142.4 MB |
| Requests / MB accepted in 2 s of writing | 4–8 / 64–128 MB | 22–26 / 22–26 MB |
| 429s in 2 s of writing | 692–709 | 6,385–6,673 |
The trade-off: with 1MB requests and 3 slots, the same overload accepts
fewer bytes per second of writing and rejects far more requests, and
clients that sent 16MB batches must send smaller ones. In return ingest
stays inside its budget instead of exceeding max_memory.
Linux release run (REL-14, before the decoding limits). The same suite in a rust:1-bookworm container
(Docker Desktop, Linux x86_64, 22 cores, 15 GB, Rust 1.99.0, --release --locked), with the repository mounted read-only:
docker run --rm -v "$PWD:/src:ro" -v vyrtel-target:/target -e CARGO_TARGET_DIR=/target -w /src \
rust:1-bookworm cargo test -p server --release --locked --test overload -- --nocapture --test-threads 1
| Linux x86_64 | Windows x86_64 | |
|---|---|---|
| Admission ×body, every fixture | within 0.15 of Windows | table above |
| Highest supported fixture | 17.00× | 17.00× |
| Default ingest peak, writers parked (3 runs) | 21.2–28.5 MB | 36.2–43.4 MB |
| Constrained ingest peak (3 runs) | 1.6–4.4 MB | 3.5–4.1 MB |
| Drain peak (12.75 MB allowance) | 2.87 MB | 2.9–6.7 MB |
| Pre-REL-14 ingest peak (3 runs) | 458.7–773.8 MB | 494.7–714.7 MB |
Linux RSS climbed to about 507 MB during the run and stayed there: glibc keeps freed memory, and the amplification test that runs first allocates over 400 MB for its out-of-envelope fixtures. RSS from this harness is therefore a process high-water mark, not a per-run figure. Measure RSS for release sign-off on a dedicated server process, as PERF-GATE-1 (#170) requires.
Rejected-body drain (rejected_body_drain_overhead_is_within_the_fixed_allowance).
One slot, held by a stalled upload; 64 clients send 1 MB bodies (half gzip)
as fast as they are rejected, for 3 s. After 1 s of warm-up the
connections' read buffers are at working size (about 470 KB each, about
29 MB in all, not counted). The remaining 2 s measure the drain itself
against the 12.75 MB fixed overhead (32 drains × hyper's 408 KB read
buffer). Measured drain peaks: 2.9–6.7 MB in release runs, 6.8 MB in a debug
run.
Frontend tests
Vitest + React Testing Library in web/tests/unit/:
- live toggle states (paused red / live green) and toggling;
- query form submission and external updates;
- log row expansion and the vertical property tree (nested objects, arrays, null, include/exclude actions);
- filter panel selection;
- error rendering with a caret under the query position;
- trace navigation from a log;
- the Logs page with a mocked API and
EventSource: results, diagnostics, filter clicks updating the query, live streaming, and paused mode keeping results stationary; - helpers (query building, formatting, routing, span tree ordering, nanosecond math).
Browser E2E (Playwright)
web/tests/e2e/logs.spec.ts runs against the real binary:
- launch the server with an empty data directory;
- ingest the NDJSON and OTLP trace fixtures;
- open Logs, search
level = Error, verify the results; - expand an event and verify structured properties and the stack trace;
- confirm Live is paused, ingest an event, verify it does not appear;
- enable Live, ingest events, verify the matching one appears (and the non-matching one does not);
- pause again — results stay;
- open the related trace and verify the span hierarchy and related logs.
web/tests/e2e/storage.spec.ts checks the storage-failure contract against
the same binary, through HTTP only:
/readyis{"status": "ready", "storageWritable": true}and every signal in/api/v1/system/storageis healthy with nofaults;- on Linux and macOS (skipped on Windows and as root),
data/tmp/is made read-only for real:/readyturns 503 withstorageWritable: falseand all signals listed, thewriteProbefault sayspermissionDeniedontmp/.write-probeand the response never contains the data directory; once permissions are restored/readyis 200 again and an event ingested before the fault is still queryable.
The data directory is exported to the specs as VYRTEL_E2E_DATA.
cd web && npm ci && npm run build && cd ..
cargo build -p server
cd web && npx playwright install chromium && npm run e2e
Set VYRTEL_BIN to test a different binary (e.g. a release build).
Release gate tests
scripts/release/release.test.mjs, scripts/release/performance.test.mjs and
scripts/release/image.test.mjs (node --test,
CI job Release gate tests) cover the checks in
scripts/release/release.mjs and
scripts/release/performance.mjs and
scripts/release/image.mjs that
release.yml runs before publishing (see
releasing.md):
| Acceptance criterion | Tests |
|---|---|
| A tag whose version differs from the workspace fails | stable and prerelease tags against temporary workspaces; stale Cargo.toml, Cargo.lock or web/package.json; malformed tags; the repository's own version is consistent |
| Failed checks publish nothing | the workflow's job graph: image and release transitively need preflight, performance, ci (the whole callable CI workflow), binaries and verify; only a tag push publishes (a manual run is a rehearsal); release builds set VYRTEL_REQUIRE_WEB_UI |
| Embedded UI, version output and checksums are verified | artifact sets that are complete, missing a binary, tampered, missing SHA256SUMS or an entry, holding unexpected or empty files; the artifact list matches the build matrix; a fake binary (testdata/fake-vyrtel.mjs) that is correct, prints or serves the wrong version, serves the placeholder UI, misses its UI assets or exits on startup |
| A prerelease does not replace the stable latest channel | stable plans are Latest and tag X.Y.Z, X.Y, latest; prereleases (-rc.1, -beta, -alpha.3) are not Latest and tag only X.Y.Z-pre; image names are lowercased |
| TC-PERF-01: missing or failed performance evidence blocks tagging and publication | in throwaway git repositories: no record → pretag --create (function and CLI) exits 1 and creates no tag; FAIL verdict, missing reviewer, failing or mis-marked metric rows, missing limit/baseline/category/reports, artifact hashes missing for a published binary or listed twice, N/A without reason, budgets declared after measuring; a version with no declared gates; a review after the tag or a lightweight tag is refused by the workflow check; uncommitted changes and existing tags |
| TC-PERF-02: stale evidence is rejected until revalidated | a commit after PASS makes the record stale (no tag) until a PASS for the new commit; same-version gates (0.2.0) on different commits, or one missing; a record for another gate or version; rebuilt artifacts whose hash differs without documented equivalence |
COMPAT-GATE TC5: missing, failed or stale compatibility evidence blocks tagging and publication (compat.test.mjs) |
no record → pretag --create refuses even with a performance PASS, and check-compat exits 1; FAIL verdict, missing reviewer or date, a failed, missing, unknown or evidence-less checklist item, no inventory, artifact hashes missing for a published binary or listed twice, a wrong issue, exceptions not approved by Rowan or without owner, removal release or issue; a commit after PASS is stale until a renewed review; a record for another layout identity than the candidate's telemetry::LAYOUT; a review after a performance measurement of the same candidate, or for another candidate than the performance gate; a review after the tag or a lightweight tag; built artifacts that differ from the reviewed hashes |
| COMPAT-GATE AC7: every tagged release needs its gate | a prerelease needs its own compatibility PASS (but no performance sign-off) and its tag records it; a version without a declared gate cannot be tagged, and declaring one in compat-gates.json (the setup for a newly assigned release) makes it require and accept its record; the manifest, the record template and the issue template are complete; the workflow's compat job blocks binaries and verify |
| TC-PERF-03: a complete passing matrix for the exact candidate passes | the documented template covers every metric category and passes; pretag --create makes an annotated tag on the candidate with the PASS lines, which check-performance --tag-date (the workflow's check) accepts; matching or documented-equivalent artifacts; prereleases need no sign-off; the manifest declares unique gates with issues; the workflow wiring |
| REL-09 AC1: on each architecture the image becomes ready and serves the UI | the workflow runs verify-image in an image-smoke matrix on native linux/amd64 and linux/arm64 runners; each platform is pushed by digest only after its smoke test and checked to have the tested layers; image tags exactly those digests without building, and the published image is checked against the tested records (checkManifest: a missing platform, or a platform whose layers differ from the tested image, fails; imagesByPlatform, testedRecord); CI runs the same gate on amd64; an image for the wrong platform fails |
| REL-09 AC2: events in a named volume survive recreating the container | checkPersisted: the same events and settings pass; a lost or changed event, an extra event, or a changed dataDir or durability fail; docker port parsing |
| REL-09 AC3: an unwritable bind mount explains the uid requirement | checkBindMountRefusal: a refusal with the hint passes; a start that succeeds, or a refusal without the hint, fails; the strings it expects are the ones in crates/server/src/access.rs. Server side: access::tests (unit) and data_dir_access (integration, Unix, real binary on a 555 directory and on a missing directory under one). awaitRefusal with a fake docker: the expected refusal is returned and the container removed; a server that keeps running fails at the deadline (0.3 s) instead of hanging; one that prints Ready. fails at once; the container is removed every time |
| REL-09 AC4: the image runs as non-root and advertises its version | checkImageConfig: nonroot:nonroot/65532 pass; root, unset, 0:0 and a root group fail; a missing or dev version label fails; missing /data volume, storage path or healthcheck fail; processUid requires uid 65532 for the running process; the Dockerfile's USER, label and /data owner |
node --test scripts/release/*.test.mjs
VYRTEL_BIN=target/debug/vyrtel node --test scripts/release/*.test.mjs # also checks a real binary
VYRTEL_IMAGE=vyrtel:rc node --test scripts/release/image.test.mjs # also runs verify-image (Docker)
The CI End-to-end job also builds the server with VYRTEL_REQUIRE_WEB_UI=1 and runs
verify-binary against it, so the gate is exercised against a real binary on every pull request. The performance tests
run git with an empty global configuration (GIT_CONFIG_GLOBAL), so local signing or hook
settings do not affect them.
Benchmarks
Criterion benchmarks (cargo bench -p storage -p query). Results from one
run on a Windows 11 laptop, release profile — treat them as relative, not
absolute:
| Benchmark | Result |
|---|---|
| WAL append, 1-event batch | 5.0 µs (no fsync) |
| WAL append, 100-event batch | 34 µs (~2.9 M events/s) |
| Encode 1,000 events | 213 µs |
| Decode 1,000 events | 2.8 ms (allocation-bound) |
| Create 20k-event segment (sort, encode, compress, index, fsync) | 183 ms (~110k events/s) |
| Decode a whole 20k-event segment | 71 ms |
| Decompress one block, all columns | 556 µs (~470 MiB/s) |
| Decompress one block, meta column only | 34 µs |
| Parse a 5-clause query | 3.2 µs |
200k events / 4 segments: message contains (scan) |
39 ms |
… traceId = … (Bloom filter) |
1.6 ms |
… level = Error (bitmap) |
4.9 ms |
… durationMs > 1990 (zone map) |
14 ms |
| … latest 200 events | 0.66 ms |
| … count all (block counts, no decode) | 2.9 µs |
The benchmark dataset is synthetic and very regular (13.6× compression;
its unique-per-event requestId makes Bloom filters a large share of a tiny
segment). The load generator's data is more realistic: see below.
End-to-end benchmarks
The Criterion benchmarks above measure library code in-process. For HTTP-level
numbers (ingest throughput and query latency against a real release server; the
baseline adds a mixed-load p50/p90/p95/p99
profile), use the .NET harness under dotnet/. Results are published at benchmarks:
cargo build --release -p server
dotnet run --project dotnet/Vyrtel.Benchmark -c Release -- --job short
Reproducible baselines
dotnet/Vyrtel.Baseline records repeated runs of a seeded workload against a server with a
pinned configuration and proposes regression budgets from the run-to-run variance (see
Reproducible baselines):
cargo build --release -p server
dotnet run --project dotnet/Vyrtel.Baseline -c Release -- --runs 5
dotnet run --project dotnet/Vyrtel.Baseline -c Release -- compare old.json target/baseline/report.json
Its logic is unit-tested without a server in dotnet/Vyrtel.Baseline.Tests (xUnit;
dotnet test dotnet/Vyrtel.slnx -c Release, CI job .NET). The tests check arithmetic, report
contents and determinism; they never assert a machine-specific timing. Measured runs keep their
raw numbers in the report instead.
| Acceptance criterion (REL-10) | Tests and evidence |
|---|---|
| AC1: a seeded workload with a pinned configuration records accepted/rejected counts and latency percentiles | DatasetCheckTests (spans and data points counted from the fixture payloads; a complete dataset passes; events acknowledged but not stored, under-acknowledged events, and a shortfall or surplus in any signal fail the run with both counts); RequestTallyTests (each status classified: 2xx accepted, 429/503 backpressure, 413 too large, other rejected, 0 transport error; counts by outcome and status, exact under concurrency, summed across operations); BaselineReportWriterTests.States_accepted_and_rejected_counts_and_percentiles; WorkloadTests.Each_worker_issues_the_same_sequence_every_run, The_sequence_is_fixed_across_processes, Seeded_events_are_identical_apart_from_timestamps; HostAndOptionsTests.Finds_the_checked_in_pinned_configuration; Rust config::tests::baseline_config_file_is_valid_and_pins_the_derived_ingest_concurrency (the pinned file loads, keeps compaction off, and its ingest concurrency equals the derived value); measured: the reference baseline's accepted/rejected table |
| AC2: selective queries and message scans include cold/warm behaviour and diagnostics | QueryMeasurerTests (a healthy endpoint is measured cold and 20× warm; every warm request returning 500, a 503 on the cold request, a single 500 among warm requests, and repeated timeouts each stop the run with the query, request and status named, so a broken endpoint cannot record 0 ms; through the real HTTP client, a local server answering 500 yields no measurement); WorkloadTests.Queries_cover_selective_lookups_and_message_scans, Reads_the_servers_diagnostics (every diagnostics field, including indexes), Missing_or_malformed_diagnostics_are_null; BaselineReportWriterTests.States_cold_and_warm_timings_with_diagnostics; measured: the reference baseline's cold/warm table |
| AC3: the report states hardware, duration, peak RSS, binary size and compression method | BaselineReportWriterTests.States_hardware_duration_peak_rss_binary_size_and_compression; HostAndOptionsTests (/proc/cpuinfo on x86 and arm64, this host's CPU, memory and disk, peak RSS source: OS peak vs sampling, never under-reporting) |
| AC4: budgets are justified by measured variance | BaselineStatisticsTests (sample standard deviation and CV; the 90% upper-bound factor k(n) for 3, 5, 10, 31 and 41 runs; 3 × CV × k(n) rounded up, wider for fewer runs; timing and size floors; Same_commit_noise_measured_on_the_reference_laptop_is_not_a_regression replays the false regression two same-commit baselines produced before k(n); fewer than 3 runs, zero mean and a budget over 50% are not gated, with the reason); BaselineComparerTests (regression, improvement and within-budget verdicts in both directions; throughput is higher-is-better; peak RSS growth; Collapsing_successful_throughput_behind_fast_429s_fails: attempted requests/s rises while successful requests/s, accepted events/s and the unsuccessful share regress, so the comparison fails with exit status 2; a rise in rejections from a 0% baseline is caught by the 1-point floor; a missing gated metric, a report missing every metric, a 2-run baseline that gates nothing, and configuration, CPU or seed changes all fail; a missing ungated metric and a different number of runs do not); BaselineStatisticsTests.Successful_throughput_counts_only_accepted_requests, Attempted_throughput_is_informational_and_never_gated, Ratio_budgets_are_absolute_points; BaselineReportWriterTests.States_budgets_with_their_justification, Json_round_trips_and_samples_go_to_csv, Refuses_a_report_with_another_schema; measured: two independent baselines compared in Run-to-run evidence |
Load generator
tools/loadgen sends realistic structured logs (services, error rate,
high-cardinality customer and request ids, nested HTTP properties,
exceptions with stack traces) and optionally OTLP traces.
cargo run --release -p loadgen -- \
--target http://localhost:8080 --rate 10000 --duration 60s \
--services 8 --error-rate 0.02 --cardinality 10000 --traces-every 200
Options: --rate, --duration, --services, --error-rate,
--cardinality, --batch, --concurrency, --traces-every, --api-key,
--seed. It reports throughput, latency and backpressure (429/503).
The same package has connload, which keeps a steady mixed load while
opening waves of idle, slow and normal connections, for the
transport soak:
cargo run --release -p loadgen --bin connload -- --target http://localhost:8080 \
--duration 3m --attempts 1000 --wave-interval 30s --out connload-out
Options: --attempts, --wave-interval, --wave-patience,
--ingest-clients, --batch, --ingest-interval, --query-clients,
--query-interval, --sample-interval, --no-waves, --out, --label.
It writes summary.json and samples.csv, and exits with status 1 if any
acknowledged event is missing afterwards.
Observed on the same laptop with a release build (--rate 10000 --duration 30s --traces-every 200, default config):
| Metric | Value |
|---|---|
| Accepted | 302,000 events, 10,063 events/s, 0 rejected |
| Request latency (500-event batches) | 5.3 ms mean, 20.9 ms max |
| Process memory during the run | 112 MB working set (126 MB peak) |
| Idle memory before the run | 20 MB working set |
| Compression (sealed segments) | 4.9× raw → stored |
| Index overhead | 9.2% of compressed data |
| Query latency on that data | 2–105 ms (see diagnostics in the UI) |