Skip to content

Commit ee005c3

Browse files
committed
Fix captool analyze for dictionary labels; bind it to the writer in CI
The `Dictionary(Int32, Utf8)` label typing broke a second in-repo reader that the first pass missed: `captool analyze` decoded the label `key`/`value` columns with `downcast_ref::<StringArray>()`, which returns `None` once those columns are `DictionaryArray<Int32Type>`, so `analyze_metric` failed on every capture written by the new writer. It compiles (auto-detected bin target), so there was no compile-time signal, and nothing exercised the path. - Make `resolve_label_dictionary` `pub` so `captool` (in the `lading` crate) uses the same canonical decode path as the `lading-capture` reader/validator instead of re-rolling the two-step downcast. - Route `captool analyze` label decoding through `resolve_label_dictionary`. - Add a `captool` test that writes a capture with the production `Format` writer and runs `list_metrics`/`analyze_metric` over it, so any future divergence between the writer's on-disk schema and the analyzer fails in CI. - Add a `lading-capture` roundtrip test that flushes between chunks, forcing shared label strings into independent per-batch dictionaries. - Add a `validate_parquet` test covering its own dictionary-decode and per-series label-reconstruction path. - Fix doc/import/casing nits from review.
1 parent 8b1ffad commit ee005c3

6 files changed

Lines changed: 244 additions & 20 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lading/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ async-pidfd = { version = "0.1" }
107107
tempfile = { workspace = true }
108108
proptest = { workspace = true }
109109
tower-test = { version = "0.4" }
110+
uuid = { workspace = true }
110111

111112
[build-dependencies]
112113
prost-build = { workspace = true }

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

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use std::collections::BTreeSet;
77
use std::fs::File;
88
use std::path::Path;
99

