Skip to content

feat: MessagePack encoding parity for modified-OTLP sketch hot path (PR I) - #9

Merged
zzylol merged 2 commits into
mainfrom
feat/msgpack-encoding-parity
Apr 15, 2026
Merged

zzylol merged 2 commits into
mainfrom
feat/msgpack-encoding-parity

Conversation

@zzylol

@zzylol zzylol commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

PR I — adds MessagePack as a parallel encoding option alongside the protobuf-encoded sketchlib *State path that PR B and PR C wired up for all five modified-OTLP Metric.data sketch variants. Gives sketchlib-go (Go producer) a practical alternative to prost-encoded sketchlib proto without touching the existing PROTO path.

Cross-language producer ships in sketchlib-go #50 (merged)wire/asapmsgpack.Marshal* emits bytes that this PR's from_msgpack_bytes decoders deserialize byte-for-byte. Golden fixtures over there were generated from this Rust side's serialize_msgpack output, so the cross-language contract is pinned on both sides.

What's new

Proto

Adds *_ENCODING_MSGPACK = 3 and *_ENCODING_MSGPACK_DELTA = 4 to each of the five per-sketch encoding enums in the vendored metrics.proto. Backward-compatible: producers emitting tags 0-2 see no change.

Dispatcher (drivers/ingest/otel.rs)

decode_modified_otlp_sketch_bytes now matches on the full encoding value:

  • PROTO = 1 → per-sketch from_sketchlib_proto_bytes (existing)
  • MSGPACK = 3 → per-sketch from_msgpack_bytes (new)
  • PROTO_DELTA = 2, MSGPACK_DELTA = 4 → deferred, falls through to §5.2 fallback

Per-sketch decoders

