Slide 1

Slide 1 text

SHOWING PROGRESS OF BACKGROUND JOBS WITH TURBO

Slide 2

Slide 2 text

MICHAŁ ŁĘCICKI m.lecicki@visuality.pl @mlecicki https://maikhel.github.io

Slide 3

Slide 3 text

Before we start…

Slide 4

Slide 4 text

SHOWING PROGRESS OF BACKGROUND JOBS WITH TURBO

Slide 5

Slide 5 text

https://github.com/kenaniah/sidekiq-status Sidekiq-status

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

Random flow chart from Internet

Slide 8

Slide 8 text

The app Two models: Joke belongs to JokeRequest Job fetching number of jokes from Chuck Norris API “Chuck Norris doesn't have a watch, HE decides what time it is.”

Slide 9

Slide 9 text

1. Broadcast from model 2. Broadcast from worker 3. Pseudo-background job 4. Updates with Stimulus 5. Bonus

Slide 10

Slide 10 text

1. Broadcast from model class class Joke Joke < < ApplicationRecord ApplicationRecord after_create_commit after_create_commit -> ->( (joke joke) ) do do broadcast_replace_to broadcast_replace_to([ ([ joke joke. .jokes_request jokes_request, , "jokes_progress_bar" "jokes_progress_bar" ], ], target: target: "jokes_progress_bar" "jokes_progress_bar", , partial: partial: "jokes_requests/jokes_progress_bar" "jokes_requests/jokes_progress_bar", , locals: locals: { { jokes_request: jokes_request: joke joke. .jokes_request jokes_request } } ) ) end end belongs_to belongs_to :jokes_request :jokes_request end end

Slide 11

Slide 11 text

# app/views/jokes_requests/_jokes_progress_bar.html.erb # app/views/jokes_requests/_jokes_progress_bar.html.erb <% <% progress_width progress_width = = jokes_request jokes_request. .jokes jokes. .count count / / jokes_request jokes_request. .amount amount. .to_f to_f * * 100 100 %> %>
<
%;" "width: <%= progress_width.to_i %>%;"> >
>
> 1. Broadcast from model # app/views/jokes_requests/show.html.erb # app/views/jokes_requests/show.html.erb <%= <%= turbo_stream_from turbo_stream_from @jokes_request @jokes_request, , "jokes_progress_bar" "jokes_progress_bar" %> %> # (..) # (..) <%= <%= render render "jokes_progress_bar" "jokes_progress_bar", , jokes_request jokes_request: : @jokes_request @jokes_request %> %>

Slide 12

Slide 12 text

PROS Fast Easy CONS Callbacks Polluting model CRUD operations with broadcasts Not always applicable 1. Broadcast from model Chuck Norris can nail a hammer into a wall.

Slide 13

Slide 13 text

2. Broadcast from worker # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb def def update_progress_bar update_progress_bar( (number number) ) Turbo::StreamsChannel Turbo::StreamsChannel. .broadcast_replace_to broadcast_replace_to( ( [ [ jokes_request jokes_request, , "jokes_progress_bar" "jokes_progress_bar" ], ], target: target: "jokes_progress_bar" "jokes_progress_bar", , partial: partial: "jokes_requests/jokes_progress_bar" "jokes_requests/jokes_progress_bar", , locals: locals: { { actual: actual: number number, , limit: limit: jokes_request jokes_request. .amount amount } } ) ) end end

Slide 14

Slide 14 text

2. Broadcast from worker # app/views/jokes_requests/_jokes_progress_bar.html.erb # app/views/jokes_requests/_jokes_progress_bar.html.erb <% <% progress_width progress_width = = actual actual / / limit limit. .to_f to_f * * 100 100 %> %>
<
%;" "width: <%= progress_width.to_i %>%;"> >
>
> # app/views/jokes_requests/show.html.erb # app/views/jokes_requests/show.html.erb <%= <%= turbo_stream_from turbo_stream_from @jokes_request @jokes_request, , "jokes_progress_bar" "jokes_progress_bar" %> %> # (..) # (..) <%= <%= render render "jokes_progress_bar" "jokes_progress_bar", , actual actual: : @jokes_request @jokes_request. .jokes jokes. .count count, , limit limit: : @jokes_request @jokes_request. .amount amount %> %>

Slide 15

Slide 15 text

PROS No callbacks Easy to test Broadcast limited to one source place CONS More work? Still needs to bind to some record/ unique user ID 2. Broadcast from worker

Slide 16

Slide 16 text

3. Pseudo-background job # app/views/jokes_request/show.html.erb # app/views/jokes_request/show.html.erb <% <% jokes_request jokes_request. .amount amount. .times times do do %> %> <%= turbo_frame_tag "joke", src: fetch_joke_path do %> <%= turbo_frame_tag "joke", src: fetch_joke_path do %> <% <% end end %> %> <% end %> <% end %>

Slide 17

Slide 17 text

3. Pseudo-background job # app/controllers/jokes_controller.rb # app/controllers/jokes_controller.rb def def fetch_joke fetch_joke @joke @joke = = FetchJokeService FetchJokeService. .new new. .call call end end # app/views/jokes/fetch_joke.html.erb # app/views/jokes/fetch_joke.html.erb <%= <%= turbo_frame_tag turbo_frame_tag "joke" "joke" do do %> %>
rounded px-4"> <%= <%= @joke @joke %> %>
<% <% end end %> %>

Slide 18

Slide 18 text

PROS No background processing CONS Not always applicable 3. Pseudo-background job

Slide 19

Slide 19 text

What if there is more than 1 job? Chuck Norris gives cigarettes cancer

Slide 20

Slide 20 text

1. Broadcast from model 2. Broadcast from worker

Slide 21

Slide 21 text

4. Updates with Stimulus

Slide 22

Slide 22 text

4. Updates with Stimulus < <% %= = turbo_stream_from turbo_stream_from @jokes_request, @jokes_request, "jokes" "jokes" % %> > <
" "<%= @jokes_request.amount %>" data-progress-bar-actual-value data-progress-bar-actual-value= ="<%= @jokes_request.jokes.size %>" "<%= @jokes_request.jokes.size %>"> > < <% %= = render render "jokes_progress_bar" "jokes_progress_bar", , actual: actual: @jokes_request.jokes.size, @jokes_request.jokes.size, limit: limit: @jokes_request.amount @jokes_request.amount % %> > < <% %= = turbo_frame_tag turbo_frame_tag "jokes" "jokes" do do % %> > <
> < <% % @jokes.each @jokes.each do do |joke| |joke| % %> > < <% %= = render render 'jokes/joke' 'jokes/joke', , joke: joke: joke joke % %> > < <% % end end % %> >
> < <% % end end % %> >

Slide 23

Slide 23 text

4. Updates with Stimulus That’s how I feel when I programm in JavaScript

Slide 24

Slide 24 text

4. Updates with Stimulus // app/javascript/controllers/progress_bar_controller.js // app/javascript/controllers/progress_bar_controller.js import import { { Controller Controller } } from from "@hotwired/stimulus" "@hotwired/stimulus" // Connects to data-controller="progress-bar" // Connects to data-controller="progress-bar" export export default default class class extends extends Controller Controller { { static static values values = = { { limit limit: : 0 0, , actual actual: : 0 0, , } } static static targets targets = = [ ["progress" "progress", , "count" "count"] ] connect connect() { () { // (...) // (...) } } } }

Slide 25

Slide 25 text

4. Updates with Stimulus export export default default class class extends extends Controller Controller { { // (..) // (..) connect connect() { () { addEventListener addEventListener( ("turbo:before-stream-render" "turbo:before-stream-render", (( , ((event event) ) => => { { const const fallbackToDefaultActions fallbackToDefaultActions = = event event. .detail detail. .render render event event. .detail detail. .render render = = ( (streamElement streamElement) ) => => { { if if ( (streamElement streamElement. .action action === === "append" "append" && && streamElement streamElement. .target target === === "jokes_grid" "jokes_grid") { ) { this this. .increment increment() () } } fallbackToDefaultActions fallbackToDefaultActions( (streamElement streamElement) ) } } })) })) } } } }

Slide 26

Slide 26 text

4. Updates with Stimulus export export default default class class extends extends Controller Controller { { increment increment() { () { this this. .actualValue actualValue++ ++ this this. .updateProgress updateProgress() () this this. .updateCount updateCount() () } } updateProgress updateProgress() { () { let let progress progress = = ( (this this. .actualValue actualValue / / this this. .limitValue limitValue) ) * * 100 100 this this. .progressTarget progressTarget. .style style. .width width = = `${ `${progress progress}%` }%` } } updateCount updateCount() { () { this this. .countTarget countTarget. .innerText innerText = = `${ `${this this. .actualValue actualValue} } / ${ / ${this this. .limitValue limitValue}` }` } } } }

Slide 27

Slide 27 text

Sometimes you just need to move more logic to the frontend 4. Updates with Stimulus

Slide 28

Slide 28 text

Bonus Chuck Norris does infinit loops in 4 seconds.

Slide 29

Slide 29 text

Bonus: PAGINATION # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb class class FetchJokesService FetchJokesService # ... # ... def def add_joke add_joke( (joke joke, , jokes_count jokes_count) ) last_page last_page = = jokes_count jokes_count / / Joke::PER_PAGE Joke::PER_PAGE + + 1 1 if if jokes_count jokes_count % % Joke::PER_PAGE Joke::PER_PAGE == == 1 1 # change page to next one # change page to next one clear_all_jokes clear_all_jokes add_joke_card add_joke_card( (joke joke) ) replace_pagination replace_pagination( (jokes_count jokes_count, , last_page last_page) ) else else add_joke_card add_joke_card( (joke joke) ) end end end end end end

Slide 30

Slide 30 text

Bonus: PAGINATION # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb def def clear_all_jokes clear_all_jokes Turbo::StreamsChannel Turbo::StreamsChannel. .broadcast_replace_to broadcast_replace_to( ( [ [ jokes_request jokes_request, , "jokes" "jokes" ], ], target: target: "jokes_grid" "jokes_grid", , partial: partial: "jokes_requests/empty_jokes_grid" "jokes_requests/empty_jokes_grid" ) ) end end # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb def def add_joke_card add_joke_card( (joke joke) ) Turbo::StreamsChannel Turbo::StreamsChannel. .broadcast_append_to broadcast_append_to( ( [ [ jokes_request jokes_request, , "jokes" "jokes" ], ], target: target: "jokes_grid" "jokes_grid", , partial: partial: "jokes/joke" "jokes/joke", , locals: locals: { { joke: joke: joke joke } } ) ) end end

Slide 31

Slide 31 text

Bonus: PAGINATION # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb def def replace_pagination replace_pagination( (jokes_count jokes_count, , last_page last_page) ) pagy pagy = = Pagy Pagy. .new new( (count: count: jokes_count jokes_count, , page: page: last_page last_page, , items: items: Joke::PER_PAGE Joke::PER_PAGE, , link_extra: link_extra: 'data-turbo-action="advance"' 'data-turbo-action="advance"') ) Turbo::StreamsChannel Turbo::StreamsChannel. .broadcast_replace_to broadcast_replace_to( ( [ [ jokes_request jokes_request, , "jokes_pagination" "jokes_pagination" ], ], target: target: "jokes_pagination" "jokes_pagination", , partial: partial: "jokes_requests/jokes_pagination" "jokes_requests/jokes_pagination", , locals: locals: { { pagy: pagy: pagy pagy } } ) ) end end

Slide 32

Slide 32 text

No content

Slide 33

Slide 33 text

Use Hotwire

Slide 34

Slide 34 text

LINKS https://www.visuality.pl/posts/showing-progress-of- background-jobs-with-turbo https://www.visuality.pl/posts/smooth-concurrent- updates-with-hotwire-stimulus https://www.driftingruby.com/episodes/broadcasting- progress-from-background-jobs https://onrails.blog/2022/11/07/displaying-progress-in-a- long-running-background-job-hotwire/ Chuck Norris doesn't need glasses. He just gets new eyeballs.

Slide 35

Slide 35 text

github.com/maikhel/hotwire-jokes

Slide 36

Slide 36 text

Thank you! “Chuck Norris is the reason Waldo is hiding”

Slide 37

Slide 37 text

Questions? https://maikhel.github.io Chuck Norris silenced noise.