tstime: add new package for time utilities, starting with Parse3339

Go's time.Parse always allocates a FixedZone for time strings not in
UTC (ending in "Z"). This avoids that allocation, at the cost of
adding a cache.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2020-04-05 14:47:10 -07:00 committed by Brad Fitzpatrick
parent 4e0ee141e8
commit d503dee6f1
2 changed files with 162 additions and 0 deletions

58
tstime/tstime.go Normal file
View File

@ -0,0 +1,58 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package tstime defines Tailscale-specific time utilities.
package tstime
import (
"strings"
"sync"
"time"
)
// zoneOf returns the RFC3339 zone suffix, or the empty string
// if it's invalid or not something we want to cache.
func zoneOf(s string) string {
if strings.HasSuffix(s, "Z") {
return ""
}
if len(s) < len("2020-04-05T15:56:00+08:00") {
// Too short, invalid? Let time.Parse fail on it.
return ""
}
zone := s[len(s)-len("+08:00"):]
if c := zone[0]; c == '+' || c == '-' {
min := zone[len("+08:"):]
switch min {
case "00", "15", "30":
return zone
}
}
return ""
}
// locCache maps from zone offset suffix string ("+08:00") =>
// *time.Location (from FixedLocation).
var locCache sync.Map
// Parse3339 is a wrapper around time.Parse(time.RFC3339Nano, s) that caches
// timezone Locations for future parses.
func Parse3339(s string) (time.Time, error) {
zone := zoneOf(s)
if zone == "" {
return time.Parse(time.RFC3339Nano, s)
}
loci, ok := locCache.Load(zone)
if ok {
// TODO(bradfitz): just rewrite this do the trivial parsing by hand
// which will be faster than Go's format-driven one. RFC3339 is trivial.
return time.ParseInLocation(time.RFC3339Nano, s, loci.(*time.Location))
}
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}, err
}
locCache.LoadOrStore(zone, t.Location())
return t, nil
}

104
tstime/tstime_test.go Normal file
View File

@ -0,0 +1,104 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package tstime
import (
"testing"
"time"
)
func TestZoneOf(t *testing.T) {
tests := []struct {
in, want string
}{
{"2020-04-05T15:56:00+08:00", "+08:00"},
{"2020-04-05T15:56:00-08:00", "-08:00"},
{"2020-04-05T15:56:00.12345-08:00", "-08:00"},
// don't cache weird offsets, only 00 15, 30:
{"2020-04-05T15:56:00.12345-08:00", "-08:00"},
{"2020-04-05T15:56:00.12345-08:30", "-08:30"},
{"2020-04-05T15:56:00.12345-08:15", "-08:15"},
{"2020-04-05T15:56:00.12345-08:17", ""},
// don't cache UTC:
{"2020-04-05T15:56:00.12345Z", ""},
{"2020-04-05T15:56:00Z", ""},
// too short:
{"123+08:00", ""},
{"+08:00", ""},
}
for _, tt := range tests {
if got := zoneOf(tt.in); got != tt.want {
t.Errorf("zoneOf(%q) = %q; want %q", tt.in, got, tt.want)
}
}
}
func BenchmarkGoParse3339(b *testing.B) {
b.ReportAllocs()
const in = `2020-04-05T15:56:00.148487491+08:00`
for i := 0; i < b.N; i++ {
_, err := time.Parse(time.RFC3339Nano, in)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkGoParse3339InLocation(b *testing.B) {
b.ReportAllocs()
const in = `2020-04-05T15:56:00.148487491+08:00`
t, err := time.Parse(time.RFC3339Nano, in)
if err != nil {
b.Fatal(err)
}
loc := t.Location()
t2, err := time.ParseInLocation(time.RFC3339Nano, in, loc)
if err != nil {
b.Fatal(err)
}
if !t.Equal(t2) {
b.Fatal("not equal")
}
if s1, s2 := t.Format(time.RFC3339Nano), t2.Format(time.RFC3339Nano); s1 != s2 {
b.Fatalf("times don't stringify the same: %q vs %q", s1, s2)
}
for i := 0; i < b.N; i++ {
_, err := time.ParseInLocation(time.RFC3339Nano, in, loc)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkParse3339(b *testing.B) {
b.ReportAllocs()
const in = `2020-04-05T15:56:00.148487491+08:00`
t, err := time.Parse(time.RFC3339Nano, in)
if err != nil {
b.Fatal(err)
}
t2, err := Parse3339(in)
if err != nil {
b.Fatal(err)
}
if !t.Equal(t2) {
b.Fatal("not equal")
}
if s1, s2 := t.Format(time.RFC3339Nano), t2.Format(time.RFC3339Nano); s1 != s2 {
b.Fatalf("times don't stringify the same: %q vs %q", s1, s2)
}
for i := 0; i < b.N; i++ {
_, err := Parse3339(in)
if err != nil {
b.Fatal(err)
}
}
}