@@ -197,8 +197,9 @@ impl<'scope> SinkRender<'scope> for MetricSinkConnection {
197197/// The source relation exposes `metric_name`, `labels`, `value`, and `help` of the required types,
198198/// and `shape_metric_sink_source` adds the `metric_kind` and `name_valid` columns this reads.
199199/// `resolve` panics if a column is missing. No tree caller enforces this column contract yet; the
200- /// SQL planner will. `metric_name` and `value` may still be `Datum::Null`;
201- /// the rest are non-null by construction. Column position within the row is unconstrained.
200+ /// SQL planner will. `metric_name`, `value`, and the values of the `labels` map may still be
201+ /// `Datum::Null` (a `map[text=>text]` has no per-value nullability); the columns themselves are
202+ /// non-null by construction. Column position within the row is unconstrained.
202203struct ColumnIndices {
203204 metric_name : usize ,
204205 labels : usize ,
@@ -231,6 +232,10 @@ impl ColumnIndices {
231232///
232233/// The planner already did the row-wise shaping.
233234///
235+ /// A null label value stays `None` rather than being coerced to a string. Neither a null nor a
236+ /// genuine empty-string value is a representable Prometheus label (an empty value reads as absent),
237+ /// so a row carrying either is skipped downstream instead of published.
238+ ///
234239/// Strings borrow from `datums`, so the caller must own what it needs
235240/// (see `SinkState::stage_ok`) before the row backing `datums` is dropped.
236241fn extract_row < ' a > (
@@ -240,7 +245,7 @@ fn extract_row<'a>(
240245 & ' a str ,
241246 Option < MetricKind > ,
242247 bool ,
243- Vec < ( & ' a str , & ' a str ) > ,
248+ Vec < ( & ' a str , Option < & ' a str > ) > ,
244249 Option < f64 > ,
245250 & ' a str ,
246251) {
@@ -250,10 +255,10 @@ fn extract_row<'a>(
250255 } ;
251256 let metric_kind = MetricKind :: from_datum ( datums[ cols. metric_kind ] ) ;
252257 let name_valid = matches ! ( datums[ cols. name_valid] , Datum :: True ) ;
253- let mut labels: Vec < ( & str , & str ) > = datums[ cols. labels ]
258+ let mut labels: Vec < ( & str , Option < & str > ) > = datums[ cols. labels ]
254259 . unwrap_map ( )
255260 . iter ( )
256- . map ( |( k, v) | ( k, v . unwrap_str ( ) ) )
261+ . map ( |( k, v) | ( k, ( !v . is_null ( ) ) . then ( || v . unwrap_str ( ) ) ) )
257262 . collect ( ) ;
258263 labels. sort ( ) ;
259264 let value = match datums[ cols. value ] {
@@ -269,7 +274,8 @@ fn extract_row<'a>(
269274///
270275/// A null value is its own distinct row identity, not a stand-in for any particular number,
271276/// so it is kept apart from every `Some(_)` identity rather than coerced to
272- /// one.
277+ /// one. A null label value is likewise distinct from a `''` value, so `{a => NULL}` and
278+ /// `{a => ''}` retract only against their own inserts even though both are unpublishable.
273279/// The name and labels lead the tuple so that a `BTreeMap<RowKey, _>` keeps all rows of one
274280/// `(metric_name, labels)` series adjacent.
275281///
@@ -279,7 +285,7 @@ fn extract_row<'a>(
279285/// granularity of the `skipped` count for rows that are never published either way.
280286type RowKey = (
281287 String ,
282- Vec < ( String , String ) > ,
288+ Vec < ( String , Option < String > ) > ,
283289 Option < u64 > ,
284290 Option < MetricKind > ,
285291 bool ,
@@ -363,6 +369,13 @@ fn is_valid_label_name(name: &str) -> bool {
363369 }
364370}
365371
372+ /// Whether a label value can be published as-is. A null (`None`) has no value to encode, and an
373+ /// empty string reads as absent to Prometheus, so `{a => ''}` would fold into `{}`. Both make the
374+ /// row that carries them unpublishable.
375+ fn is_publishable_label_value ( value : Option < & str > ) -> bool {
376+ matches ! ( value, Some ( v) if !v. is_empty( ) )
377+ }
378+
366379impl SinkState {
367380 /// Buffers one ok-collection update under its timestamp.
368381 ///
@@ -375,7 +388,7 @@ impl SinkState {
375388 metric_name : & str ,
376389 metric_kind : Option < MetricKind > ,
377390 name_valid : bool ,
378- labels : & [ ( & str , & str ) ] ,
391+ labels : & [ ( & str , Option < & str > ) ] ,
379392 value : Option < f64 > ,
380393 help : & str ,
381394 time : Timestamp ,
@@ -385,7 +398,7 @@ impl SinkState {
385398 metric_name. to_string ( ) ,
386399 labels
387400 . iter ( )
388- . map ( |& ( k, v) | ( k. to_string ( ) , v. to_string ( ) ) )
401+ . map ( |& ( k, v) | ( k. to_string ( ) , v. map ( str :: to_string ) ) )
389402 . collect ( ) ,
390403 value. map ( f64:: to_bits) ,
391404 metric_kind,
@@ -459,16 +472,19 @@ impl SinkState {
459472 }
460473}
461474
462- /// Counts live working rows dropped for an unsupported `metric_type` or an invalid Prometheus
463- /// metric or label name.
475+ /// Counts live working rows dropped for an unsupported `metric_type`, an invalid Prometheus
476+ /// metric or label name, or a null or empty label value .
464477fn count_skipped ( working : & BTreeMap < RowKey , i64 > ) -> u64 {
465478 let mut skipped = 0u64 ;
466479 for ( ( _name, labels, _bits, metric_kind, name_valid, _help) , acc) in working {
467480 if * acc <= 0 {
468481 continue ;
469482 }
470483 let unsupported = metric_kind. is_none ( ) ;
471- let invalid = !name_valid || !labels. iter ( ) . all ( |( k, _) | is_valid_label_name ( k) ) ;
484+ let invalid = !name_valid
485+ || !labels
486+ . iter ( )
487+ . all ( |( k, v) | is_publishable_label_value ( v. as_deref ( ) ) && is_valid_label_name ( k) ) ;
472488 if unsupported || invalid {
473489 skipped += 1 ;
474490 }
@@ -479,6 +495,9 @@ fn count_skipped(working: &BTreeMap<RowKey, i64>) -> u64 {
479495/// Collapses the live, representable rows of `working` into one published entry per
480496/// `(metric_name, labels)` series and counts colliding and null-suppressed series.
481497///
498+ /// A row whose label set is not representable (an invalid label name, or a null or empty label
499+ /// value) is dropped here and counted by [`count_skipped`] instead.
500+ ///
482501/// A series collides when more than one distinct live non-null value exists for its
483502/// `(metric_name, labels)`: a genuine conflict of two source rows, unlike an ordinary
484503/// value update whose old row is retracted and new row inserted within the same closed
@@ -501,13 +520,31 @@ fn rebuild_published(
501520 let Some ( kind) = metric_kind else {
502521 continue ;
503522 } ;
523+ // A null or empty label value has no representable Prometheus label (an empty value reads
524+ // as absent, folding `{a => ''}` into `{}`), so the whole row is unpublishable. Drop it
525+ // rather than emit a series the source never had.
526+ let Some ( labels) = labels
527+ . iter ( )
528+ . map ( |( k, v) | {
529+ is_publishable_label_value ( v. as_deref ( ) ) . then ( || {
530+ (
531+ k. clone ( ) ,
532+ v. as_ref ( ) . expect ( "publishable is non-null" ) . clone ( ) ,
533+ )
534+ } )
535+ } )
536+ . collect :: < Option < Vec < _ > > > ( )
537+ else {
538+ continue ;
539+ } ;
504540 if !name_valid || !labels. iter ( ) . all ( |( k, _) | is_valid_label_name ( k) ) {
505541 continue ;
506542 }
507- grouped
508- . entry ( ( name. clone ( ) , labels. clone ( ) ) )
509- . or_default ( )
510- . push ( ( bits. map ( f64:: from_bits) , * kind, help. clone ( ) ) ) ;
543+ grouped. entry ( ( name. clone ( ) , labels) ) . or_default ( ) . push ( (
544+ bits. map ( f64:: from_bits) ,
545+ * kind,
546+ help. clone ( ) ,
547+ ) ) ;
511548 }
512549
513550 let mut published = BTreeMap :: new ( ) ;
@@ -660,7 +697,7 @@ impl SinkCollector {
660697 ) ,
661698 skipped_gauge : gauge (
662699 "mz_compute_metric_sink_skipped" ,
663- "The number of input rows skipped for an unsupported metric type or an invalid name." ,
700+ "The number of input rows skipped for an unsupported metric type, an invalid name, or a null or empty label value ." ,
664701 ) ,
665702 conflicts_gauge : gauge (
666703 "mz_compute_metric_sink_conflicts" ,
@@ -727,12 +764,35 @@ mod tests {
727764 }
728765
729766 /// `label_a()`, borrowed: what `stage_ok` now takes (see `extract_row`).
730- const LABEL_A : & [ ( & str , & str ) ] = & [ ( "a" , "1" ) ] ;
767+ const LABEL_A : & [ ( & str , Option < & str > ) ] = & [ ( "a" , Some ( "1" ) ) ] ;
731768
732769 fn key_m ( ) -> PublishedKey {
733770 ( "m" . into ( ) , label_a ( ) )
734771 }
735772
773+ /// Mirrors the shaped relation `shape_metric_sink_source` builds: `labels`/`help` are
774+ /// non-null by construction, `metric_name`/`value` stay nullable, and `metric_kind`/
775+ /// `name_valid` are the planner's computed classification columns.
776+ fn shaped_desc ( ) -> RelationDesc {
777+ use mz_repr:: SqlScalarType ;
778+
779+ RelationDesc :: builder ( )
780+ . with_column ( "metric_name" , SqlScalarType :: String . nullable ( true ) )
781+ . with_column (
782+ "labels" ,
783+ SqlScalarType :: Map {
784+ value_type : Box :: new ( SqlScalarType :: String ) ,
785+ custom_id : None ,
786+ }
787+ . nullable ( false ) ,
788+ )
789+ . with_column ( "value" , SqlScalarType :: Float64 . nullable ( true ) )
790+ . with_column ( "help" , SqlScalarType :: String . nullable ( false ) )
791+ . with_column ( "metric_kind" , SqlScalarType :: Int32 . nullable ( true ) )
792+ . with_column ( "name_valid" , SqlScalarType :: Bool . nullable ( true ) )
793+ . finish ( )
794+ }
795+
736796 /// Stages one gauge update for the `(m, {a:1})` series.
737797 fn stage_m ( st : & mut SinkState , value : f64 , time : u64 , diff : i64 ) {
738798 st. stage_ok (
@@ -891,26 +951,7 @@ mod tests {
891951
892952 #[ mz_ore:: test]
893953 fn extract_row_normalizes_null_datums ( ) {
894- use mz_repr:: SqlScalarType ;
895-
896- // Mirrors the shaped relation `shape_metric_sink_source` builds: `labels`/`help` are
897- // non-null by construction, `metric_name`/`value` stay nullable, and `metric_kind`/
898- // `name_valid` are the planner's computed classification columns.
899- let desc = RelationDesc :: builder ( )
900- . with_column ( "metric_name" , SqlScalarType :: String . nullable ( true ) )
901- . with_column (
902- "labels" ,
903- SqlScalarType :: Map {
904- value_type : Box :: new ( SqlScalarType :: String ) ,
905- custom_id : None ,
906- }
907- . nullable ( false ) ,
908- )
909- . with_column ( "value" , SqlScalarType :: Float64 . nullable ( true ) )
910- . with_column ( "help" , SqlScalarType :: String . nullable ( false ) )
911- . with_column ( "metric_kind" , SqlScalarType :: Int32 . nullable ( true ) )
912- . with_column ( "name_valid" , SqlScalarType :: Bool . nullable ( true ) )
913- . finish ( ) ;
954+ let desc = shaped_desc ( ) ;
914955 let cols = ColumnIndices :: resolve ( & desc) ;
915956
916957 let mut row = Row :: default ( ) ;
@@ -929,11 +970,77 @@ mod tests {
929970 assert_eq ! ( name, "" ) ;
930971 assert_eq ! ( metric_kind, None ) ;
931972 assert ! ( !name_valid) ;
932- assert_eq ! ( labels, Vec :: < ( & str , & str ) > :: new( ) ) ;
973+ assert_eq ! ( labels, Vec :: new( ) ) ;
933974 assert_eq ! ( value, None ) ;
934975 assert_eq ! ( help, "" ) ;
935976 }
936977
978+ /// A null map value must survive extraction as `None`; unwrapping it as a string panicked the
979+ /// worker and took down the replica.
980+ #[ mz_ore:: test]
981+ fn extract_row_keeps_null_label_values ( ) {
982+ let desc = shaped_desc ( ) ;
983+ let cols = ColumnIndices :: resolve ( & desc) ;
984+
985+ let mut row = Row :: default ( ) ;
986+ {
987+ let mut packer = row. packer ( ) ;
988+ packer. push ( Datum :: String ( "m" ) ) ;
989+ packer. push_dict_with ( |row| {
990+ row. push ( Datum :: String ( "bad" ) ) ;
991+ row. push ( Datum :: Null ) ;
992+ row. push ( Datum :: String ( "good" ) ) ;
993+ row. push ( Datum :: String ( "1" ) ) ;
994+ } ) ;
995+ packer. push ( Datum :: Float64 ( 1.0 . into ( ) ) ) ;
996+ packer. push ( Datum :: String ( "h" ) ) ;
997+ packer. push ( Datum :: Int32 ( 0 ) ) ;
998+ packer. push ( Datum :: True ) ;
999+ }
1000+
1001+ let datums: Vec < Datum > = row. iter ( ) . collect ( ) ;
1002+ let ( _name, _metric_kind, _name_valid, labels, _value, _help) = extract_row ( & cols, & datums) ;
1003+ assert_eq ! ( labels, vec![ ( "bad" , None ) , ( "good" , Some ( "1" ) ) ] ) ;
1004+ }
1005+
1006+ #[ mz_ore:: test]
1007+ fn null_or_empty_label_value_skips_row ( ) {
1008+ let mut st = SinkState :: default ( ) ;
1009+ // A null label value has no representable label set: publishes nothing, counts as skipped.
1010+ st. stage_ok (
1011+ "m" ,
1012+ Some ( MetricKind :: Gauge ) ,
1013+ true ,
1014+ & [ ( "a" , None ) ] ,
1015+ Some ( 1.0 ) ,
1016+ "h" ,
1017+ Timestamp :: from ( 1 ) ,
1018+ 1 ,
1019+ ) ;
1020+ st. integrate ( & frontier ( 2 ) ) ;
1021+ st. publish_if_healthy ( ) ;
1022+ assert ! ( st. published. is_empty( ) ) ;
1023+ assert_eq ! ( st. skipped, 1 ) ;
1024+
1025+ // An empty label value folds to `{}` in Prometheus, so it is skipped too rather than
1026+ // published as a bare `{}` series. It is a distinct identity from the null row, so both
1027+ // stay live and count: `skipped` reaches 2, not 1.
1028+ st. stage_ok (
1029+ "m" ,
1030+ Some ( MetricKind :: Gauge ) ,
1031+ true ,
1032+ & [ ( "a" , Some ( "" ) ) ] ,
1033+ Some ( 1.0 ) ,
1034+ "h" ,
1035+ Timestamp :: from ( 3 ) ,
1036+ 1 ,
1037+ ) ;
1038+ st. integrate ( & frontier ( 4 ) ) ;
1039+ st. publish_if_healthy ( ) ;
1040+ assert ! ( st. published. is_empty( ) ) ;
1041+ assert_eq ! ( st. skipped, 2 ) ;
1042+ }
1043+
9371044 fn pkey ( name : & str , labels : & [ ( & str , & str ) ] ) -> PublishedKey {
9381045 (
9391046 name. to_string ( ) ,
0 commit comments