Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,49 @@ module Maintenance
end
```

### Task Output

Maintenance tasks can log output during execution, which is displayed in the web UI.

To use this feature:

1. Run the migration to add the `output` column to your database:
```bash
rails generate migration AddOutputToMaintenanceTasksRuns output:text
rails db:migrate
```

2. Use the `log_output` method in your task's `process` method:

```ruby
module Maintenance
class DataCleanupTask < MaintenanceTasks::Task
def collection
User.where(last_sign_in_at: nil)
end

def process(user)
log_output("Processing user: #{user.email} (ID: #{user.id})")

if user.created_at < 1.year.ago
log_output(" -> Marking user for cleanup")
user.update!(cleanup_flag: true)
log_output(" -> Successfully processed")
else
log_output(" -> Skipping: user created recently")
end
end
end
end
```

The output:
- Is stored in the database with the run record
- Persists permanently and can be viewed anytime
- Is displayed in a formatted box on the run details page
- Updates in real-time as the task executes


### Subscribing to instrumentation events

If you are interested in actioning a specific task event, please refer to the
Expand Down
4 changes: 4 additions & 0 deletions app/jobs/concerns/maintenance_tasks/task_job_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ def task_iteration(input)
def before_perform
@run = arguments.first
@task = @run.task
# Set the run as an instance variable on the task to enable features
# like log_output that need to write data back to the run.
# This creates a bidirectional reference between the Run and Task.
@task.instance_variable_set(:@run, @run)
if @task.has_csv_content?
@task.csv_content = @run.csv_file.download
end
Expand Down
14 changes: 14 additions & 0 deletions app/models/maintenance_tasks/run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,20 @@ def masked_arguments
argument_filter.filter(arguments)
end

# Appends output to the existing output in the database if the column exists.
#
# @param new_output [String] the output to append.
def append_output(new_output)
return unless self.class.column_names.include?("output")

# Reload output from database to get the latest value
current_output = self.class.where(id: id).pluck(:output).first || ""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately I don't think we can afford adding a feature like that to the framework. It may work okay for smaller applications but for large applications with a lot of rows to iterate over the approach of transferring large chunks for data back-and-forth doesn't scale well

separator = current_output.present? ? "\n" : ""
updated_output = current_output + separator + new_output

self.class.where(id: id).update_all(output: updated_output)
end

private

def instrument_status_change
Expand Down
11 changes: 10 additions & 1 deletion app/models/maintenance_tasks/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class NotFoundError < NameError; end

define_callbacks :start, :complete, :error, :cancel, :pause, :interrupt

attr_accessor :metadata
attr_accessor :metadata, :output

class << self
# Finds a Task with the given name.
Expand Down Expand Up @@ -335,5 +335,14 @@ def count
def enumerator_builder(cursor:)
nil
end

# Appends a message to the task output.
#
# @param message [String] the message to append to the output.
def log_output(message)
return unless defined?(@run) && @run

@run.append_output(message)
end
end
end
7 changes: 7 additions & 0 deletions app/views/maintenance_tasks/runs/_output.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<% if output.present? %>
<div class="content">
<h6 class="title is-6">Task Output</h6>
<pre class="box has-background-light"><%= output %></pre>
</div>
<hr>
<% end %>
1 change: 1 addition & 0 deletions app/views/maintenance_tasks/runs/_run.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<%= render "maintenance_tasks/runs/arguments", arguments: run.masked_arguments %>
<%= tag.hr if run.csv_file.present? || run.arguments.present? && run.metadata.present? %>
<%= render "maintenance_tasks/runs/metadata", metadata: run.metadata %>
<%= render "maintenance_tasks/runs/output", output: run.output if run.respond_to?(:output) %>

<div class="buttons">
<% if run.paused? %>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddOutputToMaintenanceTasksRuns < ActiveRecord::Migration[7.0]
def change
add_column(:maintenance_tasks_runs, :output, :text)
end
end
14 changes: 14 additions & 0 deletions test/dummy/app/tasks/maintenance/output_test_task.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Maintenance
class OutputTestTask < MaintenanceTasks::Task
def collection
(1..5).to_a
end

def process(element)
log_output("Processing element #{element}")
log_output("Square of #{element} is #{element * element}")
end
end
end
11 changes: 6 additions & 5 deletions test/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2023_06_22_035229) do
ActiveRecord::Schema.define(version: 2025_01_17_000000) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
Expand Down Expand Up @@ -52,19 +52,20 @@
t.string "error_class"
t.string "error_message"
t.text "backtrace"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "arguments"
t.integer "lock_version", default: 0, null: false
t.text "metadata"
t.text "output"
t.index ["task_name", "status", "created_at"], name: "index_maintenance_tasks_runs", order: { created_at: :desc }
end

create_table "posts", force: :cascade do |t|
t.string "title"
t.string "content"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end

add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
Expand Down
3 changes: 3 additions & 0 deletions test/models/maintenance_tasks/task_data_index_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ class TaskDataIndexTest < ActiveSupport::TestCase
"Maintenance::NoCollectionTask",
# duplicate due to fixtures containing two active runs of this task
"Maintenance::NoCollectionTask",
"Maintenance::OutputTestTask",
"Maintenance::ParamsTask",
"Maintenance::TestTask",
"Maintenance::UpdatePostsInBatchesTask",
"Maintenance::UpdatePostsModulePrependedTask",
"Maintenance::UpdatePostsTask",
"Maintenance::UpdatePostsThrottledTask",
"MaintenanceTasks::TaskOutputTest::NoCollectionTaskWithOutput",
"MaintenanceTasks::TaskOutputTest::TestTaskWithOutput",
]
assert_equal expected, TaskDataIndex.available_tasks.map(&:name)
end
Expand Down
111 changes: 111 additions & 0 deletions test/models/maintenance_tasks/task_output_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

require "test_helper"

module MaintenanceTasks
class TaskOutputTest < ActiveSupport::TestCase
class TestTaskWithOutput < Task
def collection
[1, 2, 3]
end

def process(item)
log_output("Processing item #{item}")
log_output("Item #{item} squared is #{item * item}")
end
end

class NoCollectionTaskWithOutput < Task
no_collection

def process
log_output("Starting task")
log_output("Task completed")
end
end

test "task logs output during processing" do
run = Run.create!(task_name: "MaintenanceTasks::TaskOutputTest::TestTaskWithOutput")
task = TestTaskWithOutput.new
task.instance_variable_set(:@run, run)

task.process(5)
run.reload

assert_equal "Processing item 5\nItem 5 squared is 25", run.output
end

test "output accumulates across multiple process calls" do
run = Run.create!(task_name: "MaintenanceTasks::TaskOutputTest::TestTaskWithOutput")
task = TestTaskWithOutput.new
task.instance_variable_set(:@run, run)

task.process(1)
task.process(2)
run.reload

expected_output = "Processing item 1\nItem 1 squared is 1\n" \
"Processing item 2\nItem 2 squared is 4"
assert_equal expected_output, run.output
end

test "no collection task can log output" do
run = Run.create!(task_name: "MaintenanceTasks::TaskOutputTest::NoCollectionTaskWithOutput")
task = NoCollectionTaskWithOutput.new
task.instance_variable_set(:@run, run)

task.process
run.reload

assert_equal "Starting task\nTask completed", run.output
end

test "log_output does nothing when run is not set" do
task = TestTaskWithOutput.new

# Should not raise error
assert_nothing_raised do
task.process(1)
end
end

test "run appends output correctly" do
run = Run.create!(task_name: "MaintenanceTasks::TaskOutputTest::TestTaskWithOutput")

run.append_output("First line")
run.reload
assert_equal "First line", run.output

run.append_output("Second line")
run.reload
assert_equal "First line\nSecond line", run.output
end

test "output persists across run status changes" do
run = Run.create!(task_name: "MaintenanceTasks::TaskOutputTest::TestTaskWithOutput")

run.append_output("Test output content")
assert_equal "Test output content", run.reload.output

run.running!
assert_equal "Test output content", run.reload.output

run.succeeded!
assert_equal "Test output content", run.reload.output
end

test "append_output handles nil output column gracefully" do
# Simulate missing output column by stubbing column_names
columns_without_output = Run.column_names.dup - ["output"]
Run.stubs(:column_names).returns(columns_without_output)
run = Run.create!(task_name: "MaintenanceTasks::TaskOutputTest::TestTaskWithOutput")

# Should not raise error
assert_nothing_raised do
run.append_output("Test")
end
ensure
Run.unstub(:column_names)
end
end
end
3 changes: 3 additions & 0 deletions test/models/maintenance_tasks/task_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ class TaskTest < ActiveSupport::TestCase
"Maintenance::Nested::NestedMore::NestedMoreTask",
"Maintenance::Nested::NestedTask",
"Maintenance::NoCollectionTask",
"Maintenance::OutputTestTask",
"Maintenance::ParamsTask",
"Maintenance::TestTask",
"Maintenance::UpdatePostsInBatchesTask",
"Maintenance::UpdatePostsModulePrependedTask",
"Maintenance::UpdatePostsTask",
"Maintenance::UpdatePostsThrottledTask",
"MaintenanceTasks::TaskOutputTest::NoCollectionTaskWithOutput",
"MaintenanceTasks::TaskOutputTest::TestTaskWithOutput",
]
assert_equal expected,
MaintenanceTasks::Task.load_all.map(&:name).sort
Expand Down
1 change: 1 addition & 0 deletions test/system/maintenance_tasks/tasks_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class TasksTest < ApplicationSystemTestCase
"Maintenance::ImportPostsWithOptionsTask New",
"Maintenance::Nested::NestedMore::NestedMoreTask New",
"Maintenance::Nested::NestedTask New",
"Maintenance::OutputTestTask New",
"Maintenance::ParamsTask New",
"Maintenance::TestTask New",
"Maintenance::UpdatePostsInBatchesTask New",
Expand Down