Skip to content

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

Draft
zzylol wants to merge 6 commits into
feat/watermark-barrierfrom
feat/concurrent-engine
Draft

zzylol wants to merge 6 commits into
feat/watermark-barrierfrom
feat/concurrent-engine

Conversation

@zzylol

@zzylol zzylol commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #821 → #824 → #839 → this PR (deferred; see Status).

Why

One engine thread ran ingestion, pane builds and every query, because the Planner's DAG types used to hold Rcs. A long query held up ingestion and every query behind it. ASAPPlanner main (pinned ecea897f) now compiles plans that are Send + Sync, so the data plane can run ingestion and queries on many threads over one compiled plan.

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.
    • Global watermark = the minimum over the workers that have seen a sample of their newest routed raw sample, capped at the wall clock, minus the allowed lateness; or the newest barrier if later.
    • The worker that moves the watermark past a pane end closes the pane: under every worker's lock it takes each worker's samples of the pane and runs the definition's build once. A pane never waits for an idle worker, and a group spanning workers is built whole.
    • The event-time barrier (feat(precompute): event-time barrier for Remote Write producers #839) runs after every worker has run the writes acknowledged before it, and closes every worker's panes.
    • A write is admitted whole or refused (503) by the existing queue bound, then sent to every worker, so a retry never doubles samples.
  • Query threads (--query-threads, default one per core). One query per thread; queries no longer run on an ingestion thread. Before reading, a query waits until every worker has run every write acknowledged before it.
  • Compile once. Ingestion and QueryEngine are compiled when the plan is installed and shared through Arc.
  • SDS: SummaryStore holds Arc summary instances under a RwLock. An instance is inserted once and never rewritten (insert returns the stored one); readers clone the Arc under the read lock. Query-run summaries (StoredOutputBuild::QueryRun) go to the same store from any query thread.
  • jemalloc (tikv-jemallocator) is the data plane's global allocator.

How

  • precompute::Shared is what ingestion threads share: the compiled Ingestion, each worker's open panes (Mutex), each worker's newest sample, the barrier, per-definition first pane and built-through end, the SDS and the counters. Shared::ingest_at(worker, …) takes a shard, then closes panes. Precompute runs all workers on the caller's thread, for tests.
  • QueryEngine::evaluate takes &self; its counters are atomics (kept_stats()).
  • Right after startup a worker with no sample yet does not hold the watermark back, so the first worker to take a request may close a pane before another takes its part; the allowed lateness covers that.

Before this PR

One engine thread: a planned query waited behind ingestion and every other query; the SDS was owned by that thread.

After this PR

Ingestion runs on N workers and queries on M threads over one compiled plan; queries read summary instances concurrently by Arc.

Verification

cargo test --workspace --locked passes (fmt, clippy and the benefit harness were not run for this draft). New tests:

  • sds::store_shares_instances_and_never_rewrites_them, sds::store_is_read_concurrently;
  • precompute::a_pane_closes_once_every_shard_has_passed_it (minimum watermark over 2 shards), precompute::a_barrier_closes_every_shards_panes, precompute::sharded_panes_answer_like_one_shard (4 shards against 1, Example 4b's KLL and Example 1's rates and sums);
  • answers::answers_concurrent_queries_like_one_at_a_time (32 concurrent queries, 4 workers, 4 query threads), answers::a_query_reads_the_panes_of_the_writes_before_it (8 runs, no wait between write and query).

Existing tests changed only where the API did: kept_stats is a method, and SummaryStore::get returns an Arc.

Status: deferred (MVP is single-threaded)

Concurrency is deferred for the MVP. This branch stays at a70c2382, stacked on the old #839 head 7fbd02a6. These review findings are still open:

  • H1 (High). Right after startup a pane can be built from only some shards. The watermark is the minimum over workers that have already taken a sample, so a worker whose first shard is still queued does not hold it back. Fix (prototyped locally, not pushed): try_ingest records each shard's oldest routed sample as queued for its worker before sending, and that holds the watermark until the worker has taken it. Add a test with real worker threads (1, 2 and 4 workers, several runs) asserting zero late samples and answers equal to query_time_reference. The "allowed lateness covers that" claim in the code doc is wrong and must go.
  • M1. Pane builds hold every worker's lock. Take the samples out and advance built_through under the locks, then build and insert after releasing them.
  • M2. An idle shard pins the global watermark. Add a per-worker idle timeout (lateness plus one pane, on wall-clock time relative to the other workers) and document it in the README next to the barrier.
  • L1. Concurrent evaluations can both build the same QueryRun output. Count built and stored once, and single-flight the build.
  • L2. A failed send leaves samples_queued too high for the shards that were not sent.
  • L3. Use the query_run names throughout instead of "kept".
  • L4. Add a Usage section.

@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
Each plan root (InstallablePlan.queries) is compiled once with two kinds
of inputs: stored outputs, bound at evaluation time T to their
definition's summary ending at T - offset, and the plan's query-time raw
windows (query_time_raw_windows), bound to the metric's samples in
(T - offset - width, T - offset] fetched from Prometheus and selected by
the window's RawSampleSelector. An instant query whose text is a planned
query is answered with a Prometheus vector and x-asap-execution: plan.
Unplanned queries are forwarded; planned ones that cannot be answered
(summary missing or off the coverage grid, fetch or execution failure)
are forwarded with x-asap-forward-reason.

Raw-window reads carry the caller's end-to-end headers (Authorization,
X-Scope-OrgID and the rest query forwarding keeps), so Prometheus
authorizes and scopes them as it would the caller's own query. Answer
labels come from series_labels_of_output_row.
- A query whose time is not finite or beyond MAX_TIME_MS gets 400
  bad_data, as Prometheus answers; window and pane binding use checked
  arithmetic, so no evaluation time can overflow (time=-1e300 panicked the
  engine thread in debug builds).
