Skip to content

Commit a62e7de

Browse files
zzylolclaude
andcommitted
feat(precompute): build stored summary panes from Remote Write samples
An engine thread owns the installed plan (Planner IR holds Rcs) and receives Remote Write samples from the HTTP server. For each stored summary definition it buffers its Scan metric's samples by tumbling pane. When the watermark (newest sample minus --allowed-lateness-ms) passes a pane's end, it runs the plan's Scan over them (applying label matchers) and the definition's build from its TimeRange input, then stores the output. Panes without samples are stored empty; the first pane seen is skipped because it may be partial; samples for built panes are dropped. A full engine queue answers Remote Write with 503 so senders retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bce77c9 commit a62e7de

11 files changed

Lines changed: 636 additions & 38 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ The backend is being rebuilt on the Planner's physical DAG:
3131
|---|---|
3232
| Control plane: plan a PromQL workload, write `plan.json` | done |
3333
| Data plane: load and validate `plan.json`, accept Remote Write | done |
34-
| Stored summaries (SDS) | next |
35-
| Precompute engine: run ingestion-time nodes per pane | planned |
34+
| Stored summaries (SDS): one definition per computation, panes by end time | done |
35+
| Precompute engine: build ingestion-time outputs once per tumbling pane | done |
3636
| Query engine: run query-time nodes over stored panes and raw data | planned |
3737

38+
Panes close `--allowed-lateness-ms` (default 15 s) after the newest sample
39+
passes their end; later samples for them are dropped. The first pane after
40+
startup is skipped because it may be partial.
41+
3842
Until the query engine lands, the data plane forwards every query to
3943
Prometheus and marks the response `x-asap-execution: forwarded`. SQL and
4044
ClickHouse are not supported yet.

‎data_plane/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ axum.workspace = true
2020
reqwest.workspace = true
2121
prost = "0.13"
2222
snap = "1.1"
23+
futures = "0.3"
2324

2425
[dev-dependencies]
2526
tower = { version = "0.5", features = ["util"] }

‎data_plane/src/engine.rs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! The engine thread: owns the installed plan and everything that executes
2+
//! it. Planner IR holds `Rc`s, so the plan cannot be shared across the HTTP
3+
//! server's threads; handlers send the engine commands instead.
4+
5+
use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
6+
7+
use asap_executor::physical_planner::frontier_from_timing;
8+
use control_plane::InstalledPlan;
9+
use tokio::sync::oneshot;
10+
11+
use crate::precompute::{Precompute, PrecomputeStats, RawSample};
12+
use crate::sds::Sds;
13+
14+
/// Commands queued for the engine before ingestion pushes back.
15+
const QUEUE: usize = 1024;
16+
17+
pub enum Command {
18+
Ingest(Vec<RawSample>),
19+
Stats(oneshot::Sender<PrecomputeStats>),
20+
}
21+
22+
#[derive(Debug, thiserror::Error)]
23+
#[error("the engine is busy")]
24+
pub struct Busy;
25+
26+
/// The HTTP side of the engine thread.
27+
#[derive(Clone)]
28+
pub struct EngineHandle {
29+
commands: SyncSender<Command>,
30+
}
31+
32+
impl EngineHandle {
33+
/// Starts the engine for the plan serialized in `plan_json`. Fails if the
34+
/// plan is invalid or the data plane cannot execute it.
35+
pub fn spawn(plan_json: String, allowed_lateness_ms: i64) -> anyhow::Result<Self> {
36+
let (commands, receiver) = sync_channel(QUEUE);
37+
let (ready, started) = std::sync::mpsc::channel();
38+
std::thread::Builder::new()
39+
.name("engine".into())
40+
.spawn(move || match Engine::new(&plan_json, allowed_lateness_ms) {
41+
Ok(engine) => {
42+
let _ = ready.send(Ok(()));
43+
engine.serve(receiver);
44+
}
45+
Err(error) => {
46+
let _ = ready.send(Err(error));
47+
}
48+
})?;
49+
started.recv()??;
50+
Ok(Self { commands })
51+
}
52+
53+
/// Queues `samples` for precompute, or fails if the engine is behind.
54+
pub fn try_ingest(&self, samples: Vec<RawSample>) -> Result<(), Busy> {
55+
match self.commands.try_send(Command::Ingest(samples)) {
56+
Ok(()) => Ok(()),
57+
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => Err(Busy),
58+
}
59+
}
60+
61+
/// Precompute counters, once every command queued before has run.
62+
pub async fn stats(&self) -> PrecomputeStats {
63+
let (reply, stats) = oneshot::channel();
64+
let _ = self.commands.send(Command::Stats(reply));
65+
stats.await.unwrap_or_default()
66+
}
67+
}
68+
69+
struct Engine {
70+
precompute: Precompute,
71+
}
72+
73+
impl Engine {
74+
fn new(plan_json: &str, allowed_lateness_ms: i64) -> anyhow::Result<Self> {
75+
let plan: InstalledPlan = serde_json::from_str(plan_json)?;
76+
plan.validate()?;
77+
let dag = plan.dag.dag;
78+
let frontier = frontier_from_timing(&dag)?;
79+
let sds = Sds::from_dag(&dag, &frontier)?;
80+
tracing::info!(
81+
definitions = sds.definitions.len(),
82+
outputs = sds.outputs.len(),
83+
"stored summaries"
84+
);
85+
Ok(Self {
86+
precompute: Precompute::new(dag, &sds, allowed_lateness_ms)?,
87+
})
88+
}
89+
90+
fn serve(mut self, commands: Receiver<Command>) {
91+
for command in commands {
92+
match command {
93+
Command::Ingest(samples) => self.precompute.ingest(&samples),
94+
Command::Stats(reply) => {
95+
let _ = reply.send(self.precompute.stats);
96+
}
97+
}
98+
}
99+
}
100+
}

