employer 0.0.1 → 0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore CHANGED
@@ -15,3 +15,4 @@ spec/reports
15
15
  test/tmp
16
16
  test/version_tmp
17
17
  tmp
18
+ config/
data/.pryrc ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler"
2
+ Bundler.require
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color --format documentation -I. -rspec_helper
data/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Employer
2
2
 
3
- TODO: Write a gem description
3
+ There comes a time in the life of an application that async job processing
4
+ becomes a requirement. If you want something flexible that you can easily adapt
5
+ to fit in with your application's infrastucture, then Employer may be what you
6
+ are looking for.
4
7
 
5
8
  ## Installation
6
9
 
@@ -18,7 +21,135 @@ Or install it yourself as:
18
21
 
19
22
  ## Usage
20
23
 
21
- TODO: Write usage instructions here
24
+ To use Employer to run jobs you need to do the following:
25
+
26
+ - Define your jobs as classes that include the Employer::Job module
27
+ - Hook up Employer with a backend to manage the jobs
28
+
29
+ ### Defining your own jobs
30
+
31
+ Implementing your own jobs is simple, here's a silly example:
32
+
33
+ ```ruby
34
+ class NamePutsJob
35
+ include Employer::Job
36
+
37
+ attribute :first_name
38
+ attribute :last_name
39
+ attribute :tries
40
+
41
+ def initialize
42
+ tries ||= 0
43
+ end
44
+
45
+ def try_again?
46
+ true if tries < 3
47
+ end
48
+
49
+ def perform
50
+ puts "#{first_name} #{last_name}"
51
+ end
52
+ end
53
+ ```
54
+
55
+ The attribute class method will define an attr_accessor and will ensure that the
56
+ attribute is part of the serialized data that is sent to the backend when a job
57
+ is enqueued.
58
+
59
+ The perform method is what will get executed when Employer picks up a job for
60
+ processing.
61
+
62
+ If a job fails the try_again? method will determine whether or not the job gets
63
+ tried again, if this method returns false the job will be marked as failed and
64
+ won't be attempted again.
65
+
66
+ ### Hooking up a backend
67
+
68
+ Employer manages its jobs through its pipeline, in order to feed jobs into the
69
+ pipeline and to get jobs out of the pipeline you need to connect a backend to
70
+ it. You can either use a backend that someone has built already (if you're using
71
+ Mongoid 3 you can use the employer-mongoid gem), or implement your own. A valid
72
+ pipeline backend must implement the methods shown in the below code snippet:
73
+
74
+ ```ruby
75
+ class CustomPipelineBackend
76
+ # job_hash is a Hash with the following keys:
77
+ # - class: The class of the Job object
78
+ # - attributes: A Hash with attribute values set on the Job object
79
+ def enqueue(job_hash)
80
+ end
81
+
82
+ # dequeue must return a job_hash in the same format as is passed into
83
+ # enqueue, except that it must add the key id with the Job's unique
84
+ # identifier (such as a record id)
85
+ def dequeue
86
+ end
87
+
88
+ # complete accepts a Job object, using its id the pipeline backend should
89
+ # mark the job as complete
90
+ def complete(job)
91
+ end
92
+
93
+ # fail accepts a Job object, using its id the pipeline backend should
94
+ # mark the job as failed
95
+ def fail(job)
96
+ end
97
+
98
+ # reset accepts a Job object, using its id the pipeline backend should
99
+ # reset the job by marking it as free
100
+ def reset(job)
101
+ end
102
+ end
103
+ ```
104
+
105
+ To hook up the backend to Employer you must generate and edit a config file by
106
+ running `employer config` (or more likely `bundle exec employer config`). If you
107
+ don't specify a custom path (with -c /path/to/employer\_config.rb) this will
108
+ generate config/employer.rb, the file will look something like this:
109
+
110
+ ```ruby
111
+ # If you're using Rails the below line requires config/environment to setup the
112
+ # Rails environment. If you're not using Rails you'll want to require something
113
+ # here that sets up Employer's environment appropriately (making available the
114
+ # classes that your jobs need to do their work, providing the connection to
115
+ # your database, etc.)
116
+ # require "./config/environment"
117
+
118
+ require "employer-mongoid"
119
+
120
+ # Setup the backend for the pipeline, this is where the boss gets the jobs to
121
+ # process. See the documentation for details on writing your own pipeline
122
+ # backend.
123
+ pipeline_backend Employer::Mongoid::Pipeline.new
124
+
125
+ # Use employees that fork subprocesses to perform jobs. You cannot use these
126
+ # with JRuby, because JRuby doesn't support Process#fork.
127
+ forking_employees 4
128
+
129
+ # Use employees that run their jobs in threads, you can use these when using
130
+ # JRuby. While threaded employees also work with MRI they are limited by the
131
+ # GIL (this may or may not be a problem depending on the type of work your jobs
132
+ # need to do).
133
+ # threading_employees 4
134
+ ```
135
+
136
+ The comments in the file pretty much explain how you should edit it.
137
+
138
+ When setup properly you can start processing jobs by running `employer` (or
139
+ `employer -c /path/to/employer\_config.rb`, likely prepended with `bundle exec`)
140
+
141
+ In your application code you can obtain a pipeline to enqueue jobs with like so:
142
+
143
+ ```ruby
144
+ # Obtain the pipeline
145
+ pipeline = Employer::Workshop.enqueue("/path/to/employer\_config.rb")
146
+
147
+ # Enqueue a job
148
+ job = NamePutsJob.new
149
+ job.first_name = "Mark"
150
+ job.last_name = "Kremer"
151
+ pipeline.enqueue(job)
152
+ ```
22
153
 