10-
use arrow_array::{Array, Float64Array, MapArray, StringArray, UInt64Array};
10+
use arrow_array::{Array, ArrayAccessor, Float64Array, MapArray, StringArray, UInt64Array};
11+
use lading_capture::formats::parquet::resolve_label_dictionary;
1112
use lading_capture_schema::columns;
1213
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
1314
use rustc_hash::FxHashMap;
@@ -170,16 +171,10 @@ pub(crate) fn analyze_metric<P: AsRef<Path>>(
170171
// Extract labels
171172
let mut sorted_labels = BTreeSet::new();
172173
let labels_slice = labels_array.value(row);
173-
let key_array = labels_slice
174-
.column(0)
175-
.as_any()
176-
.downcast_ref::<StringArray>()
177-
.ok_or_else(|| Error::InvalidColumnType("Label keys not String".to_string()))?;
178-
let value_array = labels_slice
179-
.column(1)
180-
.as_any()
181-
.downcast_ref::<StringArray>()
182-
.ok_or_else(|| Error::InvalidColumnType("Label values not String".to_string()))?;
174+
let key_array = resolve_label_dictionary(labels_slice.column(0), "Labels keys")
175+
.map_err(Error::InvalidColumnType)?;
176+
let value_array = resolve_label_dictionary(labels_slice.column(1), "Labels values")
177+
.map_err(Error::InvalidColumnType)?;
183178

184179
for i in 0..key_array.len() {
185180
let key = key_array.value(i);
@@ -231,3 +226,100 @@ pub(crate) fn analyze_metric<P: AsRef<Path>>(
231226

232227
Ok(results)
233228
}
229+
230+
#[cfg(test)]
231+
mod tests {
232+
use super::*;
233+
234+
use lading_capture::formats::parquet::Format;
235+
use lading_capture::line::{Line, LineValue, MetricKind};
236+
use rustc_hash::FxHashMap;
237+
use tempfile::NamedTempFile;
238+
use uuid::Uuid;
239+
240+
fn labels(pairs: &[(&str, &str)]) -> FxHashMap<String, String> {
241+
pairs
242+
.iter()
243+
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
244+
.collect()
245+
}
246+
247+
/// Write the given lines to a parquet capture file using the production
248+
/// [`Format`] writer and return the backing temp file.
249+
fn write_capture(lines: &[Line]) -> NamedTempFile {
250+
let tmp = NamedTempFile::new().expect("create temp file");
251+
let file = tmp.reopen().expect("reopen temp file");
252+
let mut writer = Format::new(file, 3).expect("create parquet writer");
253+
for line in lines {
254+
writer.write_metric(line).expect("write metric");
255+
}
256+
writer.flush().expect("flush");
257+
writer.close().expect("close");
258+
tmp
259+
}
260+
261+
/// `analyze_metric` reads captures written by the production parquet writer.
262+
///
263+
/// This binds `captool analyze` to the capture writer: the file is produced
264+
/// by `lading_capture`'s [`Format`], so any change to the on-disk label
265+
/// encoding (e.g. the `Dictionary(Int32, Utf8)` label columns) that the
266+
/// analyzer fails to track breaks this test in CI rather than shipping a
267+
/// silently broken tool.
268+
#[test]
269+
#[expect(
270+
clippy::float_cmp,
271+
reason = "stats are computed from exact integer-valued inputs"
272+
)]
273+
fn analyze_metric_reads_writer_output() {
274+
let run_id = Uuid::from_u128(0x1234_5678_9abc_def0_1234_5678_9abc_def0);
275+
let prod = labels(&[("env", "prod"), ("service", "api")]);
276+
let staging = labels(&[("env", "staging")]);
277+
278+
let mut lines: Vec<Line> = (0u64..3)
279+
.map(|i| Line {
280+
run_id,
281+
time: 1_000 + u128::from(i),
282+
fetch_index: i,
283+
metric_name: "requests".to_string(),
284+
metric_kind: MetricKind::Counter,
285+
value: LineValue::Int((i + 1) * 10),
286+
labels: prod.clone(),
287+
value_histogram: Vec::new(),
288+
})
289+
.collect();
290+
lines.extend((0u64..2).map(|i| Line {
291+
run_id,
292+
time: 2_000 + u128::from(i),
293+
fetch_index: i,
294+
metric_name: "requests".to_string(),
295+
metric_kind: MetricKind::Counter,
296+
value: LineValue::Int((i + 1) * 100),
297+
labels: staging.clone(),
298+
value_histogram: Vec::new(),
299+
}));
300+
301+
let tmp = write_capture(&lines);
302+
303+
let metrics = list_metrics(tmp.path()).expect("list metrics");
304+
assert!(
305+
metrics.iter().any(|m| m.name == "requests"),
306+
"requests metric should be listed, got {metrics:?}"
307+
);
308+
309+
let series = analyze_metric(tmp.path(), "requests").expect("analyze requests");
310+
assert_eq!(series.len(), 2, "two distinct label sets expected");
311+
312+
let prod_key: BTreeSet<String> = ["env:prod".to_string(), "service:api".to_string()].into();
313+
let staging_key: BTreeSet<String> = ["env:staging".to_string()].into();
314+
315+
let prod_stats = series.get(&prod_key).expect("prod series present");
316+
assert_eq!(prod_stats.min, 10.0);
317+
assert_eq!(prod_stats.max, 30.0);
318+
assert_eq!(prod_stats.mean, 20.0);
319+
assert!(prod_stats.is_monotonic);
320+
321+
let staging_stats = series.get(&staging_key).expect("staging series present");
322+
assert_eq!(staging_stats.min, 100.0);
323+
assert_eq!(staging_stats.max, 200.0);
324+
}
325+
}

lading_capture/src/formats.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,68 @@ mod tests {
465465
}
466466
}
467467

