-
Notifications
You must be signed in to change notification settings - Fork 110
Add capacity forecast client method #571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+245
−0
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
4579073
feat: add CapacityForecast client method
cniackz cc63809
feat: add worst case, confidence, recency fields
cniackz a85325c
feat: add DayMinDelta and DayMaxDelta fields to CapacityForecast
cniackz 7a9a068
refactor: use *float64 pointers for optional days-until fields
cniackz 2cbb4a7
docs: update CapacityForecast comments for Kalman filter
cniackz 67c73a1
capacity-forecast: drop RSquared field from CapacityForecast
cniackz 941640d
capacity-forecast: rename fields with explicit units
cniackz 5de3ab9
capacity-forecast: describe the days-until projection
cniackz b957d44
capacity-forecast: expose min/max daily deltas in bytes
cniackz b373641
Merge remote-tracking branch 'upstream/main' into feat/capacity-forecast
cniackz a70ee5b
test: add httptest coverage for CapacityForecast client method
cniackz 445b4dd
test: silence unused-parameter lint on capacity-forecast_test handlers
cniackz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| // 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, | ||
|
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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.