Skip to content

Commit 2028927

Browse files
authored
fix: sliding window min() returns wrong value for all-NULL windows (#23874)
## Which issue does this PR close? - Closes #23872 ## Rationale for this change `SlidingMinAccumulator::update_batch` skipped NULL values. This meant that if all the non-NULL values in a window frame were retracted, the window frame would not be empty but the `MovingMin` data structure by the `SlidingMinAccumulator` would not contain any values. This resulted in incorrectly returning a stale non-NULL value for a sliding `min()` over a window frame consisting of only NULL values. Along the way, optimize the min and max sliding window accumulators to make them both more efficient and more symmetric with one another. In the original coding, `SlidingMaxAccumulator` included NULL values but `SlidingMinAccumulator` omitted them, in part because omitting NULLs made the original `retract_batch` implementation more expensive. This PR optimizes `retract_batch`, so we can now use the same scheme for both the min and max sliding accumulators: * Omit NULLs on `update_batch` (this improves on the prior behavior of `max`) * Efficiently account for NULLs in `retract_batch` (this improves on the prior behavior of `min`) * Ensure correct results for all-NULL window frames (this fixes the prior bug in `min`). ## What changes are included in this PR? * Fix bug in `min()` over all-NULL window frames * Optimize `SlidingMinAccumulator::retract_batch` (avoid materializing values just to count NULLs) * Optimize `SlidingMinAccumulator::update_batch` (omit NULLs), also improving symmetry with `min` * Optimize both accumulators to stop caching the current `min` / `max`; this saves a few clones, but perhaps more importantly it is simpler and avoids the risk of inconsistency between the cached value and the underlying `MovingMin` / `MovingMax` data structure ## Are these changes tested? Yes, new tests added. ## Are there any user-facing changes? No, aside from the bug fix.
1 parent 9ab2068 commit 2028927

2 files changed

Lines changed: 111 additions & 32 deletions

File tree

datafusion/functions-aggregate/src/min_max.rs

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -381,38 +381,47 @@ impl AggregateUDFImpl for Max {
381381

382382
#[derive(Debug)]
383383
pub struct SlidingMaxAccumulator {
384-
max: ScalarValue,
384+
/// Typed NULL returned when the window contains no non-null values
385+
empty_value: ScalarValue,
385386
moving_max: MovingMax<ScalarValue>,
386387
}
387388

388389
impl SlidingMaxAccumulator {
389390
/// new max accumulator
390391
pub fn try_new(datatype: &DataType) -> Result<Self> {
391392
Ok(Self {
392-
max: ScalarValue::try_from(datatype)?,
393+
empty_value: ScalarValue::try_from(datatype)?,
393394
moving_max: MovingMax::<ScalarValue>::new(),
394395
})
395396
}
397+
398+
fn current_max(&self) -> ScalarValue {
399+
match self.moving_max.max() {
400+
Some(res) => res.clone(),
401+
None => self.empty_value.clone(),
402+
}
403+
}
396404
}
397405

398406
impl Accumulator for SlidingMaxAccumulator {
399407
fn update_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
400408
for idx in 0..values[0].len() {
401409
let val = ScalarValue::try_from_array(&values[0], idx)?;
402-
self.moving_max.push(val);
403-
}
404-
if let Some(res) = self.moving_max.max() {
405-
self.max = res.clone();
410+
if !val.is_null() {
411+
self.moving_max.push(val);
412+
}
406413
}
407414
Ok(())
408415
}
409416

410417
fn retract_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
411-
for _idx in 0..values[0].len() {
412-
(self.moving_max).pop();
413-
}
414-
if let Some(res) = self.moving_max.max() {
415-
self.max = res.clone();
418+
// We assume that values are retracted in the order they were added, so
419+
// the retracted values must be the oldest elements of `moving_max`.
420+
// NULLs are never pushed, so be sure to only pop once per non-NULL
421+
// value.
422+
let valid_count = values[0].len() - values[0].logical_null_count();
423+
for _ in 0..valid_count {
424+
self.moving_max.pop();
416425
}
417426
Ok(())
418427
}
@@ -422,20 +431,20 @@ impl Accumulator for SlidingMaxAccumulator {
422431
}
423432

424433
fn state(&mut self) -> Result<Vec<ScalarValue>> {
425-
Ok(vec![self.max.clone()])
434+
Ok(vec![self.current_max()])
426435
}
427436

428437
fn evaluate(&mut self) -> Result<ScalarValue> {
429-
Ok(self.max.clone())
438+
Ok(self.current_max())
430439
}
431440

432441
fn supports_retract_batch(&self) -> bool {
433442
true
434443
}
435444

436445
fn size(&self) -> usize {
437-
size_of_val(self) - size_of_val(&self.max)
438-
+ self.max.size()
446+
size_of_val(self) - size_of_val(&self.empty_value)
447+
+ self.empty_value.size()
439448
+ self.moving_max.heap_size(|sv| sv.size() - size_of_val(sv))
440449
}
441450
}
@@ -667,22 +676,30 @@ impl AggregateUDFImpl for Min {
667676

668677
#[derive(Debug)]
669678
pub struct SlidingMinAccumulator {
670-
min: ScalarValue,
679+
/// Typed NULL returned when the window contains no non-null values
680+
empty_value: ScalarValue,
671681
moving_min: MovingMin<ScalarValue>,
672682
}
673683

674684
impl SlidingMinAccumulator {
675685
pub fn try_new(datatype: &DataType) -> Result<Self> {
676686
Ok(Self {
677-
min: ScalarValue::try_from(datatype)?,
687+
empty_value: ScalarValue::try_from(datatype)?,
678688
moving_min: MovingMin::<ScalarValue>::new(),
679689
})
680690
}
691+
692+
fn current_min(&self) -> ScalarValue {
693+
match self.moving_min.min() {
694+
Some(res) => res.clone(),
695+
None => self.empty_value.clone(),
696+
}
697+
}
681698
}
682699

683700
impl Accumulator for SlidingMinAccumulator {
684701
fn state(&mut self) -> Result<Vec<ScalarValue>> {
685-
Ok(vec![self.min.clone()])
702+
Ok(vec![self.current_min()])
686703
}
687704

688705
fn update_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
@@ -692,21 +709,17 @@ impl Accumulator for SlidingMinAccumulator {
692709
self.moving_min.push(val);
693710
}
694711
}
695-
if let Some(res) = self.moving_min.min() {
696-
self.min = res.clone();
697-
}
698712
Ok(())
699713
}
700714

701715
fn retract_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
702-
for idx in 0..values[0].len() {
703-
let val = ScalarValue::try_from_array(&values[0], idx)?;
704-
if !val.is_null() {
705-
(self.moving_min).pop();
706-
}
707-
}
708-
if let Some(res) = self.moving_min.min() {
709-
self.min = res.clone();
716+
// We assume that values are retracted in the order they were added, so
717+
// the retracted values must be the oldest elements of `moving_min`.
718+
// NULLs are never pushed, so be sure to only pop once per non-NULL
719+
// value.
720+
let valid_count = values[0].len() - values[0].logical_null_count();
721+
for _ in 0..valid_count {
722+
self.moving_min.pop();
710723
}
711724
Ok(())
712725
}
@@ -716,16 +729,16 @@ impl Accumulator for SlidingMinAccumulator {
716729
}
717730

718731
fn evaluate(&mut self) -> Result<ScalarValue> {
719-
Ok(self.min.clone())
732+
Ok(self.current_min())
720733
}
721734

722735
fn supports_retract_batch(&self) -> bool {
723736
true
724737
}
725738

726739
fn size(&self) -> usize {
727-
size_of_val(self) - size_of_val(&self.min)
728-
+ self.min.size()
740+
size_of_val(self) - size_of_val(&self.empty_value)
741+
+ self.empty_value.size()
729742
+ self.moving_min.heap_size(|sv| sv.size() - size_of_val(sv))
730743
}
731744
}
@@ -1193,6 +1206,58 @@ mod tests {
11931206
Ok(())
11941207
}
11951208

