Skip to content

otap: real plugin integration + Arrow IPC network transport - #6

Merged
zzylol merged 7 commits into
mainfrom
feat/otap-plugin-and-network-transport
Aug 24, 2026
Merged

otap: real plugin integration + Arrow IPC network transport#6
zzylol merged 7 commits into
mainfrom
feat/otap-plugin-and-network-transport

Conversation

@zzylol

@zzylol zzylol commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Why

PR #5 proved the SCHEMA/DICTIONARY/RECORD codec end-to-end, but only in-process, in-memory — not through a real transport, and not through OTAP's actual plugin lifecycle or engine. For ASAP's near-term MVP (sketch creation/merging/estimation with control-plane configs, running as real asap_sketches dataflow processors) two gaps had to close before that's more than a demo:

  1. Sketch state has to actually survive a real serialize → network → deserialize hop between nodes, not just a function call.
  2. The processor has to be a real OTAP component — ingesting genuine OtapPdata metric traffic and emitting real OTAP metric traffic back out — not a Message::PData pass-through. This is the "self-describing sketch binary living inside an OTAP metric" seam the repo's own docs and README flagged as the one thing left to wire (Phase D/E).

Everything on this branch is in service of closing those two gaps, plus the dependency/correctness cleanup that surfaced along the way.

What

  • otap::wire: Arrow-IPC framing + async send_stream_batch/recv_stream_batch over a real TcpStream, so a SketchStreamBatch can actually cross a socket.
  • AsapSketchesPlugin::start_from_envelopes: the receiver-role counterpart to start() — consumes an upstream node's SketchStreamBatch stream, merges via observe_envelope, and (in transmit_sketch = false estimate mode) re-emits quantile/cardinality gauges — so producer/receiver plugin instances compose into a real pipeline.
  • examples/sketch_producer_node.rs + sketch_receiver_node.rs: two real binaries proving the above over an actual TCP socket (examples/sketch_pipeline_demo.rs remains the fast in-process version).
  • otap-patch/all/otap_bridge.rs (new): the actual OtapPdata ↔ OtapMetricRecords binding — real OTLP metrics in, sketch/estimate output back out, both as ordinary OTAP metric traffic. AsapSketchesProcessor (otap-patch/all/mod.rs) is rewritten from a pass-through into a real processor built on this.
  • Correctness fixes (full list in commit 8bb5e86/0032b2a): a series-identity collision in the dictionary codec, a decoder state-corruption bug on a resent DICTIONARY row, a wrong hash-seed lookup for matrix-family sketches, an unvalidated cast that could silently fabricate a wrong wire value, a shutdown race that could drop the last buffered batch, silently-swallowed decode errors, and two u32-cast / unbounded-allocation issues in the wire codec.
  • Dependency sync: asap_sketchlib bumped to latest (010457d); Arrow bumped 53 → 58.3 to match the OTAP Dataflow workspace's own pin (required so RecordBatch is the literal same type across the otap-patch/ boundary).

How

The two pieces of actual design/algorithm worth calling out:

  • Dictionary economics over a real wire. otap::wire frames each SketchStreamBatch's four sibling RecordBatches as independent one-shot Arrow IPC streams behind a length-prefixed frame. Combined with SeriesDictionary/SeriesDictionaryDecoder (PR otap: implement Schema/Dictionary/Record codec from data_model.md #5), a live socket shows the economics working for real: window 0 pays the full SCHEMA+DICTIONARY+LABELS cost, every later window on the same connection pays only RECORD (see Output below).

  • The OTAP metric binding without hand-rolling Arrow. Rather than driving OTAP's low-level Arrow-batch builders directly (intricate dictionary-encoding rules, easy to get subtly wrong), otap_bridge.rs implements upstream's own MetricsView trait family as a thin adapter over ASAP's already-existing flat OtapMetricRecords, then calls upstream's own encode_metrics_otap_batch() to build a real OtapArrowRecords::Metrics — pushing all the low-level correctness onto already-tested upstream code. On decode, a sketch shipped as binary inside an OTAP metric (_asap_envelope attribute) needs no special-casing at all: it round-trips into OtapMetricRecords like any other attribute, and Precompute::observe already routes ObservationValueKind::Envelope observations to observe_envelope (merge, not expand) automatically — content-based routing that falls out of the existing design rather than new branching. The processor also turned out not to need AsapSketchesPlugin's own Tokio-task lifecycle at all: since PR otap: implement Schema/Dictionary/Record codec from data_model.md #5/otap: real plugin integration + Arrow IPC network transport #6's dictionary-economics work changed that lifecycle's emit shape to SketchStreamBatch (not OtapMetricRecords), the adapter instead drives a bare Precompute directly from OTAP's native per-message/per-timer process() callbacks (effect_handler.start_periodic_timerNodeControlMsg::TimerTick), which fits a callback-style observe/tick/drain API better than bridging a Stream would have.

