Skip to content

Commit

Permalink
Merge pull request #16 from okkdev/snowflake
Browse files Browse the repository at this point in the history
  • Loading branch information
rvcas authored Oct 9, 2023
2 parents c7be029 + 8e796e1 commit 5b8bcac
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 1 deletion.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier)
- [NanoID](https://github.com/ai/nanoid)
- [ULID](https://github.com/ulid/spec)
- [Snowflake ID](https://en.wikipedia.org/wiki/Snowflake_ID)

## Installation

Expand All @@ -26,3 +27,4 @@ gleam add ids
1. [Original CUID](https://en.wikipedia.org/wiki/Universally_unique_identifier)
2. [Elixir CUID](https://github.com/duailibe/cuid)
3. [Ecto UUID](https://github.com/elixir-ecto/ecto/blob/v3.5.4/lib/ecto/uuid.ex)
4. [Rust Snowflake ID](https://github.com/BinChengZhao/snowflake-rs)
2 changes: 1 addition & 1 deletion manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

packages = [
{ name = "gleam_erlang", version = "0.22.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "367D8B41A7A86809928ED1E7E55BFD0D46D7C4CF473440190F324AFA347109B4" },
{ name = "gleam_otp", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "ED7381E90636E18F5697FD7956EECCA635A3B65538DC2BE2D91A38E61DCE8903" },
{ name = "gleam_otp", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "ED7381E90636E18F5697FD7956EECCA635A3B65538DC2BE2D91A38E61DCE8903" },
{ name = "gleam_stdlib", version = "0.31.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6D1BC5B4D4179B9FEE866B1E69FE180AC2CE485AD90047C0B32B2CA984052736" },
{ name = "gleeunit", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "1397E5C4AC4108769EE979939AC39BF7870659C5AFB714630DEEEE16B8272AD5" },
]
Expand Down
106 changes: 106 additions & 0 deletions src/ids/snowflake.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//// A module for generating Snowflake IDs.

import gleam/int
import gleam/string
import gleam/result
import gleam/erlang
import gleam/otp/actor.{Next}
import gleam/erlang/process.{Subject}

@external(erlang, "binary", "encode_unsigned")
fn encode_unsigned(i: Int) -> BitString

/// The messages handled by the actor.
/// The actor shouldn't be called directly so this type is opaque.
pub opaque type Message {
Generate(reply_with: Subject(Int))
}

/// The internal state of the actor.
/// The state keeps track of the Snowflake parts.
pub opaque type State {
State(epoch: Int, last_time: Int, machine_id: Int, idx: Int)
}

/// Starts a Snowflake generator.
pub fn start(machine_id: Int) -> Result(Subject(Message), String) {
start_with_epoch(machine_id, 0)
}

/// Starts a Snowflake generator with an epoch offset.
pub fn start_with_epoch(
machine_id: Int,
epoch: Int,
) -> Result(Subject(Message), String) {
case epoch > erlang.system_time(erlang.Millisecond) {
True -> Error("Error: Epoch can't be larger than current time.")
False ->
State(epoch: epoch, last_time: 0, machine_id: machine_id, idx: 0)
|> actor.start(handle_msg)
|> result.map_error(fn(err) {
"Error: Couldn't start actor. Reason: " <> string.inspect(err)
})
}
}

/// Generates a Snowflake ID using the given channel.
///
/// ### Usage
/// ```gleam
/// import ids/snowflake
///
/// let assert Ok(channel) = snowflake.start(machine_id: 1)
/// let id: Int = snowflake.generate(channel)
///
/// let discord_epoch = 1_420_070_400_000
/// let assert Ok(d_channel) = snowflake.start_with_epoch(machine_id: 1, epoch: discord_epoch)
/// let discord_id: Int = snowflake.generate(d_channel)
/// ```
pub fn generate(channel: Subject(Message)) -> Int {
actor.call(channel, Generate, 1000)
}

/// Decodes a Snowflake ID into #(timestamp, machine_id, idx).
pub fn decode(snowflake: Int) -> Result(#(Int, Int, Int), String) {
case encode_unsigned(snowflake) {
<<timestamp:int-size(42), machine_id:int-size(10), idx:int-size(12)>> ->
Ok(#(timestamp, machine_id, idx))
_other -> Error("Error: Couldn't decode snowflake id.")
}
}

/// Actor message handler.
fn handle_msg(msg: Message, state: State) -> Next(Message, State) {
case msg {
Generate(reply) -> {
let new_state = update_state(state)

let snowflake =
new_state.last_time
|> int.bitwise_shift_left(22)
|> int.bitwise_or({
new_state.machine_id
|> int.bitwise_shift_left(12)
|> int.bitwise_or(new_state.idx)
})

actor.send(reply, snowflake)
actor.continue(new_state)
}
}
}

/// Prepares the state for generation.
/// Handles incrementing if id is being generated in the same millisecond.
/// Calls itself recursively to make a millisecond pass if all 4096 ids have been generated in the past millisecond.
fn update_state(state: State) -> State {
let now =
erlang.system_time(erlang.Millisecond)
|> int.subtract(state.epoch)

case state.last_time {
lt if lt == now && state.idx < 4095 -> State(..state, idx: state.idx + 1)
lt if lt == now -> update_state(state)
_other -> State(..state, last_time: now)
}
}
58 changes: 58 additions & 0 deletions test/ids/snowflake_test.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import ids/snowflake
import gleeunit/should
import gleam/string
import gleam/int
import gleam/list
import gleam/erlang

pub fn gen_test() {
let machine_id = 1
let assert Ok(channel) = snowflake.start(machine_id)

let snowflake = snowflake.generate(channel)

snowflake
|> int.to_string()
|> string.length()
|> should.equal(19)

let assert Ok(#(timestamp, m_id, idx)) = snowflake.decode(snowflake)

{ timestamp <= erlang.system_time(erlang.Millisecond) }
|> should.be_true()
m_id
|> should.equal(machine_id)
idx
|> should.equal(0)

list.range(1, 5000)
|> list.map(fn(_) { snowflake.generate(channel) })
|> list.unique()
|> list.length()
|> should.equal(5000)
}

pub fn gen_with_epoch_test() -> Nil {
let machine_id = 1
let now = erlang.system_time(erlang.Millisecond)

let now_much = erlang.system_time(erlang.Millisecond) + 1000
snowflake.start_with_epoch(machine_id, now_much)
|> should.be_error()

let discord_epoch = 1_420_070_400_000
let assert Ok(channel) = snowflake.start_with_epoch(machine_id, discord_epoch)

let discord_snowflake = snowflake.generate(channel)

discord_snowflake
|> int.to_string()
|> string.length()
|> should.equal(19)

let assert Ok(#(timestamp, _, _)) = snowflake.decode(discord_snowflake)

let t = timestamp + discord_epoch
{ t >= now && t <= { now + 1000 } }
|> should.be_true()
}

0 comments on commit 5b8bcac

Please sign in to comment.