Skip to content

feat(data-plane): sharded ingestion workers, a query thread pool and a shared pane store - #829

Draft
zzylol wants to merge 2 commits into
feat/repin-plan-installablefrom
feat/concurrent-engine
Draft

zzylol wants to merge 2 commits into
feat/repin-plan-installablefrom
feat/concurrent-engine

Conversation

@zzylol

@zzylol zzylol commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Stack 2/4. Base: #828.

Why

One engine thread ran ingestion, pane builds and every query, because the Planner's DAG types hold Rcs. A long query held up ingestion and every other query behind it. Each evaluation also deep-copied the stored panes it read. User decisions (design doc, Stage D and the threading section): a multi-threaded precompute engine as in ASAPQuery, one query per thread, a pane store of immutable Arc panes under a read-write lock, and jemalloc as the default allocator.

What

  • Ingestion workers (--ingestion-workers, default one per core). Remote Write series are sharded by a hash of their label set. Each worker owns its shard's open panes.
    • A worker's watermark is its newest sample. The global watermark is the minimum over the workers that have seen a sample.
    • When the global watermark passes a pane, every worker builds its part of it, and the last part completes the pane. Parts are joined, and the states of a group seen by several workers are merged with the family's merge.
    • A write is queued to every worker or refused whole (503), so a retry never doubles samples.
  • Query pool (--query-threads, default one per core). A query, with every step of a range query, runs on one pool thread. Before reading, a query waits until the workers have run every write answered before it, so it sees those writes, as the single queue guaranteed.
  • SDS store: RwLock<HashMap<(definition, pane end), Arc<pane>>>. A pane is inserted once and never rewritten, and readers clone the Arc under the read lock. Eviction takes the write lock. Kept query-time results use the same store, shared by all query threads, and are inserted only once their pane is closed.
  • A stored pane binds to a compiled root as a source over the shared Arc. It is copied once per run, because the executor's sources emit owned batches; before, it was copied twice.
  • jemalloc (tikv-jemallocator) is the data plane's global allocator.

How

  • Planner IR and the executor's compiled plans (CompiledPhysicalDAG, RawSampleSelector) are not Send: CompiledExpression holds Rc<OperatorNode>. So each worker and query thread reads plan.json and compiles its own.
  • Batch and Value are Send + Sync (their summary states are Arc<dyn AggregateCore + Send + Sync>), so panes cross threads as Arc<Batch>.
  • precompute::Shared holds what threads share: the store, per-worker watermarks, the partial panes and the counters. ShardWorker is one worker's compiled state. Precompute runs all workers on the caller's thread, for tests.

Before this PR

One engine thread. A planned query waited behind ingestion and behind every other query. Each evaluation cloned every stored pane it read. glibc malloc.

After this PR

Ingestion runs on N workers and queries on M threads; queries read panes concurrently by reference.

Verification

  • cargo test --workspace passes, including e2e_examples (9), range, kept, answers.
  • New tests, each failing on feat!: install the Planner's InstallablePlan; depend on asap-planner and asap-executor only #828:
    • sds::store_shares_panes_and_never_rewrites_them and sds::store_is_read_concurrently;
    • precompute::a_pane_closes_once_every_shard_has_passed_it (minimum watermark over 2 shards);
    • precompute::sharded_panes_answer_like_one_shard (4 shards against 1: Example 4b's per-series KLL; Example 1's rates and sums);
    • answers::answers_concurrent_queries_like_one_at_a_time (32 concurrent queries);
    • answers::a_query_reads_the_panes_of_the_writes_before_it. It caught two races in this PR's first commit, fixed in its second: a query racing the workers, and a worker announcing a newer watermark than it had closed. It fails 3 of 3 runs without the fix and 0 of 8 with it.

Benefit harness

Same setup as #828. "Prometheus alone" is the baseline of the pr1 run. The harness issues one query at a time, so it measures per-query cost and ingestion contention, not concurrent query throughput.

s10

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
Prometheus alone 19.3 - - (Prometheus 0.09) 9.8 / 18.2 10.1 / 17.9 20.9 / 33.0 107.5 / 129.8 -
pr1 20.2 43.9 0.37 37.0 / 57.8 37.6 / 55.7 123.9 / 156.9 1010.5 / 1175.0 0
pr2 20.4 43.8 0.47 41.6 / 57.9 39.8 / 52.2 136.4 / 154.7 858.3 / 979.9 0

s10-noraw

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
Prometheus alone 18.9 - - (Prometheus 0.09) 9.8 / 16.4 10.2 / 20.0 19.5 / 34.6 110.4 / 152.1 -
pr1 9.8 30.1 0.29 25.9 / 205.8 20.7 / 25.2 51.9 / 90.0 87.2 / 117.8 325
pr2 9.9 35.0 0.30 22.8 / 143.2 19.5 / 29.9 70.9 / 112.8 97.1 / 133.1 325

s50

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
Prometheus alone 89.9 - - (Prometheus 0.14) 44.7 / 75.5 44.9 / 76.5 99.1 / 143.1 407.8 / 602.2 -
pr1 96.9 216.9 1.89 200.5 / 246.0 197.4 / 240.4 646.5 / 817.6 4887.2 / 5982.6 0
pr2 96.6 174.0 1.73 163.3 / 221.9 157.9 / 217.4 582.6 / 645.4 3853.3 / 4901.5 0

