task_progress 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/MANUAL.md +851 -0
- data/README.md +158 -0
- data/RECIPES.md +413 -0
- data/config/importmap.rb +17 -0
- data/exe/task_progress +51 -0
- data/js/local_tasks.d.ts +44 -0
- data/js/local_tasks.js +229 -0
- data/js/package.json +31 -0
- data/js/paths.d.ts +5 -0
- data/js/paths.js +7 -0
- data/js/react_task_tree.d.ts +49 -0
- data/js/react_task_tree.js +123 -0
- data/js/render_queue.d.ts +24 -0
- data/js/render_queue.js +79 -0
- data/js/row_view.d.ts +25 -0
- data/js/row_view.js +69 -0
- data/js/stimulus_controller.d.ts +10 -0
- data/js/stimulus_controller.js +137 -0
- data/js/task_progress_clock.d.ts +2 -0
- data/js/task_progress_clock.js +33 -0
- data/js/task_tree.css +51 -0
- data/js/task_tree.d.ts +111 -0
- data/js/task_tree.js +444 -0
- data/lib/task_progress/assertions.rb +49 -0
- data/lib/task_progress/batch.rb +23 -0
- data/lib/task_progress/branch.rb +74 -0
- data/lib/task_progress/buffered_output.rb +63 -0
- data/lib/task_progress/cancellations_app.rb +76 -0
- data/lib/task_progress/event.rb +46 -0
- data/lib/task_progress/execution_log_output.rb +183 -0
- data/lib/task_progress/fold.rb +18 -0
- data/lib/task_progress/html_renderer.rb +153 -0
- data/lib/task_progress/job_serializer.rb +92 -0
- data/lib/task_progress/journal.rb +35 -0
- data/lib/task_progress/log.rb +263 -0
- data/lib/task_progress/memory_cache.rb +31 -0
- data/lib/task_progress/memory_stream_store.rb +142 -0
- data/lib/task_progress/mount.rb +448 -0
- data/lib/task_progress/ndjson_output.rb +24 -0
- data/lib/task_progress/node.rb +52 -0
- data/lib/task_progress/poll.rb +87 -0
- data/lib/task_progress/publishing_output.rb +30 -0
- data/lib/task_progress/ractor_output.rb +23 -0
- data/lib/task_progress/rails_defaults.rb +46 -0
- data/lib/task_progress/railtie.rb +91 -0
- data/lib/task_progress/recording_output.rb +51 -0
- data/lib/task_progress/redis_stream_store.rb +131 -0
- data/lib/task_progress/squasher.rb +59 -0
- data/lib/task_progress/sse.rb +114 -0
- data/lib/task_progress/state.rb +319 -0
- data/lib/task_progress/stream_output.rb +222 -0
- data/lib/task_progress/stream_reader.rb +76 -0
- data/lib/task_progress/summary_output.rb +37 -0
- data/lib/task_progress/terminal_output.rb +241 -0
- data/lib/task_progress/token.rb +58 -0
- data/lib/task_progress/tracked_io.rb +68 -0
- data/lib/task_progress/transaction.rb +41 -0
- data/lib/task_progress/trees_app.rb +66 -0
- data/lib/task_progress/turbo_helper.rb +18 -0
- data/lib/task_progress/turbo_sse.rb +103 -0
- data/lib/task_progress/turbo_stream_output.rb +61 -0
- data/lib/task_progress/version.rb +7 -0
- data/lib/task_progress/wire.rb +211 -0
- data/lib/task_progress.rb +1036 -0
- data/sig/generated/task_progress/assertions.rbs +36 -0
- data/sig/generated/task_progress/batch.rbs +33 -0
- data/sig/generated/task_progress/branch.rbs +47 -0
- data/sig/generated/task_progress/buffered_output.rbs +38 -0
- data/sig/generated/task_progress/cancellations_app.rbs +41 -0
- data/sig/generated/task_progress/event.rbs +20 -0
- data/sig/generated/task_progress/execution_log_output.rbs +93 -0
- data/sig/generated/task_progress/fold.rbs +25 -0
- data/sig/generated/task_progress/html_renderer.rbs +71 -0
- data/sig/generated/task_progress/journal.rbs +25 -0
- data/sig/generated/task_progress/log.rbs +140 -0
- data/sig/generated/task_progress/memory_cache.rbs +28 -0
- data/sig/generated/task_progress/memory_stream_store.rbs +83 -0
- data/sig/generated/task_progress/mount.rbs +200 -0
- data/sig/generated/task_progress/ndjson_output.rbs +18 -0
- data/sig/generated/task_progress/node.rbs +66 -0
- data/sig/generated/task_progress/poll.rbs +50 -0
- data/sig/generated/task_progress/publishing_output.rbs +24 -0
- data/sig/generated/task_progress/ractor_output.rbs +17 -0
- data/sig/generated/task_progress/rails_defaults.rbs +33 -0
- data/sig/generated/task_progress/recording_output.rbs +41 -0
- data/sig/generated/task_progress/redis_stream_store.rbs +75 -0
- data/sig/generated/task_progress/squasher.rbs +20 -0
- data/sig/generated/task_progress/sse.rbs +73 -0
- data/sig/generated/task_progress/state.rbs +165 -0
- data/sig/generated/task_progress/stream_output.rbs +142 -0
- data/sig/generated/task_progress/stream_reader.rbs +46 -0
- data/sig/generated/task_progress/summary_output.rbs +19 -0
- data/sig/generated/task_progress/terminal_output.rbs +117 -0
- data/sig/generated/task_progress/token.rbs +41 -0
- data/sig/generated/task_progress/tracked_io.rbs +48 -0
- data/sig/generated/task_progress/transaction.rbs +33 -0
- data/sig/generated/task_progress/trees_app.rbs +40 -0
- data/sig/generated/task_progress/turbo_helper.rbs +14 -0
- data/sig/generated/task_progress/turbo_sse.rbs +69 -0
- data/sig/generated/task_progress/turbo_stream_output.rbs +51 -0
- data/sig/generated/task_progress/version.rbs +6 -0
- data/sig/generated/task_progress/wire.rbs +102 -0
- data/sig/generated/task_progress.rbs +553 -0
- metadata +178 -0
data/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# TaskProgress
|
|
2
|
+
|
|
3
|
+
Progress for task trees: in one process, across threads, Fibers, Ractors and subprocesses, and out to a terminal or a browser.
|
|
4
|
+
|
|
5
|
+
Think progress bars, but properly, for complex task trees that the user needs to be updated on. Fit for both the web and the CLI.
|
|
6
|
+
|
|
7
|
+
The progress is not a mutable structure but an _event log_ which allows task progress collection to be distributed. Every change goes through one lock, which numbers it and commits it as a batch. Outputs receive the batches in order: a terminal renderer, a pipe, an SSE stream, a Sossego stream. A batch is one transaction. On a pipe it is one line, in a stream one event or row, so a reader gets all of it or none of it.
|
|
8
|
+
|
|
9
|
+
Any run of batches can be squashed into one, and a reader that joins late starts from that squash. This also lends itself perfectly to redraw-the-world UIs like React - but using it with Stimulus is a breeze as well.
|
|
10
|
+
|
|
11
|
+
<div>
|
|
12
|
+
<picture>
|
|
13
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/bench-dark.png">
|
|
14
|
+
<img alt="The bench (bin/dev) mid-job: an import with worker threads and an engine subprocess mounted under its own node, drawn by the Stimulus and React renderers side by side above the stream inspector" src="docs/bench-light.png" width="100%">
|
|
15
|
+
</picture>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
The [MANUAL](MANUAL.md) has everything, and [RECIPES](RECIPES.md) whole setups to copy from - a Rails job polled from a page, subprocesses drawn in one terminal, a Rails action streaming to React. This page has what you need first.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "task_progress", github: "julik/task_progress"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Ruby 3.4 or newer. No runtime dependencies. Typed: RBS signatures ship in `sig/generated`, and the code is checked against them with Steep.
|
|
27
|
+
|
|
28
|
+
## A bar
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
bar = TaskProgress.bar("Importing", total: rows.size)
|
|
32
|
+
rows.each { import(it) && bar.tick }
|
|
33
|
+
|
|
34
|
+
TaskProgress.bar("Importing", total: rows.size).iterate(rows) { |row| import(row) } # or let it do the counting
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
⠹ Importing ██████████░░░░░░░░░░ 21/40 19/s ETA 1.0s 1.1s
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It finishes at its total, and is finished for you when the program exits. `bar.puts("Skipped row 12")` prints a line above it that stays.
|
|
42
|
+
|
|
43
|
+
## A tree
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
TaskProgress.cli("Import") do |root|
|
|
47
|
+
download = root.subtask("Download", total: io.size, unit: "bytes")
|
|
48
|
+
rows = root.subtask("Rows", total: rows.size, weight: 3, cancelable: true)
|
|
49
|
+
|
|
50
|
+
download.run { copy(download.track_io(io)) }
|
|
51
|
+
rows.run { rows.each { import(it) && rows.tick } }
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A block is a task's work: done when it returns, failed with the error when it raises. A parent reads 100% only once it and everything under it are done. On a terminal the tree redraws in place; piped or on CI it writes an execution log instead - one finished line per thing worth saying, never a redraw.
|
|
56
|
+
|
|
57
|
+
## With Turbo
|
|
58
|
+
|
|
59
|
+
In a Rails app with turbo-rails, a job draws its tree in every page that shows it - from whichever process it runs in, with no JavaScript of your own. The tree is drawn for a record, here a `CsvImport` - any Active Record model will do, the one whose page shows the progress:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class ImportJob < ApplicationJob
|
|
63
|
+
def perform(csv_import)
|
|
64
|
+
TaskProgress.broadcast(csv_import, "Importing") do |root|
|
|
65
|
+
root.subtask("Rows", total: csv_import.rows.size, cancelable: true) do |rows|
|
|
66
|
+
csv_import.rows.each { import_row(it) && rows.tick }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```erb
|
|
74
|
+
<%# app/views/csv_imports/show.html.erb %>
|
|
75
|
+
<%= task_progress_tree @csv_import %>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The job draws the whole tree as HTML a few times a second, and Action Cable takes it to every page, where Turbo morphs it in. Cancel buttons are plain forms. Nothing of Rails is needed for the rest of the gem: outside Rails, `to:` says where frames go, and `TurboSSE` streams them to a `<turbo-stream-source>`.
|
|
79
|
+
|
|
80
|
+
## In a web app
|
|
81
|
+
|
|
82
|
+
Stream any task - the whole tree, or one branch of it - as Server-Sent Events:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# Rails
|
|
86
|
+
headers.merge!(TaskProgress::SSE::HEADERS)
|
|
87
|
+
self.response_body = TaskProgress::SSE.new(task)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
and draw it with the browser client that ships in `js/` (`TaskProgress.js_path`): plain ES modules, no build step, with a Stimulus controller and a React component on top.
|
|
91
|
+
|
|
92
|
+
```html
|
|
93
|
+
<div data-controller="task-tree" data-task-tree-url-value="/jobs/42/progress"></div>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Where SSE does not get through, `TaskProgress::Poll` answers plain GETs - with a 304 while nothing changed - and `followPollingAt` polls it. Work that runs in the browser itself can be grafted under a server task with `LocalTasks`.
|
|
97
|
+
|
|
98
|
+
## Getting the browser client
|
|
99
|
+
|
|
100
|
+
The JS ships inside the gem, at the gem's version, and is not on npm. Take it from the gem in one of three ways:
|
|
101
|
+
|
|
102
|
+
- **Rails with importmap-rails:** nothing to do. The Railtie serves `js/` at `/task_progress/js` and pins it in your import map as `task_progress`, `task_progress/react`, `task_progress/stimulus`, `task_progress/local_tasks`, `task_progress/row_view` and `task_progress/clock`. Add `<link rel="stylesheet" href="/task_progress/js/task_tree.css">` for the styles.
|
|
103
|
+
- **A bundler - Vite, esbuild, Inertia:** copy it into your app, and install the copy as a package:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
$ bin/rails task_progress:vendor # or `task_progress vendor` outside Rails
|
|
107
|
+
$ npm install ./vendor/javascript/task_progress
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Then `import { TaskTreeView } from "task_progress/react"` and `import "task_progress/task_tree.css"`, with TypeScript declarations included. Commit the copy, and vendor again after updating the gem - Rails warns at boot while the copy is from another version.
|
|
111
|
+
- **Anything else on Rack:** serve `TaskProgress.js_path` under any prefix. The modules import each other by relative path, and need no build step.
|
|
112
|
+
|
|
113
|
+
React and Stimulus are not bundled: the components import `react`, `react-dom/client` and `@hotwired/stimulus` by name, from your app.
|
|
114
|
+
|
|
115
|
+
## Handing work off
|
|
116
|
+
|
|
117
|
+
Create the tree here, and let a job write to it there - through Redis:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
TaskProgress.stream_store = TaskProgress::RedisStreamStore.new(RedisClient.config(url: ENV["REDIS_URL"]).new_pool)
|
|
121
|
+
|
|
122
|
+
progress = TaskProgress.handoff("Importing", cancelable: true)
|
|
123
|
+
ImportJob.perform_later(csv_import, progress) # the task is an argument like any other
|
|
124
|
+
|
|
125
|
+
def perform(csv_import, progress) # ...and arrives adopted: perform is its work
|
|
126
|
+
progress.subtask("Rows", total: csv_import.rows.size) { |rows| ... }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
TaskProgress::SSE.response(progress.key) # a page's stream, anywhere that reaches Redis
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`csv_import` is the same `CsvImport` record as above; `progress` is the tree. `TaskProgress.for_page(progress.key)` is everything a page needs - links with a signed token in them, never the key, and the tree as it stands - for a Stimulus value or an Inertia prop: `<TaskTreeView progress={progress} onComplete={() => router.reload()} />`, with the client from `rails task_progress:vendor` - or pinned by itself, with importmap-rails. The page shows the job queued before a worker has it. A worker that dies goes stale, then fails; a retry starts the tree over as attempt 2. `task_progress watch KEY` draws it in a terminal.
|
|
133
|
+
|
|
134
|
+
## Across processes
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
engine = root.mount("Engine", weight: 3)
|
|
138
|
+
engine.spawn(RbConfig.ruby, "engine.rb") # engine.rb uses TaskProgress.cli, and reports up a pipe
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The child's tree lands under the mount, and a cancel goes down to it the same way. Ractors work too, on Ruby 4.0.
|
|
142
|
+
|
|
143
|
+
## Canceling
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
task.cancel!("Not needed") # from any thread
|
|
147
|
+
root.request_cancel(path, "From the browser") # as a request: :canceled, :forwarded, :moot or :refused
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Whoever works on a canceled task next gets a `TaskProgress::Canceled`, which `run` takes as the end of that work. Blocking work can use `on_cancel { }` to unblock. The renderers show a cancel button on tasks marked `cancelable: true`, and `TaskProgress::Token` signs the right to press it.
|
|
151
|
+
|
|
152
|
+
## The bench
|
|
153
|
+
|
|
154
|
+
`bin/dev` starts a bench on http://localhost:9292: pick a scenario and watch it in both renderers side by side, with a stream inspector. Cancel tasks, cut the stream to watch it resume, or join a job late. The terminal demo is `ruby -Ilib examples/cli_demo.rb`.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
data/RECIPES.md
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# TaskProgress recipes
|
|
2
|
+
|
|
3
|
+
Whole setups to copy from, start to end. The [README](README.md) has the short version, and the [MANUAL](MANUAL.md) everything else.
|
|
4
|
+
|
|
5
|
+
- [Relaying a command's progress: ffmpeg](#relaying-a-commands-progress-ffmpeg)
|
|
6
|
+
- [A video transcoded in a background job, polled from a Rails page](#a-video-transcoded-in-a-background-job-polled-from-a-rails-page)
|
|
7
|
+
- [The same video on an Inertia page, with React](#the-same-video-on-an-inertia-page-with-react)
|
|
8
|
+
- [Videos transcoded in subprocesses, drawn in one terminal](#videos-transcoded-in-subprocesses-drawn-in-one-terminal)
|
|
9
|
+
- [A Rails action that streams its own progress to React](#a-rails-action-that-streams-its-own-progress-to-react)
|
|
10
|
+
- [Upload progress, all in the browser](#upload-progress-all-in-the-browser)
|
|
11
|
+
|
|
12
|
+
Every Rails recipe leans on what the Railtie sets up by itself: the browser client served at `/task_progress/js`, trees as job arguments, and the `task_progress_tree` helper.
|
|
13
|
+
|
|
14
|
+
## Relaying a command's progress: ffmpeg
|
|
15
|
+
|
|
16
|
+
Most long work is somebody else's program, and most such programs say how far along they are - ffmpeg among the most talkative. Read what it says, and move a task along with it. The two recipes after this one use it as it is.
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# lib/transcoder.rb
|
|
20
|
+
require "open3"
|
|
21
|
+
|
|
22
|
+
# Runs ffmpeg, and moves `task` along through the seconds of the input as ffmpeg gets through
|
|
23
|
+
# them. ffmpeg writes its progress as key=value lines to wherever `-progress` says - here, its
|
|
24
|
+
# stdout. Canceling the task asks ffmpeg to stop, the way a person at its terminal would: with a "q"
|
|
25
|
+
module Transcoder
|
|
26
|
+
Failed = Class.new(StandardError)
|
|
27
|
+
|
|
28
|
+
def self.run(input, output, task, *options)
|
|
29
|
+
duration = Float(IO.popen(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", input], &:read))
|
|
30
|
+
task.total = duration.ceil
|
|
31
|
+
command = ["ffmpeg", "-hide_banner", "-y", "-i", input, *options, "-progress", "pipe:1", "-nostats", output]
|
|
32
|
+
Open3.popen3(*command) do |stdin, stdout, stderr, wait|
|
|
33
|
+
said = Thread.new { stderr.read rescue "" } # where ffmpeg says what went wrong
|
|
34
|
+
task.on_cancel { stdin.write("q") rescue nil }
|
|
35
|
+
speed = nil
|
|
36
|
+
stdout.each_line do |line|
|
|
37
|
+
key, value = line.chomp.split("=", 2)
|
|
38
|
+
case key
|
|
39
|
+
when "speed" then speed = value.strip
|
|
40
|
+
when "out_time_us"
|
|
41
|
+
seconds = value.to_i / 1_000_000
|
|
42
|
+
task.advance(seconds - task.count, message: ("#{speed} realtime" if speed)) if seconds > task.count
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
raise Failed, said.value.lines.last(3).join.strip unless wait.value.success?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
TaskProgress.cli("clip.mov") do |root|
|
|
53
|
+
root.subtask("720p", cancelable: true) do |task|
|
|
54
|
+
Transcoder.run("clip.mov", "clip-720p.mp4", task, "-vf", "scale=-2:720", "-c:v", "libx264")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
⠋ clip.mov ██████████████░░░░░░ 70% 0.5s
|
|
61
|
+
⠋ 720p ██████████████░░░░░░ 14/20 0.5s 15.1x realtime
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The task counts seconds of video, so the bar comes for free - and after the first second, the rate and the time left too. It finishes by itself at its total. Canceled, ffmpeg is told to stop, and the next `advance` raises `TaskProgress::Canceled`, which ends the work quietly. If ffmpeg fails, the task fails with what ffmpeg last said:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
✗ clip.mov › 720p: Error opening output /nonexistent/clip-720p.mp4: No such file or directory
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Anything else that prints its progress goes the same way: `rsync --info=progress2`, `curl --progress-bar`, `pg_restore --verbose`, a `docker build`. Read its lines, find the number, and `advance` or `update(percent)`.
|
|
71
|
+
|
|
72
|
+
## A video transcoded in a background job, polled from a Rails page
|
|
73
|
+
|
|
74
|
+
A video is uploaded, a job transcodes it, and the page asks every second how it is doing, with a plain GET - no WebSockets, no SSE, nothing a proxy can get in the way of. Nothing is sent while nothing changes: an unchanged tree is a 304.
|
|
75
|
+
|
|
76
|
+
**1. Where trees live.** Jobs run in other processes, so the tree goes through Redis:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# Gemfile
|
|
80
|
+
gem "task_progress"
|
|
81
|
+
gem "redis-client"
|
|
82
|
+
|
|
83
|
+
# config/initializers/task_progress.rb
|
|
84
|
+
TaskProgress.stream_store = TaskProgress::RedisStreamStore.new(RedisClient.config(url: ENV.fetch("REDIS_URL", "redis://localhost:6379")).new_pool(size: 10))
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
In development with the `:async` queue adapter, jobs run in the same process as the pages, and you can leave this out - the stream is then kept in memory.
|
|
88
|
+
|
|
89
|
+
**2. Remember which tree is whose.** A tree is named by its key:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
add_column :videos, :progress_key, :string
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**3. Hand the tree to the job.**
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# app/controllers/videos_controller.rb
|
|
99
|
+
class VideosController < ApplicationController
|
|
100
|
+
def create
|
|
101
|
+
video = Video.create!(video_params) # has_one_attached :original, has_many_attached :renditions
|
|
102
|
+
progress = TaskProgress.handoff("Transcoding #{video.original.filename}", cancelable: true)
|
|
103
|
+
video.update!(progress_key: progress.key)
|
|
104
|
+
TranscodeJob.perform_later(video, progress)
|
|
105
|
+
redirect_to video
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def show
|
|
109
|
+
@video = current_user.videos.find(params[:id])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The tree is pending from the moment it is handed off, so the page shows the video queued before any worker has it. No routes or actions for the progress itself: the Railtie mounts them, and the page gets links to them from `TaskProgress.for_page` - links with a signed token in them, never the tree's key. Handing them out in `show`, which only finds the user's own videos, is the authorization.
|
|
115
|
+
|
|
116
|
+
**4. Do the work.** The job gets the tree as an argument, already taken up. Its `perform` is the tree's work: done when it returns, failed with the error when it raises. Each size is a task of its own, moved along by [the ffmpeg relay](#relaying-a-commands-progress-ffmpeg):
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# app/jobs/transcode_job.rb
|
|
120
|
+
class TranscodeJob < ApplicationJob
|
|
121
|
+
SIZES = {"1080p" => [], "720p" => ["-vf", "scale=-2:720"], "480p" => ["-vf", "scale=-2:480"]}
|
|
122
|
+
retry_on Transcoder::Failed, attempts: 2
|
|
123
|
+
|
|
124
|
+
def perform(video, progress)
|
|
125
|
+
video.original.open do |original|
|
|
126
|
+
SIZES.each do |size, options|
|
|
127
|
+
output = "#{Dir.tmpdir}/#{video.id}-#{size}.mp4"
|
|
128
|
+
progress.subtask(size, weight: 2, cancelable: true) do |task|
|
|
129
|
+
Transcoder.run(original.path, output, task, *options, "-c:v", "libx264", "-preset", "fast")
|
|
130
|
+
end
|
|
131
|
+
progress.subtask("Storing #{size}") { video.renditions.attach(io: File.open(output), filename: "#{size}.mp4") }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Storing is a task too, without a measure: it counts for little (weight 1 against the transcode's 2) and shows a spinner while it runs. A retry starts the tree over, and the page shows "attempt 2". A worker that dies without a word goes stale on the page after a few seconds, and fails after a minute. Canceling "720p" from the page stops ffmpeg and ends that task - the job goes on with 480p; cancel the root to stop the job, without failing it.
|
|
139
|
+
|
|
140
|
+
**5. Draw it.** With importmap-rails, the Railtie has pinned `task_progress/stimulus` already.
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
// app/javascript/controllers/index.js
|
|
144
|
+
import TaskTreeController from "task_progress/stimulus"
|
|
145
|
+
application.register("task-tree", TaskTreeController)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```erb
|
|
149
|
+
<%# app/views/videos/show.html.erb %>
|
|
150
|
+
<link rel="stylesheet" href="/task_progress/js/task_tree.css">
|
|
151
|
+
<div class="tree" data-controller="task-tree"
|
|
152
|
+
data-task-tree-progress-value="<%= TaskProgress.for_page(@video.progress_key).to_json %>"
|
|
153
|
+
data-task-tree-poll-value="1"></div>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The tree is drawn at once, from the snapshot `for_page` puts in. Then it polls every second (`poll-value`), draws once a frame at most, and stops once the video is through. Its cancel buttons ask where `for_page` says.
|
|
157
|
+
|
|
158
|
+
To have each poll wait for news instead - long polling - set `data-task-tree-wait-value="25"` in place of the poll value; every waiting poll holds a server thread, so size Puma for the pages you expect open at once. Leave both out, and it follows the tree over Server-Sent Events, falling back to polling by itself where those do not get through.
|
|
159
|
+
|
|
160
|
+
## The same video on an Inertia page, with React
|
|
161
|
+
|
|
162
|
+
The job and the controller's `create` are [the ones above](#a-video-transcoded-in-a-background-job-polled-from-a-rails-page). What changes is the page: a React component that Inertia renders, and that reloads the video once its renditions are there.
|
|
163
|
+
|
|
164
|
+
**1. The browser client, vendored from the gem.**
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
$ bin/rails task_progress:vendor
|
|
168
|
+
$ npm install ./vendor/javascript/task_progress
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The first copies the gem's `js/` into `vendor/javascript/task_progress`, as a package named `task_progress` with TypeScript declarations; the second links it into `node_modules`, for Vite to resolve like any other. React is a peer dependency - the one your app has. Commit the vendored copy, and run the task again after updating the gem - Rails warns at boot when the copy is from another version of the gem.
|
|
172
|
+
|
|
173
|
+
**2. One prop.**
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# app/controllers/videos_controller.rb
|
|
177
|
+
def show
|
|
178
|
+
video = current_user.videos.find(params[:id])
|
|
179
|
+
render inertia: "Videos/Show", props: {
|
|
180
|
+
video: video.as_json(methods: :rendition_urls),
|
|
181
|
+
progress: -> { TaskProgress.for_page(video.progress_key) if video.progress_key }
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`for_page` hands out the links to the tree's events, polling and cancellations - with a signed token in them, good for an hour - and the tree as it stands, for the first render. As a lambda, Inertia leaves it out of a partial reload that does not ask for it.
|
|
187
|
+
|
|
188
|
+
**3. The component.**
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
// app/frontend/pages/Videos/Show.tsx
|
|
192
|
+
import { router } from "@inertiajs/react"
|
|
193
|
+
import { TaskTreeView, type Progress } from "task_progress/react"
|
|
194
|
+
import "task_progress/task_tree.css"
|
|
195
|
+
|
|
196
|
+
export default function Show({ video, progress }: { video: Video; progress: Progress | null }) {
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
<VideoPlayer video={video} />
|
|
200
|
+
{progress && (
|
|
201
|
+
<div className="tree">
|
|
202
|
+
<TaskTreeView progress={progress} onComplete={() => router.reload({ only: ["video"] })} />
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
It draws the snapshot straight away - server-side rendering included - follows the tree over Server-Sent Events, and falls back to polling by itself where those do not get through. When the root is done, `onComplete` reloads `video`, and the player has its renditions; `onFail` is there for the other outcome. Navigating away closes the stream.
|
|
211
|
+
|
|
212
|
+
To draw it your own way - with your design system's components, say - take the rows instead:
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
import { useTaskTree } from "task_progress/react"
|
|
216
|
+
|
|
217
|
+
const { rows, cancel } = useTaskTree(progress, { onComplete: () => router.reload({ only: ["video"] }) })
|
|
218
|
+
return rows.map((row) => (
|
|
219
|
+
<ProgressRow key={row.key} depth={row.depth} name={row.name} percent={row.percent} striped={row.indeterminate}
|
|
220
|
+
status={row.status} message={row.message} onCancel={row.cancelable ? () => cancel(row) : undefined} />
|
|
221
|
+
))
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Videos transcoded in subprocesses, drawn in one terminal
|
|
225
|
+
|
|
226
|
+
A script that transcodes a pile of videos into two sizes each, a few videos at a time, each in a Ruby process of its own - and draws the lot as one tree. Every process reports up a pipe; Ctrl-C cancels everything and stops every ffmpeg, and a process that crashes fails its own branch and the run with it.
|
|
227
|
+
|
|
228
|
+
**The worker** is an ordinary command-line program, using [the ffmpeg relay](#relaying-a-commands-progress-ffmpeg). Run on its own, it draws its own tree:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
# transcode.rb
|
|
232
|
+
require "task_progress"
|
|
233
|
+
require_relative "lib/transcoder"
|
|
234
|
+
|
|
235
|
+
input = ARGV.fetch(0)
|
|
236
|
+
base = File.basename(input, ".*")
|
|
237
|
+
TaskProgress.cli(File.basename(input)) do |root|
|
|
238
|
+
root.subtask("1080p", cancelable: true) { |t| Transcoder.run(input, "#{base}-1080p.mp4", t, "-c:v", "libx264", "-preset", "fast") }
|
|
239
|
+
root.subtask("720p", cancelable: true) { |t| Transcoder.run(input, "#{base}-720p.mp4", t, "-vf", "scale=-2:720", "-c:v", "libx264", "-preset", "fast") }
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**The parent** mounts one worker per video. ffmpeg uses several cores of its own, so it runs half as many at once as there are cores:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
# transcode_all.rb
|
|
247
|
+
require "task_progress"
|
|
248
|
+
require "etc"
|
|
249
|
+
|
|
250
|
+
files = ARGV
|
|
251
|
+
TaskProgress.cli("Transcode #{files.length} files", cancelable: true) do |root|
|
|
252
|
+
queue = Queue.new
|
|
253
|
+
files.each { queue << [it, root.mount(File.basename(it), cancelable: true)] }
|
|
254
|
+
queue.close
|
|
255
|
+
|
|
256
|
+
workers = [Etc.nprocessors / 2, files.length].min.clamp(1, nil).times.map do
|
|
257
|
+
Thread.new do
|
|
258
|
+
while (file, mount = queue.pop)
|
|
259
|
+
mount.spawn(RbConfig.ruby, File.expand_path("transcode.rb", __dir__), file)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
workers.each(&:join)
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
$ ruby transcode_all.rb *.mov
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Every video shows from the start, waiting its turn. `TaskProgress.cli` in the worker sees it was started by `spawn` and writes its batches up the pipe instead of drawing; `spawn` returns once the worker exits. On a terminal the tree redraws in place. Piped, or on CI, it writes an execution log instead:
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
[ 10% 0.6s] Transcode 2 files › a.mov › 1080p 4/20
|
|
275
|
+
[ 99% 2.4s] ✓ Transcode 2 files › b.mov › 1080p 1.7s
|
|
276
|
+
[ 99% 4.7s] ✓ Transcode 2 files › a.mov 4.1s
|
|
277
|
+
[ 99% 4.7s] ✓ Transcode 2 files › b.mov 4.1s
|
|
278
|
+
[100% 4.7s] ✓ Transcode 2 files 4.1s
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
ffmpeg is a process of its own already, so the relay works from threads in one Ruby process just as well. A Ruby process per video is what to reach for when the Ruby side does real work of its own, or might fall over. Workers on other machines report the same way through Redis: hand each one a tree with `TaskProgress.handoff`, `adopt` it in the worker, and mount it in the parent with `root.mount(name).follow_stream(TaskProgress.stream_store, key)`. `task_progress watch KEY` draws any one of them from yet another terminal.
|
|
282
|
+
|
|
283
|
+
## A Rails action that streams its own progress to React
|
|
284
|
+
|
|
285
|
+
A report that takes a minute to build, built while the page watches: the request does the work, and its response is the progress, as Server-Sent Events. Nothing to queue, nothing to store - when the report is ready, the stream ends.
|
|
286
|
+
|
|
287
|
+
**1. The action.** `ActionController::Live` runs it on a thread of its own and sends what it writes as it writes it. The work goes on another thread, and the stream follows it:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
# config/routes.rb
|
|
291
|
+
get "/reports/:id/build" => "reports#build", as: :build_report
|
|
292
|
+
|
|
293
|
+
# app/controllers/reports_controller.rb
|
|
294
|
+
class ReportsController < ApplicationController
|
|
295
|
+
include ActionController::Live
|
|
296
|
+
|
|
297
|
+
def build
|
|
298
|
+
report = Report.find(params[:id])
|
|
299
|
+
response.headers.merge!(TaskProgress::SSE::HEADERS)
|
|
300
|
+
root = TaskProgress.new("Building #{report.name}", cancelable: true)
|
|
301
|
+
work = Thread.new do
|
|
302
|
+
Rails.application.executor.wrap do
|
|
303
|
+
root.run { |r| report.build!(progress: r) }
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
TaskProgress::SSE.new(root).each { response.stream.write(it) }
|
|
307
|
+
rescue ActionController::Live::ClientDisconnected
|
|
308
|
+
root.cancel!("Nobody is watching any more")
|
|
309
|
+
ensure
|
|
310
|
+
work&.join
|
|
311
|
+
response.stream.close
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
# app/models/report.rb
|
|
318
|
+
def build!(progress:)
|
|
319
|
+
queries = progress.subtask("Queries", total: sections.size)
|
|
320
|
+
sections.each { |section| section.query! && queries.tick }
|
|
321
|
+
progress.subtask("PDF", indeterminate: true) { render_pdf! }
|
|
322
|
+
end
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The tree is sent whole first, then every change as it happens, and the stream ends once the root has finished. A page that goes away is noticed at the next write - a change, or the ping every 15 seconds - and cancels the work. The work is the request's own: each connection builds the report anew, so link to the stream only where you mean to start it. Every open stream holds a Puma thread.
|
|
326
|
+
|
|
327
|
+
**2. Load the client.**
|
|
328
|
+
|
|
329
|
+
```
|
|
330
|
+
$ bin/importmap pin react react-dom/client
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
The Railtie pins `task_progress/react` itself. In an app with a bundler, vendor it instead - see [the Inertia recipe](#the-same-video-on-an-inertia-page-with-react).
|
|
334
|
+
|
|
335
|
+
**3. Draw it.** In a React app:
|
|
336
|
+
|
|
337
|
+
```jsx
|
|
338
|
+
import { TaskTreeView } from "task_progress/react"
|
|
339
|
+
|
|
340
|
+
export function ReportBuilder({ report }) {
|
|
341
|
+
const [building, setBuilding] = useState(false)
|
|
342
|
+
if (!building) return <button onClick={() => setBuilding(true)}>Build the report</button>
|
|
343
|
+
return <div className="tree"><TaskTreeView url={`/reports/${report.id}/build`} /></div>
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Or on a page that is not a React app, with no JSX and no build step:
|
|
348
|
+
|
|
349
|
+
```erb
|
|
350
|
+
<link rel="stylesheet" href="/task_progress/js/task_tree.css">
|
|
351
|
+
<div class="tree" id="report-progress"></div>
|
|
352
|
+
<script type="module">
|
|
353
|
+
import { createTaskTreeRoot } from "task_progress/react"
|
|
354
|
+
createTaskTreeRoot(document.getElementById("report-progress")).render({ url: "<%= build_report_path(@report) %>" })
|
|
355
|
+
</script>
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`useTaskTree(url)` hands you the tree to draw your own way: `tree.rows()` is what to draw, and `rowView(tree, row)` from `row_view.js` what each row says. A tree of thousands of tasks folds what is done and what waits into a row each, so a big report draws as a handful of rows.
|
|
359
|
+
|
|
360
|
+
## Upload progress, all in the browser
|
|
361
|
+
|
|
362
|
+
Files going up from a form, a bar for each, with no tree on the server and nothing to set up there. The tree lives in the page: `LocalTasks` keeps it, `trackUpload` follows an `XMLHttpRequest` - `fetch` reports nothing about a body going up - and the renderers draw it as it changes.
|
|
363
|
+
|
|
364
|
+
**React:**
|
|
365
|
+
|
|
366
|
+
```jsx
|
|
367
|
+
import { useState } from "react"
|
|
368
|
+
import { LocalTasks, trackUpload } from "task_progress/local_tasks"
|
|
369
|
+
import { TaskTreeView } from "task_progress/react"
|
|
370
|
+
|
|
371
|
+
function upload(files) {
|
|
372
|
+
const uploads = new LocalTasks("Uploading", { cancelable: true })
|
|
373
|
+
for (const file of files) {
|
|
374
|
+
const xhr = new XMLHttpRequest()
|
|
375
|
+
xhr.open("PUT", `/attachments/${encodeURIComponent(file.name)}`)
|
|
376
|
+
trackUpload(xhr, uploads.root.subtask(file.name, { unit: "bytes", cancelable: true }), { abortOnCancel: true })
|
|
377
|
+
xhr.send(file)
|
|
378
|
+
}
|
|
379
|
+
return uploads
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function Uploader() {
|
|
383
|
+
const [uploads, setUploads] = useState(null)
|
|
384
|
+
return <>
|
|
385
|
+
<input type="file" multiple onChange={(e) => setUploads(upload(e.target.files))} />
|
|
386
|
+
{uploads && <div className="tree"><TaskTreeView local={uploads} /></div>}
|
|
387
|
+
</>
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Stimulus** - the same `upload(files)`, drawn by the `task-tree` controller through an outlet:
|
|
392
|
+
|
|
393
|
+
```html
|
|
394
|
+
<input type="file" multiple data-controller="uploader" data-action="uploader#upload" data-uploader-task-tree-outlet="#uploads">
|
|
395
|
+
<div class="tree" id="uploads" data-controller="task-tree"></div>
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
```js
|
|
399
|
+
// app/javascript/controllers/uploader_controller.js
|
|
400
|
+
export default class extends Controller {
|
|
401
|
+
static outlets = ["task-tree"]
|
|
402
|
+
upload(event) { this.taskTreeOutlet.show(upload(event.target.files)) }
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
That is all of it. What you get:
|
|
407
|
+
|
|
408
|
+
- A row per file, with bytes sent, the rate and the time left, under a row for the lot. Put `task_tree.css` on the page for them.
|
|
409
|
+
- A file is done when the server answers 2xx, not when the last byte has gone out. Any other answer fails it with the status, and so do a network error and a timeout.
|
|
410
|
+
- Every cancel button works here, with nothing on the server to ask: a file's button aborts its request, and the top one aborts them all.
|
|
411
|
+
- The top row finishes when you say so. To have it end with the files, collect `task.whenFinished()` from each `trackUpload` and close it once all have ended: `Promise.all(endings).then(() => uploads.root.done())`. It resolves with how the task ended - `"done"`, `"failed"` or `"canceled"` - if you want a failure to fail the lot.
|
|
412
|
+
|
|
413
|
+
Uploading to S3, or to Active Storage's direct uploads, is the same `XMLHttpRequest`, with another URL and its headers. When the files are a step in a job the server tracks, graft the uploads under that job's task instead - see [Tasks that run in the browser](MANUAL.md#uploads) in the MANUAL.
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Drawn into the app's import map by the Railtie when importmap-rails is in use, before the app's
|
|
2
|
+
# own config/importmap.rb - so a pin of the same name there wins. The files are pinned where the
|
|
3
|
+
# Railtie serves them as they are: through the asset pipeline they would be fingerprinted, and
|
|
4
|
+
# the relative imports between them would point at names it does not serve
|
|
5
|
+
js = Rails.application.config.task_progress.js_path
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"task_progress" => "task_tree.js",
|
|
9
|
+
"task_progress/react" => "react_task_tree.js",
|
|
10
|
+
"task_progress/stimulus" => "stimulus_controller.js",
|
|
11
|
+
"task_progress/local_tasks" => "local_tasks.js",
|
|
12
|
+
"task_progress/row_view" => "row_view.js",
|
|
13
|
+
"task_progress/clock" => "task_progress_clock.js"
|
|
14
|
+
}.each do |name, file|
|
|
15
|
+
# Not preloaded: the React and Stimulus parts import what only some pages have
|
|
16
|
+
pin name, to: "#{js}/#{file}", preload: false, integrity: false
|
|
17
|
+
end
|
data/exe/task_progress
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../lib/task_progress"
|
|
6
|
+
|
|
7
|
+
url = ENV.fetch("REDIS_URL", "redis://localhost:6379")
|
|
8
|
+
# In a Rails app, where the Railtie looks for it too
|
|
9
|
+
default_dir = File.exist?("config/application.rb") ? "vendor/javascript/task_progress" : "task_progress"
|
|
10
|
+
parser = OptionParser.new do |o|
|
|
11
|
+
o.banner = <<~TEXT
|
|
12
|
+
Usage: task_progress watch KEY [--redis URL]
|
|
13
|
+
task_progress vendor [DIR]
|
|
14
|
+
|
|
15
|
+
watch Draws a tree handed off with TaskProgress.handoff - running anywhere that reaches
|
|
16
|
+
the same Redis - until it finishes. Exits 0 if it was complete, 1 if not. Ctrl-C
|
|
17
|
+
stops watching, and leaves the work alone.
|
|
18
|
+
vendor Copies the browser client into DIR (#{default_dir}), replacing what an earlier
|
|
19
|
+
vendoring left there, for a bundler to take from - `npm install ./DIR` makes it
|
|
20
|
+
the `task_progress` package.
|
|
21
|
+
|
|
22
|
+
TEXT
|
|
23
|
+
o.on("--redis URL", "Where the tree is kept (REDIS_URL, or #{url})") { url = it }
|
|
24
|
+
end
|
|
25
|
+
command, arg = parser.parse(ARGV)
|
|
26
|
+
|
|
27
|
+
case command
|
|
28
|
+
when "vendor"
|
|
29
|
+
dir = arg || default_dir
|
|
30
|
+
begin
|
|
31
|
+
files = TaskProgress.vendor_js(dir)
|
|
32
|
+
rescue ArgumentError => e
|
|
33
|
+
abort e.message
|
|
34
|
+
end
|
|
35
|
+
puts "The task_progress client #{TaskProgress::VERSION} is in #{dir}: #{files.join(", ")}"
|
|
36
|
+
when "watch"
|
|
37
|
+
abort(parser.help) unless arg
|
|
38
|
+
begin
|
|
39
|
+
require "redis-client"
|
|
40
|
+
rescue LoadError
|
|
41
|
+
abort "task_progress watch needs the redis-client gem"
|
|
42
|
+
end
|
|
43
|
+
stream_store = TaskProgress::RedisStreamStore.new(RedisClient.config(url:).new_pool(size: 2))
|
|
44
|
+
begin
|
|
45
|
+
exit(TaskProgress.watch(arg, stream_store:).complete? ? 0 : 1)
|
|
46
|
+
rescue KeyError => e
|
|
47
|
+
abort e.message
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
abort(parser.help)
|
|
51
|
+
end
|