diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..bd89c64 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,27 @@ +name: Go + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + go: ["1.17", "1.18", "1.19", "1.20", "1.21"] + steps: + - uses: actions/checkout@v3 + + - name: Set up Go ${{ matrix.go }} + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go }} + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85841fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# GoLand +.idea/ \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..ab525eb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +hi@yankeguo.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18078e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 GUO YANKE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cefe0b4 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# snowid + +[![workflow badge](https://github.com/yankeguo/snowid/actions/workflows/go.yml/badge.svg)](https://github.com/yankeguo/snowid/actions) +[![Go Reference](https://pkg.go.dev/badge/github.com/yankeguo/snowid.svg)](https://pkg.go.dev/github.com/yankeguo/snowid) + +A concurrent-safe lock-free implementation of snowflake algorithm in Golang + +## Install + +`go get -u github.com/yankeguo/snowid` + +## Usage + +```go +// create an unique identifier +id, _ := strconv.ParseUint(os.Getenv("WORKER_ID"), 10, 64) + +// create an instance (a sonyflake like instance) +s := snowid.New(snowflake.Options{ + Epoch: time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC), + ID: id, + Grain: time.Millisecond*10, + LeadingBit: true, +}) + +// get a id +s.NewID() + +// stop and release all related resource +s.Stop() + +``` + +## Performance + +Less than `1us/op` on **Apple MacBook Air (M1)** + +``` +goos: darwin +goarch: arm64 +pkg: github.com/yankeguo/snowid +BenchmarkGenerator_NewID-8 2465515 469.5 ns/op +PASS +ok github.com/yankeguo/snowid 1.742s +``` + +## Credits + +GUO YANKE, MIT License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..86ec774 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.x | :white_check_mark: | + +## Reporting a Vulnerability + +Send an encrypted mail to hi@yankeguo.com, with [this GPG public key](https://keys.openpgp.org/search?q=hi%40yankeguo.com) diff --git a/cog.toml b/cog.toml new file mode 100644 index 0000000..ee92ed8 --- /dev/null +++ b/cog.toml @@ -0,0 +1,25 @@ +from_latest_tag = false +ignore_merge_commits = false +disable_changelog = false +disable_bump_commit = false +generate_mono_repository_global_tag = true +branch_whitelist = [] +skip_ci = "[skip ci]" +skip_untracked = false +pre_bump_hooks = [] +post_bump_hooks = [] +pre_package_bump_hooks = [] +post_package_bump_hooks = [] +tag_prefix = "v" + +[git_hooks] + +[commit_types] + +[changelog] +path = "CHANGELOG.md" +authors = [] + +[bump_profiles] + +[packages] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..96a579c --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/yankeguo/snowid + +go 1.18 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..93786ae --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/snowid.go b/snowid.go new file mode 100644 index 0000000..3650f7f --- /dev/null +++ b/snowid.go @@ -0,0 +1,162 @@ +package snowid + +import ( + "errors" + "time" +) + +const ( + Uint10Mask = (uint64(1) << 10) - 1 + Uint12Mask = (uint64(1) << 12) - 1 + Uint41Mask = (uint64(1) << 41) - 1 + Uint40Mask = (uint64(1) << 40) - 1 + Uint40Bit = uint64(1) << 40 +) + +var ( + ErrInvalidEpoch = errors.New("failed to create snowid.Generator: invalid Epoch") + ErrInvalidID = errors.New("failed to create snowid.Generator: invalid ID") + ErrStopped = errors.New("failed to retrieve ID: snowid.Generator stopped") +) + +// Clock abstract the standard time package +type Clock interface { + Since(t time.Time) time.Duration + Sleep(d time.Duration) +} + +type defaultClock struct{} + +func (defaultClock) Since(t time.Time) time.Duration { + return time.Since(t) +} + +func (defaultClock) Sleep(d time.Duration) { + time.Sleep(d) +} + +func DefaultClock() Clock { + return defaultClock{} +} + +// Options options for Generator +type Options struct { + // Clock custom implementation of clock, default to standard library + Clock Clock + // Epoch pre-defined zero time in Snowflake algorithm, required + Epoch time.Time + // Grain time grain of ID, default to millisecond, minimum to millisecond + Grain time.Duration + + // ID unique unsigned integer indicate the ID of current Generator instance, maximum 10 bits wide, default to 0 + ID uint64 + + // LeadingBit whether to fill leadingBit bit in ID, default to false + // If you are planning to use the ID in a string field, this will ensure the ID is always the same length + LeadingBit bool +} + +// Generator the main interface +type Generator interface { + // Stop shutdown the instance, release all related resources + // can not stop twice, NewID() invocation will panic after stopped + Stop() + + // Count returns the count of generated ids + Count() uint64 + + // NewID returns a new id + NewID() uint64 +} + +type generator struct { + chReq chan struct{} + chResp chan uint64 + chStop chan struct{} + epoch time.Time + grain time.Duration + leadingBit bool + shiftedID uint64 + count uint64 + clock Clock +} + +// New create a new instance of Generator +func New(opts Options) (Generator, error) { + if opts.Clock == nil { + opts.Clock = DefaultClock() + } + if opts.Epoch.IsZero() { + return nil, ErrInvalidEpoch + } + if opts.ID&Uint10Mask != opts.ID { + return nil, ErrInvalidID + } + if opts.Grain <= time.Millisecond { + opts.Grain = time.Millisecond + } + sf := &generator{ + chReq: make(chan struct{}), + chResp: make(chan uint64), + chStop: make(chan struct{}), + epoch: opts.Epoch, + grain: opts.Grain, + leadingBit: opts.LeadingBit, + shiftedID: opts.ID << 12, + clock: opts.Clock, + } + go sf.run() + return sf, nil +} + +func (sf *generator) Stop() { + close(sf.chStop) +} + +func (sf *generator) run() { + var nowT, lastT, seqID uint64 + for { + select { + case <-sf.chReq: + retry: + nowT = uint64(sf.clock.Since(sf.epoch) / sf.grain) + if nowT == lastT { + seqID = seqID + 1 + if seqID > Uint12Mask { + sf.clock.Sleep(sf.grain) + goto retry + } + } else { + lastT = nowT + seqID = 0 + } + sf.count++ + if sf.leadingBit { + sf.chResp <- (((nowT & Uint40Mask) | Uint40Bit) << 22) | sf.shiftedID | seqID + } else { + sf.chResp <- ((nowT & Uint41Mask) << 22) | sf.shiftedID | seqID + } + case <-sf.chStop: + return + } + } +} + +func (sf *generator) Count() uint64 { + return sf.count +} + +func (sf *generator) NewID() uint64 { + select { + case sf.chReq <- struct{}{}: + select { + case v := <-sf.chResp: + return v + case <-sf.chStop: + panic(ErrStopped) + } + return <-sf.chResp + case <-sf.chStop: + panic(ErrStopped) + } +} diff --git a/snowid_test.go b/snowid_test.go new file mode 100644 index 0000000..f1cf026 --- /dev/null +++ b/snowid_test.go @@ -0,0 +1,178 @@ +package snowid + +import ( + "github.com/stretchr/testify/require" + "math/rand" + "testing" + "time" +) + +var ( + testEpoch = time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC) +) + +func TestConstants(t *testing.T) { + require.Equal(t, Uint41Mask, Uint40Mask|Uint40Bit) +} + +type testClock struct { + since func(t time.Time) time.Duration + sleep func(d time.Duration) +} + +func (tc *testClock) Since(t time.Time) time.Duration { + return tc.since(t) +} + +func (tc *testClock) Sleep(d time.Duration) { + tc.sleep(d) +} + +func TestNew(t *testing.T) { + t.Run("error-on-invalid-epoch", func(t *testing.T) { + _, err := New(Options{}) + require.Equal(t, ErrInvalidEpoch, err) + }) + + t.Run("error-on-invalid-id", func(t *testing.T) { + _, err := New(Options{ + Epoch: time.Now(), + ID: Uint41Mask, + }) + require.Equal(t, ErrInvalidID, err) + }) + + t.Run("default", func(t *testing.T) { + g, err := New(Options{ + Epoch: testEpoch, + }) + require.NoError(t, err) + defer g.Stop() + + require.NotEqualValues(t, 0, g.NewID()) + }) +} + +func TestGenerator(t *testing.T) { + t.Run("error-on-stopped", func(t *testing.T) { + g, err := New(Options{ + Epoch: testEpoch, + }) + require.NoError(t, err) + require.PanicsWithError(t, ErrStopped.Error(), func() { + g.Stop() + g.NewID() + }) + }) + + t.Run("standard-and-with-overflow", func(t *testing.T) { + var sleepInvoked int64 + + customGrain := time.Duration(rand.Intn(20)+10) * time.Millisecond + + tc := &testClock{ + since: func(t time.Time) time.Duration { + return 11*customGrain + time.Duration(sleepInvoked)*customGrain + }, + sleep: func(d time.Duration) { + require.Equal(t, customGrain, d) + sleepInvoked++ + }, + } + + g, err := New(Options{ + Epoch: testEpoch, + ID: 0b111111, + Grain: customGrain, + Clock: tc, + }) + require.NoError(t, err) + defer g.Stop() + + for i := uint64(0); i < 4096; i++ { + require.Equal(t, 11<<22|0b111111<<12|i, g.NewID()) + } + for i := uint64(0); i < 4096; i++ { + require.Equal(t, 12<<22|0b111111<<12|i, g.NewID()) + } + require.Equal(t, int64(1), sleepInvoked) + require.Equal(t, uint64(4096*2), g.Count()) + }) + + t.Run("standard-and-with-overflow-leading-bit", func(t *testing.T) { + var sleepInvoked int64 + + customGrain := time.Duration(rand.Intn(20)+10) * time.Millisecond + + tc := &testClock{ + since: func(t time.Time) time.Duration { + return 11*customGrain + time.Duration(sleepInvoked)*customGrain + }, + sleep: func(d time.Duration) { + require.Equal(t, customGrain, d) + sleepInvoked++ + }, + } + + g, err := New(Options{ + Epoch: testEpoch, + ID: 0b111111, + Grain: customGrain, + Clock: tc, + LeadingBit: true, + }) + require.NoError(t, err) + defer g.Stop() + + for i := uint64(0); i < 4096; i++ { + require.Equal(t, 1<<62|11<<22|0b111111<<12|i, g.NewID()) + } + for i := uint64(0); i < 4096; i++ { + require.Equal(t, 1<<62|12<<22|0b111111<<12|i, g.NewID()) + } + require.Equal(t, int64(1), sleepInvoked) + require.Equal(t, uint64(4096*2), g.Count()) + }) + + t.Run("samples", func(t *testing.T) { + g, err := New(Options{ + Epoch: testEpoch, + ID: 0b1010101, + }) + require.NoError(t, err) + defer g.Stop() + + for i := 0; i < 5; i++ { + t.Log("ID:", g.NewID()) + } + }) + + t.Run("samples-leading-bit", func(t *testing.T) { + g, err := New(Options{ + Epoch: testEpoch, + ID: 0b1010101, + LeadingBit: true, + }) + require.NoError(t, err) + defer g.Stop() + + for i := 0; i < 5; i++ { + t.Log("ID:", g.NewID()) + } + }) + +} + +func BenchmarkGenerator_NewID(b *testing.B) { + g, err := New(Options{ + Epoch: testEpoch, + ID: 0b1010101, + LeadingBit: true, + }) + require.NoError(b, err) + defer g.Stop() + + for n := 0; n < b.N; n++ { + g.NewID() + } +}