girl_friday 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/.rvmrc ADDED
@@ -0,0 +1,2 @@
1
+ rvm use rbx
2
+ export RBXOPT=-Xrbc.db=~/.rbxdb
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in girl_friday.gemspec
4
+ gemspec
5
+
6
+ # Needed for testing only!
7
+ gem 'minitest'
8
+ gem 'redis'
data/History.md ADDED
@@ -0,0 +1,9 @@
1
+ Changes
2
+ ================
3
+
4
+
5
+
6
+ 0.9.0
7
+ ---------
8
+
9
+ * Initial release
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
@@ -0,0 +1,8 @@
1
+ require 'rake/testtask'
2
+ Rake::TestTask.new(:test) do |test|
3
+ test.libs << 'test'
4
+ test.warning = true
5
+ test.pattern = 'test/**/test_*.rb'
6
+ end
7
+
8
+ task :default => :test
data/TODO.md ADDED
@@ -0,0 +1,5 @@
1
+ TODO
2
+ ===============
3
+
4
+ - web admin UI to surface status() metrics
5
+ - nicer project homepage
@@ -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,3 @@
1
+ module GirlFriday
2
+ VERSION = "0.9.0"
3
+ end
@@ -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
@@ -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
@@ -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