Repository navigation
Conversation
A query bounded to a small time window still opens and decodes the Parquet metadata of every persisted block: blocks are not eliminated by their time range before being opened, and the predicate is applied to row groups only after each block's file is already open. So a small-window query's cost grows with the total number of blocks stored, not with the size of the window. The benchmark writes N persisted blocks, each covering a distinct time range, then queries a window that only ever covers the newest block -- the matched data is the same every run, only the number of older, non-matching blocks changes. Query time and, most clearly, allocations grow with N: blocks=1 5.1 ms 5,297 allocs/op blocks=25 7.9 ms 12,934 allocs/op blocks=100 13.7 ms 36,793 allocs/op blocks=400 28.5 ms 132,202 allocs/op Found while investigating slow queries on a Parca server backed by FrostDB: https://github.com/guettli/parcareport # Committing as: tilldu@googlemail.com
|
For anyone landing here from a slow single-node Parca: the downstream direction that addresses this is an embedded columnar backend. parca-dev/parca#6357 adds an embedded DuckDB storage backend ( Quick validation on a real single-node Parca (181M rows over 13.7h; CPU profile = 24M rows): the equivalent time-pruned aggregation runs in <1–2 s in DuckDB, where the FrostDB path times out on a 6 h window. A DataFusion engine is an equivalent-speed alternative (though arrow-rs is stricter and won't read FrostDB's current block Parquet — separate note). Full landscape research + benchmarks: guettli/parcareport#31. |
Adds BenchmarkQuerySmallWindowManyBlocksIceberg, which runs the same small-window / many-blocks workload against the Iceberg backend and shares a harness (benchmarkSmallWindowManyBlocks) with the default-backend case. The Iceberg manifests carry per-data-file upper/lower bounds for the partition columns (here: timestamp via WithIcebergPartitionSpec), so the planner skips data files whose bounds fall entirely outside the query window without opening them. Measured allocs/op from 1 to 400 blocks grow ~5x (Iceberg) vs ~28x (default) -- pruning is not flat because the manifest read is still O(files), and it adds fixed overhead that makes it slower for very few blocks, but it removes most of the block-count sensitivity that the default backend has. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMDr5xaUfywx6NoNmHGgLB # Committing as: tilldu@googlemail.com
Extended: in-repo benchmark of the pruning backend (Iceberg)The original PR compared against ClickHouse via
Result ( From 1→400 blocks: ~28× growth (default) vs ~5× (Iceberg). Two honest caveats, documented in the benchmark's doc comment:
So the benchmark now documents both the default backend's O(total blocks) cost and the mitigation already in frostdb, with numbers from the same harness. 🤖 Generated with Claude Code |
What
Adds
BenchmarkQuerySmallWindowManyBlocks, which measures a query over a small, fixed time window as the number of persisted blocks grows.Why
A query bounded to a small time window still opens and decodes the Parquet metadata of every persisted block. In
store.go,ProcessFileopens each block'sdata.parquet(openBlockFile→parquet.OpenFile) and only then applies the predicate to row groups infilterRowGroups— there is no step that skips a block whose time range is entirely outside the query window. So the cost of a small-window query grows with the total number of blocks stored, not with the size of the window.The benchmark makes this visible. It writes N persisted blocks, each covering a distinct time range, then queries a window that only ever covers the newest block — the matched data is identical across runs, only the number of older, non-matching blocks changes. It reopens the store against the same bucket so the blocks are read back through the bucket-scan path rather than from memory, and asserts the query matched the newest block's rows so it can't silently regress into measuring an empty query.
Result (
-benchmem -benchtime=20x), clearest in allocs/op:Wall time grows more slowly than allocations because blocks are opened concurrently, but the total work is O(total blocks).
This is a benchmark only — it documents and measures the behaviour, it doesn't change it.
For comparison: a store that skips non-matching partitions at query time
To sanity-check that this is a property of the default backend and not of the query itself, here is the same shape (N daily partitions, query one day's window) against ClickHouse (
MergeTree,PARTITION BY toYYYYMMDD(...)), read offEXPLAIN indexes=1:It reads a single partition regardless of the total — the non-matching partitions are skipped at query time (they are not read, and not deleted), so cost does not grow with retained data. The absolute milliseconds are not comparable (ClickHouse is a separate server with fixed per-query overhead; FrostDB is embedded); the point is the scaling: linear vs flat.
The mechanism that enables this is per-file/partition min/max recorded outside the data file, so the filter can eliminate files before opening them. FrostDB's own Iceberg backend does exactly this (
storage/iceberg.go), but the defaultDefaultObjstoreBucketdoes not.Context
This came out of investigating slow queries on a self-hosted Parca server backed by FrostDB, where retained data (not query window) drove query latency: https://github.com/guettli/parcareport
Two notes for anyone hitting this: