feat: controller BackendClient — push StreamingConfig to ASAPQuery - #156
Merged
Merged
Conversation
Closes the producer side of the ASAPQuery PR E hot-reload contract. After a replan, the controller now (optionally) POSTs the new plan as a StreamingConfig YAML to ASAPQuery-backend's /api/v1/streaming-config endpoint, paralleling its existing OpAMP push to agent-role and backend-role collectors. ## Why ASAPQuery PRs #10 and #12 landed the endpoint and made it observable at query time, but the backend's active StreamingConfig only changes when something POSTs to it. Manual curl was the only producer until now. With this PR the controller closes the loop: query hits SimpleEngine miss → ASAPQuery PR #11 fires fire-and-forget POST to controller → this controller runs the planner, generates a new plan → THIS PR pushes the plan to the backend's streaming-config endpoint → next query re-snapshots and finds a match (via PR #12 phase 2) The data path (DataCollector agent → OTLP sketch → backend ingest) was already covered by the existing OpAMP push. This PR covers the control path back to the query-side state the backend holds. ## What's new ### `controller::config::asapquery_backend` Converts a `CollectionPlan` + metric name into the YAML shape `StreamingConfig::from_yaml_data` consumes (aggregations: [{aggregationId, aggregationType, metric, labels, parameters, windowSize, windowType, spatialFilter}]). Key details: * Maps `SketchType` → backend `AggregationType::Display` string (notably KLL → "DatasketchesKLL", NOT the factory string "KLL") * Derives a deterministic `u64` `aggregationId` from the metric name so repeat pushes for the same metric update (not duplicate) the backend's agg map * Rejects zero-window plans (the backend parser does too) * Joins the controller's `label_matchers` list into the backend's comma-separated `spatialFilter` string Separate from existing `config::backend` which emits OTel YAML (for a backend OTel collector running merge processors). The two consumers are different services consuming different formats. ### `controller::backend_client` Thin HTTP client wrapping reqwest::Client. Methods: * `BackendClient::new(endpoint)` — 5-second timeout (symmetric with ASAPQuery's HttpControllerClient in PR #11) * `BackendClient::push_streaming_config(yaml) -> Result<()>` — POSTs with content-type application/x-yaml, maps non-2xx to Err * `push_or_log(client, metric, yaml)` — fire-and-forget helper used by the replanner, never propagates errors (next replan cycle retries) ### `Replanner::with_backend_client(client)` builder New optional field. When set, `replan_metric()` calls `generate_streaming_config_yaml(metric, &plan)` after the OpAMP pushes and fire-and-forgets the result via the client. Without the builder call, replans behave exactly as before — existing deployments that don't yet run ASAPQuery-backend are unaffected. ### `main.rs` wiring New env var `CONTROLLER_BACKEND_ENDPOINT`. When set, constructs a `BackendClient` and attaches it to the Replanner via the new builder. Logs whether the feature is enabled at startup. ## Tests ### `config::asapquery_backend` (5 new) * `deterministic_id_is_stable_across_calls` — same metric → same id, different metrics → different ids, never returns 0 * `yaml_round_trips_through_serde_yaml` — generate, re-parse, assert every field (metric, window_size, window_type, spatial filter, grouping labels) * `maps_all_sketch_types` — pins the SketchType → AggregationType name mapping for all 5 sketch types, including the KLL → DatasketchesKLL gotcha * `rejects_plan_without_window_duration` — zero-window plan is rejected with a clear error * `spatial_filter_joins_label_matchers` — multiple matchers are joined with commas ### `backend_client` (3 new) * `success_path_round_trips_yaml` — spawns a local axum mock that records POST bodies, asserts the client posts the exact YAML and the server receives it * `non_2xx_status_is_reported_as_error` — mock returns 500, client maps to formatted Err containing the status * `push_or_log_swallows_errors` — points at an unreachable port, verifies the fire-and-forget helper does not propagate the failure (replan must never abort on backend unavailability) ## Validation * cargo check --all-targets: clean * cargo test --bin controller: 353 passed (up 8 from main) * cargo fmt --check on new files only: clean (pre-existing fmt drift in unrelated files not touched by this PR) ## Follow-ups * **Rate-limit / dedupe** — the controller may push the same plan repeatedly across rapid replans. The backend tolerates this idempotently via deterministic agg_ids, but a "push only on hash change" filter would cut unnecessary HTTP traffic. * **Retry with backoff** — fire-and-forget is fine for the common case. A transient backend outage currently drops one replan cycle; a small retry queue would recover it. * **Merge semantics on the backend side** — today the backend's POST handler REPLACES the entire StreamingConfig. When the controller pushes for metric A, any aggregations the backend had for metric B are wiped. A follow-up should either (a) restrict the controller to push full snapshots, or (b) extend the backend endpoint to support partial updates keyed by metric / agg_id. * **Labels.rollup / aggregated** — today we leave those empty because the controller doesn't track rollup dimensions separately. When the cost model starts producing richer label metadata, wire it through. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the producer side of the ASAPQuery PR E hot-reload contract. After a replan, the controller now (optionally) POSTs the new plan as a
StreamingConfigYAML to ASAPQuery-backend's/api/v1/streaming-configendpoint, paralleling its existing OpAMP push to agent-role and backend-role collectors.Why
ASAPQuery #10 and #12 landed the endpoint and made it observable at query time, but the backend's active
StreamingConfigonly changes when something POSTs to it. Manualcurlwas the only producer until now. With this PR the controller closes the loop:The data path (agent → OTLP sketch → backend ingest) was already covered by the existing OpAMP push in
replan.rs. This PR covers the control path back to the query-side state the backend holds.What's new
controller::config::asapquery_backend(new module)Converts a
CollectionPlan+ metric name into the YAML shapeStreamingConfig::from_yaml_dataconsumes:Key details:
SketchType→ backendAggregationType::Displaystring (notably KLL →\"DatasketchesKLL\", not the factory string\"KLL\")u64aggregationIdfrom the metric name so repeat pushes for the same metric update (not duplicate) the backend's agg maplabel_matchersinto a comma-separatedspatialFilterstringSeparate from existing
config::backendwhich emits OTel YAML for a backend OTel collector running merge processors — two different services, two different formats.controller::backend_client(new module)Thin HTTP client wrapping
reqwest::Client:BackendClient::new(endpoint)— 5-second timeout (symmetric with ASAPQuery'sHttpControllerClientin PR update README #11)BackendClient::push_streaming_config(yaml) -> Result<()>— POSTs withcontent-type: application/x-yaml, maps non-2xx toErrpush_or_log(client, metric, yaml)— fire-and-forget helper used by the replanner, never propagates errors (next replan cycle retries)Replanner::with_backend_client(client)builderNew optional field. When set,
replan_metric()callsgenerate_streaming_config_yaml(metric, &plan)after the OpAMP pushes and fire-and-forgets the result. Without the builder call, replans behave exactly as before — existing deployments unaffected.main.rswiringNew env var
CONTROLLER_BACKEND_ENDPOINT. When set, constructs aBackendClientand attaches it to theReplanner. Logs whether the feature is enabled at startup.Tests
config::asapquery_backend(5 new)deterministic_id_is_stable_across_calls— same metric → same id, different metrics → different ids, never returns 0yaml_round_trips_through_serde_yaml— generate → re-parse → assert every fieldmaps_all_sketch_types— pins all 5 SketchType → AggregationType mappings including the KLL → DatasketchesKLL gotcharejects_plan_without_window_duration— zero-window plan rejected with clear errorspatial_filter_joins_label_matchers— multiple matchers joined with commasbackend_client(3 new)success_path_round_trips_yaml— spawns a local axum mock, asserts the client posts the exact YAML and the server receives itnon_2xx_status_is_reported_as_error— mock returns 500 → client returns formattedErrcontaining the statuspush_or_log_swallows_errors— points at an unreachable port, verifies fire-and-forget does not propagate the failureValidation
cargo check --all-targets: cleancargo test --bin controller: 353 passed (up 8 from main)rustfmt --checkon new files only: cleanStack
Follow-ups
StreamingConfig. When the controller pushes for metric A, any aggregations the backend had for metric B are wiped. A follow-up should either restrict the controller to full snapshots or extend the backend endpoint to support partial updates keyed by metric.labels.rollup/aggregated— today left empty; wire through once the cost model tracks rollup dimensions separately.🤖 Generated with Claude Code