Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions control_plane/src/asap_tier_implement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,7 @@ mod tests {

#[test]
fn bare_selector_has_no_aggregate_root_to_implement() {
// L1 adoption (design-target-architecture.md Part B), accepted
// behavior change -- see
// asap_tier_analysis::bare_selector_is_no_longer_asap_tier_answerable's
// comment: `lower_promql` doesn't implicitly wrap a bare selector
// in `Aggregate { Sum }` the way the retired local parser did, so
// there's no `Aggregate` node here at all to find a root at.
// A bare selector has no `Aggregate` node, so there is no ASAP-tier root.
let roots =
implement_promql_for_asap_tier("http_requests_total").expect("parses and implements");
assert!(roots.is_empty(), "{roots:?}");
Expand Down Expand Up @@ -324,16 +319,8 @@ mod tests {
/// behavior shift.
#[test]
fn implement_frequency_as_agg_test() {
// Per this test's own prior instructions: the gap it used to
// document (under-realizing to `Logical` because `asap-plan` had
// no `Extension`/`Frequency` opinion) is now closed -- not via an
// `Extension` hook, but because L1 adoption
// (design-target-architecture.md Part B) makes `count_over_time`
// lower directly to `AggIntent::Count { accuracy: Epsilon(...) }`
// (a real, first-class, non-exact intent) rather than needing
// this deployment's `Frequency` extension wrapper at all --
// `asap-plan` realizes a non-exact `Count` as a real CMS-backed
// `SummaryAgg` + `SummaryEstimate` on its own.
// Approximate `count_over_time` lowers to a first-class `Count` intent,
// which Planner realizes as CMS-backed `SummaryAgg` + `SummaryEstimate`.
let roots = implement_promql_for_asap_tier("count_over_time(http_requests_total[5m])")
.expect("parses and implements");
assert_eq!(roots.len(), 1);
Expand Down
37 changes: 9 additions & 28 deletions control_plane/src/backend_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,8 @@ impl BackendClient {
}
}

/// Phase C (MVP v6) variant of [`Self::push_streaming_config`]
/// that POSTs `application/json`. The typed L5
/// `emit_backend_streaming_config_json` emitter produces a `serde_json::Value`
/// rather than a YAML document, and the ASAPQuery-backend's
/// `/api/v1/streaming-config` endpoint accepts both content types
/// (PR #297 / Phase B documents the JSON shape). Same 2xx-or-error
/// contract as the YAML variant; same fire-and-forget semantics
/// at the call site.
/// Post streaming configuration as `application/json`. The backend accepts
/// both JSON and YAML; a non-2xx response is an error for the caller to log.
pub async fn post_streaming_config_json(&self, json: String) -> Result<()> {
debug!(
endpoint = %self.endpoint,
Expand Down Expand Up @@ -280,22 +274,9 @@ impl BackendClient {
}
}

/// Phase α (MVP) sibling of [`Self::post_streaming_config_json`]:
/// POSTs the control-plane-emitted `BackendStorageRouting` JSON
/// document to the backend's `POST /api/v1/storage_routing`
/// endpoint. The backend hot-loads the routing table and the next
/// instant query consults the new table.
///
/// Endpoint resolution: the field [`Self::endpoint`] is the
/// control plane's configured streaming-config endpoint (e.g.
/// `http://backend.svc:8088/api/v1/streaming-config`). We rewrite
/// the path component from `/api/v1/streaming-config` to
/// `/api/v1/storage_routing` so operators only configure one
/// `CONTROL_PLANE_BACKEND_ENDPOINT` env var and both pushes land
/// at the same backend host. URLs that don't end in
/// `/api/v1/streaming-config` are passed through unchanged
/// (a test-mode escape hatch — the unit test below builds a
/// mock URL ending in `/storage_routing` directly).
/// Post backend storage-routing JSON. Derive the URL by replacing the
/// `/api/v1/streaming-config` suffix with `/api/v1/storage_routing`; URLs
/// without that suffix are used verbatim.
pub async fn post_storage_routing_json(&self, json: String) -> Result<()> {
let url = derive_storage_routing_url(&self.endpoint);
debug!(
Expand Down Expand Up @@ -533,7 +514,7 @@ mod tests {
push_or_log(&client, "cpu_usage", "content".to_string()).await;
}

/// Phase C: the JSON variant POSTs the body verbatim, returns
/// the JSON variant POSTs the body verbatim, returns
/// `Ok(())` on a 2xx, and surfaces non-2xx as `Err`. Mock backend
/// captures the body so we can verify it round-trips.
#[tokio::test]
Expand All @@ -553,7 +534,7 @@ mod tests {
assert_eq!(received[0], json);
}

/// Phase C: non-2xx from the backend surfaces as an error so the
/// non-2xx from the backend surfaces as an error so the
/// caller (handle_plan) can log + move on.
#[tokio::test]
async fn json_post_non_2xx_is_error() {
Expand All @@ -567,7 +548,7 @@ mod tests {
assert!(msg.contains("400"), "error msg should mention 400: {msg}");
}

/// Phase α: storage-routing-URL derivation rewrites the path
/// storage-routing-URL derivation rewrites the path
/// component when the configured endpoint ends in
/// `/api/v1/streaming-config`, leaving everything else untouched.
#[test]
Expand Down Expand Up @@ -648,7 +629,7 @@ mod tests {
server.abort();
}

/// Phase α: full happy path. A mock backend hosts the storage
/// full happy path. A mock backend hosts the storage
/// routing endpoint; the client POSTs the control-plane-emitted JSON
/// and the body round-trips verbatim. Mirrors `json_post_round_trips_body`.
async fn start_mock_routing_backend(
Expand Down
49 changes: 10 additions & 39 deletions control_plane/src/emit/backend_push.rs
Original file line number Diff line number Diff line change
@@ -1,42 +1,13 @@
//! Typed cumulative push of `BackendStageConfig` to the ASAPQuery-backend.
//! Cumulative backend configuration push shared by planning and replanning.
//!
//! Single entrypoint — [`post_typed_backend_for_role`] — invoked from
//! every plan-emit cycle (HTTP `POST /api/v1/plan`, the replanner's
//! plan-expiry / SLA-violation triggers, startup pre-pop tick, OpAMP
//! on-connect tick). It:
//! Each push updates the per-`(metric, role)` cache, concatenates all aggregations
//! and readouts in deterministic order, then posts streaming configuration.
//! Storage routing merges those entries by metric before posting. Both endpoints
//! replace their configuration atomically, so a push must preserve sibling roles
//! and metrics.
//!
//! 1. Updates the per-`(metric, role)` cache with the new
//! `BackendStageConfig`.
//! 2. Builds a **cumulative** `BackendStageConfig` whose
//! `aggregations` + `readouts` concatenate every cache entry's,
//! ordered deterministically (`(metric, role.as_str())` ascending)
//! so the emitted JSON body is reproducible across runs and tests.
//! 3. POSTs the cumulative streaming-config JSON to
//! `/api/v1/streaming-config` — the data plane's atomic
//! `handle.swap(new_config)` then installs every role's
//! aggregations simultaneously.
//! 4. Groups cache entries by metric, merges each metric's
//! `BackendStageConfig`s, and POSTs the per-metric merged routing
//! table to `/api/v1/storage_routing`.
//!
//! **Why one helper, not two paths**: prior to Option B the control
//! plane had two emit paths into the backend:
//!
//! * the typed cumulative path from `handle_plan` (post PR #287) —
//! correct under the data plane's swap semantics;
//! * the legacy single-aggregation path from `Replanner`
//! (`generate_streaming_config_yaml`) — emits ONE aggregation per
//! POST. Under the swap, this WIPES the cumulative state on the
//! backend the moment plan-expiry or accuracy-violation fires it.
//!
//! Option B unifies both call sites through this helper so the swap
//! semantics are honoured at every emit cycle, and the legacy YAML
//! emitter is retired.
//!
//! Fire-and-forget contract: every error (emit failure, HTTP transport
//! error, non-2xx response) logs at WARN and returns — never panics,
//! never propagates. The next replan cycle retries with the latest
//! plan.
//! Emission and transport errors log at WARN and return. The next planning cycle
//! retries with the latest configuration.

use std::collections::{BTreeMap, HashMap};
// `Future` is only referenced by the now-test-only `retry_transient`
Expand Down Expand Up @@ -1005,7 +976,7 @@ mod tests {
/// re-plan, so the restarted backend recovers its config.
#[tokio::test]
async fn repost_after_simulated_backend_reset_re_pushes_full_config() {
// Phase 1: initial plan lands on the first backend instance.
// initial plan lands on the first backend instance.
let (url1, mock1) =
start_dual_mock(axum::http::StatusCode::OK, axum::http::StatusCode::OK).await;
let client1 = StdArc::new(BackendClient::new(url1));
Expand All @@ -1022,7 +993,7 @@ mod tests {
assert_eq!(mock1.streaming_hits.load(StdOrdering::SeqCst), 1);
assert_eq!(mock1.routing_hits.load(StdOrdering::SeqCst), 1);

// Phase 2: the backend silently restarts — model it as a brand-new
// the backend silently restarts — model it as a brand-new
// mock with zero recorded hits. NOTHING expires, NO replan fires.
let (url2, mock2) =
start_dual_mock(axum::http::StatusCode::OK, axum::http::StatusCode::OK).await;
Expand Down
48 changes: 7 additions & 41 deletions control_plane/src/emit/mod.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
//! `emit/` — per-deployment-model plan emitters (L5 output side).
//!
//! Per `control_plane/docs/design.md` §5 `core::emit`. The 2026-05
//! layered-cleanup refactor consolidated the former
//! `controller/src/config/` directory here. Mapping:
//!
//! | Old path | New path |
//! |---|---|
//! | `config/agent.rs` | [`agent`] |
//! | `config/backend.rs` | *retired — emitted YAML for a "backend-role" OTel merge collector tier that was never deployed; superseded by the typed L5's [`stage_config::emit_backend_streaming_config_json`] which posts to asapquery-backend's precompute engine over HTTP* |
//! | `config/asapquery_backend.rs` | *retired — `generate_streaming_config_yaml` was the legacy single-aggregation `CollectionPlan`-shaped emitter for `POST /api/v1/streaming-config`; under the data plane's atomic `handle.swap(new_config)` it would WIPE sibling `(metric, role)` aggregations on every fire. Replaced by [`backend_push::post_typed_backend_for_role`], which posts a cumulative typed `BackendStageConfig` derived from the shared per-`(metric, role)` cache* |
//! | *(new)* | [`backend_push`] |
//! | `config/stage_config.rs` | [`stage_config`] (TODO: split into `opamp` + `streaming_config` + `inference_config` per design.md §5; deferred from refactor 2026-05 because the 3,020-line monolith mixes OTel-collector YAML emit, ASAPQuery-backend JSON emit, and shared internals — clean split needs ownership reorganisation, not file renames) |
//! | `config/stage_config_otap.rs` | [`otap`] |
//! | `config/stage_config_telegraf.rs` | [`telegraf`] |
//! | `config/workloads.rs` | [`crate::workload`] (top-level — design.md §5 puts `workload` next to `emit`, not inside it) |
//! Per-deployment plan emitters. Typed stage configs become collector
//! configuration, backend streaming configuration, and storage routing.

pub mod agent;
pub mod backend_push;
Expand Down Expand Up @@ -50,25 +36,9 @@ use anyhow::Result;
use planner_types::post_asap::{SketchAlgorithm, SummaryExpr, SummaryNode};
use std::rc::Rc;

/// Phase ε.1.5 — which edge runtime an agent identifies as.
///
/// Today every agent the controller has built for runs the OTel-collector
/// (`AsapOtel`); Phase ε.1.5 adds the two new runtime variants the
/// per-runtime emitters target. The runtime is reported by the agent on
/// OpAMP `on_connect` (header `X-Agent-Runtime`); when absent (legacy
/// agents) the controller defaults to `AsapOtel` so the existing
/// behaviour is preserved.
///
/// Phase ε.1.5 commits the enum + emit-dispatch function. Threading the
/// runtime through OpAMP `on_connect` and into the typed L5 emit path
/// is a follow-up — the emitters can be exercised in isolation today
/// (the Phase ε.1.5 test suite does exactly that).
///
/// Naming history: the variants were originally `Sketchcollector` /
/// `Sketchotap` / `Sketchtelegraf`; the rename to `AsapOtel` /
/// `AsapOtap` / `AsapTelegraf` (PR `refactor/rename-edge-runtimes-...`)
/// drops the v0 `sketch*` prefix in favour of the symmetric `asap-*`
/// namespace. `from_header` accepts both forms during transition.
/// Edge runtime reported through the OpAMP `X-Agent-Runtime` header.
/// Missing headers default to `AsapOtel`. `from_header` accepts both
/// `asap-*` and `sketch*` names for compatibility.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
Expand Down Expand Up @@ -97,7 +67,7 @@ impl AgentRuntime {
}
}

/// Phase ε.1.5 — dispatch the edge emit by agent runtime. Mirrors
/// dispatch the edge emit by agent runtime. Mirrors
/// `emit_edge_yaml`'s `(cfg, opamp_endpoint) -> String` shape; the OTAP
/// and Telegraf emitters take an additional optional Prometheus URL
/// override which we pass through `prometheus_url`.
Expand Down Expand Up @@ -300,11 +270,7 @@ fn extract_from_plan(plan: &PostAsapPlan) -> Option<SketchAlgorithm> {

fn extract_from_node(node: &Rc<SummaryNode>) -> Option<SketchAlgorithm> {
match &node.expr {
// `SummaryAgg`'s `kind`/`params` collapsed into one `family:
// SummaryFamilyType` field (ASAPPlanner#218 -- see
// control_plane/docs/design-asapplanner-pin-migration.md); the
// exact-vs-sketch check this used to need `is_exact_accumulator`
// for is now which enum variant `family` is.
// The `family` variant distinguishes exact accumulators from sketches.
SummaryExpr::SummaryAgg {
family: planner_types::post_asap::SummaryFamilyType::Sketch(kind, _),
..
Expand Down
23 changes: 6 additions & 17 deletions control_plane/src/emit/otap.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Phase ε.1.5 — OTAP Dataflow DAG YAML emitter (per-runtime mirror of
//! OTAP Dataflow DAG YAML emitter (per-runtime mirror of
//! [`super::stage_config::emit_edge_yaml`]).
//!
//! `asap-otap` uses the otap-dataflow Rust runtime; its config surface is
Expand All @@ -9,10 +9,7 @@
//! and registers under the URN `urn:otel:exporter:otlp_http` via
//! `linkme`'s `distributed_slice(OTAP_EXPORTER_FACTORIES)`.
//!
//! Three modes (the placement decision itself is made upstream in
//! `stage_split`; the `BindMode`-shaped selector Phase ε.1 sketched out
//! in `physical::deployment_cost::wire` was never wired in and was removed in the
//! 2026-07 retirement pass — see that module's doc):
//! Placement modes selected by the upstream stage splitter:
//!
//! 1. `SketchAtEdge` — DAG includes a sketch processor node between the
//! OTLP receiver and the OTLP gRPC exporter to the gateway. (The
Expand All @@ -26,7 +23,7 @@
//! (`/api/v1/otlp/v1/metrics`).
//!
//! The function consumes the same [`EdgeStageConfig`] the OTel-collector
//! emitter does — Phase ε.1.5 keeps the typed L5 plan as the single
//! emitter does, keeping the typed plan as the single
//! source of truth across all three runtimes. The emitter dispatches per
//! `EdgeStageConfig` via the same `prometheus_archive_metrics` /
//! `sketch_processors` signals the OTel-collector emitter uses (Mode 3
Expand Down Expand Up @@ -60,19 +57,11 @@ const URN_OTLP_GRPC_EXPORTER: &str = "exporter:otlp_grpc";
/// URN of the OTLP receiver (gRPC + HTTP).
const URN_OTLP_RECEIVER: &str = "receiver:otlp";

/// URN of the asap_sketches processor registered in the otap-patch tree.
/// Phase ε.1.5 wires the URN abstractly; the binary side lands the
/// plugin in `otap-patch/plugins/asap_sketches/` per
/// [`docs/design-asap-otap-rust-integration.md`].
/// URN of the sketch processor registered by the OTAP plugin.
const URN_ASAP_SKETCHES_PROCESSOR: &str = "processor:asap_sketches";

// ── DAG YAML structural types ────────────────────────────────────────────────
//
// These mirror the otap-dataflow `engine`/`groups`/`pipelines`/`nodes`
// schema in `otel-arrow/rust/otap-dataflow/configs/*.yaml`. We keep a
// minimal set to round-trip; the full schema (channel_capacity policies,
// engine settings, etc.) is left at struct defaults — Phase ε.1.5 only
// commits the wiring shape, not policy.
// Structural subset of the OTAP DAG configuration. Omitted engine and channel
// policy fields retain their runtime defaults.

#[derive(Serialize)]
struct OtapDag {
Expand Down
Loading
Loading