Skip to content

otap: implement Schema/Dictionary/Record codec from data_model.md - #5

Merged
zzylol merged 1 commit into
mainfrom
feat/schema-dictionary-record-codec
Aug 24, 2026
Merged

otap: implement Schema/Dictionary/Record codec from data_model.md#5
zzylol merged 1 commit into
mainfrom
feat/schema-dictionary-record-codec

Conversation

@zzylol

@zzylol zzylol commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

An audit against docs/data_model.md found that the OTAP Strategy-B wire codec (encode_batch/decode_batch) never actually implemented the doc's Schema/Dictionary/Record economics: every row inlined metric, labels, sketch_type, agg_id, schema_version, and encoding, repeated on every RECORD row, every window, forever — the exact anti-pattern the doc's rationale exists to avoid. The design only ever existed as a field-categorization scheme, not an implemented protocol behavior.

This PR implements it for real.

What changed

  • New otap::dictionary module: SeriesDictionary (sender) assigns stable series_ids and emits SCHEMA/DICTIONARY/LABELS rows only the first time an agg_id/series is seen; RECORD always carries just series_id + window bounds + envelope/value. SeriesDictionaryDecoder (receiver) retains that state across a continuous stream and reconstructs full SketchEnvelopes by joining RECORD rows back against it — an unresolvable series_id/agg_id is a hard decode error, not a silent partial result, per the doc's own statefulness caveat.
  • sketch_size is resolved from PrecomputeConfig::sketch_params via a new Precompute::active_config() trait method.
  • hash_seed/hash_function resolve asap_sketchlib's HashSpec down to the one canonical seed position (seed_list[canonical_seed_index]) rather than carrying the full 20-entry seed table — checked against asap_sketchlib's own self-describing wire-format doc, since one SCHEMA row (one agg_id, one sketch_type) only ever needs one seed position. SeriesDictionaryDecoder::schema_for() exposes the resolved value on the receiver side without fabricating a lossy reconstructed HashSpec.
  • StubPlugin and AsapSketchesPlugin now use this codec for tick/drain (encode) and inbound-envelope (decode), each with persistent dictionary state across calls.
  • encode_batch/decode_batch/records::{flatten,lift} are unchanged — they solve a different problem (disguising a payload as OTAP-Metrics-shaped so it can transit a generic OTAP pipeline hop) that docs/data_model.md was never about (its own first line scopes it to the asap_sketches-to-asap_sketches hop).
  • New examples/sketch_pipeline_demo.rs: a runnable three-stage demo — sketch creation processor → receive processor (merges via observe_envelope, queries via estimate mode) → Prometheus text exposition. Run with cargo run --example sketch_pipeline_demo --features otap. The printed output makes the dictionary economics directly visible: window 0 sends all four batches, every later window sends RECORD only.

Testing

  • cargo test --features otap: 149/149 passing (14 new tests covering first-window-vs-repeat-window batch shapes, distinct series_id assignment, full encode→decode round trips, hash-seed resolution, and decode error handling for unknown series_id/agg_id).
  • cargo fmt --check and cargo clippy --features otap --tests --examples: clean.
  • cargo run --example sketch_pipeline_demo --features otap: verified end to end — window 0 emits schema=1 dictionary=1 labels=1 record=1, windows 1-3 emit schema=0 dictionary=0 labels=0 record=1, and the receiver's queried p99 gauge tracks the injected latency drift correctly.

🤖 Generated with Claude Code

The OTAP Strategy-B wire codec (encode_batch/decode_batch) emitted one
fully self-describing row per envelope: metric name, labels, sketch_type,
agg_id, schema_version, and encoding all repeated on every RECORD row,
every window, forever -- exactly the anti-pattern docs/data_model.md's
Schema/Dictionary/Record split exists to avoid. Nothing in the codec
implemented the doc's "sent once, referenced by index thereafter"
economics; the design existed only as a field-categorization scheme, not
an implemented protocol behavior.

Add otap::dictionary with SeriesDictionary (sender) / SeriesDictionaryDecoder
(receiver):

- SeriesDictionary::encode assigns stable series_ids and emits SCHEMA /
  DICTIONARY / LABELS rows only the first time an agg_id / series is seen;
  RECORD always carries just series_id + window bounds + envelope/value.
- SeriesDictionaryDecoder retains SCHEMA/DICTIONARY/LABELS state across a
  continuous stream and reconstructs full SketchEnvelopes by joining RECORD
  rows back against it, per the doc's own statefulness caveat -- an unknown
  series_id/agg_id is a hard decode error, not a silent partial result.
- sketch_size is resolved from PrecomputeConfig::sketch_params via the new
  Precompute::active_config() trait method.
- hash_seed/hash_function resolve HashSpec down to the one canonical seed
  position (seed_list[canonical_seed_index]) rather than carrying
  asap_sketchlib's full 20-entry seed table, per its own self-describing
  wire-format doc; SeriesDictionaryDecoder::schema_for() exposes the
  resolved value without fabricating a lossy reconstructed HashSpec.

StubPlugin and AsapSketchesPlugin now use this codec for tick/drain
(encode) and inbound-envelope (decode), each with persistent dictionary
state across calls. encode_batch/decode_batch/records::{flatten,lift}
are kept as-is -- they solve a different problem (disguising a payload as
OTAP-Metrics-shaped to transit a generic OTAP pipeline hop) that
docs/data_model.md was never about (its own first line scopes it to the
asap_sketches-to-asap_sketches hop).

Also adds examples/sketch_pipeline_demo.rs: a runnable three-stage demo
(sketch creation processor -> receive processor that merges + queries via
estimate mode -> Prometheus text exposition) showing the dictionary
economics directly in the output -- window 0 sends all four batches,
every later window sends RECORD only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
zzylol added a commit that referenced this pull request Aug 24, 2026
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.
zzylol added a commit that referenced this pull request Aug 24, 2026
…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.
@zzylol
zzylol merged commit b48cc67 into main Aug 24, 2026
@zzylol
zzylol deleted the feat/schema-dictionary-record-codec branch August 24, 2026 17:08
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