maintenance_tasks 2.14.0 → 2.15.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: 7ceffb83ecb318ecf0edd66c78f55d4dd51c6528d4faf72d6e7ca66efa83db02
4
- data.tar.gz: 360bc77f347ac909ed96a30495f965cc908cc5fc7956f1edfa8c3b2aba385607
3
+ metadata.gz: f74ab248a9b57875d5e7b47234598a46fbc37033fb7d96c95e26b03c55d83793
4
+ data.tar.gz: cf2322966430eee4215131e9885aba07591d2e6f5081f6ab1683188ae5be8e80
5
5
  SHA512:
6
- metadata.gz: b46d6c5afcb297a2be58c9e218cad173b4abf65d50784574ac5fb4afdbaeaf83fc9add501e0817c85190b6b0e4299988b9a3ca87d28a83147e912e10f8d98cfd
7
- data.tar.gz: 5b4ecaeff775b36f909dfe80d50df4834d642ba1fbf09d9668d11d47d011a2bb1a8302b612a07d8bec9f0b4d14ffba2d33f53f6e46b9999b6b74df4c41f9571b
6
+ metadata.gz: 22bde697fa7b38899ff5dc4c7fc353776a29766f479a3fac4f988b921aaf62bd58534f55a2c9caa1b9bf227ddbbef249c1d521b863afc5ab59527ad32528a4e9
7
+ data.tar.gz: 1f51f3e1ead4af15d02886b950562e0a103baf055839e1e5e6f661b2dfd29d47626859fbd350e8d311293c88d4b4f59189ae70e32a8dbca54559d75ce5fafaa5
data/README.md CHANGED
@@ -391,7 +391,10 @@ This method should return an `Enumerator`, yielding pairs of `[item, cursor]`.
391
391
  Maintenance Tasks takes care of persisting the current cursor position and will
392
392
  provide it as the `cursor` argument if your task is interrupted or resumed. The
393
393
  `cursor` is stored as a `String`, so your custom enumerator should handle