23
154
  ## Contributing
24
155
 
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "employer"
5
+ require "employer/cli"
6
+
7
+ Employer::CLI.start(ARGV)
@@ -9,10 +9,16 @@ Gem::Specification.new do |gem|
9
9
  gem.authors = ["Mark Kremer"]
10
10
  gem.email = ["mark@without-brains.net"]
11
11
  gem.summary = %q{Job processing made easy}
12
- gem.homepage = ""
12
+ gem.homepage = "https://github.com/mkremer/employer"
13
+ gem.license = "MIT"
13
14
 
14
15
  gem.files = `git ls-files`.split($/)
15
16
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
16
17
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
17
18
  gem.require_paths = ["lib"]
19
+
20
+ gem.add_runtime_dependency "thor", "~> 0.17"
21
+
22
+ gem.add_development_dependency "rspec"
23
+ gem.add_development_dependency "pry"
18
24
  end
@@ -1,5 +1,7 @@
1
1
  require "employer/version"
2
-
3
- module Employer
4
- # Your code goes here...
5
- end
2
+ require "employer/errors"
3
+ require "employer/pipeline"
4
+ require "employer/job"
5
+ require "employer/employees"
6
+ require "employer/boss"
7
+ require "employer/workshop"
@@ -0,0 +1,110 @@
1
+ require_relative "errors"
2
+
3
+ module Employer
4
+ class Boss
5
+ attr_reader :pipeline, :employees, :keep_going, :sleep_time
6
+
7
+ def initialize
8
+ @pipeline = nil
9
+ @employees = []
10
+ @sleep_time_index = 0
11
+ end
12
+
13
+ def pipeline=(pipeline)
14
+ @pipeline = pipeline
15
+ end
16
+
17
+ def allocate_employee(employee)
18
+ employees << employee
19
+ end
20
+
21
+ def stop_managing
22
+ @keep_going = false
23
+ end
24
+
25
+ def manage
26
+ @keep_going = true
27
+
28
+ while keep_going
29
+ delegate_work
30
+ progress_update
31
+ end
32
+
33
+ wait_on_employees
34
+ end
35
+
36
+ def delegate_work
37
+ while free_employee? && job = get_work
38
+ delegate_job(job)
39
+ end
40
+ end
41
+
42
+ def get_work
43
+ sleep_times = [0.1, 0.5, 1, 2.5, 5]
44
+ if job = pipeline.dequeue
45
+ @sleep_time_index = 0
46
+ else
47
+ @sleep_time_index += 1 unless @sleep_time_index == (sleep_times.count - 1)
48
+ end
49
+ @sleep_time = sleep_times[@sleep_time_index]
50
+ sleep(sleep_time)
51
+ job
52
+ end
53
+
54
+ def progress_update
55
+ busy_employees.each do |employee|
56
+ update_job_status(employee)
57
+ end
58
+ end
59
+
60
+ def update_job_status(employee)
61
+ return if employee.work_in_progress?
62
+
63
+ job = employee.job
64
+
65
+ if employee.work_completed?
66
+ pipeline.complete(job)
67
+ elsif employee.work_failed?
68
+ if job.try_again?
69
+ pipeline.reset(job)
70
+ else
71
+ pipeline.fail(job)
72
+ end
73
+ end
74
+
75
+ employee.free
76
+ end
77
+
78
+ def wait_on_employees
79
+ busy_employees.each do |employee|
80
+ employee.wait_for_completion
81
+ update_job_status(employee)
82
+ end
83
+ end
84
+
85
+ def stop_employees
86
+ busy_employees.each do |employee|
87
+ employee.stop_working
88
+ update_job_status(employee)
89
+ employee.free
90
+ end
91
+ end
92
+
93
+ def delegate_job(job)
94
+ raise Employer::Errors::NoEmployeeFree unless employee = free_employee
95
+ employee.work(job)
96
+ end
97
+
98
+ def busy_employees
99
+ employees.select { |employee| !employee.free? }
100
+ end
101
+
102
+ def free_employee
103
+ employees.find(&:free?)
104
+ end
105
+
106
+ def free_employee?
107
+ free_employee
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,70 @@
1
+ require "thor"
2
+ require "fileutils"
3
+
4
+ module Employer
5
+ class CLI < Thor
6
+ default_task :work
7
+
8
+ desc "work", "Process jobs"
9
+ option :config, default: "config/employer.rb", desc: "Config file to use"
10
+ def work
11
+ unless File.exists?(options[:config])
12
+ STDERR.puts "#{options[:config]} does not exist."
13
+ exit 1
14
+ end
15
+
16
+ int_count = 0
17
+ workshop = Employer::Workshop.setup(File.read(options[:config]))
18
+
19
+ Signal.trap("INT") do
20
+ int_count += 1
21
+ if int_count == 1
22
+ workshop.stop
23
+ else
24
+ workshop.stop_now
25
+ end
26
+ end
27
+
28
+ workshop.run
29
+ end
30
+
31
+ desc "config", "Generate config file"
32
+ option :config, default: "config/employer.rb", desc: "Path to config file"
33
+ def config
34
+ if File.exists?(options[:config])
35
+ STDERR.puts "#{options[:config]} already exists."
36
+ exit 1
37
+ end
38
+
39
+ FileUtils.mkdir("config") unless File.directory?("config")
40
+
41
+ File.open(options[:config], "w") do |file|
42
+ file.write <<CONFIG
43
+ # If you're using Rails the below line requires config/environment to setup the
44
+ # Rails environment. If you're not using Rails you'll want to require something
45
+ # here that sets up Employer's environment appropriately (making available the
46
+ # classes that your jobs need to do their work, providing the connection to
47
+ # your database, etc.)
48
+ # require "./config/environment"
49
+
50
+ require "employer-mongoid"
51
+
52
+ # Setup the backend for the pipeline, this is where the boss gets the jobs to
53
+ # process. See the documentation for details on writing your own pipeline
54
+ # backend.
55
+ pipeline_backend Employer::Mongoid::Pipeline.new
56
+
57
+ # Use employees that fork subprocesses to perform jobs. You cannot use these
58
+ # with JRuby, because JRuby doesn't support Process#fork.
59
+ forking_employees 4
60
+
61
+ # Use employees that run their jobs in threads, you can use these when using
62
+ # JRuby. While threaded employees also work with MRI they are limited by the
63
+ # GIL (this may or may not be a problem depending on the type of work your jobs
64
+ # need to do).
65
+ # threading_employees 4
66
+ CONFIG
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,3 @@
1
+ require_relative "employees/abstract_employee"
2
+ require_relative "employees/forking_employee"
3
+ require_relative "employees/threading_employee"
@@ -0,0 +1,55 @@
1
+ require_relative "../errors"
2
+
3
+ module Employer
4
+ module Employees
5
+ class AbstractEmployee
6
+ attr_reader :job
7
+
8
+ def work(job)
9
+ raise Employer::Errors::EmployeeBusy unless free?
10
+ @job = job
11
+ end
12
+
13
+ def perform_job
14
+ job.perform
15
+ end
16
+
17
+ def wait_for_completion
18
+ work_state(true)
19
+ end
20
+
21
+ def stop_working
22
+ return if work_completed? || work_failed?
23
+ force_work_stop
24
+ end
25
+
26
+ def free?
27
+ job.nil?
28
+ end
29
+
30
+ def work_in_progress?
31
+ true if work_state == :busy
32
+ end
33
+
34
+ def work_completed?
35
+ true if work_state == :complete
36
+ end
37
+
38
+ def work_failed?
39
+ true if work_state == :failed
40
+ end
41
+
42
+ def free
43
+ return unless work_completed? || work_failed?
44
+ @work_state = nil
45
+ @job = nil
46
+ end
47
+
48
+ def work_state(wait = false)
49
+ end
50
+
51
+ def force_work_stop
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "abstract_employee"
2
+
3
+ module Employer
4
+ module Employees
5
+ class ForkingEmployee < AbstractEmployee
6
+ def work(job)
7
+ super
8
+
9
+ @job_pid = fork do
10
+ state = nil
11
+
12
+ begin
13
+ perform_job
14
+ state = 0
15
+ ensure
16
+ state = 1 if state.nil?
17
+ exit(state)
18
+ end
19
+ end
20
+ end
21
+
22
+ def free
23
+ super
24
+ @job_pid = nil
25
+ end
26
+
27
+ def work_state(wait = false)
28
+ return @work_state if [:complete, :failed].include?(@work_state)
29
+
30
+ @work_state = :busy
31
+
32
+ flags = wait == false ? Process::WNOHANG : 0
33
+ pid, status = Process.waitpid2(@job_pid, flags)
34
+ if pid
35
+ if status.exitstatus == 0
36
+ @work_state = :complete
37
+ else
38
+ @work_state = :failed
39
+ end
40
+ end
41
+
42
+ @work_state
43
+ end
44
+
45
+ def force_work_stop
46
+ return if free?
47
+ Process.kill("KILL", @job_pid)
48
+ work_state(true)
49
+ end
50
+ end
51
+ end
52
+ end