maintenance_tasks 1.2.2 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
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