Skip to content

Commit 61bf03a

Browse files
committed
Added Hampel+MAD baseline with a configurable (default 6h) window and outlier ejection. The new baselining supports defer and partial window support for the initial 6h measurement window. The existing baseline is still available as a selectable strategy.
1 parent dc3e102 commit 61bf03a

14 files changed

Lines changed: 953 additions & 49 deletions

operator/api/v1alpha1/storagecluster_types.go

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,40 @@ const (
111111
MetricsBackendUniform MetricsBackend = "uniform"
112112
)
113113

114+
// BaselineStrategy selects how the per-node latency baseline (the denominator of the
115+
// rebalancing deviation signal) is derived.
116+
// +kubebuilder:validation:Enum=benchmark;rollingWindow
117+
type BaselineStrategy string
118+
119+
const (
120+
// BaselineStrategyBenchmark uses the one-shot fio measurement taken by the baseline
121+
// Job on a fresh cluster and frozen on the StorageNode CR status. Simple but tends to
122+
// read too low, because an idle cluster is far faster than a loaded one — every loaded
123+
// node then shows a large deviation.
124+
BaselineStrategyBenchmark BaselineStrategy = "benchmark"
125+
// BaselineStrategyRollingWindow derives the baseline from a rolling window of the
126+
// probe-sidecar latency series in Prometheus, using a robust outlier-rejecting
127+
// estimator. Reflects each node's actual recent operating latency rather than an idle
128+
// measurement. This is the default.
129+
BaselineStrategyRollingWindow BaselineStrategy = "rollingWindow"
130+
)
131+
132+
// BaselineColdStartPolicy selects what happens for a node that has fewer than
133+
// BaselineMinSamples samples in the rolling window (e.g. a freshly onboarded node, or
134+
// shortly after the probe sidecar starts).
135+
// +kubebuilder:validation:Enum=defer;partialWindow
136+
type BaselineColdStartPolicy string
137+
138+
const (
139+
// BaselineColdStartDefer omits an under-sampled node from the evaluation cycle: it is
140+
// neither a migration source nor a target until it has accumulated BaselineMinSamples
141+
// samples. Avoids acting on a noisy baseline.
142+
BaselineColdStartDefer BaselineColdStartPolicy = "defer"
143+
// BaselineColdStartPartialWindow computes the baseline from whatever samples exist,
144+
// accepting a noisier baseline early on so rebalancing engages sooner. This is the default.
145+
BaselineColdStartPartialWindow BaselineColdStartPolicy = "partialWindow"
146+
)
147+
114148
// VolumeAutoPlacementSettings controls the automatic, latency-driven volume rebalancing
115149
// behaviour. It is configured under StorageClusterSpec.VolumeAutoPlacement.
116150
type VolumeAutoPlacementSettings struct {
@@ -157,9 +191,32 @@ type VolumeAutoPlacementSettings struct {
157191
// +optional
158192
LatencyBenchmarkEnabled *bool `json:"latencyBenchmarkEnabled,omitempty"`
159193
// LatencyBenchmarkInterval is how often fio benchmark Jobs run against each storage node.
160-
// Defaults to 5m.
194+
// It also sets the step of the rolling-window baseline query (the cadence at which the
195+
// probe sidecar publishes latency samples). Defaults to 5m.
161196
// +optional
162197
LatencyBenchmarkInterval *metav1.Duration `json:"latencyBenchmarkInterval,omitempty"`
198+
// BaselineStrategy selects how the per-node latency baseline is derived. Defaults to
199+
// "rollingWindow" (robust estimate over BaselineWindow of the Prometheus latency series);
200+
// "benchmark" uses the frozen one-shot fio measurement instead.
201+
// +optional
202+
BaselineStrategy *BaselineStrategy `json:"baselineStrategy,omitempty"`
203+
// BaselineWindow is the look-back window used by the "rollingWindow" strategy. Defaults to 6h.
204+
// +optional
205+
BaselineWindow *metav1.Duration `json:"baselineWindow,omitempty"`
206+
// BaselineColdStart selects what happens for a node with fewer than BaselineMinSamples
207+
// samples in the window. Defaults to "partialWindow" (compute from available samples);
208+
// "defer" skips the node until enough samples exist.
209+
// +optional
210+
BaselineColdStart *BaselineColdStartPolicy `json:"baselineColdStart,omitempty"`
211+
// BaselineMinSamples is the number of samples below which a node is considered
212+
// under-sampled (see BaselineColdStart). Defaults to 6.
213+
// +optional
214+
BaselineMinSamples *int32 `json:"baselineMinSamples,omitempty"`
215+
// BaselineOutlierK is the Hampel-identifier threshold: a sample is rejected as an outlier
216+
// when it lies more than k·1.4826·MAD from the window median. Lower is more aggressive.
217+
// Defaults to 3.0.
218+
// +optional
219+
BaselineOutlierK *float64 `json:"baselineOutlierK,omitempty"`
163220
// IOPSWeight is the weight applied to per-volume IOPS in the volume IO score. Defaults to 1.0.
164221
// +optional
165222
IOPSWeight *float64 `json:"iopsWeight,omitempty"`

operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

operator/config/crd/bases/storage.simplyblock.io_storageclusters.yaml

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,40 @@ spec:
220220
VolumeAutoPlacement configures automatic, latency-driven volume rebalancing. When
221221
nil/disabled the operator performs only manually-triggered VolumeMigrations.
222222
properties:
223+
baselineColdStart:
224+
description: |-
225+
BaselineColdStart selects what happens for a node with fewer than BaselineMinSamples
226+
samples in the window. Defaults to "partialWindow" (compute from available samples);
227+
"defer" skips the node until enough samples exist.
228+
enum:
229+
- defer
230+
- partialWindow
231+
type: string
232+
baselineMinSamples:
233+
description: |-
234+
BaselineMinSamples is the number of samples below which a node is considered
235+
under-sampled (see BaselineColdStart). Defaults to 6.
236+
format: int32
237+
type: integer
238+
baselineOutlierK:
239+
description: |-
240+
BaselineOutlierK is the Hampel-identifier threshold: a sample is rejected as an outlier
241+
when it lies more than k·1.4826·MAD from the window median. Lower is more aggressive.
242+
Defaults to 3.0.
243+
type: number
244+
baselineStrategy:
245+
description: |-
246+
BaselineStrategy selects how the per-node latency baseline is derived. Defaults to
247+
"rollingWindow" (robust estimate over BaselineWindow of the Prometheus latency series);
248+
"benchmark" uses the frozen one-shot fio measurement instead.
249+
enum:
250+
- benchmark
251+
- rollingWindow
252+
type: string
253+
baselineWindow:
254+
description: BaselineWindow is the look-back window used by the
255+
"rollingWindow" strategy. Defaults to 6h.
256+
type: string
223257
defaultCoolDownSeconds:
224258
description: |-
225259
DefaultCoolDownSeconds is the cool-down period (seconds) applied to a volume after
@@ -252,7 +286,8 @@ spec:
252286
latencyBenchmarkInterval:
253287
description: |-
254288
LatencyBenchmarkInterval is how often fio benchmark Jobs run against each storage node.
255-
Defaults to 5m.
289+
It also sets the step of the rolling-window baseline query (the cadence at which the
290+
probe sidecar publishes latency samples). Defaults to 5m.
256291
type: string
257292
maxVolumeMigrationsPerCycle:
258293
description: MaxVolumeMigrationsPerCycle is the maximum number
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package autoplacement
2+
3+
import (
4+
"math"
5+
"sort"
6+
)
7+
8+
const (
9+
// madToSigma scales the median absolute deviation (MAD) to an estimate of the standard
10+
// deviation for normally distributed data; it makes the Hampel k threshold comparable to
11+
// a number of standard deviations.
12+
madToSigma = 1.4826
13+
// meanAbsDevToSigma is the equivalent scale factor for the mean absolute deviation, used
14+
// only in the degenerate MAD==0 fallback.
15+
meanAbsDevToSigma = 1.2533
16+
)
17+
18+
// robustBaselineNS reduces a set of latency samples (ns) to a single baseline using the
19+
// Hampel identifier: samples further than k·1.4826·MAD from the window median are rejected
20+
// as outliers, and the median of the survivors is returned. The Hampel identifier has the
21+
// highest possible breakdown point (50%) — both its centre (median) and its scale (MAD) are
22+
// themselves robust, so the extreme journal/EC/HA spikes it is meant to reject cannot inflate
23+
// the threshold and hide themselves.
24+
//
25+
// Degrade rules:
26+
// - fewer than 3 samples: rejection is not meaningful, so the plain median is returned;
27+
// - MAD == 0 (a majority of identical samples): fall back to the mean absolute deviation,
28+
// and if that is also 0 (all samples identical) skip rejection entirely.
29+
//
30+
// kept and rejected count survivors and rejects (kept+rejected == len(samples)). ok is false
31+
// only when samples is empty.
32+
func robustBaselineNS(samples []float64, k float64) (baselineNS int64, kept, rejected int, ok bool) {
33+
if len(samples) == 0 {
34+
return 0, 0, 0, false
35+
}
36+
if len(samples) < 3 {
37+
return int64(math.Round(median(samples))), len(samples), 0, true
38+
}
39+
40+
m := median(samples)
41+
scale := madToSigma * medianAbsDev(samples, m)
42+
if scale == 0 {
43+
// MAD degenerate (majority identical): fall back to the mean absolute deviation.
44+
scale = meanAbsDevToSigma * meanAbsDev(samples, m)
45+
}
46+
if scale == 0 {
47+
// All samples identical: nothing to reject.
48+
return int64(math.Round(m)), len(samples), 0, true
49+
}
50+
51+
threshold := k * scale
52+
survivors := make([]float64, 0, len(samples))
53+
for _, x := range samples {
54+
if math.Abs(x-m) <= threshold {
55+
survivors = append(survivors, x)
56+
}
57+
}
58+
// The median always survives, so survivors is never empty; guard defensively anyway.
59+
if len(survivors) == 0 {
60+
survivors = samples
61+
}
62+
return int64(math.Round(median(survivors))), len(survivors), len(samples) - len(survivors), true
63+
}
64+
65+
// median returns the median of xs without mutating it. Returns 0 for an empty slice.
66+
func median(xs []float64) float64 {
67+
n := len(xs)
68+
if n == 0 {
69+
return 0
70+
}
71+
sorted := make([]float64, n)
72+
copy(sorted, xs)
73+
sort.Float64s(sorted)
74+
mid := n / 2
75+
if n%2 == 1 {
76+
return sorted[mid]
77+
}
78+
return (sorted[mid-1] + sorted[mid]) / 2
79+
}
80+
81+
// medianAbsDev returns the median of the absolute deviations of xs from center.
82+
func medianAbsDev(xs []float64, center float64) float64 {
83+
devs := make([]float64, len(xs))
84+
for i, x := range xs {
85+
devs[i] = math.Abs(x - center)
86+
}
87+
return median(devs)
88+
}
89+
90+
// meanAbsDev returns the mean of the absolute deviations of xs from center.
91+
func meanAbsDev(xs []float64, center float64) float64 {
92+
if len(xs) == 0 {
93+
return 0
94+
}
95+
var sum float64
96+
for _, x := range xs {
97+
sum += math.Abs(x - center)
98+
}
99+
return sum / float64(len(xs))
100+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package autoplacement
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
simplyblockv1alpha1 "github.com/simplyblock/simplyblock-operator/api/v1alpha1"
8+
promlatency "github.com/simplyblock/simplyblock-operator/internal/metrics/prometheus"
9+
"sigs.k8s.io/controller-runtime/pkg/client"
10+
)
11+
12+
// BaselineProvider resolves the per-node latency baseline (ns at the configured percentile)
13+
// that forms the denominator of the rebalancing deviation signal. Nodes without a usable
14+
// baseline are omitted from the returned map — ComputeLatencyDeviationPct then reads 0 for
15+
// them, so they are neither flagged hot nor preferred as targets.
16+
type BaselineProvider interface {
17+
BaselineNS(ctx context.Context, inputs ...StorageNodeSelectorInput) (map[string]int64, error)
18+
}
19+
20+
// newBaselineProvider selects the BaselineProvider implementation for cfg.BaselineStrategy.
21+
// "benchmark" reads the frozen one-shot fio measurement from the StorageNodeSet CRs;
22+
// rollingWindow (the default, and the fallback for any unrecognised value) derives a robust
23+
// estimate from a rolling window of the probe latency series in Prometheus.
24+
func newBaselineProvider(k8sClient client.Client, cfg RebalancingConfig) (BaselineProvider, error) {
25+
if cfg.BaselineStrategy == string(simplyblockv1alpha1.BaselineStrategyBenchmark) {
26+
return &benchmarkBaselineProvider{client: k8sClient, percentile: cfg.LatencyPercentile}, nil
27+
}
28+
provider, err := promlatency.New(cfg.PrometheusURL)
29+
if err != nil {
30+
return nil, fmt.Errorf("create prometheus baseline provider: %w", err)
31+
}
32+
return &rollingWindowBaselineProvider{prom: provider, cfg: cfg}, nil
33+
}
34+
35+
// benchmarkBaselineProvider reads the one-shot fio baseline recorded once per node by the
36+
// baseline Job and stored on StorageNodeSet.status.latencyMetrics.
37+
type benchmarkBaselineProvider struct {
38+
client client.Client
39+
percentile string
40+
}
41+
42+
func (b *benchmarkBaselineProvider) BaselineNS(
43+
ctx context.Context,
44+
inputs ...StorageNodeSelectorInput,
45+
) (map[string]int64, error) {
46+
result := make(map[string]int64)
47+
for _, input := range inputs {
48+
var snodeList simplyblockv1alpha1.StorageNodeSetList
49+
if err := b.client.List(ctx, &snodeList, client.InNamespace(input.Namespace)); err != nil {
50+
// Stay resilient to a transient list error: skip this namespace rather than
51+
// failing the whole evaluation cycle (matches the previous CR-read behaviour).
52+
continue
53+
}
54+
for _, snode := range snodeList.Items {
55+
for _, lm := range snode.Status.LatencyMetrics {
56+
baseline := lm.BaselineP50NS
57+
if b.percentile == promlatency.PercentileP99 {
58+
baseline = lm.BaselineP99NS
59+
}
60+
if baseline > 0 {
61+
result[lm.NodeUUID] = baseline
62+
}
63+
}
64+
}
65+
}
66+
return result, nil
67+
}
68+
69+
// rollingWindowBaselineProvider derives each node's baseline from a rolling window of the
70+
// probe-sidecar latency series in Prometheus, reduced to a single value by robustBaselineNS
71+
// (Hampel outlier rejection + median of survivors). It emits the per-node baseline and
72+
// sample-count gauges as a side effect.
73+
type rollingWindowBaselineProvider struct {
74+
prom *promlatency.Provider
75+
cfg RebalancingConfig
76+
}
77+
78+
func (r *rollingWindowBaselineProvider) BaselineNS(
79+
ctx context.Context,
80+
inputs ...StorageNodeSelectorInput,
81+
) (map[string]int64, error) {
82+
clusterIDs := distinctClusterUUIDs(inputs)
83+
windowed, err := r.prom.GetClustersWindowedLatency(
84+
ctx, clusterIDs, r.cfg.LatencyPercentile, r.cfg.BaselineWindow, r.cfg.BaselineStep,
85+
)
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
reduced := reduceWindowedBaselines(windowed, r.cfg)
91+
result := make(map[string]int64, len(reduced))
92+
for _, nb := range reduced {
93+
result[nb.nodeUUID] = nb.baselineNS
94+
setBaselineGauges(nb.clusterUUID, nb.nodeUUID, nb.baselineNS, nb.samplesTotal, nb.samplesRejected)
95+
}
96+
return result, nil
97+
}
98+
99+
// nodeBaseline is one node's reduced rolling-window baseline plus the sample diagnostics.
100+
type nodeBaseline struct {
101+
clusterUUID string
102+
nodeUUID string
103+
baselineNS int64
104+
samplesTotal int
105+
samplesRejected int
106+
}
107+
108+
// reduceWindowedBaselines reduces per-node windowed samples to a single robust baseline each,
109+
// applying the cold-start policy. It is pure (no Prometheus, no metrics) so the cold-start
110+
// and estimator behaviour can be tested directly. A node is dropped when it is under-sampled
111+
// under the "defer" policy, or when no positive baseline can be computed from its samples.
112+
func reduceWindowedBaselines(
113+
windowed map[string]map[string][]float64,
114+
cfg RebalancingConfig,
115+
) []nodeBaseline {
116+
deferUnderSampled := cfg.BaselineColdStart == string(simplyblockv1alpha1.BaselineColdStartDefer)
117+
118+
var out []nodeBaseline
119+
for clusterUUID, byNode := range windowed {
120+
for nodeUUID, samples := range byNode {
121+
// Cold start: an under-sampled node is either skipped ("defer") or computed
122+
// from whatever samples exist ("partialWindow").
123+
if len(samples) < cfg.BaselineMinSamples && deferUnderSampled {
124+
continue
125+
}
126+
baselineNS, kept, rejected, ok := robustBaselineNS(samples, cfg.BaselineOutlierK)
127+
if !ok || baselineNS <= 0 {
128+
continue
129+
}
130+
out = append(out, nodeBaseline{
131+
clusterUUID: clusterUUID,
132+
nodeUUID: nodeUUID,
133+
baselineNS: baselineNS,
134+
samplesTotal: kept + rejected,
135+
samplesRejected: rejected,
136+
})
137+
}
138+
}
139+
return out
140+
}

0 commit comments

Comments
 (0)