- Raw-window fetches select only the series the plan keeps: the Scan's
  label matchers PromQL can express go into the range selector, with
  metric and values escaped. The selector still applies every matcher.
- Raw windows are fetched concurrently.
- A forward reason with characters a header cannot carry is sanitized
  instead of dropped.
A plan that keeps query-run summaries installs, but this data plane does
not build them yet, so queries reading them are forwarded. Say so in the
log at startup.
…e closed

A stored output the plan builds in a query run and keeps
(StoredOutputBuild::QueryRun, #509 Example 4's B3) is now executed. The
query engine builds its definition's summary from the coverage's raw
samples the first time an evaluation needs it, and stores it in the SDS
store under the same SummaryInstanceKey as ingestion-built summaries once
its coverage is closed. Later evaluations reuse it.

- Closure is judged by the ingestion progress snapshotted when the
  evaluation chose its fetch (raw_ranges returns it with the ranges and
  evaluate takes it back), not when the evaluation runs, so a partial
  summary built from an earlier fetch is never kept.
- Eviction is by the plan's retention_ms plus one pane, measured from the
  newest closed coverage at or before the watermark, as for
  ingestion-built summaries. A far-future evaluation no longer stops
  keeping.
- One fetch per query-run definition covers all its missing summaries.
- KeptResultStats (built, kept, kept_while_metric_behind, reused) is on
  /api/v1/status/asap. kept_while_metric_behind counts summaries kept
  while their own metric's newest sample had not reached their end: the
  watermark is global.
- A query-run definition whose selector does not build no longer fails the
  install: its queries are forwarded.

SummaryBuild, shared by the precompute and query engines, compiles a
definition's build and runs it over one coverage's samples. Samples of a
metric only query-run summaries read move the watermark (counted as
samples_query_run_only) without being buffered.

The backend now declares query_time_results_retained: true.
examples/planner-layering-example4-b3.json is a workload for which the
Planner selects B3 under the default cost model.
Ports #814 to the plan-executing engine.

POST /api/v1/precompute/watermark?time_ms=T: the producer states that
every sample at or before T has been written. The request queues an
AdvanceWatermark command behind every write already acknowledged and
answers {"barrier_ms": B}, the barrier in force, once it has run: every pane with end <= T is closed, keeping
the left-open (end - width, end] convention, so the last pane of a
series that stopped sending is built and its window served from the
plan without a later sample.

- T is capped at the wall clock, like the sample watermark.
- A repeated or lower T does nothing; the answer is the barrier in force.
- The closing reuses the expired-pane skip and checked arithmetic, so
  T = i64::MAX terminates.
- Input stays open; a sample arriving later for a pane the barrier closed
  counts as late.
- A missing or non-integer time_ms gets 400 bad_data with the reason.
- With several producers, the barrier must follow all of their writes
  through T; the README says so.
…a shared SDS

Series are sharded by label set across ingestion workers that each own
their shard's open panes. The global watermark is the minimum over the
workers that have seen a sample; the worker or barrier that moves it
closes the passed panes from every worker's samples. The event-time
barrier closes every worker's panes. Planned queries run on a pool of
query threads, one query per thread, over a plan compiled once and shared
through Arc, and wait for the writes acknowledged before them. The SDS
store holds Arc instances under a RwLock and never rewrites one. jemalloc
is the data plane's allocator.
@zzylol
zzylol force-pushed the feat/concurrent-engine branch from 1b5d7a9 to a70c238 Compare October 20, 2026 23:52
@zzylol zzylol changed the title feat(data-plane): sharded ingestion workers, a query thread pool and a shared pane store feat(data-plane): sharded ingestion workers, a query thread pool and a shared SDS Oct 20, 2026
@zzylol
zzylol changed the base branch from feat/repin-plan-installable to feat/watermark-barrier October 20, 2026 23:53
@zzylol
zzylol force-pushed the feat/watermark-barrier branch 4 times, most recently from 4987a39 to 0a5c014 Compare October 21, 2026 00:17
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