employer 0.0.1 → 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|