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",