dogtag 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/dogtag.rb +48 -0
- data/lib/dogtag/generator.rb +34 -0
- data/lib/dogtag/id.rb +29 -0
- data/lib/dogtag/mixins/redis.rb +10 -0
- data/lib/dogtag/request.rb +63 -0
- data/lib/dogtag/response.rb +41 -0
- data/lib/dogtag/timestamp.rb +48 -0
- data/lib/dogtag/version.rb +3 -0
- data/lua/id-generation.lua +61 -0
- metadata +80 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 040cf6345d43641af35bd1982b190cde7143c481
|
4
|
+
data.tar.gz: d2f72279c3599a6fc07bee47683b0a3d431bc425
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a0d87a8746ea3e7c9fb47a49ebb68e326810d871c59046119a319c2847cf149b7f14c2cd9b0f094ff6d5cb9dce39ba8d9ce6cbb3e571db942c50b27823536e39
|
7
|
+
data.tar.gz: ee27ededce69e8de0f1e6c1f859ef05e9c693f98d8616c60d574001843a334946893d9182101a26bb89d6ea6705dd6bc70774aca2d2f8867312c2430a70ba8fc
|
data/lib/dogtag.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'dogtag/mixins/redis'
|
3
|
+
|
4
|
+
module Dogtag
|
5
|
+
extend Dogtag::Mixins::Redis
|
6
|
+
|
7
|
+
CUSTOM_EPOCH = 1483228800000 # in milliseconds
|
8
|
+
|
9
|
+
TIMESTAMP_BITS = 41
|
10
|
+
LOGICAL_SHARD_ID_BITS = 10
|
11
|
+
SEQUENCE_BITS = 12
|
12
|
+
|
13
|
+
SEQUENCE_SHIFT = 0
|
14
|
+
LOGICAL_SHARD_ID_SHIFT = SEQUENCE_BITS
|
15
|
+
TIMESTAMP_SHIFT = SEQUENCE_BITS + LOGICAL_SHARD_ID_BITS
|
16
|
+
|
17
|
+
LOGICAL_SHARD_ID_KEY = 'dogtag-generator-logical-shard-id'.freeze
|
18
|
+
|
19
|
+
def self.logical_shard_id=(logical_shard_id)
|
20
|
+
redis.set LOGICAL_SHARD_ID_KEY, logical_shard_id
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.generate_id
|
24
|
+
Generator.new(1).ids.first
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.generate_ids(count)
|
28
|
+
ids = []
|
29
|
+
|
30
|
+
# The Lua script can't always return as many IDs as you may want. So we loop
|
31
|
+
# until we have the exact amount.
|
32
|
+
while ids.length < count
|
33
|
+
initial_id_count = ids.length
|
34
|
+
ids += Generator.new(count - ids.length).ids
|
35
|
+
|
36
|
+
# Ensure the ids array keeps growing as infinite loop insurance
|
37
|
+
return ids unless ids.length > initial_id_count
|
38
|
+
end
|
39
|
+
|
40
|
+
ids
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
require 'dogtag/generator'
|
45
|
+
require 'dogtag/id'
|
46
|
+
require 'dogtag/request'
|
47
|
+
require 'dogtag/response'
|
48
|
+
require 'dogtag/timestamp'
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Dogtag
|
2
|
+
class Generator
|
3
|
+
def initialize(count = 1)
|
4
|
+
@count = count
|
5
|
+
end
|
6
|
+
|
7
|
+
def ids
|
8
|
+
response.sequence.map do |sequence|
|
9
|
+
(
|
10
|
+
shifted_timestamp |
|
11
|
+
shifted_logical_shard_id |
|
12
|
+
(sequence << Dogtag::SEQUENCE_SHIFT)
|
13
|
+
)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
attr_reader :count
|
20
|
+
|
21
|
+
def shifted_timestamp
|
22
|
+
timestamp = Timestamp.from_redis(response.seconds, response.microseconds_part)
|
23
|
+
timestamp.with_epoch(Dogtag::CUSTOM_EPOCH).milliseconds << Dogtag::TIMESTAMP_SHIFT
|
24
|
+
end
|
25
|
+
|
26
|
+
def shifted_logical_shard_id
|
27
|
+
response.logical_shard_id << Dogtag::LOGICAL_SHARD_ID_SHIFT
|
28
|
+
end
|
29
|
+
|
30
|
+
def response
|
31
|
+
@response ||= Request.new(count).response
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/dogtag/id.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
module Dogtag
|
2
|
+
class Id
|
3
|
+
SEQUENCE_MAP = ~(-1 << Dogtag::SEQUENCE_BITS) << Dogtag::SEQUENCE_SHIFT
|
4
|
+
LOGICAL_SHARD_ID_MAP = (~(-1 << Dogtag::LOGICAL_SHARD_ID_BITS)) << Dogtag::LOGICAL_SHARD_ID_SHIFT
|
5
|
+
TIMESTAMP_MAP = ~(-1 << Dogtag::TIMESTAMP_BITS) << Dogtag::TIMESTAMP_SHIFT
|
6
|
+
|
7
|
+
attr_reader :id
|
8
|
+
|
9
|
+
def initialize(id)
|
10
|
+
@id = id
|
11
|
+
end
|
12
|
+
|
13
|
+
def custom_timestamp
|
14
|
+
(id & TIMESTAMP_MAP) >> Dogtag::TIMESTAMP_SHIFT
|
15
|
+
end
|
16
|
+
|
17
|
+
def timestamp
|
18
|
+
@timestamp ||= Timestamp.new(custom_timestamp, epoch: Dogtag::CUSTOM_EPOCH)
|
19
|
+
end
|
20
|
+
|
21
|
+
def logical_shard_id
|
22
|
+
(id & LOGICAL_SHARD_ID_MAP) >> Dogtag::LOGICAL_SHARD_ID_SHIFT
|
23
|
+
end
|
24
|
+
|
25
|
+
def sequence
|
26
|
+
(id & SEQUENCE_MAP) >> Dogtag::SEQUENCE_SHIFT
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Dogtag
|
2
|
+
class Request
|
3
|
+
include Dogtag::Mixins::Redis
|
4
|
+
|
5
|
+
MAX_SEQUENCE = ~(-1 << Dogtag::SEQUENCE_BITS)
|
6
|
+
MIN_LOGICAL_SHARD_ID = 1
|
7
|
+
MAX_LOGICAL_SHARD_ID = ~(-1 << Dogtag::LOGICAL_SHARD_ID_BITS)
|
8
|
+
LUA_SCRIPT_PATH = 'lua/id-generation.lua'.freeze
|
9
|
+
MAX_TRIES = 5
|
10
|
+
|
11
|
+
def initialize(count = 1)
|
12
|
+
raise ArgumentError, 'count must be a number' unless count.is_a? Numeric
|
13
|
+
raise ArgumentError, 'count must be greater than zero' unless count > 0
|
14
|
+
|
15
|
+
@tries = 0
|
16
|
+
@count = count
|
17
|
+
end
|
18
|
+
|
19
|
+
def response
|
20
|
+
Response.new(try_redis_response)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :count
|
26
|
+
|
27
|
+
def lua_script
|
28
|
+
File.read(
|
29
|
+
File.expand_path "../../#{LUA_SCRIPT_PATH}", File.dirname(__FILE__)
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
def lua_args
|
34
|
+
lua_args = [
|
35
|
+
MAX_SEQUENCE,
|
36
|
+
MIN_LOGICAL_SHARD_ID,
|
37
|
+
MAX_LOGICAL_SHARD_ID,
|
38
|
+
count
|
39
|
+
]
|
40
|
+
end
|
41
|
+
|
42
|
+
# NOTE: If too many requests come in inside of a millisecond the Lua script
|
43
|
+
# will lock for 1ms and throw an error. This is meant to retry in those cases.
|
44
|
+
def try_redis_response
|
45
|
+
begin
|
46
|
+
@tries += 1
|
47
|
+
redis_response
|
48
|
+
rescue Redis::CommandError => err
|
49
|
+
if @tries < MAX_TRIES
|
50
|
+
# Exponentially sleep more and more on each try
|
51
|
+
sleep (@tries * @tries).to_f / 900
|
52
|
+
retry
|
53
|
+
else
|
54
|
+
raise err
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def redis_response
|
60
|
+
@redis_response ||= redis.eval(lua_script, keys: lua_args)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Dogtag
|
2
|
+
class Response
|
3
|
+
START_SEQUENCE_INDEX = 0
|
4
|
+
END_SEQUENCE_INDEX = 1
|
5
|
+
LOGICAL_SHARD_ID_INDEX = 2
|
6
|
+
SECONDS_INDEX = 3
|
7
|
+
MICROSECONDS_INDEX = 4
|
8
|
+
|
9
|
+
def initialize(redis_response)
|
10
|
+
@redis_response = redis_response
|
11
|
+
end
|
12
|
+
|
13
|
+
def sequence
|
14
|
+
start_sequence..end_sequence
|
15
|
+
end
|
16
|
+
|
17
|
+
def start_sequence
|
18
|
+
redis_response[START_SEQUENCE_INDEX]
|
19
|
+
end
|
20
|
+
|
21
|
+
def end_sequence
|
22
|
+
redis_response[END_SEQUENCE_INDEX]
|
23
|
+
end
|
24
|
+
|
25
|
+
def logical_shard_id
|
26
|
+
redis_response[LOGICAL_SHARD_ID_INDEX]
|
27
|
+
end
|
28
|
+
|
29
|
+
def seconds
|
30
|
+
redis_response[SECONDS_INDEX]
|
31
|
+
end
|
32
|
+
|
33
|
+
def microseconds_part
|
34
|
+
redis_response[MICROSECONDS_INDEX]
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
attr_reader :redis_response
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Dogtag
|
2
|
+
class Timestamp
|
3
|
+
ONE_SECOND_IN_MILLIS = 1_000
|
4
|
+
ONE_MILLI_IN_MICRO_SECS = 1_000
|
5
|
+
|
6
|
+
attr_reader :milliseconds, :epoch
|
7
|
+
|
8
|
+
def initialize(milliseconds, epoch: 0)
|
9
|
+
@milliseconds = milliseconds
|
10
|
+
@epoch = epoch
|
11
|
+
end
|
12
|
+
|
13
|
+
def seconds
|
14
|
+
(milliseconds / ONE_SECOND_IN_MILLIS).floor
|
15
|
+
end
|
16
|
+
|
17
|
+
def microseconds_part
|
18
|
+
(milliseconds - (seconds * ONE_SECOND_IN_MILLIS)) * ONE_MILLI_IN_MICRO_SECS
|
19
|
+
end
|
20
|
+
|
21
|
+
alias to_i milliseconds
|
22
|
+
|
23
|
+
def to_time
|
24
|
+
Time.at(with_unix_epoch.seconds, with_unix_epoch.microseconds_part)
|
25
|
+
end
|
26
|
+
|
27
|
+
def with_unix_epoch
|
28
|
+
@with_unix_epoch ||= with_epoch(0)
|
29
|
+
end
|
30
|
+
|
31
|
+
def with_epoch(new_epoch)
|
32
|
+
new_milliseconds = milliseconds - (new_epoch - epoch)
|
33
|
+
|
34
|
+
self.class.new(new_milliseconds, epoch: new_epoch)
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.from_redis(seconds_part, microseconds_part)
|
38
|
+
# NOTE: we're dropping the microseconds here because we don't need that
|
39
|
+
# level of precision
|
40
|
+
milliseconds = (
|
41
|
+
(seconds_part * ONE_SECOND_IN_MILLIS) +
|
42
|
+
(microseconds_part / ONE_MILLI_IN_MICRO_SECS)
|
43
|
+
)
|
44
|
+
|
45
|
+
new(milliseconds)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
local lock_key = 'dogtag-generator-lock'
|
2
|
+
local sequence_key = 'dogtag-generator-sequence'
|
3
|
+
local logical_shard_id_key = 'dogtag-generator-logical-shard-id'
|
4
|
+
|
5
|
+
local max_sequence = tonumber(KEYS[1])
|
6
|
+
local min_logical_shard_id = tonumber(KEYS[2])
|
7
|
+
local max_logical_shard_id = tonumber(KEYS[3])
|
8
|
+
local num_ids = tonumber(KEYS[4])
|
9
|
+
|
10
|
+
if redis.call('EXISTS', lock_key) == 1 then
|
11
|
+
redis.log(redis.LOG_NOTICE, 'Dogtag: Cannot generate ID, waiting for lock to expire.')
|
12
|
+
return redis.error_reply('Dogtag: Cannot generate ID, waiting for lock to expire.')
|
13
|
+
end
|
14
|
+
|
15
|
+
--[[
|
16
|
+
Increment by a set number, this can
|
17
|
+
--]]
|
18
|
+
local end_sequence = redis.call('INCRBY', sequence_key, num_ids)
|
19
|
+
local start_sequence = end_sequence - num_ids + 1
|
20
|
+
local logical_shard_id = tonumber(redis.call('GET', logical_shard_id_key)) or -1
|
21
|
+
|
22
|
+
if end_sequence >= max_sequence then
|
23
|
+
--[[
|
24
|
+
As the sequence is about to roll around, we can't generate another ID until we're sure we're not in the same
|
25
|
+
millisecond since we last rolled. This is because we may have already generated an ID with the same time and
|
26
|
+
sequence, and we cannot allow even the smallest possibility of duplicates. It's also because if we roll the sequence
|
27
|
+
around, we will start generating IDs with smaller values than the ones previously in this millisecond - that would
|
28
|
+
break our k-ordering guarantees!
|
29
|
+
|
30
|
+
The only way we can handle this is to block for a millisecond, as we can't store the time due the purity constraints
|
31
|
+
of Redis Lua scripts.
|
32
|
+
|
33
|
+
In addition to a neat side-effect of handling leap seconds (where milliseconds will last a little bit longer to bring
|
34
|
+
time back to where it should be) because Redis uses system time internally to expire keys, this prevents any duplicate
|
35
|
+
IDs from being generated if the rate of generation is greater than the maximum sequence per millisecond.
|
36
|
+
|
37
|
+
Note that it only blocks even it rolled around *not* in the same millisecond; this is because unless we do this, the
|
38
|
+
IDs won't remain ordered.
|
39
|
+
--]]
|
40
|
+
redis.log(redis.LOG_NOTICE, 'Dogtag: Rolling sequence back to the start, locking for 1ms.')
|
41
|
+
redis.call('SET', sequence_key, '-1')
|
42
|
+
redis.call('PSETEX', lock_key, 1, 'lock')
|
43
|
+
end_sequence = max_sequence
|
44
|
+
end
|
45
|
+
|
46
|
+
--[[
|
47
|
+
The TIME command MUST be called after anything that mutates state, or the Redis server will error the script out.
|
48
|
+
This is to ensure the script is "pure" in the sense that randomness or time based input will not change the
|
49
|
+
outcome of the writes.
|
50
|
+
|
51
|
+
See the "Scripts as pure functions" section at http://redis.io/commands/eval for more information.
|
52
|
+
--]]
|
53
|
+
local time = redis.call('TIME')
|
54
|
+
|
55
|
+
return {
|
56
|
+
start_sequence,
|
57
|
+
end_sequence, -- Doesn't need conversion, the result of INCR or the variable set is always a number.
|
58
|
+
logical_shard_id,
|
59
|
+
tonumber(time[1]),
|
60
|
+
tonumber(time[2])
|
61
|
+
}
|
metadata
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dogtag
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Adam Crownoble
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-05-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.5'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.5'
|
41
|
+
description: Generate unique IDs with Redis for distributed systems, based heavily
|
42
|
+
off of Icicle and Twitter Snowflake
|
43
|
+
email: adam@codenoble.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- lib/dogtag.rb
|
49
|
+
- lib/dogtag/generator.rb
|
50
|
+
- lib/dogtag/id.rb
|
51
|
+
- lib/dogtag/mixins/redis.rb
|
52
|
+
- lib/dogtag/request.rb
|
53
|
+
- lib/dogtag/response.rb
|
54
|
+
- lib/dogtag/timestamp.rb
|
55
|
+
- lib/dogtag/version.rb
|
56
|
+
- lua/id-generation.lua
|
57
|
+
homepage: https://github.com/zillyinc/dogtag
|
58
|
+
licenses: []
|
59
|
+
metadata: {}
|
60
|
+
post_install_message:
|
61
|
+
rdoc_options: []
|
62
|
+
require_paths:
|
63
|
+
- lib
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
requirements: []
|
75
|
+
rubyforge_project:
|
76
|
+
rubygems_version: 2.6.11
|
77
|
+
signing_key:
|
78
|
+
specification_version: 4
|
79
|
+
summary: A Redis-powered Ruby ID generation client
|
80
|
+
test_files: []
|