tennis-jobs 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,12 +1,13 @@
1
1
  require "json"
2
2
 
3
3
  module Tennis
4
- module Serializer
5
- class Generic
4
+ module Backend
5
+ class Serializer
6
6
 
7
7
  RECOGNIZED_TYPES = {
8
- active_record: "active_record".freeze,
9
- class: "class".freeze,
8
+ findable: "findable".freeze,
9
+ class: "class".freeze,
10
+ job: "job".freeze,
10
11
  }.freeze
11
12
 
12
13
  def load(message)
@@ -30,12 +31,18 @@ module Tennis
30
31
  _type: RECOGNIZED_TYPES[:class],
31
32
  _class: object.to_s
32
33
  }
33
- when :active_record
34
+ when :findable
34
35
  {
35
- _type: RECOGNIZED_TYPES[:active_record],
36
+ _type: RECOGNIZED_TYPES[:findable],
36
37
  _class: object.class.to_s,
37
38
  _id: object.id,
38
39
  }
40
+ when :job
41
+ {
42
+ _type: RECOGNIZED_TYPES[:job],
43
+ _class: object.class.to_s,
44
+ _dump: object.job_dump,
45
+ }
39
46
  else
40
47
  fail "Unexpected type: #{type} when visiting object"
41
48
  end
@@ -49,9 +56,12 @@ module Tennis
49
56
  object
50
57
  when :class
51
58
  Object.const_get(object["_class"])
52
- when :active_record
59
+ when :findable
53
60
  klass = Object.const_get(object["_class"])
54
61
  klass.find(object["_id"])
62
+ when :job
63
+ klass = Object.const_get(object["_class"])
64
+ klass.job_load(object["_dump"])
55
65
  else
56
66
  fail "Unexpected type: #{type} when visiting object"
57
67
  end
@@ -65,8 +75,10 @@ module Tennis
65
75
  def visit_any(object, block)
66
76
  if object.kind_of?(Array)
67
77
  visit_array(object, block)
68
- elsif is_active_record?(object)
69
- block.call(:active_record, object)
78
+ elsif is_job?(object)
79
+ block.call(:job, object)
80
+ elsif is_findable?(object)
81
+ block.call(:findable, object)
70
82
  elsif is_class?(object)
71
83
  block.call(:class, object)
72
84
  elsif object.kind_of?(Hash)
@@ -86,14 +98,19 @@ module Tennis
86
98
  end
87
99
  end
88
100
 
89
- def is_active_record?(object)
90
- object.respond_to?(:attributes) ||
91
- object.is_a?(Hash) && object["_type"] == RECOGNIZED_TYPES[:active_record]
101
+ def is_findable?(object)
102
+ (object.class.respond_to?(:find) && object.respond_to?(:id)) ||
103
+ (object.is_a?(Hash) && object["_type"] == RECOGNIZED_TYPES[:findable])
92
104
  end
93
105
 
94
106
  def is_class?(object)
95
107
  object.is_a?(Class) ||
96
- object.is_a?(Hash) && object["_type"] == RECOGNIZED_TYPES[:class]
108
+ (object.is_a?(Hash) && object["_type"] == RECOGNIZED_TYPES[:class])
109
+ end
110
+
111
+ def is_job?(object)
112
+ object.is_a?(Tennis::Job) ||
113
+ (object.is_a?(Hash) && object["_type"] == RECOGNIZED_TYPES[:job])
97
114
  end
98
115
 
99
116
  end
@@ -0,0 +1,36 @@
1
+ module Tennis
2
+ module Backend
3
+ class Task
4
+
5
+ attr_reader :task_id, :job, :method, :args, :meta
6
+ attr_accessor :worker
7
+
8
+ def initialize(backend, task_id, job, method, args, meta = {})
9
+ @backend, @task_id, @acked = backend, task_id, false
10
+ @job, @method, @args = job, method, args
11
+ @meta = meta
12
+ end
13
+
14
+ def execute
15
+ @job.__send__(@method, *@args)
16
+ end
17
+
18
+ def ack
19
+ return if acked?
20
+ @backend.ack(self)
21
+ @acked = true
22
+ end
23
+
24
+ def requeue
25
+ return if acked?
26
+ @backend.requeue(self)
27
+ @acked = true
28
+ end
29
+
30
+ def acked?
31
+ @acked
32
+ end
33
+
34
+ end
35
+ end
36
+ end
@@ -1,32 +1,30 @@
1
- require "yaml"
2
1
  require "optparse"
