Skip to content

Commit 9dea3f9

Browse files
authored
Type capture label keys and values as Dictionary(Int32,Utf8) (#1927)
* Type capture label keys and values as Dictionary(Int32,Utf8) * Version dictionary capture labels as capture format 2.0 Dictionary(Int32, Utf8) label keys and values break the frozen V1 Arrow contract, so the writer now declares capture.version 2.0. Lading readers read one selected schema major version. The default is the current major, and captool --schema-major-version selects another major. A file that declares a different major fails. For a file without capture.version, the selected major selects the label decoding. Add a frozen V2 baseline and run the contract tests for both majors. Keep the V1 fixtures unchanged. * Simplify capture label decoding and tighten 2.0 tests resolve_labels takes a LabelEncoding and returns None for another label type. The validator and captool look up the encoding once and report a wrong label type as a version mismatch. Remove label_dictionary_type, because LabelEncoding gives the same type. Add tests for Utf8 labels in a 2.0 file and for untagged V1 captures in captool. Also test sliced label arrays and clap error kinds. Restore the time-reordering test across different series.
1 parent 9db0d2c commit 9dea3f9

17 files changed

Lines changed: 1476 additions & 444 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
### Changed
99
- OpenTelemetry metric weights may now be zero, which disables that metric type.
1010
Only an all-zero set is rejected.
11+
- **Breaking Change**: Capture Parquet files now declare `capture.version = "2.0"` and use `Dictionary(Int32, Utf8)` for label keys and values instead of `Utf8`. Arrow consumers must read these columns as dictionary arrays.
12+
- **Breaking Change**: `captool` reads one capture schema major version. Select it with `--schema-major-version`. The default is the current major, `2`. `captool` rejects files that declare a different major. Use `--schema-major-version 1` for `1.x` captures and for captures without `capture.version`. `validate_parquet` takes the major as its `schema_major_version` argument.
1113

1214
### Fixed
1315
- Container build no longer fails on stale apt version pins for `libssl-dev` and
@@ -19,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1921
- Trace-agent `agent_payload` generation for the agent-to-intake
2022
`/api/v0.2/traces` protobuf endpoint, reusing v0.4 trace-shape settings, with
2123
configurable host and tracer identity cardinality (`hosts`, `tracers`).
22-
- Capture Parquet files now include a `capture.version` footer entry, starting at `1.0`.
24+
- Capture Parquet files now include a `capture.version` footer entry.
2325
- Trace-agent payload generation for `/v1.0/traces` (idx/ETP), with a service
2426
graph, streaming string interning, error injection, span links and events,
2527
and an exact `chunks_per_payload` batch size.

‎lading/src/bin/captool/analyze/parquet.rs‎

Lines changed: 196 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use std::fs::File;
88
use std::path::Path;
99

1010
use arrow_array::{Array, Float64Array, MapArray, StringArray, UInt64Array};
11-
use lading_capture::formats::parquet::{ReadError, reader_builder};
12-
use lading_capture_schema::columns;
11+
use lading_capture::formats::parquet::{LabelStrings, ReadError, reader_builder, resolve_labels};
12+
use lading_capture_schema::{LabelEncoding, columns};
1313
use rustc_hash::FxHashMap;
1414

1515
use super::{MetricInfo, SeriesStats};
@@ -23,6 +23,12 @@ pub(crate) enum Error {
2323
/// Capture metadata or declared schema error.
2424
#[error(transparent)]
2525
Capture(#[from] ReadError),
26+
/// Label columns do not match the selected schema major version.
27+
///
28+
/// A declared major that differs from the selected major arrives as
29+
/// `Capture(ReadError::VersionMismatch)` instead.
30+
#[error("version mismatch: {0}")]
31+
VersionMismatch(String),
2632
/// Parquet error
2733
#[error("Parquet error: {0}")]
2834
Parquet(#[from] parquet::errors::ParquetError),
@@ -37,16 +43,35 @@ pub(crate) enum Error {
3743
InvalidColumnType(String),
3844
}
3945

46+
/// Read label strings, or describe a label type that the selected major does not use.
47+
fn labels<'a>(
48+
column: &'a dyn Array,
49+
role: &str,
50+
encoding: LabelEncoding,
51+
schema_major_version: u64,
52+
) -> Result<LabelStrings<'a>, Error> {
53+
resolve_labels(column, encoding).ok_or_else(|| {
54+
Error::VersionMismatch(format!(
55+
"schema major version {schema_major_version} uses {expected} for {role} and does not support {actual}",
56+
expected = encoding.data_type(),
57+
actual = column.data_type(),
58+
))
59+
})
60+
}
61+
4062
/// Lists all unique metrics in a parquet capture file.
4163
///
4264
/// Returns a sorted list of (kind, name) tuples.
4365
///
4466
/// # Errors
4567
///
4668
/// Returns an error if the file cannot be read or has invalid schema.
47-
pub(crate) fn list_metrics<P: AsRef<Path>>(path: P) -> Result<Vec<MetricInfo>, Error> {
69+
pub(crate) fn list_metrics<P: AsRef<Path>>(
70+
path: P,
71+
schema_major_version: u64,
72+
) -> Result<Vec<MetricInfo>, Error> {
4873
let file = File::open(path)?;
49-
let reader = reader_builder(file)?.build()?;
74+
let reader = reader_builder(file, schema_major_version)?.build()?;
5075

5176
let mut metrics: BTreeSet<MetricInfo> = BTreeSet::new();
5277

@@ -96,9 +121,14 @@ pub(crate) fn list_metrics<P: AsRef<Path>>(path: P) -> Result<Vec<MetricInfo>, E
96121
pub(crate) fn analyze_metric<P: AsRef<Path>>(
97122
path: P,
98123
metric_name: &str,
124+
schema_major_version: u64,
99125
) -> Result<FxHashMap<BTreeSet<String>, SeriesStats>, Error> {
100126
let file = File::open(path)?;
101-
let reader = reader_builder(file)?.build()?;
127+
let reader = reader_builder(file, schema_major_version)?.build()?;
128+
let encoding =
129+
LabelEncoding::for_major(schema_major_version).ok_or(ReadError::UnsupportedMajor {
130+
schema_major_version,
131+
})?;
102132

103133
let mut series_map: FxHashMap<BTreeSet<String>, Vec<(u64, f64)>> = FxHashMap::default();
104134

@@ -171,20 +201,20 @@ pub(crate) fn analyze_metric<P: AsRef<Path>>(
171201
// Extract labels
172202
let mut sorted_labels = BTreeSet::new();
173203
let labels_slice = labels_array.value(row);
174-
let key_array = labels_slice
175-
.column(0)
176-
.as_any()
177-
.downcast_ref::<StringArray>()
178-
.ok_or_else(|| Error::InvalidColumnType("Label keys not String".to_string()))?;
179-
let value_array = labels_slice
180-
.column(1)
181-
.as_any()
182-
.downcast_ref::<StringArray>()
183-
.ok_or_else(|| Error::InvalidColumnType("Label values not String".to_string()))?;
184-
185-
for i in 0..key_array.len() {
186-
let key = key_array.value(i);
187-
let val = value_array.value(i);
204+
let key_array = labels(
205+
labels_slice.column(0),
206+
"Label keys",
207+
encoding,
208+
schema_major_version,
209+
)?;
210+
let value_array = labels(
211+
labels_slice.column(1),
212+
"Label values",
213+
encoding,
214+
schema_major_version,
215+
)?;
216+
217+
for (key, val) in key_array.iter().zip(value_array.iter()) {
188218
sorted_labels.insert(format!("{key}:{val}"));
189219
}
190220

@@ -232,3 +262,150 @@ pub(crate) fn analyze_metric<P: AsRef<Path>>(
232262

233263
Ok(results)
234264
}
265+
266+
#[cfg(test)]
267+
mod tests {
268+
use super::*;
269+
270+
use std::{path::PathBuf, sync::Arc};
271+
272+
use arrow_array::RecordBatch;
273+
use arrow_schema::Schema;
274+
use lading_capture::{
275+
formats::parquet::Format,
276+
line::{Line, LineValue, MetricKind},
277+
};
278+
use lading_capture_schema::CURRENT_CAPTURE_VERSION;
279+
use parquet::arrow::{ArrowWriter, arrow_reader::ParquetRecordBatchReaderBuilder};
280+
use tempfile::NamedTempFile;
281+
282+
fn fixture(major: u64) -> PathBuf {
283+
Path::new(env!("CARGO_MANIFEST_DIR"))
284+
.join(format!("../lading_capture/tests/fixtures/v{major}.parquet"))
285+
}
286+
287+
/// For an untagged file with V1 labels and the default major 2: `list_metrics` succeeds,
288+
/// and `analyze_metric` returns `VersionMismatch` because it decodes labels.
289+
#[test]
290+
fn untagged_v1_labels_need_schema_major_version_1() -> Result<(), Box<dyn std::error::Error>> {
291+
let batches = ParquetRecordBatchReaderBuilder::try_new(File::open(fixture(1))?)?
292+
.build()?
293+
.collect::<Result<Vec<_>, _>>()?;
294+
let first = batches.first().ok_or("missing fixture batch")?;
295+
let schema = Arc::new(Schema::new(first.schema().fields().clone()));
296+
let capture = NamedTempFile::new()?;
297+
let mut writer = ArrowWriter::try_new(capture.reopen()?, Arc::clone(&schema), None)?;
298+
for batch in &batches {
299+
writer.write(&RecordBatch::try_new(
300+
Arc::clone(&schema),
301+
batch.columns().to_vec(),
302+
)?)?;
303+
}
304+
writer.close()?;
305+
306+
let major = CURRENT_CAPTURE_VERSION.major;
307+
assert_eq!(list_metrics(capture.path(), major)?.len(), 1);
308+
let error = analyze_metric(capture.path(), "requests", major).err();
309+
assert!(
310+
matches!(
311+
&error,
312+
Some(Error::VersionMismatch(message))
313+
if message == &format!(
314+
"schema major version {major} uses Dictionary(Int32, Utf8) for Label keys and does not support Utf8"
315+
)
316+
),
317+
"unexpected result {error:?}"
318+
);
319+
assert_eq!(analyze_metric(capture.path(), "requests", 1)?.len(), 2);
320+
Ok(())
321+
}
322+
323+
/// For all frozen majors M: captool reads the M.0 capture only with schema major version M.
324+
#[test]
325+
fn frozen_captures_need_their_selected_major() -> Result<(), Box<dyn std::error::Error>> {
326+
for (major, other) in [(1, 2), (2, 1)] {
327+
assert_eq!(
328+
list_metrics(fixture(major), major)?,
329+
vec![MetricInfo {
330+
kind: "counter".to_string(),
331+
name: "requests".to_string(),
332+
}]
333+
);
334+
let series = analyze_metric(fixture(major), "requests", major)?;
335+
assert_eq!(series.len(), 2);
336+
let prod_key = ["env:prod".to_string(), "service:api".to_string()].into();
337+
let prod = series.get(&prod_key).ok_or("missing prod series")?;
338+
assert_eq!(prod.values, vec![(0, 10.0), (2, 30.0)]);
339+
assert!(matches!(
340+
list_metrics(fixture(major), other),
341+
Err(Error::Capture(ReadError::VersionMismatch { .. }))
342+
));
343+
assert!(matches!(
344+
analyze_metric(fixture(major), "requests", other),
345+
Err(Error::Capture(ReadError::VersionMismatch { .. }))
346+
));
347+
}
348+
Ok(())
349+
}
350+
351+
/// For all writer output read with `CURRENT_CAPTURE_VERSION.major`: each distinct label set
352+
/// is one series, and its statistics equal those of its input values.
353+
#[test]
354+
#[expect(clippy::float_cmp, reason = "Statistics use exact integer inputs.")]
355+
fn analyze_metric_reads_writer_output() -> Result<(), Box<dyn std::error::Error>> {
356+
let run_id = "12345678-9abc-def0-1234-56789abcdef0".parse()?;
357+
let capture = NamedTempFile::new()?;
358+
let mut writer = Format::new(capture.reopen()?, 3)?;
359+
for (env, fetch_index, value) in [
360+
("prod", 0u64, 10),
361+
("prod", 1, 20),
362+
("prod", 2, 30),
363+
("staging", 0, 100),
364+
("staging", 1, 200),
365+
] {
366+
writer.write_metric(&Line {
367+
run_id,
368+
time: 1_000 + u128::from(fetch_index),
369+
fetch_index,
370+
metric_name: "requests".to_string(),
371+
metric_kind: MetricKind::Counter,
372+
value: LineValue::Int(value),
373+
labels: [
374+
("env".to_string(), env.to_string()),
375+
("service".to_string(), "api".to_string()),
376+
]
377+
.into_iter()
378+
.collect(),
379+
value_histogram: Vec::new(),
380+
})?;
381+
}
382+
writer.close()?;
383+
384+
assert_eq!(
385+
list_metrics(capture.path(), CURRENT_CAPTURE_VERSION.major)?,
386+
vec![MetricInfo {
387+
kind: "counter".to_string(),
388+
name: "requests".to_string(),
389+
}]
390+
);
391+
392+
let series = analyze_metric(capture.path(), "requests", CURRENT_CAPTURE_VERSION.major)?;
393+
assert_eq!(series.len(), 2);
394+
395+
let prod_key = ["env:prod".to_string(), "service:api".to_string()].into();
396+
let staging_key = ["env:staging".to_string(), "service:api".to_string()].into();
397+
398+
let prod_stats = series.get(&prod_key).ok_or("missing prod series")?;
399+
assert_eq!(prod_stats.min, 10.0);
400+
assert_eq!(prod_stats.max, 30.0);
401+
assert_eq!(prod_stats.mean, 20.0);
402+
assert!(prod_stats.is_monotonic);
403+
404+
let staging_stats = series.get(&staging_key).ok_or("missing staging series")?;
405+
assert_eq!(staging_stats.min, 100.0);
406+
assert_eq!(staging_stats.max, 200.0);
407+
assert_eq!(staging_stats.mean, 150.0);
408+
assert!(staging_stats.is_monotonic);
409+
Ok(())
410+
}
411+
}

0 commit comments

Comments
 (0)