Skip to content

Conversation

@iberianpig
Copy link

@iberianpig iberianpig commented Sep 9, 2025

Problem

When a maintenance task completes with an empty collection (tick_total = 0), the progress bar shows infinite loading instead of completion. This
happens because the <progress> HTML element without a value attribute continuously animates, even when the task has finished successfully.

This is especially problematic for tasks with mutable collections. For example, when running a task multiple times where the first execution processes
all matching articles, subsequent runs will have tick_total = 0 but the progress bar still shows infinite loading.

Example scenario:

module Maintenance
  class UpdateArticleStatusTask < MaintenanceTasks::Task
    def collection
      Article.where(status: 'draft', published_at: nil)
    end

    def process(article)
      ActiveRecord::Base.transaction do
        article.update!(status: 'archived')
        some_other_operation(article)
      end
    end

    def count
      collection.count
    end
  end
end

When this task runs again after all matching articles have been processed, tick_total becomes 0, but the progress bar shows infinite loading instead of
completion.

Solution

Modified MaintenanceTasks::Progress#value to return 0 when:

  • The run is completed (succeeded, errored, or cancelled)
  • The tick_total is 0

This makes the HTML <progress> element receive value="0", showing a completed state instead of the infinite animation.

HTML output comparison:

Before: <progress class="progress mt-4 is-primary is-light"></progress> (infinite progress animation)

Screenshot from 2025-09-10 00-00-28

After: <progress value="0" class="progress mt-4 is-success"></progress> (with value="0", shows completed state)

image

Also updated Progress#text to handle cases where count is nil and task is completed, showing "Processed 0 items." for better user feedback.

prevent infinite progress animation for completed zero-total tasks

- Set progress value to 0 for completed tasks with tick_total = 0
- Fixes progress bar animation continuing after task completion
- Update progress text handling for nil tick_count values
@iberianpig iberianpig marked this pull request as ready for review September 9, 2025 15:21
Copy link
Contributor

@adrianna-chang-shopify adrianna-chang-shopify left a comment

Choose a reason for hiding this comment

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

Thanks for contributing! Can you sign the CLA?

#
# @return [String] the text for the Run progress.
def text
count = @run.tick_count
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps we could do this instead?

Suggested change
count = @run.tick_count
count = @run.tick_count || 0

And leave the if statement intact?

@iberianpig
Copy link
Author

I have signed the CLA!

iberianpig and others added 3 commits September 10, 2025 09:44
- Default tick_count to 0 when nil to prevent errors
- Simplify progress text calculation by removing redundant nil checks

Co-authored-by: adrianna-chang-shopify <[email protected]>
- Add test case for #value returning 0 when run is succeeded and tick_total is nil
- Prevent infinite progress animation for zero-total completed tasks
@etiennebarrie
Copy link
Member

I can't reproduce the original issue.
I tried with the ParamsTask which can take a list of ids, so I can easily make the collection empty by using an unused id.
By default the seeds create posts id 1 to 10. I ran the ParamsTasks with post_ids set to 42, the collection is empty, I get:

MaintenanceTasks::Run.last.slice :tick_count, :tick_total
# => {"tick_count" => 0, "tick_total" => 0}
MaintenanceTasks::Progress.new(MaintenanceTasks::Run.last).then { [it.max, it.value] }
# => [0, 0]

and the progress bar markup is:

<progress value="0" max="0" class="progress mt-4 is-success"></progress>

and it appears like this:
image


The code for the <progress> value attribute is here:

def value
@run.tick_count if estimatable? || @run.stopped?

If the run is completed, it is stopped, so even if it's not estimatable (nil or 0 total), the progress shows the count, which is 0 (and can't be nil because it's a NOT NULL DEFAULT 0 column).

@iberianpig
Copy link
Author

iberianpig commented Sep 13, 2025

To reproduce the issue, please use the provided issue_1726.rb file in the repository root:

issue_1726.rb

# frozen_string_literal: true
require 'bundler/inline'

gemfile(true) do
  source 'https://rubygems.org'

  gem 'rails', '~> 8.0'
  gem 'sqlite3'
  gem "rackup"
  gem "puma"
  gem "debug"
  gem "maintenance_tasks", path: '.'
end

require 'rails/all'
require 'maintenance_tasks'

