diff --git a/time/time.go b/time/time.go new file mode 100644 index 000000000..05e89da9d --- /dev/null +++ b/time/time.go @@ -0,0 +1,86 @@ +package time + +import ( + "math" + "math/rand" + "net/http" + "strconv" + "time" + + "github.com/prometheus/common/model" + "github.com/weaveworks/common/httpgrpc" +) + +const ( + nanosecondsInMillisecond = int64(time.Millisecond / time.Nanosecond) +) + +func ToMillis(t time.Time) int64 { + return t.UnixNano() / nanosecondsInMillisecond +} + +// FromMillis is a helper to turn milliseconds -> time.Time +func FromMillis(ms int64) time.Time { + return time.Unix(0, ms*nanosecondsInMillisecond) +} + +// FormatTimeMillis returns a human readable version of the input time (in milliseconds). +func FormatTimeMillis(ms int64) string { + return FromMillis(ms).String() +} + +// FormatTimeModel returns a human readable version of the input time. +func FormatTimeModel(t model.Time) string { + return FromMillis(int64(t)).String() +} + +// ParseTime parses the string into an int64, milliseconds since epoch. +func ParseTime(s string) (int64, error) { + if t, err := strconv.ParseFloat(s, 64); err == nil { + s, ns := math.Modf(t) + ns = math.Round(ns*1000) / 1000 + tm := time.Unix(int64(s), int64(ns*float64(time.Second))) + return ToMillis(tm), nil + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return ToMillis(t), nil + } + return 0, httpgrpc.Errorf(http.StatusBadRequest, "cannot parse %q to a valid timestamp", s) +} + +// DurationWithJitter returns random duration from "input - input*variance" to "input + input*variance" interval. +func DurationWithJitter(input time.Duration, variancePerc float64) time.Duration { + // No duration? No jitter. + if input == 0 { + return 0 + } + + variance := int64(float64(input) * variancePerc) + jitter := rand.Int63n(variance*2) - variance + + return input + time.Duration(jitter) +} + +// DurationWithPositiveJitter returns random duration from "input" to "input + input*variance" interval. +func DurationWithPositiveJitter(input time.Duration, variancePerc float64) time.Duration { + // No duration? No jitter. + if input == 0 { + return 0 + } + + variance := int64(float64(input) * variancePerc) + jitter := rand.Int63n(variance) + + return input + time.Duration(jitter) +} + +// NewDisableableTicker essentially wraps NewTicker but allows the ticker to be disabled by passing +// zero duration as the interval. Returns a function for stopping the ticker, and the ticker channel. +func NewDisableableTicker(interval time.Duration) (func(), <-chan time.Time) { + if interval == 0 { + return func() {}, nil + } + + tick := time.NewTicker(interval) + return func() { tick.Stop() }, tick.C +} diff --git a/time/time_test.go b/time/time_test.go new file mode 100644 index 000000000..b5b572d8e --- /dev/null +++ b/time/time_test.go @@ -0,0 +1,133 @@ +package time + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTimeFromMillis(t *testing.T) { + var testExpr = []struct { + input int64 + expected time.Time + }{ + {input: 1000, expected: time.Unix(1, 0)}, + {input: 1500, expected: time.Unix(1, 500*nanosecondsInMillisecond)}, + } + + for i, c := range testExpr { + t.Run(fmt.Sprint(i), func(t *testing.T) { + res := FromMillis(c.input) + require.Equal(t, c.expected, res) + }) + } +} + +func TestDurationWithJitter(t *testing.T) { + const numRuns = 1000 + + for i := 0; i < numRuns; i++ { + actual := DurationWithJitter(time.Minute, 0.5) + assert.GreaterOrEqual(t, int64(actual), int64(30*time.Second)) + assert.LessOrEqual(t, int64(actual), int64(90*time.Second)) + } +} + +func TestDurationWithJitter_ZeroInputDuration(t *testing.T) { + assert.Equal(t, time.Duration(0), DurationWithJitter(time.Duration(0), 0.5)) +} + +func TestDurationWithPositiveJitter(t *testing.T) { + const numRuns = 1000 + + for i := 0; i < numRuns; i++ { + actual := DurationWithPositiveJitter(time.Minute, 0.5) + assert.GreaterOrEqual(t, int64(actual), int64(60*time.Second)) + assert.LessOrEqual(t, int64(actual), int64(90*time.Second)) + } +} + +func TestDurationWithPositiveJitter_ZeroInputDuration(t *testing.T) { + assert.Equal(t, time.Duration(0), DurationWithPositiveJitter(time.Duration(0), 0.5)) +} + +func TestParseTime(t *testing.T) { + var tests = []struct { + input string + fail bool + result time.Time + }{ + { + input: "", + fail: true, + }, { + input: "abc", + fail: true, + }, { + input: "30s", + fail: true, + }, { + input: "123", + result: time.Unix(123, 0), + }, { + input: "123.123", + result: time.Unix(123, 123000000), + }, { + input: "2015-06-03T13:21:58.555Z", + result: time.Unix(1433337718, 555*time.Millisecond.Nanoseconds()), + }, { + input: "2015-06-03T14:21:58.555+01:00", + result: time.Unix(1433337718, 555*time.Millisecond.Nanoseconds()), + }, { + // Test nanosecond rounding. + input: "2015-06-03T13:21:58.56789Z", + result: time.Unix(1433337718, 567*1e6), + }, { + // Test float rounding. + input: "1543578564.705", + result: time.Unix(1543578564, 705*1e6), + }, + } + + for _, test := range tests { + ts, err := ParseTime(test.input) + if test.fail { + require.Error(t, err) + continue + } + + require.NoError(t, err) + assert.Equal(t, ToMillis(test.result), ts) + } +} + +func TestNewDisableableTicker_Enabled(t *testing.T) { + stop, ch := NewDisableableTicker(10 * time.Millisecond) + defer stop() + + time.Sleep(100 * time.Millisecond) + + select { + case <-ch: + break + default: + t.Error("ticker should have ticked when enabled") + } +} + +func TestNewDisableableTicker_Disabled(t *testing.T) { + stop, ch := NewDisableableTicker(0) + defer stop() + + time.Sleep(100 * time.Millisecond) + + select { + case <-ch: + t.Error("ticker should not have ticked when disabled") + default: + break + } +}