Each of the five accumulators gains a from_msgpack_bytes(buffer) constructor wrapping the sketch-core deserialize_msgpack already present on the underlying type:

  • CountMinSketchAccumulator::from_msgpack_bytes
  • CountSketchAccumulator::from_msgpack_bytes
  • DatasketchesKLLAccumulator::from_msgpack_bytesnotable: unlike the _ENCODING_PROTO path (which does lossy statistical reconstruction because sketchlib's KllState proto elides level structure the Rust backend keeps private), the msgpack path is a bit-identical round-trip because sketch-core's KllSketch serializes its full internal state. This gives Go producers a way to send KLL sketches losslessly to the Rust backend. (Note: sketchlib-go test(engine): end-to-end tests for schema-timeline query dispatch #50 does not yet implement the KLL wire format — see note below.)
  • DDSketchAccumulator::from_msgpack_bytes
  • HllSketchAccumulator::from_msgpack_bytes

Tests

  • Unit tests — 6 new, 2 per decoder (round-trip + garbage rejection) on CountSketchAccumulator, DDSketchAccumulator, and HllSketchAccumulator. CountMin + KLL already covered by existing deserialize_from_bytes_arroyo tests.
  • E2e test — new e2e_count_min_sketch_msgpack_modified_otlp_path: builds a real CountMinSketch in sketch-core, serializes via msgpack, POSTs through OTLP HTTP with encoding = MSGPACK, closes a window, verifies stored counts preserved. Covers the dispatcher path end-to-end. Other four variants share the same branch; per-variant msgpack round-trips are covered by unit tests.
  • Cross-language contract pinned — sketchlib-go #50 ships golden-fixture tests using exact bytes from this Rust side's sketch_core::*::serialize_msgpack output (CountSketch, DDSketch, HLL Regular, HLL Hip, CountMinSketch). If either side drifts, both CI gates fail.

Drive-by: two clippy fixes inherited from main

cargo clippy -- -D warnings on rust 1.91 flags two pre-existing issues in persistence PR #4 code:

  • cache.rs — unnecessary u64 as u64 casts
  • part.rs — test &[snap.clone()]std::slice::from_ref(&snap)

Fixed here since CI's clippy gate would otherwise block PR I.

Validation

  • cargo check --all-targets: clean
  • cargo clippy --all-targets -- -D warnings: clean
  • cargo fmt --check: clean
  • cargo test -p sketch-core --lib: 54 passed
  • cargo test -p query_engine_rust --lib: 480 passed (up 6 from main — the msgpack unit tests)
  • cargo test -p query_engine_rust --test e2e_modified_otlp_sketch_path: 6 passed (5 existing PROTO variants + 1 new MSGPACK variant)

Follow-ups outside this PR scope

  • sketchlib-go Go-side MessagePack serializer — shipped in sketchlib-go #50 (merged). CountMin / CountSketch / DDSketch / HLL supported; KLL deferred (see below).
  • DataCollector proto enum names — tracked in DataCollector #154, forward-compatible (integer tags already match at runtime).
  • DataCollector *processor wire-up — a processor-side config option to emit _ENCODING_MSGPACK instead of _ENCODING_PROTO, calling sketchlib-go's wire/asapmsgpack.Marshal* on the sketch state. Next in the producer stack.
  • KLL msgpack parity — sketchlib-go's KLL and ASAPQuery-backend's datasketches-rs-backed KllSketch do not share a byte-level backend, so Go-side KLL msgpack is not reachable today. Producers emitting KLL should use _ENCODING_PROTO (the Rust side does lossy statistical reconstruction within KLL's rank-error bound). Tracked upstream.
  • Delta encoding (_DELTA variants) — needs a sketch-core apply_delta API design.

🤖 Generated with Claude Code

…PR I)

Adds MessagePack as a parallel encoding option alongside the
protobuf-encoded sketchlib `*State` path that PR B and PR C wired up
for all five modified-OTLP `Metric.data` sketch variants. The new
encoding is a cross-language contract between DataCollector (Go,
sketchlib-go) and ASAPQuery-backend (Rust, sketch-core) — both sides
serialize the same cross-language sketch-core wire struct via
MessagePack. This gives sketchlib-go a practical alternative to
prost-encoded sketchlib proto without touching existing paths.

## Proto

Adds two new enum variants to each of the five per-sketch encoding
enums in the vendored `opentelemetry.proto.metrics.v1`:
  * `*_ENCODING_MSGPACK         = 3`
  * `*_ENCODING_MSGPACK_DELTA   = 4`

Backward-compatible: existing producers emitting tags 0-2 see no
change. The vendored proto is the consumer side — DataCollector's
upstream proto can adopt matching enum names in a parallel PR without
breaking either side, since protobuf enums are just integer tags.

## Dispatcher (`drivers/ingest/otel.rs`)

`decode_modified_otlp_sketch_bytes` now matches on the full `encoding`
i32 value rather than only accepting `PROTO = 1`:

  * `PROTO = 1`    → per-sketch `from_sketchlib_proto_bytes`  (existing)
  * `MSGPACK = 3`  → per-sketch `from_msgpack_bytes`          (new)
  * `PROTO_DELTA = 2`, `MSGPACK_DELTA = 4` → deferred, caller falls
                                              through to §5.2 fallback

## Per-sketch decoders

Each of the five accumulators gains a `from_msgpack_bytes(buffer)`
constructor that wraps the sketch-core `deserialize_msgpack` already
present on the underlying type:

  * `CountMinSketchAccumulator::from_msgpack_bytes`
  * `CountSketchAccumulator::from_msgpack_bytes`
  * `DatasketchesKLLAccumulator::from_msgpack_bytes` — and this is
    notable: unlike the `_ENCODING_PROTO` path (which does lossy
    statistical reconstruction via `update()` replay because
    sketchlib's `KllState` proto elides level structure the Rust
    backend types keep private), the msgpack path is a **bit-identical
    round-trip** because sketch-core's `KllSketch` serializes its
    full internal state to msgpack. This gives Go producers a way to
    send KLL sketches losslessly to the Rust backend.
  * `DDSketchAccumulator::from_msgpack_bytes`
  * `HllSketchAccumulator::from_msgpack_bytes`

## Tests

Unit tests — 6 new, 2 per decoder (round-trip + garbage rejection)
on `CountSketchAccumulator`, `DDSketchAccumulator`, and
`HllSketchAccumulator`. CountMin + KLL msgpack paths already had
coverage through `deserialize_from_bytes_arroyo` tests.

E2e test — new `e2e_count_min_sketch_msgpack_modified_otlp_path` in
`tests/e2e_modified_otlp_sketch_path.rs`. Builds a real
`CountMinSketch` in sketch-core, serializes via msgpack, POSTs through
the OTLP HTTP receiver with `encoding = COUNT_MIN_SKETCH_ENCODING_MSGPACK`,
closes a window, and verifies the stored accumulator preserves per-key
counts. Covers the dispatcher path end-to-end for the msgpack branch;
the other four variants share the same branch so per-variant smoke
tests aren't needed for dispatcher coverage (per-variant msgpack
round-trips are covered by the unit tests).

## Drive-by: fix two clippy warnings inherited from main

`cargo clippy --all-targets -- -D warnings` on rust 1.91 flags two
pre-existing issues in the persistence PR #4 code path:
  * `cache.rs` unnecessary `u64 as u64` casts
  * `part.rs` test `&[snap.clone()]` → `std::slice::from_ref(&snap)`

Fixed in this PR since the CI clippy gate would otherwise block PR I.

## Validation

- cargo check --all-targets: clean
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
- cargo test -p sketch-core --lib: 54 passed
- cargo test -p query_engine_rust --lib: 480 passed (includes 6 new msgpack unit tests)
- cargo test -p query_engine_rust --test e2e_modified_otlp_sketch_path: 6 passed (5 existing PROTO variants + 1 new MSGPACK variant)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zzylol added a commit to ProjectASAP/ASAPCollector that referenced this pull request Apr 14, 2026
…sketches

Adds `_ENCODING_MSGPACK = 3` and `_ENCODING_MSGPACK_DELTA = 4` to the
five per-sketch encoding enums in the modified OTLP proto patch —
matching the variants landed in ASAPQuery-backend PR #9
(ProjectASAP/ASAPQuery-backend#9) and implemented
on the producer side in sketchlib-go PR #50
(ProjectASAP/sketchlib-go#50).

Before this PR the three sides of the cross-language MessagePack contract
had mismatched proto enum names:

  * ASAPQuery-backend (Rust consumer) — already had `_MSGPACK = 3` in its
    vendored metrics.proto, ready to decode msgpack-encoded sketch bytes
    from the hot path dispatcher
  * sketchlib-go (Go producer) — already has `wire/asapmsgpack.Marshal*`
    producing bytes the Rust consumer deserializes byte-for-byte
  * DataCollector — lacked the enum names entirely, so processors
    emitting msgpack bytes had to hard-code the integer tag `3`

This patch aligns the DataCollector proto with the other two, closing
the naming gap. Because protobuf enums are just integer tags, the change
is forward-compatible: producers and consumers that already know about
tag `3` at runtime continue to interoperate whether or not the name is
declared on their side of the wire. So this commit is a pure readability
/ discoverability win; no generated code or runtime behavior changes
until a follow-up wires a processor component to actually emit the new
tag.

Affected enums:
  * `DDSketchEncoding`        — adds MSGPACK=3, MSGPACK_DELTA=4
  * `KLLSketchEncoding`       — adds MSGPACK=3, MSGPACK_DELTA=4 (reserved;
                                sketchlib-go KLL doesn't share a byte-level
                                backend with ASAPQuery-backend's KLL, so
                                MSGPACK is not usable for KLL today — use
                                PROTO instead)
  * `CountSketchEncoding`     — adds MSGPACK=3, MSGPACK_DELTA=4
  * `CountMinSketchEncoding`  — adds MSGPACK=3, MSGPACK_DELTA=4
  * `HLLSketchEncoding`       — adds MSGPACK=3, MSGPACK_DELTA=4

No changes to .proto message shapes, no reserved-range shifts, no
impact on existing `_PROTO = 1` / `_PROTO_DELTA = 2` / `_DELTA = 2`
producers and consumers.

The generated `.pb.go` files and the opentelemetry-proto submodule
checkout are NOT committed — they're regenerated from this patch file
via `restore_otel_proto_patches.sh` at build time.

Follow-ups:
  * `*processor` components in DataCollector that today emit only
    `_ENCODING_PROTO` can gain a config option to emit `_ENCODING_MSGPACK`
    instead, calling sketchlib-go's `wire/asapmsgpack.Marshal*` on the
    sketch state
  * `_DELTA` variants still deferred until sketch-core grows an
    `apply_delta` API — these enum entries exist only to reserve the
    tag numbers consistently with the Rust side

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zzylol added a commit to ProjectASAP/ASAPCollector that referenced this pull request Apr 14, 2026
…sketches (#154)

Adds `_ENCODING_MSGPACK = 3` and `_ENCODING_MSGPACK_DELTA = 4` to the
five per-sketch encoding enums in the modified OTLP proto patch —
matching the variants landed in ASAPQuery-backend PR #9
(ProjectASAP/ASAPQuery-backend#9) and implemented
on the producer side in sketchlib-go PR #50
(ProjectASAP/sketchlib-go#50).

Before this PR the three sides of the cross-language MessagePack contract
had mismatched proto enum names:

  * ASAPQuery-backend (Rust consumer) — already had `_MSGPACK = 3` in its
    vendored metrics.proto, ready to decode msgpack-encoded sketch bytes
    from the hot path dispatcher
  * sketchlib-go (Go producer) — already has `wire/asapmsgpack.Marshal*`
    producing bytes the Rust consumer deserializes byte-for-byte
  * DataCollector — lacked the enum names entirely, so processors
    emitting msgpack bytes had to hard-code the integer tag `3`

This patch aligns the DataCollector proto with the other two, closing
the naming gap. Because protobuf enums are just integer tags, the change
is forward-compatible: producers and consumers that already know about
tag `3` at runtime continue to interoperate whether or not the name is
declared on their side of the wire. So this commit is a pure readability
/ discoverability win; no generated code or runtime behavior changes
until a follow-up wires a processor component to actually emit the new
tag.

Affected enums:
  * `DDSketchEncoding`        — adds MSGPACK=3, MSGPACK_DELTA=4
  * `KLLSketchEncoding`       — adds MSGPACK=3, MSGPACK_DELTA=4 (reserved;
                                sketchlib-go KLL doesn't share a byte-level
                                backend with ASAPQuery-backend's KLL, so
                                MSGPACK is not usable for KLL today — use
                                PROTO instead)
  * `CountSketchEncoding`     — adds MSGPACK=3, MSGPACK_DELTA=4
  * `CountMinSketchEncoding`  — adds MSGPACK=3, MSGPACK_DELTA=4
  * `HLLSketchEncoding`       — adds MSGPACK=3, MSGPACK_DELTA=4

No changes to .proto message shapes, no reserved-range shifts, no
impact on existing `_PROTO = 1` / `_PROTO_DELTA = 2` / `_DELTA = 2`
producers and consumers.

The generated `.pb.go` files and the opentelemetry-proto submodule
checkout are NOT committed — they're regenerated from this patch file
via `restore_otel_proto_patches.sh` at build time.

Follow-ups:
  * `*processor` components in DataCollector that today emit only
    `_ENCODING_PROTO` can gain a config option to emit `_ENCODING_MSGPACK`
    instead, calling sketchlib-go's `wire/asapmsgpack.Marshal*` on the
    sketch state
  * `_DELTA` variants still deferred until sketch-core grows an
    `apply_delta` API — these enum entries exist only to reserve the
    tag numbers consistently with the Rust side

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The existing `hard_cap_back_pressure_blocks_inserts_until_flusher_drains`
test asserts that at least one insert takes ≥3 ms wall-clock time when
sealed memory hits the hard cap — the intuition being that a blocked
insert must wait ≥1 flush tick for the condvar to fire. On CI (fast
runners, small test data) the flush tick completes well under 1 ms,
so the slow path runs and fully drains before the 3 ms threshold
ever trips. Result: the assertion is flaky even though back-pressure
is wired up correctly.

Replace wall-clock timing with a dedicated monotonic counter.

## Changes

* `FlusherShared` gains `back_pressure_wait_count: AtomicU64`
* `FlusherHandle::wait_for_memory_under` increments the counter
  exactly once per call that observes `mem_counter >= cap` (i.e.
  every call that enters the blocking wait loop — the fast-path
  early return does not bump the counter)
* `FlusherHandle::back_pressure_wait_count()` getter exposes it
* `SimpleMapStorePerKey::back_pressure_wait_count()` proxies through
  to the flusher, returning 0 when persistence is disabled
* The test now asserts `store.back_pressure_wait_count() > 0` after
  the 200-insert loop — a timing-independent signal

## Why a counter, not a timing threshold

Tests that assert "this path is slower than X ms" assume the path
is slow for a reason other than what it's actually measuring. Here,
`wait_for_memory_under` blocks until the flusher drains, and the
drain is fast, so the total wait can be sub-millisecond even though
the mechanism is firing exactly as designed. Timing thresholds trade
signal for noise on fast hardware. A counter sidesteps that tradeoff:
it is 0 if the mechanism never fires and positive if it fires even
once — exactly the semantic the test cares about.

## Validation

Ran 5× back-to-back: all 5 passed (previously observed flake on CI).
Full query_engine_rust --lib suite: 480 passed. Clippy + fmt clean.

Drive-by fix: inherited from main (the persistence PR #4 merge), not
introduced by this PR. Landing it here because the PR is already
touching enough persistence-adjacent surface (clippy fixes) that
CI needs to be unblocked on the full test gate, and a real fix is
cleaner than a retry loop or `#[ignore]`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zzylol added a commit that referenced this pull request Apr 14, 2026
Adds `HotReloadStreamingConfig`, a thin `arc_swap::ArcSwap` wrapper,
plus `GET/POST /api/v1/streaming-config` HTTP endpoints so an external
controller (or a test harness) can push a new `StreamingConfig` at
runtime without restarting the query engine binary.

This is **phase 1** — the narrowest useful slice. The machinery exists
end-to-end (wire format, endpoint, swap), tests pin the contract, and
the controller has a working push target. Query execution and ingest
routing do NOT yet re-snapshot per request; those are phase 2 and are
documented on `HotReloadStreamingConfig` so the boundaries are clear.

## What's new

### `asap-query-engine/src/data_model/hot_reload_config.rs`
New module with `HotReloadStreamingConfig`, cloneable, built on
`Arc<ArcSwap<StreamingConfig>>`:

  * `new(StreamingConfig)` / `from_arc(Arc<StreamingConfig>)` —
    construct from initial config (two entry points so callers don't
    have to double-allocate)
  * `snapshot() -> Arc<StreamingConfig>` — cheap, lock-free read
  * `swap(StreamingConfig) -> Arc<StreamingConfig>` — atomic replace,
    returns the old `Arc` so callers can diff added/removed agg_ids

Four unit tests pin:
  * initial snapshot reflects constructor
  * swap replaces atomically (old handle unchanged, new reads fresh)
  * clones share the underlying `ArcSwap` (so server-held handle and
    test-held handle see the same swaps)
  * concurrent reader never observes a torn state while a writer
    swaps in a loop

### `GET / POST /api/v1/streaming-config` in `http.rs`
Two new routes on the existing control-plane surface, next to
`/api/v1/precompute`, `/api/v1/health`, `/api/v1/store/metrics`:

  * **GET** — return the currently active config as JSON
    (`{status, aggregation_count, aggregation_ids,
    streaming_config}`). Used by tests and operators to verify a
    push landed.

  * **POST** — accept a YAML body matching the existing
    `StreamingConfig::from_yaml_data` shape (the same format the
    binary loads at startup), parse, validate, and atomically swap
    via `HotReloadStreamingConfig::swap`. Returns `{status,
    agg_ids_added, agg_ids_removed, new_aggregation_count}` so the
    caller can confirm the transition without a round-trip to GET.
    Logs a warning if any agg_ids were removed, since in-flight
    precompute worker groups for those ids continue with their
    construction-time config until they close naturally (phase 1
    limitation).

Both endpoints return `503 Service Unavailable` when the server was
constructed without `HttpServer::with_hot_reload_config(...)` — a
clear failure mode for misconfigured deployments.

### `HttpServer::with_hot_reload_config(handle)` builder
New opt-in builder method on `HttpServer`. Without it, the endpoints
return 503 — so existing code paths (unit tests, legacy binaries) are
unaffected by this PR. `main.rs` calls it to attach the production
handle.

### `main.rs` wiring
Constructs a single `HotReloadStreamingConfig` from the startup
`Arc<StreamingConfig>` and passes it to `HttpServer::with_hot_reload_config`.
Other consumers (SimpleEngine, PrecomputeEngine, SimpleMapStore,
KafkaConsumer) still receive the startup `Arc<StreamingConfig>` and
will ignore swaps until phase 2.

## What's NOT hot-reloaded yet (documented on the module)

  * **SimpleEngine query execution** — holds a startup snapshot,
    doesn't re-snapshot per query. A query landing after a swap
    sees the old config. Phase 2 will change this to re-snapshot on
    query entry (one `Arc::clone` per query, negligible cost).

  * **Ingest router / OTLP receiver** — routes by startup
    `AggregationConfig` clones. New metric/labels mapped to new
    agg_ids after a swap would not be routed until phase 2 wires
    the router to re-snapshot per incoming message.

  * **In-flight precompute worker `GroupState`** — each
    `GroupState` caches `Arc<AggregationConfig>` at group creation.
    Existing groups complete with their original config (correct
    semantics, not a bug). New groups created after a swap use the
    new config. This is the intended behavior for graceful add and
    for param-tuning that only matters for future windows. The
    only case where phase 1 is visibly incomplete is **removing**
    an agg_id mid-window — the existing groups for that id keep
    ingesting until their tumbling window closes. The POST handler
    logs a warning when this happens.

## Drive-by fixes

Same two clippy-on-rust-1.91 issues in persistence code that
PR #9 also fixes:
  * `persistence/cache.rs` — unnecessary `u64 as u64` cast
  * `persistence/part.rs` — test `&[snap.clone()]` → `std::slice::from_ref(&snap)`

Inherited from main (persistence PR #4); fixed here so CI's clippy
gate passes on this branch too. These will be harmless duplicates if
PR #9 lands first.

## Tests

  * **Unit tests** (4, in `hot_reload_config.rs`) — snapshot /
    swap / clone-sharing / concurrent race.

  * **HTTP integration tests** (3, in `http.rs::tests`):
    - `test_streaming_config_hot_reload_round_trip`: starts a test
      server with a hot-reload handle, POSTs a 2-agg YAML config,
      asserts the POST response (agg_ids_added, new count), then
      GETs and verifies the count + ids. Also asserts that the
      externally-held `HotReloadStreamingConfig::snapshot()` sees
      the swap, confirming the ArcSwap is shared.
    - `test_streaming_config_hot_reload_missing_handle_503`: no
      handle attached, both GET and POST return 503.
    - `test_streaming_config_hot_reload_rejects_bad_yaml`: POST
      with garbage YAML returns 400 + error message.

## Validation

  * cargo check --all-targets: clean
  * cargo clippy --all-targets -- -D warnings: clean
  * cargo fmt --check: clean
  * cargo test -p query_engine_rust --lib: 481 passed

## Follow-ups

  * **Phase 2**: re-snapshot per query in `SimpleEngine`, per message
    in the ingest router. Will likely change `SimpleEngine`'s field
    from `Arc<StreamingConfig>` to `Arc<HotReloadStreamingConfig>`
    and add a helper that snapshots at query entry.
  * **Controller → backend channel**: once PR E's endpoint is merged,
    the DataCollector controller can call it from its replanner
    (`controller/src/replan.rs`) as a new transport alongside the
    existing OpAMP push to agents. Tracked independently.
  * **Graceful drain on removal**: PR E currently warns but does not
    block config reloads that remove in-flight agg_ids. A stronger
    guarantee would explicitly drain affected groups before
    applying the swap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zzylol added a commit that referenced this pull request Apr 14, 2026
When SimpleEngine fails to match a query against any stored
aggregation — `StreamingConfig::find_compatible_aggregation` returns
`None` — the query plane now fires a fire-and-forget notification to
the DataCollector controller so the controller can generate a new
sketch plan and push it back via PR E's `/api/v1/streaming-config`
endpoint.

The query itself is **not** retried. It continues to fall through to
the existing §5.2 fallback (direct Prometheus read, SQL forwarding,
etc.) and returns whatever the fallback provides. Future queries
benefit once the new plan lands. Wiring the query retry into the plan
generation latency is explicitly out of scope — the fire-and-forget
model sidesteps the coordination problem and keeps the §5.2 fallback
as the correctness anchor.

## New module: `drivers/query/controller_client`

A thin transport-agnostic abstraction:

  ```rust
  #[async_trait]
  pub trait ControllerClient: Send + Sync {
      async fn notify_capability_miss(
          &self,
          requirements: &QueryRequirements,
      ) -> Result<(), String>;
  }
  ```

Plus:

  * `HttpControllerClient` — HTTP impl with a 5-second timeout so a
    slow or unreachable controller can't stall the spawned task.
    POSTs a flat JSON payload projecting `QueryRequirements` into
    serde-friendly fields (metric, statistics as `Debug` strings,
    data_range_ms, grouping_labels as `Vec<String>`,
    spatial_filter_normalized). Using a flat DTO avoids adding
    `Serialize` derives to shared `asap_types` / `promql_utilities`
    crates.

  * `spawn_capability_miss_notify(&Option<Arc<dyn ControllerClient>>,
    &QueryRequirements)` — fire-and-forget helper used by the query
    hot path. Spawns via `tokio::spawn` so the query return path is
    never blocked on network I/O. No-op when the option is `None`.
    Errors are logged at WARN level — capability misses must never
    fail the query.

## SimpleEngine wiring

  * New field `controller_client: Option<Arc<dyn ControllerClient>>`
  * New builder method `with_controller_client(client)` —
    unset by default, so existing code paths (unit tests, callers
    that don't configure a controller) are behaviorally unchanged.
  * New helper `find_compatible_aggregation_with_miss_notify` —
    wraps `streaming_config.find_compatible_aggregation` with the
    fire-and-forget notification on `None`.
  * Three capability-miss call sites updated to use the helper:
      - SQL temporal/spatial  (line ~1980)
      - SQL spatio-temporal   (line ~2130)
      - PromQL                (line ~2755)

## main.rs

New CLI flag:

  ```
  --controller-endpoint <URL>
      DataCollector controller endpoint for capability-miss
      notifications. When set, SimpleEngine fires a fire-and-forget
      POST on every miss. When unset (default), misses fall through
      to the §5.2 fallback silently.
  ```

When set, main.rs constructs an `HttpControllerClient` and attaches
it via `SimpleEngine::with_controller_client(...)`.

## Tests

Five new unit tests in `controller_client.rs`, covering:

  1. `payload_projects_all_requirement_fields` — DTO conversion from
     `QueryRequirements` round-trips through JSON correctly
  2. `spawn_helper_is_noop_when_client_is_none` — fire-and-forget
     helper is safe to call with `None`
  3. `spawn_helper_invokes_client_via_tokio_spawn` — with a mock
     client, the spawned task actually fires and is observable via
     a shared counter (exercises the fire-and-forget path end-to-end)
  4. `http_client_reports_non_success_status` — real axum mock
     server returning 500, asserts the client maps the status to
     a formatted `Err`
  5. `http_client_success_path_round_trips_payload` — real axum
     mock server, client POSTs a capability-miss notification, the
     server parses the JSON body and asserts the fields match the
     source `QueryRequirements`

A public `MockControllerClient` is also exported from the test
module so follow-up tests in other crates (or other modules) can
compose a SimpleEngine with a fake client without needing an HTTP
mock.

## Drive-by

Same two clippy-on-rust-1.91 issues in persistence code that PR #9
and PR E also fix:

  * `persistence/cache.rs` — unnecessary `u64 as u64` cast
  * `persistence/part.rs` — test `&[snap.clone()]` →
    `std::slice::from_ref(&snap)`

Inherited from main; fixed here so CI's clippy gate passes on this
branch too. Harmless duplicate when PR #9 lands first.

## Validation

  * cargo check --all-targets: clean
  * cargo clippy --all-targets -- -D warnings: clean
  * cargo fmt --check: clean
  * cargo test -p query_engine_rust --lib: 479 passed (up 5 from
    main — the new unit tests)

## Follow-up

  * **Query retry** — wire an optional retry after the controller
    plan lands and the backend's StreamingConfig reflects the new
    plan. Needs PR E phase 2 (hot-reload picked up by SimpleEngine
    at query time) to be useful. Retry-once semantics, bounded wait,
    with a clear fallback to the §5.2 path if the plan never lands.
  * **Deduplication / rate-limiting** — the current fire-and-forget
    spawns one notification per miss. Under heavy miss load on the
    same `QueryRequirements`, the controller may be asked to plan
    repeatedly. A simple LRU + "seen in last N seconds" filter in
    `HttpControllerClient` would prevent this.
  * **Retry telemetry** — track (miss → notify → plan landed) latency
    across queries so we can actually tell if the loop closes fast
    enough to matter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zzylol
zzylol merged commit 94b57a6 into main Apr 15, 2026
8 of 9 checks passed
@zzylol
zzylol deleted the feat/msgpack-encoding-parity branch April 15, 2026 16:44
zzylol added a commit that referenced this pull request Apr 15, 2026
Adds `HotReloadStreamingConfig`, a thin `arc_swap::ArcSwap` wrapper,
plus `GET/POST /api/v1/streaming-config` HTTP endpoints so an external
controller (or a test harness) can push a new `StreamingConfig` at
runtime without restarting the query engine binary.

This is **phase 1** — the narrowest useful slice. The machinery exists
end-to-end (wire format, endpoint, swap), tests pin the contract, and
the controller has a working push target. Query execution and ingest
routing do NOT yet re-snapshot per request; those are phase 2 and are
documented on `HotReloadStreamingConfig` so the boundaries are clear.

## What's new

### `asap-query-engine/src/data_model/hot_reload_config.rs`
New module with `HotReloadStreamingConfig`, cloneable, built on
`Arc<ArcSwap<StreamingConfig>>`:

  * `new(StreamingConfig)` / `from_arc(Arc<StreamingConfig>)` —
    construct from initial config (two entry points so callers don't
    have to double-allocate)
  * `snapshot() -> Arc<StreamingConfig>` — cheap, lock-free read
  * `swap(StreamingConfig) -> Arc<StreamingConfig>` — atomic replace,
    returns the old `Arc` so callers can diff added/removed agg_ids

Four unit tests pin:
  * initial snapshot reflects constructor
  * swap replaces atomically (old handle unchanged, new reads fresh)
  * clones share the underlying `ArcSwap` (so server-held handle and
    test-held handle see the same swaps)
  * concurrent reader never observes a torn state while a writer
    swaps in a loop

### `GET / POST /api/v1/streaming-config` in `http.rs`
Two new routes on the existing control-plane surface, next to
`/api/v1/precompute`, `/api/v1/health`, `/api/v1/store/metrics`:

  * **GET** — return the currently active config as JSON
    (`{status, aggregation_count, aggregation_ids,
    streaming_config}`). Used by tests and operators to verify a
    push landed.

  * **POST** — accept a YAML body matching the existing
    `StreamingConfig::from_yaml_data` shape (the same format the
    binary loads at startup), parse, validate, and atomically swap
    via `HotReloadStreamingConfig::swap`. Returns `{status,
    agg_ids_added, agg_ids_removed, new_aggregation_count}` so the
    caller can confirm the transition without a round-trip to GET.
    Logs a warning if any agg_ids were removed, since in-flight
    precompute worker groups for those ids continue with their
    construction-time config until they close naturally (phase 1
    limitation).

Both endpoints return `503 Service Unavailable` when the server was
constructed without `HttpServer::with_hot_reload_config(...)` — a
clear failure mode for misconfigured deployments.

### `HttpServer::with_hot_reload_config(handle)` builder
New opt-in builder method on `HttpServer`. Without it, the endpoints
return 503 — so existing code paths (unit tests, legacy binaries) are
unaffected by this PR. `main.rs` calls it to attach the production
handle.

### `main.rs` wiring
Constructs a single `HotReloadStreamingConfig` from the startup
`Arc<StreamingConfig>` and passes it to `HttpServer::with_hot_reload_config`.
Other consumers (SimpleEngine, PrecomputeEngine, SimpleMapStore,
KafkaConsumer) still receive the startup `Arc<StreamingConfig>` and
will ignore swaps until phase 2.

## What's NOT hot-reloaded yet (documented on the module)

  * **SimpleEngine query execution** — holds a startup snapshot,
    doesn't re-snapshot per query. A query landing after a swap
    sees the old config. Phase 2 will change this to re-snapshot on
    query entry (one `Arc::clone` per query, negligible cost).

  * **Ingest router / OTLP receiver** — routes by startup
    `AggregationConfig` clones. New metric/labels mapped to new
    agg_ids after a swap would not be routed until phase 2 wires
    the router to re-snapshot per incoming message.

  * **In-flight precompute worker `GroupState`** — each
    `GroupState` caches `Arc<AggregationConfig>` at group creation.
    Existing groups complete with their original config (correct
    semantics, not a bug). New groups created after a swap use the
    new config. This is the intended behavior for graceful add and
    for param-tuning that only matters for future windows. The
    only case where phase 1 is visibly incomplete is **removing**
    an agg_id mid-window — the existing groups for that id keep
    ingesting until their tumbling window closes. The POST handler
    logs a warning when this happens.

## Drive-by fixes

Same two clippy-on-rust-1.91 issues in persistence code that
PR #9 also fixes:
  * `persistence/cache.rs` — unnecessary `u64 as u64` cast
  * `persistence/part.rs` — test `&[snap.clone()]` → `std::slice::from_ref(&snap)`

Inherited from main (persistence PR #4); fixed here so CI's clippy
gate passes on this branch too. These will be harmless duplicates if
PR #9 lands first.

## Tests

  * **Unit tests** (4, in `hot_reload_config.rs`) — snapshot /
    swap / clone-sharing / concurrent race.

  * **HTTP integration tests** (3, in `http.rs::tests`):
    - `test_streaming_config_hot_reload_round_trip`: starts a test
      server with a hot-reload handle, POSTs a 2-agg YAML config,
      asserts the POST response (agg_ids_added, new count), then
      GETs and verifies the count + ids. Also asserts that the
      externally-held `HotReloadStreamingConfig::snapshot()` sees
      the swap, confirming the ArcSwap is shared.
    - `test_streaming_config_hot_reload_missing_handle_503`: no
      handle attached, both GET and POST return 503.
    - `test_streaming_config_hot_reload_rejects_bad_yaml`: POST
      with garbage YAML returns 400 + error message.

## Validation

  * cargo check --all-targets: clean
  * cargo clippy --all-targets -- -D warnings: clean
  * cargo fmt --check: clean
  * cargo test -p query_engine_rust --lib: 481 passed

## Follow-ups

  * **Phase 2**: re-snapshot per query in `SimpleEngine`, per message
    in the ingest router. Will likely change `SimpleEngine`'s field
    from `Arc<StreamingConfig>` to `Arc<HotReloadStreamingConfig>`
    and add a helper that snapshots at query entry.
  * **Controller → backend channel**: once PR E's endpoint is merged,
    the DataCollector controller can call it from its replanner
    (`controller/src/replan.rs`) as a new transport alongside the
    existing OpAMP push to agents. Tracked independently.
  * **Graceful drain on removal**: PR E currently warns but does not
    block config reloads that remove in-flight agg_ids. A stronger
    guarantee would explicitly drain affected groups before
    applying the swap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zzylol added a commit that referenced this pull request Apr 15, 2026
When SimpleEngine fails to match a query against any stored
aggregation — `StreamingConfig::find_compatible_aggregation` returns
`None` — the query plane now fires a fire-and-forget notification to
the DataCollector controller so the controller can generate a new
sketch plan and push it back via PR E's `/api/v1/streaming-config`
endpoint.

The query itself is **not** retried. It continues to fall through to
the existing §5.2 fallback (direct Prometheus read, SQL forwarding,
etc.) and returns whatever the fallback provides. Future queries
benefit once the new plan lands. Wiring the query retry into the plan
generation latency is explicitly out of scope — the fire-and-forget
model sidesteps the coordination problem and keeps the §5.2 fallback
as the correctness anchor.

## New module: `drivers/query/controller_client`

A thin transport-agnostic abstraction:

  ```rust
  #[async_trait]
  pub trait ControllerClient: Send + Sync {
      async fn notify_capability_miss(
          &self,
          requirements: &QueryRequirements,
      ) -> Result<(), String>;
  }
  ```

Plus:

  * `HttpControllerClient` — HTTP impl with a 5-second timeout so a
    slow or unreachable controller can't stall the spawned task.
    POSTs a flat JSON payload projecting `QueryRequirements` into
    serde-friendly fields (metric, statistics as `Debug` strings,
    data_range_ms, grouping_labels as `Vec<String>`,
    spatial_filter_normalized). Using a flat DTO avoids adding
    `Serialize` derives to shared `asap_types` / `promql_utilities`
    crates.

  * `spawn_capability_miss_notify(&Option<Arc<dyn ControllerClient>>,
    &QueryRequirements)` — fire-and-forget helper used by the query
    hot path. Spawns via `tokio::spawn` so the query return path is
    never blocked on network I/O. No-op when the option is `None`.
    Errors are logged at WARN level — capability misses must never
    fail the query.

## SimpleEngine wiring

  * New field `controller_client: Option<Arc<dyn ControllerClient>>`
  * New builder method `with_controller_client(client)` —
    unset by default, so existing code paths (unit tests, callers
    that don't configure a controller) are behaviorally unchanged.
  * New helper `find_compatible_aggregation_with_miss_notify` —
    wraps `streaming_config.find_compatible_aggregation` with the
    fire-and-forget notification on `None`.
  * Three capability-miss call sites updated to use the helper:
      - SQL temporal/spatial  (line ~1980)
      - SQL spatio-temporal   (line ~2130)
      - PromQL                (line ~2755)

## main.rs

New CLI flag:

  ```
  --controller-endpoint <URL>
      DataCollector controller endpoint for capability-miss
      notifications. When set, SimpleEngine fires a fire-and-forget
      POST on every miss. When unset (default), misses fall through
      to the §5.2 fallback silently.
  ```

When set, main.rs constructs an `HttpControllerClient` and attaches
it via `SimpleEngine::with_controller_client(...)`.

## Tests

Five new unit tests in `controller_client.rs`, covering:

  1. `payload_projects_all_requirement_fields` — DTO conversion from
     `QueryRequirements` round-trips through JSON correctly
  2. `spawn_helper_is_noop_when_client_is_none` — fire-and-forget
     helper is safe to call with `None`
  3. `spawn_helper_invokes_client_via_tokio_spawn` — with a mock
     client, the spawned task actually fires and is observable via
     a shared counter (exercises the fire-and-forget path end-to-end)
  4. `http_client_reports_non_success_status` — real axum mock
     server returning 500, asserts the client maps the status to
     a formatted `Err`
  5. `http_client_success_path_round_trips_payload` — real axum
     mock server, client POSTs a capability-miss notification, the
     server parses the JSON body and asserts the fields match the
     source `QueryRequirements`

A public `MockControllerClient` is also exported from the test
module so follow-up tests in other crates (or other modules) can
compose a SimpleEngine with a fake client without needing an HTTP
mock.

## Drive-by

Same two clippy-on-rust-1.91 issues in persistence code that PR #9
and PR E also fix:

  * `persistence/cache.rs` — unnecessary `u64 as u64` cast
  * `persistence/part.rs` — test `&[snap.clone()]` →
    `std::slice::from_ref(&snap)`

Inherited from main; fixed here so CI's clippy gate passes on this
branch too. Harmless duplicate when PR #9 lands first.

## Validation

  * cargo check --all-targets: clean
  * cargo clippy --all-targets -- -D warnings: clean
  * cargo fmt --check: clean
  * cargo test -p query_engine_rust --lib: 479 passed (up 5 from
    main — the new unit tests)

## Follow-up

  * **Query retry** — wire an optional retry after the controller
    plan lands and the backend's StreamingConfig reflects the new
    plan. Needs PR E phase 2 (hot-reload picked up by SimpleEngine
    at query time) to be useful. Retry-once semantics, bounded wait,
    with a clear fallback to the §5.2 path if the plan never lands.
  * **Deduplication / rate-limiting** — the current fire-and-forget
    spawns one notification per miss. Under heavy miss load on the
    same `QueryRequirements`, the controller may be asked to plan
    repeatedly. A simple LRU + "seen in last N seconds" filter in
    `HttpControllerClient` would prevent this.
  * **Retry telemetry** — track (miss → notify → plan landed) latency
    across queries so we can actually tell if the loop closes fast
    enough to matter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zzylol added a commit that referenced this pull request May 11, 2026
… + typed Predicate (#136)

Additive lift of all 10 A-classified legacy variants into the canonical
`intent_algebra::query_expr::QueryExpr`, plus the minimal typed
`Predicate` IR for `Filter.pred` covering the 4 used-and-cleanly-shaped
ScalarExpr variants.

## What landed

### Canonical `QueryExpr` grew from 5 → 15 variants

Pre-existing (PR #130 / canonical): `Scan`, `Window`, `Aggregate`,
`LetBinding`, `Ref`.

New (this PR): `Filter`, `Project`, `Partition`, `Distinct`, `Merge`,
`Join`, `SetOp`, `Sort`, `Limit`, `BinaryOp`.

Naming convention: single-input variants use `child:` (matches existing
canonical `Window`/`Aggregate`/`LetBinding`). Multi-input variants
follow design.md §6 shapes exactly: `Merge { children }`,
`Join { kind, pred, left, right }`, `SetOp { kind, all, left, right }`,
`BinaryOp { op, lhs, rhs, vector_match }`.

### Supporting types lifted alongside

`ColumnRef`, `PartitionKeys`, `BinaryOpKind`, `JoinKind`, `SetOpKind`,
`SortKey`, `VectorMatch`/`VectorMatchKind`/`VectorGrouping`/`GroupSide`,
`LiteralValue`, `ProjectItem`. All re-exported from
`intent_algebra::mod.rs`.

### New typed `Predicate`

```rust
pub enum Predicate {
    Column(ColumnRef),
    Literal(LiteralValue),
    BinaryOp { op: BinaryOpKind, lhs: Box<Predicate>, rhs: Box<Predicate> },
    IsNull { expr: Box<Predicate>, negated: bool },
}
```

Plus `Predicate::from_legacy_scalar(&legacy::ScalarExpr) ->
Result<Predicate, QueryExprError>`. Translates the 4 supported
variants (Column / Literal / BinaryOp / IsNull); returns
`QueryExprError::UnsupportedLegacyScalar(name)` for the 4 E-deferred
(FunctionCall / ScalarSubquery / InList / Between). Per user decision,
those stay in legacy until a real consumer demands the typed shape.

Translation handles `LiteralValue::Duration → Int(nanos)` fold (canonical
LiteralValue is deliberately narrower).

### Consumer migration deferred to subsequent batches

**Zero consumer-site redirects in this PR** — and that's the right
call. Every legacy-A-variant consumer (`query_parser/`, `physical/
{stage_split,planner,allocator}.rs`, `optimizer/engine.rs`,
`legacy_lower.rs`) simultaneously matches A-variants AND C-variants
(`Source`, `Aggregate`, `Window`, `SketchAgg`, `WindowedAgg`, `TopK`,
`HistogramQuantile`, `PromQLSubquery`) AND constructs `ScalarExpr`
predicates with E-deferred variants. Migrating them mid-pipeline would
require lifting C-variants and E-variants in the same PR — explicit
out-of-scope per the batch plan.

The consumers migrate one C-batch at a time:
- Batch 3a (PR #11): `AggFunc → AggIntent`
- Batch 3b (PR #10): `TopK` split
- Batch 3c (PR #8): `WindowedAgg` un-fusion
- Batch 3d (PR #9): `SketchAgg → Aggregate@L3`
- Batch 3e (PR #12): PromQL parser `histogram_quantile → Quantile`
- Batch 13: retire `legacy_expr.rs` + `legacy_lower.rs`

### Catch-all arms added in 6 canonical-side consumers

Files: `optimizer/cost/mod.rs`, `physical/colored_dag/{allocator,emitter}.rs`,
`sketch_algebra/lower.rs`, `warm_tier_analysis.rs`. These previously
matched canonical `QueryExpr` exhaustively over the 5 pre-existing
variants; now they need conservative handling for the 10 new ones
(Edge stage / Logical wrap / child-walk / zero-cost) with TODO
markers for the follow-up reshape batches.

### Serde tag note

Initially gave `ColumnRef`/`LiteralValue`/`PartitionKeys`/`Predicate`
internally-tagged `#[serde(tag = "kind", rename_all = "snake_case")]`
attrs, but serde rejects internally-tagged newtype variants containing
primitives (e.g. `Named(String)`). Switched to externally-tagged
`#[serde(rename_all = "snake_case")]`. Caught by the round-trip test.

## Build + test

- `cargo build --release -p controller` — clean
- `cargo build --release -p query_engine_rust` — clean
- `cargo test -p controller --lib` — **666 passed** (was 655 after
  Batch 1; +11 from new typed-Predicate + schema tests)
- `cargo test -p query_engine_rust --lib -- engines::warm_tier` — 13/13

## Diff: 8 files, +794 / -8

Co-authored-by: zz_y <zz_y@node0.zz-y-304941.softmeasure-pg0.clemson.cloudlab.us>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant