Broadcasting Progress from Background Jobs

Episode #408 by Teacher's Avatar David Kimura

Summary

In a previous episode, we had created a custom ActionCable channel to assist in broadcasting updates from a background job. With the introduction of Turbo, we can simplify this process as we look at a few different approaches in displaying a progress bar with real time updates from background jobs.
rails hotwire background processing 22:23

Chapters

  • Introduction (0:00)
  • Setting up the Heavy Task (1:58)
  • Creating the background job (2:59)
  • Creating the button view (4:10)
  • Broadcasting the progress (5:36)
  • Creating the progress bar (8:38)
  • Quick recap (9:33)
  • Demo (10:29)
  • Breaking up the Heavy task into Small tasks (11:33)
  • Demo of smaller tasks executing (13:06)
  • Talking about the problem with a bunch of smaller jobs (14:14)
  • Creating the stimulus controller (14:58)
  • Updating the view to use the stimulus controller (18:10)
  • Updating the background tasks (19:11)
  • Demo (20:49)
  • Final thoughts (21:43)

Resources

Download Source Code

Summary

# Terminal
rails g controller heavy_tasks
rails g job heavy_tasks
rails g job small_task
rails g stimulus progress-bar

# config/routes.rb
resources :heavy_tasks, only: :create

# app/controllers/heavy_tasks_controller.rb
class HeavyTasksController < ApplicationController
  before_action :authenticate_user!

  def create
    HeavyTaskJob.perform_later(current_user.id)
  end
end

# app/jobs/heavy_task_job.rb
class HeavyTaskJob < ApplicationJob
  queue_as :default
  before_perform :broadcast_initial_update

  def perform(current_user_id)

    total_count.times do |i|
      SmallTaskJob.perform_later(current_user_id, i, total_count)
    end
  end

  private

  def broadcast_initial_update
    Turbo::StreamsChannel.broadcast_replace_to ["heavy_task_channel", current_user.to_gid_param].join(":"),
      target: "heavy_task",
      partial: "heavy_tasks/progress",
      locals: {
        total_count: total_count
      }
  end

  def total_count
    @total_count ||= rand(10..100)
  end

  def current_user
    @current_user ||= User.find(self.arguments.first)
  end
end

# app/jobs/small_task_job.rb
class SmallTaskJob < ApplicationJob
  queue_as :default

  def perform(current_user_id, i, total_count)
    current_user = User.find(current_user_id)
    sleep rand
    # Turbo::StreamsChannel.broadcast_replace_to ["heavy_task_channel", current_user.to_gid_param].join(":"),
    #   target: "heavy_task",
    #   partial: "heavy_tasks/progress",
    #   locals: {
    #     progress: (i + 1) * 100 / total_count
    #   }

    Turbo::StreamsChannel.broadcast_action_to ["heavy_task_channel", current_user.to_gid_param].join(":"),
      action: "append",
      target: "heavy_task",
      content: "<div></div>"
  end
end

# app/views/welcome/index.html.erb
<%= render partial: "heavy_tasks/button" %>

# app/views/heavy_tasks/_button.html.erb
<%= turbo_stream_from ["heavy_task_channel", current_user] if user_signed_in? %>

<div id="heavy_task">
  <%= button_to "Perform Heavy Task", heavy_tasks_path, class: "btn btn-primary" %>
</div>

# app/views/heavy_tasks/_progress.html.erb
<div id="heavy_task"
  data-controller="progress-bar"
  data-progress-bar-total-jobs-value="<%= total_count %>">
  <div class="progress">
    <div class="progress-bar progress-bar-striped active"
      role="progressbar"
      data-progress-bar-target="progress">
  </div>
</div>

# app/javascript/controllers/progress_bar_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="progress-bar"
export default class extends Controller {
  static values = {
    totalJobs: 0,
    completedJobs: 0
  }
  static targets = ["progress"]
  connect() {
    this.observer = new MutationObserver((mutationsList, _observer) => {
      for (let mutation of mutationsList) {
        if (mutation.type === 'childList') {
          this.increment()
        }
      }
    })

    this.observer.observe(this.element, { childList: true })
  }

  increment() {
    this.completedJobsValue++
    this.updateProgress()
  }

  updateProgress() {
    let progress = (this.completedJobsValue / this.totalJobsValue) * 100
    this.progressTarget.style.width = `${progress}%`
  }
}