From 618b81b16279badacb78a2989b4653c88ea0b055 Mon Sep 17 00:00:00 2001 From: Radu Berinde Date: Mon, 8 Sep 2025 19:40:27 -0700 Subject: [PATCH] crhumanize: add Duration --- crhumanize/duration.go | 73 ++++++++++++++++++++++++++++++++ crhumanize/duration_test.go | 60 +++++++++++++++++++++++++++ crhumanize/testdata/duration | 80 ++++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 crhumanize/duration.go create mode 100644 crhumanize/duration_test.go create mode 100644 crhumanize/testdata/duration diff --git a/crhumanize/duration.go b/crhumanize/duration.go new file mode 100644 index 0000000..b869190 --- /dev/null +++ b/crhumanize/duration.go @@ -0,0 +1,73 @@ +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. See the License for the specific language governing +// permissions and limitations under the License. + +package crhumanize + +import ( + "fmt" + "time" +) + +// Duration returns a simplified approximation (±5%) of a duration. +// +// Examples: +// - 123.456µs -> "123µs" +// - 1.234567ms -> "1.2ms" +// - 59.1s -> "59s" +// - 1m6.5s -> 1m7s +func Duration(d time.Duration) SafeString { + if d < 0 { + return "-" + Duration(-d) + } + if d == 0 { + return "0s" + } + + if d < 10*time.Minute { + // Round to a precision that gives us one decimal when the value is a single + // digit (e.g. "1.5ms" vs "12ms") + r := time.Nanosecond + switch { + case d < time.Microsecond: + case d < 10*time.Microsecond: + r = 100 * time.Nanosecond + case d < time.Millisecond: + r = time.Microsecond + case d < 10*time.Millisecond: + r = 100 * time.Microsecond + case d < time.Second: + r = time.Millisecond + case d < 10*time.Second: + r = 100 * time.Millisecond + default: + r = time.Second + } + return SafeString(d.Round(r).String()) + } + + if d > 100*time.Hour { + d = d.Round(time.Hour) + } else { + d = d.Round(time.Minute) + } + h := int(d / time.Hour) + m := int((d % time.Hour) / time.Minute) + switch { + case h == 0: + return SafeString(fmt.Sprintf("%dm", m)) + case m == 0: + return SafeString(fmt.Sprintf("%dh", h)) + default: + return SafeString(fmt.Sprintf("%dh%dm", h, m)) + } +} diff --git a/crhumanize/duration_test.go b/crhumanize/duration_test.go new file mode 100644 index 0000000..4ea139a --- /dev/null +++ b/crhumanize/duration_test.go @@ -0,0 +1,60 @@ +// Copyright 2025 The Cockroach Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. See the License for the specific language governing +// permissions and limitations under the License. + +package crhumanize + +import ( + "fmt" + "math" + "math/rand/v2" + "strings" + "testing" + "time" + + "github.com/cockroachdb/crlib/crstrings" + "github.com/cockroachdb/crlib/internal/datadriven" +) + +func TestDuration(t *testing.T) { + datadriven.RunTest(t, "testdata/duration", func(t *testing.T, td *datadriven.TestData) string { + if td.Cmd != "duration" { + td.Fatalf(t, "unknown command: %q", td.Cmd) + } + var buf strings.Builder + for _, l := range crstrings.Lines(td.Input) { + d, err := time.ParseDuration(l) + if err != nil { + td.Fatalf(t, "could not parse duration %q: %v", l, err) + } + fmt.Fprintf(&buf, "%s -> %s\n", d, Duration(d)) + } + return buf.String() + }) +} + +func TestDurationError(t *testing.T) { + for _, v := range []time.Duration{time.Microsecond, time.Second, time.Minute, time.Hour, 100 * time.Hour, 10000 * time.Hour} { + for i := 0; i < 1000; i++ { + d := time.Duration(rand.Int64N(int64(v))) + s := string(Duration(d)) + d1, err := time.ParseDuration(s) + if err != nil { + t.Fatalf("%s: could not parse duration %q: %v", d, s, err) + } + if relativeErr := math.Abs(float64(d1-d)) / float64(d); relativeErr > 0.05 { + t.Fatalf("%s -> %s -> %s error is too large: %f\n", d, s, d1, relativeErr) + } + } + } +} diff --git a/crhumanize/testdata/duration b/crhumanize/testdata/duration new file mode 100644 index 0000000..b67ed59 --- /dev/null +++ b/crhumanize/testdata/duration @@ -0,0 +1,80 @@ +duration +1ns +12345ns +123456ns +1234567ns +5ms +500ms +900ms +1s +1001ms +1011ms +1111ms +1500ms +---- +1ns -> 1ns +12.345µs -> 12µs +123.456µs -> 123µs +1.234567ms -> 1.2ms +5ms -> 5ms +500ms -> 500ms +900ms -> 900ms +1s -> 1s +1.001s -> 1s +1.011s -> 1s +1.111s -> 1.1s +1.5s -> 1.5s + + +duration +15s +30s +31s +45s +59.1s +60s +61s +66.5s +90.2s +119s +121s +40m20s +---- +15s -> 15s +30s -> 30s +31s -> 31s +45s -> 45s +59.1s -> 59s +1m0s -> 1m0s +1m1s -> 1m1s +1m6.5s -> 1m7s +1m30.2s -> 1m30s +1m59s -> 1m59s +2m1s -> 2m1s +40m20s -> 40m + +duration +1h1m15s +1h31m13s +2h3m +20h34m50s +205h45m15s +2057h36m +20576h7m +205761h18m +2057613h9m +---- +1h1m15s -> 1h1m +1h31m13s -> 1h31m +2h3m0s -> 2h3m +20h34m50s -> 20h35m +205h45m15s -> 206h +2057h36m0s -> 2058h +20576h7m0s -> 20576h +205761h18m0s -> 205761h +2057613h9m0s -> 2057613h + +duration +1h15m13s +---- +1h15m13s -> 1h15m