class App < Rails::Application
  config.consider_all_requests_local = true
  config.secret_key_base = 'i_am_a_secret'
  config.active_storage.service_configurations = { 'local' => { 'service' => 'Disk', 'root' => './storage' } }
  config.eager_load = false
  config.public_file_server.enabled = true

  routes.append do
    mount MaintenanceTasks::Engine, at: "/maintenance_tasks"
    root to: 'welcome#index'
  end
  config.paths["config/routes.rb"] = []  
end

database = Pathname.new( __FILE__).basename.sub_ext('.sqlite3')
puts "Using database at #{database}"
ENV['DATABASE_URL'] = "sqlite3:#{database}"
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: database)
Rails.logger = Logger.new(STDOUT)

App.initialize!

ActiveRecord::Schema.define do
  create_table :old_posts, force: true do |t|
    t.string :title
    t.text :content
    t.timestamps
  end

  create_table :posts, force: true do |t|
    t.string :title
    t.text :content
    t.timestamps
  end

  create_table :comments, force: true do |t|
    t.integer :post_id
    t.text :content
    t.timestamps
  end
  
  create_table :maintenance_tasks_runs, force: :cascade do |t|
    t.string "task_name", null: false
    t.datetime "started_at", precision: nil
    t.datetime "ended_at", precision: nil
    t.float "time_running", default: 0.0, null: false
    t.bigint "tick_count"
    t.bigint "tick_total"
    t.string "job_id"
    t.string "cursor"
    t.string "status", default: "enqueued", null: false
    t.string "error_class"
    t.string "error_message"
    t.text "backtrace"
    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.index ["task_name", "status", "created_at"], name: "index_maintenance_tasks_runs", order: { created_at: :desc }
  end
end

class OldPost < ActiveRecord::Base
end

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post
end

class WelcomeController < ActionController::Base
  def index
    render json: {
      message: "Rails Inline App is running!",
      maintenance_tasks: "Mounted at /maintenance_tasks",
      database: "Connected",
      tables: ActiveRecord::Base.connection.tables.sort
    }
  end
end

class MigratePostsToOldPosts < MaintenanceTasks::Task
  def collection
    # The collection of elements to be processed by the task.
    # This should return an ActiveRecord::Relation or an array.
    Post.where('created_at < ?', 1.year.ago)
  end

  def process(element)
    # The work to be done in a single iteration of the task.
    # This should be idempotent, as the same element may be processed more
    # than once if the task is interrupted and resumed.

    OldPost.create!(title: element.title, content: element.content)
    element.destroy!
  end

  def count
    # Optionally, define the number of rows that will be iterated over
    # This is used to track the task's progress
    collection.count
  end
end

Post.create!(
  title: "Old Post from 2023",
  content: "This is an old post that should be migrated",
  created_at: 2.years.ago,
  updated_at: 2.years.ago
)

Post.create!(
  title: "New Post from 2025",
  content: "This is a new post that should not be migrated",
  created_at: Time.current,
  updated_at: Time.current
)

Comment.create!(
  post_id: 1,
  content: "This comment should be deleted with the old post",
  created_at: 2.years.ago,
  updated_at: 2.years.ago
)


puts "🚀 Starting Rails Inline App with Maintenance Tasks Engine..."
puts "📍 Available endpoints:"
puts "   GET http://localhost:3000/ - Welcome page"
puts "   GET http://localhost:3000/maintenance_tasks - Maintenance Tasks Engine"
puts "📊 Tables: #{ActiveRecord::Base.connection.tables.sort}"

Rackup::Server.start(app: App, Port: 3000)

Reproduction Steps

ruby issue_1726.rb
  1. Once the Rails app starts, navigate to http://localhost:3000/maintenance_tasks
  2. First execution: Run the MigratePostsToOldPosts task
    • This processes 1 old post (created in 2023)
    • Task completes successfully with proper progress bar display
  3. Second execution: Run the same task again immediately
    • Now there are 0 posts to process (already deleted in first run)
    • Task completes with tick_total = 0, tick_count = 0
    • Issue occurs: Progress bar shows infinite loading animation

Expected Behavior (with fix)

Second execution should show: <progress value="0" class="progress mt-4 is-success"></progress>

Actual Behavior (without fix)

Second execution shows: <progress class="progress mt-4 is-success"></progress> (no value attribute = infinite loading)

image

This demonstrates the real-world scenario where tasks with mutable collections complete with empty results on subsequent runs. The issue is specific to completed tasks with tick_total = 0, which is different from testing with arbitrary empty IDs in ParamsTask.

@etiennebarrie
Copy link
Member

Your tick_count is:

    t.bigint "tick_count"

