From 5aec8fa016c918ec4a36ef55c3813f2f3d26e478 Mon Sep 17 00:00:00 2001 From: jdl Date: Sun, 14 Jun 2026 16:35:50 +0200 Subject: [PATCH] Initial commit. --- README.md | 2 +- go.mod | 3 ++ idgen.go | 66 ++++++++++++++++++++++++++ idgen_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 go.mod create mode 100644 idgen.go create mode 100644 idgen_test.go diff --git a/README.md b/README.md index fb664ff..0e749d0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # idgen -Simple ID generation. \ No newline at end of file +Simple ID generation. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9340d09 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.crumpinton.com/lib/idgen + +go 1.25.1 diff --git a/idgen.go b/idgen.go new file mode 100644 index 0000000..6af9cc8 --- /dev/null +++ b/idgen.go @@ -0,0 +1,66 @@ +package idgen + +import ( + "crypto/rand" + "encoding/base32" + "sync" + "time" +) + +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) + rand.Read(buf) // Guaranteed not to return an error. + return encoding.EncodeToString(buf) +} + +var ( + lock sync.Mutex + ts int64 = time.Now().Unix() + seq int64 = 0 + seqMax int64 = 1 << 20 +) + +// NextID can generate ~1M int64s per second. +// +// 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 + seq = 1 + } else { + seq++ + if seq >= seqMax { + return 0 + } + } + + return (ts << 26) + (nodeID << 20) + seq +} + +func SplitID(id int64) (unixTime, nodeID, counter int64) { + counter = id & (0x00000000000FFFFF) + nodeID = (id >> 20) & (0x000000000000003F) + unixTime = id >> 26 + return +} diff --git a/idgen_test.go b/idgen_test.go new file mode 100644 index 0000000..77bcdb6 --- /dev/null +++ b/idgen_test.go @@ -0,0 +1,125 @@ +package idgen + +import ( + "testing" + "testing/synctest" + "time" +) + +func TestNewToken_length(t *testing.T) { + tok := NewToken() + if len(tok) != 32 { + t.Fatalf("expected 32 chars, got %d: %q", len(tok), tok) + } +} + +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) + } +}