maintenance_tasks 1.1.1 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +18 -0
  3. data/README.md +90 -32
  4. data/app/controllers/maintenance_tasks/application_controller.rb +2 -4
  5. data/app/controllers/maintenance_tasks/tasks_controller.rb +1 -1
  6. data/app/helpers/maintenance_tasks/application_helper.rb +2 -13
  7. data/app/helpers/maintenance_tasks/tasks_helper.rb +17 -16
  8. data/app/jobs/concerns/maintenance_tasks/task_job_concern.rb +150 -0
  9. data/app/jobs/maintenance_tasks/task_job.rb +1 -129
  10. data/app/models/maintenance_tasks/csv_collection.rb +1 -1
  11. data/app/models/maintenance_tasks/progress.rb +13 -11
  12. data/app/models/maintenance_tasks/run.rb +26 -4
  13. data/app/models/maintenance_tasks/runner.rb +2 -2
  14. data/app/models/maintenance_tasks/runs_page.rb +55 -0
  15. data/app/models/maintenance_tasks/task_data.rb +3 -3
  16. data/app/tasks/maintenance_tasks/task.rb +24 -1
  17. data/app/validators/maintenance_tasks/run_status_validator.rb +11 -11
  18. data/app/views/maintenance_tasks/runs/_info.html.erb +1 -1
  19. data/app/views/maintenance_tasks/runs/info/_running.html.erb +0 -2
  20. data/app/views/maintenance_tasks/tasks/show.html.erb +3 -3
  21. data/config/routes.rb +4 -4
  22. data/db/migrate/20210225152418_remove_index_on_task_name.rb +1 -1
  23. data/exe/maintenance_tasks +2 -2
  24. data/lib/generators/maintenance_tasks/install_generator.rb +4 -4
  25. data/lib/generators/maintenance_tasks/task_generator.rb +10 -10
  26. data/lib/generators/maintenance_tasks/templates/task_spec.rb.tt +7 -5
  27. data/lib/maintenance_tasks.rb +51 -29
  28. data/lib/maintenance_tasks/cli.rb +3 -3
  29. data/lib/maintenance_tasks/engine.rb +4 -3
  30. metadata +10 -21
  31. data/Rakefile +0 -29
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '017924f32ef1d9cb9e8ef6b739e1dc7ab60695dce1ee03c9f9f14fcaa35dfdde'
4
- data.tar.gz: 17d3d2e220dd8b3cc7270797398abe33e5c90e38a3d19b28a571cb61c94a567c
3
+ metadata.gz: 649f66cc11a303666134c75aad8575b81eb376e358f69312180e21a14528ce50
4
+ data.tar.gz: b564b46e647c467c5d93a6198ab112729623059cfe194d1115d095c8ab8d9953
5
5
  SHA512:
6
- metadata.gz: '0942f2e8f1d23e1dc180a13deef7af7c198dcb348fdd62ed0c9107d4eded0a7908c0477c6e15c954658f4a99a41c5858fec4b02477d8deed4f72f44c4ae7cb5e'
7
- data.tar.gz: 61c1d448e4c1eaf7a8076dc253d72de1d515b70ed4623b76a290b24dd4e6f0c76aff6fccde27619eceffc497ca750910aa9590363f2c3cbd2085a75a8838a18f
6
+ metadata.gz: f313350c5a80fa8840b23976eb7cde8776f825bc25a9b6a8fb7ce5d6f1abe8148d547ae7b2c1368a72caf980a2a12377e33b7c325e1447de3c0a0b0686d87a8d
7
+ data.tar.gz: '084950e7be06503e12d437294c784d02e2f0449c76db6297c5f95fcfd40845e4ce7e3354e16fbe12dd99b82554946f16fbc3415c047c17109aab1303c6b81b9b'
data/LICENSE.md ADDED
@@ -0,0 +1,18 @@
1
+ Copyright 2020-present, Shopify Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -2,34 +2,6 @@
2
2
 