Verification note: otap-patch/ has no standalone build in this repo (no OTAP Dataflow workspace checkout here). Every upstream type/signature otap_bridge.rs uses was read directly from open-telemetry/otel-arrow@3e85c346 (2026-08-24) — not compiled against. The Arrow-only construction logic was extracted and compile+test-verified in isolation against the real arrow-array 58.4.0 crate; the OTAP-specific view-trait implementation could not be. Expect a real build pass against the actual pinned OTAP commit to surface some mismatches.

Output

cargo test --features otap — 156/156 passing:

test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

cargo run --example sketch_pipeline_demo --features otap — real dictionary economics (window 0 pays full cost, later windows pay RECORD only) feeding a real merge → p99 estimate:

[producer] window 0: schema=1 dictionary=1 labels=1 record=1 row(s)
[receiver] decoded 1 envelope(s) from the batch
http_request_duration_ms_p99{path="/api",quantile="0.99"} 34.12798334547728 1787590730000
[producer] window 1: schema=0 dictionary=0 labels=0 record=1 row(s)
[receiver] decoded 1 envelope(s) from the batch
http_request_duration_ms_p99{path="/api",quantile="0.99"} 41.6842908996314 1787590740000
[producer] window 2: schema=0 dictionary=0 labels=0 record=1 row(s)
http_request_duration_ms_p99{path="/api",quantile="0.99"} 49.905456284265675 1787590750000
[producer] window 3: schema=0 dictionary=0 labels=0 record=1 row(s)
http_request_duration_ms_p99{path="/api",quantile="0.99"} 58.56490783547649 1787590760000

🤖 Generated with Claude Code

Stacked on feat/schema-dictionary-record-codec, which implemented the
Schema/Dictionary/Record codec (SeriesDictionary/SeriesDictionaryDecoder)
but only proved it end-to-end via direct calls in one process, in-memory
-- not through the actual OTAP plugin lifecycle, and not across a real
serialize/transmit/deserialize hop.

This PR closes both gaps:

otap::wire (new): Arrow-IPC serializes a SketchStreamBatch's four
RecordBatches into a length-prefixed frame, plus async send_stream_batch/
recv_stream_batch over a TcpStream. Each sub-batch is its own
self-contained IPC stream (schema + one record batch + EOS); recv_stream_batch
distinguishes a clean EOF between frames from a truncated one mid-frame.

AsapSketchesPlugin::start_from_envelopes (new): the receiver-role
counterpart to the existing producer-role start(). Consumes
Stream<Item = SketchStreamBatch> instead of Stream<Item = OtapMetricRecords>,
decodes via a persistent SeriesDictionaryDecoder, and routes reconstructed
envelopes through Precompute::observe_envelope (merge, never expand to
samples) -- reusing the same ticker/control-task/graceful-drain machinery
as the producer role via a new shared spawn_lifecycle helper. A receiver
configured with transmit_sketch=false naturally re-emits query-mode
(quantile) estimates instead of sketch bytes through its own emit channel,
so a chain of AsapSketchesPlugins can compose without any new machinery.

examples/sketch_producer_node.rs + sketch_receiver_node.rs (new): two
separate binaries -- real AsapSketchesPlugin producer and receiver roles,
connected over a real TCP socket via otap::wire, not the in-process mpsc
channel sketch_pipeline_demo.rs uses. The producer feeds a real OTAP-shaped
input stream (records::flatten + decode_batch, not a direct observe()
call) and lets the plugin's actual Wakeup-style Tokio ticker close windows
on its own wall-clock schedule. Verified running both together: producer
emits 5 windows (window 0 carries SCHEMA+DICTIONARY+LABELS, windows 1-4
carry RECORD only), receiver receives and decodes all 5 over the socket,
merges them, and prints a correct p99 gauge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
zzylol added 6 commits August 24, 2026 09:24
Bumps the pinned asap_sketchlib git rev from 0a2ac37 (2026-07-13) to
010457d (2026-08-22) — 48 commits, including ASAPv1 wire-format work
(KLL payload + compaction seed, CMS/HLL hardening, Count-Min
metadata layout), the CMS RegularPath i32::MAX clamp fix, and
UnivMon-Q additions.

