Skip to content

time: time zone lookup using extend string makes wrong start time for non-DST zones #58682

@KimMachineGun

Description

@KimMachineGun

What version of Go are you using (go version)?

$ go version
go version go1.20 darwin/arm64

Does this issue reproduce with the latest release?

Yes.

What operating system and processor architecture are you using (go env)?

go env Output
$ go env
GO111MODULE=""
GOARCH="arm64"
GOEXE=""
GOEXPERIMENT=""
GOFLAGS=""
GOHOSTARCH="arm64"
GOHOSTOS="darwin"
GOOS="darwin"
GOVERSION="go1.20"

What did you do?

package main

import (
	"fmt"
	"time"
)

func main() {
	loc, err := time.LoadLocation("Asia/Seoul")
	if err != nil {
		panic(err)
	}

	fmt.Println(time.Date(2023, 2, 24, 0, 0, 0, 0, loc).ZoneBounds())
}

What did you expect to see?

Since the last transition in Asia/Seoul on Sunday, 9 October 1988, 02:00:00, the expected result:

1988-10-09 02:00:00 +0900 KST 0001-01-01 00:00:00 +0000 UTC

What did you see instead?

292277026596-12-05 00:30:07 +0900 KST 0001-01-01 00:00:00 +0000 UTC

Debugging

It seems like time.LoadLocationFromTZData and time.Location.lookup work inappropriately if the time is after the last transition and the extend string doesn't have DST rule.
I think the problematic parts are these:

// `time.LoadLocationFromTZData` (`time.Location.lookup` is similar to this)

// Fill in the cache with information about right now,
// since that will be the most common lookup.
sec, _, _ := now()
for i := range tx {
	if tx[i].when <= sec && (i+1 == len(tx) || sec < tx[i+1].when) {
		l.cacheStart = tx[i].when
		l.cacheEnd = omega
		l.cacheZone = &l.zone[tx[i].index]
		if i+1 < len(tx) {
			l.cacheEnd = tx[i+1].when
		} else if l.extend != "" {
			// If we're at the end of the known zone transitions,
			// try the extend string.
			if name, offset, estart, eend, isDST, ok := tzset(l.extend, l.cacheEnd, sec); ok {
				l.cacheStart = estart
				l.cacheEnd = eend
				// Find the zone that is returned by tzset to avoid allocation if possible.
				if zoneIdx := findZone(l.zone, name, offset, isDST); zoneIdx != -1 {
					l.cacheZone = &l.zone[zoneIdx]
				} else {
					l.cacheZone = &zone{
						name:   name,
						offset: offset,
						isDST:  isDST,
					}
				}
			}
		}
		break
	}
}
// func tzset(s string, initEnd, sec int64) (name string, offset int, start, end int64, isDST, ok bool) {

if len(s) == 0 || s[0] == ',' {
	// No daylight savings time.
	return stdName, stdOffset, initEnd, omega, false, true
}

Based on tzfile(5) manual, the extend string is applied to the last transition, but the above codes pass the last transition's end time(omega), and tzset uses it as a start time in non-DST case.

It leads us to two problems:

  • time.Time.ZoneBounds method returns wrong results.
  • zone lookup always needs binary search to find a zone for the time after the last transition because the cacheStart is omega.

Due to the second problem, some operations that need timezone information for non-DST fall into significant performance degradation.

This is a benchstat result after solving the problem for some cases. (in Asia/Seoul)

goos: darwin
goarch: arm64
pkg: time
                        │ docs/benchmark/old.txt │       docs/benchmark/new.txt        │
                        │         sec/op         │   sec/op     vs base                │
