From d21ea59238eb07a854762ebbf0b10cc8cdbb6fdb Mon Sep 17 00:00:00 2001 From: jdl Date: Sun, 14 Jun 2026 16:54:40 +0200 Subject: [PATCH] Cleanup --- idgen.go | 47 ++++++++++++------- idgen_test.go | 124 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 26 deletions(-) diff --git a/idgen.go b/idgen.go index c95a1ef..6af9cc8 100644 --- a/idgen.go +++ b/idgen.go @@ -7,42 +7,55 @@ import ( "time" ) -// Creates a new, random token. +var encoding = base32.StdEncoding.WithPadding(base32.NoPadding) + +// Creates a new, random token from 20 random bytes, encoded as base32. func NewToken() string { buf := make([]byte, 20) - _, err := rand.Read(buf) - if err != nil { - panic(err) - } - return base32.StdEncoding.EncodeToString(buf) + rand.Read(buf) // Guaranteed not to return an error. + return encoding.EncodeToString(buf) } var ( - lock sync.Mutex - ts int64 = time.Now().Unix() - counter int64 = 1 - counterMax int64 = 1 << 20 + lock sync.Mutex + ts int64 = time.Now().Unix() + seq int64 = 0 + seqMax int64 = 1 << 20 ) -// NextID can generate ~1M ints per second for a given nodeID. +// NextID can generate ~1M int64s per second. // -// nodeID must be less than 64. +// nodeID must be in [0, 63]. The counter is shared across all nodeIDs within a +// process, so IDs are not guaranteed to be sequential. func NextID(nodeID int64) int64 { + if nodeID < 0 || nodeID >= 64 { + panic("nodeID out of range [0, 63]") + } + + for { + if id := nextID(nodeID); id != 0 { + return id + } + time.Sleep(100 * time.Millisecond) + } +} + +func nextID(nodeID int64) int64 { lock.Lock() defer lock.Unlock() tt := time.Now().Unix() if tt > ts { ts = tt - counter = 1 + seq = 1 } else { - counter++ - if counter == counterMax { - panic("Too many IDs.") + seq++ + if seq >= seqMax { + return 0 } } - return (ts << 26) + (nodeID << 20) + counter + return (ts << 26) + (nodeID << 20) + seq } func SplitID(id int64) (unixTime, nodeID, counter int64) { diff --git a/idgen_test.go b/idgen_test.go index 822f6d7..77bcdb6 100644 --- a/idgen_test.go +++ b/idgen_test.go @@ -1,19 +1,125 @@ package idgen import ( - "log" "testing" + "testing/synctest" + "time" ) -func BenchmarkNext(b *testing.B) { - for i := 0; i < b.N; i++ { - NextID(0) +func TestNewToken_length(t *testing.T) { + tok := NewToken() + if len(tok) != 32 { + t.Fatalf("expected 32 chars, got %d: %q", len(tok), tok) } } -func TestNextID(t *testing.T) { - id := NextID(32) - - a, b, c := SplitID(id) - log.Print(a, b, c) +func TestNewToken_validBase32(t *testing.T) { + tok := NewToken() + if _, err := encoding.DecodeString(tok); err != nil { + t.Fatalf("invalid base32: %v", err) + } +} + +func TestNewToken_unique(t *testing.T) { + seen := make(map[string]bool, 100) + for range 100 { + tok := NewToken() + if seen[tok] { + t.Fatal("duplicate token generated") + } + seen[tok] = true + } +} + +func TestNextID_roundtrip(t *testing.T) { + const nodeID = 42 + before := time.Now().Unix() + id := NextID(nodeID) + after := time.Now().Unix() + + gotTime, gotNode, gotSeq := SplitID(id) + if gotNode != nodeID { + t.Errorf("nodeID: got %d, want %d", gotNode, nodeID) + } + if gotTime < before || gotTime > after { + t.Errorf("timestamp %d out of range [%d, %d]", gotTime, before, after) + } + if gotSeq < 1 { + t.Errorf("seq should be >= 1, got %d", gotSeq) + } +} + +func TestNextID_allNodeIDs(t *testing.T) { + for nodeID := int64(0); nodeID < 64; nodeID++ { + id := NextID(nodeID) + _, got, _ := SplitID(id) + if got != nodeID { + t.Errorf("nodeID %d: SplitID returned %d", nodeID, got) + } + } +} + +func TestNextID_monotonic(t *testing.T) { + prev := NextID(0) + for range 1000 { + id := NextID(0) + if id <= prev { + t.Fatalf("not monotonically increasing: %d <= %d", id, prev) + } + prev = id + } +} + +func TestNextID_unique(t *testing.T) { + seen := make(map[int64]bool, 1000) + for i := range 1000 { + id := NextID(0) + if seen[id] { + t.Fatalf("duplicate ID at i=%d", i) + } + seen[id] = true + } +} + +func TestNextID_invalidNodeID(t *testing.T) { + for _, bad := range []int64{-1, 64, 100} { + func() { + defer func() { + if recover() == nil { + t.Errorf("NextID(%d) should have panicked", bad) + } + }() + NextID(bad) + }() + } +} + +func TestNextID_timestampAdvances(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + // synctest's synthetic clock starts at 2000-01-01 00:00:00 UTC. + // Prime ts to one second before that so the first NextID call + // enters the new-second branch and records the synthetic start time. + lock.Lock() + ts = time.Now().Unix() - 1 + seq = 0 + lock.Unlock() + + id1 := NextID(0) + t1, _, _ := SplitID(id1) + + time.Sleep(2 * time.Second) + + id2 := NextID(0) + t2, _, _ := SplitID(id2) + + if t2 <= t1 { + t.Errorf("timestamp did not advance: %d -> %d", t1, t2) + } + }) +} + +func BenchmarkNextID(b *testing.B) { + for b.Loop() { + NextID(0) + } }