batch_processor 0.2.1 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Gem Version](https://badge.fury.io/rb/batch_processor.svg)](https://badge.fury.io/rb/batch_processor)
|
6
|
+
[![Build Status](https://semaphoreci.com/api/v1/freshly/batch_processor/branches/master/badge.svg)](https://semaphoreci.com/freshly/batch_processor)
|
7
|
+
[![Maintainability](https://api.codeclimate.com/v1/badges/fbdaeaf118a16a55ab7d/maintainability)](https://codeclimate.com/github/Freshly/batch_processor/maintainability)
|
8
|
+
[![Test Coverage](https://api.codeclimate.com/v1/badges/fbdaeaf118a16a55ab7d/test_coverage)](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
|