Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Comment on the the Timestamp.Time() method #197

Merged

Conversation

nathanmcgarvey-modopayments
Copy link
Contributor

@nathanmcgarvey-modopayments nathanmcgarvey-modopayments commented Feb 8, 2025

Fixes #196

@nathanmcgarvey-modopayments

This comment was marked as duplicate.

Copy link

codecov bot commented Feb 9, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (5e1b166) to head (de3c87c).
Report is 4 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff            @@
##            master      #197   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            5         5           
  Lines          545       545           
=========================================
  Hits           545       545           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

kohenkatz

This comment was marked as outdated.

@kohenkatz
Copy link
Contributor

kohenkatz commented Feb 9, 2025

I thought of one situation in which this could be a breaking change. From the documentation for time.Time:

Note that the Go == operator compares not just the time instant but also the Location and the monotonic clock reading. Therefore, Time values should not be used as map or database keys without first guaranteeing that the identical Location has been set for all values, which can be achieved through use of the UTC or Local method, and that the monotonic clock reading has been stripped by setting t = t.Round(0).

So if anyone was relying on the previous behavior of being Local, this will be a problem for them.

Is this something we need to worry about?

@kohenkatz kohenkatz dismissed their stale review February 9, 2025 02:33

I think this might be considered a breaking change

@ldez
Copy link
Contributor

ldez commented Feb 9, 2025

Maybe you can do something like that:

// Time returns the time.Time representation of a Timestamp.
// Deprecated: use TimeUTC instead.
func (t Timestamp) Time() (time.Time, error) {
	secs := uint64(t) / _100nsPerSecond
	nsecs := 100 * (uint64(t) % _100nsPerSecond)

	return time.Unix(int64(secs)-(epochStart/_100nsPerSecond), int64(nsecs)), nil
}

// TimeUTC returns the UTC time.Time representation of a Timestamp.
func (t Timestamp) TimeUTC() (time.Time, error) {
	base, err := t.Time()
	if err != nil {
		return time.Time{}, err
	}

	return base.UTC(), nil
}

This avoids any side effects or breaking changes.

The other safest approach is to only update the documentation:

// Time returns the local time.Time representation of a Timestamp.

No possible regression but the existing "bug" will still be here if someone created its code based on the comment.

@nathanmcgarvey-modopayments
Copy link
Contributor Author

nathanmcgarvey-modopayments commented Feb 9, 2025

