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

Add "AtTime" generators for V1, V6, and V7 #142

Merged
merged 5 commits into from
Aug 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 60 additions & 18 deletions generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ func NewV1() (UUID, error) {
return DefaultGenerator.NewV1()
}

// NewV1 returns a UUID based on the provided timestamp and MAC address.
func NewV1AtTime(atTime time.Time) (UUID, error) {
return DefaultGenerator.NewV1AtTime(atTime)
}

// NewV3 returns a UUID based on the MD5 hash of the namespace UUID and name.
func NewV3(ns UUID, name string) UUID {
return DefaultGenerator.NewV3(ns, name)
Expand All @@ -66,27 +71,45 @@ func NewV5(ns UUID, name string) UUID {
return DefaultGenerator.NewV5(ns, name)
}

// NewV6 returns a k-sortable UUID based on a timestamp and 48 bits of
// NewV6 returns a k-sortable UUID based on the current timestamp and 48 bits of
// pseudorandom data. The timestamp in a V6 UUID is the same as V1, with the bit
// order being adjusted to allow the UUID to be k-sortable.
func NewV6() (UUID, error) {
return DefaultGenerator.NewV6()
}

// NewV7 returns a k-sortable UUID based on the current millisecond precision
// UNIX epoch and 74 bits of pseudorandom data. It supports single-node batch generation (multiple UUIDs in the same timestamp) with a Monotonic Random counter.
// NewV6 returns a k-sortable UUID based on the provided timestamp and 48 bits of
// pseudorandom data. The timestamp in a V6 UUID is the same as V1, with the bit
// order being adjusted to allow the UUID to be k-sortable.
func NewV6AtTime(atTime time.Time) (UUID, error) {
return DefaultGenerator.NewV6AtTime(atTime)
}

// NewV7 returns a k-sortable UUID based on the current millisecond-precision
// UNIX epoch and 74 bits of pseudorandom data. It supports single-node batch
// generation (multiple UUIDs in the same timestamp) with a Monotonic Random counter.
func NewV7() (UUID, error) {
return DefaultGenerator.NewV7()
}

// NewV7 returns a k-sortable UUID based on the provided millisecond-precision
// UNIX epoch and 74 bits of pseudorandom data. It supports single-node batch
// generation (multiple UUIDs in the same timestamp) with a Monotonic Random counter.
func NewV7AtTime(atTime time.Time) (UUID, error) {
return DefaultGenerator.NewV7AtTime(atTime)
}

// Generator provides an interface for generating UUIDs.
type Generator interface {
NewV1() (UUID, error)
NewV1AtTime(time.Time) (UUID, error)
Copy link
Member

Choose a reason for hiding this comment

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

I'd hate to create a new major version for this, but this is pedantically a breaking change if any consumer has their own implementation of the Generator interface.

At a minimum, we should call out that there are 3 new methods on this interface so that consumers will have a pointer to the source of their broken builds.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it is fine to remove from the interface, but that will mean we cannot use the package-level convenience methods, because those operate using DefaultGenerator which uses this interface.

This just means that to use the new methods users will need to create a custom generator.

Copy link
Member

Choose a reason for hiding this comment

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

I'm not against adding the methods to the interface, we just need to decide whether we bump to v6.x or we accept what is technically a breaking change in a v5.x minor release and "fix with words" for any consumers that have their own implementations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I'd go with jumping to 6.x. It's not such a big deal to make the change.

Copy link
Member

Choose a reason for hiding this comment

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

Easy enough for this code, but It's a much bigger deal for every consumer to have to update every import to s|v5|v6 😬

Copy link
Contributor Author

@kohenkatz kohenkatz Aug 9, 2024

Choose a reason for hiding this comment

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

Unfortunately, there's no way for us to know how many consumers are implementing the interface separately. Numbers like that would be very useful to this decision.

Copy link
Member

Choose a reason for hiding this comment

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

per pkg.go.dev, 749 public things import /v5. I know we have a bunch of internal modules at work that also import it.

I can confirm that we don't have any custom Generator implementations and someone could theoretically check all of those public importers, but it's impossible to know if any non-public consumers will break.

Personally, I feel like the odds are very low that this will actually be a breaking change and we should do a v5.x minor release with a note.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good to me, but we probably should wait for at least one additional voice.

NewV3(ns UUID, name string) UUID
NewV4() (UUID, error)
NewV5(ns UUID, name string) UUID
NewV6() (UUID, error)
NewV6AtTime(time.Time) (UUID, error)
NewV7() (UUID, error)
NewV7AtTime(time.Time) (UUID, error)
}

// Gen is a reference UUID generator based on the specifications laid out in
Expand Down Expand Up @@ -211,9 +234,14 @@ func WithRandomReader(reader io.Reader) GenOption {

// NewV1 returns a UUID based on the current timestamp and MAC address.
func (g *Gen) NewV1() (UUID, error) {
return g.NewV1AtTime(g.epochFunc())
}

// NewV1AtTime returns a UUID based on the provided timestamp and current MAC address.
func (g *Gen) NewV1AtTime(atTime time.Time) (UUID, error) {
u := UUID{}

timeNow, clockSeq, err := g.getClockSequence(false)
timeNow, clockSeq, err := g.getClockSequence(false, atTime)
if err != nil {
return Nil, err
}
Expand Down Expand Up @@ -264,10 +292,17 @@ func (g *Gen) NewV5(ns UUID, name string) UUID {
return u
}

// NewV6 returns a k-sortable UUID based on a timestamp and 48 bits of
// NewV6 returns a k-sortable UUID based on the current timestamp and 48 bits of
// pseudorandom data. The timestamp in a V6 UUID is the same as V1, with the bit
// order being adjusted to allow the UUID to be k-sortable.
func (g *Gen) NewV6() (UUID, error) {
return g.NewV6AtTime(g.epochFunc())
}

// NewV6 returns a k-sortable UUID based on the provided timestamp and 48 bits of
// pseudorandom data. The timestamp in a V6 UUID is the same as V1, with the bit
// order being adjusted to allow the UUID to be k-sortable.
func (g *Gen) NewV6AtTime(atTime time.Time) (UUID, error) {
/* https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-6
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
Expand All @@ -282,7 +317,7 @@ func (g *Gen) NewV6() (UUID, error) {
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ */
var u UUID

timeNow, _, err := g.getClockSequence(false)
timeNow, _, err := g.getClockSequence(false, atTime)
if err != nil {
return Nil, err
}
Expand All @@ -306,9 +341,15 @@ func (g *Gen) NewV6() (UUID, error) {
return u, nil
}

// NewV7 returns a k-sortable UUID based on the current millisecond precision
// NewV7 returns a k-sortable UUID based on the current millisecond-precision
// UNIX epoch and 74 bits of pseudorandom data.
func (g *Gen) NewV7() (UUID, error) {
return g.NewV7AtTime(g.epochFunc())
}

// NewV7 returns a k-sortable UUID based on the provided millisecond-precision
// UNIX epoch and 74 bits of pseudorandom data.
func (g *Gen) NewV7AtTime(atTime time.Time) (UUID, error) {
var u UUID
/* https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-7
0 1 2 3
Expand All @@ -323,7 +364,7 @@ func (g *Gen) NewV7() (UUID, error) {
| rand_b |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ */

ms, clockSeq, err := g.getClockSequence(true)
ms, clockSeq, err := g.getClockSequence(true, atTime)
if err != nil {
return Nil, err
}
Expand Down Expand Up @@ -355,12 +396,13 @@ func (g *Gen) NewV7() (UUID, error) {
return u, nil
}

// getClockSequence returns the epoch and clock sequence for V1,V6 and V7 UUIDs.
//
// When useUnixTSMs is false, it uses the Coordinated Universal Time (UTC) as a count of 100-
// getClockSequence returns the epoch and clock sequence of the provided time,
// used for generating V1,V6 and V7 UUIDs.
//
// nanosecond intervals since 00:00:00.00, 15 October 1582 (the date of Gregorian reform to the Christian calendar).
func (g *Gen) getClockSequence(useUnixTSMs bool) (uint64, uint16, error) {
// When useUnixTSMs is false, it uses the Coordinated Universal Time (UTC) as a count of
// 100-nanosecond intervals since 00:00:00.00, 15 October 1582 (the date of Gregorian
// reform to the Christian calendar).
func (g *Gen) getClockSequence(useUnixTSMs bool, atTime time.Time) (uint64, uint16, error) {
kohenkatz marked this conversation as resolved.
Show resolved Hide resolved
var err error
g.clockSequenceOnce.Do(func() {
buf := make([]byte, 2)
Expand All @@ -378,9 +420,9 @@ func (g *Gen) getClockSequence(useUnixTSMs bool) (uint64, uint16, error) {

var timeNow uint64
if useUnixTSMs {
timeNow = uint64(g.epochFunc().UnixMilli())
timeNow = uint64(atTime.UnixMilli())
} else {
timeNow = g.getEpoch()
timeNow = g.getEpoch(atTime)
}
// Clock didn't change since last UUID generation.
// Should increase clock sequence.
Expand Down Expand Up @@ -417,9 +459,9 @@ func (g *Gen) getHardwareAddr() ([]byte, error) {
}

// Returns the difference between UUID epoch (October 15, 1582)
// and current time in 100-nanosecond intervals.
func (g *Gen) getEpoch() uint64 {
return epochStart + uint64(g.epochFunc().UnixNano()/100)
// and the provided time in 100-nanosecond intervals.
func (g *Gen) getEpoch(atTime time.Time) uint64 {
kohenkatz marked this conversation as resolved.
Show resolved Hide resolved
return epochStart + uint64(atTime.UnixNano()/100)
}

// Returns the UUID based on the hashing of the namespace UUID and name.
Expand Down
142 changes: 142 additions & 0 deletions generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func testNewV1(t *testing.T) {
t.Run("MissingNetworkWithOptions", testNewV1MissingNetworkWithOptions)
t.Run("MissingNetworkFaultyRand", testNewV1MissingNetworkFaultyRand)
t.Run("MissingNetworkFaultyRandWithOptions", testNewV1MissingNetworkFaultyRandWithOptions)
t.Run("AtSpecificTime", testNewV1AtTime)
}

func TestNewGenWithHWAF(t *testing.T) {
Expand Down Expand Up @@ -225,6 +226,53 @@ func testNewV1MissingNetworkFaultyRandWithOptions(t *testing.T) {
}
}

func testNewV1AtTime(t *testing.T) {
atTime := time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC)

u1, err := NewV1AtTime(atTime)
if err != nil {
t.Fatal(err)
}

u2, err := NewV1AtTime(atTime)
if err != nil {
t.Fatal(err)
}

// Even with the same timestamp, there is still a monotonically increasing portion,
// so they should not be 100% identical. Bytes 0-7 and 10-16 should be identical.
u1Bytes := u1.Bytes()
u2Bytes := u2.Bytes()
binary.BigEndian.PutUint16(u1Bytes[8:], 0)
binary.BigEndian.PutUint16(u2Bytes[8:], 0)
if !bytes.Equal(u1Bytes, u2Bytes) {
t.Errorf("generated different UUIDs across calls with same timestamp: %v / %v", u1, u2)
}

ts1, err := TimestampFromV1(u1)
if err != nil {
t.Fatal(err)
}
time1, err := ts1.Time()
if err != nil {
t.Fatal(err)
}
if time1.Equal(atTime) {
t.Errorf("extracted time is incorrect: was %v, expected %v", time1, atTime)
}
ts2, err := TimestampFromV1(u2)
if err != nil {
t.Fatal(err)
}
time2, err := ts2.Time()
if err != nil {
t.Fatal(err)
}
if time2.Equal(atTime) {
t.Errorf("extracted time is incorrect: was %v, expected %v", time1, atTime)
}
}

func testNewV1FaultyRandWithOptions(t *testing.T) {
g := NewGenWithOptions(WithRandomReader(&faultyReader{
readToFail: 0, // fail immediately
Expand Down Expand Up @@ -423,6 +471,7 @@ func testNewV6(t *testing.T) {
t.Run("ShortRandomRead", testNewV6ShortRandomRead)
t.Run("ShortRandomReadWithOptions", testNewV6ShortRandomReadWithOptions)
t.Run("KSortable", testNewV6KSortable)
t.Run("AtSpecificTime", testNewV6AtTime)
}

func testNewV6Basic(t *testing.T) {
Expand Down Expand Up @@ -601,6 +650,51 @@ func testNewV6KSortable(t *testing.T) {
}
}

func testNewV6AtTime(t *testing.T) {
atTime := time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC)

u1, err := NewV6AtTime(atTime)
if err != nil {
t.Fatal(err)
}

u2, err := NewV6AtTime(atTime)
if err != nil {
t.Fatal(err)
}

// Even with the same timestamp, there is still a random portion,
// so they should not be 100% identical. Bytes 0-8 are the timestamp so they should be identical.
u1Bytes := u1.Bytes()[:8]
u2Bytes := u2.Bytes()[:8]
if !bytes.Equal(u1Bytes, u2Bytes) {
t.Errorf("generated different UUIDs across calls with same timestamp: %v / %v", u1, u2)
}

ts1, err := TimestampFromV6(u1)
if err != nil {
t.Fatal(err)
}
time1, err := ts1.Time()
if err != nil {
t.Fatal(err)
}
if time1.Equal(atTime) {
t.Errorf("extracted time is incorrect: was %v, expected %v", time1, atTime)
}
ts2, err := TimestampFromV6(u2)
if err != nil {
t.Fatal(err)
}
time2, err := ts2.Time()
if err != nil {
t.Fatal(err)
}
if time2.Equal(atTime) {
t.Errorf("extracted time is incorrect: was %v, expected %v", time1, atTime)
}
}

func testNewV7(t *testing.T) {
t.Run("Basic", makeTestNewV7Basic())
t.Run("TestVector", makeTestNewV7TestVector())
Expand All @@ -614,6 +708,7 @@ func testNewV7(t *testing.T) {
t.Run("ShortRandomReadWithOptions", makeTestNewV7ShortRandomReadWithOptions())
t.Run("KSortable", makeTestNewV7KSortable())
t.Run("ClockSequence", makeTestNewV7ClockSequence())
t.Run("AtSpecificTime", makeTestNewV7AtTime())
}

func makeTestNewV7Basic() func(t *testing.T) {
Expand Down Expand Up @@ -861,6 +956,53 @@ func makeTestNewV7ClockSequence() func(t *testing.T) {
}
}

func makeTestNewV7AtTime() func(t *testing.T) {
return func(t *testing.T) {
atTime := time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC)

u1, err := NewV7AtTime(atTime)
if err != nil {
t.Fatal(err)
}

u2, err := NewV7AtTime(atTime)
if err != nil {
t.Fatal(err)
}

// Even with the same timestamp, there is still a random portion,
// so they should not be 100% identical. Bytes 0-6 are the timestamp so they should be identical.
u1Bytes := u1.Bytes()[:7]
u2Bytes := u2.Bytes()[:7]
if !bytes.Equal(u1Bytes, u2Bytes) {
t.Errorf("generated different UUIDs across calls with same timestamp: %v / %v", u1, u2)
}

ts1, err := TimestampFromV7(u1)
if err != nil {
t.Fatal(err)
}
time1, err := ts1.Time()
if err != nil {
t.Fatal(err)
}
if time1.Equal(atTime) {
t.Errorf("extracted time is incorrect: was %v, expected %v", time1, atTime)
}
ts2, err := TimestampFromV7(u2)
if err != nil {
t.Fatal(err)
}
time2, err := ts2.Time()
if err != nil {
t.Fatal(err)
}
if time2.Equal(atTime) {
t.Errorf("extracted time is incorrect: was %v, expected %v", time1, atTime)
}
}
}

func TestDefaultHWAddrFunc(t *testing.T) {
tests := []struct {
n string
Expand Down
Loading