girl_friday 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/.rvmrc +2 -0
- data/Gemfile +8 -0
- data/History.md +9 -0
- data/README.md +67 -0
- data/Rakefile +8 -0
- data/TODO.md +5 -0
- data/girl_friday.gemspec +19 -0
- data/lib/girl_friday/actor.rb +463 -0
- data/lib/girl_friday/error_handler.rb +22 -0
- data/lib/girl_friday/monkey_patches.rb +31 -0
- data/lib/girl_friday/persistence.rb +52 -0
- data/lib/girl_friday/version.rb +3 -0
- data/lib/girl_friday/work_queue.rb +145 -0
- data/lib/girl_friday.rb +52 -0
- data/test/helper.rb +24 -0
- data/test/test_girl_friday.rb +140 -0
- data/test/timed_queue.rb +48 -0
- metadata +87 -0
data/.gitignore
ADDED
data/.rvmrc
ADDED
data/Gemfile
ADDED
data/History.md
ADDED
data/README.md
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
girl_friday
|
2
|
+
====================
|
3
|
+
|
4
|
+
Have a task you want to get done sometime soon but don't want to do it yourself? Give it to girl_friday! From wikipedia:
|
5
|
+
|
6
|
+
> The term Man Friday has become an idiom, still in mainstream usage, to describe an especially faithful servant or
|
7
|
+
> one's best servant or right-hand man. The female equivalent is Girl Friday. The title of the movie His Girl Friday
|
8
|
+
> alludes to it and may have popularized it.
|
9
|
+
|
10
|
+
girl_friday is a Ruby library for performing asynchronous tasks. Often times you don't want to block a web response by performing some task, like sending an email, so you can just use this gem to perform it in the background. It works with any Ruby application, including Rails 3 applications.
|
11
|
+
|
12
|
+
|
13
|
+
Installation
|
14
|
+
------------------
|
15
|
+
|
16
|
+
We recommend using [JRuby 1.6+](http://jruby.org) or [Rubinius 2.0+](http://rubini.us) with girl_friday. Both are excellent options for executing Ruby these days.
|
17
|
+
|
18
|
+
gem install girl_friday
|
19
|
+
|
20
|
+
girl_friday does not support Ruby 1.8 (MRI) because of its poor threading support. Ruby 1.9 will work reasonably well if you use gems that release the GIL for network I/O (mysql2 is a good example of this, do **not** use the original mysql gem).
|
21
|
+
|
22
|
+
|
23
|
+
Usage
|
24
|
+
--------------------
|
25
|
+
|
26
|
+
Put girl_friday in your Gemfile:
|
27
|
+
|
28
|
+
gem 'girl_friday'
|
29
|
+
|
30
|
+
In your Rails app, create a `config/initializers/girl_friday.rb` which defines your queues:
|
31
|
+
|
32
|
+
EMAIL_QUEUE = GirlFriday::WorkQueue.new(:user_email, :size => 3) do |msg|
|
33
|
+
UserMailer.registration_email(msg).deliver
|
34
|
+
end
|
35
|
+
IMAGE_QUEUE = GirlFriday::WorkQueue.new(:image_crawler, :size => 7) do |msg|
|
36
|
+
ImageCrawler.process(msg)
|
37
|
+
end
|
38
|
+
|
39
|
+
:size is the number of workers to spin up and defaults to 5. Keep in mind, ActiveRecord defaults to a connection pool size of 5 so if your workers are accessing the database you'll want to ensure that the connection pool is large enough by modifying `config/database.yml`.
|
40
|
+
|
41
|
+
In your controller action or model, you can call `#push(msg)`
|
42
|
+
|
43
|
+
EMAIL_QUEUE.push(:email => @user.email, :name => @user.name)
|
44
|
+
|
45
|
+
The msg parameter to push is just a Hash whose contents are completely up to you.
|
46
|
+
|
47
|
+
Your message processing block should **not** access any instance data or variables outside of the block. That's shared mutable state and dangerous to touch! I also strongly recommend your queue processor block be **VERY** short, ideally just a method call or two. You can unit test those methods easily but not the processor block itself.
|
48
|
+
|
49
|
+
|
50
|
+
More Detail
|
51
|
+
--------------------
|
52
|
+
|
53
|
+
Please see the [girl_friday wiki](https://github.com/mperham/girl_friday/wiki) for more detail and advanced options and tuning. You'll find details on queue persistence with Redis, implementing clean shutdown, querying runtime metrics and SO MUCH MORE!
|
54
|
+
|
55
|
+
|
56
|
+
Thanks
|
57
|
+
--------------------
|
58
|
+
|
59
|
+
[Carbon Five](http://carbonfive.com), I write and maintain girl_friday on their clock.
|
60
|
+
|
61
|
+
This gem contains a copy of the Rubinius Actor API, modified to work on any Ruby VM. Thanks to Evan Phoenix, MenTaLguY and the Rubinius project for permission to use and distribute this code.
|
62
|
+
|
63
|
+
|
64
|
+
Author
|
65
|
+
--------------------
|
66
|
+
|
67
|
+
Mike Perham, [@mperham](https://twitter.com/mperham), [mikeperham.com](http://mikeperham.com)
|
data/Rakefile
ADDED
data/TODO.md
ADDED
data/girl_friday.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require "./lib/girl_friday/version"
|
3
|
+
|
4
|
+
Gem::Specification.new do |s|
|
5
|
+
s.name = "girl_friday"
|
6
|
+
s.version = GirlFriday::VERSION
|
7
|
+
s.platform = Gem::Platform::RUBY
|
8
|
+
s.authors = ["Mike Perham"]
|
9
|
+
s.email = ["mperham@gmail.com"]
|
10
|
+
s.homepage = "http://github.com/mperham/girl_friday"
|
11
|
+
s.summary = s.description = %q{Background processing, simplified}
|
12
|
+
|
13
|
+
s.rubyforge_project = "girl_friday"
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
end
|
@@ -0,0 +1,463 @@
|
|
1
|
+
# actor.rb - implementation of the actor model
|
2
|
+
#
|
3
|
+
# Copyright 2007-2008 MenTaLguY <mental@rydia.net>
|
4
|
+
#
|
5
|
+
# All rights reserved.
|
6
|
+
#
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
8
|
+
# modification, are permitted provided that the following conditions are met:
|
9
|
+
#
|
10
|
+
# * Redistributions of source code must retain the above copyright notice,
|
11
|
+
# thi slist of conditions and the following disclaimer.
|
12
|
+
# * Redistributions in binary form must reproduce the above copyright notice
|
13
|
+
# this list of conditions and the following disclaimer in the documentatio
|
14
|
+
# and/or other materials provided with the distribution.
|
15
|
+
# * Neither the name of the Evan Phoenix nor the names of its contributors
|
16
|
+
# may be used to endorse or promote products derived from this software
|
17
|
+
# without specific prior written permission.
|
18
|
+
#
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
30
|
+
|
31
|
+
require 'thread' # for Queue
|
32
|
+
|
33
|
+
class Actor
|
34
|
+
class DeadActorError < RuntimeError
|
35
|
+
attr_reader :actor
|
36
|
+
attr_reader :reason
|
37
|
+
def initialize(actor, reason)
|
38
|
+
super(reason)
|
39
|
+
@actor = actor
|
40
|
+
@reason = reason
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
ANY = Object.new
|
45
|
+
def ANY.===(other)
|
46
|
+
true
|
47
|
+
end
|
48
|
+
|
49
|
+
class << self
|
50
|
+
alias_method :private_new, :new
|
51
|
+
private :private_new
|
52
|
+
|
53
|
+
@@registered_lock = Queue.new
|
54
|
+
@@registered = {}
|
55
|
+
@@registered_lock << nil
|
56
|
+
|
57
|
+
def current
|
58
|
+
Thread.current[:__current_actor__] ||= private_new
|
59
|
+
end
|
60
|
+
|
61
|
+
# Spawn a new Actor that will run in its own thread
|
62
|
+
def spawn(*args, &block)
|
63
|
+
raise ArgumentError, "no block given" unless block
|
64
|
+
spawned = Queue.new
|
65
|
+
Thread.new do
|
66
|
+
private_new do |actor|
|
67
|
+
Thread.current[:__current_actor__] = actor
|
68
|
+
spawned << actor
|
69
|
+
block.call(*args)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
spawned.pop
|
73
|
+
end
|
74
|
+
alias_method :new, :spawn
|
75
|
+
|
76
|
+
# Atomically spawn an actor and link it to the current actor
|
77
|
+
def spawn_link(*args, &block)
|
78
|
+
current = self.current
|
79
|
+
link_complete = Queue.new
|
80
|
+
spawn do
|
81
|
+
begin
|
82
|
+
Actor.link(current)
|
83
|
+
ensure
|
84
|
+
link_complete << Actor.current
|
85
|
+
end
|
86
|
+
block.call(*args)
|
87
|
+
end
|
88
|
+
link_complete.pop
|
89
|
+
end
|
90
|
+
|
91
|
+
# Polls for exit notifications
|
92
|
+
def check_for_interrupt
|
93
|
+
current._check_for_interrupt
|
94
|
+
self
|
95
|
+
end
|
96
|
+
|
97
|
+
# Waits until a matching message is received in the current actor's
|
98
|
+
# mailbox, and executes the appropriate action. May be interrupted by
|
99
|
+
# exit notifications.
|
100
|
+
def receive #:yields: filter
|
101
|
+
filter = Filter.new
|
102
|
+
if block_given?
|
103
|
+
yield filter
|
104
|
+
else
|
105
|
+
filter.when(ANY) { |m| m }
|
106
|
+
end
|
107
|
+
current._receive(filter)
|
108
|
+
end
|
109
|
+
|
110
|
+
# Send a "fake" exit notification to another actor, as if the current
|
111
|
+
# actor had exited with +reason+
|
112
|
+
def send_exit(recipient, reason)
|
113
|
+
recipient.notify_exited(current, reason)
|
114
|
+
self
|
115
|
+
end
|
116
|
+
|
117
|
+
# Link the current Actor to another one.
|
118
|
+
def link(actor)
|
119
|
+
current = self.current
|
120
|
+
current.notify_link actor
|
121
|
+
actor.notify_link current
|
122
|
+
self
|
123
|
+
end
|
124
|
+
|
125
|
+
# Unlink the current Actor from another one
|
126
|
+
def unlink(actor)
|
127
|
+
current = self.current
|
128
|
+
current.notify_unlink actor
|
129
|
+
actor.notify_unlink current
|
130
|
+
self
|
131
|
+
end
|
132
|
+
|
133
|
+
# Actors trapping exit do not die when an error occurs in an Actor they
|
134
|
+
# are linked to. Instead the exit message is sent to their regular
|
135
|
+
# mailbox in the form [:exit, actor, reason]. This allows certain
|
136
|
+
# Actors to supervise sets of others and restart them in the event
|
137
|
+
# of an error. Setting the trap flag may be interrupted by pending
|
138
|
+
# exit notifications.
|
139
|
+
#
|
140
|
+
def trap_exit=(value)
|
141
|
+
current._trap_exit = value
|
142
|
+
self
|
143
|
+
end
|
144
|
+
|
145
|
+
# Is the Actor trapping exit?
|
146
|
+
def trap_exit
|
147
|
+
current._trap_exit
|
148
|
+
end
|
149
|
+
alias_method :trap_exit?, :trap_exit
|
150
|
+
|
151
|
+
# Lookup a locally named service
|
152
|
+
def lookup(name)
|
153
|
+
raise ArgumentError, "name must be a symbol" unless Symbol === name
|
154
|
+
@@registered_lock.pop
|
155
|
+
begin
|
156
|
+
@@registered[name]
|
157
|
+
ensure
|
158
|
+
@@registered_lock << nil
|
159
|
+
end
|
160
|
+
end
|
161
|
+
alias_method :[], :lookup
|
162
|
+
|
163
|
+
# Register an Actor locally as a named service
|
164
|
+
def register(name, actor)
|
165
|
+
raise ArgumentError, "name must be a symbol" unless Symbol === name
|
166
|
+
unless actor.nil? or actor.is_a?(Actor)
|
167
|
+
raise ArgumentError, "only actors may be registered"
|
168
|
+
end
|
169
|
+
|
170
|
+
@@registered_lock.pop
|
171
|
+
begin
|
172
|
+
if actor.nil?
|
173
|
+
@@registered.delete(name)
|
174
|
+
else
|
175
|
+
@@registered[name] = actor
|
176
|
+
end
|
177
|
+
ensure
|
178
|
+
@@registered_lock << nil
|
179
|
+
end
|
180
|
+
end
|
181
|
+
alias_method :[]=, :register
|
182
|
+
|
183
|
+
def _unregister(actor) #:nodoc:
|
184
|
+
@@registered_lock.pop
|
185
|
+
begin
|
186
|
+
@@registered.delete_if { |n, a| actor.equal? a }
|
187
|
+
ensure
|
188
|
+
@@registered_lock << nil
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def initialize
|
194
|
+
@lock = Queue.new
|
195
|
+
|
196
|
+
@filter = nil
|
197
|
+
@ready = Queue.new
|
198
|
+
@action = nil
|
199
|
+
@message = nil
|
200
|
+
|
201
|
+
@mailbox = []
|
202
|
+
@interrupts = []
|
203
|
+
@links = []
|
204
|
+
@alive = true
|
205
|
+
@exit_reason = nil
|
206
|
+
@trap_exit = false
|
207
|
+
@thread = Thread.current
|
208
|
+
|
209
|
+
@lock << nil
|
210
|
+
|
211
|
+
if block_given?
|
212
|
+
watchdog { yield self }
|
213
|
+
else
|
214
|
+
Thread.new { watchdog { @thread.join } }
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def send(message)
|
219
|
+
@lock.pop
|
220
|
+
begin
|
221
|
+
return self unless @alive
|
222
|
+
if @filter
|
223
|
+
@action = @filter.action_for(message)
|
224
|
+
if @action
|
225
|
+
@filter = nil
|
226
|
+
@message = message
|
227
|
+
@ready << nil
|
228
|
+
else
|
229
|
+
@mailbox << message
|
230
|
+
end
|
231
|
+
else
|
232
|
+
@mailbox << message
|
233
|
+
end
|
234
|
+
ensure
|
235
|
+
@lock << nil
|
236
|
+
end
|
237
|
+
self
|
238
|
+
end
|
239
|
+
alias_method :<<, :send
|
240
|
+
|
241
|
+
def _check_for_interrupt #:nodoc:
|
242
|
+
check_thread
|
243
|
+
@lock.pop
|
244
|
+
begin
|
245
|
+
raise @interrupts.shift unless @interrupts.empty?
|
246
|
+
ensure
|
247
|
+
@lock << nil
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
def _receive(filter) #:nodoc:
|
252
|
+
check_thread
|
253
|
+
|
254
|
+
action = nil
|
255
|
+
message = nil
|
256
|
+
timed_out = false
|
257
|
+
|
258
|
+
@lock.pop
|
259
|
+
begin
|
260
|
+
raise @interrupts.shift unless @interrupts.empty?
|
261
|
+
|
262
|
+
for i in 0...(@mailbox.size)
|
263
|
+
message = @mailbox[i]
|
264
|
+
action = filter.action_for(message)
|
265
|
+
if action
|
266
|
+
@mailbox.delete_at(i)
|
267
|
+
break
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
unless action
|
272
|
+
@filter = filter
|
273
|
+
@lock << nil
|
274
|
+
begin
|
275
|
+
if filter.timeout?
|
276
|
+
timed_out = @ready.receive_timeout(filter.timeout) == false # TODO Broken!
|
277
|
+
else
|
278
|
+
@ready.pop
|
279
|
+
end
|
280
|
+
ensure
|
281
|
+
@lock.pop
|
282
|
+
end
|
283
|
+
|
284
|
+
if !timed_out and @interrupts.empty?
|
285
|
+
action = @action
|
286
|
+
message = @message
|
287
|
+
else
|
288
|
+
@mailbox << @message if @action
|
289
|
+
end
|
290
|
+
|
291
|
+
@action = nil
|
292
|
+
@message = nil
|
293
|
+
|
294
|
+
raise @interrupts.shift unless @interrupts.empty?
|
295
|
+
end
|
296
|
+
ensure
|
297
|
+
@lock << nil
|
298
|
+
end
|
299
|
+
|
300
|
+
if timed_out
|
301
|
+
filter.timeout_action.call
|
302
|
+
else
|
303
|
+
action.call message
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
# Notify this actor that it's now linked to the given one; this is not
|
308
|
+
# intended to be used directly except by actor implementations. Most
|
309
|
+
# users will want to use Actor.link instead.
|
310
|
+
#
|
311
|
+
def notify_link(actor)
|
312
|
+
@lock.pop
|
313
|
+
alive = nil
|
314
|
+
exit_reason = nil
|
315
|
+
begin
|
316
|
+
alive = @alive
|
317
|
+
exit_reason = @exit_reason
|
318
|
+
@links << actor if alive and not @links.include? actor
|
319
|
+
ensure
|
320
|
+
@lock << nil
|
321
|
+
end
|
322
|
+
actor.notify_exited(self, exit_reason) unless alive
|
323
|
+
self
|
324
|
+
end
|
325
|
+
|
326
|
+
# Notify this actor that it's now unlinked from the given one; this is
|
327
|
+
# not intended to be used directly except by actor implementations. Most
|
328
|
+
# users will want to use Actor.unlink instead.
|
329
|
+
#
|
330
|
+
def notify_unlink(actor)
|
331
|
+
@lock.pop
|
332
|
+
begin
|
333
|
+
return self unless @alive
|
334
|
+
@links.delete(actor)
|
335
|
+
ensure
|
336
|
+
@lock << nil
|
337
|
+
end
|
338
|
+
self
|
339
|
+
end
|
340
|
+
|
341
|
+
# Notify this actor that one of the Actors it's linked to has exited;
|
342
|
+
# this is not intended to be used directly except by actor implementations.
|
343
|
+
# Most users will want to use Actor.send_exit instead.
|
344
|
+
#
|
345
|
+
def notify_exited(actor, reason)
|
346
|
+
exit_message = nil
|
347
|
+
@lock.pop
|
348
|
+
begin
|
349
|
+
return self unless @alive
|
350
|
+
@links.delete(actor)
|
351
|
+
if @trap_exit
|
352
|
+
exit_message = DeadActorError.new(actor, reason)
|
353
|
+
elsif reason
|
354
|
+
@interrupts << DeadActorError.new(actor, reason)
|
355
|
+
if @filter
|
356
|
+
@filter = nil
|
357
|
+
@ready << nil
|
358
|
+
end
|
359
|
+
end
|
360
|
+
ensure
|
361
|
+
@lock << nil
|
362
|
+
end
|
363
|
+
send exit_message if exit_message
|
364
|
+
self
|
365
|
+
end
|
366
|
+
|
367
|
+
def watchdog
|
368
|
+
reason = nil
|
369
|
+
begin
|
370
|
+
yield
|
371
|
+
rescue Exception => reason
|
372
|
+
ensure
|
373
|
+
links = nil
|
374
|
+
Actor._unregister(self)
|
375
|
+
@lock.pop
|
376
|
+
begin
|
377
|
+
@alive = false
|
378
|
+
@mailbox = nil
|
379
|
+
@interrupts = nil
|
380
|
+
@exit_reason = reason
|
381
|
+
links = @links
|
382
|
+
@links = nil
|
383
|
+
ensure
|
384
|
+
@lock << nil
|
385
|
+
end
|
386
|
+
links.each do |actor|
|
387
|
+
begin
|
388
|
+
actor.notify_exited(self, reason)
|
389
|
+
rescue Exception
|
390
|
+
end
|
391
|
+
end
|
392
|
+
end
|
393
|
+
end
|
394
|
+
private :watchdog
|
395
|
+
|
396
|
+
def check_thread
|
397
|
+
unless Thread.current == @thread
|
398
|
+
raise ThreadError, "illegal cross-actor call"
|
399
|
+
end
|
400
|
+
end
|
401
|
+
private :check_thread
|
402
|
+
|
403
|
+
def _trap_exit=(value) #:nodoc:
|
404
|
+
check_thread
|
405
|
+
@lock.pop
|
406
|
+
begin
|
407
|
+
raise @interrupts.shift unless @interrupts.empty?
|
408
|
+
@trap_exit = !!value
|
409
|
+
ensure
|
410
|
+
@lock << nil
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
def _trap_exit #:nodoc:
|
415
|
+
check_thread
|
416
|
+
@lock.pop
|
417
|
+
begin
|
418
|
+
@trap_exit
|
419
|
+
ensure
|
420
|
+
@lock << nil
|
421
|
+
end
|
422
|
+
end
|
423
|
+
end
|
424
|
+
|
425
|
+
|
426
|
+
class Actor
|
427
|
+
class Filter
|
428
|
+
attr_reader :timeout
|
429
|
+
attr_reader :timeout_action
|
430
|
+
|
431
|
+
def initialize
|
432
|
+
@pairs = []
|
433
|
+
@timeout = nil
|
434
|
+
@timeout_action = nil
|
435
|
+
end
|
436
|
+
|
437
|
+
def timeout?
|
438
|
+
not @timeout.nil?
|
439
|
+
end
|
440
|
+
|
441
|
+
def when(pattern, &action)
|
442
|
+
raise ArgumentError, "no block given" unless action
|
443
|
+
@pairs.push [pattern, action]
|
444
|
+
self
|
445
|
+
end
|
446
|
+
|
447
|
+
def after(seconds, &action)
|
448
|
+
raise ArgumentError, "no block given" unless action
|
449
|
+
|
450
|
+
seconds = seconds.to_f
|
451
|
+
if !@timeout or seconds < @timeout
|
452
|
+
@timeout = seconds
|
453
|
+
@timeout_action = action
|
454
|
+
end
|
455
|
+
self
|
456
|
+
end
|
457
|
+
|
458
|
+
def action_for(value)
|
459
|
+
pair = @pairs.find { |pattern, action| pattern === value }
|
460
|
+
pair ? pair.last : nil
|
461
|
+
end
|
462
|
+
end
|
463
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module GirlFriday
|
2
|
+
class ErrorHandler
|
3
|
+
def handle(ex)
|
4
|
+
$stderr.puts(ex)
|
5
|
+
$stderr.puts(ex.backtrace.join("\n"))
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.default
|
9
|
+
defined?(HoptoadNotifier) ? Hoptoad : self
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module GirlFriday
|
15
|
+
class ErrorHandler
|
16
|
+
class Hoptoad
|
17
|
+
def handle(ex)
|
18
|
+
HoptoadNotifier.notify(ex)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
if RUBY_ENGINE == 'rbx' && (Rubinius::VERSION < '1.2.4' || Rubinius::VERSION == '1.2.4dev')
|
2
|
+
puts "Loading rubinius actor monkeypatches" if $testing
|
3
|
+
class Actor
|
4
|
+
|
5
|
+
# Monkeypatch so this works with Rubinius 1.2.3 (latest).
|
6
|
+
# 1.2.4 should have the necessary fix included.
|
7
|
+
def notify_exited(actor, reason)
|
8
|
+
exit_message = nil
|
9
|
+
@lock.receive
|
10
|
+
begin
|
11
|
+
return self unless @alive
|
12
|
+
@links.delete(actor)
|
13
|
+
if @trap_exit
|
14
|
+
exit_message = DeadActorError.new(actor, reason)
|
15
|
+
elsif reason
|
16
|
+
@interrupts << DeadActorError.new(actor, reason)
|
17
|
+
if @filter
|
18
|
+
@filter = nil
|
19
|
+
@ready << nil
|
20
|
+
end
|
21
|
+
end
|
22
|
+
ensure
|
23
|
+
@lock << nil
|
24
|
+
end
|
25
|
+
send exit_message if exit_message
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module GirlFriday
|
2
|
+
module Store
|
3
|
+
|
4
|
+
class InMemory
|
5
|
+
def initialize(name, options)
|
6
|
+
@backlog = []
|
7
|
+
end
|
8
|
+
|
9
|
+
def push(work)
|
10
|
+
@backlog << work
|
11
|
+
end
|
12
|
+
alias_method :<<, :push
|
13
|
+
|
14
|
+
def pop
|
15
|
+
@backlog.pop
|
16
|
+
end
|
17
|
+
|
18
|
+
def size
|
19
|
+
@backlog.size
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class Redis
|
24
|
+
def initialize(name, options)
|
25
|
+
@opts = options
|
26
|
+
@key = "girl_friday-#{name}"
|
27
|
+
end
|
28
|
+
|
29
|
+
def push(work)
|
30
|
+
val = Marshal.dump(work)
|
31
|
+
redis.rpush(@key, val)
|
32
|
+
end
|
33
|
+
alias_method :<<, :push
|
34
|
+
|
35
|
+
def pop
|
36
|
+
val = redis.lpop(@key)
|
37
|
+
Marshal.load(val) if val
|
38
|
+
end
|
39
|
+
|
40
|
+
def size
|
41
|
+
redis.llen(@key)
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def redis
|
47
|
+
@redis ||= ::Redis.new(*@opts)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
@@ -0,0 +1,145 @@
|
|
1
|
+
module GirlFriday
|
2
|
+
|
3
|
+
class WorkQueue
|
4
|
+
Ready = Struct.new(:this)
|
5
|
+
Work = Struct.new(:msg, :callback)
|
6
|
+
Shutdown = Struct.new(:callback)
|
7
|
+
|
8
|
+
attr_reader :name
|
9
|
+
def initialize(name, options={}, &block)
|
10
|
+
@name = name
|
11
|
+
@size = options[:size] || 5
|
12
|
+
@processor = block
|
13
|
+
@error_handler = (options[:error_handler] || ErrorHandler.default).new
|
14
|
+
|
15
|
+
@shutdown = false
|
16
|
+
@ready_workers = []
|
17
|
+
@busy_workers = []
|
18
|
+
@created_at = Time.now.to_i
|
19
|
+
@total_processed = @total_errors = @total_queued = 0
|
20
|
+
@persister = (options[:store] || Store::InMemory).new(name, (options[:store_config] || []))
|
21
|
+
start
|
22
|
+
end
|
23
|
+
|
24
|
+
def push(work, &block)
|
25
|
+
@supervisor << Work[work, block]
|
26
|
+
end
|
27
|
+
alias_method :<<, :push
|
28
|
+
|
29
|
+
def status
|
30
|
+
{ @name => {
|
31
|
+
:pid => $$,
|
32
|
+
:pool_size => @size,
|
33
|
+
:ready => @ready_workers.size,
|
34
|
+
:busy => @busy_workers.size,
|
35
|
+
:backlog => @persister.size,
|
36
|
+
:total_queued => @total_queued,
|
37
|
+
:total_processed => @total_processed,
|
38
|
+
:total_errors => @total_errors,
|
39
|
+
:uptime => Time.now.to_i - @created_at,
|
40
|
+
:created_at => @created_at,
|
41
|
+
}
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
def shutdown
|
46
|
+
# Runtime state should never be modified by caller thread,
|
47
|
+
# only the Supervisor thread.
|
48
|
+
@supervisor << Shutdown[block_given? ? Proc.new : nil]
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def on_ready(who)
|
54
|
+
@total_processed += 1
|
55
|
+
if !@shutdown && work = @persister.pop
|
56
|
+
who.this << work
|
57
|
+
drain(@ready_workers, @persister)
|
58
|
+
else
|
59
|
+
@busy_workers.delete(who.this)
|
60
|
+
@ready_workers << who.this
|
61
|
+
shutdown_complete if @shutdown && @busy_workers.size == 0
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def shutdown_complete
|
66
|
+
begin
|
67
|
+
@when_shutdown.call(self) if @when_shutdown
|
68
|
+
rescue Exception => ex
|
69
|
+
@error_handler.handle(ex)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def on_work(work)
|
74
|
+
@total_queued += 1
|
75
|
+
if !@shutdown && worker = @ready_workers.pop
|
76
|
+
@busy_workers << worker
|
77
|
+
worker << work
|
78
|
+
drain(@ready_workers, @persister)
|
79
|
+
else
|
80
|
+
@persister << work
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def start
|
85
|
+
@supervisor = Actor.spawn do
|
86
|
+
supervisor = Actor.current
|
87
|
+
work_loop = Proc.new do
|
88
|
+
loop do
|
89
|
+
work = Actor.receive
|
90
|
+
result = @processor.call(work.msg)
|
91
|
+
work.callback.call(result) if work.callback
|
92
|
+
supervisor << Ready[Actor.current]
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
Actor.trap_exit = true
|
97
|
+
@size.times do |x|
|
98
|
+
# start N workers
|
99
|
+
@ready_workers << Actor.spawn_link(&work_loop)
|
100
|
+
end
|
101
|
+
|
102
|
+
begin
|
103
|
+
loop do
|
104
|
+
Actor.receive do |f|
|
105
|
+
f.when(Ready) do |who|
|
106
|
+
on_ready(who)
|
107
|
+
end
|
108
|
+
f.when(Work) do |work|
|
109
|
+
on_work(work)
|
110
|
+
end
|
111
|
+
f.when(Shutdown) do |stop|
|
112
|
+
@shutdown = true
|
113
|
+
@when_shutdown = stop.callback
|
114
|
+
shutdown_complete if @shutdown && @busy_workers.size == 0
|
115
|
+
end
|
116
|
+
f.when(Actor::DeadActorError) do |exit|
|
117
|
+
# TODO Provide current message contents as error context
|
118
|
+
@total_errors += 1
|
119
|
+
@busy_workers.delete(exit.actor)
|
120
|
+
@ready_workers << Actor.spawn_link(&work_loop)
|
121
|
+
@error_handler.handle(exit.reason)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
rescue Exception => ex
|
127
|
+
$stderr.print "Fatal error in girl_friday: supervisor for #{name} died.\n"
|
128
|
+
$stderr.print("#{ex}\n")
|
129
|
+
$stderr.print("#{ex.backtrace.join("\n")}\n")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def drain(ready, work)
|
135
|
+
# give as much work to as many ready workers as possible
|
136
|
+
todo = ready.size < work.size ? ready.size : work.size
|
137
|
+
todo.times do
|
138
|
+
worker = ready.pop
|
139
|
+
@busy_workers << worker
|
140
|
+
worker << work.pop
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
end
|
145
|
+
end
|
data/lib/girl_friday.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'thread'
|
2
|
+
begin
|
3
|
+
# Rubinius
|
4
|
+
require 'actor'
|
5
|
+
require 'girl_friday/monkey_patches'
|
6
|
+
rescue LoadError
|
7
|
+
# Others
|
8
|
+
require 'girl_friday/actor'
|
9
|
+
end
|
10
|
+
|
11
|
+
require 'girl_friday/version'
|
12
|
+
require 'girl_friday/work_queue'
|
13
|
+
require 'girl_friday/error_handler'
|
14
|
+
require 'girl_friday/persistence'
|
15
|
+
|
16
|
+
module GirlFriday
|
17
|
+
|
18
|
+
def self.status
|
19
|
+
ObjectSpace.each_object(WorkQueue).inject({}) { |memo, queue| memo.merge(queue.status) }
|
20
|
+
end
|
21
|
+
|
22
|
+
##
|
23
|
+
# Notify girl_friday to shutdown ASAP. Workers will not pick up any
|
24
|
+
# new work; any new work pushed onto the queues will be pushed onto the
|
25
|
+
# backlog (and persisted). This method will block until all queues are
|
26
|
+
# quiet or the timeout has passed.
|
27
|
+
#
|
28
|
+
# Note that shutdown! just works with existing queues. If you create a
|
29
|
+
# new queue, it will act as normal.
|
30
|
+
def self.shutdown!(timeout=30)
|
31
|
+
queues = []
|
32
|
+
ObjectSpace.each_object(WorkQueue).each { |q| queues << q }
|
33
|
+
count = queues.size
|
34
|
+
m = Mutex.new
|
35
|
+
var = ConditionVariable.new
|
36
|
+
|
37
|
+
queues.each do |q|
|
38
|
+
q.shutdown do |queue|
|
39
|
+
m.synchronize do
|
40
|
+
count -= 1
|
41
|
+
var.signal if count == 0
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
m.synchronize do
|
47
|
+
var.wait(m, timeout)
|
48
|
+
end
|
49
|
+
count
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
$testing = true
|
2
|
+
|
3
|
+
# require 'simplecov'
|
4
|
+
# SimpleCov.start do
|
5
|
+
# add_filter "/actor.rb"
|
6
|
+
# end
|
7
|
+
|
8
|
+
# rbx is 1.8-mode for another month...
|
9
|
+
require 'rubygems'
|
10
|
+
require 'minitest/autorun'
|
11
|
+
require 'timed_queue'
|
12
|
+
require 'girl_friday'
|
13
|
+
|
14
|
+
puts RUBY_DESCRIPTION
|
15
|
+
|
16
|
+
class MiniTest::Unit::TestCase
|
17
|
+
|
18
|
+
def async_test(time=0.5)
|
19
|
+
q = TimedQueue.new
|
20
|
+
yield Proc.new { q << nil }
|
21
|
+
q.timed_pop(time)
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
class TestGirlFriday < MiniTest::Unit::TestCase
|
4
|
+
|
5
|
+
class TestErrorHandler
|
6
|
+
include MiniTest::Assertions
|
7
|
+
end
|
8
|
+
|
9
|
+
def test_should_process_messages
|
10
|
+
async_test do |cb|
|
11
|
+
queue = GirlFriday::WorkQueue.new('test') do |msg|
|
12
|
+
assert_equal 'foo', msg[:text]
|
13
|
+
cb.call
|
14
|
+
end
|
15
|
+
queue.push(:text => 'foo')
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_should_handle_worker_error
|
20
|
+
async_test do |cb|
|
21
|
+
TestErrorHandler.send(:define_method, :handle) do |ex|
|
22
|
+
assert_equal 'oops', ex.message
|
23
|
+
assert_equal 'RuntimeError', ex.class.name
|
24
|
+
cb.call
|
25
|
+
end
|
26
|
+
|
27
|
+
queue = GirlFriday::WorkQueue.new('test', :error_handler => TestErrorHandler) do |msg|
|
28
|
+
raise 'oops'
|
29
|
+
end
|
30
|
+
queue.push(:text => 'foo')
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_should_call_callback_when_complete
|
35
|
+
async_test do |cb|
|
36
|
+
queue = GirlFriday::WorkQueue.new('test', :size => 1) do |msg|
|
37
|
+
assert_equal 'foo', msg[:text]
|
38
|
+
'camel'
|
39
|
+
end
|
40
|
+
queue.push(:text => 'foo') do |result|
|
41
|
+
assert_equal 'camel', result
|
42
|
+
cb.call
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_should_provide_status
|
48
|
+
mutex = Mutex.new
|
49
|
+
total = 200
|
50
|
+
count = 0
|
51
|
+
incr = Proc.new do
|
52
|
+
mutex.synchronize do
|
53
|
+
count += 1
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
async_test do |cb|
|
58
|
+
count = 0
|
59
|
+
queue = GirlFriday::WorkQueue.new('image_crawler', :size => 3) do |msg|
|
60
|
+
incr.call
|
61
|
+
cb.call if count == total
|
62
|
+
end
|
63
|
+
total.times do |idx|
|
64
|
+
queue.push(:text => 'foo')
|
65
|
+
end
|
66
|
+
|
67
|
+
sleep 0.01
|
68
|
+
actual = GirlFriday.status
|
69
|
+
refute_nil actual
|
70
|
+
refute_nil actual['image_crawler']
|
71
|
+
metrics = actual['image_crawler']
|
72
|
+
assert_equal total, metrics[:total_queued]
|
73
|
+
assert_equal 3, metrics[:pool_size]
|
74
|
+
assert_equal 3, metrics[:busy]
|
75
|
+
assert_equal 0, metrics[:ready]
|
76
|
+
assert(metrics[:backlog] > 0)
|
77
|
+
assert(metrics[:total_processed] > 0)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def test_should_persist_with_redis
|
82
|
+
begin
|
83
|
+
require 'redis'
|
84
|
+
redis = Redis.new
|
85
|
+
redis.flushdb
|
86
|
+
rescue LoadError
|
87
|
+
return puts "Skipping redis test, 'redis' gem not found: #{$!.message}"
|
88
|
+
rescue Errno::ECONNREFUSED
|
89
|
+
return puts 'Skipping redis test, not running locally'
|
90
|
+
end
|
91
|
+
|
92
|
+
mutex = Mutex.new
|
93
|
+
total = 100
|
94
|
+
count = 0
|
95
|
+
incr = Proc.new do
|
96
|
+
mutex.synchronize do
|
97
|
+
count += 1
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
async_test do |cb|
|
102
|
+
queue = GirlFriday::WorkQueue.new('test', :size => 2, :store => GirlFriday::Store::Redis) do |msg|
|
103
|
+
incr.call
|
104
|
+
cb.call if count == total
|
105
|
+
end
|
106
|
+
total.times do
|
107
|
+
queue.push(:text => 'foo')
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def test_should_allow_graceful_shutdown
|
113
|
+
mutex = Mutex.new
|
114
|
+
total = 100
|
115
|
+
count = 0
|
116
|
+
incr = Proc.new do
|
117
|
+
mutex.synchronize do
|
118
|
+
count += 1
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
async_test do |cb|
|
123
|
+
queue = GirlFriday::WorkQueue.new('shutdown', :size => 2) do |msg|
|
124
|
+
incr.call
|
125
|
+
cb.call if count == total
|
126
|
+
end
|
127
|
+
total.times do
|
128
|
+
queue.push(:text => 'foo')
|
129
|
+
end
|
130
|
+
|
131
|
+
GirlFriday.shutdown!
|
132
|
+
s = queue.status
|
133
|
+
assert_equal 0, s['shutdown'][:busy]
|
134
|
+
assert_equal 2, s['shutdown'][:ready]
|
135
|
+
assert(s['shutdown'][:backlog] > 0)
|
136
|
+
cb.call
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
end
|
data/test/timed_queue.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'timeout'
|
3
|
+
|
4
|
+
class TimedQueue
|
5
|
+
def initialize
|
6
|
+
@que = []
|
7
|
+
@waiting = []
|
8
|
+
@mutex = Mutex.new
|
9
|
+
@resource = ConditionVariable.new
|
10
|
+
end
|
11
|
+
|
12
|
+
def push(obj)
|
13
|
+
@mutex.synchronize do
|
14
|
+
@que.push obj
|
15
|
+
@resource.signal
|
16
|
+
end
|
17
|
+
end
|
18
|
+
alias << push
|
19
|
+
|
20
|
+
def timed_pop(timeout=0.5)
|
21
|
+
while true
|
22
|
+
@mutex.synchronize do
|
23
|
+
@waiting.delete(Thread.current)
|
24
|
+
if @que.empty?
|
25
|
+
@waiting.push Thread.current
|
26
|
+
@resource.wait(@mutex, timeout)
|
27
|
+
raise TimeoutError if @que.empty?
|
28
|
+
else
|
29
|
+
retval = @que.shift
|
30
|
+
@resource.signal
|
31
|
+
return retval
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def empty?
|
38
|
+
@que.empty?
|
39
|
+
end
|
40
|
+
|
41
|
+
def clear
|
42
|
+
@que.clear
|
43
|
+
end
|
44
|
+
|
45
|
+
def length
|
46
|
+
@que.length
|
47
|
+
end
|
48
|
+
end
|
metadata
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: girl_friday
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 59
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 9
|
9
|
+
- 0
|
10
|
+
version: 0.9.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Mike Perham
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2011-04-20 00:00:00 -07:00
|
19
|
+
default_executable:
|
20
|
+
dependencies: []
|
21
|
+
|
22
|
+
description: Background processing, simplified
|
23
|
+
email:
|
24
|
+
- mperham@gmail.com
|
25
|
+
executables: []
|
26
|
+
|
27
|
+
extensions: []
|
28
|
+
|
29
|
+
extra_rdoc_files: []
|
30
|
+
|
31
|
+
files:
|
32
|
+
- .gitignore
|
33
|
+
- .rvmrc
|
34
|
+
- Gemfile
|
35
|
+
- History.md
|
36
|
+
- README.md
|
37
|
+
- Rakefile
|
38
|
+
- TODO.md
|
39
|
+
- girl_friday.gemspec
|
40
|
+
- lib/girl_friday.rb
|
41
|
+
- lib/girl_friday/actor.rb
|
42
|
+
- lib/girl_friday/error_handler.rb
|
43
|
+
- lib/girl_friday/monkey_patches.rb
|
44
|
+
- lib/girl_friday/persistence.rb
|
45
|
+
- lib/girl_friday/version.rb
|
46
|
+
- lib/girl_friday/work_queue.rb
|
47
|
+
- test/helper.rb
|
48
|
+
- test/test_girl_friday.rb
|
49
|
+
- test/timed_queue.rb
|
50
|
+
has_rdoc: true
|
51
|
+
homepage: http://github.com/mperham/girl_friday
|
52
|
+
licenses: []
|
53
|
+
|
54
|
+
post_install_message:
|
55
|
+
rdoc_options: []
|
56
|
+
|
57
|
+
require_paths:
|
58
|
+
- lib
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
60
|
+
none: false
|
61
|
+
requirements:
|
62
|
+
- - ">="
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
hash: 3
|
65
|
+
segments:
|
66
|
+
- 0
|
67
|
+
version: "0"
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
69
|
+
none: false
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
hash: 3
|
74
|
+
segments:
|
75
|
+
- 0
|
76
|
+
version: "0"
|
77
|
+
requirements: []
|
78
|
+
|
79
|
+
rubyforge_project: girl_friday
|
80
|
+
rubygems_version: 1.5.2
|
81
|
+
signing_key:
|
82
|
+
specification_version: 3
|
83
|
+
summary: Background processing, simplified
|
84
|
+
test_files:
|
85
|
+
- test/helper.rb
|
86
|
+
- test/test_girl_friday.rb
|
87
|
+
- test/timed_queue.rb
|