dogtag 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -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
@@ -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,10 @@
1
+ module Dogtag
2
+ module Mixins
3
+ module Redis
4
+ def redis
5
+ # TODO: Redis config
6
+ @redis ||= ::Redis.new
7
+ end
8
+ end
9
+ end
10
+ 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,3 @@
1
+ module Dogtag
2
+ VERSION = '0.1.0'.freeze
3
+ 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: []