When it should be

t.bigint "tick_count", default: 0, null: false

The issue is that the migration doesn't create the column like that:

To repro on the repo easily:

$ rm test/dummy/db/schema.rb 
$ bin/rails db:drop db:create db:migrate

Then run ParamsTasks with post_ids set to 42.


The issue does not impact MySQL, because the migration runs like this:

Migrating to ChangeRunsTickColumnsToBigints (20220706101937)
== 20220706101937 ChangeRunsTickColumnsToBigints: migrating ===================
-- change_table(:maintenance_tasks_runs, {bulk: true})
   (10.2ms)  ALTER TABLE `maintenance_tasks_runs` CHANGE `tick_count` `tick_count` bigint DEFAULT 0 NOT NULL, CHANGE `tick_total` `tick_total` bigint DEFAULT NULL
   -> 0.0229s
== 20220706101937 ChangeRunsTickColumnsToBigints: migrated (0.0230s) ==========

Whereas on SQLite we get:

Migrating to ChangeRunsTickColumnsToBigints (20220706101937)
== 20220706101937 ChangeRunsTickColumnsToBigints: migrating ===================
-- change_table(:maintenance_tasks_runs, {bulk: true})
  TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION
  SQL (0.2ms)  PRAGMA foreign_keys
  SQL (0.0ms)  PRAGMA defer_foreign_keys
   (0.0ms)  PRAGMA defer_foreign_keys = ON
   (0.0ms)  PRAGMA foreign_keys = OFF
   (0.3ms)  CREATE TEMPORARY TABLE "amaintenance_tasks_runs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "task_name" varchar NOT NULL, "started_at" datetime(6), "ended_at" datetime(6), "time_running" float DEFAULT 0.0 NOT NULL, "tick_count" integer DEFAULT 0 NOT NULL, "tick_total" integer, "job_id" varchar, "cursor" varchar, "status" varchar DEFAULT 'enqueued' NOT NULL, "error_class" varchar, "error_message" varchar, "backtrace" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "arguments" text, "lock_version" integer DEFAULT 0 NOT NULL)
   (0.0ms)  CREATE INDEX "tindex_amaintenance_tasks_runs_on_task_name_and_created_at" ON "amaintenance_tasks_runs" ("task_name", "created_at" DESC)
  SQL (0.0ms)  INSERT INTO "amaintenance_tasks_runs" ("id","task_name","started_at","ended_at","time_running","tick_count","tick_total","job_id","cursor","status","error_class","error_message","backtrace","created_at","updated_at","arguments","lock_version")
                     SELECT "id","task_name","started_at","ended_at","time_running","tick_count","tick_total","job_id","cursor","status","error_class","error_message","backtrace","created_at","updated_at","arguments","lock_version" FROM "maintenance_tasks_runs"
   (0.1ms)  DROP TABLE "maintenance_tasks_runs"
   (0.0ms)  CREATE TABLE "maintenance_tasks_runs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "task_name" varchar NOT NULL, "started_at" datetime(6), "ended_at" datetime(6), "time_running" float DEFAULT 0.0 NOT NULL, "tick_count" bigint, "tick_total" integer, "job_id" varchar, "cursor" varchar, "status" varchar DEFAULT 'enqueued' NOT NULL, "error_class" varchar, "error_message" varchar, "backtrace" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "arguments" text, "lock_version" integer DEFAULT 0 NOT NULL)
   (0.0ms)  CREATE INDEX "index_maintenance_tasks_runs_on_task_name_and_created_at" ON "maintenance_tasks_runs" ("task_name", "created_at" DESC)
  SQL (0.0ms)  INSERT INTO "maintenance_tasks_runs" ("id","task_name","started_at","ended_at","time_running","tick_count","tick_total","job_id","cursor","status","error_class","error_message","backtrace","created_at","updated_at","arguments","lock_version")
                     SELECT "id","task_name","started_at","ended_at","time_running","tick_count","tick_total","job_id","cursor","status","error_class","error_message","backtrace","created_at","updated_at","arguments","lock_version" FROM "amaintenance_tasks_runs"
   (0.0ms)  DROP TABLE "amaintenance_tasks_runs"
   (0.0ms)  PRAGMA defer_foreign_keys = 0
   (0.0ms)  PRAGMA foreign_keys = 1
  SQL (0.0ms)  PRAGMA foreign_keys
  SQL (0.0ms)  PRAGMA defer_foreign_keys
   (0.0ms)  PRAGMA defer_foreign_keys = ON
   (0.0ms)  PRAGMA foreign_keys = OFF
   (0.0ms)  CREATE TEMPORARY TABLE "amaintenance_tasks_runs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "task_name" varchar NOT NULL, "started_at" datetime(6), "ended_at" datetime(6), "time_running" float DEFAULT 0.0 NOT NULL, "tick_count" bigint, "tick_total" integer, "job_id" varchar, "cursor" varchar, "status" varchar DEFAULT 'enqueued' NOT NULL, "error_class" varchar, "error_message" varchar, "backtrace" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "arguments" text, "lock_version" integer DEFAULT 0 NOT NULL)
   (0.0ms)  CREATE INDEX "tindex_amaintenance_tasks_runs_on_task_name_and_created_at" ON "amaintenance_tasks_runs" ("task_name", "created_at" DESC)
  SQL (0.0ms)  INSERT INTO "amaintenance_tasks_runs" ("id","task_name","started_at","ended_at","time_running","tick_count","tick_total","job_id","cursor","status","error_class","error_message","backtrace","created_at","updated_at","arguments","lock_version")
                     SELECT "id","task_name","started_at","ended_at","time_running","tick_count","tick_total","job_id","cursor","status","error_class","error_message","backtrace","created_at","updated_at","arguments","lock_version" FROM "maintenance_tasks_runs"
   (0.0ms)  DROP TABLE "maintenance_tasks_runs"
   (0.0ms)  CREATE TABLE "maintenance_tasks_runs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "task_name" varchar NOT NULL, "started_at" datetime(6), "ended_at" datetime(6), "time_running" float DEFAULT 0.0 NOT NULL, "tick_count" bigint, "tick_total" bigint, "job_id" varchar, "cursor" varchar, "status" varchar DEFAULT 'enqueued' NOT NULL, "error_class" varchar, "error_message" varchar, "backtrace" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "arguments" text, "lock_version" integer DEFAULT 0 NOT NULL)
   (0.0ms)  CREATE INDEX "index_maintenance_tasks_runs_on_task_name_and_created_at" ON "maintenance_tasks_runs" ("task_name", "created_at" DESC)
  SQL (0.0ms)  INSERT INTO "maintenance_tasks_runs" ("id","task_name","started_at","ended_at","time_running","tick_count","tick_total","job_id","cursor","status","error_class","error_message","backtrace","created_at","updated_at","arguments","lock_version")
                     SELECT "id","task_name","started_at","ended_at","time_running","tick_count","tick_total","job_id","cursor","status","error_class","error_message","backtrace","created_at","updated_at","arguments","lock_version" FROM "amaintenance_tasks_runs"
   (0.0ms)  DROP TABLE "amaintenance_tasks_runs"
   (0.0ms)  PRAGMA defer_foreign_keys = 0
   (0.0ms)  PRAGMA foreign_keys = 1
   -> 0.0248s