Verified: `cargo build`/`cargo build --features otap`, `cargo test
--features otap` (156/156 passing), `cargo clippy --all-targets
--features otap -- -D warnings`, and `cargo fmt --check` are all
clean against the new rev — no API breakage surfaced.
Fixes surfaced by code review of #5 (feat/schema-dictionary-record-codec).
Files here originate on that branch — cherry-pick onto it if the fix
should land in #5 itself rather than only on this stacked branch.

- dictionary.rs: SeriesDictionary::identity_key joined agg_id/metric/
  labels with unescaped '|'/'='/';' delimiters, so two genuinely
  different label sets could collide onto the same series_id (e.g.
  {"a": "1;b=2"} vs. {"a": "1", "b": "2"}). Now length-prefixes each
  segment, which makes every segment boundary unambiguous regardless
  of its contents.

- dictionary.rs: SeriesDictionaryDecoder::ingest_dictionary
  unconditionally reset a series' labels to empty on every DICTIONARY
  row, so a duplicate/replayed row (or a sender that lost its own
  dictionary state and resent it) would silently wipe out previously-
  learned labels. Now preserves existing labels via the entry API.

- dictionary.rs: resolve_hash_seed always read
  seed_list[canonical_seed_index], but asap_sketchlib's matrix-family
  sketches (CountSketch/CountMinSketch) always hash via
  HashSpec::matrix_seed() (seed_list[0]) on the packed hot path,
  regardless of canonical_seed_index. Now dispatches on sketch_type.

- config.rs: sketch_size_string cast sketch params from f64 to u64
  with `as`, silently truncating a misconfigured value (e.g. k = 0.5
  truncating to "0") into a fabricated, wrong SCHEMA.sketch_size on
  the wire. Now validates (finite, non-negative, integral) and omits
  the field instead of fabricating a value, mirroring
  resolve_hash_seed's own "omit rather than fabricate" stance.

Verified: cargo build/test (otap + default), cargo clippy -D
warnings, cargo fmt --check all clean.
Fixes surfaced by code review of #6 (feat/otap-plugin-and-network-transport).

- lifecycle.rs: spawn_input_task/spawn_envelope_input_task raced
  cancellation against reading the next stream item with an unbiased
  tokio::select!, so graceful shutdown could drop an already-ready
  batch instead of processing it (e.g. a producer enqueues its final
  batch then immediately signals shutdown). Now biased toward the
  input branch, so a ready batch is always consumed before the next
  loop iteration observes cancellation.

- lifecycle.rs: decode/encode/precompute errors in the input, ticker,
  and drain tasks were caught and completely discarded (`if let
  Err(_e) = ...` / `let _ = ...`) with zero signal, even for errors
  the codec's own design says must be loud
  (OtapDecodeError::UnknownSeriesId/UnknownAggId). Added a
  dropped_batches counter (Arc<AtomicU64>, exposed via
  AsapSketchesPlugin::dropped_batches()) so this "drop the bad batch,
  keep the plugin alive" resilience policy is at least observable
  instead of fully silent, pending Phase D's real OTAP effect-handler
  error channel.

- wire.rs: encode_stream_batch/send_stream_batch cast serialized
  lengths to u32 with `as`, so a sub-batch or frame body at/above
  4 GiB would silently wrap to a too-small length prefix, desyncing
  every subsequent frame the decoder reads on that connection. Now a
  checked u32::try_from that errors instead of truncating.

- wire.rs: recv_stream_batch allocated a buffer sized directly from
  an untrusted 4-byte length prefix with no cap, letting a hostile or
  corrupt peer force an ~4 GiB allocation attempt before a single
  content byte was validated. Added a 256 MiB MAX_FRAME_LEN sanity
  cap, checked before allocating.

Verified: cargo build/test (otap + default), cargo clippy -D
warnings, cargo fmt --check all clean.
The OTAP Dataflow workspace (rust/otap-dataflow/Cargo.toml, checked
against otel-arrow@3e85c34) pins arrow-array/-schema/-ipc at "58.3".
This crate was still on "53" — a 5-major-version skew. Since Phase D's
OtapPdata <-> OtapMetricRecords binding lives in otap-patch/ and gets
staged as a path-dependency member of the OTAP workspace itself
(per otap-patch/all/Cargo.toml's own doc), the whole build resolves
one shared Cargo.lock: RecordBatch built here and RecordBatch expected
by OTAP's engine must be the literal same arrow-array version, or the
binding is a type mismatch despite both nominally being "Arrow".

Matching the same "58.3" requirement lets Cargo's resolver unify to
one shared version automatically once staged, rather than requiring
an exact patch pin.

Verified: cargo build/test (otap + default, 156/156 passing), cargo
clippy -D warnings, cargo fmt --check all clean against arrow 58.4.0
(the version "58.3" currently resolves to).
…er role)

