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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# Redraws the tree in place on a terminal, from a ticker thread of its own so spinners turn
|
|
6
|
+
# while nothing reports. A finished subtree folds into its one line, and when the tree is
|
|
7
|
+
# taller than the screen the finished lines go first, then the ones not started. When the
|
|
8
|
+
# root finishes the tree is drawn one last time and left on screen - or erased, with `erase: true`.
|
|
9
|
+
# A line a task `puts` goes above the tree and stays there
|
|
10
|
+
#
|
|
11
|
+
# With `interval: nil` there is no thread and every batch redraws, which is what tests want
|
|
12
|
+
class TerminalOutput
|
|
13
|
+
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze #: Array[String]
|
|
14
|
+
BAR = 20 #: Integer
|
|
15
|
+
DEFAULT_SIZE = [24, 80].freeze #: [Integer, Integer]
|
|
16
|
+
private_constant :SPINNER, :BAR, :DEFAULT_SIZE
|
|
17
|
+
|
|
18
|
+
# @rbs @io: IO | StringIO
|
|
19
|
+
# @rbs @interval: (Integer | Float)?
|
|
20
|
+
# @rbs @erase: bool
|
|
21
|
+
# @rbs @clock: ^() -> Float
|
|
22
|
+
# @rbs @lock: Thread::Mutex
|
|
23
|
+
# @rbs @drawn: Integer
|
|
24
|
+
# @rbs @frame: Integer
|
|
25
|
+
# @rbs @log: Log?
|
|
26
|
+
# @rbs @closed: bool
|
|
27
|
+
# @rbs @ticker: Thread?
|
|
28
|
+
# @rbs @dirty: bool
|
|
29
|
+
|
|
30
|
+
# Draws on `io` every `interval` seconds - or on every batch, with `interval: nil`. With `erase: true` the tree is cleared once the root finishes
|
|
31
|
+
#: (?IO | StringIO, ?interval: (Integer | Float)?, ?erase: bool, ?clock: ^() -> Float) -> void
|
|
32
|
+
def initialize(io = $stderr, interval: 0.1, erase: false, clock: -> { Process.clock_gettime(Process::CLOCK_REALTIME) })
|
|
33
|
+
@io = io
|
|
34
|
+
@interval = interval
|
|
35
|
+
@erase = erase
|
|
36
|
+
@clock = clock
|
|
37
|
+
@lock = Mutex.new
|
|
38
|
+
@drawn = 0
|
|
39
|
+
@frame = 0
|
|
40
|
+
@log = nil
|
|
41
|
+
@closed = false
|
|
42
|
+
@ticker = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Takes one batch, see Log#add_output
|
|
46
|
+
#: (Batch, Log) -> void
|
|
47
|
+
def call(batch, log)
|
|
48
|
+
said = batch.events.filter_map { it.value if it.op == :say } #: Array[String]
|
|
49
|
+
@lock.synchronize do
|
|
50
|
+
@log = log
|
|
51
|
+
return said.each { @io.puts(it) } if @closed
|
|
52
|
+
print_above(said) unless said.empty?
|
|
53
|
+
if log.snapshot[[]]&.finished?
|
|
54
|
+
finish
|
|
55
|
+
elsif @interval.nil? || !said.empty?
|
|
56
|
+
draw
|
|
57
|
+
@ticker ||= Thread.new { tick } if @interval
|
|
58
|
+
else
|
|
59
|
+
@dirty = true
|
|
60
|
+
@ticker ||= Thread.new { tick }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Draws the tree one last time and stops the ticker
|
|
66
|
+
#: () -> void
|
|
67
|
+
def close
|
|
68
|
+
@lock.synchronize { finish if @log && !@closed }
|
|
69
|
+
@ticker&.join
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The lines for a snapshot, without the terminal control around them
|
|
73
|
+
#: (State, ?width: Integer, ?height: Integer) -> Array[String]
|
|
74
|
+
def lines(snapshot, width: columns, height: rows)
|
|
75
|
+
shown = [] #: Array[[path?, Node | Fold | Integer]]
|
|
76
|
+
snapshot.rows do |path, node|
|
|
77
|
+
next if path.length.positive? && hidden?(snapshot, path)
|
|
78
|
+
shown << [path, node]
|
|
79
|
+
end
|
|
80
|
+
shown = trim(shown, height - 1)
|
|
81
|
+
shown.map { |path, node| line(snapshot, path, node, width) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
#: () -> void
|
|
87
|
+
def tick
|
|
88
|
+
loop do
|
|
89
|
+
sleep(@interval || 0.1)
|
|
90
|
+
@lock.synchronize do
|
|
91
|
+
return if @closed
|
|
92
|
+
@frame += 1
|
|
93
|
+
draw
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#: () -> void
|
|
99
|
+
def finish
|
|
100
|
+
@closed = true
|
|
101
|
+
@erase ? clear : draw
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#: () -> void
|
|
105
|
+
def draw
|
|
106
|
+
log = @log or return
|
|
107
|
+
text = lines(log.snapshot)
|
|
108
|
+
@io.write("#{up}#{text.join("\n")}\n")
|
|
109
|
+
@io.flush
|
|
110
|
+
@drawn = text.length
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#: (Array[String]) -> void
|
|
114
|
+
def print_above(said)
|
|
115
|
+
@io.write("#{up}#{said.join("\n")}\n")
|
|
116
|
+
@drawn = 0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: () -> void
|
|
120
|
+
def clear
|
|
121
|
+
@io.write(up)
|
|
122
|
+
@io.flush
|
|
123
|
+
@drawn = 0
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
#: () -> String
|
|
127
|
+
def up = @drawn.zero? ? "" : "\e[#{@drawn}A\e[J"
|
|
128
|
+
|
|
129
|
+
# Under a finished node only what failed shows, so a failure leaves the way down to it
|
|
130
|
+
#: (State, path) -> bool
|
|
131
|
+
def hidden?(snapshot, path)
|
|
132
|
+
(0...path.length).any? { snapshot[path.take(it)]&.finished? && snapshot[path.take(it + 1)]&.status != :failed }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# What fits in `room` lines - the last of them saying how many more there are
|
|
136
|
+
#: (Array[[path?, Node | Fold | Integer]], Integer) -> Array[[path?, Node | Fold | Integer]]
|
|
137
|
+
def trim(shown, room)
|
|
138
|
+
room = [room, 2].max
|
|
139
|
+
%i[done pending].each do |status|
|
|
140
|
+
break if shown.length <= room
|
|
141
|
+
excess = shown.length - room
|
|
142
|
+
shown = shown.each_with_index.reject { |(_, node), i| i.positive? && node.is_a?(Node) && node.status == status && (excess -= 1) >= 0 }.map(&:first)
|
|
143
|
+
end
|
|
144
|
+
return shown if shown.length <= room
|
|
145
|
+
more = [nil, shown.length - room + 1] #: [path?, Node | Fold | Integer]
|
|
146
|
+
shown.first(room - 1) + [more]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
#: (State, path?, Node | Fold | Integer, Integer) -> String
|
|
150
|
+
def line(snapshot, path, node, width)
|
|
151
|
+
return " … #{node} more" if path.nil? || node.is_a?(Integer)
|
|
152
|
+
indent = " " * path.length
|
|
153
|
+
return "#{indent}#{(node.status == :done) ? "✓" : "·"} #{node.name}"[0, width - 1].to_s if node.is_a?(Fold)
|
|
154
|
+
parts = ["#{indent}#{icon(node)} #{node.name}"] #: Array[String?]
|
|
155
|
+
parts << measure(snapshot, path, node)
|
|
156
|
+
parts << pace(node) if node.status == :active && node.children.empty?
|
|
157
|
+
parts << elapsed(node)
|
|
158
|
+
parts << "attempt #{node.attempt}" if node.attempt.to_i > 1
|
|
159
|
+
parts << ((node.status == :failed) ? node.error&.dig(:message) : node.message)
|
|
160
|
+
parts.compact.reject(&:empty?).join(" ")[0, width - 1].to_s
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
#: (Node) -> String
|
|
164
|
+
def icon(node)
|
|
165
|
+
case node.status
|
|
166
|
+
when :done then "✓"
|
|
167
|
+
when :failed then "✗"
|
|
168
|
+
when :canceled then "⊘"
|
|
169
|
+
when :pending then "·"
|
|
170
|
+
else node.stale ? "?" : SPINNER.fetch(@frame % SPINNER.length)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
#: (State, path, Node) -> String?
|
|
175
|
+
def measure(snapshot, path, node)
|
|
176
|
+
return nil if node.status == :done && path.length.positive?
|
|
177
|
+
percent = snapshot.percent(path)
|
|
178
|
+
if node.children.empty? && node.indeterminate
|
|
179
|
+
counted = ((node.unit == "bytes") ? TaskProgress.human_bytes(node.count) : node.count.to_s) if node.count.positive?
|
|
180
|
+
# A barber pole while it runs, a full bar once it is through
|
|
181
|
+
[(node.status == :active) ? stripes : bar(percent), counted].compact.join(" ")
|
|
182
|
+
elsif node.children.empty? && (total = node.total)
|
|
183
|
+
count = (node.unit == "bytes") ? "#{TaskProgress.human_bytes(node.count)} / #{TaskProgress.human_bytes(total)}" : "#{node.count}/#{total}"
|
|
184
|
+
"#{bar(percent)} #{count}"
|
|
185
|
+
else
|
|
186
|
+
format("%s %3d%%", bar(percent), percent)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Diagonal stripes that crawl one cell along every frame - for work with no measure
|
|
191
|
+
#: () -> String
|
|
192
|
+
def stripes = Array.new(BAR) { ((it - @frame) % 6 < 3) ? "╱" : "░" }.join
|
|
193
|
+
|
|
194
|
+
#: (Integer) -> String
|
|
195
|
+
def bar(percent)
|
|
196
|
+
filled = BAR * percent / 100
|
|
197
|
+
"#{"█" * filled}#{"░" * (BAR - filled)}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The rate over the whole run so far, and the time left at that rate. Steadier than a
|
|
201
|
+
# rate over the last few ticks, which jumps about with every pause in the work
|
|
202
|
+
#: (Node) -> String?
|
|
203
|
+
def pace(node)
|
|
204
|
+
started = node.started_at or return nil
|
|
205
|
+
seconds = @clock.call - started
|
|
206
|
+
return nil unless seconds >= 1 && node.count.positive?
|
|
207
|
+
rate = node.count / seconds
|
|
208
|
+
per = if node.unit == "bytes"
|
|
209
|
+
"#{TaskProgress.human_bytes(rate)}/s"
|
|
210
|
+
else
|
|
211
|
+
format((rate < 10) ? "%.1f/s" : "%d/s", rate)
|
|
212
|
+
end
|
|
213
|
+
total = node.total
|
|
214
|
+
return per unless total && total > node.count
|
|
215
|
+
"#{per} ETA #{TaskProgress.duration((total - node.count) / rate)}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
#: (Node) -> String?
|
|
219
|
+
def elapsed(node)
|
|
220
|
+
started = node.started_at or return nil
|
|
221
|
+
TaskProgress.duration((node.finished_at || @clock.call) - started)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
#: () -> Integer
|
|
225
|
+
def columns = size[1]
|
|
226
|
+
|
|
227
|
+
#: () -> Integer
|
|
228
|
+
def rows = size[0]
|
|
229
|
+
|
|
230
|
+
#: () -> [Integer, Integer]
|
|
231
|
+
def size
|
|
232
|
+
io = @io
|
|
233
|
+
return DEFAULT_SIZE unless io.is_a?(IO) # a StringIO has no screen
|
|
234
|
+
require "io/console"
|
|
235
|
+
rows, cols = io.winsize
|
|
236
|
+
(rows.positive? && cols.positive?) ? [rows, cols] : DEFAULT_SIZE
|
|
237
|
+
rescue NoMethodError, SystemCallError
|
|
238
|
+
DEFAULT_SIZE
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
class TaskProgress
|
|
7
|
+
# The right to see a tree and to ask for its tasks to be canceled, signed, and good for a while.
|
|
8
|
+
# The server issues one when it hands out a page - after it has decided the page may show that
|
|
9
|
+
# tree - and checks it when the page comes back for the tree or with a cancellation. So neither
|
|
10
|
+
# can be forged without the secret, or used after the token expires, and the tree's own key never
|
|
11
|
+
# has to reach a page. Which tasks may be canceled is for `cancelable` to say.
|
|
12
|
+
#
|
|
13
|
+
# tokens = TaskProgress::Token.new(ENV.fetch("TASK_PROGRESS_SECRET"))
|
|
14
|
+
# token = tokens.issue(import.key, expires_in: 3600) # into the page
|
|
15
|
+
# tokens.verify(token) # => import.key, when the page comes back
|
|
16
|
+
class Token
|
|
17
|
+
# @rbs @secret: String
|
|
18
|
+
# @rbs @clock: ^() -> Integer
|
|
19
|
+
|
|
20
|
+
# Signs with `secret`, which must be at least 32 bytes
|
|
21
|
+
#: (String, ?clock: ^() -> Integer) -> void
|
|
22
|
+
def initialize(secret, clock: -> { Time.now.to_i })
|
|
23
|
+
raise ArgumentError, "The secret must be at least 32 bytes" if secret.to_s.bytesize < 32
|
|
24
|
+
@secret = secret.to_s
|
|
25
|
+
@clock = clock
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A token for `subject` - a log id, a run, whatever the tokens are checked against - good for `expires_in` seconds
|
|
29
|
+
#: (String, ?expires_in: Integer) -> String
|
|
30
|
+
def issue(subject, expires_in: 3600)
|
|
31
|
+
payload = encode("#{subject}\n#{@clock.call + expires_in}")
|
|
32
|
+
"#{payload}.#{sign(payload)}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# What the token was issued for, or nil if it is forged, mangled or expired
|
|
36
|
+
#: (String?) -> String?
|
|
37
|
+
def verify(token)
|
|
38
|
+
payload, signature = token.to_s.split(".", 2)
|
|
39
|
+
return unless payload && signature && signature.bytesize == sign(payload).bytesize
|
|
40
|
+
return unless OpenSSL.fixed_length_secure_compare(signature, sign(payload))
|
|
41
|
+
subject, expires = decode(payload).split("\n", 2)
|
|
42
|
+
subject if expires && Integer(expires, exception: false)&.>=(@clock.call)
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
#: (String) -> String
|
|
50
|
+
def sign(payload) = encode(OpenSSL::HMAC.digest("SHA256", @secret, "task_progress.token\n#{payload}"))
|
|
51
|
+
|
|
52
|
+
#: (String) -> String
|
|
53
|
+
def encode(bytes) = [bytes].pack("m0").tr("+/", "-_").delete("=")
|
|
54
|
+
|
|
55
|
+
#: (String) -> String
|
|
56
|
+
def decode(text) = (text + "=" * (-text.length % 4)).tr("-_", "+/").unpack1("m0").to_s.force_encoding(Encoding::UTF_8)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
begin
|
|
5
|
+
require "progressive_io"
|
|
6
|
+
rescue LoadError
|
|
7
|
+
raise LoadError, "TaskProgress#track_io needs the progressive_io gem - add `gem \"progressive_io\"` to your Gemfile"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class TaskProgress
|
|
11
|
+
# A ProgressiveIO that advances a task by how far it has been read. Readers seek (ZipKit reads
|
|
12
|
+
# the end of an archive first), so only the furthest position counts. The task moves in steps,
|
|
13
|
+
# a thousandth of the total by default - a loop of small reads over a big file would otherwise
|
|
14
|
+
# commit a batch per read, and every output and browser would get each of them
|
|
15
|
+
class TrackedIO < ::ProgressiveIO
|
|
16
|
+
STEP_WITHOUT_TOTAL = 64 * 1024 #: Integer
|
|
17
|
+
private_constant :STEP_WITHOUT_TOTAL
|
|
18
|
+
|
|
19
|
+
# @rbs @task: TaskProgress
|
|
20
|
+
# @rbs @total: Integer?
|
|
21
|
+
# @rbs @step: Integer
|
|
22
|
+
# @rbs @furthest: Integer
|
|
23
|
+
# @rbs @reported: Integer
|
|
24
|
+
|
|
25
|
+
# Advances `task` as `io` is read, in steps of `step` bytes - see TaskProgress#track_io, which makes one
|
|
26
|
+
#: (IO, TaskProgress, ?total: Integer?, ?step: Integer?) -> void
|
|
27
|
+
def initialize(io, task, total: nil, step: nil)
|
|
28
|
+
@task = task
|
|
29
|
+
@total = total
|
|
30
|
+
@step = step || (total ? [total / 1000, 1].max : STEP_WITHOUT_TOTAL)
|
|
31
|
+
@furthest = 0
|
|
32
|
+
@reported = 0
|
|
33
|
+
super(io) { |pos| moved_to(pos) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# ProgressiveIO lets these through unreported, and IO.copy_stream reads anything that is not
|
|
37
|
+
# an IO with readpartial
|
|
38
|
+
# Reads the way IO#readpartial does, and reports how far it got
|
|
39
|
+
#: (*untyped, **untyped) -> untyped
|
|
40
|
+
def readpartial(*, **) = super.tap { notify_read }
|
|
41
|
+
|
|
42
|
+
# Reads the way IO#read_nonblock does, and reports how far it got
|
|
43
|
+
#: (*untyped, **untyped) -> untyped
|
|
44
|
+
def read_nonblock(*, **) = super.tap { notify_read }
|
|
45
|
+
|
|
46
|
+
# Reads the way IO#sysread does, and reports how far it got
|
|
47
|
+
#: (*untyped, **untyped) -> untyped
|
|
48
|
+
def sysread(*, **) = super.tap { notify_read }
|
|
49
|
+
|
|
50
|
+
# Reports what was read since the last step
|
|
51
|
+
#: () -> void
|
|
52
|
+
def flush_progress
|
|
53
|
+
return if @furthest == @reported || @task.finished?
|
|
54
|
+
@task.advance(@furthest - @reported)
|
|
55
|
+
@reported = @furthest
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
#: (Integer) -> void
|
|
61
|
+
def moved_to(pos)
|
|
62
|
+
return if pos <= @furthest
|
|
63
|
+
@furthest = pos
|
|
64
|
+
total = @total
|
|
65
|
+
flush_progress if @furthest - @reported >= @step || (total && @furthest >= total)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# What a block given to Log#transaction adds its events through. Each one is applied to the
|
|
6
|
+
# tree as it comes, and journaled, so the whole transaction can be undone if the block raises
|
|
7
|
+
class Transaction # :nodoc:
|
|
8
|
+
# What it has added so far, in order
|
|
9
|
+
attr_reader :events #: Array[Event]
|
|
10
|
+
# What it changed, to undo it by
|
|
11
|
+
attr_reader :journal #: Journal
|
|
12
|
+
# The time it opened - every event in it happens then
|
|
13
|
+
attr_reader :at #: Float
|
|
14
|
+
# The tree as the transaction has it so far
|
|
15
|
+
attr_reader :state #: State
|
|
16
|
+
|
|
17
|
+
# @rbs @random: Random
|
|
18
|
+
|
|
19
|
+
# For changing `state` at `at`, drawing task ids from `random` - see Log#transaction, which opens one
|
|
20
|
+
#: (State, Float, Random) -> void
|
|
21
|
+
def initialize(state, at, random)
|
|
22
|
+
@state = state
|
|
23
|
+
@at = at
|
|
24
|
+
@random = random
|
|
25
|
+
@events = []
|
|
26
|
+
@journal = Journal.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Applies the event, and adds it to the batch
|
|
30
|
+
#: (Event) -> self
|
|
31
|
+
def <<(event)
|
|
32
|
+
@state.apply(event, at: @at, journal: @journal)
|
|
33
|
+
@events << event
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A path for a new child of `path` - see State#new_child
|
|
38
|
+
#: (path) -> path
|
|
39
|
+
def new_child(path) = @state.new_child(path, @random)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
class TaskProgress
|
|
7
|
+
# A Rack app that serves handed-off trees to pages, by a Token rather than by the tree's key -
|
|
8
|
+
# what TaskProgress.for_page hands out links to. The Railtie mounts it at `/task_progress/trees`.
|
|
9
|
+
#
|
|
10
|
+
# GET /:token/events Server-Sent Events, see SSE
|
|
11
|
+
# GET /:token/poll the tree, polled or long-polled, see Poll
|
|
12
|
+
# POST /:token/cancellations path=x3k0-00af (a form, or JSON)
|
|
13
|
+
# 202 {"path": ..., "state": "requested"} asked of whoever works on it
|
|
14
|
+
# 200 {"path": ..., "state": "moot"} nothing left to cancel - no such task, or it ended
|
|
15
|
+
# 409 the task is not cancelable
|
|
16
|
+
# 403 a token that is forged, mangled or expired
|
|
17
|
+
# 404 a tree that is gone, or anything else
|
|
18
|
+
#
|
|
19
|
+
# Issuing the token is the authorization: it happens in an action of yours, which has decided
|
|
20
|
+
# the page may see that tree. The key itself never leaves the server
|
|
21
|
+
class TreesApp
|
|
22
|
+
# @rbs @tokens: Token
|
|
23
|
+
# @rbs @stream_store: untyped
|
|
24
|
+
|
|
25
|
+
# Checks tokens with `tokens`, and reads trees from `stream_store` - TaskProgress.stream_store
|
|
26
|
+
# unless given
|
|
27
|
+
#: (tokens: Token, ?stream_store: untyped) -> void
|
|
28
|
+
def initialize(tokens:, stream_store: nil)
|
|
29
|
+
@tokens = tokens
|
|
30
|
+
@stream_store = stream_store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Serves one request
|
|
34
|
+
#: (Hash[String, untyped]) -> [Integer, Hash[String, String], untyped]
|
|
35
|
+
def call(env)
|
|
36
|
+
_, token, action = env["PATH_INFO"].to_s.split("/", 3)
|
|
37
|
+
key = @tokens.verify(token) or return respond(403, {error: "invalid or expired token"})
|
|
38
|
+
stream_store = @stream_store || TaskProgress.stream_store
|
|
39
|
+
case [env["REQUEST_METHOD"], action]
|
|
40
|
+
in ["GET", "events"] then SSE.response(key, stream_store:)
|
|
41
|
+
in ["GET", "poll"] then Poll.response(key, env, stream_store:)
|
|
42
|
+
in ["POST", "cancellations"] then cancel(key, env, stream_store)
|
|
43
|
+
else respond(404, {error: "not found"})
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
#: (String, Hash[String, untyped], untyped) -> [Integer, Hash[String, String], Array[String]]
|
|
50
|
+
def cancel(key, env, stream_store)
|
|
51
|
+
path = CancellationsApp.params(env)["path"].to_s.split("-")
|
|
52
|
+
return respond(422, {error: "malformed path"}) unless path.length <= Wire::MAX_DEPTH && path.all? { it.match?(Wire::SEGMENT) }
|
|
53
|
+
outcome = TaskProgress.fetch(key, stream_store:).request_cancel(path, "Canceled from the page")
|
|
54
|
+
case outcome
|
|
55
|
+
when :refused then respond(409, {error: "not cancelable"})
|
|
56
|
+
when :moot then respond(200, {path: path.join("-"), state: "moot"})
|
|
57
|
+
else respond(202, {path: path.join("-"), state: "requested"})
|
|
58
|
+
end
|
|
59
|
+
rescue KeyError
|
|
60
|
+
respond(404, {error: "no such tree"})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (Integer, Hash[Symbol, untyped]) -> [Integer, Hash[String, String], Array[String]]
|
|
64
|
+
def respond(status, body) = [status, {"content-type" => "application/json"}, [JSON.generate(body)]]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# View helpers, in every view once the gem is in a Rails app
|
|
6
|
+
module TurboHelper
|
|
7
|
+
# The tree a job broadcasts with `TaskProgress.broadcast(record, ...)`, kept up to date: drawn
|
|
8
|
+
# as it stood at its last frame, and subscribed to every frame after that. Needs turbo-rails,
|
|
9
|
+
# and `task_tree.css` on the page - plus `task_progress_clock.js`, for elapsed times to tick
|
|
10
|
+
#
|
|
11
|
+
# <%= task_progress_tree @csv_import %>
|
|
12
|
+
#: (untyped, ?store: untyped) -> String
|
|
13
|
+
def task_progress_tree(record, store: ::Rails.cache) # steep:ignore
|
|
14
|
+
drawn = store.read(RailsDefaults.key(record)) || %(<div id="#{ERB::Util.html_escape(RailsDefaults.id(record))}" class="tree"></div>)
|
|
15
|
+
safe_join([turbo_stream_from(record, :task_progress), drawn.html_safe]) # steep:ignore
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "cgi/escape"
|
|
5
|
+
|
|
6
|
+
class TaskProgress
|
|
7
|
+
# A Rack body streaming a tree as Turbo Streams over Server-Sent Events, for a page that shows
|
|
8
|
+
# it with `<turbo-stream-source src="...">` - Turbo with no Action Cable, and no JavaScript of
|
|
9
|
+
# your own. Every frame replaces the whole tree by morphing, at most every `interval` seconds
|
|
10
|
+
# however fast the tree changes; a reconnect starts with the whole tree, so nothing is ever
|
|
11
|
+
# missed for good. Given a subtask, it streams that branch alone.
|
|
12
|
+
#
|
|
13
|
+
# TaskProgress::TurboSSE.response(task, renderer:, source: "progress-source")
|
|
14
|
+
#
|
|
15
|
+
# Once the task finishes it sends the last frame, removes the element with the id `source` -
|
|
16
|
+
# the `<turbo-stream-source>`, so the browser does not reconnect to a finished stream - and ends.
|
|
17
|
+
# Without a `source` the browser reconnects every `reconnect` seconds, and gets the last frame
|
|
18
|
+
# each time. Given a key, it follows the tree handed off there, as SSE does
|
|
19
|
+
class TurboSSE
|
|
20
|
+
# What a Turbo SSE response is sent with - the same as an SSE one
|
|
21
|
+
HEADERS = SSE::HEADERS #: Hash[String, String]
|
|
22
|
+
|
|
23
|
+
# @rbs @task: TaskProgress
|
|
24
|
+
# @rbs @renderer: TurboStreamOutput::_Renderer
|
|
25
|
+
# @rbs @interval: Integer | Float
|
|
26
|
+
# @rbs @heartbeat: Integer | Float
|
|
27
|
+
# @rbs @source: String?
|
|
28
|
+
# @rbs @reconnect: Integer | Float
|
|
29
|
+
# @rbs @close: bool
|
|
30
|
+
|
|
31
|
+
# A whole Rack response: status, headers and this body - or a 404, for a key with nothing at it
|
|
32
|
+
#: (TaskProgress | String, **untyped) -> [Integer, Hash[String, String], TurboSSE | Array[String]]
|
|
33
|
+
def self.response(source, **)
|
|
34
|
+
[200, HEADERS.dup, new(source, **)]
|
|
35
|
+
rescue KeyError
|
|
36
|
+
[404, {"content-type" => "text/plain"}, ["Nothing to follow"]]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Streams `task`, drawn by `renderer` - an HTMLRenderer, or anything that answers `id` and
|
|
40
|
+
# `render(snapshot, from)`, `from` being the task's path. `source` is the id of the
|
|
41
|
+
# `<turbo-stream-source>` to remove at the end
|
|
42
|
+
#: (TaskProgress | String, renderer: TurboStreamOutput::_Renderer, ?stream_store: untyped, ?interval: Integer | Float, ?heartbeat: Integer | Float, ?reconnect: Integer | Float, ?source: String?) -> void
|
|
43
|
+
def initialize(task, renderer:, stream_store: TaskProgress.stream_store, interval: 0.25, heartbeat: 15, reconnect: 2, source: nil)
|
|
44
|
+
@close = task.is_a?(String)
|
|
45
|
+
@task = task.is_a?(String) ? TaskProgress.follow(task, stream_store:) : task
|
|
46
|
+
@reconnect = reconnect
|
|
47
|
+
@renderer = renderer
|
|
48
|
+
@interval = interval
|
|
49
|
+
@heartbeat = heartbeat
|
|
50
|
+
@source = source
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Yields the stream a chunk at a time, until the task finishes - as a Rack body does
|
|
54
|
+
#: () { (String) -> void } -> void
|
|
55
|
+
def each
|
|
56
|
+
queue = Thread::Queue.new
|
|
57
|
+
output = @task.add_output(replay: false) { |batch, _log| queue << batch }
|
|
58
|
+
yield "retry: #{(@reconnect * 1000).round}\n\n"
|
|
59
|
+
yield message(frame)
|
|
60
|
+
until ended?
|
|
61
|
+
next yield(": ping\n\n") unless queue.pop(timeout: @heartbeat)
|
|
62
|
+
queue.clear # one frame for all of them: it draws the tree as it stands
|
|
63
|
+
yield message(frame)
|
|
64
|
+
sleep(@interval) unless ended?
|
|
65
|
+
end
|
|
66
|
+
yield message(frame)
|
|
67
|
+
source = @source
|
|
68
|
+
yield message(%(<turbo-stream action="remove" target="#{CGI.escapeHTML(source)}"></turbo-stream>)) if source
|
|
69
|
+
ensure
|
|
70
|
+
@task.remove_output(output) if output
|
|
71
|
+
close
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Stops following a tree it followed by its key - Rack calls it when the response is over
|
|
75
|
+
#: () -> void
|
|
76
|
+
def close
|
|
77
|
+
@task.log.close if @close
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Finished, or pruned away - which is as finished as it will ever be
|
|
83
|
+
#: () -> bool
|
|
84
|
+
def ended?
|
|
85
|
+
node = @task.log.snapshot[@task.path]
|
|
86
|
+
node.nil? || node.finished?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
#: () -> String
|
|
90
|
+
def frame
|
|
91
|
+
snapshot = @task.log.snapshot
|
|
92
|
+
TurboStreamOutput.frame(@renderer.id, render(snapshot))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
#: (State) -> String
|
|
96
|
+
def render(snapshot) = @renderer.render(snapshot, @task.path)
|
|
97
|
+
|
|
98
|
+
# An unnamed event - what `<turbo-stream-source>` listens for. A line of SSE ends at a CR as
|
|
99
|
+
# much as at a LF, and task names and messages - curl's and ffmpeg's, say - are full of CRs
|
|
100
|
+
#: (String) -> String
|
|
101
|
+
def message(html) = "#{html.split(/\r\n|\r|\n/).map { "data: #{it}\n" }.join}\n"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "cgi/escape"
|
|
5
|
+
|
|
6
|
+
class TaskProgress
|
|
7
|
+
# Draws the whole tree as HTML on every batch, as a Turbo Stream that replaces it in the page by
|
|
8
|
+
# morphing, and hands that to `to` - in Rails, Turbo::StreamsChannel, which reaches every
|
|
9
|
+
# page subscribed with `turbo_stream_from`, whichever process the tree lives in. Put it behind a
|
|
10
|
+
# BufferedOutput, as TaskProgress.broadcast does, and a spammy job is drawn a few times a second.
|
|
11
|
+
#
|
|
12
|
+
# The whole tree, every time: a Turbo Stream has no seq and no gaps to notice, so a page that
|
|
13
|
+
# missed a frame - or joined late - is set right by the next one. Morphing touches only what
|
|
14
|
+
# differs, so a bar's width eases rather than jumps.
|
|
15
|
+
#
|
|
16
|
+
# With a `store`, the latest frame is also written under `key`, for a page to draw the tree
|
|
17
|
+
# before the first broadcast reaches it - see TurboHelper#task_progress_tree
|
|
18
|
+
class TurboStreamOutput
|
|
19
|
+
# @rbs!
|
|
20
|
+
# interface _Renderer
|
|
21
|
+
# def render: (State snapshot, ?path from) -> String
|
|
22
|
+
# def id: () -> String
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# interface _Store
|
|
26
|
+
# def read: (String key) -> untyped
|
|
27
|
+
# def write: (String key, untyped value, **untyped) -> void
|
|
28
|
+
# def delete: (String key) -> void
|
|
29
|
+
# end
|
|
30
|
+
|
|
31
|
+
# The store key the latest frame is written under
|
|
32
|
+
attr_reader :key #: String
|
|
33
|
+
|
|
34
|
+
# @rbs @renderer: _Renderer
|
|
35
|
+
# @rbs @to: ^(String) -> void
|
|
36
|
+
# @rbs @store: _Store?
|
|
37
|
+
|
|
38
|
+
# Draws with `renderer`, and hands each frame - a `<turbo-stream>` element - to `to`
|
|
39
|
+
#: (renderer: _Renderer, to: ^(String) -> void, ?store: _Store?, ?key: String) -> void
|
|
40
|
+
def initialize(renderer:, to:, store: nil, key: "task_progress/#{renderer.id}")
|
|
41
|
+
@renderer = renderer
|
|
42
|
+
@to = to
|
|
43
|
+
@store = store
|
|
44
|
+
@key = key
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Draws the tree as it stands - the batch only says it changed
|
|
48
|
+
#: (Batch, Log) -> void
|
|
49
|
+
def call(_batch, log)
|
|
50
|
+
html = @renderer.render(log.snapshot)
|
|
51
|
+
@store&.write(@key, html)
|
|
52
|
+
@to.call(TurboStreamOutput.frame(@renderer.id, html))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A Turbo Stream that replaces the element `target` with `html`, morphing it
|
|
56
|
+
#: (String, String) -> String
|
|
57
|
+
def self.frame(target, html)
|
|
58
|
+
%(<turbo-stream action="replace" method="morph" target="#{CGI.escapeHTML(target)}"><template>#{html}</template></turbo-stream>)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|