It seems the MySQL adapter preserves the NOT NULL and DEFAULT whereas the SQLite adapter doesn't.


You can fix this in your application by adding a migration that sets the default for the column to 0.
We'll probably need to add a new migration to fix this for everyone out there using SQLite, and we need to check PostgreSQL too.
Maybe we'll want to add some code to the gem to explicitly default to 0 too, just to avoid the glitch.

Thanks again for your contribution and the detailed repro script!

@iberianpig
Copy link
Author

@etiennebarrie Thank you for the very detailed analysis!

I looked more into this and found it's a SQLite migration issue that began in Rails 7.x.
Currently, migration with SQLite drops NOT NULL and DEFAULT constraints that aren't clearly specified, so we must pass all column options during migrations.

For anyone with this problem:

Here's a workaround to fix the constraints:

# db/migrate/[timestamp]_restore_tick_count_constraints.rb
class RestoreTickCountConstraints < ActiveRecord::Migration[7.0]
  def up
    if ActiveRecord::Base.connection.adapter_name == 'SQLite'
      change_column_default :maintenance_tasks_runs, :tick_count, from: nil, to: 0
      change_column_null :maintenance_tasks_runs, :tick_count, false, 0
    end
  end

  def down
    if ActiveRecord::Base.connection.adapter_name == 'SQLite'
      change_column_null :maintenance_tasks_runs, :tick_count, true
      change_column_default :maintenance_tasks_runs, :tick_count, from: 0, to: nil
    end
  end
end

This is actually a bug in Rails itself, so it should be fixed in Rails.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants