maintenance_tasks 1.2.2 → 1.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45bd2df064845d494e9bfd74589131d6b77caf345b3d5f4c852403dfa05fd5ac
4
- data.tar.gz: a48816bc76477c58717f273a1318249dd638daf5b186c8b196df57d41b2e50ec
3
+ metadata.gz: 649f66cc11a303666134c75aad8575b81eb376e358f69312180e21a14528ce50
4
+ data.tar.gz: b564b46e647c467c5d93a6198ab112729623059cfe194d1115d095c8ab8d9953
5
5
  SHA512:
6
- metadata.gz: d96b17402de319cef50393e133100317cbb480180e6c1041e998e24fa1ccd224a4f252832b60c068f489217c4c1ea60121e15e015559746bf6dfc967bceb4d12
7
- data.tar.gz: 5fe898977ebda4b13829df10917466ab4c7474cf705600f5574b561073b9191cf3b200b36bc32d34f9477be51b80b70335e2ed759b241de62771500ff7e74ed5
6
+ metadata.gz: f313350c5a80fa8840b23976eb7cde8776f825bc25a9b6a8fb7ce5d6f1abe8148d547ae7b2c1368a72caf980a2a12377e33b7c325e1447de3c0a0b0686d87a8d
7
+ data.tar.gz: '084950e7be06503e12d437294c784d02e2f0449c76db6297c5f95fcfd40845e4ce7e3354e16fbe12dd99b82554946f16fbc3415c047c17109aab1303c6b81b9b'
data/README.md CHANGED
@@ -112,6 +112,46 @@ title,content
112
112
  My Title,Hello World!
113
113
  ```
114
114
 
115
+ ### Throttling
116
+
117
+ Maintenance Tasks often modify a lot of data and can be taxing on your database.
118
+ The gem provides a throttling mechanism that can be used to throttle a Task when
119
+ a given condition is met. If a Task is throttled, it will be interrupted and
120
+ retried after a backoff period has passed. The default backoff is 30 seconds.
121
+ Specify the throttle condition as a block:
122
+
123
+ ```ruby
124
+ # app/tasks/maintenance/update_posts_throttled_task.rb
125
+ module Maintenance
126
+ class UpdatePostsThrottledTask < MaintenanceTasks::Task
127
+ throttle_on(backoff: 1.minute) do
128
+ DatabaseStatus.unhealthy?
129
+ end
130
+
131
+ def collection
132
+ Post.all
133
+ end
134
+
135
+ def count
136
+ collection.count
137
+ end
138
+
139
+ def process(post)
140
+ post.update!(content: "New content added on #{Time.now.utc}")
141
+ end
142
+ end
143
+ end
144
+ ```
145
+
146
+ Note that it's up to you to define a throttling condition that makes sense for
147
+ your app. Shopify implements `DatabaseStatus.healthy?` to check various MySQL
148
+ metrics such as replication lag, DB threads, whether DB writes are available,
149
+ etc.
150
+
151
+ Tasks can define multiple throttle conditions. Throttle conditions are inherited
152
+ by descendants, and new conditions will be appended without impacting existing
153
+ conditions.
154
+
115
155
  ### Considerations when writing Tasks
116
156
 
117
157
  MaintenanceTasks relies on the queue adapter configured for your application to
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+ module MaintenanceTasks
3
+ # Concern that holds the behaviour of the job that runs the tasks. It is
4
+ # included in {TaskJob} and if MaintenanceTasks.job is overridden, it must be
5
+ # included in the job.
6
+ module TaskJobConcern
7
+ extend ActiveSupport::Concern
8
+ include JobIteration::Iteration
9
+
10
+ included do
11
+ before_perform(:before_perform)
12
+
13
+ on_start(:on_start)
14
+ on_complete(:on_complete)
15
+ on_shutdown(:on_shutdown)
16
+
17
+ after_perform(:after_perform)
18
+
19
+ rescue_from StandardError, with: :on_error
20
+ end
21
+
22
+ class_methods do
23
+ # Overrides ActiveJob::Exceptions.retry_on to declare it unsupported.
24
+ # The use of rescue_from prevents retry_on from being usable.
25
+ def retry_on(*, **)
26
+ raise NotImplementedError, "retry_on is not supported"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def build_enumerator(_run, cursor:)
33
+ cursor ||= @run.cursor
34
+ collection = @task.collection
35
+
36
+ collection_enum = case collection
37
+ when ActiveRecord::Relation
38
+ enumerator_builder.active_record_on_records(collection, cursor: cursor)
39
+ when Array
40
+ enumerator_builder.build_array_enumerator(collection, cursor: cursor)
41
+ when CSV
42
+ JobIteration::CsvEnumerator.new(collection).rows(cursor: cursor)
43
+ else
44
+ raise ArgumentError, "#{@task.class.name}#collection must be either "\
45
+ "an Active Record Relation, Array, or CSV."
46
+ end
47
+
48
+ @task.throttle_conditions.reduce(collection_enum) do |enum, condition|
49
+ enumerator_builder.build_throttle_enumerator(enum, **condition)
50
+ end
51
+ end
52
+
53
+ # Performs task iteration logic for the current input returned by the
54
+ # enumerator.
55
+ #
56
+ # @param input [Object] the current element from the enumerator.
57
+ # @param _run [Run] the current Run, passed as an argument by Job Iteration.
58
+ def each_iteration(input, _run)
59
+ throw(:abort, :skip_complete_callbacks) if @run.stopping?
60
+ task_iteration(input)
61
+ @ticker.tick
62
+ @run.reload_status
63
+ end
64
+
65
+ def task_iteration(input)
66
+ @task.process(input)
67
+ rescue => error
68
+ @errored_element = input
69
+ raise error
70
+ end
71
+
72
+ def before_perform
73
+ @run = arguments.first
74
+ @task = Task.named(@run.task_name).new
75
+ if @task.respond_to?(:csv_content=)
76
+ @task.csv_content = @run.csv_file.download
77
+ end
78
+ @run.job_id = job_id
79
+
80
+ @run.running! unless @run.stopping?
81
+
82
+ @ticker = Ticker.new(MaintenanceTasks.ticker_delay) do |ticks, duration|
83
+ @run.persist_progress(ticks, duration)
84
+ end
85
+ end
86
+
87
+ def on_start
88
+ @run.update!(started_at: Time.now, tick_total: @task.count)
89
+ end
90
+
91
+ def on_complete
92
+ @run.status = :succeeded
93
+ @run.ended_at = Time.now
94
+ end
95
+
96
+ def on_shutdown
97
+ if @run.cancelling?
98
+ @run.status = :cancelled
99
+ @run.ended_at = Time.now
100
+ else
101
+ @run.status = @run.pausing? ? :paused : :interrupted
102
+ @run.cursor = cursor_position
103
+ end
104
+
105
+ @ticker.persist
106
+ end
107
+
108
+ # We are reopening a private part of Job Iteration's API here, so we should
109
+ # ensure the method is still defined upstream. This way, in the case where
110
+ # the method changes upstream, we catch it at load time instead of at
111
+ # runtime while calling `super`.
112
+ unless JobIteration::Iteration
113
+ .private_method_defined?(:reenqueue_iteration_job)
114
+ error_message = <<~HEREDOC
115
+ JobIteration::Iteration#reenqueue_iteration_job is expected to be
116
+ defined. Upgrading the maintenance_tasks gem should solve this problem.
117
+ HEREDOC
118
+ raise error_message
119
+ end
120
+ def reenqueue_iteration_job(should_ignore: true)
121
+ super() unless should_ignore
122
+ @reenqueue_iteration_job = true
123
+ end
124
+
125
+ def after_perform
126
+ @run.save!
127
+ if defined?(@reenqueue_iteration_job) && @reenqueue_iteration_job
128
+ reenqueue_iteration_job(should_ignore: false)
129
+ end
130
+ end
131
+
132
+ def on_error(error)
133
+ @ticker.persist if defined?(@ticker)
134
+
135
+ if defined?(@run)
136
+ @run.persist_error(error)
137
+
138
+ task_context = {
139
+ task_name: @run.task_name,
140
+ started_at: @run.started_at,
141
+ ended_at: @run.ended_at,
142
+ }
143
+ else
144
+ task_context = {}
145
+ end
146
+ errored_element = @errored_element if defined?(@errored_element)
147
+ MaintenanceTasks.error_handler.call(error, task_context, errored_element)
148
+ end
149
+ end
150
+ end
@@ -3,139 +3,6 @@
3
3
  module MaintenanceTasks
4
4
  # Base class that is inherited by the host application's task classes.
5
5
  class TaskJob < ActiveJob::Base
6
- include JobIteration::Iteration
7
-
8
- before_perform(:before_perform)
9
-
10
- on_start(:on_start)
11
- on_complete(:on_complete)
12
- on_shutdown(:on_shutdown)
13
-
14
- after_perform(:after_perform)
15
-
16
- rescue_from StandardError, with: :on_error
17
-
18
- class << self
19
- # Overrides ActiveJob::Exceptions.retry_on to declare it unsupported.
20
- # The use of rescue_from prevents retry_on from being usable.
21
- def retry_on(*, **)
22
- raise NotImplementedError, "retry_on is not supported"
23
- end
24
- end
25
-
26
- private
27
-
28
- def build_enumerator(_run, cursor:)
29
- cursor ||= @run.cursor
30
- collection = @task.collection
31
-
32
- case collection
33
- when ActiveRecord::Relation
34
- enumerator_builder.active_record_on_records(collection, cursor: cursor)
35
- when Array
36
- enumerator_builder.build_array_enumerator(collection, cursor: cursor)
37
- when CSV
38
- JobIteration::CsvEnumerator.new(collection).rows(cursor: cursor)
39
- else
40
- raise ArgumentError, "#{@task.class.name}#collection must be either "\
41
- "an Active Record Relation, Array, or CSV."
42
- end
43
- end
44
-
45
- # Performs task iteration logic for the current input returned by the
46
- # enumerator.
47
- #
48
- # @param input [Object] the current element from the enumerator.
49
- # @param _run [Run] the current Run, passed as an argument by Job Iteration.
50
- def each_iteration(input, _run)
51
- throw(:abort, :skip_complete_callbacks) if @run.stopping?
52
- task_iteration(input)
53
- @ticker.tick
54
- @run.reload_status
55
- end
56
-
57
- def task_iteration(input)
58
- @task.process(input)
59
- rescue => error
60
- @errored_element = input
61
- raise error
62
- end
63
-
64
- def before_perform
65
- @run = arguments.first
66
- @task = Task.named(@run.task_name).new
67
- if @task.respond_to?(:csv_content=)
68
- @task.csv_content = @run.csv_file.download
69
- end
70
- @run.job_id = job_id
71
-
72
- @run.running! unless @run.stopping?
73
-
74
- @ticker = Ticker.new(MaintenanceTasks.ticker_delay) do |ticks, duration|
75
- @run.persist_progress(ticks, duration)
76
- end
77
- end
78
-
79
- def on_start
80
- @run.update!(started_at: Time.now, tick_total: @task.count)
81
- end
82
-
83
- def on_complete
84
- @run.status = :succeeded
85
- @run.ended_at = Time.now
86
- end
87
-
88
- def on_shutdown
89
- if @run.cancelling?
90
- @run.status = :cancelled
91
- @run.ended_at = Time.now
92
- else
93
- @run.status = @run.pausing? ? :paused : :interrupted
94
- @run.cursor = cursor_position
95
- end
96
-
97
- @ticker.persist
98
- end
99
-
100
- # We are reopening a private part of Job Iteration's API here, so we should
101
- # ensure the method is still defined upstream. This way, in the case where
102
- # the method changes upstream, we catch it at load time instead of at
103
- # runtime while calling `super`.
104
- unless private_method_defined?(:reenqueue_iteration_job)
105
- error_message = <<~HEREDOC
106
- JobIteration::Iteration#reenqueue_iteration_job is expected to be
107
- defined. Upgrading the maintenance_tasks gem should solve this problem.
108
- HEREDOC
109
- raise error_message
110
- end
111
- def reenqueue_iteration_job(should_ignore: true)
112
- super() unless should_ignore
113
- @reenqueue_iteration_job = true
114
- end
115
-
116
- def after_perform
117
- @run.save!
118
- if defined?(@reenqueue_iteration_job) && @reenqueue_iteration_job
119
- reenqueue_iteration_job(should_ignore: false)
120
- end
121
- end
122
-
123
- def on_error(error)
124
- @ticker.persist if defined?(@ticker)
125
-
126
- if defined?(@run)
127
- @run.persist_error(error)
128
-
129
- task_context = {
130
- task_name: @run.task_name,
131
- started_at: @run.started_at,
132
- ended_at: @run.ended_at,
133
- }
134
- else
135
- task_context = {}
136
- end
137
- errored_element = @errored_element if defined?(@errored_element)
138
- MaintenanceTasks.error_handler.call(error, task_context, errored_element)
139
- end
6
+ include TaskJobConcern
140
7
  end
141
8
  end
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  module MaintenanceTasks
4
3
  # Base class that is inherited by the host application's task classes.
5
4
  class Task
@@ -7,6 +6,13 @@ module MaintenanceTasks
7
6
 
8
7
  class NotFoundError < NameError; end
9
8
 
9
+ # The throttle conditions for a given Task. This is provided as an array of
10
+ # hashes, with each hash specifying two keys: throttle_condition and
11
+ # backoff. Note that Tasks inherit conditions from their superclasses.
12
+ #
13
+ # @api private
14
+ class_attribute :throttle_conditions, default: []
15
+
10
16
  class << self
11
17
  # Finds a Task with the given name.
12
18
  #
@@ -72,6 +78,19 @@ module MaintenanceTasks
72
78
  new.count
73
79
  end
74
80
 
81
+ # Add a condition under which this Task will be throttled.
82
+ #
83
+ # @param backoff [ActiveSupport::Duration] optionally, a custom backoff
84
+ # can be specified. This is the time to wait before retrying the Task.
85
+ # If no value is specified, it defaults to 30 seconds.
86
+ # @yieldreturn [Boolean] where the throttle condition is being met,
87
+ # indicating that the Task should throttle.
88
+ def throttle_on(backoff: 30.seconds, &condition)
89
+ self.throttle_conditions += [
90
+ { throttle_on: condition, backoff: backoff },
91
+ ]
92
+ end
93
+
75
94
  private
76
95
 
77
96
  def load_constants
@@ -4,11 +4,13 @@ require 'rails_helper'
4
4
  module <%= tasks_module %>
5
5
  <% module_namespacing do -%>
6
6
  RSpec.describe <%= class_name %>Task do
7
- # describe '#process' do
8
- # it 'performs a task iteration' do
9
- # <%= tasks_module %>::<%= class_name %>Task.process(element)
10
- # end
11
- # end
7
+ describe "#process" do
8
+ subject(:process) { described_class.process(element) }
9
+ let(:element) {
10
+ # Object to be processed in a single iteration of this task
11
+ }
12
+ pending "add some examples to (or delete) #{__FILE__}"
13
+ end
12
14
  end
13
15
  <% end -%>
14
16
  end
@@ -7,46 +7,56 @@ require "active_record"
7
7
  require "job-iteration"
8
8
  require "maintenance_tasks/engine"
9
9
 
10
- # Force the TaskJob class to load so we can verify upstream compatibility with
11
- # the JobIteration gem
12
- require_relative "../app/jobs/maintenance_tasks/task_job"
13
-
14
10
  # The engine's namespace module. It provides isolation between the host
15
11
  # application's code and the engine-specific code. Top-level engine constants
16
12
  # and variables are defined under this module.
17
13
  module MaintenanceTasks
18
- # The module to namespace Tasks in, as a String. Defaults to 'Maintenance'.
19
- # @param [String] the tasks_module value.
14
+ # @!attribute tasks_module
15
+ # @scope class
16
+ #
17
+ # The module to namespace Tasks in, as a String. Defaults to 'Maintenance'.
18
+ # @return [String] the name of the module.
20
19
  mattr_accessor :tasks_module, default: "Maintenance"
21
20
 
22
- # Defines the job to be used to perform Tasks. This job must be either
23
- # `MaintenanceTasks::TaskJob` or a class that inherits from it.
21
+ # @!attribute job
22
+ # @scope class
24
23
  #
25
- # @param [String] the name of the job class.
24
+ # The name of the job to be used to perform Tasks. Defaults to
25
+ # `"MaintenanceTasks::TaskJob"`. This job must be either a class that
26
+ # inherits from {TaskJob} or a class that includes {TaskJobConcern}.
27
+ #
28
+ # @return [String] the name of the job class.
26
29
  mattr_accessor :job, default: "MaintenanceTasks::TaskJob"
27
30
 
28
- # After each iteration, the progress of the task may be updated. This duration
29
- # in seconds limits these updates, skipping if the duration since the last
30
- # update is lower than this value, except if the job is interrupted, in which
31
- # case the progress will always be recorded.
31
+ # @!attribute ticker_delay
32
+ # @scope class
33
+ #
34
+ # The delay between updates to the tick count. After each iteration, the
35
+ # progress of the Task may be updated. This duration in seconds limits
36
+ # these updates, skipping if the duration since the last update is lower
37
+ # than this value, except if the job is interrupted, in which case the
38
+ # progress will always be recorded.
32
39
  #
33
- # @param [ActiveSupport::Duration, Numeric] Duration of the delay to update
34
- # the ticker during Task iterations.
40
+ # @return [ActiveSupport::Duration, Numeric] duration of the delay between
41
+ # updates to the tick count during Task iterations.
35
42
  mattr_accessor :ticker_delay, default: 1.second
36
43
 
37
- # Specifies which Active Storage service to use for uploading CSV file blobs.
44
+ # @!attribute active_storage_service
45
+ # @scope class
38
46
  #
39
- # @param [Symbol] the key for the storage service, as specified in the app's
40
- # config/storage.yml.
47
+ # The Active Storage service to use for uploading CSV file blobs.
48
+ #
49
+ # @return [Symbol] the key for the storage service, as specified in the
50
+ # app's config/storage.yml.
41
51
  mattr_accessor :active_storage_service
42
52
 
43
- # Retrieves the callback to be performed when an error occurs in the task.
53
+ # @private
44
54
  def self.error_handler
45
55
  return @error_handler if defined?(@error_handler)
46
56
  @error_handler = ->(_error, _task_context, _errored_element) {}
47
57
  end
48
58
 
49
- # Defines a callback to be performed when an error occurs in the task.
59
+ # @private
50
60
  def self.error_handler=(error_handler)
51
61
  unless error_handler.arity == 3
52
62
  ActiveSupport::Deprecation.warn(
@@ -59,4 +69,12 @@ module MaintenanceTasks
59
69
  end
60
70
  @error_handler = error_handler
61
71
  end
72
+
73
+ # @!attribute error_handler
74
+ # @scope class
75
+ #
76
+ # The callback to perform when an error occurs in the Task. See the
77
+ # {file:README#label-Customizing+the+error+handler} for details.
78
+ #
79
+ # @return [Proc] the callback to perform when an error occurs in the Task.
62
80
  end
@@ -12,6 +12,7 @@ module MaintenanceTasks
12
12
  end
13
13
 
14
14
  config.to_prepare do
15
+ _ = TaskJobConcern # load this for JobIteration compatibility check
15
16
  unless Rails.autoloaders.zeitwerk_enabled?
16
17
  tasks_module = MaintenanceTasks.tasks_module.underscore
17
18
  Dir["#{Rails.root}/app/tasks/#{tasks_module}/*.rb"].each do |file|
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maintenance_tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify Engineering
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-04-16 00:00:00.000000000 Z
11
+ date: 2021-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -94,6 +94,7 @@ files:
94
94
  - app/controllers/maintenance_tasks/tasks_controller.rb
95
95
  - app/helpers/maintenance_tasks/application_helper.rb
96
96
  - app/helpers/maintenance_tasks/tasks_helper.rb
97
+ - app/jobs/concerns/maintenance_tasks/task_job_concern.rb
97
98
  - app/jobs/maintenance_tasks/task_job.rb
98
99
  - app/models/maintenance_tasks/application_record.rb
99
100
  - app/models/maintenance_tasks/csv_collection.rb
@@ -139,10 +140,10 @@ homepage: https://github.com/Shopify/maintenance_tasks
139
140
  licenses:
140
141
  - MIT
141
142
  metadata:
142
- source_code_uri: https://github.com/Shopify/maintenance_tasks/tree/v1.2.2
143
+ source_code_uri: https://github.com/Shopify/maintenance_tasks/tree/v1.3.0
143
144
  allowed_push_host: https://rubygems.org
144
145
  post_install_message: |-
145
- Thank you for installing Maintenance Tasks 1.2.2. To complete, please run:
146
+ Thank you for installing Maintenance Tasks 1.3.0. To complete, please run:
146
147
 
147
148
  rails generate maintenance_tasks:install
148
149
  rdoc_options: []
@@ -159,7 +160,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
159
160
  - !ruby/object:Gem::Version
160
161
  version: '0'
161
162
  requirements: []
162
- rubygems_version: 3.0.3
163
+ rubygems_version: 3.2.17
163
164
  signing_key:
164
165
  specification_version: 4
165
166
  summary: A Rails engine for queuing and managing maintenance tasks