Files
zeekay 1e81bc7b64 a window is a count and a unit, so 90d and all answer
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".
2026-08-13 23:56:10 -07:00

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)
}