3
- require "sneakers"
4
- require "sneakers/runner"
2
+
3
+ require "tennis/launcher"
5
4
 
6
5
  module Tennis
7
6
  class CLI
8
7
 
9
8
  DEFAULT_OPTIONS = {
10
- config: "./tennis.yml",
9
+ concurrency: 2,
10
+ job_class_names: [],
11
11
  }.freeze
12
12
 
13
13
  def self.start
14
14
  options = DEFAULT_OPTIONS.dup
15
15
  OptionParser.new do |opts|
16
- opts.banner = "Usage: tennis [options] group"
17
- opts.on("-c", "--config FILE", "Set the config file") do |file|
18
- options[:config] = file
16
+ opts.banner = "Usage: tennis [options]"
17
+ opts.on("-j", "--jobs JOBS", "List of the job classes to handle") do |jobs|
18
+ options[:job_class_names] = jobs.split(",")
19
+ end
20
+ opts.on("-c", "--concurrency COUNT", "The number of concurrent jobs") do |concurrency|
21
+ options[:concurrency] = concurrency.to_i
19
22
  end
20
23
  opts.on("-r", "--require PATH", "Require files before starting") do |path|
21
24
  options[:require] ||= []
22
25
  options[:require] << path
23
26
  end
24
- opts.on("-x", "--execute CODE", "Execute code before starting") do |code|
25
- options[:execute] ||= []
26
- options[:execute] << code
27
- end
28
27
  end.parse!
29
- options[:group] = ARGV.first
30
28
  new(options).start
31
29
  end
32
30
 
@@ -35,68 +33,30 @@ module Tennis
35
33
  end
36
34
 
37
35
  def start
38
- do_require
39
- execute_code
40
- configure_tennis
41
- start_group
36
+ require_paths
37
+ start_launcher
42
38
  end
43
39
 
44
40
  private
45
41
 
46
- def do_require
42
+ def require_paths
47
43
  return unless requires = @options[:require]
48
44
  requires.each { |path| require path } if @options[:require]
49
45
  end
50
46
 
51
- def execute_code
52
- return unless codes = @options[:execute]
53
- codes.each { |code| eval code }
54
- end
55
-
56
- def configure_tennis
57
- Tennis.configure do |config|
58
- config.async = true
59
- config.exchange = group["exchange"]
60
- config.workers = group["workers"].to_i
61
- config.logger = Logger.new(STDOUT)
62
- config.logger.level = Logger::WARN
63
- config.sneakers_options = sneakers_options
64
- end
47
+ def start_launcher
48
+ raise "You must specify at least one job class" if job_classes.empty?
49
+ Launcher.new({
50
+ job_classes: job_classes,
51
+ concurrency: @options[:concurrency]
52
+ }).start
65
53
  end
66
54
 
67
- def sneakers_options
68
- classes.map(&:options).each_with_object({}) do |options, all_options|
69
- merge_options(all_options, options)
70
- end
71
- end
72
-
73
- def merge_options(target, options)
74
- options.each do |name, value|
75
- if target[name].nil?
76
- target[name] = value
77
- elsif target[name] != value
78
- fail "Workers shouldn't have different '#{name}' options"
79
- end
80
- end
81
- end
82
-
83
- def start_group
84
- Sneakers::Runner.new(classes.map(&:worker)).run
85
- end
86
-
87
- def classes
88
- @classes ||= group["classes"].map do |name|
55
+ def job_classes
56
+ @job_classes ||= @options[:job_class_names].map do |name|
89
57
  Object.const_get(name)
90
58
  end
91
59
  end
92
60
 
93
- def group
94
- @group ||= config[@options[:group]]
95
- end
96
-
97
- def config
98
- YAML.load_file(@options[:config])
99
- end
100
-
101
61
  end
