maintenance_tasks 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +138 -21
  3. data/app/controllers/maintenance_tasks/runs_controller.rb +1 -11
  4. data/app/controllers/maintenance_tasks/tasks_controller.rb +6 -2
  5. data/app/helpers/maintenance_tasks/application_helper.rb +0 -34
  6. data/app/helpers/maintenance_tasks/task_helper.rb +8 -0
  7. data/app/jobs/maintenance_tasks/task_job.rb +23 -4
  8. data/app/models/maintenance_tasks/csv_collection.rb +34 -0
  9. data/app/models/maintenance_tasks/run.rb +19 -0
  10. data/app/models/maintenance_tasks/runner.rb +19 -4
  11. data/app/models/maintenance_tasks/task_data.rb +7 -2
  12. data/app/tasks/maintenance_tasks/task.rb +37 -13
  13. data/app/validators/maintenance_tasks/run_status_validator.rb +5 -2
  14. data/app/views/layouts/maintenance_tasks/_navbar.html.erb +0 -6
  15. data/app/views/maintenance_tasks/runs/_info.html.erb +6 -1
  16. data/app/views/maintenance_tasks/runs/_run.html.erb +6 -9
  17. data/app/views/maintenance_tasks/tasks/show.html.erb +29 -2
  18. data/config/routes.rb +0 -2
  19. data/lib/generators/maintenance_tasks/task_generator.rb +8 -1
  20. data/lib/generators/maintenance_tasks/templates/csv_task.rb.tt +13 -0
  21. data/lib/generators/maintenance_tasks/templates/task_spec.rb.tt +1 -1
  22. data/lib/generators/maintenance_tasks/templates/task_test.rb.tt +1 -1
  23. data/lib/maintenance_tasks.rb +17 -21
  24. data/lib/maintenance_tasks/cli.rb +16 -1
  25. metadata +6 -16
  26. data/app/views/maintenance_tasks/runs/index.html.erb +0 -15
  27. data/app/views/maintenance_tasks/tasks/actions/_cancelled.html.erb +0 -1
  28. data/app/views/maintenance_tasks/tasks/actions/_cancelling.html.erb +0 -4
  29. data/app/views/maintenance_tasks/tasks/actions/_enqueued.html.erb +0 -2
  30. data/app/views/maintenance_tasks/tasks/actions/_errored.html.erb +0 -1
  31. data/app/views/maintenance_tasks/tasks/actions/_interrupted.html.erb +0 -2
  32. data/app/views/maintenance_tasks/tasks/actions/_new.html.erb +0 -1
  33. data/app/views/maintenance_tasks/tasks/actions/_paused.html.erb +0 -2
  34. data/app/views/maintenance_tasks/tasks/actions/_pausing.html.erb +0 -2
  35. data/app/views/maintenance_tasks/tasks/actions/_running.html.erb +0 -2
  36. data/app/views/maintenance_tasks/tasks/actions/_succeeded.html.erb +0 -1
  37. data/lib/maintenance_tasks/integrations/bugsnag_handler.rb +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d591c21fb76efef72185bf183b0f4b0bc0a5b97a2606e083f6fbfc11325e029b
4
- data.tar.gz: 6195855524490ebc663f1970134b6aed2abe7887345b6c17d2d0dccea9d81fb4
3
+ metadata.gz: 3e274988d4a5facd653134ab4d4f2c51a71957ad8b2e6e1e16958f3726d50bb1
4
+ data.tar.gz: beae97f203e12ed5017b9a76e1ced6b8d9f3e9e8ca8bc65a85a63d781d150bcf
5
5
  SHA512:
6
- metadata.gz: 3fa1e498d50830521cabeb8babda612a9762438cd1fdccf5cecf776ae069ec85cdbaedbded208bdfc1e72479b69ffb76a39b999e4c2f8d810811dc8672f0b5cc
7
- data.tar.gz: 2d9aa8e0c134baa3864f2cd5e9fe53944a5d13a89c21caab26dcd394b3eb8845af62397f2d1be880c9f27d71cbe77db3865d16c7da53f0b70cc64cc7bf4153a4
6
+ metadata.gz: 67ed786c6c0281422ef4a10af1167c129cbab7422624656c3cae3eb3fe8f35bc5df00f874f2aee2a1eacf54fe957faccd5a4df12240e3f461b81817cdd5347a7
7
+ data.tar.gz: 3cf190c896f046accf647b74e6acb58680b9e7eb89d25ea58e1a58e2db48e5437c5a410f2536c440c494efc599ea635a0380fc6a554ae157b53b402efbda4211
data/README.md CHANGED
@@ -3,11 +3,16 @@
3
3
  A Rails engine for queuing and managing maintenance tasks.
4
4
 
5
5
  ## Table of Contents
