tailscale/tstime/tstime.go
Brad Fitzpatrick febdac0499 tstime: write Parse3339 parse that doesn't use time.Parse
It doesn't allocate and it's half the time of time.Parse (which
allocates), and 2/3rds the time of time.ParseInLocation (which
doesn't).

Go with a UTC time:

BenchmarkGoParse3339/Z-8                 2200995               534 ns/op               0 B/op          0 allocs/op
BenchmarkGoParse3339/Z-8                 2254816               554 ns/op               0 B/op          0 allocs/op
BenchmarkGoParse3339/Z-8                 2159504               522 ns/op               0 B/op          0 allocs/op

Go allocates with a "-08:00" suffix instead of ending in "Z":

BenchmarkGoParse3339/TZ-8                1276491               884 ns/op             144 B/op          3 allocs/op
BenchmarkGoParse3339/TZ-8                1355858               942 ns/op             144 B/op          3 allocs/op
BenchmarkGoParse3339/TZ-8                1385484               911 ns/op             144 B/op          3 allocs/op

Go doesn't allocate if you use time.ParseInLocation, but then you need
to parse the string to find the location anyway, so might as well go
all the way (below).

BenchmarkGoParse3339InLocation-8         1912254               597 ns/op               0 B/op          0 allocs/op
BenchmarkGoParse3339InLocation-8         1980043               612 ns/op               0 B/op          0 allocs/op
BenchmarkGoParse3339InLocation-8         1891366               612 ns/op               0 B/op          0 allocs/op

Parsing RFC3339 ourselves, UTC:

BenchmarkParse3339/Z-8                   3889220               307 ns/op               0 B/op          0 allocs/op
BenchmarkParse3339/Z-8                   3718500               309 ns/op               0 B/op          0 allocs/op
BenchmarkParse3339/Z-8                   3621231               303 ns/op               0 B/op          0 allocs/op

Parsing RFC3339 ourselves, with timezone (w/ *time.Location fetched
from sync.Map)

BenchmarkParse3339/TZ-8                  3019612               418 ns/op               0 B/op          0 allocs/op
BenchmarkParse3339/TZ-8                  2921618               401 ns/op               0 B/op          0 allocs/op
BenchmarkParse3339/TZ-8                  3031671               408 ns/op               0 B/op          0 allocs/op

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2020-04-06 08:19:17 -07:00

115 lines
2.8 KiB
Go

// 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 (
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
// zoneOf returns the RFC3339 zone suffix (either "Z" or like
// "+08:30"), 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 "Z"
}
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
func getLocation(zone, timeValue string) (*time.Location, error) {
if zone == "Z" {
return time.UTC, nil
}
if loci, ok := locCache.Load(zone); ok {
return loci.(*time.Location), nil
}
// TODO(bradfitz): just parse it and call time.FixedLocation.
// For now, just have time.Parse do it once:
t, err := time.Parse(time.RFC3339Nano, timeValue)
if err != nil {
return nil, err
}
loc := t.Location()
locCache.LoadOrStore(zone, loc)
return loc, nil
}
// 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 == "" {
// Invalid or weird timezone offset. Use slow path,
// which'll probably return an error.
return time.Parse(time.RFC3339Nano, s)
}
loc, err := getLocation(zone, s)
if err != nil {
return time.Time{}, err
}
s = s[:len(s)-len(zone)] // remove zone suffix
var year, mon, day, hr, min, sec, nsec int
const baseLen = len("2020-04-05T15:56:00")
if len(s) < baseLen ||
!parseInt(s[:4], &year) ||
s[4] != '-' ||
!parseInt(s[5:7], &mon) ||
s[7] != '-' ||
!parseInt(s[8:10], &day) ||
s[10] != 'T' ||
!parseInt(s[11:13], &hr) ||
s[13] != ':' ||
!parseInt(s[14:16], &min) ||
s[16] != ':' ||
!parseInt(s[17:19], &sec) {
return time.Time{}, errors.New("invalid time")
}
nsStr := s[baseLen:]
if nsStr != "" {
if nsStr[0] != '.' {
return time.Time{}, errors.New("invalid optional nanosecond prefix")
}
if !parseInt(nsStr[1:], &nsec) {
return time.Time{}, fmt.Errorf("invalid optional nanosecond number %q", nsStr[1:])
}
for i := 0; i < len("999999999")-(len(nsStr)-1); i++ {
nsec *= 10
}
}
return time.Date(year, time.Month(mon), day, hr, min, sec, nsec, loc), nil
}
func parseInt(s string, dst *int) bool {
n, err := strconv.ParseInt(s, 10, 0)
if err != nil || n < 0 {
return false
}
*dst = int(n)
return true
}