pigeon 0.3.0 → 0.4.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.
data/lib/pigeon/queue.rb CHANGED
@@ -1,66 +1,299 @@
1
1
  class Pigeon::Queue
2
2
  # == Constants ============================================================
3
+
4
+ # == Exceptions ===========================================================
3
5
 
4
- DEFAULT_CONCURRENCY_LIMIT = 24
6
+ class BlockRequired < Exception
7
+ end
8
+
9
+ class TaskNotQueued < Exception
10
+ def initialize(task = nil)
11
+ @task = task
12
+ end
13
+
14
+ def inspect
15
+ "Task #{@task.inspect} not queued."
16
+ end
17
+ alias_method :to_s, :inspect
18
+ end
5
19
 
6
- # == Properties ===========================================================
20
+ # == Extensions ===========================================================
7
21
 
8
- attr_reader :exceptions
22
+ # == Relationships ========================================================
23
+
24
+ # == Scopes ===============================================================
25
+
26
+ # == Callbacks ============================================================
27
+
28
+ # == Validations ==========================================================
9
29
 
10
30
  # == Class Methods ========================================================
31
+
32
+ def self.filters
33
+ @filters ||= {
34
+ nil => lambda { |task| true }
35
+ }
36
+ end
37
+
38
+ def self.filter(name, &block)
39
+ filters[name] = block
40
+ end
11
41
 
12
42
  # == Instance Methods =====================================================
13
-
14
- def initialize(limit = nil)
15
- @limit = limit || DEFAULT_CONCURRENCY_LIMIT
16
- @blocks = [ ]
17
- @threads = [ ]
18
- @exceptions = [ ]
43
+
44
+ def initialize(&block)
45
+ @filter_lock = Mutex.new
46
+ @observer_lock = Mutex.new
47
+
48
+ @claimable_task = { }
49
+ @filters = self.class.filters.dup
50
+ @observers = { }
51
+ @next_task = { }
52
+ @insert_backlog = [ ]
53
+
54
+ if (block_given?)
55
+ @sort_by = block
56
+ else
57
+ @sort_by = lambda { |a,b| a.priority <=> b.priority }
58
+ end
59
+
60
+ @tasks = Pigeon::SortedArray.new(&@sort_by)
19
61
  end
20
62
 
21
- def perform(*args, &block)
22
- @blocks << [ block, args, caller(0) ]
63
+ def sort_by(&block)
64
+ raise BlockRequired unless (block_given?)
23
65
 
24
- if (@threads.length < @limit and @threads.length < @blocks.length)
25
- create_thread
66
+ @sort_by = block
67
+ @filter_lock.synchronize do
68
+ @tasks = Pigeon::SortedArray.new(&@sort_by) + @tasks
69
+
70
+ @next_task = { }
26
71
  end
27
72
  end
28
73
 
29
- def empty?
30
- @blocks.empty? and @threads.empty?
74
+ def observe(filter_name = nil, &block)
75
+ raise BlockRequired unless (block_given?)
76
+
77
+ @observer_lock.synchronize do
78
+ @observers[filter_name] ||= [ ]
79
+ end
80
+
81
+ @observers[filter_name] << block
82
+
83
+ task = assign_next_task(filter_name)
31
84
  end
32
85
 
33
- def exceptions?
34
- !@exceptions.empty?
86
+ def filter(filter_name, &block)
87
+ raise BlockRequired unless (block_given?)
88
+
89
+ @filter_lock.synchronize do
90
+ @filters[filter_name] = block
91
+ end
92
+
93
+ assign_next_task(filter_name)
35
94
  end