6
+
7
+ * [Demo](#demo)
6
8
  * [Installation](#installation)
9
+ * [Active Job Dependency](#active-job-dependency)
7
10
  * [Usage](#usage)
8
11
  * [Creating a Task](#creating-a-task)
9
- * [Considerations when writing Tasks](#considerations-when-writing-tasks)
12
+ * [Creating a CSV Task](#creating-a-csv-task)
13
+ * [Considerations when writing Tasks](#considerations-when-writing-tasks)
10
14
  * [Writing tests for a Task](#writing-tests-for-a-task)
15
+ * [Writing tests for a CSV Task](#writing-tests-for-a-csv-task)
11
16
  * [Running a Task](#running-a-task)
12
17
  * [Monitoring your Task's status](#monitoring-your-tasks-status)
13
18
  * [How Maintenance Tasks runs a Task](#how-maintenance-tasks-runs-a-task)
@@ -21,18 +26,18 @@ A Rails engine for queuing and managing maintenance tasks.
21
26
  * [Contributing](#contributing)
22
27
  * [Releasing new versions](#releasing-new-versions)
23
28
 
24
- ## Installation
29
+ ## Demo
25
30
 
26
- Add this line to your application's Gemfile:
31
+ Watch this demo video to see the gem in action:
27
32
 
28
- ```ruby
29
- gem 'maintenance_tasks'
30
- ```
33
+ [![Link to demo video](static/demo.png)](https://www.youtube.com/watch?v=BTuvTQxlFzs)
31
34
 
32
- And then execute:
35
+ ## Installation
36
+
37
+ To install the gem and run the install generator, execute:
33
38
 
34
39
  ```bash
35
- $ bundle
40
+ $ bundle add maintenance_tasks
36
41
  $ rails generate maintenance_tasks:install
37
42
  ```
38
43
 
@@ -40,6 +45,22 @@ The generator creates and runs a migration to add the necessary table to your
40
45
  database. It also mounts Maintenance Tasks in your `config/routes.rb`. By
41
46
  default the web UI can be accessed in the new `/maintenance_tasks` path.
42
47
 
48
+ In case you use an exception reporting service (e.g. Bugsnag) you might want to
49
+ define an error handler. See [Customizing the error
50
+ handler](#customizing-the-error-handler) for more information.
51
+
52
+ ### Active Job Dependency
53
+
54
+ The Maintenance Tasks framework relies on ActiveJob behind the scenes to run
55
+ Tasks. The default queuing backend for ActiveJob is
56
+ [asynchronous][async-adapter]. It is **strongly recommended** to change this to
57
+ a persistent backend so that Task progress is not lost during code or
58
+ infrastructure changes. For more information on configuring a queuing backend,
59
+ take a look at the [ActiveJob documentation][active-job-docs].
60
+
61
+ [async-adapter]: https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/AsyncAdapter.html
62
+ [active-job-docs]: https://guides.rubyonrails.org/active_job_basics.html#setting-the-backend
63
+
43
64
  ## Usage
44
65
 
45
66
  ### Creating a Task
@@ -81,7 +102,45 @@ module Maintenance
81
102
  end
82
103
  ```
83
104
 
84
- #### Considerations when writing Tasks
105
+ ### Creating a CSV Task
106
+
107
+ You can also write a Task that iterates on a CSV file. Note that writing CSV
108
+ Tasks **requires ActiveStorage to be configured**. Ensure that the dependency
109
+ is specified in your application's Gemfile, and that you've followed the
110
+ [setup instuctions][setup].
111
+
112
+ [setup]: https://edgeguides.rubyonrails.org/active_storage_overview.html#setup
113
+
114
+ Generate a CSV Task by running:
115
+
116
+ ```bash
117
+ $ rails generate maintenance_tasks:task import_posts --csv
118
+ ```
119
+
120
+ The generated task is a subclass of `MaintenanceTasks::Task` that implements:
121
+
122
+ * `process`: do the work of your maintenance task on a `CSV::Row`
123
+
124
+ ```ruby
125
+ # app/tasks/maintenance/import_posts_task.rb
126
+ module Maintenance
127
+ class ImportPostsTask < MaintenanceTasks::Task
128
+ csv_collection
129
+
130
+ def process(row)
131
+ Post.create!(title: row["title"], content: row["content"])
132
+ end
133
+ end
134
+ end
135
+ ```
136
+
137
+ ```csv
138
+ # posts.csv
139
+ title,content
140
+ My Title,Hello World!
141
+ ```
142
+
143
+ ### Considerations when writing Tasks
85
144
 
86
145
  MaintenanceTasks relies on the queue adapter configured for your application to
87
146
  run the job which is processing your Task. The guidelines for writing Task may
@@ -120,7 +179,7 @@ module Maintenance
120
179
  test "#process performs a task iteration" do
121
180
  post = Post.new
122
181
 
123
- Maintenance::UpdatePostsTask.new.process(post)
182
+ Maintenance::UpdatePostsTask.process(post)
124
183
 
125
184
  assert_equal 'New content!', post.content
126
185
  end
@@ -128,6 +187,32 @@ module Maintenance
128
187
  end
129
188
  ```
130
189
 
190
+ ### Writing tests for a CSV Task
191
+
192
+ You should write tests for your `#process` method in a CSV Task as well. It
193
+ takes a `CSV::Row` as an argument. You can pass a row, or a hash with string
194
+ keys to `#process` from your test.
195
+
196
+ ```ruby
197
+ # app/tasks/maintenance/import_posts_task_test.rb
198
+ module Maintenance
199
+ class ImportPostsTaskTest < ActiveSupport::TestCase
200
+ test "#process performs a task iteration" do
201
+ assert_difference -> { Post.count } do
202
+ Maintenance::UpdatePostsTask.process({
203
+ 'title' => 'My Title',
204
+ 'content' => 'Hello World!',
205
+ })
206
+ end
207
+
208
+ post = Post.last
209
+ assert_equal 'My Title', post.title
210
+ assert_equal 'Hello World!', post.content
211
+ end
212
+ end
213
+ end
214
+ ```
215
+
131
216
  ### Running a Task
132
217
 
133
218
  You can run your new Task by accessing the Web UI and clicking on "Run".
@@ -138,11 +223,26 @@ Alternatively, you can run your Task in the command line:
138
223
  $ bundle exec maintenance_tasks perform Maintenance::UpdatePostsTask
139
224
  ```
140
225
 
141
- You can also run a Task in Ruby by sending `run` with a Task name to a Runner
142
- instance:
226
+ To run a Task that processes CSVs from the command line, use the --csv option:
227
+
228
+ ```bash
229
+ $ bundle exec maintenance_tasks perform Maintenance::ImportPostsTask --csv 'path/to/my_csv.csv'
230
+ ```
231
+
232
+ You can also run a Task in Ruby by sending `run` with a Task name to Runner:
143
233
 
144
234
  ```ruby
145
- MaintenanceTasks::Runner.new.run('Maintenance::UpdatePostsTask')
235
+ MaintenanceTasks::Runner.run(name: 'Maintenance::UpdatePostsTask')
236
+ ```
237
+
238
+ To run a Task that processes CSVs using the Runner, provide a Hash containing an
239
+ open IO object and a filename to `run`:
240
+
241
+ ```ruby
242
+ MaintenanceTasks::Runner.run(
243
+ name: 'Maintenance::ImportPostsTask'
244
+ csv_file: { io: File.open('path/to/my_csv.csv'), filename: 'my_csv.csv' }
245
+ )
146
246
  ```
147
247
 
148
248
  ### Monitoring your Task's status
@@ -224,17 +324,36 @@ be placed in a `maintenance_tasks.rb` initializer.
224
324
  Exceptions raised while a Task is performing are rescued and information about
225
325
  the error is persisted and visible in the UI.
226
326
 
227
- If your application uses Bugsnag to monitor errors, the gem will automatically
228
- notify Bugsnag of any errors raised while a Task is performing.
229
-
230
- If you want to integrate with another exception monitoring service or customize
231
- error handling, a callback can be defined:
327
+ If you want to integrate with an exception monitoring service (e.g. Bugsnag),
328
+ you can define an error handler:
232
329
 
233
330
  ```ruby
234
331
  # config/initializers/maintenance_tasks.rb
235
- MaintenanceTasks.error_handler = ->(error) { MyErrorMonitor.notify(error) }
332
+ MaintenanceTasks.error_handler = ->(error, task_context, _errored_element) do
333
+ Bugsnag.notify(error) do |notification|
334
+ notification.add_tab(:task, task_context)
335
+ end
336
+ end
236
337
  ```
237
338
 
339
+ The error handler should be a lambda that accepts three arguments:
340
+
341
+ * `error`: The object containing the exception that was raised.
342
+ * `task_context`: A hash with additional information about the Task and the
343
+ error:
344
+ * `task_name`: The name of the Task that errored
345
+ * `started_at`: The time the Task started
346
+ * `ended_at`: The time the Task errored
347
+ * `errored_element`: The element, if any, that was being processed when the
348
+ Task raised an exception. If you would like to pass this object to your
349
+ exception monitoring service, make sure you **sanitize the object** to avoid
350
+ leaking sensitive data and **convert it to a format** that is compatible with
351
+ your bug tracker. For example, Bugsnag only sends the id and class name of
352
+ ActiveRecord objects in order to protect sensitive data. CSV rows, on the
353
+ other hand, are converted to strings and passed raw to Bugsnag, so make sure
354
+ to filter any personal data from these objects before adding them to a
355
+ report.
356
+
238
357
  #### Customizing the maintenance tasks module
239
358
 
240
359
  `MaintenanceTasks.tasks_module` can be configured to define the module in which
@@ -302,8 +421,6 @@ pull requests. You can find the contribution guidelines on
302
421
 
303
422
  ## Releasing new versions
304
423
 
305
- This gem is published to packagecloud. The procedure to publish a new version:
306
-
307
424
  * Update `spec.version` in `maintenance_tasks.gemspec`.
308
425
  * Run `bundle install` to bump the `Gemfile.lock` version of the gem.
309
426
  * Open a PR and merge on approval.
@@ -6,17 +6,7 @@ module MaintenanceTasks
6
6
  #
7
7
  # @api private
8
8
  class RunsController < ApplicationController
9
- before_action :set_run, except: :index
10
-
11
- # Shows a full list of Runs.
12
- def index
13
- query = Run.all.order(id: :desc)
14
- if params[:task_name].present?
15
- task_name = Run.sanitize_sql_like(params[:task_name])
16
- query = query.where('task_name LIKE ?', "%#{task_name}%")
17
- end
18
- @pagy, @runs = pagy(query)
19
- end
9
+ before_action :set_run
20
10
 
21
11
  # Updates a Run status to paused.
22
12
  def pause
@@ -7,7 +7,7 @@ module MaintenanceTasks
7
7
  #
8
8
  # @api private
9
9
  class TasksController < ApplicationController
10
- before_action :set_refresh, only: [:index, :show]
10
+ before_action :set_refresh, only: [:index]
11
11
 
12
12
  # Renders the maintenance_tasks/tasks page, displaying
13
13
  # available tasks to users, grouped by category.
@@ -19,12 +19,16 @@ module MaintenanceTasks
19
19
  # Shows running and completed instances of the Task.
20
20
  def show
21
21
  @task = TaskData.find(params.fetch(:id))
22
+ set_refresh if @task.last_run&.active?
22
23
  @pagy, @previous_runs = pagy(@task.previous_runs)
23
24
  end
24
25
 
25
26
  # Runs a given Task and redirects to the Task page.
26
27
  def run
27
- task = Runner.new.run(name: params.fetch(:id))
28
+ task = Runner.run(
29
+ name: params.fetch(:id),
30
+ csv_file: params[:csv_file]
31
+ )
28
32
  redirect_to(task_path(task))
29
33
  rescue ActiveRecord::RecordInvalid => error
30
34
  redirect_to(task_path(error.record.task_name), alert: error.message)
@@ -28,40 +28,6 @@ module MaintenanceTasks
28
28
  time_ago_in_words(datetime) + ' ago'
29
29
  end
30
30
  end
31
-
32
- # Fix stylesheet_link_tag to handle integrity when preloading.
33
- # To be reverted once fixed upstream in Rails.
34
- def stylesheet_link_tag(*sources)
35
- return super unless respond_to?(:send_preload_links_header, true)
36
- options = sources.extract_options!.stringify_keys
37
- path_options = options.extract!('protocol', 'host', 'skip_pipeline')
38
- .symbolize_keys
39
- preload_links = []
40
- crossorigin = options.delete('crossorigin')
41
- crossorigin = 'anonymous' if crossorigin == true
42
- nopush = options['nopush'].nil? ? true : options.delete('nopush')
43
- integrity = options['integrity']
44
-
45
- sources_tags = sources.uniq.map do |source|
46
- href = path_to_stylesheet(source, path_options)
47
- preload_link = "<#{href}>; rel=preload; as=style"
48
- preload_link += "; crossorigin=#{crossorigin}" unless crossorigin.nil?
49
- preload_link += "; integrity=#{integrity}" unless integrity.nil?
50
- preload_link += '; nopush' if nopush
51
- preload_links << preload_link
52
- tag_options = {
53
- 'rel' => 'stylesheet',
54
- 'media' => 'screen',
55
- 'crossorigin' => crossorigin,
56
- 'href' => href,
57
- }.merge!(options)
58
- tag(:link, tag_options)
59
- end
60
-
61
- send_preload_links_header(preload_links)
62
-
63
- safe_join(sources_tags)
64
- end
65
31
  end
66
32
  private_constant :ApplicationHelper
67
33
  end
@@ -105,6 +105,14 @@ module MaintenanceTasks
105
105
  end
106
106
  safe_join(tokens)
107
107
  end
108
+
109
+ # Returns a download link for a Run's CSV attachment
110
+ def csv_file_download_path(run)
111
+ Rails.application.routes.url_helpers.rails_blob_path(
112
+ run.csv_file,
113
+ only_path: true
114
+ )
115
+ end
108
116
  end
109
117
  private_constant :TaskHelper
110
118
  end
@@ -34,9 +34,11 @@ module MaintenanceTasks
34
34
  enumerator_builder.active_record_on_records(collection, cursor: cursor)
35
35
  when Array
36
36
  enumerator_builder.build_array_enumerator(collection, cursor: cursor)
37
+ when CSV
38
+ JobIteration::CsvEnumerator.new(collection).rows(cursor: cursor)
37
39
  else
38
40
  raise ArgumentError, "#{@task.class.name}#collection must be either "\
39
- 'an Active Record Relation or an Array.'
41
+ 'an Active Record Relation, Array, or CSV.'
40
42
  end
41
43
  end
42
44
 
@@ -47,14 +49,24 @@ module MaintenanceTasks
47
49
  # @param _run [Run] the current Run, passed as an argument by Job Iteration.
48
50
  def each_iteration(input, _run)
49
51
  throw(:abort, :skip_complete_callbacks) if @run.stopping?
50
- @task.process(input)
52
+ task_iteration(input)
51
53
  @ticker.tick
52
54
  @run.reload_status
53
55
  end
54
56
 
57
+ def task_iteration(input)
58
+ @task.process(input)
59
+ rescue => error
60
+ @errored_element = input
61
+ raise error
62
+ end
63
+
55
64
  def before_perform
56
65
  @run = arguments.first
57
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
58
70
  @run.job_id = job_id
59
71
 
60
72
  @run.running! unless @run.stopping?
@@ -90,9 +102,16 @@ module MaintenanceTasks
90
102
  end
91
103
 
92
104
  def on_error(error)
93
- @ticker.persist
105
+ @ticker.persist if defined?(@ticker)
94
106
  @run.persist_error(error)
95
- MaintenanceTasks.error_handler.call(error)
107
+
108
+ task_context = {
109
+ task_name: @run.task_name,
110
+ started_at: @run.started_at,
111
+ ended_at: @run.ended_at,
112
+ }
113
+ errored_element = @errored_element if defined?(@errored_element)
114
+ MaintenanceTasks.error_handler.call(error, task_context, errored_element)
96
115
  end
97
116
  end
98
117
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+
5
+ module MaintenanceTasks
6
+ # Module that is included into Task classes by Task.csv_collection for
7
+ # processing CSV files.
8
+ #
9
+ # @api private
10
+ module CsvCollection
11
+ # The contents of a CSV file to be processed by a Task.
12
+ #
13
+ # @return [String] the content of the CSV file to process.
14
+ attr_accessor :csv_content
15
+
16
+ # Defines the collection to be iterated over, based on the provided CSV.
17
+ #
18
+ # @return [CSV] the CSV object constructed from the specified CSV content,
19
+ # with headers.
20
+ def collection
21
+ CSV.new(csv_content, headers: true)
22
+ end
23
+
24
+ # The number of rows to be processed. Excludes the header row from the count
25
+ # and assumed a trailing new line in the CSV file. Note that this number is
26
+ # an approximation based on the number of new lines.
27
+ #
28
+ # @return [Integer] the approximate number of rows to process.
29
+ def count
30
+ csv_content.count("\n") - 1
31
+ end
32
+ end
33
+ private_constant :CsvCollection
34
+ end
@@ -33,6 +33,8 @@ module MaintenanceTasks
33
33
  validates :task_name, on: :create, inclusion: { in: ->(_) {
34
34
  Task.available_tasks.map(&:to_s)
35
35
  } }
36
+ validate :csv_attachment_presence, on: :create
37
+
36
38
  attr_readonly :task_name
37
39
 
38
40
  serialize :backtrace
@@ -41,6 +43,8 @@ module MaintenanceTasks
41
43
 
42
44
  validates_with RunStatusValidator, on: :update
43
45
 
46
+ has_one_attached :csv_file
47
+
44
48
  # Sets the run status to enqueued, making sure the transition is validated
45
49
  # in case it's already enqueued.
46
50
  def enqueued!
@@ -175,6 +179,21 @@ module MaintenanceTasks
175
179
  def stuck?
176
180
  cancelling? && updated_at <= 5.minutes.ago
177
181
  end
182
+
183
+ # Performs validation on the presence of a :csv_file attachment.
184
+ # A Run for a Task that uses CsvCollection must have an attached :csv_file
185
+ # to be valid. Conversely, a Run for a Task that doesn't use CsvCollection
186
+ # should not have an attachment to be valid. The appropriate error is added
187
+ # if the Run does not meet the above criteria.
188
+ def csv_attachment_presence
189
+ if Task.named(task_name) < CsvCollection && !csv_file.attached?
190
+ errors.add(:csv_file, 'must be attached to CSV Task.')
191
+ elsif !(Task.named(task_name) < CsvCollection) && csv_file.attached?
192
+ errors.add(:csv_file, 'should not be attached to non-CSV Task.')
193
+ end
194
+ rescue Task::NotFoundError
195
+ nil
196
+ end
178
197
  end
179
198
  private_constant :Run
180
199
  end
@@ -2,7 +2,17 @@
2
2
 
3
3
  module MaintenanceTasks
4
4
  # This class is responsible for running a given Task.
5
- class Runner
5
+ module Runner
6
+ extend self
7
+
8
+ # @deprecated Use {Runner} directly instead.
9
+ def new
10
+ ActiveSupport::Deprecation.warn(
11
+ 'Use Runner.run instead of Runner.new.run'
12
+ )
13
+ self
14
+ end
15
+
6
16
  # Exception raised when a Task Job couldn't be enqueued.
7
17
  class EnqueuingError < StandardError
8
18
  # Initializes a Enqueuing Error.
@@ -20,17 +30,22 @@ module MaintenanceTasks
20
30
  # Runs a Task.
21
31
  #
22
32
  # This method creates a Run record for the given Task name and enqueues the
23
- # Run.
33
+ # Run. If a CSV file is provided, it is attached to the Run record.
24
34
  #
25
35
  # @param name [String] the name of the Task to be run.
36
+ # @param csv_file [attachable, nil] a CSV file that provides the collection
37
+ # for the Task to iterate over when running, in the form of an attachable
38
+ # (see https://edgeapi.rubyonrails.org/classes/ActiveStorage/Attached/One.html#method-i-attach).
39
+ # Value is nil if the Task does not use CSV iteration.
26
40
  #
27
41
  # @return [Task] the Task that was run.
28
42
  #
29
43
  # @raise [EnqueuingError] if an error occurs while enqueuing the Run.
30
44
  # @raise [ActiveRecord::RecordInvalid] if validation errors occur while
31
45
  # creating the Run.
32
- def run(name:)
46
+ def run(name:, csv_file: nil)
33
47
  run = Run.active.find_by(task_name: name) || Run.new(task_name: name)
48
+ run.csv_file.attach(csv_file) if csv_file
34
49
 
35
50
  run.enqueued!
36
51
  enqueue(run)
@@ -40,7 +55,7 @@ module MaintenanceTasks
40
55
  private
41
56
 
42
57
  def enqueue(run)
43
- unless MaintenanceTasks.job.perform_later(run)
58
+ unless MaintenanceTasks.job.constantize.perform_later(run)
44
59
  raise "The job to perform #{run.task_name} could not be enqueued. "\
45
60
  'Enqueuing has been prevented by a callback.'
46
61
  end
@@ -73,7 +73,7 @@ module MaintenanceTasks
73
73
  def code
74
74
  return if deleted?
75
75
  task = Task.named(name)
76
- file = task.instance_method(:collection).source_location.first
76
+ file = task.instance_method(:process).source_location.first
77
77
  File.read(file)
78
78
  end
79
79
 
@@ -127,10 +127,15 @@ module MaintenanceTasks
127
127
  end
128
128
  end
129
129
 
130
+ # @return [Boolean] whether the Task inherits from CsvTask.
131
+ def csv_task?
132
+ !deleted? && Task.named(name) < CsvCollection
133
+ end
134
+
130
135
  private
131
136
 
132
137
  def runs
133
- Run.where(task_name: name).order(created_at: :desc)
138
+ Run.where(task_name: name).with_attached_csv_file.order(created_at: :desc)
134
139
  end
135
140
  end
136
141
  private_constant :TaskData
@@ -30,6 +30,41 @@ module MaintenanceTasks
30
30
  descendants
31
31
  end
32
32
 
33
+ # Make this Task a task that handles CSV.
34
+ #
35
+ # An input to upload a CSV will be added in the form to start a Run. The
36
+ # collection and count method are implemented.
37
+ def csv_collection
38
+ include(CsvCollection)
39
+ end
40
+
41
+ # Processes one item.
42
+ #
43
+ # Especially useful for tests.
44
+ #
45
+ # @param item the item to process.
46
+ def process(item)
47
+ new.process(item)
48
+ end
49
+
50
+ # Returns the collection for this Task.
51
+ #
52
+ # Especially useful for tests.
53
+ #
54
+ # @return the collection.
55
+ def collection
56
+ new.collection
57
+ end
58
+
59
+ # Returns the count of items for this Task.
60
+ #
61
+ # Especially useful for tests.
62
+ #
63
+ # @return the count of items.
64
+ def count
65
+ new.count
66
+ end
67
+
33
68
  private
34
69
 
35
70
  def load_constants
@@ -45,8 +80,7 @@ module MaintenanceTasks
45
80
  # @raise [NotImplementedError] with a message advising subclasses to
46
81
  # implement an override for this method.
47
82
  def collection
48
- raise NotImplementedError,
49
- "#{self.class.name} must implement `collection`."
83
+ raise NoMethodError, "#{self.class.name} must implement `collection`."
50
84
  end
51
85
 
52
86
  # Placeholder method to raise in case a subclass fails to implement the
@@ -57,8 +91,7 @@ module MaintenanceTasks
57
91
  # @raise [NotImplementedError] with a message advising subclasses to
58
92
  # implement an override for this method.
59
93
  def process(_item)
60
- raise NotImplementedError,
61
- "#{self.class.name} must implement `process`."
94
+ raise NoMethodError, "#{self.class.name} must implement `process`."
62
95
  end
63
96
 
64
97
  # Total count of iterations to be performed.
@@ -70,14 +103,5 @@ module MaintenanceTasks
70
103
  # @return [Integer, nil]
71
104
  def count
72
105
  end
73
-
74
- # Convenience method to allow tasks define enumerators with cursors for
75
- # compatibility with Job Iteration.
76
- #
77
- # @return [JobIteration::EnumeratorBuilder] instance of an enumerator
78
- # builder available to tasks.
79
- def enumerator_builder
80
- JobIteration.enumerator_builder.new(nil)
81
- end
82
106
  end
83
107
  end
@@ -11,7 +11,8 @@ module MaintenanceTasks
11
11
  # enqueued -> pausing occurs when the task is paused before starting.
12
12
  # enqueued -> cancelling occurs when the task is cancelled
13
13
  # before starting.
14
- # enqueued -> errored occurs when the task job fails to be enqueued.
14
+ # enqueued -> errored occurs when the task job fails to be enqueued, or
15
+ # if the Task is deleted before is starts running.
15
16
  'enqueued' => ['running', 'pausing', 'cancelling', 'errored'],
16
17
  # pausing -> paused occurs when the task actually halts performing and
17
18
  # occupies a status of paused.
@@ -56,7 +57,9 @@ module MaintenanceTasks
56
57
  # it is interrupted.
57
58
  # interrupted -> cancelling occurs when the task is cancelled by the user
58
59
  # while it is interrupted.
59
- 'interrupted' => ['running', 'pausing', 'cancelling'],
60
+ # interrupted -> errored occurs when the task is deleted while it is
61
+ # interrupted.
62
+ 'interrupted' => ['running', 'pausing', 'cancelling', 'errored'],
60
63
  }
61
64
 
62
65
  # Validate whether a transition from one Run status
@@ -2,10 +2,4 @@
2
2
  <div class="navbar-brand">
3
3
  <%= link_to 'Maintenance Tasks', root_path, class: 'navbar-item is-size-4 has-text-weight-semibold has-text-danger' %>
4
4
  </div>
5
-
6
- <div id="navbarMenu" class="navbar-menu is-active">
7
- <div class="navbar-start">
8
- <%= link_to "Runs", runs_path, class: 'navbar-item' %>
9
- </div>
10
- </div>
11
5
  </nav>
@@ -1,6 +1,5 @@
1
1
  <h5 class="title is-5">
2
2
  <%= time_tag run.created_at, title: run.created_at %>
3
- <%= status_tag run.status if with_status %>
4
3
  </h5>
5
4
 
6
5
  <%= progress run %>
@@ -8,3 +7,9 @@
8
7
  <div class="content">
9
8
  <%= render "maintenance_tasks/runs/info/#{run.status}", run: run %>
10
9
  </div>
10
+
11
+ <% if run.csv_file.attached? %>
12
+ <div class="block">
13
+ <%= link_to('Download CSV', csv_file_download_path(run)) %>
14
+ </div>
15
+ <% end %>
@@ -1,11 +1,8 @@
1
1
  <div class="box">
2
- <% if with_task_name %>
3
- <h3 class="title is-3">
4
- <%= link_to run.task_name, task_path(run.task_name) %>
5
- <%= status_tag run.status %>
6
- </h3>
7
- <%= render 'maintenance_tasks/runs/info', run: run, with_status: false %>
8
- <% else %>
9
- <%= render 'maintenance_tasks/runs/info', run: run, with_status: true %>
10
- <% end %>
2
+ <h5 class="title is-5">
3
+ <%= time_tag run.created_at, title: run.created_at %>
4
+ <%= status_tag run.status %>
5
+ </h5>
6
+
7
+ <%= render 'maintenance_tasks/runs/info', run: run %>
11
8
  </div>
@@ -7,7 +7,34 @@
7
7
  <%= render 'maintenance_tasks/runs/info', run: @task.last_run, with_status: false if @task.last_run %>
8
8
 
9
9
  <div class="buttons">
10
- <%= render "maintenance_tasks/tasks/actions/#{@task.status}", task: @task %>
10
+ <% last_run = @task.last_run %>
11
+ <% if last_run.nil? || last_run.completed? %>
12
+ <%= form_with url: run_task_path(@task), method: :put do |form| %>
13
+ <% if @task.csv_task? %>
14
+ <div class="block">
15
+ <%= form.label :csv_file %>
16
+ <%= form.file_field :csv_file %>
17
+ </div>
18
+ <% end %>
19
+ <div class="block">
20
+ <%= form.submit 'Run', class: "button is-success", disabled: @task.deleted? %>
21
+ </div>
22
+ <% end %>
23
+ <% elsif last_run.cancelling? %>
24
+ <%= button_to 'Run', run_task_path(@task), method: :put, class: 'button is-success', disabled: true %>
25
+ <% if last_run.stuck? %>
26
+ <%= button_to 'Cancel', cancel_task_run_path(@task, last_run), method: :put, class: 'button is-danger', disabled: @task.deleted? %>
27
+ <% end %>
28
+ <% elsif last_run.pausing? %>
29
+ <%= button_to 'Pausing', pause_task_run_path(@task, last_run), method: :put, class: 'button is-warning', disabled: true %>
30
+ <%= button_to 'Cancel', cancel_task_run_path(@task, last_run), method: :put, class: 'button is-danger' %>
31
+ <% elsif last_run.paused? %>
32
+ <%= button_to 'Resume', run_task_path(@task), method: :put, class: 'button is-primary', disabled: @task.deleted? %>
33
+ <%= button_to 'Cancel', cancel_task_run_path(@task, last_run), method: :put, class: 'button is-danger' %>
34
+ <% else %>
35
+ <%= button_to 'Pause', pause_task_run_path(@task, last_run), method: :put, class: 'button is-warning', disabled: @task.deleted? %>
36
+ <%= button_to 'Cancel', cancel_task_run_path(@task, last_run), method: :put, class: 'button is-danger' %>
37
+ <% end%>
11
38
  </div>
12
39
 
13
40
  <% if (code = @task.code) %>
@@ -19,7 +46,7 @@
19
46
 
20
47
  <h4 class="title is-4">Previous Runs</h4>
21
48
 
22
- <%= render @previous_runs, with_task_name: false %>
49
+ <%= render @previous_runs %>
23
50
 
24
51
  <%= pagination(@pagy) %>
25
52
  <% end %>
data/config/routes.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  MaintenanceTasks::Engine.routes.draw do
3
- resources :runs, only: [:index], format: false
4
-
5
3
  resources :tasks, only: [:index, :show], format: false do
6
4
  member do
7
5
  put 'run'
@@ -9,6 +9,9 @@ module MaintenanceTasks
9
9
  desc 'This generator creates a task file at app/tasks and a corresponding '\
10
10
  'test.'
11
11
 
12
+ class_option :csv, type: :boolean, default: false,
13
+ desc: 'Generate a CSV Task.'
14
+
12
15
  check_class_collision suffix: 'Task'
13
16
 
14
17
  # Creates the Task file.
@@ -18,7 +21,11 @@ module MaintenanceTasks
18
21
  class_path,
19
22
  "#{file_name}_task.rb"
20
23
  )
21
- template('task.rb', template_file)
24
+ if options[:csv]
25
+ template('csv_task.rb', template_file)
26
+ else
27
+ template('task.rb', template_file)
28
+ end
22
29
  end
23
30
 
24
31
  # Creates the Task test file, according to the app's test framework.
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= tasks_module %>
4
+ <% module_namespacing do -%>
5
+ class <%= class_name %>Task < MaintenanceTasks::Task
6
+ csv_collection
7
+
8
+ def process(row)
9
+ # The work to be done on a row of the CSV
10
+ end
11
+ end
12
+ <% end -%>
13
+ end
@@ -6,7 +6,7 @@ module <%= tasks_module %>
6
6
  RSpec.describe <%= class_name %>Task do
7
7
  # describe '#process' do
8
8
  # it 'performs a task iteration' do
9
- # <%= tasks_module %>::<%= class_name %>Task.new.process(element)
9
+ # <%= tasks_module %>::<%= class_name %>Task.process(element)
10
10
  # end
11
11
  # end
12
12
  end
@@ -5,7 +5,7 @@ module <%= tasks_module %>
5
5
  <% module_namespacing do -%>
6
6
  class <%= class_name %>TaskTest < ActiveSupport::TestCase
7
7
  # test "#process performs a task iteration" do
8
- # <%= tasks_module %>::<%= class_name %>Task.new.process(element)
8
+ # <%= tasks_module %>::<%= class_name %>Task.process(element)
9
9
  # end
10
10
  end
11
11
  <% end -%>
@@ -21,7 +21,7 @@ module MaintenanceTasks
21
21
  # `MaintenanceTasks::TaskJob` or a class that inherits from it.
22
22
  #
23
23
  # @param [String] the name of the job class.
24
- mattr_writer :job, default: 'MaintenanceTasks::TaskJob'
24
+ mattr_accessor :job, default: 'MaintenanceTasks::TaskJob'
25
25
 
26
26
  # After each iteration, the progress of the task may be updated. This duration
27
27
  # in seconds limits these updates, skipping if the duration since the last
@@ -32,27 +32,23 @@ module MaintenanceTasks
32
32
  # the ticker during Task iterations.
33
33
  mattr_accessor :ticker_delay, default: 1.second
34
34
 
35
- # Defines a callback to be performed when an error occurs in the task.
36
- mattr_accessor :error_handler, default: ->(_error) {}
37
-
38
- class << self
39
- # Retrieves the class that is configured as the Task Job to be used to
40
- # perform Tasks.
41
- #
42
- # @return [TaskJob] the job class.
43
- def job
44
- @@job.constantize
45
- end
35
+ # Retrieves the callback to be performed when an error occurs in the task.
36
+ def self.error_handler
37
+ return @error_handler if defined?(@error_handler)
38
+ @error_handler = ->(_error, _task_context, _errored_element) {}
39
+ end
46
40
 
47
- # Attempts to configure Bugsnag integration. If the application uses
48
- # Bugsnag, it is automatically configured to report on errors raised while
49
- # a Task is performing.
50
- def configure_bugsnag_integration
51
- load('maintenance_tasks/integrations/bugsnag_handler.rb')
52
- rescue LoadError
53
- nil
41
+ # Defines a callback to be performed when an error occurs in the task.
42
+ def self.error_handler=(error_handler)
43
+ unless error_handler.arity == 3
44
+ ActiveSupport::Deprecation.warn(
45
+ 'MaintenanceTasks.error_handler should be a lambda that takes three '\
46
+ 'arguments: error, task_context, and errored_element.'
47
+ )
48
+ @error_handler = ->(error, _task_context, _errored_element) do
49
+ error_handler.call(error)
50
+ end
54
51
  end
52
+ @error_handler = error_handler
55
53
  end
56
54
  end
57
-
58
- MaintenanceTasks.configure_bugsnag_integration
@@ -24,17 +24,32 @@ module MaintenanceTasks
24
24
  #{MaintenanceTasks::Task.available_tasks.join("\n\n")}
25
25
  LONGDESC
26
26
 
27
+ # Specify the CSV file to process for CSV Tasks
28
+ option :csv, desc: 'Supply a CSV file to be processed by a CSV Task, '\
29
+ '--csv "path/to/csv/file.csv"'
30
+
27
31
  # Command to run a Task.
28
32
  #
29
33
  # It instantiates a Runner and sends a run message with the given Task name.
34
+ # If a CSV file is supplied using the --csv option, an attachable with the
35
+ # File IO object is sent along with the Task name to run.
30
36
  #
31
37
  # @param name [String] the name of the Task to be run.
32
38
  def perform(name)
33
- task = Runner.new.run(name: name)
39
+ task = Runner.run(name: name, csv_file: csv_file)
34
40
  say_status(:success, "#{task.name} was enqueued.", :green)
35
41
  rescue => error
36
42
  say_status(:error, error.message, :red)
37
43
  end
44
+
45
+ private
46
+
47
+ def csv_file
48
+ csv_option = options[:csv]
49
+ if csv_option
50
+ { io: File.open(csv_option), filename: File.basename(csv_option) }
51
+ end
52
+ end
38
53
  end
39
54
  private_constant :CLI
40
55
  end
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.0.0
4
+ version: 1.1.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-01-06 00:00:00.000000000 Z
11
+ date: 2021-02-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -110,6 +110,7 @@ files:
110
110
  - app/helpers/maintenance_tasks/task_helper.rb
111
111
  - app/jobs/maintenance_tasks/task_job.rb
112
112
  - app/models/maintenance_tasks/application_record.rb
113
+ - app/models/maintenance_tasks/csv_collection.rb
113
114
  - app/models/maintenance_tasks/progress.rb
114
115
  - app/models/maintenance_tasks/run.rb
115
116
  - app/models/maintenance_tasks/runner.rb
@@ -121,7 +122,6 @@ files:
121
122
  - app/views/layouts/maintenance_tasks/application.html.erb
122
123
  - app/views/maintenance_tasks/runs/_info.html.erb
123
124
  - app/views/maintenance_tasks/runs/_run.html.erb
124
- - app/views/maintenance_tasks/runs/index.html.erb
125
125
  - app/views/maintenance_tasks/runs/info/_cancelled.html.erb
126
126
  - app/views/maintenance_tasks/runs/info/_cancelling.html.erb
127
127
  - app/views/maintenance_tasks/runs/info/_enqueued.html.erb
@@ -132,16 +132,6 @@ files:
132
132
  - app/views/maintenance_tasks/runs/info/_running.html.erb
133
133
  - app/views/maintenance_tasks/runs/info/_succeeded.html.erb
134
134
  - app/views/maintenance_tasks/tasks/_task.html.erb
135
- - app/views/maintenance_tasks/tasks/actions/_cancelled.html.erb
136
- - app/views/maintenance_tasks/tasks/actions/_cancelling.html.erb
137
- - app/views/maintenance_tasks/tasks/actions/_enqueued.html.erb
138
- - app/views/maintenance_tasks/tasks/actions/_errored.html.erb
139
- - app/views/maintenance_tasks/tasks/actions/_interrupted.html.erb
140
- - app/views/maintenance_tasks/tasks/actions/_new.html.erb
141
- - app/views/maintenance_tasks/tasks/actions/_paused.html.erb
142
- - app/views/maintenance_tasks/tasks/actions/_pausing.html.erb
143
- - app/views/maintenance_tasks/tasks/actions/_running.html.erb
144
- - app/views/maintenance_tasks/tasks/actions/_succeeded.html.erb
145
135
  - app/views/maintenance_tasks/tasks/index.html.erb
146
136
  - app/views/maintenance_tasks/tasks/show.html.erb
147
137
  - config/routes.rb
@@ -149,21 +139,21 @@ files:
149
139
  - exe/maintenance_tasks
150
140
  - lib/generators/maintenance_tasks/install_generator.rb
151
141
  - lib/generators/maintenance_tasks/task_generator.rb
142
+ - lib/generators/maintenance_tasks/templates/csv_task.rb.tt
152
143
  - lib/generators/maintenance_tasks/templates/task.rb.tt
153
144
  - lib/generators/maintenance_tasks/templates/task_spec.rb.tt
154
145
  - lib/generators/maintenance_tasks/templates/task_test.rb.tt
155
146
  - lib/maintenance_tasks.rb
156
147
  - lib/maintenance_tasks/cli.rb
157
148
  - lib/maintenance_tasks/engine.rb
158
- - lib/maintenance_tasks/integrations/bugsnag_handler.rb
159
149
  - lib/tasks/maintenance_tasks_tasks.rake
160
150
  homepage: https://github.com/Shopify/maintenance_tasks
161
151
  licenses: []
162
152
  metadata:
163
- source_code_uri: https://github.com/Shopify/maintenance_tasks/tree/v1.0.0
153
+ source_code_uri: https://github.com/Shopify/maintenance_tasks/tree/v1.1.0
164
154
  allowed_push_host: https://rubygems.org
165
155
  post_install_message: |-
166
- Thank you for installing Maintenance Tasks 1.0.0. To complete, please run:
156
+ Thank you for installing Maintenance Tasks 1.1.0. To complete, please run:
167
157
 
168
158
  rails generate maintenance_tasks:install
169
159
  rdoc_options: []
@@ -1,15 +0,0 @@
1
- <div class="block">
2
- <%= form_with url: runs_path, method: :get do |form| %>
3
- <div class="field has-addons">
4
- <div class="control">
5
- <%= form.search_field :task_name, value: params[:task_name], placeholder: "Task name", class: "input" %>
6
- </div>
7
- <div class="control">
8
- <%= form.submit "Search", class: "button is-link" %>
9
- </div>
10
- </div>
11
- <% end %>
12
- </div>
13
-
14
- <%= render @runs, with_task_name: true %>
15
- <%= pagination(@pagy) %>
@@ -1 +0,0 @@
1
- <%= button_to 'Run', run_task_path(task), method: :put, class: 'button is-success', disabled: task.deleted? %>
@@ -1,4 +0,0 @@
1
- <%= button_to 'Run', run_task_path(task), method: :put, class: 'button is-success', disabled: true %>
2
- <% if task.last_run.stuck? %>
3
- <%= button_to 'Cancel', cancel_task_run_path(task, task.last_run), method: :put, class: 'button is-danger', disabled: task.deleted? %>
4
- <% end %>
@@ -1,2 +0,0 @@
1
- <%= button_to 'Pause', pause_task_run_path(task, task.last_run), method: :put, class: 'button is-warning', disabled: task.deleted? %>
2
- <%= button_to 'Cancel', cancel_task_run_path(task, task.last_run), method: :put, class: 'button is-danger' %>
@@ -1 +0,0 @@
1
- <%= button_to 'Run', run_task_path(task), method: :put, class: 'button is-success', disabled: task.deleted? %>
@@ -1,2 +0,0 @@
1
- <%= button_to 'Pause', pause_task_run_path(task, task.last_run), method: :put, class: 'button is-warning', disabled: task.deleted? %>
2
- <%= button_to 'Cancel', cancel_task_run_path(task, task.last_run), method: :put, class: 'button is-danger' %>
@@ -1 +0,0 @@
1
- <%= button_to 'Run', run_task_path(task), method: :put, class: 'button is-success', disabled: task.deleted? %>
@@ -1,2 +0,0 @@
1
- <%= button_to 'Resume', run_task_path(task), method: :put, class: 'button is-primary', disabled: task.deleted? %>
2
- <%= button_to 'Cancel', cancel_task_run_path(task, task.last_run), method: :put, class: 'button is-danger' %>
@@ -1,2 +0,0 @@
1
- <%= button_to 'Pausing', pause_task_run_path(task, task.last_run), method: :put, class: 'button is-warning', disabled: true %>
2
- <%= button_to 'Cancel', cancel_task_run_path(task, task.last_run), method: :put, class: 'button is-danger', disabled: task.deleted? %>
@@ -1,2 +0,0 @@
1
- <%= button_to 'Pause', pause_task_run_path(task, task.last_run), method: :put, class: 'button is-warning', disabled: task.deleted? %>
2
- <%= button_to 'Cancel', cancel_task_run_path(task, task.last_run), method: :put, class: 'button is-danger' %>
@@ -1 +0,0 @@
1
- <%= button_to 'Run', run_task_path(task), method: :put, class: 'button is-success', disabled: task.deleted? %>
@@ -1,4 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'bugsnag'
3
-
4
- MaintenanceTasks.error_handler = ->(error) { Bugsnag.notify(error) }