468+
/// Roundtrip labels across multiple flushed batches.
469+
///
470+
/// The writer builds a fresh dictionary per `flush()`, so each batch encodes
471+
/// its label strings in an independent dictionary whose indices restart at 0.
472+
/// Flushing between chunks that share the same strings (`env=prod`) forces
473+
/// those strings into separate per-batch dictionaries, exercising the reader's
474+
/// per-batch dictionary reconciliation — a case a single-flush test cannot
475+
/// reach. A reader that assumed one global dictionary would return wrong
476+
/// labels here while passing the single-batch tests.
477+
#[test]
478+
fn parquet_round_trip_multiple_batches() {
479+
let run_id = Uuid::from_u128(0x0fed_cba9_8765_4321_0fed_cba9_8765_4321);
480+
let label_sets = [
481+
[("env".to_string(), "prod".to_string())],
482+
[("env".to_string(), "staging".to_string())],
483+
[("env".to_string(), "prod".to_string())],
484+
];
485+
486+
let mut buffer = Cursor::new(Vec::new());
487+
let mut input_lines: Vec<Line> = Vec::new();
488+
{
489+
let mut writer = super::parquet::Format::new(&mut buffer, 3).expect("create writer");
490+
for (chunk_idx, labels) in label_sets.iter().enumerate() {
491+
for row in 0u64..4 {
492+
let fetch_index = chunk_idx as u64 * 4 + row;
493+
let line = Line {
494+
run_id,
495+
time: 1_000 + u128::from(fetch_index),
496+
fetch_index,
497+
metric_name: "requests".to_string(),
498+
metric_kind: MetricKind::Counter,
499+
value: LineValue::Int(fetch_index),
500+
labels: labels.iter().cloned().collect(),
501+
value_histogram: Vec::new(),
502+
};
503+
writer.write_metric(&line).expect("write");
504+
input_lines.push(line);
505+
}
506+
// Flush between chunks so each lands in its own dictionary-encoded
507+
// batch rather than one coalesced batch.
508+
writer.flush().expect("flush");
509+
}
510+
writer.close().expect("close");
511+
}
512+
let bytes = buffer.into_inner();
513+
514+
let deserialized_lines = read_parquet_lines(&bytes).expect("read parquet");
515+
516+
assert_eq!(input_lines.len(), deserialized_lines.len());
517+
let mut by_fetch_index: FxHashMap<u64, &Line> = deserialized_lines
518+
.iter()
519+
.map(|l| (l.fetch_index, l))
520+
.collect();
521+
for input in &input_lines {
522+
let output = by_fetch_index
523+
.remove(&input.fetch_index)
524+
.expect("row round-trips");
525+
assert_eq!(input.labels, output.labels);
526+
assert_eq!(input.metric_name, output.metric_name);
527+
}
528+
}
529+
468530
#[expect(clippy::too_many_lines)]
469531
fn read_parquet_lines(bytes: &[u8]) -> Result<Vec<Line>, Box<dyn std::error::Error>> {
470532
let bytes_buf = Bytes::copy_from_slice(bytes);
@@ -568,9 +630,9 @@ mod tests {
568630
};
569631

570632
let labels_slice: StructArray = labels_array.value(row_idx);
571-
let keys = resolve_label_dictionary(labels_slice.column(0), "label keys")
633+
let keys = resolve_label_dictionary(labels_slice.column(0), "Labels keys")
572634
.expect("label keys are Dictionary(Int32,Utf8)");
573-
let values = resolve_label_dictionary(labels_slice.column(1), "label values")
635+
let values = resolve_label_dictionary(labels_slice.column(1), "Labels values")
574636
.expect("label values are Dictionary(Int32,Utf8)");
575637

576638
let mut labels = FxHashMap::default();

lading_capture/src/formats/parquet.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,17 @@ use crate::line;
3030
/// dictionary view of its `Utf8` values.
3131
///
3232
/// Both the label `key` and `value` columns are written as
33-
/// `Dictionary(Int32, Utf8)`, so every reader resolves them the same way. On
34-
/// failure returns a message naming `column` (e.g. `"Labels keys"`) so callers
35-
/// can surface distinct errors for the key and value columns.
36-
pub(crate) fn resolve_label_dictionary<'a>(
33+
/// `Dictionary(Int32, Utf8)`, so every reader resolves them the same way. This
34+
/// is the single canonical decode path: readers in other crates (e.g. `captool`)
35+
/// call it too, so a schema change here surfaces in every reader at once. On
36+
/// failure returns a message prefixed with `column_name` (e.g. `"Labels keys"`)
37+
/// so callers can surface distinct errors for the key and value columns.
38+
///
39+
/// # Errors
40+
///
41+
/// Returns an error message if `column` is not a `Dictionary(Int32, _)` or its
42+
/// dictionary values are not `Utf8`.
43+
pub fn resolve_label_dictionary<'a>(
3744
column: &'a dyn Array,
3845
column_name: &str,
3946
) -> Result<TypedDictionaryArray<'a, Int32Type, StringArray>, String> {

