Skip to content

Commit 91bea5d

Browse files
committed
Correct multi-batch test coverage and drop redundant validate test
Addresses a second review pass over the dictionary-label changes: - Rewrite the `formats` multi-batch test. The old `parquet_round_trip_multiple_batches` documented that flushing between chunks forces separate per-batch dictionaries, but `ArrowWriter` coalesces per-`write()` batches into one row group, so three flushes of four rows produced a single read batch — the doc claim was false and the test never exercised per-batch decode. `parquet_round_trip_multiple_read_batches` reads with `with_batch_size(2)` to genuinely emit several batches, decodes each batch's `Dictionary(Int32, Utf8)` labels via `resolve_label_dictionary`, and asserts `batch_count > 1` so the coverage claim cannot silently lapse. - Assert `mean` and `is_monotonic` on the staging series in the captool `analyze_metric` test, matching the prod series assertions. - Remove a redundant dictionary-label validate test; `validate.rs` already covers the `validate_parquet` decode path across happy and error cases.
1 parent ee005c3 commit 91bea5d

3 files changed

Lines changed: 90 additions & 109 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,5 +321,7 @@ mod tests {
321321
let staging_stats = series.get(&staging_key).expect("staging series present");
322322
assert_eq!(staging_stats.min, 100.0);
323323
assert_eq!(staging_stats.max, 200.0);
324+
assert_eq!(staging_stats.mean, 150.0);
325+
assert!(staging_stats.is_monotonic);
324326
}
325327
}

lading_capture/src/formats.rs

Lines changed: 88 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -465,66 +465,108 @@ mod tests {
465465
}
466466
}
467467

468-
/// Roundtrip labels across multiple flushed batches.
468+
/// Decode dictionary labels across multiple read batches.
469469
///
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.
470+
/// The parquet reader emits one `RecordBatch` per `batch_size` rows, and each
471+
/// batch decodes its own `Dictionary(Int32, Utf8)` label columns independently
472+
/// (indices are batch-local). Production files exceed the default batch size,
473+
/// so they are always read as several such batches — a path a small
474+
/// single-batch roundtrip never reaches. Here a small `batch_size` forces
475+
/// multiple batches over rows that share label strings (`env=prod`) so each
476+
/// batch resolves the shared strings from its own dictionary. A reader that
477+
/// mishandled per-batch dictionaries (stale indices, assuming one global
478+
/// dictionary) would return wrong labels for some batch. The test asserts it
479+
/// actually produced more than one batch so the coverage claim cannot silently
480+
/// lapse.
477481
#[test]
478-
fn parquet_round_trip_multiple_batches() {
482+
fn parquet_round_trip_multiple_read_batches() {
479483
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-
];
484+
let envs = ["prod", "staging", "prod", "staging", "prod", "staging"];
485485

486486
let mut buffer = Cursor::new(Vec::new());
487-
let mut input_lines: Vec<Line> = Vec::new();
487+
let mut expected_labels: FxHashMap<u64, FxHashMap<String, String>> = FxHashMap::default();
488488
{
489489
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");
490+
for (i, env) in envs.iter().enumerate() {
491+
let fetch_index = i as u64;
492+
let labels: FxHashMap<String, String> = [("env".to_string(), (*env).to_string())]
493+
.into_iter()
494+
.collect();
495+
let line = Line {
496+
run_id,
497+
time: 1_000 + u128::from(fetch_index),
498+
fetch_index,
499+
metric_name: "requests".to_string(),
500+
metric_kind: MetricKind::Counter,
501+
value: LineValue::Int(fetch_index),
502+
labels: labels.clone(),
503+
value_histogram: Vec::new(),
504+
};
505+
writer.write_metric(&line).expect("write");
506+
expected_labels.insert(fetch_index, labels);
509507
}
508+
writer.flush().expect("flush");
510509
writer.close().expect("close");
511510
}
512511
let bytes = buffer.into_inner();
513512

514-
let deserialized_lines = read_parquet_lines(&bytes).expect("read parquet");
513+
// A batch size below the row count forces the reader to emit several
514+
// batches, each decoding its labels from an independent dictionary.
515+
let bytes_buf = Bytes::copy_from_slice(&bytes);
516+
let reader = ParquetRecordBatchReaderBuilder::try_new(bytes_buf)
517+
.expect("reader builder")
518+
.with_batch_size(2)
519+
.build()
520+
.expect("reader");
521+
522+
let mut batch_count = 0usize;
523+
let mut seen = 0usize;
524+
for batch_result in reader {
525+
let batch = batch_result.expect("batch");
526+
if batch.num_rows() == 0 {
527+
continue;
528+
}
529+
batch_count += 1;
515530

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);
531+
let fetch_index_array = batch
532+
.column_by_name("fetch_index")
533+
.expect("fetch_index column")
534+
.as_any()
535+
.downcast_ref::<UInt64Array>()
536+
.expect("fetch_index is UInt64Array");
537+
let labels_array = batch
538+
.column_by_name("labels")
539+
.expect("labels column")
540+
.as_any()
541+
.downcast_ref::<MapArray>()
542+
.expect("labels is MapArray");
543+
544+
for row in 0..batch.num_rows() {
545+
let fetch_index = fetch_index_array.value(row);
546+
let labels_slice: StructArray = labels_array.value(row);
547+
let keys = resolve_label_dictionary(labels_slice.column(0), "Labels keys")
548+
.expect("label keys are Dictionary(Int32,Utf8)");
549+
let values = resolve_label_dictionary(labels_slice.column(1), "Labels values")
550+
.expect("label values are Dictionary(Int32,Utf8)");
551+
let decoded: FxHashMap<String, String> = (0..keys.len())
552+
.map(|i| (keys.value(i).to_string(), values.value(i).to_string()))
553+
.collect();
554+
assert_eq!(
555+
&decoded,
556+
expected_labels
557+
.get(&fetch_index)
558+
.expect("known fetch_index"),
559+
"labels mismatch at fetch_index {fetch_index}"
560+
);
561+
seen += 1;
562+
}
527563
}
564+
565+
assert!(
566+
batch_count > 1,
567+
"expected multiple read batches, got {batch_count}"
568+
);
569+
assert_eq!(seen, envs.len(), "every row decoded exactly once");
528570
}
529571

530572
#[expect(clippy::too_many_lines)]

lading_capture/src/validate/parquet.rs

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -309,66 +309,3 @@ pub fn validate_parquet<P: AsRef<Path>>(
309309
first_error,
310310
})
311311
}
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)