Implements the "one seam left" the README calls out: putting a
self-describing sketch's serialized bytes onto the wire as a real OTAP
metric, and the reverse (ingesting real OTAP metrics into the
precompute runtime). This is the producer-role half of Phase D/E;
receiver-role (ingesting another asap_sketches node's SketchStreamBatch
as OtapPdata) is a separate, larger piece not covered here.

## What changed

- otap-patch/all/otap_bridge.rs (new): the actual binding.
  - Encode (OtapMetricRecords -> OtapPdata): implements the upstream
    `MetricsView` trait family (MetricsView/ResourceMetricsView/
    ScopeMetricsView/MetricView/DataView/GaugeView/NumberDataPointView/
    AttributeView/AnyValueView, plus uninhabited-enum placeholders for
    Sum/Histogram/ExponentialHistogram/Summary/Exemplar, which this
    binding never produces) as a thin adapter over OtapMetricRecords's
    existing flat Arrow schema, then calls upstream's own
    `encode_metrics_otap_batch` to build a real `OtapArrowRecords::Metrics`
    — pushes all the low-level builder/dictionary-encoding correctness
    onto already-tested upstream code instead of hand-rolling it.
  - Decode (OtapPdata -> OtapMetricRecords): converts payload to
    `OtapArrowRecords` (transparently handles OTLP-proto-bytes or
    Arrow-record input via upstream's `TryIntoWithOptions`), walks it
    via upstream's own `OtapMetricsView` reader, and rebuilds
    OtapMetricRecords's flat 2-batch shape with plain arrow-array
    builders (the same pattern records.rs's own `flatten`/`lift` use).
  - Scope: only Gauge/Sum (NumberDataPoints) — the scalar shape
    Observation/OtapMetricRecords already assume. Histogram/
    ExponentialHistogram/Summary rows are skipped and counted
    (DecodeOutcome::skipped_non_scalar), not silently dropped.

- otap-patch/all/mod.rs: AsapSketchesProcessor rewritten from a
  pass-through into a real processor. Drives a bare `Precompute`
  instance directly (obtained via
  `AsapSketchesPlugin::from_plugin_config(...).precompute().clone()`,
  discarding the plugin wrapper) rather than through
  `AsapSketchesPlugin::start()`'s own Tokio-task/Stream lifecycle —
  that lifecycle's emit channel now carries `SketchStreamBatch` (PR
  #5/#6's dictionary-economics wire format for the asap_sketches ->
  asap_sketches transport hop), not the OTAP-Metrics-shaped
  `OtapMetricRecords` this adapter needs, so bridging it would need
  reconciling formats rather than genuinely fitting. Precompute's own
  observe/tick/drain are callback-style already, so OTAP's per-message
  `process()` + `effect_handler.start_periodic_timer` (emitting
  NodeControlMsg::TimerTick) hosts them directly with no bridging
  machinery needed. Also wires NodeControlMsg::Config ->
  Precompute::update_config (previously a no-op) — live
  reconfiguration for this one processor instance.

- otap-patch/all/Cargo.toml, otap-patch/all/mod.rs,
  otap-patch/all/otap_bridge.rs: renamed all OTAP Dataflow crate
  imports `otap_df_*` -> `otel_arrow_dfe_*`, matching upstream's very
  recent (unreleased as of the pinned commit) package rename,
  otel-arrow issue #1848 / .chloggen/otel-arrow-dfe-crate-prefix.yaml.
  Revert to `otap_df_*` throughout if the actual internal build pin
  predates that rename.

- asap-precompute-rs/{Cargo.toml,Cargo.lock} (prior commit, prerequisite
  for this one): Arrow 53 -> 58.3, matching the OTAP workspace's own
  pin so RecordBatch is the literal same type across this binding.

## Verification status — read carefully

`otap-patch/` has no standalone build in this repo (confirmed: no
OTAP Dataflow workspace checkout, no lockfile pinning one — see the
repo README's existing "No — depends on the OTAP workspace crates"
note on this directory, which predates this change). Every upstream
type/function signature referenced in otap_bridge.rs and mod.rs was
read directly from a fresh clone of open-telemetry/otel-arrow at
commit 3e85c3460361446ebfce99e9f35fffd2dd5ab740 (2026-08-24) — not
compiled against. The Arrow-only portions (RecordBatch construction,
typed-column accessors) were extracted and compile+test verified in
isolation against the real arrow-array 58.4.0 crate (round-trip test
passing) since that part doesn't depend on the OTAP-specific crates.
The OTAP-specific view-trait implementation (the biggest, riskiest
part) could not be compiled here at all.

Expect a real build pass against the actual pinned OTAP Dataflow
commit to surface mismatches — this is a first cut at the binding,
not a verified-working one. asap-precompute-rs itself (the standalone,
buildable part) is unaffected except for the Arrow version bump, and
remains fully verified: cargo build/test (156/156 passing), clippy
-D warnings, fmt --check all clean.
Per review feedback: the previous "Scope" doc framed decode handling
as type-based (Gauge/Sum handled, Histogram/ExpHistogram/Summary
skipped), which obscured the actual, more important distinction —
content-based, not type-based:

- A data point carrying `_asap_envelope` (sketch shipped as binary
  inside an OTAP metric, from this module's own encode output or any
  other asap_sketches node) needs no special casing in this module at
  all: it round-trips into OtapMetricRecords like any other attribute,
  and downstream `decode_batch` already tags it
  ObservationValueKind::Envelope; `Precompute::observe` already routes
  that internally to `observe_envelope` (merge as pre-aggregated
  sketch, never expanded to samples) — confirmed by reading
  PrecomputeImpl::observe's body (precompute.rs).
- A genuine (non-envelope) OTLP metric gets scalar-sample handling for
  Gauge/Sum; Histogram/ExponentialHistogram/Summary are still skipped
  and counted (DecodeOutcome::skipped_non_scalar) — confirmed keeping
  this as-is rather than inventing a lossy bucket/quantile expansion
  policy.

Rewrote otap_bridge.rs's module doc "Scope" section and added a
comment at the actual `precompute.observe(obs)` call site in mod.rs
(previously the dual-path behavior was implicit / only discoverable
by reading precompute.rs directly).

No functional change — the routing described was already correct;
only the documentation was misleading.

Also re-confirmed against upstream: otel-arrow's `main` is still at
commit 3e85c3460361446ebfce99e9f35fffd2dd5ab740 (re-fetched, no new
commits since the prior otap_bridge.rs commit), so the otel_arrow_dfe_*
naming and every signature referenced there remains current as of
this commit.
@zzylol
zzylol changed the base branch from feat/schema-dictionary-record-codec to main August 24, 2026 17:03
@zzylol
zzylol merged commit 831b7fe into main Aug 24, 2026
@zzylol
zzylol deleted the feat/otap-plugin-and-network-transport branch August 24, 2026 17:08
zzylol added a commit that referenced this pull request Aug 24, 2026
… already gives us dictionary economics

The direct-TCP wire lane added in the previous commit (otap_wire.rs +
otap_receiver.rs's AsapSketchesReceiver, peer_addr config) is gone.
There is now exactly one transport: effect_handler.send_message_with_source_node,
i.e. whatever pipeline this node is already wired into.

Why: the wire lane existed to give sketch traffic real dictionary/
schema-reuse economics (asap_precompute_rs::otap::dictionary's SCHEMA/
DICTIONARY/RECORD tiering, from PR #5/#6) that riding a generic OTLP
metric didn't have. But that's solving a problem OTAP's real Arrow
encoding already solves — otap_bridge's encode_metrics_otap_batch
dictionary-encodes the metric name and every string-valued attribute
key/value by construction. Verified against the real staged workspace:

  === payload_type UnivariateMetrics ===
    name : Dictionary(UInt8, Utf8)
  === payload_type NumberDpAttrs ===
    parent_id : Dictionary(UInt8, UInt32)
    key       : Dictionary(UInt8, Utf8)
    str       : Dictionary(UInt16, Utf8)

That's the same "send the dictionary once, reference it after that"
shape SeriesDictionary was reinventing at the application layer, done
instead at the columnar/Arrow-IPC level — with no dictionary state for
this adapter to track, no second wire protocol, and none of the
"must be one ordered, single-consumer stream or the series_id
reference dangles" correctness constraint a hand-rolled scheme has.

Added a permanent regression test guarding this fact
(otap_bridge::tests::real_otap_encoding_dictionary_encodes_metric_name_and_string_attributes)
so a silent upstream schema change surfaces loudly rather than being
rediscovered from scratch. asap_precompute_rs::otap::dictionary
(SeriesDictionary / SketchStreamBatch) stays in the tree, tested, and
still backs the legacy asap_precompute_rs::otap::wire example
binaries — it's just not part of this adapter's path.

Re-verified against the same staged open-telemetry/otel-arrow checkout
(3e85c3460361446ebfce99e9f35fffd2dd5ab740, 2026-08-24): build, clippy
-D warnings, fmt --check, and test all clean; 11/11 tests passing.
asap-precompute-rs's own suite: 156/156, unaffected.

Co-Authored-By: Claude Sonnet 5 <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