redstruct 0.1.7 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -11
  3. data/Rakefile +5 -5
  4. data/lib/redstruct/all.rb +14 -0
  5. data/lib/redstruct/configuration.rb +9 -6
  6. data/lib/redstruct/connection_proxy.rb +123 -0
  7. data/lib/redstruct/counter.rb +96 -0
  8. data/lib/redstruct/error.rb +2 -0
  9. data/lib/redstruct/factory/object.rb +31 -0
  10. data/lib/redstruct/factory.rb +94 -55
  11. data/lib/redstruct/hash.rb +123 -0
  12. data/lib/redstruct/list.rb +315 -0
  13. data/lib/redstruct/lock.rb +183 -0
  14. data/lib/redstruct/script.rb +104 -0
  15. data/lib/redstruct/set.rb +155 -0
  16. data/lib/redstruct/sorted_set/slice.rb +124 -0
  17. data/lib/redstruct/sorted_set.rb +153 -0
  18. data/lib/redstruct/string.rb +66 -0
  19. data/lib/redstruct/struct.rb +87 -0
  20. data/lib/redstruct/utils/coercion.rb +14 -8
  21. data/lib/redstruct/utils/inspectable.rb +8 -4
  22. data/lib/redstruct/utils/iterable.rb +52 -0
  23. data/lib/redstruct/utils/scriptable.rb +32 -6
  24. data/lib/redstruct/version.rb +4 -1
  25. data/lib/redstruct.rb +17 -51
  26. data/lib/yard/defscript_handler.rb +5 -3
  27. data/test/redstruct/configuration_test.rb +13 -0
  28. data/test/redstruct/connection_proxy_test.rb +85 -0
  29. data/test/redstruct/counter_test.rb +108 -0
  30. data/test/redstruct/factory/object_test.rb +21 -0
  31. data/test/redstruct/factory_test.rb +136 -0
  32. data/test/redstruct/hash_test.rb +138 -0
  33. data/test/redstruct/list_test.rb +244 -0
  34. data/test/redstruct/lock_test.rb +108 -0
  35. data/test/redstruct/script_test.rb +53 -0
  36. data/test/redstruct/set_test.rb +219 -0
  37. data/test/redstruct/sorted_set/slice_test.rb +10 -0
  38. data/test/redstruct/sorted_set_test.rb +219 -0
  39. data/test/redstruct/string_test.rb +8 -0
  40. data/test/redstruct/struct_test.rb +61 -0
  41. data/test/redstruct/utils/coercion_test.rb +33 -0
  42. data/test/redstruct/utils/inspectable_test.rb +31 -0
  43. data/test/redstruct/utils/iterable_test.rb +94 -0
  44. data/test/redstruct/utils/scriptable_test.rb +67 -0
  45. data/test/redstruct_test.rb +14 -0
  46. data/test/test_helper.rb +77 -1
  47. metadata +58 -26
  48. data/lib/redstruct/connection.rb +0 -47
  49. data/lib/redstruct/factory/creation.rb +0 -95
  50. data/lib/redstruct/factory/deserialization.rb +0 -7
  51. data/lib/redstruct/hls/lock.rb +0 -175
  52. data/lib/redstruct/hls/queue.rb +0 -29
  53. data/lib/redstruct/hls.rb +0 -2
  54. data/lib/redstruct/types/base.rb +0 -36
  55. data/lib/redstruct/types/counter.rb +0 -65
  56. data/lib/redstruct/types/hash.rb +0 -72
  57. data/lib/redstruct/types/list.rb +0 -76
  58. data/lib/redstruct/types/script.rb +0 -56
  59. data/lib/redstruct/types/set.rb +0 -96
  60. data/lib/redstruct/types/sorted_set.rb +0 -129
  61. data/lib/redstruct/types/string.rb +0 -64
  62. data/lib/redstruct/types/struct.rb +0 -58
  63. data/lib/releaser/logger.rb +0 -15
  64. data/lib/releaser/repository.rb +0 -32
  65. data/lib/tasks/release.rake +0 -49
  66. data/test/redstruct/restruct_test.rb +0 -4
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redstruct/struct'
4
+ require 'redstruct/utils/scriptable'
5
+ require 'redstruct/utils/iterable'
6
+
7
+ module Redstruct
8
+ # Class to manipulate redis lists, modeled after Ruby's Array class.
9
+ # TODO: Add maximum instance variable and modify all methods (where applicable)
10
+ # to take it into account.
11
+ class List < Redstruct::Struct
12
+ include Redstruct::Utils::Scriptable
13
+ include Redstruct::Utils::Iterable
14
+
15
+ # Clears the set by simply removing the key from the DB
16
+ # @see Redstruct::Struct#clear
17
+ def clear
18
+ delete
19
+ end
20
+
21
+ # Checks if the set is empty by checking if the key actually exists on the underlying redis db
22
+ # @see Redstruct::Struct#exists?
23
+ # @return [Boolean] true if it is empty, false otherwise
24
+ def empty?
25
+ return !exists?
26
+ end
27
+
28
+ # Returns the item located at index
29
+ # @param [Integer] index the item located at index
30
+ # @return [String, nil] nil if no item at index, otherwise the value
31
+ def [](index)
32
+ return self.connection.lindex(@key, index.to_i)
33
+ end
34
+
35
+ # Sets or updates the value for item at index
36
+ # @param [Integer] index the index
37
+ # @param [#to_s] value the new value
38
+ # @raise Redis::BaseError when index is out of range
39
+ # @return [Boolean] true if set, false otherwise
40
+ def []=(index, value)
41
+ return coerce_bool(set_script(keys: @key, argv: [index.to_i, value]))
42
+ end
43
+
44
+ # Inserts the given value at the given zero-based index.
45
+ # TODO: Support multiple insertion like Array#insert? The biggest issue
46
+ # here is that concatenating lists in Lua is O(n), so on very large lists,
47
+ # this operation would become slow. There are Redis Modules which implement
48
+ # splice operations (so a O(1) list split/merge), but there's no way to
49
+ # guarantee if the module will be present. Perhaps provide optional support
50
+ # if the module is detected?
51
+ # @param [#to_s] value the value to insert
52
+ # @param [#to_i] index the index at which to insert the value
53
+ def insert(value, index)
54
+ result = case index
55
+ when 0 then prepend(value)
56
+ when -1 then append(value)
57
+ else
58
+ index += 1 if index.negative?
59
+ insert_script(keys: @key, argv: [value, index])
60
+ end
61
+
62
+ return coerce_bool(result)
63
+ end
64
+
65
+ # Appends the given items (from the right) to the list
66
+ # @param [Array<#to_s>] items the items to append
67
+ # @param [Integer] max optional; if given, appends the items and trims down the list to max afterwards
68
+ # @param [Boolean] exists optional; if true, only appends iff the list already exists (i.e. is not empty)
69
+ # @return [Integer] the number of items appended to the list
70
+ def append(*items, max: 0, exists: false)
71
+ max = max.to_i
72
+ results = if max.positive? || exists
73
+ push_and_trim_script(keys: @key, argv: [max - 1, false, exists] + items)
74
+ else
75
+ self.connection.rpush(@key, items)
76
+ end
77
+
78
+ return results
79
+ end
80
+ alias push append
81
+
82
+ # Pushes the given element onto the list. As << is a binary operator, it can
83
+ # only take one argument in. It's more of a convenience method.
84
+ # @param [#to_s] item the item to append to the list
85
+ # @return [Integer] 1 if appended, 0 otherwise
86
+ def <<(item)
87
+ return append(item)
88
+ end
89
+
90
+ # Prepends the given items (from the right) to the list
91
+ # @param [Array<#to_s>] items the items to prepend
92
+ # @param [Integer] max optional; if given, prepends the items and trims down the list to max afterwards
93
+ # @param [Boolean] exists optional; if true, only prepends iff the list already exists (i.e. is not empty)
94
+ # @return [Integer] the number of items prepended to the list
95
+ def prepend(*items, max: nil, exists: false)
96
+ max = max.to_i
97
+
98
+ # redis literally prepends each element one at a time, so 1 2 will end up 2 1
99
+ # to keep behaviour in sync with Array#unshift we preemptively reverse the list
100
+ items = items.reverse
101
+
102
+ results = if max.positive? || exists
103
+ push_and_trim_script(keys: @key, argv: [max - 1, true, exists] + items)
104
+ else
105
+ self.connection.lpush(@key, items)
106
+ end
107
+
108
+ return results
109
+ end
110
+ alias unshift prepend
111
+
112
+ # Pops an item from the list, optionally blocking to wait until the list is non-empty
113
+ # @param [Integer] timeout the amount of time to wait in seconds; if 0, waits indefinitely
114
+ # @return [nil, String] nil if the list was empty, otherwise the item
115
+ def pop(size = 1, timeout: nil)
116
+ raise ArgumentError, 'size must be positive' unless size.positive?
117
+
118
+ if timeout.nil?
119
+ return self.connection.rpop(@key) if size == 1
120
+ return shift_pop_script(keys: @key, argv: [-size, -1, 1])
121
+ else
122
+ raise ArgumentError, 'timeout is only supported if size == 1' unless size == 1
123
+ return self.connection.brpop(@key, timeout: timeout)&.last
124
+ end
125
+ end
126
+
127
+ # Shifts an item from the list, optionally blocking to wait until the list is non-empty
128
+ # @param [Integer] timeout the amount of time to wait in seconds; if 0, waits indefinitely
129
+ # @return [nil, String] nil if the list was empty, otherwise the item
130
+ def shift(size = 1, timeout: nil)
131
+ raise ArgumentError, 'size must be positive' unless size.positive?
132
+
133
+ if timeout.nil?
134
+ return self.connection.lpop(@key) if size == 1
135
+ return shift_pop_script(keys: @key, argv: [0, size - 1, 0])
136
+ else
137
+ raise ArgumentError, 'timeout is only supported if size == 1' unless size == 1
138
+ return self.connection.blpop(@key, timeout: timeout)&.last
139
+ end
140
+ end
141
+
142
+ # Pops an element from this list and shifts it onto the given list.
143
+ # @param [Redstruct::List] list the list to shift the element onto
144
+ # @param [#to_i] timeout optional timeout to wait for in seconds
145
+ # @return [String] the element that was popped from the list and pushed onto the other
146
+ def popshift(list, timeout: nil)
147
+ raise ArgumentError, 'list must respond to #key' unless list.respond_to?(:key)
148
+
149
+ if timeout.nil?
150
+ return self.connection.rpoplpush(@key, list.key)
151
+ else
152
+ return self.connection.brpoplpush(@key, list.key, timeout: timeout)
153
+ end
154
+ end
155
+
156
+ # Removes the given item from the list.
157
+ # @param [Integer] count count > 0: Remove items equal to value moving from head to tail.
158
+ # count < 0: Remove items equal to value moving from tail to head.
159
+ # count = 0: Remove all items equal to value.
160
+ # @return [Integer] the number of items removed
161
+ def remove(value, count: 1)
162
+ self.connection.lrem(@key, count.to_i, value)
163
+ end
164
+
165
+ # Checks how many items are in the list.
166
+ # @return [Integer] the number of items in the list
167
+ def size
168
+ return self.connection.llen(@key)
169
+ end
170
+
171
+ # Returns a slice of the list starting at start (inclusively), up to length (inclusively)
172
+ # @example
173
+ # pry> list.slice(start: 1, length: 10) #=> Array<...> # array with 11 items
174
+ # @param [Integer] start the starting index for the slice; if start is larger than the end of the list, an empty list is returned
175
+ # @param [Integer] length the length of the slice (inclusively); if -1, returns everything
176
+ # @return [Array<String>] the requested slice, or an empty list
177
+ def slice(start: 0, length: -1)
178
+ length = length.to_i
179
+ end_index = length.positive? ? start + length - 1 : length
180
+
181
+ return self.connection.lrange(@key, start.to_i, end_index)
182
+ end
183
+
184
+ # Loads all items into memory and returns an array.
185
+ # NOTE: if the list is expected to be large, use to_enum
186
+ # @return [Array<String>] the items in the list
187
+ def to_a
188
+ return slice(start: 0, length: -1)
189
+ end
190
+
191
+ # Since the list can be modified in between loops, this does not guarantee
192
+ # completion of the operation, nor that every single element of the list
193
+ # will be visited once; rather, it guarantees that it loops until no more
194
+ # elements are returned, using an incrementing offset.
195
+ # This means that should elements be removed in the meantime, they will
196
+ # not be seen, and others might be skipped as a result of this.
197
+ # If elements are added, it is however not an issue (although keep in mind
198
+ # that if elements are added faster than consumed, this can loop forever)
199
+ # @return [Enumerator] base enumerator to iterate of the list elements
200
+ def to_enum(match: '*', count: 10)
201
+ pattern = Regexp.compile("^#{Regexp.escape(match).gsub('\*', '.*')}$")
202
+
203
+ return Enumerator.new do |yielder|
204
+ offset = 0
205
+ loop do
206
+ items = slice(start: offset, length: offset + count)
207
+
208
+ offset += items.size
209
+ matched = items.select { |item| item =~ pattern }
210
+ yielder << matched unless matched.empty?
211
+
212
+ raise StopIteration if items.size < count
213
+ end
214
+ end
215
+ end
216
+
217
+ # @!group Lua Scripts
218
+
219
+ # Appends or prepends (argv[1]) a number of items (argv[2]) to a list (keys[1]),
220
+ # then trims it out to size (argv[3])
221
+ # @param [Array<#to_s>] keys first key should be the key to the list to prepend to and resize
222
+ # @param [Array<Integer, Integer, Integer, Array<#to_s>>] argv the maximum size of the list; if 1, will lpush, otherwise rpush;
223
+ # if 1, will push only if the list exists; the list of items to prepend
224
+ # @return [Integer] the length of the list after the operation
225
+ defscript :push_and_trim_script, <<~LUA
226
+ local max = tonumber(table.remove(ARGV, 1))
227
+ local prepend = tonumber(table.remove(ARGV, 1)) == 1
228
+ local exists = tonumber(table.remove(ARGV, 1)) == 1
229
+ local push = prepend and 'lpush' or 'rpush'
230
+ local size = 0
231
+
232
+ if exists then
233
+ if redis.call('exists', KEYS[1]) == 0 then
234
+ return nil
235
+ end
236
+ end
237
+
238
+ size = redis.call(push, KEYS[1], unpack(ARGV))
239
+ if max > 0 and size > max then
240
+ redis.call('ltrim', KEYS[1], 0, max)
241
+ size = max + 1
242
+ end
243
+
244
+ return size
245
+ LUA
246
+ protected :push_and_trim_script
247
+
248
+ # Removes N elements from the list (either from the head or the tail) and trims
249
+ # the list down to size (either from the head or the tail).
250
+ # @param [Array<#to_s>] keys first key should be the key to the list to prepend to and resize
251
+ # @param [Array<Integer, Integer>] argv the start of the slice; the end index of the slice
252
+ # @return [Integer] the sliced list
253
+ defscript :shift_pop_script, <<~LUA
254
+ local range_start = tonumber(ARGV[1])
255
+ local range_end = tonumber(ARGV[2])
256
+ local direction = tonumber(ARGV[3])
257
+ local list = redis.call('lrange', KEYS[1], range_start, range_end)
258
+
259
+ if #list > 0 then
260
+ if direction == 0 then
261
+ redis.call('ltrim', KEYS[1], range_end + 1, -1)
262
+ else
263
+ redis.call('ltrim', KEYS[1], 0, range_start - 1)
264
+ end
265
+ end
266
+
267
+ return list
268
+ LUA
269
+ protected :shift_pop_script
270
+
271
+ # Inserts the given element at the given index. Can raise out of bound error
272
+ # @param [Array<#to_s>] keys first key is the list key
273
+ # @param [Array<#to_s, #to_i>] argv the value to insert; the index at which to insert it
274
+ # @return [Boolean] true if inserted, false otherwise
275
+ defscript :insert_script, <<~LUA
276
+ local value = ARGV[1]
277
+ local index = tonumber(ARGV[2])
278
+ local pivot = redis.call('lindex', KEYS[1], index - 1)
279
+
280
+ if pivot ~= nil then
281
+ return redis.call('linsert', KEYS[1], 'AFTER', pivot, value)
282
+ end
283
+
284
+ return false
285
+ LUA
286
+ protected :insert_script
287
+
288
+ # Sets the element in much the same way a ruby array would, by padding
289
+ # with empty strings (redis equivalent of nil) when the index is out of range
290
+ # @param [Array<#to_s>] keys first key is the list key
291
+ # @param [Array<#to_s, #to_i>] argv the index to set; the value to set
292
+ # @return [Boolean] true if inserted, false otherwise
293
+ defscript :set_script, <<~LUA
294
+ local index = tonumber(ARGV[1])
295
+ local value = ARGV[2]
296
+ local max = redis.call('llen', KEYS[1]) - 1
297
+
298
+ if max < index then
299
+ local upto = index - max - 1
300
+ local items = {}
301
+ for i = index - max - 1, 1, -1 do
302
+ items[#items+1] = ''
303
+ end
304
+
305
+ items[#items+1] = value
306
+ return redis.call('rpush', KEYS[1], unpack(items))
307
+ end
308
+
309
+ return redis.call('lset', KEYS[1], index, value)
310
+ LUA
311
+ protected :set_script
312
+
313
+ # @!endgroup
314
+ end
315
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'redstruct/factory/object'
5
+ require 'redstruct/utils/scriptable'
6
+ require 'redstruct/utils/coercion'
7
+
8
+ module Redstruct
9
+ # Implementation of a simple binary lock (locked/not locked), with option to block and wait for the lock.
10
+ # Uses two redis structures: a string for the lease, and a list for blocking operations.
11
+ class Lock < Redstruct::Factory::Object
12
+ include Redstruct::Utils::Scriptable
13
+ include Redstruct::Utils::Coercion
14
+
15
+ # The default expiry on the underlying redis keys, in seconds; can be between 0 and 1 as a float for milliseconds
16
+ DEFAULT_EXPIRY = 1
17
+
18
+ # The default timeout when blocking, in seconds
19
+ DEFAULT_TIMEOUT = nil
20
+
21
+ # @return [String] the resource name (or ID of the lock)
22
+ attr_reader :resource
23
+
24
+ # @return [String] the current token
25
+ attr_reader :token
26
+
27
+ # @return [Float, Integer] the expiry of the underlying redis structure in seconds
28
+ attr_reader :expiry
29
+
30
+ # @return [Integer] if greater than 0, will block until timeout is reached or the lock is acquired
31
+ attr_reader :timeout
32
+
33
+ # @param [String] resource the name of the resource to be locked (or ID)
34
+ # @param [Integer] expiry in seconds; to prevent infinite locking, you should pass a minimum expiry; you can pass 0 if you want to control it yourself
35
+ # @param [Integer] timeout in seconds; if > 0, will block when trying to obtain the lock; if 0, blocks indefinitely; if nil, does not block
36
+ def initialize(resource, expiry: DEFAULT_EXPIRY, timeout: DEFAULT_TIMEOUT, **options)
37
+ super(**options)
38
+
39
+ @resource = resource
40
+ @token = nil
41
+ @expiry = expiry
42
+ @timeout = case timeout
43
+ when nil then nil
44
+ when Float::INFINITY then 0
45
+ else
46
+ timeout.to_i
47
+ end
48
+
49
+ factory = @factory.factory(@resource)
50
+ @lease = factory.string('lease')
51
+ @tokens = factory.list('tokens')
52
+ end
53
+
54
+ # Executes the given block if the lock can be acquired
55
+ # @yield Block to be executed if the lock is acquired
56
+ def locked
57
+ Thread.handle_interrupt(Exception => :never) do
58
+ begin
59
+ if acquire
60
+ Thread.handle_interrupt(Exception => :immediate) do
61
+ yield
62
+ end
63
+ end
64
+ ensure
65
+ release
66
+ end
67
+ end
68
+ end
69
+
70
+ # Whether or not the lock will block when attempting to acquire it
71
+ # @return [Boolean]
72
+ def blocking?
73
+ return !@timeout.nil?
74
+ end
75
+
76
+ # Attempts to acquire the lock. First attempts to grab the lease (a redis string).
77
+ # If the current token is already the lease token, the lock is considered acquired.
78
+ # If there is no current lease, then sets it to the current token.
79
+ # If there is a current lease that is not the current token, then:
80
+ # 1) If this not a blocking lock (see Lock#blocking?), return false
81
+ # 2) If this is a blocking lock, block and wait for the next token to be pushed on the tokens list
82
+ # 3) If a token was pushed, set it as our token and refresh the expiry
83
+ # @return [Boolean] True if acquired, false otherwise
84
+ def acquire
85
+ acquired = false
86
+
87
+ token = non_blocking_acquire
88
+ token = blocking_acquire if token.nil? && blocking?
89
+
90
+ unless token.nil?
91
+ @lease.expire(@expiry)
92
+ @token = token
93
+ acquired = true
94
+ end
95
+
96
+ return acquired
97
+ end
98
+
99
+ # Releases the lock only if the current token is the value of the lease.
100
+ # If the lock is a blocking lock (see Lock#blocking?), push the next token on the tokens list.
101
+ # @return [Boolean] True if released, false otherwise
102
+ def release
103
+ return false if @token.nil?
104
+
105
+ keys = [@lease.key, @tokens.key]
106
+ argv = [@token, generate_token, (@expiry.to_f * 1000).floor]
107
+
108
+ released = release_script(keys: keys, argv: argv)
109
+ @token = nil
110
+
111
+ return coerce_bool(released)
112
+ end
113
+
114
+ private
115
+
116
+ def non_blocking_acquire
117
+ keys = [@lease.key, @tokens.key]
118
+ argv = [generate_token]
119
+
120
+ return acquire_script(keys: keys, argv: argv)
121
+ end
122
+
123
+ def blocking_acquire
124
+ return @tokens.pop(timeout: @timeout)
125
+ end
126
+
127
+ # The acquire script attempts to set the lease (keys[1]) to the given token (argv[1]), only
128
+ # if it wasn't already set. It then compares to check if the value of the lease is that of the token,
129
+ # and if so refreshes the expiry (argv[2]) time of the lease.
130
+ # @param [Array<(::String)>] keys The lease key specifying who owns the mutex at the moment
131
+ # @param [Array<(::String, Fixnum)>] argv the current token
132
+ # @return [::String] Returns the token if acquired, nil otherwise.
133
+ defscript :acquire_script, <<~LUA
134
+ local token = ARGV[1]
135
+ local lease = redis.call('get', KEYS[1])
136
+
137
+ if not lease then
138
+ redis.call('set', KEYS[1], token)
139
+ elseif token ~= lease then
140
+ token = redis.call('lpop', KEYS[2])
141
+ if not token or token ~= lease then
142
+ return false
143
+ end
144
+ end
145
+
146
+ return token
147
+ LUA
148
+
149
+ # The release script compares the given token (argv[1]) with the lease value (keys[1]); if they are the same,
150
+ # then a new token (argv[2]) is set as the lease, and pushed on the tokens (keys[2]) list
151
+ # for the next acquire request.
152
+ # @param [Array<(::String, ::String)>] keys the lease key; the tokens list key
153
+ # @param [Array<(::String, ::String, Fixnum)>] argv the current token; the next token to push; the expiry time of both keys
154
+ # @return [Fixnum] 1 if released, 0 otherwise
155
+ defscript :release_script, <<~LUA
156
+ local currentToken = ARGV[1]
157
+ local nextToken = ARGV[2]
158
+ local expiry = tonumber(ARGV[3])
159
+
160
+ if redis.call('get', KEYS[1]) == currentToken then
161
+ redis.call('set', KEYS[1], nextToken)
162
+ redis.call('lpush', KEYS[2], nextToken)
163
+
164
+ if expiry > 0 then
165
+ redis.call('pexpire', KEYS[1], expiry)
166
+ redis.call('pexpire', KEYS[2], expiry)
167
+ end
168
+
169
+ return true
170
+ end
171
+
172
+ return false
173
+ LUA
174
+
175
+ def generate_token
176
+ return SecureRandom.uuid
177
+ end
178
+
179
+ def inspectable_attributes
180
+ super.merge(expiry: @expiry, blocking: blocking?)
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'redis'
5
+ require 'redstruct/connection_proxy'
6
+ require 'redstruct/utils/inspectable'
7
+
8
+ module Redstruct
9
+ # Utility class to interact with Lua scripts on Redis.
10
+ # It is recommended you flush your script cache on the redis server every once in a while to remove scripts that
11
+ # are not used anymore.
12
+ class Script
13
+ # Redis returns an error starting with NOSCRIPT when we try to evaluate am unknown script using its sha1.
14
+ ERROR_MESSAGE_PREFIX = 'NOSCRIPT'
15
+
16
+ # @return [String] the Lua script to evaluate
17
+ attr_reader :script
18
+
19
+ # @return [Redstruct::ConnectionProxy] the connection used for script commands
20
+ attr_reader :connection
21
+
22
+ # @param [String] script the lua source code for the script
23
+ # @param [Redstruct::ConnectionProxy] connection connection to use for script commands
24
+ # @param [String] sha1 the sha1 representation of the script; optional
25
+ def initialize(script:, connection:, sha1: nil)
26
+ self.script = script
27
+ @connection = connection
28
+ @sha1 = sha1 unless sha1.nil?
29
+
30
+ raise ArgumentError, 'requires a connection proxy' unless @connection.is_a?(Redstruct::ConnectionProxy)
31
+ end
32
+
33
+ # Duplicates and freezes the given script, and reinitializes the sha1 (which later gets lazily computed)
34
+ # @param [String] script the lua source code
35
+ def script=(script)
36
+ script = script&.strip
37
+ raise ArgumentError, 'No source script given' if script.empty?
38
+
39
+ @sha1 = nil
40
+ @script = script.dup.freeze
41
+ end
42
+
43
+ # Returns the sha1 representation of the source code at `script`
44
+ # When running a lua script, redis will compile it once and cache the bytecode, using the sha1 of the source code
45
+ # as the cache key.
46
+ # @return [String] sha1 representation of `script`
47
+ def sha1
48
+ return @sha1 ||= begin
49
+ Digest::SHA1.hexdigest(@script)
50
+ end
51
+ end
52
+
53
+ # Checks if the script was already loaded for the given redis db using #sha1
54
+ # @return [Boolean] true if the script was already loaded, false otherwise
55
+ def exists?
56
+ return @connection.script(:exists, self.sha1)
57
+ end
58
+
59
+ # Loads the given script to redis (i.e. sends the source, which gets compiled and saved) and saves the returned sha1
60
+ # @return [String] the new sha1
61
+ def load
62
+ @sha1 = @connection.script(:load, @script)
63
+ return @sha1
64
+ end
65
+
66
+ # Evaluates the script using the given keys and argv arrays, and returns the unparsed result. Caller is in charge
67
+ # of interpreting the result.
68
+ # NOTE: To minimize the number of redis commands, this always first assumes that the script was already loaded using
69
+ # its sha1 representation, and tells redis to execute the script cached by `sha1`. If it receives as error that the
70
+ # script does not exist, only then will it send the source to be executed. So in the worst case you get 2 redis
71
+ # commands, but in the average case you get 1, and it's much faster as redis does not have to reparse the script,
72
+ # and we don't need to send the lua source every time.
73
+ # @param [Array<String>] keys the KEYS array as described in the Redis doc for eval
74
+ # @param [Array<String>] argv the ARGV array as described in the Redis doc for eval
75
+ # @return [nil, Boolean, String, Numeric] returns whatever redis returns
76
+ def eval(keys: [], argv: [])
77
+ keys = [keys] unless keys.is_a?(Array)
78
+ argv = [argv] unless argv.is_a?(Array)
79
+ argv = normalize(argv)
80
+
81
+ @connection.evalsha(self.sha1, keys, argv)
82
+ rescue Redis::CommandError => err
83
+ raise unless err.message.start_with?(ERROR_MESSAGE_PREFIX)
84
+ @connection.eval(@script, keys, argv)
85
+ end
86
+
87
+ private
88
+
89
+ def inspectable_attributes # :nodoc:
90
+ return super.merge(sha1: self.sha1, script: @script.slice(0, 20))
91
+ end
92
+
93
+ def normalize(values)
94
+ values.map do |value|
95
+ case value
96
+ when true then 1
97
+ when false then 0
98
+ else
99
+ value.to_s
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end