redis-scheduler 0.3 → 0.4
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.
- data/README +22 -14
- data/lib/redis-scheduler.rb +104 -50
- metadata +2 -2
data/README
CHANGED
@@ -1,20 +1,28 @@
|
|
1
|
-
|
2
|
-
items to be processed at arbitrary points in time (via #schedule!)
|
3
|
-
|
1
|
+
RedisScheduler is a chronological scheduler for Redis. It allows you to schedule
|
2
|
+
items to be processed at arbitrary points in time (via RedisScheduler#schedule!)
|
3
|
+
and easily retrieve only those items that are due to be processed (via
|
4
|
+
RedisScheduler#each).
|
4
5
|
|
5
|
-
|
6
|
-
|
7
|
-
It does everything you'd want from a production scheduler:
|
6
|
+
It does everything you'd expect from a production scheduler:
|
8
7
|
* You can schedule items at arbitrary times.
|
9
|
-
* It supports multiple simultaneous readers and writers.
|
10
|
-
* An exception causes the in-process item to be rescheduled at the original time.
|
11
|
-
* A crash leaves the item in a separate error queue, from which it can later be recovered.
|
12
8
|
* You can iterate over ready items in either blocking or non-blocking mode.
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
9
|
+
* It supports multiple simultaneous readers and writers.
|
10
|
+
* A Ruby exception causes the item to be rescheduled at the original time.
|
11
|
+
* Work items lost as part of a Ruby crash or segfault are recoverable.
|
12
|
+
|
13
|
+
In non-blocking mode (the default), RedisScheduler#each will iterate only over
|
14
|
+
those work items whose scheduled time is less than or equal to the current time,
|
15
|
+
and then stop. In blocking mode, RedisScheduler#each will iterate over the same
|
16
|
+
items, but will also block until items are available. In this mode, #each will
|
17
|
+
never return.
|
18
|
+
|
19
|
+
For debugging purposes, you can use RedisScheduler#items to iterate over all
|
20
|
+
items in the queue, but note that this method is not guaranteed to be
|
21
|
+
consistent.
|
22
|
+
|
23
|
+
For error recovery purposes, you can use RedisScheduler#processing_set_items
|
24
|
+
to iterate over all the items in the processing set to determine whether any
|
25
|
+
of them are the result of a process crash.
|
18
26
|
|
19
27
|
== Synopsis
|
20
28
|
|
data/lib/redis-scheduler.rb
CHANGED
@@ -1,105 +1,154 @@
|
|
1
|
+
## A basic chronological scheduler for Redis.
|
2
|
+
##
|
3
|
+
## Use #schedule! to add an item to be processed at an arbitrary point in time.
|
4
|
+
## The item will be converted to a string and later returned to you as such.
|
5
|
+
##
|
6
|
+
## Use #each to iterate over those items in the schedule which are ready for
|
7
|
+
## processing. In blocking mode, this call will never terminate. In nonblocking
|
8
|
+
## mode, this call will terminate when there are no items ready for processing.
|
9
|
+
##
|
10
|
+
## Use #items to iterate over all items in the queue, for debugging purposes.
|
11
|
+
##
|
12
|
+
## == Ensuring reliable behavior in the presence of segfaults
|
13
|
+
##
|
14
|
+
## The scheduler maintains a "processing set" of items currently being
|
15
|
+
## processed. If a process dies (i.e. not as a result of a Ruby exception, but
|
16
|
+
## as the result of a segfault), the item will remain in this set but will
|
17
|
+
## not longer appear in the schedule. To avoid losing scheduled work due to
|
18
|
+
## segfaults, you must periodically iterate through this set and recover
|
19
|
+
## any items that have been abandoned, using #processing_set_items. Setting a
|
20
|
+
## proper 'descriptor' argument in #each is suggested.
|
1
21
|
class RedisScheduler
|
2
22
|
include Enumerable
|
3
23
|
|
4
24
|
POLL_DELAY = 1.0 # seconds
|
5
25
|
CAS_DELAY = 0.5 # seconds
|
6
26
|
|
7
|
-
##
|
8
|
-
## * +namespace+: prefix for
|
27
|
+
## Options:
|
28
|
+
## * +namespace+: prefix for Redis keys, e.g. "scheduler/"
|
9
29
|
## * +blocking+: whether #each should block or return immediately if there are items to be processed immediately.
|
10
30
|
##
|
11
|
-
## Note that nonblocking mode may still actually block as part of
|
12
|
-
## check-and-set semantics, i.e. block during contention from multiple
|
13
|
-
## clients. "Nonblocking"
|
14
|
-
##
|
15
|
-
##
|
31
|
+
## Note that nonblocking mode may still actually block momentarily as part of
|
32
|
+
## the check-and-set semantics, i.e. block during contention from multiple
|
33
|
+
## clients. "Nonblocking" refers to whether the scheduler should wait until
|
34
|
+
## events in the schedule are ready, or only return those items that are
|
35
|
+
## ready currently.
|
16
36
|
def initialize redis, opts={}
|
17
37
|
@redis = redis
|
18
38
|
@namespace = opts[:namespace]
|
19
39
|
@blocking = opts[:blocking]
|
20
40
|
|
21
41
|
@queue = [@namespace, "q"].join
|
22
|
-
@
|
42
|
+
@processing_set = [@namespace, "processing"].join
|
23
43
|
@counter = [@namespace, "counter"].join
|
24
44
|
end
|
25
45
|
|
26
|
-
##
|
27
|
-
## string.
|
46
|
+
## Schedule an item at a specific time. item will be converted to a string.
|
28
47
|
def schedule! item, time
|
29
48
|
id = @redis.incr @counter
|
30
49
|
@redis.zadd @queue, time.to_f, "#{id}:#{item}"
|
31
50
|
end
|
32
51
|
|
52
|
+
## Drop all data and reset the schedule entirely.
|
33
53
|
def reset!
|
34
|
-
[@queue, @
|
54
|
+
[@queue, @processing_set, @counter].each { |k| @redis.del k }
|
35
55
|
end
|
36
56
|
|
57
|
+
## Return the total number of items in the schedule.
|
37
58
|
def size; @redis.zcard @queue end
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
##
|
43
|
-
##
|
44
|
-
##
|
45
|
-
|
46
|
-
|
47
|
-
|
59
|
+
|
60
|
+
## Returns the total number of items currently being processed.
|
61
|
+
def processing_set_size; @redis.scard @processing_set end
|
62
|
+
|
63
|
+
## Yields items along with their scheduled times. only returns items on or
|
64
|
+
## after their scheduled times. items are returned as strings. if @blocking is
|
65
|
+
## false, will stop once there are no more items that can be processed
|
66
|
+
## immediately; if it's true, will wait until items become available (and
|
67
|
+
## never terminate).
|
68
|
+
##
|
69
|
+
## +Descriptor+ is an optional string that will be associated with this item
|
70
|
+
## while in the processing set. This is useful for providing whatever
|
71
|
+
## information you need to determine whether the item needs to be recovered
|
72
|
+
## when iterating through the processing set.
|
73
|
+
def each descriptor=nil
|
74
|
+
while(x = get(descriptor))
|
75
|
+
item, processing_descriptor, at = x
|
48
76
|
begin
|
49
77
|
yield item, at
|
50
78
|
rescue Exception # back in the hole!
|
51
79
|
schedule! item, at
|
52
80
|
raise
|
53
81
|
ensure
|
54
|
-
cleanup!
|
82
|
+
cleanup! processing_descriptor
|
55
83
|
end
|
56
84
|
end
|
57
85
|
end
|
58
86
|
|
59
|
-
##
|
60
|
-
##
|
61
|
-
## latest-scheduled
|
62
|
-
##
|
63
|
-
##
|
87
|
+
## Returns an Enumerable of [item, scheduled time] pairs, which can be used
|
88
|
+
## to iterate over all the items in the queue, in order of earliest- to
|
89
|
+
## latest-scheduled, regardless of the schedule time.
|
90
|
+
##
|
91
|
+
## Note that this view is not synchronized with write operations, and thus
|
92
|
+
## may be inconsistent (e.g. return duplicates, miss items, etc) if changes
|
93
|
+
## to the schedule happen while iterating.
|
64
94
|
##
|
65
|
-
##
|
95
|
+
## For these reasons, this is mainly useful for debugging purposes.
|
66
96
|
def items; ItemEnumerator.new(@redis, @queue) end
|
67
97
|
|
98
|
+
## Returns an Array of [item, timestamp, descriptor] tuples representing the
|
99
|
+
## set of in-process items. The timestamp corresponds to the time at which
|
100
|
+
## the item was removed from the schedule for processing.
|
101
|
+
def processing_set_items
|
102
|
+
@redis.smembers(@processing_set).map do |x|
|
103
|
+
item, timestamp, descriptor = Marshal.load(x)
|
104
|
+
[item, Time.at(timestamp), descriptor]
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
68
108
|
private
|
69
109
|
|
70
|
-
def get; @blocking ? blocking_get : nonblocking_get end
|
110
|
+
def get descriptor; @blocking ? blocking_get(descriptor) : nonblocking_get(descriptor) end
|
71
111
|
|
72
|
-
def blocking_get
|
73
|
-
sleep POLL_DELAY until(x = nonblocking_get)
|
112
|
+
def blocking_get descriptor
|
113
|
+
sleep POLL_DELAY until(x = nonblocking_get(descriptor))
|
74
114
|
x
|
75
115
|
end
|
76
116
|
|
117
|
+
## Thrown by some RedisScheduler operations if the item in Redis zset
|
118
|
+
## underlying the schedule is not parseable. This should basically never
|
119
|
+
## happen, unless you are naughty and are adding/removing items from that
|
120
|
+
## zset yourself.
|
77
121
|
class InvalidEntryException < StandardError; end
|
78
|
-
def nonblocking_get
|
122
|
+
def nonblocking_get descriptor
|
79
123
|
catch :cas_retry do
|
80
124
|
@redis.watch @queue
|
81
|
-
|
82
|
-
|
83
|
-
|
125
|
+
entry, at = @redis.zrangebyscore @queue, 0, Time.now.to_f, :withscores => true, :limit => [0, 1]
|
126
|
+
if entry
|
127
|
+
entry =~ /^\d+:(\S+)$/ or raise InvalidEntryException, entry
|
128
|
+
item = $1
|
129
|
+
processing_descriptor = Marshal.dump [item, Time.now.to_i, descriptor]
|
84
130
|
@redis.multi do # try and grab it
|
85
|
-
@redis.zrem @queue,
|
86
|
-
@redis.
|
131
|
+
@redis.zrem @queue, entry
|
132
|
+
@redis.sadd @processing_set, processing_descriptor
|
87
133
|
end or begin
|
88
134
|
sleep CAS_DELAY
|
89
135
|
throw :cas_retry
|
90
136
|
end
|
91
|
-
item
|
92
|
-
original = $1
|
93
|
-
[original, item, Time.at(at.to_f)]
|
137
|
+
[item, processing_descriptor, Time.at(at.to_f)]
|
94
138
|
end
|
95
139
|
end
|
96
140
|
end
|
97
141
|
|
98
142
|
def cleanup! item
|
99
|
-
@redis.
|
143
|
+
@redis.srem @processing_set, item
|
100
144
|
end
|
101
145
|
|
102
|
-
##
|
146
|
+
## Enumerable class for iterating over everything in the schedule. Paginates
|
147
|
+
## calls to Redis under the hood (and is thus usable for very large
|
148
|
+
## schedules), but is not synchronized with write traffic and thus may return
|
149
|
+
## duplicates or skip items when paginating.
|
150
|
+
##
|
151
|
+
## Supports random access with #[], with the same caveats as above.
|
103
152
|
class ItemEnumerator
|
104
153
|
include Enumerable
|
105
154
|
def initialize redis, q
|
@@ -107,21 +156,26 @@ private
|
|
107
156
|
@q = q
|
108
157
|
end
|
109
158
|
|
110
|
-
|
159
|
+
PAGE_SIZE = 50
|
111
160
|
def each
|
112
161
|
start = 0
|
113
162
|
while start < size
|
114
|
-
elements =
|
115
|
-
|
116
|
-
elements.each_slice(2) do |item, at| # isgh
|
117
|
-
item =~ /^\d+:(\S+)$/ or raise InvalidEntryException, item
|
118
|
-
item = $1
|
119
|
-
yield item, Time.at(at.to_f)
|
120
|
-
end
|
163
|
+
elements = self[start, PAGE_SIZE]
|
164
|
+
elements.each { |*x| yield(*x) }
|
121
165
|
start += elements.size
|
122
166
|
end
|
123
167
|
end
|
124
168
|
|
169
|
+
def [] start, num=nil
|
170
|
+
elements = @redis.zrange @q, start, start + (num || 0) - 1, :withscores => true
|
171
|
+
v = elements.each_slice(2).map do |item, at|
|
172
|
+
item =~ /^\d+:(\S+)$/ or raise InvalidEntryException, item
|
173
|
+
item = $1
|
174
|
+
[item, Time.at(at.to_f)]
|
175
|
+
end
|
176
|
+
num ? v : v.first
|
177
|
+
end
|
178
|
+
|
125
179
|
def size; @redis.zcard @q end
|
126
180
|
end
|
127
181
|
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: redis-scheduler
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: '0.
|
4
|
+
version: '0.4'
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-
|
12
|
+
date: 2012-05-12 00:00:00.000000000 Z
|
13
13
|
dependencies: []
|
14
14
|
description: A basic chronological scheduler for Redis. Add work items to be processed
|
15
15
|
at specific times in the future, and easily retrieve all items that are ready for
|