95
+
96
+ def <<(task)
97
+ # If there is an insert operation already in progress, put this task in
98
+ # the backlog for subsequent processing.
99
+
100
+ if (@observer_lock.locked?)
101
+ @insert_backlog << task
102
+ return task
103
+ end
104
+
105
+ active_task = task
106
+
107
+ while (active_task) do
108
+ # Set the claimable task flag for this task since it is not yet in the
109
+ # actual task queue.
110
+ @filter_lock.synchronize do
111
+ @claimable_task[active_task] = true
112
+ end
113
+
114
+ unless (@observers.empty?)
115
+ @observer_lock.synchronize do
116
+ @observers.each do |filter_name, list|
117
+ # Check if this task matches the filter restrictions, and if it
118
+ # does then call the observer chain in order.
119
+ if (@filters[filter_name].call(active_task))
120
+ @observers[filter_name].each do |proc|
121
+ case (proc.arity)
122
+ when 2
123
+ proc.call(self, active_task)
124
+ else
125
+ proc.call(active_task)
126
+ end
127
+
128
+ # An observer callback has the opportunity to claim a task,
129
+ # and if it does, the claimable task flag will be false. Loop
130
+ # only while the task is claimable.
131
+ break unless (@claimable_task[active_task])
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ # If this task wasn't claimed by an observer then insert it in the
139
+ # main task queue.
140
+ if (@claimable_task.delete(active_task))
141
+ @filter_lock.synchronize do
142
+ @tasks << active_task
143
+
144
+ # Update the next task slots for all of the unassigned filters and
145
+ # trigger observer callbacks as required.
146
+ @next_task.each do |filter_name, next_task|
147
+ next if (next_task)
148
+
149
+ if (@filters[filter_name].call(active_task))
150
+ @next_task[filter_name] = active_task
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ active_task = @insert_backlog.shift
157
+ end
36
158
 
37
- def length
38
- @blocks.length
159
+ task
39
160
  end
40
161
 
41
- def threads
42
- @threads.length
162
+ def each
163
+ @filter_lock.synchronize do
164
+ tasks = @tasks.dup
165
+ end
166
+
167
+ tasks.each do
168
+ yield(task)
169
+ end
43
170
  end
44
171
 
45
- protected
46
- def create_thread
47
- @threads << Thread.new do
48
- Thread.current.abort_on_exception = true
172
+ def peek(filter_name = nil, &block)
173
+ if (block_given?)
174
+ @filter_lock.synchronize do
175
+ @tasks.find(&block)
176
+ end
177
+ else
178
+ @next_task[filter_name] ||= begin
179
+ @filter_lock.synchronize do
180
+ filter_proc = @filters[filter_name]
49
181
 
50
- begin
51
- while (block = @blocks.pop)
52
- begin
53
- block[0].call(*block[1])
54
- rescue Object => e
55
- puts "#{e.class}: #{e} #{e.backtrace.join("\n")}"
56
- @exceptions << e
182
+ filter_proc and @tasks.find(&filter_proc)
183
+ end
184
+ end
185
+ end
186
+ end
187
+
188
+ def pull(filter_name = nil, &block)
189
+ unless (block_given?)
190
+ block = @filters[filter_name]
191
+ end
192
+
193
+ @filter_lock.synchronize do
194
+ tasks = @tasks.select(&block)
195
+
196
+ @tasks -= tasks
197
+
198
+ @next_task.each do |filter_name, next_task|
199
+ if (tasks.include?(@next_task[filter_name]))
200
+ @next_task[filter_name] = nil
201
+ end
202
+ end
203
+
204
+ tasks
205
+ end
206
+ end
207
+
208
+ def pop(filter_name = nil, &block)
209
+ @filter_lock.synchronize do
210
+ task =
211
+ if (block_given?)
212
+ @tasks.find(&block)
213
+ else
214
+ @next_task[filter_name] || begin
215
+ filter_proc = @filters[filter_name]
216
+
217
+ filter_proc and @tasks.find(&filter_proc)
218
+ end
219
+ end
220
+
221
+ if (task)
222
+ @tasks.delete(task)
223
+
224
+ @next_task.each do |filter_name, next_task|
225
+ if (task == next_task)
226
+ @next_task[filter_name] = nil
227
+ end
228
+ end
229
+ end
230
+
231
+ task
232
+ end
233
+ end
234
+
235
+ def claim(task)
236
+ @filter_lock.synchronize do
237
+ if (@claimable_task[task])
238
+ @claimable_task[task] = false
239
+ elsif (@tasks.delete(task))
240
+ @next_task.each do |filter_name, next_task|
241
+ if (task == next_task)
242
+ @next_task[filter_name] = nil
57
243
  end
58
-
59
- Thread.pass
60
244
  end
61
- ensure
62
- @threads.delete(Thread.current)
245
+ else
246
+ raise TaskNotQueued, task
63
247
  end
64
248
  end
249
+
250
+ task
251
+ end
252
+
253
+ def exist?(task)
254
+ @filter_lock.synchronize do
255
+ @tasks.exist?(task)
256
+ end
257
+ end
258
+
259
+ def empty?(filter_name = nil, &block)
260
+ if (block_given?)
261
+ @filter_lock.synchronize do
262
+ !@tasks.find(&block)
263
+ end
264
+ else
265
+ !peek(filter_name)
266
+ end
267
+ end
268
+
269
+ def length(filter_name = nil, &block)
270
+ filter_proc = @filters[filter_name]
271
+
272
+ @filter_lock.synchronize do
273
+ filter_proc ? @tasks.count(&filter_proc) : nil
274
+ end
275
+ end
276
+ alias_method :size, :length
277
+ alias_method :count, :length
278
+
279
+ def to_a
280
+ @filter_lock.synchronize do
281
+ @tasks.dup
282
+ end
283
+ end
284
+
285
+ protected
286
+ def assign_next_task(filter_name)
287
+ filter = @filters[filter_name]
288
+
289
+ return unless (filter)
290
+
291
+ if (task = @next_task[filter_name])
292
+ return task
293
+ end
294
+
295
+ @filter_lock.synchronize do
296
+ @next_task[filter_name] ||= @tasks.find(&filter)
297
+ end
65
298
  end
66
299
  end
@@ -0,0 +1,101 @@
1
+ class Pigeon::Scheduler
2
+ # == Properties ===========================================================
3
+
4
+ attr_reader :queues
5
+ attr_reader :processors
6
+
7
+ # == Class Methods ========================================================
8
+
9
+ # == Instance Methods =====================================================
10
+
11
+ # Creates a new scheduler. If queue is specified, then that queue will
12
+ # become the default queue. One or more processors can be supplied to work
13
+ # with this queue, though they should be supplied bound to the queue in
14
+ # order to properly receive tasks.
15
+ def initialize(queue = nil, *processors)
16
+ @queues = {
17
+ nil => queue || Pigeon::Queue.new
18
+ }
19
+
20
+ processors.flatten!
21
+
22
+ @processors = processors.empty? ? [ Pigeon::Processor.new(@queues[nil]) ] : processors
23
+ end
24
+
25
+ # Adds one or more tasks to the schedule, where the tasks can be provided
26
+ # individually, as a list, or as an array.
27
+ def add(*tasks)
28
+ tasks.flatten.each do |task|
29
+ enqueue_task(task)
30
+ end
31
+ end
32
+
33
+ # Returns the default queue used for scheduling.
34
+ def default_queue
35
+ @queues[nil]
36
+ end
37
+
38
+ # Used to assign the default queue.
39
+ def default_queue=(queue)
40
+ @queues[nil] = queue
41
+ end
42
+
43
+ # Returns the queue with the given name if one is defined, nil otherwise.
44
+ def queue(queue_name)
45
+ @queues[queue_name]
46
+ end
47
+
48
+ # Sets the scheduler running.
49
+ def run!
50
+ @state = :running
51
+ end
52
+
53
+ # Pauses the scheduler which will prevent additional tasks from being
54
+ # initiated. Any tasks in progress will continue to run. Tasks can still
55
+ # be added but will not be executed until the scheduler is running.
56
+ def pause!
57
+ @state = :paused
58
+ end
59
+
60
+ # Stops the scheduler and clears out the queue. No new tasks will be
61
+ # accepted until the scheduler is in a paused or running state.
62
+ def stop!
63
+ @state = :stopped
64
+ end
65
+
66
+ # Returns true if the scheduler is running, false otherwise.
67
+ def running?
68
+ @state == :running
69
+ end
70
+
71
+ # Returns true if the scheduler is paused, false otherwise.
72
+ def paused?
73
+ @state == :paused
74
+ end
75
+
76
+ # Returns true if the scheduler is stopped, false otherwise.
77
+ def stopped?
78
+ @state == :stopped
79
+ end
80
+
81
+ # Returns the number of tasks that have been queued up.
82
+ def queue_length
83
+ @queues.inject(0) do |length, (name, queue)|
84
+ length + queue.length
85
+ end
86
+ end
87
+ alias_method :queue_size, :queue_length
88
+
89
+ # Returns the number of processors that are attached to this scheduler.
90
+ def processors_count
91
+ @processors.length
92
+ end
93
+
94
+ protected
95
+ # This method defines how to handle adding a task to the scheduler, which
96
+ # in this case simply puts it into the default queue. Subclasses should
97
+ # redefine this to organize tasks as required.
98
+ def enqueue_task(task)
99
+ default_queue << task
100
+ end
101
+ end
@@ -0,0 +1,56 @@
1
+ class Pigeon::SortedArray < Array
2
+ # == Exceptions ===========================================================
3
+
4
+ class SortArgumentRequired < Exception
5
+ end
6
+
7
+ # == Class Methods ========================================================
8
+
9
+ # == Instance Methods =====================================================
10
+
11
+ # Creates a new sorted array with an optional sort method supplied as
12
+ # a block. The sort method supplied should accept two arguments that are
13
+ # objects in the array to be compared and should return -1, 0, or 1 based
14
+ # on their sorting order. By default the comparison performed is <=>
15
+ def initialize(&sort_method)
16
+ sort_method ||= lambda { |a,b| a <=> b }
17
+
18
+ @sort_method =
19
+ case (sort_method && sort_method.arity)
20
+ when 2
21
+ sort_method
22
+ when 1
23
+ lambda { |a,b| sort_method.call(a) <=> sort_method.call(b) }
24
+ else
25
+ raise SortArgumentRequired
26
+ end
27
+ end
28
+
29
+ # Adds an object to the array by inserting it into the appropriate sorted
30
+ # location directly.
31
+ def <<(object)
32
+ low = 0
33
+ high = length
34
+
35
+ while (low != high)
36
+ mid = (high + low) / 2
37
+
38
+ comparison = @sort_method.call(object, at(mid))
39
+
40
+ if (comparison < 0)
41
+ high = mid
42
+ elsif (comparison > 0)
43
+ low = mid + 1
44
+ else
45
+ break
46
+ end
47
+ end
48
+
49
+ insert(low, object)
50
+ end
51
+
52
+ # Combines another array with this one and returns the sorted result.
53
+ def +(array)
54
+ self.class[*super(array).sort(&@sort_method)]
55
+ end
56
+ end
@@ -1,3 +1,5 @@
1
+ require 'digest/sha1'
2
+
1
3
  module Pigeon::Support
2
4
  # Uses the double-fork method to create a fully detached background
3
5
  # process. Returns the process ID of the created process. May throw an
@@ -30,6 +32,19 @@ module Pigeon::Support
30
32
  end
31
33
  end
32
34
 
35
+ # Returns a unique 160-bit identifier for this engine expressed as a 40
36
+ # character hexadecimal string. The first 32-bit sequence is a timestamp
37
+ # so these numbers increase over time and can be used to identify when
38
+ # a particular instance was launched.
39
+ def unique_id
40
+ '%8x%s' % [
41
+ Time.now.to_i,
42
+ Digest::SHA1.hexdigest(
43
+ '%.8f%8x' % [ Time.now.to_f, rand(1 << 32) ]
44
+ )[0, 32]
45
+ ]
46
+ end
47
+
33
48
  # Make all methods callable directly without having to include it
34
49
  extend self
35
50
  end
@@ -0,0 +1,188 @@
1
+ class Pigeon::Task
2
+ # == Constants ============================================================
3
+
4
+ # == Properties ===========================================================
5
+
6
+ attr_reader :state
7
+ attr_reader :engine
8
+ attr_reader :exception
9
+ attr_reader :created_at
10
+ attr_reader :started_at
11
+
12
+ # == Class Methods ========================================================
13
+
14
+ # Defines the initial state of this type of task. Default is :initialized
15
+ # but this can be customized in a subclass.
16
+ def self.initial_state
17
+ :initialized
18
+ end
19
+
20
+ # Returns an array of the terminal states for this task. Default is
21
+ # :failed, :finished but this can be customized in a subclass.
22
+ def self.terminal_states
23
+ @terminal_states ||= [ :failed, :finished ].freeze
24
+ end
25
+
26
+ # == Instance Methods =====================================================
27
+
28
+ def initialize(engine = nil)
29
+ @engine = engine || Pigeon::Engine.default_engine
30
+ @created_at = Time.now
31
+
32
+ after_initialized
33
+ end
34
+
35
+ # Kicks off the task. An optional callback is executed just before each
36
+ # state is excuted and is passed the state name as a symbol.
37
+ def run!(engine = nil, &callback)
38
+ @engine = engine if (engine)
39
+ @callback = callback if (block_given?)
40
+
41
+ @state = self.class.initial_state
42
+ @started_at = Time.now
43
+
44
+ run_state!(@state)
45
+ end
46
+
47
+ # Returns true if the task is in the finished state, false otherwise.
48
+ def finished?
49
+ @state == :finished
50
+ end
51
+
52
+ # Returns true if the task is in the failed state, false otherwise.
53
+ def failed?
54
+ @state == :failed
55
+ end
56
+
57
+ # Returns true if an exception was thrown, false otherwise.
58
+ def exception?
59
+ !!@exception
60
+ end
61
+
62
+ # Returns true if the task is in any terminal state.
63
+ def terminal_state?
64
+ self.class.terminal_states.include?(@state)
65
+ end
66
+
67
+ # Dispatches a block to be run as soon as possible.
68
+ def dispatch(&block)
69
+ @engine.dispatch(&block)
70
+ end
71
+
72
+ # Returns a numerical priority order. If redefined in a subclass,
73
+ # should return a comparable value.
74
+ def priority
75
+ @created_at
76
+ end
77
+
78
+ def inspect
79
+ "<#{self.class}\##{self.object_id}>"
80
+ end
81
+
82
+ def <=>(task)
83
+ self.priority <=> task.priority
84
+ end
85
+
86
+ protected
87
+ def run_state!(state)
88
+ # Grab the current state and save it here, as it may switch at any time
89
+ @state = state
90
+ terminate = self.class.terminal_states.include?(state)
91
+
92
+ before_state(state)
93
+
94
+ send_callback(state) if (@callback)
95
+
96
+ unless (terminate)
97
+ state_method = :"state_#{state}!"
98
+
99
+ # Only perform this state action if it is defined, otherwise ignore
100
+ # as some states may be deliberately NOOP in order to wait for some
101
+ # action to be completed asynchronously.
102
+ if (respond_to?(state_method))
103
+ send(state_method)
104
+ end
105
+ end
106
+
107
+ rescue Object => e
108
+ @exception = e
109
+
110
+ handle_exception(e) rescue nil
111
+
112
+ transition_to_state(:failed) unless (self.failed?)
113
+
114
+ self.after_failed
115
+ self.after_terminated
116
+ ensure
117
+ after_state(state)
118
+
119
+ if (terminate)
120
+ self.after_finished
121
+
122
+ # Send a final notification callback
123
+ if (@callback and @callback.arity == 0)
124
+ @callback.call
125
+ end
126
+
127
+ self.after_terminated
128
+ end
129
+ end
130
+
131
+ # Schedules the next state to be executed. This method should only be
132
+ # called once per state or it may result in duplicated state actions.
133
+ def transition_to_state(state)
134
+ @engine.dispatch do
135
+ run_state!(state)
136
+ end
137
+
138
+ state
139
+ end
140
+
141
+ def send_callback(state)
142
+ # State-notificaton callbacks are not made to blocks that do not take
143
+ # arguments, but instead a singe final callback is made.
144
+ case (@callback.arity)
145
+ when 2
146
+ @callback.call(self, state)
147
+ when 1
148
+ @callback.call(state)
149
+ end
150
+ end
151
+
152
+ # Called just after the task is initialized.
153
+ def after_initialized
154
+ end
155
+
156
+ # Called before a particular state is executed.
157
+ def before_state(state)
158
+ end
159
+
160
+ # Called after a particular state is executed.
161
+ def after_state(state)
162
+ end
163
+
164
+ # Called just after the task is finished.
165
+ def after_finished
166
+ end
167
+
168
+ # Called just after the task fails.
169
+ def after_failed
170
+ end
171
+
172
+ # Called after the task finishes or terminates.
173
+ def after_terminated
174
+ end
175
+
176
+ # Called when an exception is thrown during processing with the exception
177
+ # passed as the first argument. Default behavior is to do nothing but
178
+ # this can be customized in a subclass. Any exceptions thrown by this
179
+ # method are ignored.
180
+ def handle_exception(exception)
181
+ end
182
+
183
+ # This defines the behaivor of the intialized state. By default this
184
+ # simply transitions to the finished state.
185
+ def state_initialized!
186
+ transition_to_state(:finished)
187
+ end
188
+ end