Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+211
View File
@@ -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)
}
}
+93
View File
@@ -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)
}
}
+275
View File
@@ -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
}
+86
View File
@@ -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)
}
}
}