redstruct 0.1.7 → 0.2.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.
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