otap: real plugin integration + Arrow IPC network transport - #6
Merged
Conversation
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>
11 tasks
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
changed the base branch from
feat/schema-dictionary-record-codec
to
main
August 24, 2026 17:03
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>
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.
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_sketchesdataflow processors) two gaps had to close before that's more than a demo:OtapPdatametric traffic and emitting real OTAP metric traffic back out — not aMessage::PDatapass-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 + asyncsend_stream_batch/recv_stream_batchover a realTcpStream, so aSketchStreamBatchcan actually cross a socket.AsapSketchesPlugin::start_from_envelopes: the receiver-role counterpart tostart()— consumes an upstream node'sSketchStreamBatchstream, merges viaobserve_envelope, and (intransmit_sketch = falseestimate 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.rsremains the fast in-process version).otap-patch/all/otap_bridge.rs(new): the actualOtapPdata ↔ OtapMetricRecordsbinding — 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.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 twou32-cast / unbounded-allocation issues in the wire codec.asap_sketchlibbumped to latest (010457d); Arrow bumped53 → 58.3to match the OTAP Dataflow workspace's own pin (required soRecordBatchis the literal same type across theotap-patch/boundary).How
The two pieces of actual design/algorithm worth calling out:
Dictionary economics over a real wire.
otap::wireframes eachSketchStreamBatch's four siblingRecordBatches as independent one-shot Arrow IPC streams behind a length-prefixed frame. Combined withSeriesDictionary/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 fullSCHEMA+DICTIONARY+LABELScost, every later window on the same connection pays onlyRECORD(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.rsimplements upstream's ownMetricsViewtrait family as a thin adapter over ASAP's already-existing flatOtapMetricRecords, then calls upstream's ownencode_metrics_otap_batch()to build a realOtapArrowRecords::Metrics— pushing all the low-level correctness onto already-tested upstream code. On decode, a sketch shipped as binary inside an OTAP metric (_asap_envelopeattribute) needs no special-casing at all: it round-trips intoOtapMetricRecordslike any other attribute, andPrecompute::observealready routesObservationValueKind::Envelopeobservations toobserve_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 needAsapSketchesPlugin'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 toSketchStreamBatch(notOtapMetricRecords), the adapter instead drives a barePrecomputedirectly from OTAP's native per-message/per-timerprocess()callbacks (effect_handler.start_periodic_timer→NodeControlMsg::TimerTick), which fits a callback-styleobserve/tick/drainAPI better than bridging aStreamwould have.Verification note:
otap-patch/has no standalone build in this repo (no OTAP Dataflow workspace checkout here). Every upstream type/signatureotap_bridge.rsuses was read directly fromopen-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 realarrow-array58.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:cargo run --example sketch_pipeline_demo --features otap— real dictionary economics (window 0 pays full cost, later windows payRECORDonly) feeding a real merge → p99 estimate:🤖 Generated with Claude Code