batch_processor 0.2.1 → 0.2.2
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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +50 -0
- data/lib/batch_processor.rb +34 -0
- data/lib/batch_processor/batch/controller.rb +110 -0
- data/lib/batch_processor/batch/core.rb +48 -0
- data/lib/batch_processor/batch/job.rb +30 -0
- data/lib/batch_processor/batch/job_controller.rb +97 -0
- data/lib/batch_processor/batch/predicates.rb +36 -0
- data/lib/batch_processor/batch/processor.rb +58 -0
- data/lib/batch_processor/batch_base.rb +22 -0
- data/lib/batch_processor/batch_details.rb +71 -0
- data/lib/batch_processor/batch_job.rb +59 -0
- data/lib/batch_processor/collection.rb +13 -0
- data/lib/batch_processor/processor/execute.rb +25 -0
- data/lib/batch_processor/processor/process.rb +42 -0
- data/lib/batch_processor/processor_base.rb +19 -0
- data/lib/batch_processor/processors/parallel.rb +15 -0
- data/lib/batch_processor/processors/sequential.rb +30 -0
- data/lib/batch_processor/rspec/active_job_test_adapter_monkeypatch.rb +21 -0
- data/lib/batch_processor/rspec/custom_matchers.rb +9 -0
- data/lib/batch_processor/rspec/custom_matchers/set_processor_option.rb +22 -0
- data/lib/batch_processor/rspec/custom_matchers/use_batch_processor_strategy.rb +21 -0
- data/lib/batch_processor/rspec/custom_matchers/use_default_job_class.rb +18 -0
- data/lib/batch_processor/rspec/custom_matchers/use_default_processor.rb +14 -0
- data/lib/batch_processor/rspec/custom_matchers/use_job_class.rb +24 -0
- data/lib/batch_processor/rspec/custom_matchers/use_parallel_processor.rb +15 -0
- data/lib/batch_processor/rspec/custom_matchers/use_sequential_processor.rb +15 -0
- data/lib/batch_processor/rspec/shoulda_matcher_helper.rb +7 -0
- data/lib/batch_processor/spec_helper.rb +4 -0
- data/lib/batch_processor/version.rb +5 -0
- metadata +32 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 92f896e89a4f7222fb235fc9db1db9631792ff72508b2af911807db824bc9c57
|
4
|
+
data.tar.gz: ca8818ccccf18d57cfbc5a2c9628f3a4d349417789a90e1d2ca2fd2e77ac0c67
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 88f75329cbdada1be5be38abd8edd8da5e31509a745917e56dc0d317ce9420f1a6cb7622968a40ec4342237d244c92bbaba4a3b9a8cb9b5b4b739baee69ebae7
|
7
|
+
data.tar.gz: b6ca0129ffeef8ef479bcee475420d891972d1ef307daa50140bcd63b150d2869909e8dcbfec0157e4a386e4b03baa6dcc6f98a54395eb3af5cbfd94ed57094c
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2019 Eric Garside
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
# BatchProcessor
|
2
|
+
|
3
|
+
Define your collection, job, and callbacks all in one clear and concise object
|
4
|
+
|
5
|
+
[](https://badge.fury.io/rb/batch_processor)
|
6
|
+
[](https://semaphoreci.com/freshly/batch_processor)
|
7
|
+
[](https://codeclimate.com/github/Freshly/batch_processor/maintainability)
|
8
|
+
[](https://codeclimate.com/github/Freshly/batch_processor/test_coverage)
|
9
|
+
|
10
|
+
* [BatchProcessor](#batchprocessor)
|
11
|
+
* [Installation](#installation)
|
12
|
+
* [Usage](#usage)
|
13
|
+
* [Development](#development)
|
14
|
+
* [Contributing](#contributing)
|
15
|
+
* [License](#license)
|
16
|
+
|
17
|
+
|
18
|
+
## Installation
|
19
|
+
|
20
|
+
Add this line to your application's Gemfile:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
gem 'batch_processor'
|
24
|
+
```
|
25
|
+
|
26
|
+
And then execute:
|
27
|
+
|
28
|
+
$ bundle
|
29
|
+
|
30
|
+
Or install it yourself as:
|
31
|
+
|
32
|
+
$ gem install batch_processor
|
33
|
+
|
34
|
+
## Usage
|
35
|
+
|
36
|
+
TODO: Write usage instructions here
|
37
|
+
|
38
|
+
## Development
|
39
|
+
|
40
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
41
|
+
|
42
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
43
|
+
|
44
|
+
## Contributing
|
45
|
+
|
46
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/Freshly/batch_processor.
|
47
|
+
|
48
|
+
## License
|
49
|
+
|
50
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support"
|
4
|
+
require "active_job"
|
5
|
+
|
6
|
+
require "spicerack"
|
7
|
+
|
8
|
+
require "batch_processor/version"
|
9
|
+
require "batch_processor/batch_job"
|
10
|
+
require "batch_processor/batch_details"
|
11
|
+
require "batch_processor/processor_base"
|
12
|
+
require "batch_processor/processors/parallel"
|
13
|
+
require "batch_processor/processors/sequential"
|
14
|
+
require "batch_processor/collection"
|
15
|
+
require "batch_processor/batch_base"
|
16
|
+
|
17
|
+
module BatchProcessor
|
18
|
+
class Error < StandardError; end
|
19
|
+
|
20
|
+
class NotFoundError < Error; end
|
21
|
+
class ClassMissingError < Error; end
|
22
|
+
class CollectionEmptyError < Error; end
|
23
|
+
class CollectionInvalidError < Error; end
|
24
|
+
class AlreadyExistsError < Error; end
|
25
|
+
class AlreadyStartedError < Error; end
|
26
|
+
class AlreadyEnqueuedError < Error; end
|
27
|
+
class AlreadyFinishedError < Error; end
|
28
|
+
class AlreadyAbortedError < Error; end
|
29
|
+
class AlreadyClearedError < Error; end
|
30
|
+
class StillProcessingError < Error; end
|
31
|
+
class NotProcessingError < Error; end
|
32
|
+
class NotAbortedError < Error; end
|
33
|
+
class NotStartedError < Error; end
|
34
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# The controller performs updates on and tracks details of a batch.
|
4
|
+
module BatchProcessor
|
5
|
+
module Batch
|
6
|
+
module Controller
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
batch_callbacks :started, :enqueued, :aborted, :cleared, :finished
|
11
|
+
|
12
|
+
delegate :allow_empty?, to: :class
|
13
|
+
delegate :name, to: :class, prefix: true
|
14
|
+
delegate :pipelined, to: :details
|
15
|
+
end
|
16
|
+
|
17
|
+
class_methods do
|
18
|
+
def allow_empty
|
19
|
+
@allow_empty = true
|
20
|
+
end
|
21
|
+
|
22
|
+
def allow_empty?
|
23
|
+
@allow_empty.present?
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def batch_callbacks(*events)
|
29
|
+
batch_events = events.map { |event| "batch_#{event}".to_sym }
|
30
|
+
|
31
|
+
define_callbacks_with_handler(*batch_events)
|
32
|
+
|
33
|
+
batch_events.each do |batch_event|
|
34
|
+
set_callback batch_event, :around, ->(_, block) { surveil(batch_event) { block.call } }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def start
|
40
|
+
raise BatchProcessor::CollectionInvalidError unless collection.valid?
|
41
|
+
raise BatchProcessor::AlreadyStartedError if started?
|
42
|
+
raise BatchProcessor::CollectionEmptyError if collection_items.empty? && !allow_empty?
|
43
|
+
|
44
|
+
run_callbacks(:batch_started) do
|
45
|
+
collection_size = collection_items.count
|
46
|
+
|
47
|
+
pipelined do
|
48
|
+
details.class_name = class_name
|
49
|
+
details.started_at = Time.current
|
50
|
+
details.size = collection_size
|
51
|
+
details.pending_jobs_count = collection_size
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
started?
|
56
|
+
end
|
57
|
+
|
58
|
+
def enqueued
|
59
|
+
raise BatchProcessor::AlreadyEnqueuedError if enqueued?
|
60
|
+
raise BatchProcessor::NotStartedError unless started?
|
61
|
+
|
62
|
+
run_callbacks(:batch_enqueued) { details.enqueued_at = Time.current }
|
63
|
+
|
64
|
+
enqueued?
|
65
|
+
end
|
66
|
+
|
67
|
+
def abort!
|
68
|
+
raise BatchProcessor::NotStartedError unless started?
|
69
|
+
raise BatchProcessor::AlreadyFinishedError if finished?
|
70
|
+
raise BatchProcessor::AlreadyAbortedError if aborted?
|
71
|
+
|
72
|
+
run_callbacks(:batch_aborted) { details.aborted_at = Time.current }
|
73
|
+
|
74
|
+
aborted?
|
75
|
+
end
|
76
|
+
|
77
|
+
def clear!
|
78
|
+
raise BatchProcessor::NotAbortedError unless aborted?
|
79
|
+
raise BatchProcessor::AlreadyFinishedError if finished?
|
80
|
+
raise BatchProcessor::AlreadyClearedError if cleared?
|
81
|
+
|
82
|
+
run_callbacks(:batch_cleared) do
|
83
|
+
pending_jobs_count = details.pending_jobs_count
|
84
|
+
running_jobs_count = details.running_jobs_count
|
85
|
+
|
86
|
+
pipelined do
|
87
|
+
details.cleared_at = Time.current
|
88
|
+
details.finished_at = Time.current
|
89
|
+
details.decrement(:pending_jobs_count, by: pending_jobs_count)
|
90
|
+
details.decrement(:running_jobs_count, by: running_jobs_count)
|
91
|
+
details.increment(:cleared_jobs_count, by: pending_jobs_count + running_jobs_count)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
run_callbacks(:batch_finished)
|
96
|
+
|
97
|
+
cleared?
|
98
|
+
end
|
99
|
+
|
100
|
+
def finish
|
101
|
+
raise BatchProcessor::AlreadyFinishedError if finished?
|
102
|
+
raise BatchProcessor::StillProcessingError if unfinished_jobs?
|
103
|
+
|
104
|
+
run_callbacks(:batch_finished) { details.finished_at = Time.current }
|
105
|
+
|
106
|
+
finished?
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# A batch has an ID, details, and is recoverable as an STI.
|
4
|
+
module BatchProcessor
|
5
|
+
module Batch
|
6
|
+
module Core
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
option(:batch_id) { SecureRandom.urlsafe_base64(10) }
|
11
|
+
|
12
|
+
delegate :items, :item_to_job_params, to: :collection, prefix: true
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
attr_reader :collection_input
|
17
|
+
memoize :collection
|
18
|
+
memoize :collection_items
|
19
|
+
memoize :details
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(**input)
|
23
|
+
super(input.slice(*_attributes))
|
24
|
+
@collection_input = input.except(*_attributes)
|
25
|
+
end
|
26
|
+
|
27
|
+
def collection
|
28
|
+
self.class::Collection.new(**collection_input)
|
29
|
+
end
|
30
|
+
|
31
|
+
def details
|
32
|
+
BatchProcessor::BatchDetails.new(batch_id)
|
33
|
+
end
|
34
|
+
|
35
|
+
class_methods do
|
36
|
+
def find(batch_id)
|
37
|
+
class_name = BatchProcessor::BatchDetails.class_name_for_batch_id(batch_id)
|
38
|
+
raise BatchProcessor::NotFoundError, "A Batch with id #{batch_id} was not found." if class_name.nil?
|
39
|
+
|
40
|
+
batch_class = class_name.safe_constantize
|
41
|
+
raise BatchProcessor::ClassMissingError, "#{class_name} is not a class" if batch_class.nil?
|
42
|
+
|
43
|
+
batch_class.new(batch_id: batch_id)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# A batch job is what process each item in the collection.
|
4
|
+
module BatchProcessor
|
5
|
+
module Batch
|
6
|
+
module Job
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
delegate :job_class, to: :class
|
11
|
+
end
|
12
|
+
|
13
|
+
class_methods do
|
14
|
+
def job_class
|
15
|
+
return @job_class if defined?(@job_class)
|
16
|
+
|
17
|
+
"#{name.chomp("Batch")}Job".constantize
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def process_with_job(job_class)
|
23
|
+
raise ArgumentError, "Unbatchable job" unless job_class.ancestors.include? BatchProcessor::BatchJob
|
24
|
+
|
25
|
+
@job_class = job_class
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# The job controller performs updates on and tracks details related to the jobs in a batch.
|
4
|
+
module BatchProcessor
|
5
|
+
module Batch
|
6
|
+
module JobController
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
class_methods do
|
10
|
+
private
|
11
|
+
|
12
|
+
def job_callbacks(*events)
|
13
|
+
job_events = events.map { |event| "job_#{event}".to_sym }
|
14
|
+
|
15
|
+
define_callbacks_with_handler(*job_events)
|
16
|
+
|
17
|
+
job_events.each do |job_event|
|
18
|
+
set_callback job_event, :around, ->(_, block) { surveil(job_event) { block.call } }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
included do
|
24
|
+
job_callbacks :enqueued, :running, :retried, :canceled, :success, :failure
|
25
|
+
|
26
|
+
on_job_success :finish, unless: :unfinished_jobs?
|
27
|
+
on_job_failure :finish, unless: :unfinished_jobs?
|
28
|
+
on_job_canceled :finish, unless: :unfinished_jobs?
|
29
|
+
end
|
30
|
+
|
31
|
+
def job_enqueued
|
32
|
+
raise BatchProcessor::AlreadyEnqueuedError if enqueued?
|
33
|
+
raise BatchProcessor::NotProcessingError unless processing?
|
34
|
+
|
35
|
+
run_callbacks(__method__) { details.increment(:enqueued_jobs_count) }
|
36
|
+
end
|
37
|
+
|
38
|
+
def job_running
|
39
|
+
raise BatchProcessor::NotProcessingError unless processing?
|
40
|
+
|
41
|
+
run_callbacks(__method__) do
|
42
|
+
details.pipelined do
|
43
|
+
details.increment(:running_jobs_count)
|
44
|
+
details.decrement(:pending_jobs_count)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def job_retried
|
50
|
+
raise BatchProcessor::NotProcessingError unless processing?
|
51
|
+
|
52
|
+
run_callbacks(__method__) do
|
53
|
+
details.pipelined do
|
54
|
+
details.increment(:total_retries_count)
|
55
|
+
details.increment(:pending_jobs_count)
|
56
|
+
details.decrement(:failed_jobs_count)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def job_success
|
62
|
+
raise BatchProcessor::NotStartedError unless started?
|
63
|
+
raise BatchProcessor::AlreadyFinishedError if finished?
|
64
|
+
|
65
|
+
run_callbacks(__method__) do
|
66
|
+
details.pipelined do
|
67
|
+
details.increment(:successful_jobs_count)
|
68
|
+
details.decrement(:running_jobs_count)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def job_failure
|
74
|
+
raise BatchProcessor::NotStartedError unless started?
|
75
|
+
raise BatchProcessor::AlreadyFinishedError if finished?
|
76
|
+
|
77
|
+
run_callbacks(__method__) do
|
78
|
+
details.pipelined do
|
79
|
+
details.increment(:failed_jobs_count)
|
80
|
+
details.decrement(:running_jobs_count)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def job_canceled
|
86
|
+
raise BatchProcessor::NotAbortedError unless aborted?
|
87
|
+
|
88
|
+
run_callbacks(__method__) do
|
89
|
+
details.pipelined do
|
90
|
+
details.increment(:canceled_jobs_count)
|
91
|
+
details.decrement(:pending_jobs_count)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Predicates allow inspection of the status of a batch.
|
4
|
+
module BatchProcessor
|
5
|
+
module Batch
|
6
|
+
module Predicates
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
date_predicate :started, :enqueued, :aborted, :cleared, :finished
|
11
|
+
|
12
|
+
job_count_predicate :enqueued, :pending, :running, :failed, :canceled, :unfinished, :finished
|
13
|
+
end
|
14
|
+
|
15
|
+
def processing?
|
16
|
+
started? && !aborted? && !finished?
|
17
|
+
end
|
18
|
+
|
19
|
+
class_methods do
|
20
|
+
private
|
21
|
+
|
22
|
+
def date_predicate(*methods)
|
23
|
+
methods.each do |method|
|
24
|
+
define_method("#{method}?".to_sym) { details.public_send("#{method}_at?".to_sym) }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def job_count_predicate(*methods)
|
29
|
+
methods.each do |method|
|
30
|
+
define_method("#{method}_jobs?".to_sym) { details.public_send("#{method}_jobs_count".to_sym) > 0 }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# When processed, the batch performs a job for each item in its collection.
|
4
|
+
module BatchProcessor
|
5
|
+
module Batch
|
6
|
+
module Processor
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
# This is left mutable for extension and customization
|
10
|
+
# rubocop:disable Style/MutableConstant
|
11
|
+
PROCESSOR_CLASS_BY_STRATEGY = {
|
12
|
+
default: BatchProcessor::Processors::Parallel,
|
13
|
+
parallel: BatchProcessor::Processors::Parallel,
|
14
|
+
sequential: BatchProcessor::Processors::Sequential,
|
15
|
+
}
|
16
|
+
# rubocop:enable Style/MutableConstant
|
17
|
+
|
18
|
+
included do
|
19
|
+
class_attribute :_processor_options, instance_writer: false, default: {}
|
20
|
+
delegate :processor_class, :processor_options, to: :class
|
21
|
+
end
|
22
|
+
|
23
|
+
class_methods do
|
24
|
+
PROCESSOR_CLASS_BY_STRATEGY.except(:default).each do |strategy, processor_class|
|
25
|
+
strategy_method = "with_#{strategy}_processor".to_sym
|
26
|
+
define_method(strategy_method) { @processor_class = processor_class }
|
27
|
+
private strategy_method
|
28
|
+
end
|
29
|
+
|
30
|
+
def process(*arguments)
|
31
|
+
new(*arguments).process
|
32
|
+
end
|
33
|
+
|
34
|
+
def processor_class
|
35
|
+
return @processor_class if defined?(@processor_class)
|
36
|
+
|
37
|
+
PROCESSOR_CLASS_BY_STRATEGY[:default]
|
38
|
+
end
|
39
|
+
|
40
|
+
def inherited(base)
|
41
|
+
dup = _processor_options.dup
|
42
|
+
base._processor_options = dup.each { |k, v| dup[k] = v.dup }
|
43
|
+
super
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def processor_option(option, value = nil)
|
49
|
+
_processor_options[option.to_sym] = value
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def process
|
54
|
+
processor_class.execute(batch: self, **_processor_options)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "batch/core"
|
4
|
+
require_relative "batch/job"
|
5
|
+
require_relative "batch/processor"
|
6
|
+
require_relative "batch/predicates"
|
7
|
+
require_relative "batch/controller"
|
8
|
+
require_relative "batch/job_controller"
|
9
|
+
|
10
|
+
module BatchProcessor
|
11
|
+
class BatchBase < Spicerack::InputObject
|
12
|
+
class BatchCollection < BatchProcessor::Collection; end
|
13
|
+
class Collection < BatchCollection; end
|
14
|
+
|
15
|
+
include BatchProcessor::Batch::Core
|
16
|
+
include BatchProcessor::Batch::Job
|
17
|
+
include BatchProcessor::Batch::Processor
|
18
|
+
include BatchProcessor::Batch::Predicates
|
19
|
+
include BatchProcessor::Batch::Controller
|
20
|
+
include BatchProcessor::Batch::JobController
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# The details of a batch represent the state of the work to process.
|
4
|
+
module BatchProcessor
|
5
|
+
class BatchDetails < Spicerack::RedisModel
|
6
|
+
attr_reader :batch_id
|
7
|
+
|
8
|
+
field :class_name, :string
|
9
|
+
|
10
|
+
field :started_at, :datetime
|
11
|
+
field :enqueued_at, :datetime
|
12
|
+
field :aborted_at, :datetime
|
13
|
+
field :cleared_at, :datetime
|
14
|
+
field :finished_at, :datetime
|
15
|
+
|
16
|
+
field :size, :integer, default: 0
|
17
|
+
|
18
|
+
field :enqueued_jobs_count, :integer, default: 0
|
19
|
+
|
20
|
+
field :pending_jobs_count, :integer, default: 0
|
21
|
+
field :running_jobs_count, :integer, default: 0
|
22
|
+
|
23
|
+
field :successful_jobs_count, :integer, default: 0
|
24
|
+
field :failed_jobs_count, :integer, default: 0
|
25
|
+
|
26
|
+
field :canceled_jobs_count, :integer, default: 0
|
27
|
+
field :cleared_jobs_count, :integer, default: 0
|
28
|
+
|
29
|
+
field :total_retries_count, :integer, default: 0
|
30
|
+
|
31
|
+
class << self
|
32
|
+
def redis_key_for_batch_id(batch_id)
|
33
|
+
"#{name}::#{batch_id}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def class_name_for_batch_id(batch_id)
|
37
|
+
default_redis.hget(redis_key_for_batch_id(batch_id), "class_name")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def initialize(batch_id)
|
42
|
+
@batch_id = batch_id
|
43
|
+
super redis_key: self.class.redis_key_for_batch_id(batch_id)
|
44
|
+
end
|
45
|
+
|
46
|
+
def unfinished_jobs_count
|
47
|
+
sum_up(:pending_jobs_count, :running_jobs_count)
|
48
|
+
end
|
49
|
+
|
50
|
+
def finished_jobs_count
|
51
|
+
sum_up(:successful_jobs_count, :failed_jobs_count, :canceled_jobs_count)
|
52
|
+
end
|
53
|
+
|
54
|
+
def total_jobs_count
|
55
|
+
sum_up(
|
56
|
+
:pending_jobs_count,
|
57
|
+
:running_jobs_count,
|
58
|
+
:successful_jobs_count,
|
59
|
+
:failed_jobs_count,
|
60
|
+
:canceled_jobs_count,
|
61
|
+
:cleared_jobs_count,
|
62
|
+
)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def sum_up(*fields)
|
68
|
+
values_at(*fields).map(&:to_i).sum
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# A batch can only be processed by a batchable job.
|
4
|
+
module BatchProcessor
|
5
|
+
class BatchJob < ActiveJob::Base
|
6
|
+
attr_accessor :batch_id
|
7
|
+
|
8
|
+
class BatchAbortedError < StandardError; end
|
9
|
+
|
10
|
+
after_enqueue(if: :batch_job?) do |job|
|
11
|
+
if job.executions == 0
|
12
|
+
batch.job_enqueued
|
13
|
+
else
|
14
|
+
batch.job_retried
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
before_perform(if: :batch_job?) do
|
19
|
+
raise BatchAbortedError if batch.aborted?
|
20
|
+
|
21
|
+
batch.job_running
|
22
|
+
end
|
23
|
+
|
24
|
+
after_perform(if: :batch_job?) { batch.job_success }
|
25
|
+
|
26
|
+
def rescue_with_handler(exception)
|
27
|
+
batch.job_canceled and return exception if exception.is_a?(BatchAbortedError)
|
28
|
+
|
29
|
+
batch.job_failure if batch_job?
|
30
|
+
|
31
|
+
super
|
32
|
+
end
|
33
|
+
|
34
|
+
def retry_job(*)
|
35
|
+
return if batch_job? && batch.processor_class.disable_retries?
|
36
|
+
|
37
|
+
super
|
38
|
+
end
|
39
|
+
|
40
|
+
def serialize
|
41
|
+
super.merge("batch_id" => batch_id) # rubocop:disable Style/StringHashKeys
|
42
|
+
end
|
43
|
+
|
44
|
+
def deserialize(job_data)
|
45
|
+
super(job_data)
|
46
|
+
self.batch_id = job_data["batch_id"]
|
47
|
+
end
|
48
|
+
|
49
|
+
def batch
|
50
|
+
return unless batch_job?
|
51
|
+
|
52
|
+
@batch ||= BatchProcessor::BatchBase.find(batch_id)
|
53
|
+
end
|
54
|
+
|
55
|
+
def batch_job?
|
56
|
+
batch_id.present?
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# When executed, the processor performs a job for each item in the batch collection.
|
4
|
+
module BatchProcessor
|
5
|
+
module Processor
|
6
|
+
module Execute
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
define_callbacks :execute
|
11
|
+
set_callback :execute, :around, ->(_, block) { surveil(:execute) { block.call } }
|
12
|
+
end
|
13
|
+
|
14
|
+
class_methods do
|
15
|
+
def execute(*arguments)
|
16
|
+
new(*arguments).execute
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def execute
|
21
|
+
run_callbacks(:execute) { process }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Processing a batch performs a job for each item in the batch collection.
|
4
|
+
module BatchProcessor
|
5
|
+
module Processor
|
6
|
+
module Process
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
define_callbacks :collection_processed, :item_processed
|
11
|
+
set_callback :collection_processed, :around, ->(_, block) { surveil(:collection_processed) { block.call } }
|
12
|
+
set_callback :item_processed, :around, ->(_, block) { surveil(:item_processed) { block.call } }
|
13
|
+
end
|
14
|
+
|
15
|
+
def process
|
16
|
+
batch.start
|
17
|
+
|
18
|
+
run_callbacks(:collection_processed) { process_collection }
|
19
|
+
|
20
|
+
batch.finish unless batch.finished? || batch.unfinished_jobs?
|
21
|
+
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
def process_collection_item(_item)
|
26
|
+
# Abstract
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def iterator_method
|
32
|
+
batch.collection_items.respond_to?(:find_each) ? :find_each : :each
|
33
|
+
end
|
34
|
+
|
35
|
+
def process_collection
|
36
|
+
batch.collection_items.public_send(iterator_method) do |item|
|
37
|
+
run_callbacks(:item_processed) { process_collection_item(batch.collection_item_to_job_params(item)) }
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "processor/process"
|
4
|
+
require_relative "processor/execute"
|
5
|
+
|
6
|
+
module BatchProcessor
|
7
|
+
class ProcessorBase < Spicerack::InputObject
|
8
|
+
argument :batch, allow_nil: false
|
9
|
+
|
10
|
+
class << self
|
11
|
+
def disable_retries?
|
12
|
+
false
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
include BatchProcessor::Processor::Process
|
17
|
+
include BatchProcessor::Processor::Execute
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BatchProcessor
|
4
|
+
module Processors
|
5
|
+
class Parallel < BatchProcessor::ProcessorBase
|
6
|
+
set_callback(:collection_processed, :after) { batch.enqueued }
|
7
|
+
|
8
|
+
def process_collection_item(item)
|
9
|
+
job = batch.job_class.new(item)
|
10
|
+
job.batch_id = batch.batch_id
|
11
|
+
job.enqueue
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BatchProcessor
|
4
|
+
module Processors
|
5
|
+
class Sequential < BatchProcessor::ProcessorBase
|
6
|
+
option :continue_after_exception, default: false
|
7
|
+
option :sorted, default: false
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def disable_retries?
|
11
|
+
true
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def process_collection_item(item)
|
16
|
+
job = batch.job_class.new(item)
|
17
|
+
job.batch_id = batch.batch_id
|
18
|
+
job.perform_now
|
19
|
+
rescue StandardError => exception
|
20
|
+
raise exception unless continue_after_exception
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def iterator_method
|
26
|
+
sorted ? :each : super
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveJob
|
4
|
+
module QueueAdapters
|
5
|
+
class TestAdapter
|
6
|
+
# BatchProcessor relies on serialized arguments being passed to ActiveJob, as the batch_id is put into the
|
7
|
+
# serialized hash to keep it out of the arguments and prevent needing a deeper override of ActiveJob's API.
|
8
|
+
# This works totally fine and perfect when you are using the test adapter and processing the jobs, as the
|
9
|
+
# internal implementation is to just call ActiveJob::Base.execute on the serialized hash. Weirdly, instead of...
|
10
|
+
# just putting the serialized hash into an array and using that, the implementation literally reinvents a simpler
|
11
|
+
# wheel WHILE USING THE ACTUAL SERIALIZED HASH ITSELF TO EXTRACT ARGUMENTS. So like... I changed that.
|
12
|
+
#
|
13
|
+
# There didn't seem to be a value to me to put this implementation in a way which keeps the original API because
|
14
|
+
# the actual serialized API itself is so similar, refactoring any test implementation to work with the change
|
15
|
+
# should be trivial.
|
16
|
+
def job_to_hash(job, extras = {})
|
17
|
+
job.serialize.merge!(extras)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "custom_matchers/set_processor_option"
|
4
|
+
require_relative "custom_matchers/use_batch_processor_strategy"
|
5
|
+
require_relative "custom_matchers/use_default_job_class"
|
6
|
+
require_relative "custom_matchers/use_default_processor"
|
7
|
+
require_relative "custom_matchers/use_job_class"
|
8
|
+
require_relative "custom_matchers/use_parallel_processor"
|
9
|
+
require_relative "custom_matchers/use_sequential_processor"
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RSpec matcher that tests usages of batches which do not specify a processor
|
4
|
+
#
|
5
|
+
# class ExampleBatch < ApplicationBatch
|
6
|
+
# processor_option :sorted, true
|
7
|
+
# end
|
8
|
+
#
|
9
|
+
# RSpec.describe ExampleBatch do
|
10
|
+
# it { is_expected.to set_processor_option :sorted, true }
|
11
|
+
# end
|
12
|
+
|
13
|
+
RSpec::Matchers.define :set_processor_option do |key, value|
|
14
|
+
match { expect(test_subject._processor_options[key]).to eq value }
|
15
|
+
description { "set processor option #{key}" }
|
16
|
+
failure_message { "expected #{test_subject} to set processor option #{key} to #{value}" }
|
17
|
+
failure_message_when_negated { "expected #{test_subject} not to set processor option #{key} to #{value}" }
|
18
|
+
|
19
|
+
def test_subject
|
20
|
+
subject.is_a?(Class) ? subject : subject.class
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RSpec matcher to DRY out the similarities between the other batch processor matchers.
|
4
|
+
#
|
5
|
+
# class ExampleBatch < ApplicationBatch
|
6
|
+
# end
|
7
|
+
#
|
8
|
+
# RSpec.describe ExampleBatch do
|
9
|
+
# it { is_expected.to use_batch_processor_strategy :default }
|
10
|
+
# end
|
11
|
+
|
12
|
+
RSpec::Matchers.define :use_batch_processor_strategy do |strategy|
|
13
|
+
match { test_subject.processor_class == BatchProcessor::Batch::Processor::PROCESSOR_CLASS_BY_STRATEGY[strategy] }
|
14
|
+
description { "use #{strategy} processor" }
|
15
|
+
failure_message { "expected #{test_subject} to use #{strategy} processor" }
|
16
|
+
failure_message_when_negated { "expected #{test_subject} not to use #{strategy} processor" }
|
17
|
+
|
18
|
+
def test_subject
|
19
|
+
subject.is_a?(Class) ? subject : subject.class
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RSpec matcher to DRY out the similarities between the other batch processor matchers.
|
4
|
+
#
|
5
|
+
# class ExampleBatch < ApplicationBatch
|
6
|
+
# end
|
7
|
+
#
|
8
|
+
# RSpec.describe ExampleBatch do
|
9
|
+
# it { is_expected.to use_default_job_class }
|
10
|
+
# end
|
11
|
+
|
12
|
+
RSpec::Matchers.define :use_default_job_class do
|
13
|
+
match { is_expected.to use_job_class "#{test_subject.name.chomp("Batch")}Job".constantize }
|
14
|
+
|
15
|
+
def test_subject
|
16
|
+
subject.is_a?(Class) ? subject : subject.class
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RSpec matcher that tests usages of batches which do not specify a processor
|
4
|
+
#
|
5
|
+
# class ExampleBatch < ApplicationBatch
|
6
|
+
# end
|
7
|
+
#
|
8
|
+
# RSpec.describe ExampleBatch do
|
9
|
+
# it { is_expected.to use_default_processor }
|
10
|
+
# end
|
11
|
+
|
12
|
+
RSpec::Matchers.define :use_default_processor do
|
13
|
+
match { is_expected.to use_batch_processor_strategy :default }
|
14
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RSpec matcher to DRY out the similarities between the other batch processor matchers.
|
4
|
+
#
|
5
|
+
# class ExampleBatch < ApplicationBatch
|
6
|
+
# def self.job_class
|
7
|
+
# SpecialJobClass
|
8
|
+
# end
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# RSpec.describe ExampleBatch do
|
12
|
+
# it { is_expected.to use_job_class SpecialJobClass }
|
13
|
+
# end
|
14
|
+
|
15
|
+
RSpec::Matchers.define :use_job_class do |job_class|
|
16
|
+
match { test_subject.job_class == job_class }
|
17
|
+
description { "use #{job_class} job" }
|
18
|
+
failure_message { "expected #{test_subject} to use #{job_class} job" }
|
19
|
+
failure_message_when_negated { "expected #{test_subject} not to use #{job_class} job" }
|
20
|
+
|
21
|
+
def test_subject
|
22
|
+
subject.is_a?(Class) ? subject : subject.class
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RSpec matcher that tests usages of `.with_parallel_processor`
|
4
|
+
#
|
5
|
+
# class ExampleBatch < ApplicationBatch
|
6
|
+
# with_parallel_processor
|
7
|
+
# end
|
8
|
+
#
|
9
|
+
# RSpec.describe ExampleBatch do
|
10
|
+
# it { is_expected.to use_parallel_processor }
|
11
|
+
# end
|
12
|
+
|
13
|
+
RSpec::Matchers.define :use_parallel_processor do
|
14
|
+
match { is_expected.to use_batch_processor_strategy :parallel }
|
15
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# RSpec matcher that tests usages of `.with_parallel_processor`
|
4
|
+
#
|
5
|
+
# class ExampleBatch < ApplicationBatch
|
6
|
+
# with_sequential_processor
|
7
|
+
# end
|
8
|
+
#
|
9
|
+
# RSpec.describe ExampleBatch do
|
10
|
+
# it { is_expected.to use_sequential_processor }
|
11
|
+
# end
|
12
|
+
|
13
|
+
RSpec::Matchers.define :use_sequential_processor do
|
14
|
+
match { is_expected.to use_batch_processor_strategy :sequential }
|
15
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: batch_processor
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Eric Garside
|
@@ -231,7 +231,37 @@ email:
|
|
231
231
|
executables: []
|
232
232
|
extensions: []
|
233
233
|
extra_rdoc_files: []
|
234
|
-
files:
|
234
|
+
files:
|
235
|
+
- LICENSE.txt
|
236
|
+
- README.md
|
237
|
+
- lib/batch_processor.rb
|
238
|
+
- lib/batch_processor/batch/controller.rb
|
239
|
+
- lib/batch_processor/batch/core.rb
|
240
|
+
- lib/batch_processor/batch/job.rb
|
241
|
+
- lib/batch_processor/batch/job_controller.rb
|
242
|
+
- lib/batch_processor/batch/predicates.rb
|
243
|
+
- lib/batch_processor/batch/processor.rb
|
244
|
+
- lib/batch_processor/batch_base.rb
|
245
|
+
- lib/batch_processor/batch_details.rb
|
246
|
+
- lib/batch_processor/batch_job.rb
|
247
|
+
- lib/batch_processor/collection.rb
|
248
|
+
- lib/batch_processor/processor/execute.rb
|
249
|
+
- lib/batch_processor/processor/process.rb
|
250
|
+
- lib/batch_processor/processor_base.rb
|
251
|
+
- lib/batch_processor/processors/parallel.rb
|
252
|
+
- lib/batch_processor/processors/sequential.rb
|
253
|
+
- lib/batch_processor/rspec/active_job_test_adapter_monkeypatch.rb
|
254
|
+
- lib/batch_processor/rspec/custom_matchers.rb
|
255
|
+
- lib/batch_processor/rspec/custom_matchers/set_processor_option.rb
|
256
|
+
- lib/batch_processor/rspec/custom_matchers/use_batch_processor_strategy.rb
|
257
|
+
- lib/batch_processor/rspec/custom_matchers/use_default_job_class.rb
|
258
|
+
- lib/batch_processor/rspec/custom_matchers/use_default_processor.rb
|
259
|
+
- lib/batch_processor/rspec/custom_matchers/use_job_class.rb
|
260
|
+
- lib/batch_processor/rspec/custom_matchers/use_parallel_processor.rb
|
261
|
+
- lib/batch_processor/rspec/custom_matchers/use_sequential_processor.rb
|
262
|
+
- lib/batch_processor/rspec/shoulda_matcher_helper.rb
|
263
|
+
- lib/batch_processor/spec_helper.rb
|
264
|
+
- lib/batch_processor/version.rb
|
235
265
|
homepage: https://github.com/Freshly/batch_processor
|
236
266
|
licenses:
|
237
267
|
- MIT
|