redstruct 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: f2a542b5597811e35ed17b643ae0df3fb01fb226
4
+ data.tar.gz: bc58fb20c1cc5d2b4a502c3ba085f2d2d1304056
5
+ SHA512:
6
+ metadata.gz: 8a98538b8e61c874e1b42d487d46a83176cb563ede67eefa78131e577f6a16627678658fd314e11a7981c0c6f7ab0e9022ca82c61e6723e4620c0a3bb099fa1b
7
+ data.tar.gz: 7a230c6f9cc89433e0580efa59ddfd716bdaf569ae2f2f20c53dc2b1909d1d8f332f2b789870a834f90a9fbcc5953cc275639478ff842b40368fa41608da7add
data/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Redstruct
2
+
3
+ Provides higher level data structures in Ruby using standard Redis commands. Also provides basic object mapping for pre-existing types.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'redstruct'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install redstruct
20
+
21
+ ## Usage
22
+
23
+ TODO: Write usage instructions here
24
+
25
+ ## Development
26
+
27
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
28
+
29
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
30
+
31
+ *Note*
32
+
33
+ Avoid using transactions; the Redis documentation suggests using Lua scripts where possible, as in most cases they will be faster than transactions. Use the `Redstruct::Utils::Scriptable` module and the `defscript` macro instead.
34
+
35
+ ## Contributing
36
+
37
+ Bug reports and pull requests are welcome on GitHub at https://github.com/npepinpe/redstruct.
38
+
39
+
40
+ ## License
41
+
42
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Bundler.require(:default, :development)
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ end
11
+
12
+ task :default => :test
13
+
14
+ require 'redstruct/yard/defscript_handler'
15
+ YARD::Rake::YardocTask.new do |t|
16
+ t.files = ['lib/**/*.rb']
17
+ t.options = ['--output-dir=./docs']
18
+ end
@@ -0,0 +1,14 @@
1
+ module Redstruct
2
+ class Configuration
3
+ # @return [ConnectionPool] The Redis-rb connection pool to use
4
+ attr_accessor :connection_pool
5
+
6
+ # @return [::String] Default namespace for factories
7
+ attr_accessor :namespace
8
+
9
+ def initialize
10
+ @connection_pool = nil
11
+ @namespace = nil
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ module Redstruct
2
+ class Connection
3
+ # @return [Array<Symbol>] List of methods from the Redis class that we don't want to delegate to.
4
+ NON_COMMAND_METHODS = [:[], :[]=, :_eval, :_scan, :method_missing, :call, :dup, :inspect, :to_s].freeze
5
+
6
+ attr_reader :pool
7
+
8
+ def initialize(pool)
9
+ raise(Redstruct::Error, 'Requires a ConnectionPool to proxy to') unless pool.is_a?(ConnectionPool)
10
+ @pool = pool
11
+ end
12
+
13
+ # While slower on load, defining all methods that we want to pipe to one of the connections results in
14
+ # faster calls at runtime, and gives us the convenience of not going through the pool.with everytime.
15
+ Redis.public_instance_methods(false).each do |method|
16
+ next if NON_COMMAND_METHODS.include?(method)
17
+ class_eval <<~METHOD, __FILE__, __LINE__ + 1
18
+ def #{method}(*args)
19
+ connection = Thread.current[:__redstruct_connection]
20
+ if connection.nil?
21
+ return @pool.with { |c| c.#{method}(*args) }
22
+ else
23
+ return connection.#{method}(*args)
24
+ end
25
+ end
26
+ METHOD
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ module Redstruct
2
+ # Used to provide a single base error type to filter all errors coming from the gem.
3
+ class Error < StandardError
4
+ end
5
+ end
@@ -0,0 +1,95 @@
1
+ module Redstruct
2
+ class Factory
3
+ # Module to hold all the factory creation methods.
4
+ module Creation
5
+ # Builds a struct with the given key (namespaced) and sharing the factory connection
6
+ # Building a struct is only really useful if you plan on making only basic operations,
7
+ # such as delete, expire, etc. It is however recommended to always build your objects
8
+ # in the same way, e.g. if it's a lock, use Factory#lock
9
+ # @param [::String] key base key to use
10
+ # @return [Redstruct::Types::Struct] base struct pointing to that key
11
+ def struct(key, **options)
12
+ return create(Redstruct::Types::Struct, key, **options)
13
+ end
14
+
15
+ # Builds a Redis string struct from the key
16
+ # @param [::String] key base key to use
17
+ # @return [Redstruct::Types::String]
18
+ def string(key, **options)
19
+ return create(Redstruct::Types::String, key, **options)
20
+ end
21
+
22
+ # Builds a Redis list struct from the key
23
+ # @param [::String] key base key to use
24
+ # @return [Redstruct::Types::List]
25
+ def list(key, **options)
26
+ return create(Redstruct::Types::List, key, **options)
27
+ end
28
+
29
+ # Builds a Redis set struct from the key
30
+ # @param [::String] key base key to use
31
+ # @return [Redstruct::Types::Set]
32
+ def set(key, **options)
33
+ return create(Redstruct::Types::Set, key, **options)
34
+ end
35
+
36
+ # Builds a Redis sorted set (zset) struct from the key
37
+ # @param [::String] key base key to use
38
+ # @return [Redstruct::Types::SortedSet]
39
+ def sorted_set(key, **options)
40
+ return create(Redstruct::Types::SortedSet, key, **options)
41
+ end
42
+
43
+ # Builds a Redis hash struct from the key
44
+ # @param [::String] key base key to use
45
+ # @return [Redstruct::Types::Hash]
46
+ def hash(key, **options)
47
+ return create(Redstruct::Types::Hash, key, **options)
48
+ end
49
+
50
+ # Builds a Redis backed lock from the key
51
+ # @param [::String] key base key to use
52
+ # @return [Redstruct::Types::Lock]
53
+ def lock(key, **options)
54
+ return create(Redstruct::Types::Lock, key, **options)
55
+ end
56
+
57
+ # Builds a Redis counter struct from the key
58
+ # @param [::String] key base key to use
59
+ # @return [Redstruct::Types::Counter]
60
+ def counter(key, **options)
61
+ return create(Redstruct::Types::Counter, key, **options)
62
+ end
63
+
64
+ # Builds a Redis backed queue from the key
65
+ # @param [::String] key base key to use
66
+ # @return [Redstruct::Types::Queue]
67
+ def queue(key)
68
+ return create(Redstruct::Types::Queue, key)
69
+ end
70
+
71
+ # @todo The script cache is actually based on the database you will connect to. Therefore, it might be smarter to move it to the connection used?
72
+ # Caveat: if the script with the given ID exists in the cache, we don't bother updating it.
73
+ # So if the script actually changed since the first call, the one sent during the first call will
74
+ def script(id, script)
75
+ return @script_cache.synchronize do
76
+ @script_cache[id] = Redstruct::Types::Script.new(key: id, script: script, factory: self) if @script_cache[id].nil?
77
+ @script_cache[id]
78
+ end
79
+ end
80
+
81
+ # Returns a factory with an isolated namespace.
82
+ # @example Given a factory `f` with namespace fact:first
83
+ # f.factory('second') # => Redstruct::Factory: namespace: <"fact:first:second">, script_cache: <[]>
84
+ # @return [Factory] namespaced factory
85
+ def factory(namespace)
86
+ return self.class.new(connection: @connection, namespace: isolate(namespace))
87
+ end
88
+
89
+ def create(type, key, **options)
90
+ return type.new(key: isolate(key), factory: self, **options)
91
+ end
92
+ private :create
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,7 @@
1
+ module Redstruct
2
+ class Factory
3
+ # Module to hold all the factory creation methods.
4
+ module Deserialization
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,45 @@
1
+ module Redstruct
2
+ # Main interface of the gem; this class should be used to build all Redstruct
3
+ # objects, even when deserializing them.
4
+ class Factory
5
+ include Redstruct::Utils::Inspectable, Redstruct::Factory::Creation
6
+ extend Redstruct::Factory::Deserialization
7
+
8
+ # @return [Connection] The connection proxy to use when executing commands. Shared by all factory produced objects.
9
+ attr_reader :connection
10
+
11
+ # @param [Redstruct::Connection] connection connection to use for all objects built by the factory
12
+ # @param [ConnectionPool] pool pool to use to build a connection from if no connection param given
13
+ # @param [::String] namespace all objects build from the factory will have their keys namespaced under this one
14
+ # @return [Factory]
15
+ def initialize(connection: nil, pool: nil, namespace: nil)
16
+ namespace ||= Redstruct.config.namespace
17
+
18
+ if connection.nil?
19
+ pool ||= Redstruct.config.connection_pool
20
+ raise(Redstruct::Error, 'A connection pool is required to create a factory, but none was given') if pool.nil?
21
+ connection = Redstruct::Connection.new(pool)
22
+ end
23
+
24
+ @connection = connection
25
+ @namespace = namespace
26
+ @script_cache = {}.tap { |hash| hash.extend(MonitorMixin) }
27
+ end
28
+
29
+ # Returns a namespaced version of the key (unless already namespaced)
30
+ # @param [String] key the key to isolate/namespace
31
+ # @return [String] namespaced version of the key (or the key itself if already namespaced)
32
+ def isolate(key)
33
+ return @namespace.nil? || key.start_with?(@namespace) ? key : "#{@namespace}:#{key}"
34
+ end
35
+
36
+ # :nocov:
37
+
38
+ # Helper method for serialization
39
+ def inspectable_attributes
40
+ return { namespace: @namespace, script_cache: @script_cache.keys }
41
+ end
42
+
43
+ # :nocov:
44
+ end
45
+ end
@@ -0,0 +1,175 @@
1
+ require 'securerandom'
2
+
3
+ module Redstruct
4
+ module Hls
5
+ # Implementation of a simple binary lock (locked/not locked), with option to block and wait for the lock.
6
+ # Uses two redis structures: a string for the lease, and a list for blocking operations.
7
+ # @see #acquire
8
+ # @see #release
9
+ # @see #locked
10
+ # @attr_reader [::String, nil] token the current token or nil
11
+ # @attr_reader [Fixnum] expiry expiry of the underlying redis structures in milliseconds
12
+ # @attr_reader [Fixnum, nil] timeout the timeout to wait when attempting to acquire the lock, in seconds
13
+ class Lock < Redstruct::Types::Base
14
+ include Redstruct::Utils::Scriptable, Redstruct::Utils::Coercion
15
+
16
+ # The default expiry on the underlying redis keys, in milliseconds
17
+ DEFAULT_EXPIRY = 1000
18
+
19
+ # The default timeout when blocking, in seconds; a nil value means it is non-blocking
20
+ DEFAULT_TIMEOUT = nil
21
+
22
+ attr_reader :token, :expiry, :timeout
23
+
24
+ # @param [Integer] expiry in milliseconds; to prevent infinite locking, each mutex is released after a certain expiry time
25
+ # @param [Integer] timeout in seconds; if > 0, will block for this amount of time when trying to obtain the lock
26
+ def initialize(expiry: DEFAULT_EXPIRY, timeout: DEFAULT_TIMEOUT, **options)
27
+ super(**options)
28
+
29
+ @token = nil
30
+ @expiry = expiry
31
+ @timeout = timeout.to_i
32
+
33
+ create do |factory|
34
+ @lease = factory.string('lease')
35
+ @tokens = factory.list('tokens')
36
+ end
37
+ end
38
+
39
+ # Executes the given block if the lock can be acquired
40
+ # @yield Block to be executed if the lock is acquired
41
+ def locked
42
+ yield if acquire
43
+ ensure
44
+ release
45
+ end
46
+
47
+ # Whether or not the lock will block when attempting to acquire it
48
+ # @return [Boolean]
49
+ def blocking?
50
+ return @timeout.positive?
51
+ end
52
+
53
+ # Attempts to acquire the lock. First attempts to grab the lease (a redis string).
54
+ # If the current token is already the lease token, the lock is considered acquired.
55
+ # If there is no current lease, then sets it to the current token.
56
+ # If there is a current lease that is not the current token, then:
57
+ # 1) If this not a blocking lock (see Lock#blocking?), return false
58
+ # 2) If this is a blocking lock, block and wait for the next token to be pushed on the tokens list
59
+ # 3) If a token was pushed, set it as our token and refresh the expiry
60
+ # @return [Boolean] True if acquired, false otherwise
61
+ def acquire
62
+ acquired = false
63
+ token = non_blocking_acquire(@token)
64
+ token = blocking_acquire if token.nil? && blocking?
65
+
66
+ unless token.nil?
67
+ @token = token
68
+ acquired = true
69
+ end
70
+
71
+ return acquired
72
+ end
73
+
74
+ # Releases the lock only if the current token is the value of the lease.
75
+ # If the lock is a blocking lock (see Lock#blocking?), push the next token on the tokens list.
76
+ # @return [Boolean] True if released, false otherwise
77
+ def release
78
+ return false if @token.nil?
79
+
80
+ next_token = SecureRandom.uuid
81
+ return coerce_bool(release_script(keys: [@lease.key, @tokens.key], argv: [@token, next_token, @expiry]))
82
+ end
83
+
84
+ def non_blocking_acquire(token = nil)
85
+ token ||= generate_token
86
+ return acquire_script(keys: @lease.key, argv: [token, @expiry])
87
+ end
88
+ private :non_blocking_acquire
89
+
90
+ def blocking_acquire
91
+ timeout = @timeout == Float::INFINITY ? 0 : @timeout
92
+ token = @tokens.pop(timeout: timeout)
93
+
94
+ # Attempt to reacquire in a non blocking way to:
95
+ # 1) assert we do own the lock (edge case)
96
+ # 2) touch the lock expiry
97
+ token = non_blocking_acquire(token) unless token.nil?
98
+
99
+ return token
100
+ end
101
+ private :blocking_acquire
102
+
103
+ # The acquire script attempts to set the lease (keys[1]) to the given token (argv[1]), only
104
+ # if it wasn't already set. It then compares to check if the value of the lease is that of the token,
105
+ # and if so refreshes the expiry (argv[2]) time of the lease.
106
+ # @param [Array<(::String)>] keys The lease key specifying who owns the mutex at the moment
107
+ # @param [Array<(::String, Fixnum)>] argv The current token; the expiry time in milliseconds
108
+ # @return [::String] Returns the token if acquired, nil otherwise.
109
+ defscript :acquire_script, <<~LUA
110
+ local token = ARGV[1]
111
+ local expiry = tonumber(ARGV[2])
112
+
113
+ redis.call('set', KEYS[1], token, 'NX')
114
+ if redis.call('get', KEYS[1]) == token then
115
+ redis.call('pexpire', KEYS[1], expiry)
116
+ return token
117
+ end
118
+
119
+ return false
120
+ LUA
121
+
122
+ # The release script compares the given token (argv[1]) with the lease value (keys[1]); if they are the same,
123
+ # then a new token (argv[2]) is set as the lease, and pushed on the tokens (keys[2]) list
124
+ # for the next acquire request.
125
+ # @param [Array<(::String, ::String)>] keys The lease key; the tokens list key
126
+ # @param [Array<(::String, ::String, Fixnum)>] argv The current token; the next token to push; the expiry time of both keys
127
+ # @return [Fixnum] 1 if released, 0 otherwise
128
+ defscript :release_script, <<~LUA
129
+ local currentToken = ARGV[1]
130
+ local nextToken = ARGV[2]
131
+ local expiry = tonumber(ARGV[3])
132
+
133
+ if redis.call('get', KEYS[1]) == currentToken then
134
+ redis.call('set', KEYS[1], nextToken, 'PX', expiry)
135
+ redis.call('lpush', KEYS[2], nextToken)
136
+ redis.call('pexpire', KEYS[2], expiry)
137
+ return true
138
+ end
139
+
140
+ return false
141
+ LUA
142
+
143
+ # @!group Serialization
144
+ # Returns a hash representation of the object
145
+ # @see Lock#from_h
146
+ # @return [Hash<Symbol, Object>] hash representation of the lock
147
+ def to_h
148
+ return super.merge(token: @token, expiry: @expiry, timeout: @timeout)
149
+ end
150
+
151
+ class << self
152
+ # Builds a lock from a hash.
153
+ # @see Lock#to_h
154
+ # @see Factory#create_from_h
155
+ # @param [Hash] hash hash generated by calling Lock#to_h. Ensure beforehand that keys are symbols.
156
+ # @return [Lock]
157
+ def from_h(hash, factory)
158
+ hash[:factory] = factory
159
+ return new(**hash)
160
+ end
161
+ end
162
+ # @!endgroup
163
+
164
+ def generate_token
165
+ return SecureRandom.uuid
166
+ end
167
+ private :generate_token
168
+
169
+ # Helper method for easy inspection
170
+ def inspectable_attributes
171
+ super.merge(expiry: @expiry, blocking: blocking?)
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,29 @@
1
+ module Redstruct
2
+ module Hls
3
+ class Queue < Redstruct::Types::List
4
+ include Redstruct::Utils::Scriptable
5
+
6
+ def enqueue(*elements)
7
+ self.connection.rpush(@key, elements)
8
+ end
9
+
10
+ def dequeue(length: 1)
11
+ elements = dequeue_script(keys: @key, argv: length)
12
+ length == 1 ? elements.first : elements
13
+ end
14
+
15
+ # Dequeues up to argv[1] amount of items from the list at keys[1]
16
+ # @param [Array<(::String)>] keys The key of the list to dequeue from
17
+ # @param [Array<(Fixnum)>] argv The number of items to dequeue
18
+ # @return [Array] An array of items dequeued or an empty array
19
+ defscript :dequeue_script, <<~LUA
20
+ local length = tonumber(ARGV[1])
21
+ local elements = redis.call('lrange', KEYS[1], 0, length - 1)
22
+ redis.call('ltrim', KEYS[1], length, -1)
23
+
24
+ return elements
25
+ LUA
26
+ protected :dequeue_script
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,2 @@
1
+ require 'redstruct/hls/lock'
2
+ require 'redstruct/hls/queue'
@@ -0,0 +1,47 @@
1
+ module Redstruct
2
+ module Types
3
+ # Base class for all objects a factory can produce
4
+ class Base
5
+ include Redstruct::Utils::Inspectable
6
+ extend Forwardable
7
+
8
+ def_delegators :@factory, :connection, :connection
9
+
10
+ # @return [String] The key used to identify the struct on redis
11
+ attr_reader :key
12
+
13
+ def initialize(key:, factory:)
14
+ @factory = factory
15
+ @key = key
16
+ end
17
+
18
+ def with
19
+ self.connection.pool.with do |c|
20
+ begin
21
+ Thread.current[:__redstruct_connection] = c
22
+ yield(c)
23
+ ensure
24
+ Thread.current[:__redstruct_connection] = nil
25
+ end
26
+ end
27
+ end
28
+
29
+ def to_h
30
+ return { key: @key }
31
+ end
32
+
33
+ def create
34
+ return unless block_given?
35
+ subfactory = @factory.factory(@key)
36
+ yield(subfactory)
37
+ end
38
+ protected :create
39
+
40
+ # :nocov:
41
+ def inspectable_attributes
42
+ { key: @key, factory: @factory }
43
+ end
44
+ # :nocov:
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,65 @@
1
+ module Redstruct
2
+ module Types
3
+ class Counter < Redstruct::Types::String
4
+ include Redstruct::Utils::Scriptable
5
+
6
+ def initialize(increment: 1, max: nil, **options)
7
+ super(**options)
8
+ @increment = increment
9
+ @max = max
10
+ end
11
+
12
+ def get
13
+ super.to_i
14
+ end
15
+
16
+ def set(value)
17
+ super(value.to_i)
18
+ end
19
+
20
+ def increment(by: nil, max: nil)
21
+ by ||= @increment
22
+ max ||= @max
23
+
24
+ value = if max.nil?
25
+ self.connection.incrby(@key, by.to_i).to_i
26
+ else
27
+ ring_increment_script(keys: @key, argv: [by.to_i, max.to_i]).to_i
28
+ end
29
+
30
+ return value
31
+ end
32
+
33
+ def decrement(by: nil, max: nil)
34
+ by ||= @increment
35
+ by = -by.to_i
36
+ return increment(by: by, max: max)
37
+ end
38
+
39
+ def getset(value)
40
+ return super(value.to_i).to_i
41
+ end
42
+
43
+ # @!group Lua Scripts
44
+
45
+ defscript :ring_increment_script, <<~LUA
46
+ local by = tonumber(ARGV[1])
47
+ local max = tonumber(ARGV[2])
48
+ local current = redis.call('get', KEYS[1])
49
+ local value = current and tonumber(current) or 0
50
+
51
+ value = (value + by) % max
52
+ redis.call('set', KEYS[1], value)
53
+
54
+ return value
55
+ LUA
56
+
57
+ # @!endgroup
58
+
59
+ # Helper method for easy inspection
60
+ def inspectable_attributes
61
+ super.merge(max: @max, increment: @increment)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,72 @@
1
+ module Redstruct
2
+ module Types
3
+ class Hash < Redstruct::Types::Struct
4
+ include Redstruct::Utils::Coercion
5
+
6
+ def [](key)
7
+ return self.connection.hget(@key, key)
8
+ end
9
+
10
+ def []=(key, value)
11
+ self.connection.hset(@key, key, value)
12
+ end
13
+
14
+ def set(key, value, overwrite: true)
15
+ if overwrite
16
+ self[key] = value
17
+ else
18
+ self.connection.hsetnx(@key, key, value)
19
+ end
20
+ end
21
+
22
+ def get(*keys)
23
+ return self[keys.first] if keys.size == 1
24
+ return self.connection.mapped_hmget(@key, *keys)
25
+ end
26
+
27
+ def update(hash)
28
+ self.connection.mapped_hmset(@key, hash)
29
+ end
30
+
31
+ def delete(*keys)
32
+ return self.connection.hdel(@key, keys)
33
+ end
34
+
35
+ def key?(key)
36
+ return coerce_bool(self.connection.hexists(@key, key))
37
+ end
38
+
39
+ def incr(key, increment: 1)
40
+ if increment.is_a?(Float)
41
+ self.connection.hincrbyfloat(@key, key, increment.to_f)
42
+ else
43
+ self.connection.hincrby(@key, key, increment)
44
+ end
45
+ end
46
+
47
+ def decr(key, increment: 1)
48
+ return incr(key, (-increment))
49
+ end
50
+
51
+ def to_h
52
+ return self.connection.hgetall(@key)
53
+ end
54
+
55
+ def keys
56
+ return self.connection.hkeys(@key)
57
+ end
58
+
59
+ def values
60
+ return self.connection.hvals(@key)
61
+ end
62
+
63
+ def size
64
+ return self.connection.hlen(@key)
65
+ end
66
+
67
+ def each(options = {}, &block)
68
+ return self.connection.hscan_each(@key, options, &block)
69
+ end
70
+ end
71
+ end
72
+ end