Now-10                               40.49n ± 0%   39.79n ± 0%   -1.72% (p=0.000 n=10)
NowUnixNano-10                       38.44n ± 1%   38.25n ± 0%        ~ (p=0.867 n=10)
NowUnixMilli-10                      38.05n ± 0%   38.10n ± 0%        ~ (p=0.180 n=10)
NowUnixMicro-10                      38.07n ± 1%   38.06n ± 1%        ~ (p=0.753 n=10)
Format-10                            126.8n ± 1%   110.0n ± 0%  -13.25% (p=0.000 n=10)
FormatRFC3339-10                     59.31n ± 0%   45.70n ± 0%  -22.94% (p=0.000 n=10)
FormatRFC3339Nano-10                 60.55n ± 0%   46.80n ± 0%  -22.72% (p=0.000 n=10)
FormatNow-10                         128.5n ± 0%   111.9n ± 0%  -12.96% (p=0.000 n=10)
MarshalJSON-10                       77.21n ± 1%   62.32n ± 0%  -19.29% (p=0.000 n=10)
MarshalText-10                       77.43n ± 0%   62.04n ± 1%  -19.87% (p=0.000 n=10)
Parse-10                             113.7n ± 0%   113.6n ± 0%        ~ (p=0.197 n=10)
ParseRFC3339UTC-10                   43.85n ± 0%   43.81n ± 0%        ~ (p=0.809 n=10)
ParseRFC3339UTCBytes-10              45.65n ± 1%   45.65n ± 0%        ~ (p=0.539 n=10)
ParseRFC3339TZ-10                    70.88n ± 0%   55.53n ± 0%  -21.65% (p=0.000 n=10)
ParseRFC3339TZBytes-10               84.36n ± 0%   69.38n ± 1%  -17.76% (p=0.000 n=10)
ParseDuration-10                     62.66n ± 2%   62.62n ± 0%        ~ (p=0.183 n=10)
Hour-10                             19.245n ± 0%   3.397n ± 0%  -82.35% (p=0.000 n=10)
Second-10                           19.230n ± 0%   3.405n ± 2%  -82.29% (p=0.000 n=10)
Year-10                             21.740n ± 0%   7.315n ± 1%  -66.35% (p=0.000 n=10)
Day-10                              22.695n ± 2%   8.938n ± 0%  -60.62% (p=0.000 n=10)
ISOWeek-10                           24.33n ± 0%   10.76n ± 0%  -55.77% (p=0.000 n=10)
GoString-10                          93.85n ± 1%   76.47n ± 1%  -18.52% (p=0.000 n=10)
UnmarshalText-10                     68.17n ± 1%   52.78n ± 0%  -22.58% (p=0.000 n=10)
geomean                              51.04n        35.33n       -30.78%

                        │ docs/benchmark/old.txt │       docs/benchmark/new.txt        │
                        │          B/op          │    B/op     vs base                 │
Now-10                              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
NowUnixNano-10                      0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
NowUnixMilli-10                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
NowUnixMicro-10                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Format-10                           24.00 ± 0%     24.00 ± 0%       ~ (p=1.000 n=10) ¹
FormatRFC3339-10                    32.00 ± 0%     32.00 ± 0%       ~ (p=1.000 n=10) ¹
FormatRFC3339Nano-10                32.00 ± 0%     32.00 ± 0%       ~ (p=1.000 n=10) ¹
FormatNow-10                        32.00 ± 0%     32.00 ± 0%       ~ (p=1.000 n=10) ¹
MarshalJSON-10                      48.00 ± 0%     48.00 ± 0%       ~ (p=1.000 n=10) ¹
MarshalText-10                      48.00 ± 0%     48.00 ± 0%       ~ (p=1.000 n=10) ¹
Parse-10                            0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRFC3339UTC-10                  0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRFC3339UTCBytes-10             0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRFC3339TZ-10                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRFC3339TZBytes-10              48.00 ± 0%     48.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseDuration-10                    0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Hour-10                             0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Second-10                           0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Year-10                             0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Day-10                              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ISOWeek-10                          0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
GoString-10                         80.00 ± 0%     80.00 ± 0%       ~ (p=1.000 n=10) ¹
UnmarshalText-10                    0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean                                        ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                        │ docs/benchmark/old.txt │       docs/benchmark/new.txt        │
                        │       allocs/op        │ allocs/op   vs base                 │
Now-10                              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
NowUnixNano-10                      0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
NowUnixMilli-10                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
NowUnixMicro-10                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Format-10                           1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
FormatRFC3339-10                    1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
FormatRFC3339Nano-10                1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
FormatNow-10                        1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
MarshalJSON-10                      1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
MarshalText-10                      1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
Parse-10                            0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRFC3339UTC-10                  0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRFC3339UTCBytes-10             0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRFC3339TZ-10                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRFC3339TZBytes-10              1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseDuration-10                    0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Hour-10                             0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Second-10                           0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Year-10                             0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Day-10                              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
ISOWeek-10                          0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
GoString-10                         1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
UnmarshalText-10                    0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean                                        ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

Metadata

Metadata

Assignees

No one assigned

    Labels

    FrozenDueToAgeNeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions