pigeon 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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