‎data_plane/src/lib.rs‎

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
//! Backend data plane: receives Prometheus Remote Write samples and serves
22
//! the Prometheus query API for an installed plan.
33
//!
4-
//! The installed plan is ASAPPlanner's selected `PhysicalASAPDAG`. Executing
5-
//! it is not implemented yet: samples are validated and counted, and every
6-
//! query is forwarded to Prometheus.
4+
//! The installed plan is ASAPPlanner's selected `PhysicalASAPDAG`. The
5+
//! engine thread builds its ingestion-time outputs pane by pane; queries are
6+
//! still forwarded to Prometheus.
77
8+
pub mod engine;
9+
pub mod precompute;
810
pub mod remote_write;
911
pub mod sds;
1012

13+
use std::collections::BTreeMap;
1114
use std::sync::atomic::{AtomicU64, Ordering};
1215
use std::sync::Arc;
1316

@@ -17,14 +20,20 @@ use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode};
1720
use axum::response::{IntoResponse, Response};
1821
use axum::routing::{get, post};
1922
use axum::Router;
23+
use control_plane::InstalledPlan;
24+
use engine::EngineHandle;
25+
use precompute::RawSample;
26+
27+
/// Prometheus' staleness marker: a NaN that ends a series, not a sample.
28+
const STALE_NAN_BITS: u64 = 0x7ff0_0000_0000_0002;
2029

2130
/// Response header naming what answered a query.
2231
pub const EXECUTION_HEADER: &str = "x-asap-execution";
2332

