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 +1 -0
- data/.pryrc +2 -0
- data/.rspec +1 -0
- data/README.md +133 -2
- data/bin/employer +7 -0
- data/employer.gemspec +7 -1
- data/lib/employer.rb +6 -4
- data/lib/employer/boss.rb +110 -0
- data/lib/employer/cli.rb +70 -0
- data/lib/employer/employees.rb +3 -0
- data/lib/employer/employees/abstract_employee.rb +55 -0
- data/lib/employer/employees/forking_employee.rb +52 -0
- data/lib/employer/employees/threading_employee.rb +38 -0
- data/lib/employer/errors.rb +5 -0
- data/lib/employer/errors/employee_busy.rb +6 -0
- data/lib/employer/errors/error.rb +6 -0
- data/lib/employer/errors/job_class_mismatch.rb +6 -0
- data/lib/employer/errors/no_employee_free.rb +6 -0
- data/lib/employer/errors/pipeline_backend_required.rb +6 -0
- data/lib/employer/job.rb +55 -0
- data/lib/employer/pipeline.rb +55 -0
- data/lib/employer/version.rb +1 -1
- data/lib/employer/workshop.rb +66 -0
- data/spec/employer/boss_spec.rb +254 -0
- data/spec/employer/employees/forking_employee_spec.rb +6 -0
- data/spec/employer/employees/threading_employee_spec.rb +6 -0
- data/spec/employer/job_spec.rb +89 -0
- data/spec/employer/pipeline_spec.rb +92 -0
- data/spec/employer/workshop_spec.rb +105 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/support/shared_examples/employee.rb +132 -0
- metadata +91 -7
data/.gitignore
CHANGED
data/.pryrc
ADDED
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
|
-
|
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
|
-
|
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
|
|
data/bin/employer
ADDED
data/employer.gemspec
CHANGED
@@ -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
|
data/lib/employer.rb
CHANGED
@@ -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
|
data/lib/employer/cli.rb
ADDED
@@ -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,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
|