394
- serializing/deserializing the value if required.
394
+ serializing/deserializing the value if required. Note that if you've enabled
395
+ [JSON cursors](#json-cursors), the cursor will be automatically serialized and
396
+ deserialized. However for this to work, the cursor values must be
397
+ JSON-serializable.
395
398
 
396
399
  ```ruby
397
400
  # app/tasks/maintenance/custom_enumerator_task.rb
@@ -561,6 +564,9 @@ control the cursor columns, through the `cursor_columns` method in the
561
564
  the query is ordered by the primary key. If cursor columns values change during
562
565
  an iteration, records may be skipped or yielded multiple times.
563
566
 
567
+ Note that in order to use this feature, you must first enable
568
+ [JSON cursors](#json-cursors).
569
+
564
570
  ```ruby
565
571
  module Maintenance
566
572
  class UpdatePostsTask < MaintenanceTasks::Task
@@ -1317,6 +1323,46 @@ MaintenanceTasks.metadata = ->() do
1317
1323
  end
1318
1324
  ```
1319
1325
 
1326
+ #### JSON Cursors
1327
+
1328
+ By default, cursor values are persisted in the database as a string. If you
1329
+ want to iterate over collections that require multiple values to keep track of
1330
+ the position, you can enable JSON cursors. This will cause the cursor to be
1331
+ serialized as JSON when persisted, and deserialized when read back.
1332
+
1333
+ This is especially useful when you need to iterate over a model that uses a
1334
+ composite primary key.
1335
+
1336
+ Be advised that this feature comes with a few caveats:
1337
+
1338
+ 1. Cursor values must be capable of being serialized to JSON and parsed from
1339
+ JSON. If they are not, errors will occur during task execution.
1340
+ 2. If a cursor contains a value that loses precision when serialized, it may
1341
+ lead to unexpected behaviour.
1342
+ 3. This feature utilizes a string column to store the JSON data. If your
1343
+ database has a hard limit on how big a string value can be, be mindful that
1344
+ certain cursor structures could result in a value that could exceed that
1345
+ limit and cause issues.
1346
+
1347
+ A new column was added to discern JSON cursors from plain string cursors. Make
1348
+ sure you have run the latest database migrations provided by the gem before
1349
+ enabling this feature. See [upgrading](#upgrading) for more information.
1350
+
1351
+ ```ruby
1352
+ # config/initializers/maintenance_tasks.rb
1353
+ MaintenanceTasks.serialize_cursors_as_json = true
1354
+ ```
1355
+
1356
+ #### Configure staleness threshold
1357
+
1358
+ To specify a staleness threshold date interval which will mark task runs as stale, you can
1359
+ configure `MaintenanceTasks.task_staleness_threshold`. Tasks that have last run
1360
+ successfully after this threshold will be marked stale.
1361
+
1362
+ The value for `MaintenanceTasks.task_staleness_threshold` must be an
1363
+ `ActiveSupport::Duration`. If no value is specified, it will default to 30 days. This can be disabled
1364
+ by setting `MaintenanceTasks.task_staleness_threshold` to `false`.
1365
+
1320
1366
  ## Upgrading
1321
1367
 
1322
1368
  Use bundler to check for and upgrade to newer versions. After installing a new
@@ -62,7 +62,7 @@ module MaintenanceTasks
62
62
  def status_tag(status)
63
63
  tag.span(
64
64
  status.capitalize,
65
- class: ["tag", "has-text-weight-medium", "pr-2", "mr-4"] + STATUS_COLOURS.fetch(status),
65
+ class: ["tag", "has-text-weight-medium", "px-2", "mx-4"] + STATUS_COLOURS.fetch(status),
66
66
  )
67
67
  end
68
68
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module MaintenanceTasks
4
6
  # Concern that holds the behaviour of the job that runs the tasks. It is
5
7
  # included in {TaskJob} and if MaintenanceTasks.job is overridden, it must be
@@ -30,8 +32,18 @@ module MaintenanceTasks
30
32
 
31
33
  private
32
34
 
35
+ def serialized_cursor_position
36
+ cursor_position && @run.cursor_is_json? ? cursor_position.to_json : cursor_position
37
+ end
38
+
39
+ def deserialized_run_cursor
40
+ return JSON.parse(@run.cursor) if @run.cursor && @run.cursor_is_json?
41
+
42
+ @run.cursor
43
+ end
44
+
33
45
  def build_enumerator(_run, cursor:)
34
- cursor ||= @run.cursor
46
+ cursor ||= deserialized_run_cursor
35
47
  self.cursor_position = cursor
36
48
  enumerator_builder = self.enumerator_builder
37
49
  @collection_enum = @task.enumerator_builder(cursor: cursor)
@@ -140,7 +152,7 @@ module MaintenanceTasks
140
152
 
141
153
  def on_shutdown
142
154
  @run.job_shutdown
143
- @run.cursor = cursor_position
155
+ @run.cursor = serialized_cursor_position
144
156
  @ticker.persist
145
157
  end
146
158
 
@@ -177,7 +189,7 @@ module MaintenanceTasks
177
189
  @ticker.persist if defined?(@ticker)
178
190
 
179
191
  if defined?(@run)
180
- @run.cursor = cursor_position
192
+ @run.cursor = serialized_cursor_position
181
193
  @run.persist_error(error)
182
194
 
183
195
  task_context = {
@@ -447,6 +447,41 @@ module MaintenanceTasks
447
447
  argument_filter.filter(arguments)
448
448
  end
449
449
 
450
+ # @return [Boolean]
451
+ # True when the cursor value should be treated as serialized JSON.
452
+ def cursor_is_json?
453
+ MaintenanceTasks.serialize_cursors_as_json && cursor_is_json
454
+ end
455
+
456
+ # Configures the Run to use the appropriate type of cursor encoding based
457
+ # on `MaintenanceTasks.serialize_cursors_as_json`.
458
+ #
459
+ # This method exists to gracefully handle the situation where the
460
+ # `cursor_is_json` column does not exist. As long as the application is not
461
+ # configured to use JSON cursors (the default), the Run will continue to
462
+ # function even without the new column.
463
+ #
464
+ # * When `MaintenanceTasks.serialize_cursors_as_json` is false, this method
465
+ # no-ops.
466
+ # * When `MaintenanceTasks.serialize_cursors_as_json` is true, this method
467
+ # will mutate the Run so that `cursor_is_json` is set to true.
468
+ def configure_cursor_encoding!
469
+ return unless MaintenanceTasks.serialize_cursors_as_json
470
+
471
+ self.cursor_is_json = true
472
+ end
473
+
474
+ # Returns whether the run is stale based on the staleness threshold.
475
+ #
476
+ # @return [Boolean]
477
+ def stale?
478
+ return false unless MaintenanceTasks.task_staleness_threshold.present?
479
+ return false unless succeeded?
480
+ return false unless ended_at.present?
481
+
482
+ ended_at < MaintenanceTasks.task_staleness_threshold.ago
483
+ end
484
+
450
485
  private
451
486
 
452
487
  def instrument_status_change
@@ -41,6 +41,8 @@ module MaintenanceTasks
41
41
  # to a value being too long for the column type.
42
42
  def run(name:, csv_file: nil, arguments: {}, run_model: Run, metadata: nil)
43
43
  run = run_model.new(task_name: name, arguments: arguments, metadata: metadata)
44
+ run.configure_cursor_encoding!
45
+
44
46
  if csv_file
45
47
  run.csv_file.attach(csv_file)
46
48
  run.csv_file.filename = filename(name)
@@ -64,6 +64,15 @@ module MaintenanceTasks
64
64
 
65
65
  alias_method :to_s, :name
66
66
 
67
+ # Delegates to the related run's stale? method when available.
68
+ #
69
+ # @return [Boolean] whether the related run is stale.
70
+ def stale?
71
+ return false unless related_run.present?
72
+
73
+ related_run.stale?
74
+ end
75
+
67
76
  # Returns the status of the latest active or completed Run, if present.
68
77
  # If the Task does not have any Runs, the Task status is `new`.
69
78
  #
@@ -1,4 +1,4 @@
1
- <nav class="navbar" role="navigation" aria-label="main navigation">
1
+ <nav class="navbar" role="navigation" aria-label="Main navigation">
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>
@@ -46,6 +46,6 @@
46
46
  <% elsif run.active? %>
47
47
  <%= button_to 'Pause', pause_task_run_path(@task, run), class: 'button is-warning', disabled: @task.deleted? %>
48
48
  <%= button_to 'Cancel', cancel_task_run_path(@task, run), class: 'button is-danger' %>
49
- <% end%>
49
+ <% end %>
50
50
  </div>
51
51
  </details>
@@ -1,5 +1,5 @@
1
1
  <p>
2
- Ran for <%= time_running_in_words run %> until an error happened
2
+ Ran for <%= time_running_in_words run %> until an error happened
3
3
  <%= time_ago run.ended_at %>.
4
4
  </p>
5
5
 
@@ -1,5 +1,5 @@
1
1
  <p>
2
- Ran for <%= time_running_in_words run %> until paused,
2
+ Ran for <%= time_running_in_words run %> until paused,
3
3
  <% if (time_to_completion = run.time_to_completion) %>
4
4
  <%= distance_of_time_in_words(time_to_completion) %> remaining.
5
5
  <% else %>
@@ -1,3 +1,3 @@
1
1
  <% if (time_to_completion = run.time_to_completion) %>
2
- <p>Running for <%= time_running_in_words(run) %>. <%= distance_of_time_in_words(time_to_completion).capitalize %> remaining.</p>
2
+ <p>Running for <%= time_running_in_words(run) %>. <%= distance_of_time_in_words(time_to_completion).capitalize %> remaining.</p>
3
3
  <% end %>
@@ -2,4 +2,3 @@
2
2
  Ran for <%= time_running_in_words run %>,
3
3
  finished <%= time_ago run.ended_at %>.
4
4
  </p>
5
-
@@ -1,10 +1,22 @@
1
1
  <div class="cell box">
2
- <h3 class="title is-5 has-text-weight-medium">
2
+ <h3 class="title is-5 has-text-weight-medium is-flex is-align-items-center">
3
3
  <%= link_to task, task_path(task) %>
4
4
  <%= status_tag(task.status) %>
5
5
  </h3>
6
6
 
7
7
  <% if (run = task.related_run) %>
8
+ <% if task.stale? %>
9
+ <div class="content is-size-7 is-flex is-align-items-center has-text-warning">
10
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon is-small">
11
+ <path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
12
+ </svg>
13
+
14
+ <p class="ml-1">
15
+ This task last ran <%= MaintenanceTasks.task_staleness_threshold.inspect %> ago. Consider removing it as it may be stale.
16
+ </p>
17
+ </div>
18
+ <% end %>
19
+
8
20
  <h5 class="title is-5 has-text-weight-medium">
9
21
  <%= time_tag run.created_at, title: run.created_at.utc %>
10
22
  </h5>
@@ -14,9 +14,9 @@
14
14
  <% end %>
15
15
  <% if new_tasks = @available_tasks[:new] %>
16
16
  <h3 class="title is-4 has-text-weight-bold">New Tasks</h3>
17
- <div class="grid is-col-min-20">
17
+ <div class="grid is-col-min-20">
18
18
  <%= render partial: 'task', collection: new_tasks %>
19
- </div>
19
+ </div>
20
20
  <% end %>
21
21
  <% if completed_tasks = @available_tasks[:completed] %>
22
22
  <h3 class="title is-4 has-text-weight-bold">Completed Tasks</h3>
@@ -44,7 +44,7 @@
44
44
 
45
45
  <%= tag.div(data: { refresh: @task.refresh? || "" }) do %>
46
46
  <% if @task.active_runs.any? %>
47
- <hr/>
47
+ <hr>
48
48
 
49
49
  <h4 class="title is-4">Active Runs</h4>
50
50
 
@@ -52,7 +52,7 @@
52
52
  <% end %>
53
53
 
54
54
  <% if @task.runs_page.records.present? %>
55
- <hr/>
55
+ <hr>
56
56
 
57
57
  <h4 class="title is-5 has-text-weight-bold">Previous Runs</h4>
58
58
 
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCursorIsJsonFlagToRuns < ActiveRecord::Migration[7.1]
4
+ def change
5
+ add_column(:maintenance_tasks_runs, :cursor_is_json, :boolean, default: false, null: false)
6
+ end
7
+ end
@@ -109,6 +109,42 @@ module MaintenanceTasks
109
109
  # @return [Boolean] whether to report unexpected errors as handled (true) or unhandled (false).
110
110
  mattr_accessor :report_errors_as_handled, default: true
111
111
 
112
+ # @!attribute serialize_cursors_as_json
113
+ # @scope class
114
+ # Controls whether or not cursor values are stored as JSON in the database.
115
+ # Defaults to false.
116
+ #
117
+ # Storing cursors as JSON enables more complex cursor structures. For
118
+ # example, with JSON cursors a task can iterate over collections using
119
+ # multiple fields or columns to track progress. This is particularly useful
120
+ # for iterating over models with composite primary keys.
121
+ #
122
+ # Be advised that this feature comes with a few caveats:
123
+ #
124
+ # 1. Cursor values must be capable of being serialized to JSON and parsed
125
+ # from JSON. If they are not, errors will occur during task execution.
126
+ # 2. If a cursor contains a value that loses precision when serialized, it
127
+ # may lead to unexpected behaviour.
128
+ # 3. This feature utilizes a string column to store the JSON data. If your
129
+ # database has a hard limit on how big a string value can be, be mindful
130
+ # that certain cursor structures could result in a value that could exceed
131
+ # that limit and cause issues.
132
+ #
133
+ # A new column was added to discern JSON cursors from plain string cursors.
134
+ # Make sure you have run the latest database migrations provided by the gem
135
+ # before enabling this feature.
136
+ #
137
+ # @return [Boolean] whether or not to store cursor values as JSON.
138
+ mattr_accessor :serialize_cursors_as_json, default: false
139
+
140
+ # @!attribute task_staleness_threshold
141
+ # @scope class
142
+ # The threshold after which a task is considered stale.
143
+ # Defaults to 30 days. Can be disabled by setting this to `false`.
144
+ #
145
+ # @return [ActiveSupport::Duration, false] time interval after which a task is considered stale.
146
+ mattr_accessor :task_staleness_threshold, default: 30.days
147
+
112
148
  class << self
113
149
  DEPRECATION_MESSAGE = "MaintenanceTasks.error_handler is deprecated and will be removed in the 3.0 release. " \
114
150
  "Instead, reports will be sent to the Rails error reporter. Do not set a handler and subscribe " \
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maintenance_tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.14.0
4
+ version: 2.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify Engineering
@@ -167,6 +167,7 @@ files:
167
167
  - db/migrate/20220706101937_change_runs_tick_columns_to_bigints.rb
168
168
  - db/migrate/20220713131925_add_index_on_task_name_and_status_to_runs.rb
169
169
  - db/migrate/20230622035229_add_metadata_to_runs.rb
170
+ - db/migrate/20251128180556_add_cursor_is_json_flag_to_runs.rb
170
171
  - exe/maintenance_tasks
171
172
  - lib/generators/maintenance_tasks/install_generator.rb
172
173
  - lib/generators/maintenance_tasks/task_generator.rb
@@ -183,7 +184,7 @@ homepage: https://github.com/Shopify/maintenance_tasks
183
184
  licenses:
184
185
  - MIT
185
186
  metadata:
186
- source_code_uri: https://github.com/Shopify/maintenance_tasks/tree/v2.14.0
187
+ source_code_uri: https://github.com/Shopify/maintenance_tasks/tree/v2.15.0
187
188
  allowed_push_host: https://rubygems.org
188
189
  rdoc_options: []
189
190
  require_paths:
@@ -199,7 +200,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
199
200
  - !ruby/object:Gem::Version
200
201
  version: '0'
201
202
  requirements: []
202
- rubygems_version: 4.0.6
203
+ rubygems_version: 4.0.10
203
204
  specification_version: 4
204
205
  summary: A Rails engine for queuing and managing maintenance tasks
205
206
  test_files: []