@@ -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) ]
0 commit comments