Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions capacity-forecast.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// Copyright (c) 2015-2026 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package madmin

import (
"context"
"encoding/json"
"net/http"
)

// CapacityForecast contains storage capacity predictions based on
// historical daily snapshots processed through a Kalman filter.
//
// Days-until-threshold fields are pointers: nil means "unknown" (for example,
// not enough history yet, or usage is not growing). A concrete value may be
// negative when the threshold was already crossed in the past.
type CapacityForecast struct {
CurrentUsedBytes uint64 `json:"currentUsedBytes"`
CurrentTotalBytes uint64 `json:"currentTotalBytes"`
CurrentUsedPercent float64 `json:"currentUsedPercent"`

// Days until each usage threshold is reached. The projection comes from
// a Kalman filter that integrates up to 365 daily samples with greater
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't it 90 days/samples? That should be more than plenty.

Kalman has a pretty high recency bias - I wouldn't expect it to produce any different result from 365.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@klauspost the idea is to predict also for companies which flow is longer than 90 days. Need to talk to Kannappan if we need to revert this back to 90.

// weight on recent observations, so a fresh shift in usage dominates
// over older history. nil = unknown (slope is non-positive, or there
// is not enough history).
DaysUntil80Pct *float64 `json:"daysUntil80Pct,omitempty"`
DaysUntil90Pct *float64 `json:"daysUntil90Pct,omitempty"`
DaysUntil100Pct *float64 `json:"daysUntil100Pct,omitempty"`

// GrowthBytesPerDay is the Kalman filter slope expressed in bytes per
// day, projected from the daily snapshots in the circular buffer.
// It is independent of DailySnapshotCount: the filter is recency
// weighted, not a delta between two endpoints.
GrowthBytesPerDay int64 `json:"growthBytesPerDay"`

// DailySnapshotCount is the number of valid daily snapshots currently
// held in the year-long circular buffer (range 0..365). The forecast
// fields above are only populated when this count reaches the
// minimum required for the filter to produce stable estimates.
DailySnapshotCount int `json:"dailySnapshotCount"`

// Worst-case prediction based on the largest single-day growth
// observed between any two consecutive data points. nil = unknown.
MinDaysUntilFull *float64 `json:"minDaysUntilFull,omitempty"`

Variance float64 `json:"variance"` // variance of daily usedFraction deltas

// Smallest and largest day-to-day changes in used bytes observed
// between consecutive snapshots. A negative value means space was
// freed between those two days.
DayMinDeltaBytes int64 `json:"dayMinDeltaBytes"`
DayMaxDeltaBytes int64 `json:"dayMaxDeltaBytes"`

// Recency-weighted predictions from the Kalman filter.
RecentGrowthRatePerDay float64 `json:"recentGrowthRatePerDay"`
RecentDaysUntilFull *float64 `json:"recentDaysUntilFull,omitempty"`
}

// CapacityForecast returns a storage capacity forecast based on
// historical daily snapshots.
func (adm *AdminClient) CapacityForecast(ctx context.Context) (CapacityForecast, error) {
resp, err := adm.executeMethod(ctx,
http.MethodGet,
Comment thread
cniackz marked this conversation as resolved.
requestData{
relPath: adminAPIPrefix + "/capacity-forecast",
})
defer closeResponse(resp)
if err != nil {
return CapacityForecast{}, err
}

if resp.StatusCode != http.StatusOK {
return CapacityForecast{}, httpRespToErrorResponse(resp)
}

var f CapacityForecast
if err = json.NewDecoder(resp.Body).Decode(&f); err != nil {
return CapacityForecast{}, err
}

return f, nil
}
146 changes: 146 additions & 0 deletions capacity-forecast_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//
// Copyright (c) 2015-2026 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//

package madmin

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func newCapacityForecastTestClient(t *testing.T, serverURL string) *AdminClient {
t.Helper()
client, err := New(mustParseHost(t, serverURL), "ak", "sk", false)
if err != nil {
t.Fatalf("New: %v", err)
}
return client
}

// TestCapacityForecastRequest verifies the client issues GET against the
// capacity-forecast admin path.
func TestCapacityForecastRequest(t *testing.T) {
var capturedMethod, capturedPath string

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedMethod = r.Method
capturedPath = r.URL.Path
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}))
defer server.Close()

if _, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background()); err != nil {
t.Fatalf("CapacityForecast: %v", err)
}

if capturedMethod != http.MethodGet {
t.Errorf("expected GET, got %s", capturedMethod)
}
if !strings.HasPrefix(capturedPath, "/minio/admin/") {
t.Errorf("path missing /minio/admin/ prefix: %s", capturedPath)
}
if !strings.HasSuffix(capturedPath, "/capacity-forecast") {
t.Errorf("path missing /capacity-forecast suffix: %s", capturedPath)
}
}

// TestCapacityForecastDecode covers JSON decoding for both nil and non-nil
// pointer fields, including negative values for thresholds already crossed.
func TestCapacityForecastDecode(t *testing.T) {
cases := []struct {
name string
body string
wantNil80 bool
wantNil100 bool
wantVal80 float64
wantNegative bool
}{
{
name: "days-until fields omitted produce nil pointers",
body: `{"currentUsedBytes":100,"currentTotalBytes":1000,"dailySnapshotCount":7}`,
wantNil80: true,
wantNil100: true,
},
{
name: "positive days-until values decode as expected",
body: `{"daysUntil80Pct":42.5,"daysUntil100Pct":120}`,
wantNil80: false,
wantVal80: 42.5,
},
{
name: "negative days-until preserved (threshold already crossed)",
body: `{"daysUntil80Pct":-3.2,"daysUntil100Pct":-1}`,
wantNil80: false,
wantVal80: -3.2,
wantNegative: true,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(tc.body))
}))
defer server.Close()

f, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background())
if err != nil {
t.Fatalf("CapacityForecast: %v", err)
}

if tc.wantNil80 {
if f.DaysUntil80Pct != nil {
t.Errorf("expected DaysUntil80Pct nil, got %v", *f.DaysUntil80Pct)
}
} else {
if f.DaysUntil80Pct == nil {
t.Fatal("expected DaysUntil80Pct non-nil")
}
if *f.DaysUntil80Pct != tc.wantVal80 {
t.Errorf("DaysUntil80Pct: want %v, got %v", tc.wantVal80, *f.DaysUntil80Pct)
}
if tc.wantNegative && *f.DaysUntil80Pct >= 0 {
t.Errorf("expected negative value, got %v", *f.DaysUntil80Pct)
}
}

if tc.wantNil100 && f.DaysUntil100Pct != nil {
t.Errorf("expected DaysUntil100Pct nil, got %v", *f.DaysUntil100Pct)
}
})
}
}

// TestCapacityForecastHTTPError verifies non-200 responses produce an error
// rather than a zero-value struct.
func TestCapacityForecastHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()

if _, err := newCapacityForecastTestClient(t, server.URL).CapacityForecast(context.Background()); err == nil {
t.Fatal("expected error on 500, got nil")
}
}
Loading