3
3
  A Rails engine for queuing and managing maintenance tasks.
4
4
 
5
- ## Table of Contents
6
-
7
- * [Demo](#demo)
8
- * [Installation](#installation)
9
- * [Active Job Dependency](#active-job-dependency)
10
- * [Usage](#usage)
11
- * [Creating a Task](#creating-a-task)
12
- * [Creating a CSV Task](#creating-a-csv-task)
13
- * [Considerations when writing Tasks](#considerations-when-writing-tasks)
14
- * [Writing tests for a Task](#writing-tests-for-a-task)
15
- * [Writing tests for a CSV Task](#writing-tests-for-a-csv-task)
16
- * [Running a Task](#running-a-task)
17
- * [Monitoring your Task's status](#monitoring-your-tasks-status)
18
- * [How Maintenance Tasks runs a Task](#how-maintenance-tasks-runs-a-task)
19
- * [Help! My Task is stuck](#help-my-task-is-stuck)
20
- * [Configuring the gem](#configuring-the-gem)
21
- * [Customizing the error handler](#customizing-the-error-handler)
22
- * [Customizing the maintenance tasks module](#customizing-the-maintenance-tasks-module)
23
- * [Customizing the underlying job class](#customizing-the-underlying-job-class)
24
- * [Customizing the rate at which task progress gets updated](#customizing-the-rate-at-which-task-progress-gets-updated)
25
- * [Upgrading](#upgrading)
26
- * [Contributing](#contributing)
27
- * [Releasing new versions](#releasing-new-versions)
28
-
29
- ## Demo
30
-
31
- Watch this demo video to see the gem in action:
32
-
33
5
  [![Link to demo video](static/demo.png)](https://www.youtube.com/watch?v=BTuvTQxlFzs)
34
6
 
35
7
  ## Installation
@@ -140,6 +112,46 @@ title,content
140
112
  My Title,Hello World!
141
113
  ```
142
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
+
143
155
  ### Considerations when writing Tasks
144
156
 
145
157
  MaintenanceTasks relies on the queue adapter configured for your application to
@@ -338,12 +350,15 @@ end
338
350
 
339
351
  The error handler should be a lambda that accepts three arguments:
340
352
 
341
- * `error`: The object containing the exception that was raised.
353
+ * `error`: The exception that was raised.
342
354
  * `task_context`: A hash with additional information about the Task and the
343
355
  error:
344
356
  * `task_name`: The name of the Task that errored
345
357
  * `started_at`: The time the Task started
346
358
  * `ended_at`: The time the Task errored
359
+ Note that `task_context` may be empty if the Task produced an error before any
360
+ context could be gathered (for example, if deserializing the job to process
361
+ your Task failed).
347
362
  * `errored_element`: The element, if any, that was being processed when the
348
363
  Task raised an exception. If you would like to pass this object to your
349
364
  exception monitoring service, make sure you **sanitize the object** to avoid
@@ -400,6 +415,36 @@ MaintenanceTasks.ticker_delay = 2.seconds
400
415
 
401
416
  If no value is specified, it will default to 1 second.
402
417
 
418
+ #### Customizing which Active Storage service to use
419
+
420
+ The Active Storage framework in Rails 6.1 and up supports multiple storage
421
+ services per environment. To specify which service to use,
422
+ `MaintenanceTasks.active_storage_service` can be configured with the service's
423
+ key, as specified in your application's `config/storage.yml`:
424
+
425
+ ```yaml
426
+ # config/storage.yml
427
+ user_data:
428
+ service: GCS
429
+ credentials: <%= Rails.root.join("path/to/user/data/keyfile.json") %>
430
+ project: "my-project"
431
+ bucket: "user-data-bucket"
432
+
433
+ internal:
434
+ service: GCS
435
+ credentials: <%= Rails.root.join("path/to/internal/keyfile.json") %>
436
+ project: "my-project"
437
+ bucket: "internal-bucket"
438
+ ```
439
+
440
+ ```ruby
441
+ # config/initializers/maintenance_tasks.rb
442
+ MaintenanceTasks.active_storage_service = :internal
443
+ ```
444
+
445
+ There is no need to configure this option if your application uses only one
446
+ storage service per environment.
447
+
403
448
  ## Upgrading
404
449
 
405
450
  Use bundler to check for and upgrade to newer versions. After installing a new
@@ -419,15 +464,28 @@ pull requests. You can find the contribution guidelines on
419
464
 
420
465
  [contributing]: https://github.com/Shopify/maintenance_tasks/blob/main/.github/CONTRIBUTING.md
421
466
 
467
+ ### Dependabot updates
468
+
469
+ Whenever Dependabot creates a PR for a gem bump, check out the branch locally
470
+ and run `bin/update-gemfile <gem>` to ensure all the gemfiles have the gem
471
+ updated consistently.
472
+
422
473
  ## Releasing new versions
423
474
 
475
+ Updates should be added to the latest draft release on GitHub as Pull Requests
476
+ are merged.
477
+
478
+ Once a release is ready, follow these steps:
479
+
424
480
  * Update `spec.version` in `maintenance_tasks.gemspec`.
425
- * Run `bundle install` to bump the `Gemfile.lock` version of the gem.
481
+ * Run `bin/gemfile-update install` to bump the version in all the lockfiles.
426
482
  * Open a PR and merge on approval.
427
- * Create a [release on GitHub][release] with a version number that matches the
428
- version defined in the gemspec.
429
483
  * Deploy via [Shipit][shipit] and see the new version on
430
484
  <https://rubygems.org/gems/maintenance_tasks>.
485
+ * Ensure the release has documented all changes and publish it.
486
+ * Create a new [draft release on GitHub][release] with the title 'Upcoming
487
+ Release'. The tag version can be left blank. This will be the starting point
488
+ for documenting changes related to the next release.
431
489
 
432
490
  [release]: https://help.github.com/articles/creating-releases/
433
491
  [shipit]: https://shipit.shopify.io/shopify/maintenance_tasks/rubygems
@@ -5,9 +5,7 @@ module MaintenanceTasks
5
5
  #
6
6
  # Can be extended to add different authentication and authorization code.
7
7
  class ApplicationController < ActionController::Base
8
- include Pagy::Backend
9
-
10
- BULMA_CDN = 'https://cdn.jsdelivr.net'
8
+ BULMA_CDN = "https://cdn.jsdelivr.net"
11
9
 
12
10
  content_security_policy do |policy|
13
11
  policy.style_src(BULMA_CDN)
@@ -17,7 +15,7 @@ module MaintenanceTasks
17
15
  before_action do
18
16
  request.content_security_policy_nonce_generator ||=
19
17
  ->(_request) { SecureRandom.base64(16) }
20
- request.content_security_policy_nonce_directives = ['style-src']
18
+ request.content_security_policy_nonce_directives = ["style-src"]
21
19
  end
22
20
 
23
21
  protect_from_forgery with: :exception
@@ -20,7 +20,7 @@ module MaintenanceTasks
20
20
  def show
21
21
  @task = TaskData.find(params.fetch(:id))
22
22
  set_refresh if @task.last_run&.active?
23
- @pagy, @previous_runs = pagy(@task.previous_runs)
23
+ @runs_page = RunsPage.new(@task.previous_runs, params[:cursor])
24
24
  end
25
25
 
26
26
  # Runs a given Task and redirects to the Task page.
@@ -4,17 +4,6 @@ module MaintenanceTasks
4
4
  #
5
5
  # @api private
6
6
  module ApplicationHelper
7
- include Pagy::Frontend
8
-
9
- # Renders pagination for the page, if there is more than one page present.
10
- #
11
- # @param pagy [Pagy] the pagy instance containing pagination details,
12
- # including the number of pages the results are spread across.
13
- # @return [String] the HTML to render for pagination.
14
- def pagination(pagy)
15
- raw(pagy_bulma_nav(pagy)) if pagy.pages > 1
16
- end
17
-
18
7
  # Renders a time element with the given datetime, worded as relative to the
19
8
  # current time.
20
9
  #
@@ -24,8 +13,8 @@ module MaintenanceTasks
24
13
  # @param datetime [ActiveSupport::TimeWithZone] the time to be presented.
25
14
  # @return [String] the HTML to render with the relative datetime in words.
26
15
  def time_ago(datetime)
27
- time_tag(datetime, title: datetime.utc.iso8601, class: 'is-clickable') do
28
- time_ago_in_words(datetime) + ' ago'
16
+ time_tag(datetime, title: datetime.utc.iso8601, class: "is-clickable") do
17
+ time_ago_in_words(datetime) + " ago"
29
18
  end
30
19
  end
31
20
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ripper'
3
+ require "ripper"
4
4
 
5
5
  module MaintenanceTasks
6
6
  # Helpers for formatting data in the maintenance_tasks views.
@@ -8,16 +8,16 @@ module MaintenanceTasks
8
8
  # @api private
9
9
  module TasksHelper
10
10
  STATUS_COLOURS = {
11
- 'new' => ['is-primary'],
12
- 'enqueued' => ['is-primary is-light'],
13
- 'running' => ['is-info'],
14
- 'interrupted' => ['is-info', 'is-light'],
15
- 'pausing' => ['is-warning', 'is-light'],
16
- 'paused' => ['is-warning'],
17
- 'succeeded' => ['is-success'],
18
- 'cancelling' => ['is-light'],
19
- 'cancelled' => ['is-dark'],
20
- 'errored' => ['is-danger'],
11
+ "new" => ["is-primary"],
12
+ "enqueued" => ["is-primary is-light"],
13
+ "running" => ["is-info"],
14
+ "interrupted" => ["is-info", "is-light"],
15
+ "pausing" => ["is-warning", "is-light"],
16
+ "paused" => ["is-warning"],
17
+ "succeeded" => ["is-success"],
18
+ "cancelling" => ["is-light"],
19
+ "cancelled" => ["is-dark"],
20
+ "errored" => ["is-danger"],
21
21
  }
22
22
 
23
23
  # Formats a run backtrace.
@@ -44,12 +44,13 @@ module MaintenanceTasks
44
44
 
45
45
  progress = Progress.new(run)
46
46
 
47
- tag.progress(
47
+ progress_bar = tag.progress(
48
48
  value: progress.value,
49
49
  max: progress.max,
50
- title: progress.title,
51
- class: ['progress'] + STATUS_COLOURS.fetch(run.status)
50
+ class: ["progress"] + STATUS_COLOURS.fetch(run.status)
52
51
  )
52
+ progress_text = tag.p(tag.i(progress.text))
53
+ tag.div(progress_bar + progress_text, class: "block")
53
54
  end
54
55
 
55
56
  # Renders a span with a Run's status, with the corresponding tag class
@@ -59,7 +60,7 @@ module MaintenanceTasks
59
60
  # @return [String] the span element containing the status, with the
60
61
  # appropriate tag class attached.
61
62
  def status_tag(status)
62
- tag.span(status.capitalize, class: ['tag'] + STATUS_COLOURS.fetch(status))
63
+ tag.span(status.capitalize, class: ["tag"] + STATUS_COLOURS.fetch(status))
63
64
  end
64
65
 
65
66
  # Returns the distance between now and the Run's expected completion time,
@@ -100,7 +101,7 @@ module MaintenanceTasks
100
101
  when :on_nl, :on_sp, :on_ignored_nl
101
102
  content
102
103
  else
103
- tag.span(content, class: type.to_s.sub('on_', 'ruby-').dasherize)
104
+ tag.span(content, class: type.to_s.sub("on_", "ruby-").dasherize)
104
105
  end
105
106
  end
106
107
  safe_join(tokens)
@@ -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