redstruct 0.1.7 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +15 -11
- data/Rakefile +5 -5
- data/lib/redstruct/all.rb +14 -0
- data/lib/redstruct/configuration.rb +9 -6
- data/lib/redstruct/connection_proxy.rb +123 -0
- data/lib/redstruct/counter.rb +96 -0
- data/lib/redstruct/error.rb +2 -0
- data/lib/redstruct/factory/object.rb +31 -0
- data/lib/redstruct/factory.rb +94 -55
- data/lib/redstruct/hash.rb +123 -0
- data/lib/redstruct/list.rb +315 -0
- data/lib/redstruct/lock.rb +183 -0
- data/lib/redstruct/script.rb +104 -0
- data/lib/redstruct/set.rb +155 -0
- data/lib/redstruct/sorted_set/slice.rb +124 -0
- data/lib/redstruct/sorted_set.rb +153 -0
- data/lib/redstruct/string.rb +66 -0
- data/lib/redstruct/struct.rb +87 -0
- data/lib/redstruct/utils/coercion.rb +14 -8
- data/lib/redstruct/utils/inspectable.rb +8 -4
- data/lib/redstruct/utils/iterable.rb +52 -0
- data/lib/redstruct/utils/scriptable.rb +32 -6
- data/lib/redstruct/version.rb +4 -1
- data/lib/redstruct.rb +17 -51
- data/lib/yard/defscript_handler.rb +5 -3
- data/test/redstruct/configuration_test.rb +13 -0
- data/test/redstruct/connection_proxy_test.rb +85 -0
- data/test/redstruct/counter_test.rb +108 -0
- data/test/redstruct/factory/object_test.rb +21 -0
- data/test/redstruct/factory_test.rb +136 -0
- data/test/redstruct/hash_test.rb +138 -0
- data/test/redstruct/list_test.rb +244 -0
- data/test/redstruct/lock_test.rb +108 -0
- data/test/redstruct/script_test.rb +53 -0
- data/test/redstruct/set_test.rb +219 -0
- data/test/redstruct/sorted_set/slice_test.rb +10 -0
- data/test/redstruct/sorted_set_test.rb +219 -0
- data/test/redstruct/string_test.rb +8 -0
- data/test/redstruct/struct_test.rb +61 -0
- data/test/redstruct/utils/coercion_test.rb +33 -0
- data/test/redstruct/utils/inspectable_test.rb +31 -0
- data/test/redstruct/utils/iterable_test.rb +94 -0
- data/test/redstruct/utils/scriptable_test.rb +67 -0
- data/test/redstruct_test.rb +14 -0
- data/test/test_helper.rb +77 -1
- metadata +58 -26
- data/lib/redstruct/connection.rb +0 -47
- data/lib/redstruct/factory/creation.rb +0 -95
- data/lib/redstruct/factory/deserialization.rb +0 -7
- data/lib/redstruct/hls/lock.rb +0 -175
- data/lib/redstruct/hls/queue.rb +0 -29
- data/lib/redstruct/hls.rb +0 -2
- data/lib/redstruct/types/base.rb +0 -36
- data/lib/redstruct/types/counter.rb +0 -65
- data/lib/redstruct/types/hash.rb +0 -72
- data/lib/redstruct/types/list.rb +0 -76
- data/lib/redstruct/types/script.rb +0 -56
- data/lib/redstruct/types/set.rb +0 -96
- data/lib/redstruct/types/sorted_set.rb +0 -129
- data/lib/redstruct/types/string.rb +0 -64
- data/lib/redstruct/types/struct.rb +0 -58
- data/lib/releaser/logger.rb +0 -15
- data/lib/releaser/repository.rb +0 -32
- data/lib/tasks/release.rake +0 -49
- 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
|