A time-series storage engine in C++20, built from scratch: write-ahead log, time-partitioned columnar block files with Gorilla compression, compaction, crash recovery, and aggregate queries with push-down, behind an HTTP API that accepts Influx line protocol. Benchmarked against TimescaleDB on the TSBS workload.
TSBS cpu-only dataset: 100 hosts reporting 10 metrics every 10 seconds for 3 days, which is
25.9 million points. Both systems ran on the same machine and were loaded by the stock TSBS
loaders. TimescaleDB 2.17.2 uses its native compression, segmented by series and ordered by time.
| tsengine | TimescaleDB (compressed) | ||
|---|---|---|---|
| Ingest, points per second | 6.01 M | 5.21 M | 1.15× faster |
| Size on disk, bytes per point | 0.945 | 1.040 | 9% smaller |
single-groupby-1-1-1, p50 / p99 ms |
0.21 / 0.40 | 1.06 / 1.50 | 5.0× faster |
single-groupby-1-1-12, p50 / p99 ms |
0.35 / 0.67 | 2.32 / 2.99 | 6.6× faster |
single-groupby-1-8-1, p50 / p99 ms |
0.31 / 0.52 | 1.59 / 2.27 | 5.1× faster |
cpu-max-all-1, p50 / p99 ms |
0.71 / 1.11 | 2.37 / 3.22 | 3.3× faster |
double-groupby-1, p50 / p99 ms |
7.72 / 9.74 | 27.24 / 33.99 | 3.5× faster |
Every query was first checked to return the same answer on both systems: 10,000 comparisons across the five query types and both TimescaleDB configurations, with no mismatches.
Read these numbers with the following in mind:
- TimescaleDB is doing a harder job. It is a full SQL database: it parses and plans every query, supports transactions, concurrent clients, joins, updates and arbitrary SQL. tsengine does one thing. A purpose-built engine being faster on its one thing is expected, not a verdict on TimescaleDB.
- One client, warm cache, small data. Queries ran one at a time, and 25.9 million points fit in memory. tsengine handles one request at a time behind a single lock, so under concurrent load its latency would grow where TimescaleDB's would not. That was not measured.
- Five of the fifteen TSBS query types were run. tsengine cannot express most of the rest:
lastpoint,high-cpu-*,groupby-orderby-limit, and the variants that aggregate several metrics per host. - Where TimescaleDB is ahead in these measurements: preparing loaded data for querying. Compressing its chunks took 4.3 s; tsengine's final compaction took 10.7 s.
- WSL2 on a desktop PC, not a server. Compare the two columns with each other, not with numbers measured elsewhere.
Uncompressed TimescaleDB was measured too: it uses 19.7 bytes per point and is slower than compressed TimescaleDB on all five queries. Full tables, including every run, are in bench/results/REPORT.md, next to the raw result files.
Each block of a file stores the count, sum, minimum and maximum of its values. An aggregate query can answer from those instead of decoding the block, but only when the whole block falls inside one time bucket. With the default 1024 points per block, a block of this data spans 2.8 hours, so none of the TSBS queries above benefit. Shrinking blocks to 360 points, exactly one hour of this data, changes that:
| Points per block | Bytes per point | double-groupby-1 p50 |
Blocks answered from statistics |
|---|---|---|---|
| 1024 (default) | 0.945 | 7.72 ms | 0% |
| 360 | 1.076 | 1.46 ms | 85% |
| 128 | 1.533 | 3.14 ms | 64% |
So 14% more disk buys a 5.3× faster hourly aggregate, and going smaller than the bucket makes both worse. The 360-point result depends on blocks lining up with hour boundaries, which they do here only because the data is perfectly regular and each partition starts at midnight. It shows what push-down can do; it is not a setting to rely on. Queries with one-minute buckets are unaffected at every size.
- Workload: TSBS
cpu-only, seed 123, scale 100, 2016-01-01 to 2016-01-04, 10 s interval. The same generated data for both systems. - Ingest:
tsbs_load_influxandtsbs_load_timescaledb, 4 workers, batches of 10,000 rows, into an empty database. Median of 5 runs; the range was 5.84–6.27 M points/s for tsengine and 5.00–5.37 M for TimescaleDB. tsengine fsyncs its WAL on every request. TimescaleDB ran with the defaults of its Docker image, tuned bytimescaledb-tune(shared_buffers3892 MB). - Storage: everything each system keeps on disk for the data, after tsengine has flushed and compacted and after TimescaleDB has compressed every chunk.
- Queries: 1,000 queries per type with random hosts and time windows from a fixed seed, sent to both systems by bench/queryrunner using the SQL that TSBS generates. One untimed pass to warm up, then five timed passes: 5,000 samples per system and type. Timing covers the request and reading the full response.
- Checking the harness: the official
tsbs_run_queries_timescaledbmeasured a median of 1.47 ms forsingle-groupby-1-1-1and 27.26 ms fordouble-groupby-1on the same table. This harness measured 1.06 ms and 27.24 ms, so it is not making TimescaleDB look slower than TSBS does. - One change to TSBS: its TimescaleDB loader passes an argument that TimescaleDB 2.14 removed. bench/setup.sh replaces it with a no-op so that the baseline can be a current release.
- Version measured: commit
3caa24f. Later commits upgraded the HTTP library and added input checks. A spot check afterwards gave 6.10 M points/s and query medians of 0.19, 0.34, 0.29, 0.68 and 7.16 ms, in line with the table. - Machine: AMD Ryzen 7 7800X3D (8 cores, 16 threads), 15 GB RAM available to WSL2, ext4,
Ubuntu 24.04, GCC 13.3
-O3(CMake Release), Docker 29.1.
flowchart TD
client["HTTP client<br/>Influx line protocol"] --> parse["Parse<br/>(per request, in parallel)"]
parse --> lock{{"engine lock"}}
lock --> series["series.log<br/>series key → id"]
lock --> wal["WAL segment<br/>checksummed records, fsync"]
wal --> mem["Memtable<br/>per-series append buffers"]
mem -- "flush when full" --> blocks["Block files, one per time partition<br/>delta-of-delta timestamps + XOR values"]
blocks -- "commit" --> manifest["MANIFEST<br/>the list of live files, replaced atomically"]
blocks -- "compaction within a partition" --> blocks
query["GET /api/v1/query"] --> index["Series index<br/>measurement, field, tag → ids"]
index --> prune["Skip partitions and blocks<br/>outside the time range"]
prune --> agg["Aggregate from block statistics<br/>where safe, decode otherwise"]
blocks -.-> prune
mem -.-> agg
A write is acknowledged once it is in the write-ahead log. Points collect in the memtable and are flushed into immutable, compressed block files, one per day of data. Replacing the manifest is what commits a flush or a compaction, so a crash at any moment leaves either the old set of files or the new one. The full design, with file formats, is in docs/design.md.
Linux (or WSL2), CMake ≥ 3.25, Ninja, GCC ≥ 12 or Clang ≥ 15.
cmake --preset debug # Debug + ASan/UBSan
cmake --build --preset debug
ctest --preset debug # 142 testscmake --preset release && cmake --build --preset release
build/release/tsengine_server --dir ./data --port 8086Write points as Influx line protocol, then query them:
curl -X POST 'http://127.0.0.1:8086/write' --data-binary \
'cpu,host=a usage=58 1451606400000000000
cpu,host=a usage=61 1451606410000000000
cpu,host=b usage=12 1451606400000000000'
# Raw points of one series
curl 'http://127.0.0.1:8086/api/v1/query?measurement=cpu&field=usage&tag=host:a'
# Maximum per minute across all hosts, and mean per host over everything
curl 'http://127.0.0.1:8086/api/v1/query?measurement=cpu&agg=max&bucket=60000000000'
curl 'http://127.0.0.1:8086/api/v1/query?measurement=cpu&agg=mean&group_by=host'Needs Docker, Go, jq and Python with matplotlib. About 25 minutes.
bench/build.sh release # builds into ~/tsbench
bench/setup.sh # builds TSBS, pulls TimescaleDB, generates the dataset
bench/run_all.sh # loads, measures, cross-checks; results in ~/tsbench/results
python3 bench/report.py ~/tsbench/results docs/img- Unit and property tests for every component. The encoders, WAL, block files, engine and aggregation are each checked against a simple in-memory model over thousands of random operations, including restarts.
- Crash tests. A writer process is killed with
SIGKILLat random moments, and separately made to exit at each step of a write, a flush and a compaction. After every kill the database is reopened and must hold every acknowledged batch, nothing else, and no stray files. - Sanitizers. CI runs everything under AddressSanitizer and UndefinedBehaviorSanitizer, on GCC and Clang.
- TSBS in CI. Every push loads TSBS data with the stock loader and verifies the counts, before and after a kill.
| Decision | Why | Cost |
|---|---|---|
| Write-ahead log; a batch is one checksummed record | A write survives a crash once acknowledged, and a batch is recovered whole or not at all | Every write is written twice |
| Manifest replaced atomically | One rename commits a flush or compaction; no half-finished state to repair | Rewritten whole on every change |
| One block file per time partition | Retention deletes files, queries skip partitions, late data never rewrites recent files | Many small files when data arrives very late |
| Delta-of-delta and XOR encoding (Gorilla) | Regular timestamps cost about one bit; slowly changing values a few | Poor on irregular timestamps and noisy floats |
| Statistics per block in the file index | Blocks can be skipped or summarised without being read | Only helps when a block fits inside one bucket |
| Size-ratio compaction inside a partition | Small files merge often, large files rarely | Several files per partition to read at once |
| Single-threaded engine behind one lock | Simple enough to prove crash-safe | No concurrent queries |
- One request at a time. The engine is single-threaded. Requests are parsed in parallel, but storing and querying take turns, and a flush or compaction blocks both.
- No query language. One aggregate per request over one measurement; no joins, expressions, value filters or "latest point" queries.
- Float values only, one per series. Strings and booleans are rejected, and integers above 2^53 lose precision.
- Push-down is narrow. It needs a whole block inside one bucket, and nothing overlapping it in a newer file or in memory. There are no rollups.
- The series index lives in memory and is rebuilt on startup, so startup time and memory grow with the number of series. Not suited to millions of series.
- Crash safety is tested against process death, not power loss. The fsync ordering is designed for power loss, but nothing here tests it.
- Retention removes whole partitions of flushed data only.
- Not for untrusted networks. There is no authentication and no TLS, so anyone who can
reach the port can read and write everything. The server listens on
127.0.0.1unless told otherwise. Requests are size-limited and malformed input is rejected, but nothing limits how many series or how much data a client may create, so a client with access can fill the disk or memory. - Linux only.
- Compression figures are for this dataset. TSBS CPU metrics are small integers that change slowly, which is close to the best case for these encoders. The Gorilla paper reports 1.37 bytes per point on Facebook's production data; 0.945 here does not mean this beats it.



