mirror of
https://github.com/hanzoai/types.git
synced 2026-10-01 00:45:52 +00:00
The relative half of ParseWindow was a table of four spellings. A window nobody had enumerated — 14d, 90d, all — was an unknown range and a 400, on the surface an operator reads to see where money went. The stores behind these series keep two years, so the door was refusing questions the data could answer. Read the count off the label instead: any <N>h or <N>d resolves, day/week/month still name the common ones, and all reaches back to Horizon, the retention those stores keep. Every label the table admitted resolves to the very same window, so the change only adds. Past the horizon is still a refusal, by name, rather than a window served short: on a series that carries money a quiet zero reads as "nothing was spent".
175 lines
5.2 KiB
Go
175 lines
5.2 KiB
Go
package types
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Interval is the width of one bucket in a metered time series.
|
|
//
|
|
// The set is closed at Hour and Day. A bucket function is not a bindable
|
|
// parameter in the engines that serve these series, so the interval reaches a
|
|
// query by interpolation; keeping the set closed means a request cannot put
|
|
// anything else there.
|
|
type Interval string
|
|
|
|
const (
|
|
Hour Interval = "hour"
|
|
Day Interval = "day"
|
|
)
|
|
|
|
// Step is how much time one bucket covers.
|
|
func (i Interval) Step() time.Duration {
|
|
if i == Day {
|
|
return 24 * time.Hour
|
|
}
|
|
return time.Hour
|
|
}
|
|
|
|
// Window is a half-open time range [Start, End) and the interval its series
|
|
// buckets to.
|
|
//
|
|
// Label is the range as the caller asked for it, defaulted when the caller
|
|
// asked for nothing, so a response can say which window it answers without the
|
|
// caller re-deriving it.
|
|
type Window struct {
|
|
Label string
|
|
Start time.Time
|
|
End time.Time
|
|
Interval Interval
|
|
}
|
|
|
|
// Horizon is how far back a window may reach: the retention the warehouses
|
|
// behind these series keep. "all" resolves to exactly this, so a caller asking
|
|
// for everything is told the real span it got rather than an open bound the
|
|
// store cannot honour.
|
|
const Horizon = 2 * 365 * 24 * time.Hour
|
|
|
|
// ParseWindow resolves a range request into an absolute window.
|
|
//
|
|
// A relative label is a count and a unit — "24h", "90d", any <N>h or <N>d up to
|
|
// Horizon — so a window nobody enumerated still resolves. "day", "week" and
|
|
// "month" name the common ones, "all" reaches back to Horizon, and "custom"
|
|
// reads start and end, each RFC3339 or unix seconds, end defaulting to now and
|
|
// having to follow start.
|
|
//
|
|
// A label that is neither is an error rather than a silent default, so a typo
|
|
// cannot quietly change the window a caller is shown, and a span past Horizon
|
|
// is refused by its own name rather than served short: on a series that carries
|
|
// money, a window that answers less than it was asked for is worse than one
|
|
// that refuses.
|
|
//
|
|
// now is a parameter so the window is a function of the arguments alone.
|
|
func ParseWindow(label, start, end string, now time.Time) (Window, error) {
|
|
now = now.UTC()
|
|
|
|
w := Window{Label: strings.TrimSpace(label)}
|
|
if w.Label == "" {
|
|
w.Label = "24h"
|
|
}
|
|
|
|
if strings.EqualFold(w.Label, "custom") {
|
|
from, err := parseTime(start)
|
|
if err != nil {
|
|
return Window{}, fmt.Errorf("custom range requires a valid start: %w", err)
|
|
}
|
|
to := now
|
|
if strings.TrimSpace(end) != "" {
|
|
if to, err = parseTime(end); err != nil {
|
|
return Window{}, fmt.Errorf("custom range has an invalid end: %w", err)
|
|
}
|
|
}
|
|
if !to.After(from) {
|
|
return Window{}, fmt.Errorf("custom range end must be after start")
|
|
}
|
|
w.Start, w.End = from.UTC(), to.UTC()
|
|
w.Interval = bucket(w.End.Sub(w.Start))
|
|
return w, nil
|
|
}
|
|
|
|
back, err := span(w.Label)
|
|
if errors.Is(err, errPastHorizon) {
|
|
return Window{}, fmt.Errorf("range %q reaches past the %d-day horizon; %q is the longest window", label, int(Horizon/(24*time.Hour)), "all")
|
|
}
|
|
if err != nil {
|
|
return Window{}, fmt.Errorf("unknown range %q", label)
|
|
}
|
|
w.Start, w.End, w.Interval = now.Add(-back), now, bucket(back)
|
|
return w, nil
|
|
}
|
|
|
|
// The two ways a relative label fails, distinguished because they mean opposite
|
|
// things to a caller: one is a typo, the other is a window the stores behind
|
|
// these series do not go back far enough to answer.
|
|
var (
|
|
errUnknown = errors.New("unknown range")
|
|
errPastHorizon = errors.New("range reaches past the horizon")
|
|
)
|
|
|
|
// span is how far back a relative label reaches. The named windows are the
|
|
// counts worth a word; every other count is read off the label itself.
|
|
func span(label string) (time.Duration, error) {
|
|
unit := time.Hour
|
|
count := 0
|
|
|
|
switch strings.ToLower(label) {
|
|
case "today", "day":
|
|
count = 24
|
|
case "week":
|
|
count, unit = 7, 24*time.Hour
|
|
case "month":
|
|
count, unit = 30, 24*time.Hour
|
|
case "all":
|
|
return Horizon, nil
|
|
default:
|
|
digits, u := label[:len(label)-1], label[len(label)-1]
|
|
switch u {
|
|
case 'd', 'D':
|
|
unit = 24 * time.Hour
|
|
case 'h', 'H':
|
|
unit = time.Hour
|
|
default:
|
|
return 0, errUnknown
|
|
}
|
|
n, err := strconv.Atoi(digits)
|
|
if err != nil || n <= 0 || digits[0] < '0' || digits[0] > '9' {
|
|
return 0, errUnknown
|
|
}
|
|
count = n
|
|
}
|
|
|
|
// Compared before multiplying, so a count no store could serve is refused
|
|
// rather than overflowing into a window that looks small.
|
|
if int64(count) > int64(Horizon/unit) {
|
|
return 0, errPastHorizon
|
|
}
|
|
return time.Duration(count) * unit, nil
|
|
}
|
|
|
|
// bucket is the interval a span of this width buckets to. One rule for every
|
|
// window, named or counted, so two ranges of the same length cannot disagree.
|
|
func bucket(d time.Duration) Interval {
|
|
if d > 48*time.Hour {
|
|
return Day
|
|
}
|
|
return Hour
|
|
}
|
|
|
|
// parseTime accepts RFC3339 or a unix-seconds string.
|
|
func parseTime(s string) (time.Time, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return time.Time{}, fmt.Errorf("empty time")
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
return t.UTC(), nil
|
|
}
|
|
if secs, err := strconv.ParseInt(s, 10, 64); err == nil && secs > 0 {
|
|
return time.Unix(secs, 0).UTC(), nil
|
|
}
|
|
return time.Time{}, fmt.Errorf("not RFC3339 or unix seconds: %q", s)
|
|
}
|