-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #16 from okkdev/snowflake
- Loading branch information
Showing
4 changed files
with
167 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |