This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/pool"
|
||||
"github.com/itworx/pulse/internal/share"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type HistoryReader interface {
|
||||
Points(context.Context, string, string, time.Time, int) ([]Point, error)
|
||||
}
|
||||
|
||||
type PostgresHistory struct{ Pool *pgxpool.Pool }
|
||||
|
||||
func (r PostgresHistory) Points(ctx context.Context, kind, entityID string, since time.Time, limit int) ([]Point, error) {
|
||||
if r.Pool == nil {
|
||||
return nil, errors.New("capacity history store is unavailable")
|
||||
}
|
||||
if (kind != "share" && kind != "pool" && kind != "disk") || strings.TrimSpace(entityID) == "" || limit < 1 || limit > 512 {
|
||||
return nil, errors.New("capacity history query is invalid")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `SELECT observed_at,used_bytes FROM (
|
||||
SELECT DISTINCT ON (sampled_at) sampled_at,observed_at,used_bytes,source_id
|
||||
FROM capacity_samples
|
||||
WHERE entity_kind=$1 AND entity_id=$2 AND sampled_at >= $3
|
||||
ORDER BY sampled_at DESC,source_id ASC
|
||||
LIMIT $4
|
||||
) history ORDER BY sampled_at ASC`, kind, entityID, since.UTC(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
points := make([]Point, 0, limit)
|
||||
for rows.Next() {
|
||||
var point Point
|
||||
if err := rows.Scan(&point.ObservedAt, &point.UsedBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.ObservedAt = point.ObservedAt.UTC()
|
||||
points = append(points, point)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
type StorageProvider struct {
|
||||
Shares share.Provider
|
||||
Pools pool.Provider
|
||||
History HistoryReader
|
||||
Policy Policy
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (p StorageProvider) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if ctx == nil {
|
||||
return Snapshot{}, errors.New("forecast context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.Now != nil {
|
||||
now = p.Now().UTC()
|
||||
}
|
||||
policy := p.Policy.withDefaults()
|
||||
if err := policy.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
view := PolicyView{Enabled: policy.Enabled, WindowSeconds: int64(policy.Window / time.Second), MinPoints: policy.MinPoints, Method: MethodLinearMedian}
|
||||
if !policy.Enabled {
|
||||
view.Method = MethodDisabled
|
||||
}
|
||||
if p.Shares == nil || p.Pools == nil {
|
||||
unknown := UnknownSnapshot(now, "source_unavailable")
|
||||
unknown.Policy = view
|
||||
return unknown, nil
|
||||
}
|
||||
shares, err := p.Shares.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
pools, err := p.Pools.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if len(shares.Shares) == 0 {
|
||||
reason := shares.Source.Reason
|
||||
if reason == "" {
|
||||
reason = "no_capacity_entities"
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, GeneratedAt: now, Policy: view, Items: []Forecast{}, Reason: reason}, nil
|
||||
}
|
||||
capacities := make(map[string]uint64, len(pools.Pools))
|
||||
for _, item := range pools.Pools {
|
||||
if item.UsableBytes > 0 {
|
||||
capacities[canonical(item.ID)] = item.UsableBytes
|
||||
}
|
||||
}
|
||||
items := make([]Forecast, 0, len(shares.Shares))
|
||||
qualified := 0
|
||||
for _, item := range shares.Shares {
|
||||
points := pointsFromShare(item)
|
||||
if p.History != nil {
|
||||
// Reserve one slot for a current observation that may not have reached
|
||||
// its six-hour history bucket yet.
|
||||
points, err = p.History.Points(ctx, "share", item.ID, now.Add(-policy.Window), policy.MaxPoints-1)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
points = mergeCurrentPoint(points, item)
|
||||
}
|
||||
capacity := capacityForShare(item, capacities)
|
||||
forecast, predictErr := Predict(item.ID, item.Name, "share", capacity, points, now, policy)
|
||||
if predictErr != nil {
|
||||
return Snapshot{}, predictErr
|
||||
}
|
||||
if shares.Source.Freshness == share.Stale || item.SizeState == share.SizeStale {
|
||||
forecast.Method = MethodInsufficient
|
||||
forecast.Confidence = ConfidenceNone
|
||||
forecast.DaysToCapacity = nil
|
||||
forecast.ProjectedAt = nil
|
||||
forecast.Reason = "history_stale"
|
||||
} else if item.SizeState == share.SizeUnknown {
|
||||
forecast.Method = MethodInsufficient
|
||||
forecast.Confidence = ConfidenceNone
|
||||
forecast.DaysToCapacity = nil
|
||||
forecast.ProjectedAt = nil
|
||||
forecast.Reason = "history_unavailable"
|
||||
}
|
||||
if forecast.Confidence == ConfidenceHigh || forecast.Confidence == ConfidenceMedium {
|
||||
qualified++
|
||||
}
|
||||
items = append(items, forecast)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Name != items[j].Name {
|
||||
return items[i].Name < items[j].Name
|
||||
}
|
||||
return items[i].EntityID < items[j].EntityID
|
||||
})
|
||||
return Snapshot{ContractVersion: ContractVersion, GeneratedAt: now, Policy: view, Items: items, QualifiedCount: qualified}, nil
|
||||
}
|
||||
|
||||
func mergeCurrentPoint(points []Point, item share.Share) []Point {
|
||||
if item.SizeObservedAt.IsZero() {
|
||||
return points
|
||||
}
|
||||
result := append([]Point(nil), points...)
|
||||
for index := range result {
|
||||
if result[index].ObservedAt.Equal(item.SizeObservedAt) {
|
||||
result[index].UsedBytes = item.UsedBytes
|
||||
return result
|
||||
}
|
||||
}
|
||||
return append(result, Point{ObservedAt: item.SizeObservedAt, UsedBytes: item.UsedBytes})
|
||||
}
|
||||
|
||||
func pointsFromShare(item share.Share) []Point {
|
||||
points := make([]Point, 0, len(item.GrowthHistory)+1)
|
||||
for _, point := range item.GrowthHistory {
|
||||
points = append(points, Point{ObservedAt: point.ObservedAt, UsedBytes: point.UsedBytes})
|
||||
}
|
||||
if !item.SizeObservedAt.IsZero() {
|
||||
found := false
|
||||
for index := range points {
|
||||
if points[index].ObservedAt.Equal(item.SizeObservedAt) {
|
||||
points[index].UsedBytes = item.UsedBytes
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
points = append(points, Point{ObservedAt: item.SizeObservedAt, UsedBytes: item.UsedBytes})
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func capacityForShare(item share.Share, capacities map[string]uint64) uint64 {
|
||||
seen := make(map[string]struct{})
|
||||
var total uint64
|
||||
for _, placement := range item.Placements {
|
||||
id := canonical(placement.PoolID)
|
||||
capacity, exists := capacities[id]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[id]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if ^uint64(0)-total < capacity {
|
||||
return ^uint64(0)
|
||||
}
|
||||
total += capacity
|
||||
}
|
||||
if total == 0 {
|
||||
total = capacities[canonical(item.StoragePolicy.PrimaryPool)]
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func canonical(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
@@ -0,0 +1,70 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/agentstore"
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
"github.com/itworx/pulse/internal/pool"
|
||||
"github.com/itworx/pulse/internal/share"
|
||||
)
|
||||
|
||||
func TestStorageForecastPostgreSQLHistory(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
db, err := database.NewPool(ctx, database.Config{URL: dsn})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := database.Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := fmt.Sprintf("forecast-%x", time.Now().UnixNano())
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
for _, point := range []struct {
|
||||
at time.Time
|
||||
used int64
|
||||
}{{now.Add(-14 * 24 * time.Hour), 200}, {now.Add(-7 * 24 * time.Hour), 300}, {now, 400}} {
|
||||
_, err := db.Exec(ctx, `INSERT INTO capacity_samples (entity_kind,entity_id,entity_name,source_id,sampled_at,observed_at,used_bytes,capacity_bytes) VALUES ('share',$1,'Media',$2,$3,$3,$4,0)`, run, run, point.at, point.used)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
store := agentstore.PostgresStore{Pool: db, Clock: func() time.Time { return now }}
|
||||
sharePayload, err := json.Marshal(share.RawSnapshot{Source: share.Source{ID: "forecast-test", Type: "test"}, ObservedAt: now, ReceivedAt: now, Shares: []share.RawShare{{ID: run, Name: "Media forecast", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.RawPlacement{{PoolID: "cache"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poolPayload, err := json.Marshal(pool.RawSnapshot{Source: pool.Source{ID: "forecast-test", Type: "test"}, ObservedAt: now, ReceivedAt: now, Pools: []pool.RawPool{{ID: "cache", Name: "Cache", State: pool.StateHealthy, UsableBytes: 1000, UsedBytes: 400, Capabilities: pool.Capabilities{Capacity: pool.CapabilityAvailable}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Put(ctx, agentstore.Snapshot{AgentID: run, Capability: agentstore.CapabilityShares, ObservedAt: now, Payload: sharePayload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Put(ctx, agentstore.Snapshot{AgentID: run, Capability: agentstore.CapabilityPools, ObservedAt: now, Payload: poolPayload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := StorageProvider{
|
||||
Shares: staticShares{share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{{ID: run, Name: "Media", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}}}}},
|
||||
Pools: staticPools{pool.Snapshot{Pools: []pool.Pool{{ID: "cache", UsableBytes: 1000}}}},
|
||||
History: PostgresHistory{Pool: db}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now },
|
||||
}
|
||||
snapshot, err := provider.Snapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.QualifiedCount != 1 || len(snapshot.Items) != 1 || snapshot.Items[0].DataPoints != 3 || snapshot.Items[0].ProjectedAt == nil {
|
||||
t.Fatalf("persisted history did not produce a qualified forecast: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/pool"
|
||||
"github.com/itworx/pulse/internal/share"
|
||||
)
|
||||
|
||||
type staticShares struct{ snapshot share.Snapshot }
|
||||
|
||||
func (s staticShares) Snapshot(context.Context) (share.Snapshot, error) { return s.snapshot, nil }
|
||||
|
||||
type staticPools struct{ snapshot pool.Snapshot }
|
||||
|
||||
func (s staticPools) Snapshot(context.Context) (pool.Snapshot, error) { return s.snapshot, nil }
|
||||
|
||||
type fullHistory struct{ points []Point }
|
||||
|
||||
func (h fullHistory) Points(_ context.Context, _, _ string, _ time.Time, limit int) ([]Point, error) {
|
||||
if limit != len(h.points) {
|
||||
return nil, errors.New("history query did not reserve the current-point slot")
|
||||
}
|
||||
return h.points, nil
|
||||
}
|
||||
|
||||
func TestStorageProviderProjectsQualifiedShareHistory(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
shareSnapshot := share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{{
|
||||
ID: "media", Name: "Media", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable,
|
||||
Placements: []share.Placement{{PoolID: "cache"}}, GrowthHistory: []share.GrowthPoint{
|
||||
{ObservedAt: now.Add(-14 * 24 * time.Hour), UsedBytes: 200},
|
||||
{ObservedAt: now.Add(-7 * 24 * time.Hour), UsedBytes: 300},
|
||||
{ObservedAt: now, UsedBytes: 400},
|
||||
},
|
||||
}}}
|
||||
poolSnapshot := pool.Snapshot{Pools: []pool.Pool{{ID: " CACHE ", UsableBytes: 1000}}}
|
||||
provider := StorageProvider{Shares: staticShares{shareSnapshot}, Pools: staticPools{poolSnapshot}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }}
|
||||
|
||||
snapshot, err := provider.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.QualifiedCount != 1 || len(snapshot.Items) != 1 || snapshot.Items[0].EntityID != "media" || snapshot.Items[0].ProjectedAt == nil || snapshot.Items[0].CapacityBytes != 1000 {
|
||||
t.Fatalf("qualified storage forecast missing: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageProviderDoesNotCountInsufficientOrStaleHistory(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
base := share.Share{ID: "media", Name: "Media", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}, GrowthHistory: []share.GrowthPoint{{ObservedAt: now, UsedBytes: 400}}}
|
||||
provider := StorageProvider{Shares: staticShares{share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{base}}}, Pools: staticPools{pool.Snapshot{Pools: []pool.Pool{{ID: "cache", UsableBytes: 1000}}}}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }}
|
||||
|
||||
insufficient, err := provider.Snapshot(context.Background())
|
||||
if err != nil || insufficient.QualifiedCount != 0 || insufficient.Items[0].Reason != "insufficient_points" {
|
||||
t.Fatalf("insufficient history counted as forecast: %+v, %v", insufficient, err)
|
||||
}
|
||||
base.SizeState = share.SizeStale
|
||||
provider.Shares = staticShares{share.Snapshot{Source: share.Source{Freshness: share.Stale}, Shares: []share.Share{base}}}
|
||||
stale, err := provider.Snapshot(context.Background())
|
||||
if err != nil || stale.QualifiedCount != 0 || stale.Items[0].Reason != "history_stale" || stale.Items[0].ProjectedAt != nil {
|
||||
t.Fatalf("stale history was not explicit: %+v, %v", stale, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageProviderEmptySourceNeverCreatesZeroByteEntity(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
provider := StorageProvider{Shares: staticShares{share.UnknownSnapshot(now, "shares", "unraid", "source_unavailable")}, Pools: staticPools{pool.Snapshot{}}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }}
|
||||
snapshot, err := provider.Snapshot(context.Background())
|
||||
if err != nil || len(snapshot.Items) != 0 || snapshot.QualifiedCount != 0 || snapshot.Reason != "source_unavailable" {
|
||||
t.Fatalf("empty source created a forecast entity: %+v, %v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageProviderKeepsMatureHistoryInsidePointBound(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
history := make([]Point, 0, 4)
|
||||
for day := 4; day > 0; day-- {
|
||||
history = append(history, Point{ObservedAt: now.Add(-time.Duration(day) * 24 * time.Hour), UsedBytes: uint64((5 - day) * 100)})
|
||||
}
|
||||
provider := StorageProvider{
|
||||
Shares: staticShares{share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{{ID: "media", Name: "Media", UsedBytes: 500, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}}}}},
|
||||
Pools: staticPools{pool.Snapshot{Pools: []pool.Pool{{ID: "cache", UsableBytes: 1000}}}},
|
||||
History: fullHistory{points: history}, Policy: Policy{Enabled: true, MaxPoints: 5}, Now: func() time.Time { return now },
|
||||
}
|
||||
snapshot, err := provider.Snapshot(context.Background())
|
||||
if err != nil || snapshot.QualifiedCount != 1 || snapshot.Items[0].DataPoints != 5 {
|
||||
t.Fatalf("mature history exceeded its bound: %+v, %v", snapshot, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
const (
|
||||
MethodLinearMedian = "linear_median_rate"
|
||||
MethodDisabled = "disabled"
|
||||
MethodInsufficient = "insufficient_data"
|
||||
ConfidenceHigh = "high"
|
||||
ConfidenceMedium = "medium"
|
||||
ConfidenceLow = "low"
|
||||
ConfidenceNone = "none"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
}
|
||||
type Policy struct {
|
||||
Enabled bool
|
||||
Window time.Duration
|
||||
MinPoints int
|
||||
MaxPoints int
|
||||
MinSpan time.Duration
|
||||
BulkRateMultiplier float64
|
||||
}
|
||||
|
||||
func (p Policy) withDefaults() Policy {
|
||||
if p.Window == 0 {
|
||||
p.Window = 30 * 24 * time.Hour
|
||||
}
|
||||
if p.MinPoints == 0 {
|
||||
p.MinPoints = 3
|
||||
}
|
||||
if p.MaxPoints == 0 {
|
||||
p.MaxPoints = 128
|
||||
}
|
||||
if p.MinSpan == 0 {
|
||||
p.MinSpan = 24 * time.Hour
|
||||
}
|
||||
if p.BulkRateMultiplier == 0 {
|
||||
p.BulkRateMultiplier = 6
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (p Policy) Validate() error {
|
||||
if p.Window <= 0 || p.Window > 366*24*time.Hour || p.MinPoints < 2 || p.MinPoints > 128 || p.MaxPoints < p.MinPoints || p.MaxPoints > 512 || p.MinSpan <= 0 || p.MinSpan > p.Window || p.BulkRateMultiplier < 2 || p.BulkRateMultiplier > 100 {
|
||||
return errors.New("forecast policy is outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Forecast struct {
|
||||
EntityID string `json:"entityId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Method string `json:"method"`
|
||||
WindowSeconds int64 `json:"windowSeconds"`
|
||||
DataPoints int `json:"dataPoints"`
|
||||
Confidence string `json:"confidence"`
|
||||
CurrentUsedBytes uint64 `json:"currentUsedBytes"`
|
||||
CapacityBytes uint64 `json:"capacityBytes"`
|
||||
RateBytesPerDay float64 `json:"rateBytesPerDay"`
|
||||
DaysToCapacity *float64 `json:"daysToCapacity,omitempty"`
|
||||
ProjectedAt *time.Time `json:"projectedAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Policy PolicyView `json:"policy"`
|
||||
Items []Forecast `json:"items"`
|
||||
QualifiedCount int `json:"qualifiedCount"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type PolicyView struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
WindowSeconds int64 `json:"windowSeconds"`
|
||||
MinPoints int `json:"minPoints"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
type Provider interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
type Adapter struct {
|
||||
Source Provider
|
||||
Policy Policy
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if a.Source == nil {
|
||||
now := time.Now().UTC()
|
||||
if a.Now != nil {
|
||||
now = a.Now()
|
||||
}
|
||||
return UnknownSnapshot(now, "source_unavailable"), nil
|
||||
}
|
||||
return a.Source.Snapshot(ctx)
|
||||
}
|
||||
func UnknownSnapshot(now time.Time, reason string) Snapshot {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
p := Policy{}.withDefaults()
|
||||
return Snapshot{ContractVersion: ContractVersion, GeneratedAt: now.UTC(), Policy: PolicyView{Enabled: false, WindowSeconds: int64(p.Window / time.Second), MinPoints: p.MinPoints, Method: MethodInsufficient}, Items: []Forecast{}, QualifiedCount: 0, Reason: reason}
|
||||
}
|
||||
|
||||
func Predict(entityID, name, kind string, capacityBytes uint64, points []Point, now time.Time, policy Policy) (Forecast, error) {
|
||||
policy = policy.withDefaults()
|
||||
if err := policy.Validate(); err != nil {
|
||||
return Forecast{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
result := Forecast{EntityID: entityID, Name: name, Kind: kind, Enabled: policy.Enabled, WindowSeconds: int64(policy.Window / time.Second), Confidence: ConfidenceNone, CurrentUsedBytes: lastUsed(points), CapacityBytes: capacityBytes}
|
||||
if !policy.Enabled {
|
||||
result.Method = MethodDisabled
|
||||
result.Reason = "disabled_by_policy"
|
||||
return result, nil
|
||||
}
|
||||
if entityID == "" || name == "" {
|
||||
return Forecast{}, errors.New("forecast identity is invalid")
|
||||
}
|
||||
normalized, err := normalizePoints(points, now, policy)
|
||||
if err != nil {
|
||||
return Forecast{}, err
|
||||
}
|
||||
result.DataPoints = len(normalized)
|
||||
if len(normalized) < policy.MinPoints {
|
||||
result.Method = MethodInsufficient
|
||||
result.Reason = "insufficient_points"
|
||||
return result, nil
|
||||
}
|
||||
span := normalized[len(normalized)-1].ObservedAt.Sub(normalized[0].ObservedAt)
|
||||
if span < policy.MinSpan {
|
||||
result.Method = MethodInsufficient
|
||||
result.Reason = "insufficient_time_span"
|
||||
return result, nil
|
||||
}
|
||||
result.CurrentUsedBytes = normalized[len(normalized)-1].UsedBytes
|
||||
rates, irregular := ratesPerDay(normalized)
|
||||
if irregular {
|
||||
result.Method = MethodLinearMedian
|
||||
result.Confidence = ConfidenceLow
|
||||
result.Reason = "irregular_intervals"
|
||||
return result, nil
|
||||
}
|
||||
rate := median(rates)
|
||||
result.Method = MethodLinearMedian
|
||||
result.RateBytesPerDay = rate
|
||||
if rate <= 0 {
|
||||
result.Confidence = ConfidenceLow
|
||||
result.Reason = "no_positive_growth"
|
||||
return result, nil
|
||||
}
|
||||
if bulkImport(rates, rate, policy.BulkRateMultiplier) {
|
||||
result.Confidence = ConfidenceLow
|
||||
result.Reason = "bulk_import_detected"
|
||||
return result, nil
|
||||
}
|
||||
if capacityBytes == 0 || result.CurrentUsedBytes >= capacityBytes {
|
||||
result.Confidence = ConfidenceLow
|
||||
result.Reason = "capacity_unknown_or_reached"
|
||||
return result, nil
|
||||
}
|
||||
days := float64(capacityBytes-result.CurrentUsedBytes) / rate
|
||||
result.DaysToCapacity = &days
|
||||
projected := normalized[len(normalized)-1].ObservedAt.Add(time.Duration(days*24) * time.Hour).UTC()
|
||||
result.ProjectedAt = &projected
|
||||
if len(normalized) >= 5 && span >= 7*24*time.Hour {
|
||||
result.Confidence = ConfidenceHigh
|
||||
} else {
|
||||
result.Confidence = ConfidenceMedium
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizePoints(points []Point, now time.Time, policy Policy) ([]Point, error) {
|
||||
if len(points) > policy.MaxPoints {
|
||||
return nil, errors.New("forecast history exceeds bounds")
|
||||
}
|
||||
copyPoints := make([]Point, 0, len(points))
|
||||
cutoff := now.Add(-policy.Window)
|
||||
for _, point := range points {
|
||||
if point.ObservedAt.IsZero() || point.ObservedAt.After(now.Add(time.Minute)) {
|
||||
return nil, errors.New("forecast timestamp is invalid")
|
||||
}
|
||||
if point.ObservedAt.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
copyPoints = append(copyPoints, Point{ObservedAt: point.ObservedAt.UTC(), UsedBytes: point.UsedBytes})
|
||||
}
|
||||
sort.SliceStable(copyPoints, func(i, j int) bool { return copyPoints[i].ObservedAt.Before(copyPoints[j].ObservedAt) })
|
||||
dedup := make([]Point, 0, len(copyPoints))
|
||||
for _, point := range copyPoints {
|
||||
if len(dedup) > 0 && dedup[len(dedup)-1].ObservedAt.Equal(point.ObservedAt) {
|
||||
dedup[len(dedup)-1] = point
|
||||
} else {
|
||||
dedup = append(dedup, point)
|
||||
}
|
||||
}
|
||||
return dedup, nil
|
||||
}
|
||||
func ratesPerDay(points []Point) ([]float64, bool) {
|
||||
rates := make([]float64, 0, len(points)-1)
|
||||
intervals := make([]float64, 0, len(points)-1)
|
||||
for index := 1; index < len(points); index++ {
|
||||
duration := points[index].ObservedAt.Sub(points[index-1].ObservedAt)
|
||||
if duration <= 0 {
|
||||
continue
|
||||
}
|
||||
delta := float64(points[index].UsedBytes) - float64(points[index-1].UsedBytes)
|
||||
rates = append(rates, delta/(duration.Hours()/24))
|
||||
intervals = append(intervals, duration.Hours())
|
||||
}
|
||||
if len(intervals) < 1 {
|
||||
return rates, false
|
||||
}
|
||||
sort.Float64s(intervals)
|
||||
return rates, intervals[len(intervals)-1] > intervals[0]*10
|
||||
}
|
||||
func bulkImport(rates []float64, typical, multiplier float64) bool {
|
||||
if len(rates) < 2 {
|
||||
return false
|
||||
}
|
||||
if len(rates) == 2 {
|
||||
positive := make([]float64, 0, 2)
|
||||
for _, rate := range rates {
|
||||
if rate > 0 {
|
||||
positive = append(positive, rate)
|
||||
}
|
||||
}
|
||||
if len(positive) == 2 {
|
||||
sort.Float64s(positive)
|
||||
return positive[1] > positive[0]*multiplier
|
||||
}
|
||||
}
|
||||
for _, rate := range rates {
|
||||
if rate > 0 && rate > typical*multiplier {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func median(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
copyValues := append([]float64(nil), values...)
|
||||
sort.Float64s(copyValues)
|
||||
middle := len(copyValues) / 2
|
||||
if len(copyValues)%2 == 1 {
|
||||
return copyValues[middle]
|
||||
}
|
||||
return (copyValues[middle-1] + copyValues[middle]) / 2
|
||||
}
|
||||
func lastUsed(points []Point) uint64 {
|
||||
if len(points) == 0 {
|
||||
return 0
|
||||
}
|
||||
return points[len(points)-1].UsedBytes
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func points(now time.Time) []Point {
|
||||
return []Point{{ObservedAt: now.Add(-14 * 24 * time.Hour), UsedBytes: 200}, {ObservedAt: now.Add(-7 * 24 * time.Hour), UsedBytes: 300}, {ObservedAt: now, UsedBytes: 400}}
|
||||
}
|
||||
func TestPredictDisplaysMethodWindowAndQualifiedDate(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
got, err := Predict("share", "Media", "share", 1000, points(now), now, Policy{Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Method != MethodLinearMedian || got.Confidence != ConfidenceMedium || got.DaysToCapacity == nil || got.ProjectedAt == nil || got.RateBytesPerDay != 100.0/7.0 {
|
||||
t.Fatalf("forecast=%+v", got)
|
||||
}
|
||||
}
|
||||
func TestPredictRejectsFalsePrecisionForBulkImportAndIrregularHistory(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
bulk := []Point{{ObservedAt: now.Add(-14 * 24 * time.Hour), UsedBytes: 100}, {ObservedAt: now.Add(-7 * 24 * time.Hour), UsedBytes: 110}, {ObservedAt: now, UsedBytes: 1000}}
|
||||
got, err := Predict("share", "Media", "share", 2000, bulk, now, Policy{Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Confidence != ConfidenceLow || got.DaysToCapacity != nil || got.Reason != "bulk_import_detected" {
|
||||
t.Fatalf("bulk=%+v", got)
|
||||
}
|
||||
irregular := []Point{{ObservedAt: now.Add(-20 * 24 * time.Hour), UsedBytes: 100}, {ObservedAt: now.Add(-19 * 24 * time.Hour), UsedBytes: 110}, {ObservedAt: now, UsedBytes: 300}}
|
||||
got, err = Predict("share", "Media", "share", 2000, irregular, now, Policy{Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Reason != "irregular_intervals" || got.DaysToCapacity != nil {
|
||||
t.Fatalf("irregular=%+v", got)
|
||||
}
|
||||
}
|
||||
func TestPredictInsufficientDisabledAndBounded(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
got, err := Predict("share", "Media", "share", 1000, points(now)[:2], now, Policy{Enabled: true})
|
||||
if err != nil || got.Method != MethodInsufficient || got.DaysToCapacity != nil {
|
||||
t.Fatalf("insufficient=%+v err=%v", got, err)
|
||||
}
|
||||
got, err = Predict("share", "Media", "share", 1000, points(now), now, Policy{})
|
||||
if err != nil || got.Method != MethodDisabled || got.Enabled {
|
||||
t.Fatalf("disabled=%+v err=%v", got, err)
|
||||
}
|
||||
tooMany := make([]Point, 129)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = Point{ObservedAt: now.Add(-time.Duration(i) * time.Hour), UsedBytes: uint64(i)}
|
||||
}
|
||||
if _, err = Predict("share", "Media", "share", 1000, tooMany, now, Policy{Enabled: true}); err == nil {
|
||||
t.Fatal("expected bound error")
|
||||
}
|
||||
}
|
||||
func TestAdapterCancellationAndUnknown(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := (Adapter{}).Snapshot(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got, err := (Adapter{Now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }}).Snapshot(context.Background())
|
||||
if err != nil || len(got.Items) != 0 || got.QualifiedCount != 0 || got.Reason != "source_unavailable" {
|
||||
t.Fatalf("unknown=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPredictTargetScale(b *testing.B) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
history := make([]Point, 128)
|
||||
for index := range history {
|
||||
history[index] = Point{ObservedAt: now.Add(-time.Duration(127-index) * 6 * time.Hour), UsedBytes: uint64(index) * 1024 * 1024 * 1024}
|
||||
}
|
||||
policy := Policy{Enabled: true, Window: 30 * 24 * time.Hour, MinPoints: 3, MaxPoints: 128, MinSpan: 24 * time.Hour, BulkRateMultiplier: 6}
|
||||
b.ReportAllocs()
|
||||
for index := 0; index < b.N; index++ {
|
||||
if _, err := Predict("share", "Media", "share", 512*1024*1024*1024, history, now, policy); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user