diff --git a/README.md b/README.md
index 79aaaa15e..e027cfd08 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/jobs/concerns/maintenance_tasks/task_job_concern.rb b/app/jobs/concerns/maintenance_tasks/task_job_concern.rb
index 4293354e4..bf468af20 100644
--- a/app/jobs/concerns/maintenance_tasks/task_job_concern.rb
+++ b/app/jobs/concerns/maintenance_tasks/task_job_concern.rb
@@ -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
diff --git a/app/models/maintenance_tasks/run.rb b/app/models/maintenance_tasks/run.rb
index 7d330bcdf..c5fcd903d 100644
--- a/app/models/maintenance_tasks/run.rb
+++ b/app/models/maintenance_tasks/run.rb
@@ -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 || ""
+ 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
diff --git a/app/models/maintenance_tasks/task.rb b/app/models/maintenance_tasks/task.rb
index ff18544eb..20502633f 100644
--- a/app/models/maintenance_tasks/task.rb
+++ b/app/models/maintenance_tasks/task.rb
@@ -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.
@@ -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
diff --git a/app/views/maintenance_tasks/runs/_output.html.erb b/app/views/maintenance_tasks/runs/_output.html.erb
new file mode 100644
index 000000000..15eed69b5
--- /dev/null
+++ b/app/views/maintenance_tasks/runs/_output.html.erb
@@ -0,0 +1,7 @@
+<% if output.present? %>
+
+
Task Output
+
<%= output %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/maintenance_tasks/runs/_run.html.erb b/app/views/maintenance_tasks/runs/_run.html.erb
index b1d1dc776..584332400 100644
--- a/app/views/maintenance_tasks/runs/_run.html.erb
+++ b/app/views/maintenance_tasks/runs/_run.html.erb
@@ -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) %>
<% if run.paused? %>
diff --git a/db/migrate/20250117000000_add_output_to_maintenance_tasks_runs.rb b/db/migrate/20250117000000_add_output_to_maintenance_tasks_runs.rb
new file mode 100644
index 000000000..518c1d1b6
--- /dev/null
+++ b/db/migrate/20250117000000_add_output_to_maintenance_tasks_runs.rb
@@ -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
diff --git a/test/dummy/app/tasks/maintenance/output_test_task.rb b/test/dummy/app/tasks/maintenance/output_test_task.rb
new file mode 100644
index 000000000..db92cb1b9
--- /dev/null
+++ b/test/dummy/app/tasks/maintenance/output_test_task.rb
@@ -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
diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb
index 4032acb9d..947d2227b 100644
--- a/test/dummy/db/schema.rb
+++ b/test/dummy/db/schema.rb
@@ -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
@@ -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"
diff --git a/test/models/maintenance_tasks/task_data_index_test.rb b/test/models/maintenance_tasks/task_data_index_test.rb
index 10410d9e4..e4f1dd9d5 100644
--- a/test/models/maintenance_tasks/task_data_index_test.rb
+++ b/test/models/maintenance_tasks/task_data_index_test.rb
@@ -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
diff --git a/test/models/maintenance_tasks/task_output_test.rb b/test/models/maintenance_tasks/task_output_test.rb
new file mode 100644
index 000000000..90aee8d72
--- /dev/null
+++ b/test/models/maintenance_tasks/task_output_test.rb
@@ -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
diff --git a/test/models/maintenance_tasks/task_test.rb b/test/models/maintenance_tasks/task_test.rb
index 85d743231..396f860af 100644
--- a/test/models/maintenance_tasks/task_test.rb
+++ b/test/models/maintenance_tasks/task_test.rb
@@ -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
diff --git a/test/system/maintenance_tasks/tasks_test.rb b/test/system/maintenance_tasks/tasks_test.rb
index 3de7bd89e..8e43efdf4 100644
--- a/test/system/maintenance_tasks/tasks_test.rb
+++ b/test/system/maintenance_tasks/tasks_test.rb
@@ -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",