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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -0
  3. data/README.md +50 -0
  4. data/lib/batch_processor.rb +34 -0
  5. data/lib/batch_processor/batch/controller.rb +110 -0
  6. data/lib/batch_processor/batch/core.rb +48 -0
  7. data/lib/batch_processor/batch/job.rb +30 -0
  8. data/lib/batch_processor/batch/job_controller.rb +97 -0
  9. data/lib/batch_processor/batch/predicates.rb +36 -0
  10. data/lib/batch_processor/batch/processor.rb +58 -0
  11. data/lib/batch_processor/batch_base.rb +22 -0
  12. data/lib/batch_processor/batch_details.rb +71 -0
  13. data/lib/batch_processor/batch_job.rb +59 -0
  14. data/lib/batch_processor/collection.rb +13 -0
  15. data/lib/batch_processor/processor/execute.rb +25 -0
  16. data/lib/batch_processor/processor/process.rb +42 -0
  17. data/lib/batch_processor/processor_base.rb +19 -0
  18. data/lib/batch_processor/processors/parallel.rb +15 -0
  19. data/lib/batch_processor/processors/sequential.rb +30 -0
  20. data/lib/batch_processor/rspec/active_job_test_adapter_monkeypatch.rb +21 -0
  21. data/lib/batch_processor/rspec/custom_matchers.rb +9 -0
  22. data/lib/batch_processor/rspec/custom_matchers/set_processor_option.rb +22 -0
  23. data/lib/batch_processor/rspec/custom_matchers/use_batch_processor_strategy.rb +21 -0
  24. data/lib/batch_processor/rspec/custom_matchers/use_default_job_class.rb +18 -0
  25. data/lib/batch_processor/rspec/custom_matchers/use_default_processor.rb +14 -0
  26. data/lib/batch_processor/rspec/custom_matchers/use_job_class.rb +24 -0
  27. data/lib/batch_processor/rspec/custom_matchers/use_parallel_processor.rb +15 -0
  28. data/lib/batch_processor/rspec/custom_matchers/use_sequential_processor.rb +15 -0
  29. data/lib/batch_processor/rspec/shoulda_matcher_helper.rb +7 -0
  30. data/lib/batch_processor/spec_helper.rb +4 -0
  31. data/lib/batch_processor/version.rb +5 -0
  32. metadata +32 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f2c6357a153ebd1b3559ffca1eb4a039b0fc326180292ea1b0e182eede3b69e
4
- data.tar.gz: 7ebf000d79144b788b76867c242558424ba97e2c7bdb762d9edc7b88ff335e40
3
+ metadata.gz: 92f896e89a4f7222fb235fc9db1db9631792ff72508b2af911807db824bc9c57
4
+ data.tar.gz: ca8818ccccf18d57cfbc5a2c9628f3a4d349417789a90e1d2ca2fd2e77ac0c67
5
5
  SHA512:
6
- metadata.gz: bf7dea4b7afd8e690eb24c09b867b568562bcfe66695ce542b390a107f2f0c8b8772132473c492aa518de1fb324b145c26ed7f4946003fb4ce5c8d1204048d9a
7
- data.tar.gz: f245ab4a0848d1032180b20e3a240d4716ad93e3fe6a379e51e1a7148f2755bcaddb5439856812acd8a4530e6ca627c34bc1743d13386fc16cfbb4d5ba61eab5
6
+ metadata.gz: 88f75329cbdada1be5be38abd8edd8da5e31509a745917e56dc0d317ce9420f1a6cb7622968a40ec4342237d244c92bbaba4a3b9a8cb9b5b4b739baee69ebae7
7
+ data.tar.gz: b6ca0129ffeef8ef479bcee475420d891972d1ef307daa50140bcd63b150d2869909e8dcbfec0157e4a386e4b03baa6dcc6f98a54395eb3af5cbfd94ed57094c
@@ -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.
@@ -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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BatchProcessor
4
+ class Collection < Spicerack::InputModel
5
+ def items
6
+ []
7
+ end
8
+
9
+ def item_to_job_params(item)
10
+ item
11
+ end
12
+ end
13
+ 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(Shoulda::Matchers::ActiveModel)
4
+ RSpec.configure do |config|
5
+ config.include(Shoulda::Matchers::ActiveModel, type: :batch_collection)
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rspec/custom_matchers"
4
+ require_relative "rspec/shoulda_matcher_helper"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BatchProcessor
4
+ VERSION = "0.2.2"
5
+ 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.1
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