s50-noraw

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
Prometheus alone 89.0 - - (Prometheus 0.14) 45.5 / 71.3 44.8 / 73.7 98.2 / 153.9 343.2 / 660.2 -
pr1 47.1 190.8 1.47 157.9 / 1285.2 123.1 / 153.8 356.7 / 401.9 441.3 / 592.9 325
pr2 45.3 145.3 1.10 81.5 / 566.6 63.7 / 115.0 308.2 / 389.7 445.6 / 578.3 325

jemalloc vs glibc. "pr2-glibc" is this PR built without the global allocator line (a local build, not committed); everything else is identical.

s10

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
pr2 20.4 43.8 0.47 41.6 / 57.9 39.8 / 52.2 136.4 / 154.7 858.3 / 979.9 0
pr2-glibc 20.1 57.4 0.48 48.8 / 63.8 48.9 / 63.7 149.5 / 188.4 1016.1 / 1230.3 0

s10-noraw

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
pr2 9.9 35.0 0.30 22.8 / 143.2 19.5 / 29.9 70.9 / 112.8 97.1 / 133.1 325
pr2-glibc 10.1 49.6 0.37 32.3 / 183.9 25.5 / 46.2 71.7 / 97.8 105.6 / 138.8 325

s50

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
pr2 96.6 174.0 1.73 163.3 / 221.9 157.9 / 217.4 582.6 / 645.4 3853.3 / 4901.5 0
pr2-glibc 98.1 247.1 2.71 203.0 / 475.2 190.3 / 488.1 692.4 / 2140.2 5191.9 / 6321.9 0

s50-noraw

Run Prometheus core-s Data plane core-s Data plane peak RSS GiB rate p50/p99 ms topk p50/p99 ms p99[5m] p50/p99 ms p99[60m] p50/p99 ms forwarded
pr2 45.3 145.3 1.10 81.5 / 566.6 63.7 / 115.0 308.2 / 389.7 445.6 / 578.3 325
pr2-glibc 44.5 179.2 1.85 98.9 / 949.1 85.3 / 109.1 292.4 / 347.2 456.4 / 502.6 325

jemalloc lowers data-plane CPU in every config (s50: 174.0 vs 247.1 core-s with raw data, 145.3 vs 179.2 without) and peak RSS (s50: 1.73 vs 2.71 GiB). The quantile queries are faster with jemalloc in three of four configs (p99[60m] p50 at s50: 3853 vs 5192 ms). The one regression is p99[5m] at s50 without raw data: 308.2 vs 292.4 ms p50, 389.7 vs 347.2 ms p99. The earlier in-process comparison's +9 ms on the quantile queries was single-threaded; here the allocator is shared by 8 engine threads.

Planner gaps found

  • Compiled plans are not Send. CompiledExpression holds Rc<OperatorNode> through ScalarExpr operator references, so CompiledPhysicalDAG and RawSampleSelector are neither Send nor Sync, and every thread compiles its own. Smallest fix: compile expressions to an Rc-free form (operator references resolved to node ids), which makes compiled plans shareable.
  • Sources copy shared panes. Operator::source takes owned Batches and clones each on emit, and output streams yield owned Batch (Vec<Vec<Value>>). So a stored pane is copied into every evaluation that reads it. Smallest fix: a source over Arc<Batch> yielding the shared value, or Batch rows behind an Arc.

…a shared pane store

- Ingestion: N workers (`--ingestion-workers`, default one per core).
  Remote Write series are sharded by a hash of their label set; each worker
  owns its shard's open panes. The global watermark is the minimum over the
  workers that have seen a sample; when it passes a pane, every worker
  builds its part, and the last part completes the pane: parts are joined
  and the states of a group several workers saw are merged with the
  family's merge. A request is queued to every worker or refused whole.
- Queries: a pool of `--query-threads` threads, one query (and every step
  of a range query) per thread. The executor's compiled plans hold `Rc`s,
  so each thread reads plan.json and compiles its own.
- SDS: `RwLock<HashMap<(definition, pane end), Arc<pane>>>`. Panes are
  inserted once and never rewritten; readers clone the `Arc`. Kept
  query-time results use the same store, shared by the query threads.
  A stored pane binds to a root as a shared source, copied once per run
  (the executor's sources emit owned batches) instead of twice.
- jemalloc is the data plane's global allocator.
…he watermark it announces

Two races of the sharded engine, both found by a test that writes a pane's
samples and queries it at once:

- A planned query ran on the query pool while the ingestion workers could
  still be ingesting the Remote Write requests answered before it, so the
  newest pane was missing and the query was forwarded. A query now waits
  until every worker has run what was queued before it (`sync`), as the
  single engine thread's queue ordered it.
- A worker closed its panes at the watermark it read before another worker
  moved it, then announced the newer watermark to the other workers but
  not to itself, so its part of the newest pane never arrived. It now
  closes and announces the same watermark.
@zzylol zzylol added enhancement New feature or request area: data plane Runtime ingestion, storage, and query-serving data paths area: query engine PromQL lowering, execution, result shaping, and fallback behavior test: end-to-end Process-boundary, differential, conformance, and full-stack tests labels Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: data plane Runtime ingestion, storage, and query-serving data paths area: query engine PromQL lowering, execution, result shaping, and fallback behavior enhancement New feature or request test: end-to-end Process-boundary, differential, conformance, and full-stack tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant