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.
- 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
|