EDIT: This is expected behavior. Please read later replies (#197 (comment)) for explanation of behavior


From my very small perspective in my very small corner of the world:

The current behavior is broken, both in theory and practice.

In practice:

func TestTimeZone(t *testing.T) {
	uuids := []UUID{
		Must(FromString("00000000-0000-7000-8000-000000000000")),
		Must(FromString("10000000-0000-7000-8000-000000000000")),
		Must(FromString("20000000-0000-7000-8000-000000000000")),
		Must(FromString("30000000-0000-7000-8000-000000000000")),
		Must(FromString("30000000-0000-7000-8000-000000000000")),
		Must(FromString("40000000-0000-7000-8000-000000000000")),
		Must(FromString("50000000-0000-7000-8000-000000000000")),
		Must(FromString("60000000-0000-7000-8000-000000000000")),
		Must(FromString("70000000-0000-7000-8000-000000000000")),
		Must(FromString("80000000-0000-7000-8000-000000000000")),
		Must(FromString("90000000-0000-7000-8000-000000000000")),
		Must(FromString("a0000000-0000-7000-8000-000000000000")),
		Must(FromString("b0000000-0000-7000-8000-000000000000")),
		Must(FromString("c0000000-0000-7000-8000-000000000000")),
		Must(FromString("d0000000-0000-7000-8000-000000000000")),
		Must(FromString("e0000000-0000-7000-8000-000000000000")),
		Must(FromString("f0000000-0000-7000-8000-000000000000")),
	}

	for _, uid := range uuids {
		t.Run(uid.String(), func(t *testing.T) {
			got, _ := TimestampFromV7(uid)
			tm, err := got.Time()
			t.Log(tm.Zone())
			if err != nil {
				t.Fatalf("uuid.Time(%q) failed: %v", uid, err)
			}
		})
	}
}

Output (at least for me) gives four time zones (CST,CWT,LMT,CDT):

go test -v -timeout 30s -run ^TestTimeZone$ github.com/gofrs/uuid/v5 -count=1 
=== RUN   TestTimeZone
=== RUN   TestTimeZone/00000000-0000-7000-8000-000000000000
    uuid_test.go:312: CST -21600
=== RUN   TestTimeZone/10000000-0000-7000-8000-000000000000
    uuid_test.go:312: CWT -18000
=== RUN   TestTimeZone/20000000-0000-7000-8000-000000000000
    uuid_test.go:312: CST -21600
=== RUN   TestTimeZone/30000000-0000-7000-8000-000000000000
    uuid_test.go:312: CST -21600
=== RUN   TestTimeZone/30000000-0000-7000-8000-000000000000#01
    uuid_test.go:312: CST -21600
=== RUN   TestTimeZone/40000000-0000-7000-8000-000000000000
    uuid_test.go:312: LMT -21036
=== RUN   TestTimeZone/50000000-0000-7000-8000-000000000000
    uuid_test.go:312: LMT -21036
=== RUN   TestTimeZone/60000000-0000-7000-8000-000000000000
    uuid_test.go:312: LMT -21036
=== RUN   TestTimeZone/70000000-0000-7000-8000-000000000000
    uuid_test.go:312: LMT -21036
=== RUN   TestTimeZone/80000000-0000-7000-8000-000000000000
    uuid_test.go:312: LMT -21036
=== RUN   TestTimeZone/90000000-0000-7000-8000-000000000000
    uuid_test.go:312: LMT -21036
=== RUN   TestTimeZone/a0000000-0000-7000-8000-000000000000
    uuid_test.go:312: LMT -21036
=== RUN   TestTimeZone/b0000000-0000-7000-8000-000000000000
    uuid_test.go:312: CDT -18000
=== RUN   TestTimeZone/c0000000-0000-7000-8000-000000000000
    uuid_test.go:312: CDT -18000
=== RUN   TestTimeZone/d0000000-0000-7000-8000-000000000000
    uuid_test.go:312: CDT -18000
=== RUN   TestTimeZone/e0000000-0000-7000-8000-000000000000
    uuid_test.go:312: CDT -18000
=== RUN   TestTimeZone/f0000000-0000-7000-8000-000000000000
    uuid_test.go:312: CDT -18000
--- PASS: TestTimeZone (0.00s)
    --- PASS: TestTimeZone/00000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/10000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/20000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/30000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/30000000-0000-7000-8000-000000000000#01 (0.00s)
    --- PASS: TestTimeZone/40000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/50000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/60000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/70000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/80000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/90000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/a0000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/b0000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/c0000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/d0000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/e0000000-0000-7000-8000-000000000000 (0.00s)
    --- PASS: TestTimeZone/f0000000-0000-7000-8000-000000000000 (0.00s)
PASS
ok  	github.com/gofrs/uuid/v5	0.170s

In theory:

As your quote from the time package points out:

Time values should not be used as map or database keys without first guaranteeing that the identical Location has been set for all values, which can be achieved through use of the UTC or Local method, and that the monotonic clock reading has been stripped by setting t = t.Round(0)

The code currently does none of those (.UTC() or.Local(), and .Round(0)). So we're sure that the monotonic clock is changing regarding map keys. (A caller would have to be doing that themself, already.) And regarding equals and a timezone the time package provides a .Equal() method intentionally specifying the following:

Equal reports whether t and u represent the same time instant. Two times can be equal even if they are in different locations. For example, 6:00 +0200 and 4:00 UTC are Equal. See the documentation on the Time type for the pitfalls of using == with Time values; most code should use Equal instead.

Additionally, this is memory-internal. Unless the caller is doing string or binary-equivalent comparison on a previously saved time.Time object E.g. intentionally saving the timezone off somewhere, then bringing it back and comparing, this is a non-issue. If only in memory, then when the process is restarted after updating this package, all the comparisons should work as usual. If they saved it off as a string or binary representation, that has no time zone data, either, so marshaling it back to a UUID should be fine. Only if someone is calling the .Time() method and then saving off some representation of it with timezone that persists process restarts or communicates with a system that includes the timezone, but is not actually parsing the timezone would this be an issue. (I think.... maybe I'm missing a corner case somewhere.)

@kohenkatz
Copy link
Contributor

kohenkatz commented Feb 9, 2025

CST,CWT,LMT,CDT

This is actually working as expected. The four time zone abbreviations you are seeing are an artifact of dealing with past timestamps.

  • CST - "Central Standard Time"
  • CWT - "Central War Time" - used for WWII
  • LMT - "Local Mean Time" - used pre-Standardized-Timezones
  • CDT - "Central Daylight Time"

Your sample times are missing one other option in your area: "CPT" = "Central Peace Time" (after the conclusion of WWII).

The reason you are seeing this in an unexpected order ("LMT" seen after Standard time) is because of your other issue, #195, which is causing the dates to be calculated incorrectly.

Here is what I get (US Eastern time) if I add t.Log(tm) above t.Log(tm.Zone()) in your example:

Note the use of "EWT" for 1942, "EST/EDT" for everything else after 1883 (the creation of "Railway Time" which became "Standard Time", and "LMT" for everything before that.

go.exe test -timeout 30s -run ^TestTimeZone$ github.com/gofrs/uuid/v5

=== RUN   TestTimeZone
=== RUN   TestTimeZone/00000000-0000-7000-8000-000000000000
    codec_test.go:545: 1969-12-31 19:00:00 -0500 EST
    codec_test.go:546: EST -18000
--- PASS: TestTimeZone/00000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/10000000-0000-7000-8000-000000000000
    codec_test.go:545: 1942-12-03 02:46:10.7064484 -0400 EWT
    codec_test.go:546: EWT -14400
--- PASS: TestTimeZone/10000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/20000000-0000-7000-8000-000000000000
    codec_test.go:545: 1915-11-04 08:32:21.4128968 -0500 EST
    codec_test.go:546: EST -18000
--- PASS: TestTimeZone/20000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/30000000-0000-7000-8000-000000000000
    codec_test.go:545: 1888-10-04 15:18:32.1193452 -0500 EST
    codec_test.go:546: EST -18000
--- PASS: TestTimeZone/30000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/30000000-0000-7000-8000-000000000000#01
    codec_test.go:545: 1888-10-04 15:18:32.1193452 -0500 EST
    codec_test.go:546: EST -18000
--- PASS: TestTimeZone/30000000-0000-7000-8000-000000000000#01 (0.00s)
=== RUN   TestTimeZone/40000000-0000-7000-8000-000000000000
    codec_test.go:545: 1861-09-05 22:08:40.8257936 -0456 LMT
    codec_test.go:546: LMT -17762
--- PASS: TestTimeZone/40000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/50000000-0000-7000-8000-000000000000
    codec_test.go:545: 1834-08-08 04:54:51.532242 -0456 LMT
    codec_test.go:546: LMT -17762
--- PASS: TestTimeZone/50000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/60000000-0000-7000-8000-000000000000
    codec_test.go:545: 1807-07-10 11:41:02.2386904 -0456 LMT
    codec_test.go:546: LMT -17762
--- PASS: TestTimeZone/60000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/70000000-0000-7000-8000-000000000000
    codec_test.go:545: 1780-06-09 18:27:12.9451387 -0456 LMT
    codec_test.go:546: LMT -17762
--- PASS: TestTimeZone/70000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/80000000-0000-7000-8000-000000000000
    codec_test.go:545: 1753-05-12 01:13:23.6515871 -0456 LMT
    codec_test.go:546: LMT -17762
--- PASS: TestTimeZone/80000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/90000000-0000-7000-8000-000000000000
    codec_test.go:545: 1726-04-13 07:59:34.3580355 -0456 LMT
    codec_test.go:546: LMT -17762
--- PASS: TestTimeZone/90000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/a0000000-0000-7000-8000-000000000000
    codec_test.go:545: 1699-03-14 14:45:45.0644839 -0456 LMT
    codec_test.go:546: LMT -17762
--- PASS: TestTimeZone/a0000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/b0000000-0000-7000-8000-000000000000
    codec_test.go:545: 2256-09-03 22:02:31.4804838 -0400 EDT
    codec_test.go:546: EDT -14400
--- PASS: TestTimeZone/b0000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/c0000000-0000-7000-8000-000000000000
    codec_test.go:545: 2229-08-06 04:48:42.1869322 -0400 EDT
    codec_test.go:546: EDT -14400
--- PASS: TestTimeZone/c0000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/d0000000-0000-7000-8000-000000000000
    codec_test.go:545: 2202-07-08 11:34:52.8933806 -0400 EDT
    codec_test.go:546: EDT -14400
--- PASS: TestTimeZone/d0000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/e0000000-0000-7000-8000-000000000000
    codec_test.go:545: 2175-06-08 18:21:03.5998289 -0400 EDT
    codec_test.go:546: EDT -14400
--- PASS: TestTimeZone/e0000000-0000-7000-8000-000000000000 (0.00s)
=== RUN   TestTimeZone/f0000000-0000-7000-8000-000000000000
    codec_test.go:545: 2148-05-10 01:07:14.3062773 -0400 EDT
    codec_test.go:546: EDT -14400
--- PASS: TestTimeZone/f0000000-0000-7000-8000-000000000000 (0.00s)
--- PASS: TestTimeZone (0.00s)
PASS
ok      github.com/gofrs/uuid/v5        0.502s

The code currently does none of those (.UTC() or.Local(), and .Round(0))

Not explicitly, but it does the same thing in a not-well-documented way. Calls to time.Unix explicitly use the Local timezone, and already don't have any monotonic clock reading that would need to be stripped (by calling Round(0)).

Additionally, this is memory-internal. Unless the caller is doing string or binary-equivalent comparison on a previously saved time.Time object ... (I think.... maybe I'm missing a corner case somewhere.)

The case you are missing here is exactly what is mentioned in the documentation: map[time.Time]any - the runtime will compare the time keys using ==, not .Equal(). With the current implementation's implicit use of Local, a time.Time constructed in the local timezone pointing to the same timestamp would match. Changing this implementation to UTC would definitely cause that to break.

@nathanmcgarvey-modopayments
Copy link
Contributor Author

CST,CWT,LMT,CDT

This is actually working as expected. The four time zone abbreviations you are seeing are an artifact of dealing with past timestamps.

* CST - "Central Standard Time"

* CWT - "Central War Time" - used for WWII

* LMT - "Local Mean Time" - used pre-Standardized-Timezones

* CDT - "Central Daylight Time"

Your sample times are missing one other option in your area: "CPT" = "Central Peace Time" (after the conclusion of WWII).

The reason you are seeing this in an unexpected order ("LMT" seen after Standard time) is because of your other issue, #195, which is causing the dates to be calculated incorrectly.

Here is what I get (US Eastern time) if I add t.Log(tm) above t.Log(tm.Zone()) in your example:

Note the use of "EWT" for 1942, "EST/EDT" for everything else after 1883 (the creation of "Railway Time" which became "Standard Time", and "LMT" for everything before that.

Well, now I learned something today! I'm calling that a good day.

The code currently does none of those (.UTC() or.Local(), and .Round(0))

Not explicitly, but it does the same thing in a not-well-documented way. Calls to time.Unix explicitly use the Local timezone, and already don't have any monotonic clock reading that would need to be stripped (by calling Round(0)).

Thanks for pointing that out. My quick read didn't catch that.

The case you are missing here is exactly what is mentioned in the documentation: map[time.Time]any - the runtime will compare the time keys using ==, not .Equal(). With the current implementation's implicit use of Local, a time.Time constructed in the local timezone pointing to the same timestamp would match. Changing this implementation to UTC would definitely cause that to break.

Yes, that makes sense. I pity the person attempting to wrangle a Local timezone as a map key. That sounds unpleasant.

@nathanmcgarvey-modopayments
Copy link
Contributor Author

Maybe you can do something like that:

// Time returns the time.Time representation of a Timestamp.
// Deprecated: use TimeUTC instead.
func (t Timestamp) Time() (time.Time, error) {
	secs := uint64(t) / _100nsPerSecond
	nsecs := 100 * (uint64(t) % _100nsPerSecond)

	return time.Unix(int64(secs)-(epochStart/_100nsPerSecond), int64(nsecs)), nil
}

// TimeUTC returns the UTC time.Time representation of a Timestamp.
func (t Timestamp) TimeUTC() (time.Time, error) {
	base, err := t.Time()
	if err != nil {
		return time.Time{}, err
	}

	return base.UTC(), nil
}

This avoids any side effects or breaking changes.

The other safest approach is to only update the documentation:

// Time returns the local time.Time representation of a Timestamp.

No possible regression but the existing "bug" will still be here if someone created its code based on the comment.

I like either of these.... why does Time() return an error? (I'll assume legacy reasons.)

@kohenkatz
Copy link
Contributor

kohenkatz commented Feb 9, 2025

why does Time() return an error? (I'll assume legacy reasons.)

Not exactly for legacy reasons. It was left there "for potential unknown cases in future to avoid [breaking changes]". 😐

See #31 (comment)

The original PR on this project's predecessor does have a use for it, but that use was never relevant in this implementation.

@nathanmcgarvey-modopayments
Copy link
Contributor Author

Happy to update this PR. Is there a preference from the new function and deprecated function approach vs just updating the comment?

@kohenkatz
Copy link
Contributor

Personally, I would go with just updating the comment. Anyone who wants to call .UTC() on the returned time.Time instance can do so easily. As we've previously discussed in #81, #128, and #191 (among probably other places), we don't want to have two different ways to do similar things unless there is a very good reason.

@nathanmcgarvey-modopayments nathanmcgarvey-modopayments changed the title ensure returned time is UTC from the Timestamp.Time() method Fix Comment on the the Timestamp.Time() method Feb 9, 2025
Copy link
Contributor

@kohenkatz kohenkatz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is better. For those of us who understand the construction of time.Time values from Unix timestamps, the new meaning is clear.

But I think that anyone who does not understand that time.Unix() uses the Local time zone might think that this means that we are counting the Unix timestamp from the local time zone's 1970-01-01 00:00:00 instead of from UTC.

How about this?

// Time returns the time.Time representation of a Timestamp.
//
// For display and comparison purposes, note that this function produces a time.Time value that contains only wall clock time (no monotonic counter) and uses the time.Local time zone for display.

It simplifies the basic description, and puts the details further down for anyone who cares.

@nathanmcgarvey-modopayments
Copy link
Contributor Author

Counter offer (slightly reworded and formatted):

// Time returns the time.Time representation of a Timestamp.
//
// The resulting time.Time:
// - Contains only wall clock time (no monotonic clock component)
// - Uses the local system timezone for the location

@kohenkatz
Copy link
Contributor

Counter offer (slightly reworded and formatted):

That looks good to me

…mestamp.Time method and revert to original behavior
Copy link
Contributor

@kohenkatz kohenkatz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kohenkatz kohenkatz merged commit 7d51018 into gofrs:master Feb 10, 2025
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Timestamp.Time() not returning UTC
3 participants