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.
Files changed (3) hide show
  1. data/README +22 -14
  2. data/lib/redis-scheduler.rb +104 -50
  3. metadata +2 -2
data/README CHANGED
@@ -1,20 +1,28 @@
1
- This is a basic chronological scheduler for Redis. It allows you to schedule
2
- items to be processed at arbitrary points in time (via #schedule!), and then
3
- to easily retrieve only those items that are due to be processed (via #each).
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
- Items are represented as strings.
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
- In non-blocking mode (the default), #each will iterate only over those work items
15
- whose scheduled time is less than or equal to the current time, and then stop.
16
- In blocking mode, #each will iterate over the same items, but will also block
17
- until items are available. In blocking mode, #each will never return.
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
 
@@ -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
- ## options:
8
- ## * +namespace+: prefix for redis data, e.g. "scheduler/"
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 the
12
- ## check-and-set semantics, i.e. block during contention from multiple
13
- ## clients. "Nonblocking" mode just refers to whether the scheduler
14
- ## should wait until events in the schedule are ready, or only return
15
- ## those items that are ready currently.
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
- @error_queue = [@namespace, "errorq"].join
42
+ @processing_set = [@namespace, "processing"].join
23
43
  @counter = [@namespace, "counter"].join
24
44
  end
25
45
 
26
- ## schedule an item at a specific time. item will be converted to a
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, @error_queue, @counter].each { |k| @redis.del k }
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
- def error_queue_size; @redis.llen @error_queue end
39
-
40
- ## yields items along with their scheduled times. only returns items
41
- ## on or after their scheduled times. items returned as strings. if
42
- ## @blocking is false, will stop once there are no more items that can
43
- ## be processed immediately; if it's true, will wait until items
44
- ## become available (and never terminate).
45
- def each
46
- while(x = get)
47
- item, erritem, at = x
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! erritem
82
+ cleanup! processing_descriptor
55
83
  end
56
84
  end
57
85
  end
58
86
 
59
- ## returns an Enumerable of [item, schedule time] pairs, which can be used to
60
- ## easily iterate over all the items in the queue, in order of earliest- to
61
- ## latest-scheduled. note that this view is not coordinated with write
62
- ## operations, and may be inconsistent (e.g. return duplicates, miss items,
63
- ## etc).
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
- ## for these reasons, this operation is mainly useful for debugging purposes.
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
- item, at = @redis.zrangebyscore @queue, 0, Time.now.to_f,
82
- :withscores => true, :limit => [0, 1]
83
- if item
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, item
86
- @redis.lpush @error_queue, item
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 =~ /^\d+:(\S+)$/ or raise InvalidEntryException, 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.lrem @error_queue, 1, item
143
+ @redis.srem @processing_set, item
100
144
  end
101
145
 
102
- ## enumerable for just iterating over everything in the queue
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
- BLOCK_SIZE = 10
159
+ PAGE_SIZE = 50
111
160
  def each
112
161
  start = 0
113
162
  while start < size
114
- elements = @redis.zrange @q, start, start + BLOCK_SIZE,
115
- :withscores => true
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.3'
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-04-29 00:00:00.000000000 Z
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