A small Rails 8.1 app that verifies a single claim: Rails.event plus structured Active Job events plus Continuations are enough to build user-facing live progress for background jobs — without per-job broadcast wiring and without polling.
- Active Job lifecycle events (
active_job.enqueued,started,completed) flow throughRails.eventwith matchingjob_idanddurationpopulated. - Continuation
active_job.step_startedandactive_job.stepevents carrycursorandresumedfields, sufficient to drive an in-progress UI. - Context set via
Rails.event.set_context(user_id: ...)inside abefore_performcallback reaches every event the job emits — including framework events. - Rails' executor wraps each job in
app.reloader.wrapand clears event reporter context onto_complete, so context does not leak between consecutive jobs on the same worker thread. Noaround_performcleanup is needed for the common case.
The five tests in test/jobs/import_contacts_job_test.rb assert each of the above.
Two-thread streaming demo. The main thread enqueues the job and renders user-facing progress messages. A :async worker thread executes the job and emits raw structured events. Both streams print interleaved, with [main ] and [worker] tags so you can see which thread did what.
bin/run # user_id=42, total_records=4
bin/run 99 6 # custom user_id and record countRandom 1-5s sleeps live inside the job (gated by the DEMO_SLEEP=1 env var that bin/run sets), so step events stream in over time rather than all at once.
Sample output:
19:38:33 [worker] • import.record_processed {index: 0} [user_id=42]
19:38:33 [main ] ▶ UI: [███████·············] Imported 1 of 3 (33%)
19:38:35 [worker] • import.record_processed {index: 1} [user_id=42]
19:38:35 [main ] ▶ UI: [█████████████·······] Imported 2 of 3 (67%)
19:38:38 [worker] • import.record_processed {index: 2} [user_id=42]
19:38:38 [main ] ▶ UI: [████████████████████] Imported 3 of 3 (100%)
The same shape, swapping Queue for Turbo::StreamsChannel.broadcast_*, is what powers a browser progress card in production.
bin/rails test test/jobs/import_contacts_job_test.rbFive tests, ~0.3 seconds (no sleeps in test mode).
| File | What it does |
|---|---|
app/jobs/import_contacts_job.rb |
Continuation job with three steps; sets event context in before_perform. |
app/jobs/recording_subscriber.rb |
Test-only in-memory subscriber. |
app/jobs/leaky_attribution_job.rb, safe_attribution_job.rb, bystander_job.rb |
Used by test 4 to demonstrate that the executor auto-clears context between jobs. |
config/initializers/job_progress_subscriber.rb |
Wires the subscriber via Rails.event.subscribe. |
bin/run |
Live two-thread streaming demo with progress bar rendering. |
test/jobs/import_contacts_job_test.rb |
Five integration tests covering each verified claim. |
- Rails 8.1.3
- Ruby 3.4.5
- SQLite (default)
:asyncadapter for the demo,:inlinefor tests
step.advance!(from: x) calls x.succ, so the cursor reflects "next position to start from on resume" rather than "last index processed." For percentage display, either:
- Use
step.advance!(from: i)after processing indexi(cursor becomesi + 1, equal to count of records done), or - Emit your own progress event (e.g.
Rails.event.notify("import.record_processed", index: i)) and compute percentage in the subscriber from that count instead of from the cursor.
The bin/run demo takes the second approach.