Skip to content

Commit d7b0d9e

Browse files
authored
metric sink: skip rows with a null label value (SQL-645) (#38375)
Problem: A `map[text=>text]` has no per-value nullability, so a metric sink's `labels` map can hold nulls. Such a row hit `extract_row`, which unwrapped the value as a string and panicked the worker. That takes down clusterd, and the sink re-renders over the same persisted row on restart, crash-looping the whole cluster. Separately, `drop_optimizer_notices` handled only `Index` and `MaterializedView`, so a dropped sink's notices were never retracted. Solution: Skip a row whose label set is not representable, counting it in `mz_compute_metric_sink_skipped`. A null value has nothing to encode, and an empty string is not a stand-in either, since Prometheus reads it as absent and would fold `{a => ''}` into `{}`. Retract a dropped sink's notices through a new `dataflow_metainfo_mut`, the mutable twin of the existing `dataflow_metainfo` getter, shared by both drop sites. Testing: - unit tests - a sqllogictest over a filtered, indexed view that exercises both the panic and the notice retraction on `DROP METRIC SINK`. Closes: [SQL-645](https://linear.app/materializeinc/issue/SQL-645)
1 parent fb86c94 commit d7b0d9e

5 files changed

Lines changed: 238 additions & 115 deletions

File tree

src/adapter/src/catalog/apply.rs

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,12 +1598,7 @@ impl CatalogState {
15981598
// Extract notices directly from the owned dropped entries.
15991599
for mut entry in dropped_entries {
16001600
drop_ids.extend(entry.global_ids());
1601-
let metainfo = match entry.item_mut() {
1602-
CatalogItem::Index(idx) => idx.dataflow_metainfo.take(),
1603-
CatalogItem::MaterializedView(mv) => mv.dataflow_metainfo.take(),
1604-
_ => None,
1605-
};
1606-
if let Some(mut metainfo) = metainfo {
1601+
if let Some(metainfo) = entry.item_mut().dataflow_metainfo_mut() {
16071602
soft_assert_or_log!(
16081603
metainfo.optimizer_notices.iter().all_unique(),
16091604
"should have been pushed there by \
@@ -1639,19 +1634,8 @@ impl CatalogState {
16391634
if let Some(entry) = self.try_get_entry_by_global_id(item_id) {
16401635
let catalog_item_id = entry.id();
16411636
let entry = self.get_entry_mut(&catalog_item_id);
1642-
let item = entry.item_mut();
1643-
match item {
1644-
CatalogItem::Index(idx) => {
1645-
if let Some(ref mut m) = idx.dataflow_metainfo {
1646-
m.optimizer_notices.retain(|x| &n != x);
1647-
}
1648-
}
1649-
CatalogItem::MaterializedView(mv) => {
1650-
if let Some(ref mut m) = mv.dataflow_metainfo {
1651-
m.optimizer_notices.retain(|x| &n != x);
1652-
}
1653-
}
1654-
_ => {}
1637+
if let Some(m) = entry.item_mut().dataflow_metainfo_mut() {
1638+
m.optimizer_notices.retain(|x| &n != x);
16551639
}
16561640
}
16571641
}

src/catalog/src/memory/objects.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,6 +1852,16 @@ impl CatalogItem {
18521852
}
18531853
}
18541854

1855+
/// Returns a mutable reference to the dataflow metainfo, if this item has one.
1856+
pub fn dataflow_metainfo_mut(&mut self) -> Option<&mut DataflowMetainfo<Arc<OptimizerNotice>>> {
1857+
match self {
1858+
CatalogItem::Index(idx) => idx.dataflow_metainfo.as_mut(),
1859+
CatalogItem::MaterializedView(mv) => mv.dataflow_metainfo.as_mut(),
1860+
CatalogItem::MetricSink(ms) => ms.dataflow_metainfo.as_mut(),
1861+
_ => None,
1862+
}
1863+
}
1864+
18551865
/// Returns mutable references to the plan fields (`optimized_plan`,
18561866
/// `physical_plan`, `dataflow_metainfo`) on plan-bearing items
18571867
/// (`Index`, `MaterializedView`), or `None` for

src/compute/src/sink/metric_sink.rs

Lines changed: 146 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
202203
struct 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.
236241
fn 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.
280286
type 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+
366379
impl 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.
464477
fn 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

Comments
 (0)