diff --git a/promql/functions.go b/promql/functions.go index 965e47e261d..d98ffe4faed 100644 --- a/promql/functions.go +++ b/promql/functions.go @@ -338,12 +338,16 @@ func extendedRate(vals []parser.Value, args parser.Expressions, enh *EvalNodeHel // yIncrease is a utility function for yincrease/yrate/ydelta. // It calculates the increase of the range (allowing for counter resets if isCounter is true), // taking into account the sample at the end of the previous range (just before rangeStartMsec). -// It returns the result across the range [rangeStartMsec, rangeEndMsec). +// It returns the result across the range (rangeStartMsec, rangeEndMsec]. The left-open, +// right-closed convention matches the Prometheus 3.x range-selector semantics +// (see prometheus/prometheus#13213) so that a sample whose timestamp lands exactly on +// a range boundary is attributed to the later range, never to both or neither. // It always extends the preceding sample's value until the next sample, including the // unwritten origin sample value at the start of every time series. // -// It is a linear function, meaning that for adjacent periods p0 and p1 -// ("adjacent" means p0's rangeEndMsec == p1's rangeStartMsec): +// It is additive over adjacent periods, and therefore composable across any +// partitioning of a wider range into contiguous sub-ranges. For adjacent periods +// p0 and p1 ("adjacent" means p0's rangeEndMsec == p1's rangeStartMsec): // // yIncrease(p0) + yIncrease(p1) == yIncrease(p0 + p1) func yIncrease(points []FPoint, rangeStartMsec, rangeEndMsec int64, isCounter bool) float64 { @@ -356,8 +360,8 @@ func yIncrease(points []FPoint, rangeStartMsec, rangeEndMsec int64, isCounter bo // The points are in time order, so we can just walk the list once and remember the last values // seen "before" and "in" range. If there are no values in range, we use the last value before range // so that the increase is 0. - for i := 0; i < len(points) && points[i].T < rangeEndMsec; i++ { // Only consider points in [rangeStartMsec, rangeEndMsec). - if points[i].T >= rangeStartMsec { + for i := 0; i < len(points) && points[i].T <= rangeEndMsec; i++ { // Only consider points in (rangeStartMsec, rangeEndMsec]. + if points[i].T > rangeStartMsec { if isCounter && points[i].F < lastInRange { // Counter reset (process restart). inRangeRestartSkew += lastInRange } @@ -372,8 +376,8 @@ func yIncrease(points []FPoint, rangeStartMsec, rangeEndMsec int64, isCounter bo // rangeFromSelectors extracts points, rangeStartMsec, rangeEndMsec, and rangeSeconds // from the common (Matrix, MatrixSelector) arguments supplied to yincrease/yrate/ydelta. -// The range is [rangeStartMsec, rangeEndMsec). That is, every sample in range has the property: -// rangeStartMsec <= sample.T < rangeEndMsec. +// The range is (rangeStartMsec, rangeEndMsec]. That is, every sample in range has the property: +// rangeStartMsec < sample.T <= rangeEndMsec. func rangeFromSelectors(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) ([]FPoint, int64, int64, float64) { ms := args[0].(*parser.MatrixSelector) vs := ms.VectorSelector.(*parser.VectorSelector) diff --git a/promql/promqltest/testdata/functions.test b/promql/promqltest/testdata/functions.test index cfe32a14526..9ca78fdb549 100644 --- a/promql/promqltest/testdata/functions.test +++ b/promql/promqltest/testdata/functions.test @@ -20,7 +20,7 @@ eval instant at 25s xrate(http_requests[50s]) eval instant at 25s yrate(http_requests[50s]) {path="/foo"} 0.04 - {path="/bar"} 0.1 + {path="/bar"} 0.12 # 2. Eval 1 second earlier compared to (1). # * path="/foo" rate should be same or fractionally higher ("shorter" sample, same actual increase); @@ -69,8 +69,8 @@ eval instant at 75s xrate(http_requests[50s]) {path="/bar"} .1 eval instant at 75s yrate(http_requests[50s]) - {path="/foo"} 0.06 - {path="/bar"} 0.22 + {path="/foo"} 0.02 + {path="/bar"} 0.1 # 5. Eval 1s earlier compared to (4). # * path="/foo" rate should be same or fractionally lower ("longer" sample, same actual increase). @@ -528,6 +528,70 @@ eval instant at 29m ydelta(http_requests[5m]) clear +# Additivity invariant for the yrate/yincrease/ydelta family. +# +# These functions are additive over adjacent ranges -- which is what makes them +# composable across any partitioning of a wider range into contiguous sub-ranges. +# Because they evaluate over a half-open range (left-inclusive on the 2.53/2.55 +# add-yrate line, right-inclusive after align-yrate-to-3x-range-boundary), two +# adjacent windows partition a wider one without double-counting any sample. +# For any three timestamps T_0 < T_1 < T_2 and range durations r_1 = T_1 - T_0, +# r_2 = T_2 - T_1: +# +# yincrease(m[r_1]) @ T_1 + yincrease(m[r_2]) @ T_2 == yincrease(m[r_1 + r_2]) @ T_2 +# +# Each scenario below picks T_0, T_1, T_2 off-cadence (no sample lands on a range +# boundary) so the expected values are identical under both boundary conventions; +# this block should cherry-pick cleanly across the yrate branch stack. + +# Scenario 1: uniform counter, no resets. T_0=5s, T_1=35s, T_2=75s. +load 10s + additivity_uniform{job="api"} 0+10x10 + +eval instant at 35s yincrease(additivity_uniform[30s]) + {job="api"} 30 + +eval instant at 75s yincrease(additivity_uniform[40s]) + {job="api"} 40 + +eval instant at 75s yincrease(additivity_uniform[70s]) + {job="api"} 70 +# 30 + 40 == 70 + +clear + +# Scenario 2: counter reset in the earlier window. +# Reset between t=40s (value 40) and t=50s (value 0). T_0=5s, T_1=65s, T_2=95s. +load 10s + additivity_reset_early{job="api"} 0 10 20 30 40 0 10 20 30 40 50 + +eval instant at 65s yincrease(additivity_reset_early[60s]) + {job="api"} 50 + +eval instant at 95s yincrease(additivity_reset_early[30s]) + {job="api"} 30 + +eval instant at 95s yincrease(additivity_reset_early[90s]) + {job="api"} 80 +# 50 + 30 == 80 + +clear + +# Scenario 3: counter reset in the later window. +# Reset between t=60s (value 60) and t=70s (value 0). T_0=5s, T_1=45s, T_2=95s. +load 10s + additivity_reset_late{job="api"} 0 10 20 30 40 50 60 0 10 20 30 + +eval instant at 45s yincrease(additivity_reset_late[40s]) + {job="api"} 40 + +eval instant at 95s yincrease(additivity_reset_late[50s]) + {job="api"} 40 + +eval instant at 95s yincrease(additivity_reset_late[90s]) + {job="api"} 80 +# 40 + 40 == 80 + # Tests for idelta(). load 5m http_requests{path="/foo"} 0 50 100 150