1209+
#[test]
1210+
fn sliding_min_all_null_window() -> Result<()> {
1211+
let mut min_acc = SlidingMinAccumulator::try_new(&DataType::Int32)?;
1212+
1213+
let values: ArrayRef = Arc::new(Int32Array::from(vec![Some(3), None]));
1214+
min_acc.update_batch(&[Arc::clone(&values)])?;
1215+
assert_eq!(min_acc.evaluate()?, ScalarValue::Int32(Some(3)));
1216+
1217+
// Retract `3`; the window now contains only the NULL
1218+
let retracted: ArrayRef = Arc::new(Int32Array::from(vec![Some(3)]));
1219+
min_acc.retract_batch(&[Arc::clone(&retracted)])?;
1220+
assert_eq!(min_acc.evaluate()?, ScalarValue::Int32(None));
1221+
1222+
// A subsequent non-null value must be picked up again
1223+
let update: ArrayRef = Arc::new(Int32Array::from(vec![Some(7)]));
1224+
min_acc.update_batch(&[Arc::clone(&update)])?;
1225+
assert_eq!(min_acc.evaluate()?, ScalarValue::Int32(Some(7)));
1226+
1227+
// Retracting the NULL row must not pop the remaining value
1228+
let null_row: ArrayRef = Arc::new(Int32Array::from(vec![None::<i32>]));
1229+
min_acc.retract_batch(&[Arc::clone(&null_row)])?;
1230+
assert_eq!(min_acc.evaluate()?, ScalarValue::Int32(Some(7)));
1231+
1232+
Ok(())
1233+
}
1234+
1235+
#[test]
1236+
fn sliding_max_all_null_window() -> Result<()> {
1237+
let mut max_acc = SlidingMaxAccumulator::try_new(&DataType::Int32)?;
1238+
1239+
let values: ArrayRef = Arc::new(Int32Array::from(vec![Some(3), None]));
1240+
max_acc.update_batch(&[Arc::clone(&values)])?;
1241+
assert_eq!(max_acc.evaluate()?, ScalarValue::Int32(Some(3)));
1242+
1243+
// Retract `3`; the window now contains only the NULL
1244+
let retracted: ArrayRef = Arc::new(Int32Array::from(vec![Some(3)]));
1245+
max_acc.retract_batch(&[Arc::clone(&retracted)])?;
1246+
assert_eq!(max_acc.evaluate()?, ScalarValue::Int32(None));
1247+
1248+
// A subsequent non-null value must be picked up again
1249+
let update: ArrayRef = Arc::new(Int32Array::from(vec![Some(7)]));
1250+
max_acc.update_batch(&[Arc::clone(&update)])?;
1251+
assert_eq!(max_acc.evaluate()?, ScalarValue::Int32(Some(7)));
1252+
1253+
// Retracting the NULL row must not disturb the remaining value
1254+
let null_row: ArrayRef = Arc::new(Int32Array::from(vec![None::<i32>]));
1255+
max_acc.retract_batch(&[Arc::clone(&null_row)])?;
1256+
assert_eq!(max_acc.evaluate()?, ScalarValue::Int32(Some(7)));
1257+
1258+
Ok(())
1259+
}
1260+
11961261
#[test]
11971262
fn moving_min_tests() -> Result<()> {
11981263
moving_min_i32(100, 10)?;

datafusion/sqllogictest/test_files/window.slt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6842,3 +6842,17 @@ DROP TABLE issue_20194_t1;
68426842

68436843
statement ok
68446844
DROP TABLE issue_20194_t2;
6845+
6846+
# Sliding-window MIN/MAX over a frame whose non-NULL values have all been
6847+
# retracted should yield NULL.
6848+
query IIII
6849+
SELECT id, x,
6850+
MIN(x) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS min_x,
6851+
MAX(x) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS max_x
6852+
FROM (VALUES (1, 3), (2, NULL), (3, NULL), (4, 7)) t(id, x)
6853+
ORDER BY id
6854+
----
6855+
1 3 3 3
6856+
2 NULL 3 3
6857+
3 NULL NULL NULL
6858+
4 7 7 7

0 commit comments

Comments
 (0)