2433
pub struct DataPlane {
25-
/// The installed plan's queries, in root order. The plan itself is not
26-
/// `Send` (its operator payloads hold `Rc`s), so it stays with its loader.
34+
/// The installed plan's queries, in root order.
2735
pub queries: Vec<String>,
36+
pub engine: EngineHandle,
2837
/// Base URL of the Prometheus that answers forwarded queries.
2938
pub prometheus_url: String,
3039
pub limits: remote_write::Limits,
@@ -34,14 +43,23 @@ pub struct DataPlane {
3443
}
3544

3645
impl DataPlane {
37-
pub fn new(queries: Vec<String>, prometheus_url: String) -> Self {
38-
Self {
39-
queries,
46+
/// Installs the plan serialized in `plan_json` (an [`InstalledPlan`]).
47+
/// Panes close `allowed_lateness_ms` after the newest sample passes them.
48+
pub fn new(
49+
plan_json: String,
50+
prometheus_url: String,
51+
allowed_lateness_ms: i64,
52+
) -> anyhow::Result<Self> {
53+
let plan: InstalledPlan = serde_json::from_str(&plan_json)?;
54+
plan.validate()?;
55+
Ok(Self {
56+
queries: plan.queries,
57+
engine: EngineHandle::spawn(plan_json, allowed_lateness_ms)?,
4058
prometheus_url: prometheus_url.trim_end_matches('/').to_string(),
4159
limits: remote_write::Limits::default(),
4260
samples: AtomicU64::new(0),
4361
client: reqwest::Client::new(),
44-
}
62+
})
4563
}
4664
}
4765

@@ -57,9 +75,36 @@ pub fn router(state: Arc<DataPlane>) -> Router {
5775
async fn write(State(state): State<Arc<DataPlane>>, body: Bytes) -> Response {
5876
match remote_write::decode(&body, &state.limits) {
5977
Ok(request) => {
60-
let samples: usize = request.timeseries.iter().map(|s| s.samples.len()).sum();
61-
state.samples.fetch_add(samples as u64, Ordering::Relaxed);
62-
StatusCode::NO_CONTENT.into_response()
78+
let mut samples = Vec::new();
79+
for series in request.timeseries {
80+
let labels: Arc<BTreeMap<String, String>> = Arc::new(
81+
series
82+
.labels
83+
.into_iter()
84+
.map(|label| (label.name, label.value))
85+
.collect(),
86+
);
87+
samples.extend(
88+
series
89+
.samples
90+
.into_iter()
91+
.filter(|s| s.value.to_bits() != STALE_NAN_BITS)
92+
.map(|s| RawSample {
93+
labels: labels.clone(),
94+
timestamp_ms: s.timestamp,
95+
value: s.value,
96+
}),
97+
);
98+
}
99+
let count = samples.len() as u64;
100+
match state.engine.try_ingest(samples) {
101+
Ok(()) => {
102+
state.samples.fetch_add(count, Ordering::Relaxed);
103+
StatusCode::NO_CONTENT.into_response()
104+
}
105+
// Remote Write senders retry on 5xx.
106+
Err(busy) => (StatusCode::SERVICE_UNAVAILABLE, busy.to_string()).into_response(),
107+
}
63108
}
64109
Err(error) => (StatusCode::BAD_REQUEST, error.to_string()).into_response(),
65110
}

‎data_plane/src/main.rs‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use std::path::PathBuf;
22
use std::sync::Arc;
33

44
use clap::Parser;
5-
use control_plane::InstalledPlan;
65
use data_plane::{router, DataPlane};
76

87
/// Serve Remote Write ingestion and the Prometheus query API for a plan.
@@ -16,6 +15,9 @@ struct Args {
1615
prometheus_url: String,
1716
#[arg(long, default_value = "0.0.0.0:8088")]
1817
listen: String,
18+
/// How long after the newest sample passes a pane's end it is built.
19+
#[arg(long, default_value_t = 15_000)]
20+
allowed_lateness_ms: i64,
1921
}
2022

2123
#[tokio::main]
@@ -24,13 +26,13 @@ async fn main() -> anyhow::Result<()> {
2426
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
2527
.init();
2628
let args = Args::parse();
27-
let plan = InstalledPlan::load(&args.plan)?;
28-
tracing::info!(
29-
queries = plan.queries.len(),
30-
nodes = plan.dag.dag.nodes.len(),
31-
"installed plan"
32-
);
33-
let state = Arc::new(DataPlane::new(plan.queries, args.prometheus_url));
29+
let plan = std::fs::read_to_string(&args.plan)?;
30+
let state = Arc::new(DataPlane::new(
31+
plan,
32+
args.prometheus_url,
33+
args.allowed_lateness_ms,
34+
)?);
35+
tracing::info!(queries = state.queries.len(), "installed plan");
3436
let listener = tokio::net::TcpListener::bind(&args.listen).await?;
3537
tracing::info!(address = %listener.local_addr()?, "listening");
3638
axum::serve(listener, router(state)).await?;

0 commit comments

Comments
 (0)