lading_capture/src/validate/parquet.rs

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,9 @@ use arrow_array::{
1717
UInt64Array,
1818
};
1919
use lading_capture_schema::columns;
20-
21-
use crate::formats::parquet::resolve_label_dictionary;
2220
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
2321

24-
use crate::validate::ValidationResult;
22+
use crate::{formats::parquet::resolve_label_dictionary, validate::ValidationResult};
2523

2624
/// Errors for parquet validation
2725
#[derive(thiserror::Error, Debug)]
@@ -311,3 +309,66 @@ pub fn validate_parquet<P: AsRef<Path>>(
311309
first_error,
312310
})
313311
}
312+
313+
#[cfg(test)]
314+
mod tests {
315+
use super::*;
316+
317+
use crate::formats::parquet::Format;
318+
use crate::line::{Line, LineValue, MetricKind};
319+
use tempfile::NamedTempFile;
320+
use uuid::Uuid;
321+
322+
/// A well-formed capture with dictionary-encoded labels validates cleanly.
323+
///
324+
/// This drives the validator's own decode path — `resolve_label_dictionary`
325+
/// plus the `sorted_labels` reconstruction that builds per-series keys from
326+
/// the `Dictionary(Int32, Utf8)` label columns — which is distinct from the
327+
/// reader path exercised elsewhere. Two label sets sharing a key (`env`) must
328+
/// resolve to two separate series.
329+
#[test]
330+
fn validate_parquet_decodes_dictionary_labels() {
331+
let run_id = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
332+
333+
// fetch_index -> time is 1:1 across all rows (required invariant); each
334+
// series' fetch_index and time are strictly increasing.
335+
let mut lines: Vec<Line> = Vec::new();
336+
for (env, count) in [("prod", 3u64), ("staging", 2u64)] {
337+
for fetch_index in 0..count {
338+
lines.push(Line {
339+
run_id,
340+
time: 1_000 + u128::from(fetch_index),
341+
fetch_index,
342+
metric_name: "requests".to_string(),
343+
metric_kind: MetricKind::Counter,
344+
value: LineValue::Int(fetch_index),
345+
labels: [("env".to_string(), env.to_string())].into_iter().collect(),
346+
value_histogram: Vec::new(),
347+
});
348+
}
349+
}
350+
351+
let tmp = NamedTempFile::new().expect("create temp file");
352+
{
353+
let file = tmp.reopen().expect("reopen temp file");
354+
let mut writer = Format::new(file, 3).expect("create writer");
355+
for line in &lines {
356+
writer.write_metric(line).expect("write");
357+
}
358+
writer.flush().expect("flush");
359+
writer.close().expect("close");
360+
}
361+
362+
let result = validate_parquet(tmp.path(), None).expect("validate");
363+
364+
assert_eq!(result.line_count, 5);
365+
assert_eq!(
366+
result.unique_series, 2,
367+
"prod and staging are distinct series"
368+
);
369+
assert_eq!(result.unique_fetch_indices, 3);
370+
assert_eq!(result.fetch_index_errors, 0);
371+
assert_eq!(result.per_series_errors, 0);
372+
assert!(result.first_error.is_none(), "{:?}", result.first_error);
373+
}
374+
}

0 commit comments

Comments
 (0)