102
62
  end
@@ -1,14 +1,14 @@
1
+ require "logger"
2
+ require "celluloid"
3
+
1
4
  module Tennis
2
5
  class Configuration
3
6
  DEFAULT = {
4
7
  async: true,
5
- exchange: "tennis",
6
- workers: 4,
7
- logger: STDOUT,
8
- sneakers_options: {},
8
+ logger: Logger.new(STDOUT),
9
9
  }.freeze
10
10
 
11
- attr_accessor :async, :exchange, :workers, :logger, :sneakers_options
11
+ attr_accessor :async, :logger, :backend
12
12
 
13
13
  def initialize(opts = {})
14
14
  DEFAULT.merge(opts).each do |name, value|
@@ -17,11 +17,10 @@ module Tennis
17
17
  end
18
18
 
19
19
  def finalize!
20
- Sneakers.configure({
21
- exchange: exchange,
22
- workers: workers,
23
- log: logger,
24
- }.merge(sneakers_options))
20
+ raise "You must specify a backend during the configuration" unless backend
21
+
22
+ # Set the celluloid logger.
23
+ Celluloid.logger = logger
25
24
  end
26
25
 
27
26
  end
@@ -0,0 +1,3 @@
1
+ module Tennis
2
+ class Shutdown < StandardError ; end
3
+ end
@@ -0,0 +1,36 @@
1
+ require "tennis/actor"
2
+
3
+ module Tennis
4
+ class Fetcher
5
+ include Actor
6
+
7
+ attr_reader :worker_pool
8
+
9
+ def initialize(worker_pool, options)
10
+ @job_classes = options[:job_classes]
11
+ @worker_pool = worker_pool
12
+ @backend = Tennis.config.backend
13
+ @done = false
14
+ end
15
+
16
+ def fetch
17
+ return if done?
18
+ if task = @backend.receive(job_classes: @job_classes)
19
+ worker_pool.async.work(task)
20
+ else
21
+ async.fetch
22
+ end
23
+ end
24
+
25
+ def done!
26
+ @done = true
27
+ end
28
+
29
+ private
30
+
31
+ def done?
32
+ @done
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+ require "tennis/action"
2
+ require "tennis/worker"
3
+
4
+ module Tennis
5
+ module Job
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ # Return a proxy object that will enqueue method calls into
11
+ # the Tennis's backend.
12
+ def async
13
+ Action.new(self)
14
+ end
15
+
16
+ # Dump a Job instance into a simple hash.
17
+ def job_dump
18
+ raise NotImplementedError
19
+ end
20
+
21
+ module ClassMethods
22
+
23
+ # Build a Job instance from a simple hash.
24
+ def job_load(hash)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,38 @@
1
+ require "celluloid/condition"
2
+
3
+ require "tennis/actor"
4
+ require "tennis/fetcher"
5
+ require "tennis/worker_pool"
6
+
7
+ module Tennis
8
+ class Launcher
9
+ include Actor
10
+
11
+ attr_reader :worker_pool, :fetcher
12
+
13
+ def initialize(options)
14
+ @stop_condition = Celluloid::Condition.new
15
+ @worker_pool = WorkerPool.new_link(@stop_condition, options)
16
+ @fetcher = Fetcher.new_link(worker_pool, options)
17
+ @worker_pool.fetcher = @fetcher
18
+ end
19
+
20
+ def start
21
+ worker_pool.async.start
22
+ end
23
+
24
+ def stop
25
+ # Stop fetching
26
+ fetcher.done!
27
+
28
+ # Gracefully stop the workers that are still working
29
+ worker_pool.async.stop
30
+ @stop_condition.wait
31
+
32
+ # Terminate the two actors
33
+ worker_pool.terminate if worker_pool.alive?
34
+ fetcher.terminate if fetcher.alive?
35
+ end
36
+
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ require "tennis/actor"
2
+ require "tennis/exceptions"
3
+
4
+ module Tennis
5
+ class Worker
6
+ include Actor
7
+
8
+ attr_accessor :worker_id
9
+
10
+ def initialize(pool)
11
+ @pool = pool
12
+ end
13
+
14
+ def work(task)
15
+ # Send the current working thread to the pool.
16
+ register_working_thread
17
+
18
+ ack = true
19
+ begin
20
+ task.execute
21
+ rescue Shutdown
22
+ ack = false
23
+ raise
24
+ rescue Exception => exception
25
+ # TODO: add an error handler on the job's class
26
+ raise
27
+ ensure
28
+ task.ack if ack
29
+ end
30
+
31
+ # Tell the pool that we've successfully done the job.
32
+ notifies_work_done(task)
33
+ end
34
+
35
+ private
36
+
37
+ def register_working_thread
38
+ @pool.async.register_thread(worker_id, Thread.current)
39
+ end
40
+
41
+ def notifies_work_done(task)
42
+ @pool.async.work_done(task)
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,114 @@
1
+ require "thread"
2
+
3
+ require "tennis/actor"
4
+ require "tennis/exceptions"
5
+ require "tennis/worker"
6
+
7
+ module Tennis
8
+ class WorkerPool
9
+ include Actor
10
+
11
+ trap_exit :worker_died
12
+
13
+ attr_accessor :fetcher
14
+
15
+ def initialize(stop_condition, options)
16
+ @stop_condition = stop_condition
17
+ @size = options[:concurrency]
18
+ @pending_tasks = []
19
+ @threads = {}
20
+ @workers = Queue.new
21
+ end
22
+
23
+ def start
24
+ @size.times { start_worker }
25
+ end
26
+
27
+ def stop(timeout: 30)
28
+ done!
29
+
30
+ if @pending_tasks.empty?
31
+ shutdown
32
+ elsif timeout
33
+ plan_hard_shutdown timeout
34
+ end
35
+ end
36
+
37
+ def work(task)
38
+ # Do not accept new tasks if done.
39
+ return task.requeue if done?
40
+
41
+ @pending_tasks << task
42
+ worker = @workers.pop(true)
43
+ task.worker = worker
44
+ worker.async.work(task)
45
+ end
46
+
47
+ def work_done(task)
48
+ @pending_tasks.delete(task)
49
+ @threads.delete(task.worker.object_id)
50
+ ready(task.worker) if task.worker.alive?
51
+
52
+ # If done and there is no more pending tasks, we can shutdown. It also
53
+ # means that every workers are in que @workers queue.
54
+ shutdown if done? && @pending_tasks.empty?
55
+ end
56
+
57
+ def register_thread(worker_id, thread)
58
+ @threads[worker_id] = thread
59
+ end
60
+
61
+ def worker_died(worker, reason)
62
+ @threads.delete(worker.object_id)
63
+ @pending_tasks.delete_if { |task| task.worker == worker }
64
+ start_worker unless reason.is_a?(Shutdown)
65
+ end
66
+
67
+ private
68
+
69
+ def done!
70
+ @done = true
71
+ end
72
+
73
+ def done?
74
+ @done
75
+ end
76
+
77
+ def plan_hard_shutdown(timeout)
78
+ after(timeout) do
79
+ @pending_tasks.each do |task|
80
+ worker = task.worker
81
+ thread = @threads.delete(worker.object_id)
82
+ thread.raise(Shutdown) if worker.alive?
83
+ task.requeue
84
+ end
85
+ @pending_tasks.clear
86
+
87
+ shutdown
88
+ end
89
+ end
90
+
91
+ def shutdown
92
+ # Terminate the worker actors.
93
+ @workers.size.times do
94
+ worker = @workers.pop
95
+ worker.terminate if worker.alive?
96
+ end
97
+
98
+ # Signal the launcher that we're done processing jobs
99
+ @stop_condition.signal
100
+ end
101
+
102
+ def start_worker
103
+ worker = Worker.new_link(current_actor)
104
+ worker.worker_id = worker.object_id
105
+ ready(worker)
106
+ end
107
+
108
+ def ready(worker)
109
+ @workers << worker
110
+ fetcher.async.fetch
111
+ end
112
+
113
+ end
114
+ end