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,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
class TaskProgress
|
|
8
|
+
# A Rack app that takes the cancel buttons HTMLRenderer draws, for a tree that lives in another
|
|
9
|
+
# process - a job broadcasting with TaskProgress.broadcast. It cannot cancel anything itself:
|
|
10
|
+
# it checks the request's token, and leaves the request in the store the job watches. The job
|
|
11
|
+
# decides, as it does for any request (see TaskProgress#request_cancel), and the cancel comes
|
|
12
|
+
# back in its next frame.
|
|
13
|
+
#
|
|
14
|
+
# A Turbo form submission is answered with an empty Turbo Stream, which changes nothing on the
|
|
15
|
+
# page - the frame that follows does. Anything else gets 202 and a little JSON
|
|
16
|
+
class CancellationsApp
|
|
17
|
+
# How long a request waits for its run to take it up
|
|
18
|
+
REQUEST_TTL = 600 #: Integer
|
|
19
|
+
# @rbs @store: TurboStreamOutput::_Store
|
|
20
|
+
# @rbs @tokens: Token
|
|
21
|
+
|
|
22
|
+
# Leaves requests in `store`, for tokens `tokens` issued
|
|
23
|
+
#: (store: TurboStreamOutput::_Store, tokens: Token) -> void
|
|
24
|
+
def initialize(store:, tokens:)
|
|
25
|
+
@store = store
|
|
26
|
+
@tokens = tokens
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Takes a POST with `path` - the task's ids joined with a dash - and `token`, as a form or JSON
|
|
30
|
+
#: (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
|
|
31
|
+
def call(env)
|
|
32
|
+
return respond(405, {error: "POST a cancellation"}) unless env["REQUEST_METHOD"] == "POST"
|
|
33
|
+
params = CancellationsApp.params(env)
|
|
34
|
+
key = @tokens.verify(params["token"]&.to_s) or return respond(403, {error: "invalid or expired token"})
|
|
35
|
+
path = params["path"].to_s.split("-")
|
|
36
|
+
return respond(422, {error: "malformed path"}) unless path.length <= Wire::MAX_DEPTH && path.all? { it.match?(Wire::SEGMENT) }
|
|
37
|
+
# A request nobody takes up - the run ended first - is forgotten after a while
|
|
38
|
+
@store.write(CancellationsApp.request_key(key, path), "Canceled from the browser", expires_in: REQUEST_TTL)
|
|
39
|
+
return [200, {"content-type" => "text/vnd.turbo-stream.html"}, [""]] if env["HTTP_ACCEPT"].to_s.include?("turbo-stream")
|
|
40
|
+
respond(202, {path: path.join("-"), state: "requested"})
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Where a request to cancel the task at `path` waits, for the tree stored under `key`
|
|
44
|
+
#: (String, path) -> String
|
|
45
|
+
def self.request_key(key, path) = "#{key}/cancel/#{path.join("-")}"
|
|
46
|
+
|
|
47
|
+
# The request's parameters, from a form or JSON
|
|
48
|
+
#: (Hash[String, untyped]) -> Hash[String, untyped]
|
|
49
|
+
def self.params(env) # :nodoc:
|
|
50
|
+
# Mounted in Rails, a middleware (Rack::MethodOverride, looking for `_method`) has read a
|
|
51
|
+
# form's body already - and left it parsed here
|
|
52
|
+
form = env["rack.request.form_hash"]
|
|
53
|
+
return form if form.is_a?(Hash)
|
|
54
|
+
input = env["rack.input"]
|
|
55
|
+
input.rewind if input.respond_to?(:rewind)
|
|
56
|
+
body = input&.read(4096).to_s
|
|
57
|
+
if env["CONTENT_TYPE"].to_s.include?("json")
|
|
58
|
+
parsed = begin
|
|
59
|
+
JSON.parse(body)
|
|
60
|
+
rescue
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
64
|
+
else
|
|
65
|
+
URI.decode_www_form(body).to_h
|
|
66
|
+
end
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
{}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
#: (Integer, Hash[Symbol, untyped]) -> [Integer, Hash[String, String], Array[String]]
|
|
74
|
+
def respond(status, body) = [status, {"content-type" => "application/json"}, [JSON.generate(body)]]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# One change to one node. `path` leads to the node by its ancestors' ids - `[]` is the root,
|
|
6
|
+
# `["x3k0", "00af"]` a child of the root's child "x3k0". Each id is four random base36
|
|
7
|
+
# characters, unique among its siblings and never the task's name: names repeat, and they
|
|
8
|
+
# may say more than should leave the process
|
|
9
|
+
#
|
|
10
|
+
# The ops:
|
|
11
|
+
# open {name, total, weight, expect, indeterminate, unit} - a new node
|
|
12
|
+
# meta any of open's keys, and `stale` - attributes changed after the fact
|
|
13
|
+
# set a percent, 0..100
|
|
14
|
+
# add a delta to the node's count
|
|
15
|
+
# note a message
|
|
16
|
+
# done nil
|
|
17
|
+
# fail an error hash, see Wire.error_to_h
|
|
18
|
+
# cancel a reason, or nil - the work was called off, and did not fail
|
|
19
|
+
# prune nil - drop every descendant of the node
|
|
20
|
+
# put a whole node, see Node#to_h - what a squashed log is made of
|
|
21
|
+
# say a line to print, which changes nothing in the tree - so a squash drops it, the
|
|
22
|
+
# way a terminal's scrollback is gone once a program exits
|
|
23
|
+
# @rbs skip
|
|
24
|
+
Event = Data.define(:path, :op, :value)
|
|
25
|
+
|
|
26
|
+
# @rbs!
|
|
27
|
+
# class Event < Data
|
|
28
|
+
# attr_reader path: path
|
|
29
|
+
# attr_reader op: Symbol
|
|
30
|
+
# attr_reader value: untyped
|
|
31
|
+
# def self.new: (path: path, op: Symbol, ?value: untyped) -> instance
|
|
32
|
+
# def with: (?path: path, ?op: Symbol, ?value: untyped) -> instance
|
|
33
|
+
# end
|
|
34
|
+
|
|
35
|
+
class Event
|
|
36
|
+
# An event for the node at `path` - its path is frozen, so the event can be shared
|
|
37
|
+
#: (path: path, op: Symbol, ?value: untyped) -> void
|
|
38
|
+
def initialize(path:, op:, value: nil)
|
|
39
|
+
# RBS has no keywords for Data#initialize, which takes them all the same
|
|
40
|
+
super(path: path.frozen? ? path : path.dup.freeze, op:, value:) # steep:ignore
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Every op there is - see Event
|
|
45
|
+
OPS = %i[open meta set add note done fail cancel prune put say].freeze #: Array[Symbol]
|
|
46
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# For a CI log, or anything else that is read top to bottom after the fact: one finished
|
|
6
|
+
# line per thing worth saying, never a redraw, each line led by the overall percent and
|
|
7
|
+
# the time since the root opened.
|
|
8
|
+
#
|
|
9
|
+
# [ 42% 1m02s] Import › Rows: row 12000
|
|
10
|
+
# [ 50% 1m10s] ✓ Import › Rows 8.1s
|
|
11
|
+
# [ 50% 1m11s] ✗ Import › Engine: watermarking failed
|
|
12
|
+
# [ 50% 1m12s] ⊘ Import › Publish: Ctrl-C
|
|
13
|
+
#
|
|
14
|
+
# A task finishing, failing or being canceled, and a line it `puts`, go out at once and every
|
|
15
|
+
# time - though of a task and the ones under it ending in one batch, only the task says so. The
|
|
16
|
+
# rest - notes and counts - are only ever the latest news, so it says at most one of those
|
|
17
|
+
# per `interval`, the newest. With nothing to say for `heartbeat` it says it is still
|
|
18
|
+
# going, so a CI runner watching for output does not take a long quiet step for a hang
|
|
19
|
+
class ExecutionLogOutput
|
|
20
|
+
# @rbs @io: IO | StringIO
|
|
21
|
+
# @rbs @interval: Integer | Float
|
|
22
|
+
# @rbs @heartbeat: Integer | Float
|
|
23
|
+
# @rbs @clock: ^() -> Float
|
|
24
|
+
# @rbs @lock: Thread::Mutex
|
|
25
|
+
# @rbs @wake: Thread::ConditionVariable
|
|
26
|
+
# @rbs @log: Log?
|
|
27
|
+
# @rbs @pending: [path, String?]?
|
|
28
|
+
# @rbs @said_at: Float
|
|
29
|
+
# @rbs @closed: bool
|
|
30
|
+
# @rbs @thread: Thread?
|
|
31
|
+
# @rbs @ended: Set[path]
|
|
32
|
+
|
|
33
|
+
# Writes to `io`: news at most every `interval` seconds, and that it is still going after `heartbeat` seconds of quiet
|
|
34
|
+
#: (?IO | StringIO, ?interval: Integer | Float, ?heartbeat: Integer | Float, ?clock: ^() -> Float) -> void
|
|
35
|
+
def initialize(io = $stderr, interval: 3.0, heartbeat: 60.0, clock: -> { Process.clock_gettime(Process::CLOCK_REALTIME) })
|
|
36
|
+
@io = io
|
|
37
|
+
@interval = interval
|
|
38
|
+
@heartbeat = heartbeat
|
|
39
|
+
@clock = clock
|
|
40
|
+
@lock = Mutex.new
|
|
41
|
+
@wake = ConditionVariable.new
|
|
42
|
+
@log = nil
|
|
43
|
+
@pending = nil
|
|
44
|
+
@said_at = -Float::INFINITY
|
|
45
|
+
@closed = false
|
|
46
|
+
@thread = nil
|
|
47
|
+
@ended = Set.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Takes one batch, see Log#add_output
|
|
51
|
+
#: (Batch, Log) -> void
|
|
52
|
+
def call(batch, log)
|
|
53
|
+
@lock.synchronize do
|
|
54
|
+
return if @closed
|
|
55
|
+
@log = log
|
|
56
|
+
@said_at = @clock.call - @interval if @said_at.infinite? # the first news goes out at once
|
|
57
|
+
snapshot = log.snapshot
|
|
58
|
+
events = batch.events.map { ending_put(it) || it }
|
|
59
|
+
endings = events.filter_map { it.path if ENDINGS.include?(it.op) }
|
|
60
|
+
events.each do |event|
|
|
61
|
+
case event.op
|
|
62
|
+
when *ENDINGS
|
|
63
|
+
# News from inside a task that has ended is old news
|
|
64
|
+
@pending = nil if @pending&.first&.take(event.path.length) == event.path
|
|
65
|
+
next unless @ended.add?(event.path)
|
|
66
|
+
next if endings.any? { it.length < event.path.length && event.path.take(it.length) == it }
|
|
67
|
+
say(snapshot, ending(snapshot, event))
|
|
68
|
+
when :say then say(snapshot, "#{names(snapshot, event.path)}: #{event.value}")
|
|
69
|
+
when :note then @pending = [event.path, event.value] if event.value
|
|
70
|
+
when :set, :add then @pending = [event.path, nil] unless @pending&.first == event.path
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
# Older news than the ending. Judged by what came, never the snapshot, which can run ahead
|
|
74
|
+
# of delivery - and close before the batch that ends the root has been said
|
|
75
|
+
return close_locked(flush: false) if @ended.include?([])
|
|
76
|
+
say_pending if @pending && @clock.call - @said_at >= @interval
|
|
77
|
+
@thread ||= Thread.new { run }
|
|
78
|
+
@wake.signal
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Says what is still pending, and stops
|
|
83
|
+
#: () -> void
|
|
84
|
+
def close
|
|
85
|
+
@lock.synchronize { close_locked }
|
|
86
|
+
@thread&.join
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
#: () -> void
|
|
92
|
+
def run
|
|
93
|
+
@lock.synchronize do
|
|
94
|
+
until @closed
|
|
95
|
+
due = @pending ? @said_at + @interval : @said_at + @heartbeat
|
|
96
|
+
wait = due - @clock.call
|
|
97
|
+
next @wake.wait(@lock, wait) if wait.positive?
|
|
98
|
+
next say_pending if @pending
|
|
99
|
+
snapshot = @log&.snapshot or next
|
|
100
|
+
say(snapshot, "#{snapshot[[]]&.name}: still going")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
#: (?flush: bool) -> void
|
|
106
|
+
def close_locked(flush: true)
|
|
107
|
+
return if @closed
|
|
108
|
+
say_pending if flush && @pending && @log
|
|
109
|
+
@closed = true
|
|
110
|
+
@wake.signal
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#: () -> void
|
|
114
|
+
def say_pending
|
|
115
|
+
pending = @pending or return
|
|
116
|
+
path, message = pending
|
|
117
|
+
@pending = nil
|
|
118
|
+
snapshot = @log&.snapshot or return
|
|
119
|
+
node = snapshot[path] or return
|
|
120
|
+
say(snapshot, message ? "#{names(snapshot, path)}: #{message}" : "#{names(snapshot, path)}#{count(node)}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
#: (State, String) -> void
|
|
124
|
+
def say(snapshot, text)
|
|
125
|
+
root = snapshot[[]]
|
|
126
|
+
elapsed = root ? @clock.call - root.opened_at : 0
|
|
127
|
+
@io.puts(format("[%3d%% %6s] %s", snapshot.percent, TaskProgress.duration(elapsed), text))
|
|
128
|
+
@io.flush
|
|
129
|
+
@said_at = @clock.call
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
ENDINGS = %i[done fail cancel].freeze #: Array[Symbol]
|
|
133
|
+
PUT_ENDINGS = {done: :done, failed: :fail, canceled: :cancel}.freeze #: Hash[Symbol, Symbol]
|
|
134
|
+
private_constant :ENDINGS, :PUT_ENDINGS
|
|
135
|
+
|
|
136
|
+
# A squashed batch - from BufferedOutput, a stream, a mount catching up - says a task ended by
|
|
137
|
+
# putting it whole, finished. Said once, like the ending it stands for
|
|
138
|
+
#: (Event) -> Event?
|
|
139
|
+
def ending_put(event)
|
|
140
|
+
op = PUT_ENDINGS[event.value[:status]] if event.op == :put
|
|
141
|
+
return unless op && !@ended.include?(event.path)
|
|
142
|
+
Event.new(path: event.path, op:, value: if op == :fail
|
|
143
|
+
event.value[:error]
|
|
144
|
+
else
|
|
145
|
+
((op == :cancel) ? event.value[:message] : nil)
|
|
146
|
+
end)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
#: (State, Event) -> String
|
|
150
|
+
def ending(snapshot, event)
|
|
151
|
+
names = names(snapshot, event.path)
|
|
152
|
+
case event.op
|
|
153
|
+
when :done then "✓ #{names}#{took(snapshot[event.path])}"
|
|
154
|
+
when :fail then "✗ #{names}: #{event.value&.dig(:message)}"
|
|
155
|
+
else "⊘ #{names}#{": #{event.value}" if event.value}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The task's name, after the names of every task it is under
|
|
160
|
+
#: (State, path) -> String
|
|
161
|
+
def names(snapshot, path) = (0..path.length).filter_map { snapshot[path.take(it)]&.name }.join(" › ")
|
|
162
|
+
|
|
163
|
+
#: (Node) -> String
|
|
164
|
+
def count(node)
|
|
165
|
+
total = node.total
|
|
166
|
+
return "" unless total || node.count.positive?
|
|
167
|
+
bytes = node.unit == "bytes"
|
|
168
|
+
shown = bytes ? TaskProgress.human_bytes(node.count) : node.count.to_s
|
|
169
|
+
total = (bytes ? TaskProgress.human_bytes(total) : total.to_s) if total
|
|
170
|
+
" #{[shown, total].compact.join("/")}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A task that reported nothing before it finished started when it finished, as far as the
|
|
174
|
+
# tree knows - so it took as long as it was open
|
|
175
|
+
#: (Node?) -> String
|
|
176
|
+
def took(node)
|
|
177
|
+
return "" unless node && (finished = node.finished_at)
|
|
178
|
+
started = node.started_at
|
|
179
|
+
from = (started && started < finished) ? started : node.opened_at
|
|
180
|
+
" #{TaskProgress.duration(finished - from)}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# Siblings drawn as one row, see below
|
|
6
|
+
Fold = Data.define(
|
|
7
|
+
:status, #: Symbol
|
|
8
|
+
:count #: Integer
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# Siblings drawn as one row - "✓ 9,812 more done", "· 340 more waiting" - so a task with ten
|
|
12
|
+
# thousand children draws as a handful of rows. See State#rows, which makes them
|
|
13
|
+
class Fold
|
|
14
|
+
# What the row says
|
|
15
|
+
#: () -> String
|
|
16
|
+
def name = "#{count.to_s.gsub(/\B(?=(\d{3})+(?!\d))/, ",")} more #{(status == :done) ? "done" : "waiting"}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "cgi/escape"
|
|
5
|
+
|
|
6
|
+
class TaskProgress
|
|
7
|
+
# Draws a tree as HTML - the same rows, with the same classes, as the browser client's renderers
|
|
8
|
+
# draw, so `task_tree.css` styles both and a page can move from one to the other unchanged.
|
|
9
|
+
# This is what TurboStreamOutput sends, and what TurboSSE streams.
|
|
10
|
+
#
|
|
11
|
+
# Elapsed times are `<task-progress-clock>` elements (`task_progress_clock.js`), which tick on
|
|
12
|
+
# between frames; everything else is as it was when the tree was drawn. A cancelable task gets
|
|
13
|
+
# a cancel button that is a plain form, posting the task's path and `cancel_token` to
|
|
14
|
+
# `cancel_url` - so it needs no JavaScript, and Turbo submits it without leaving the page.
|
|
15
|
+
#
|
|
16
|
+
# To draw rows your own way, subclass it and override `row`, or hand TurboStreamOutput anything
|
|
17
|
+
# that answers `id` and `render(snapshot, from = [])` - say, an object that renders a partial
|
|
18
|
+
class HTMLRenderer
|
|
19
|
+
# A circle with a cross in it, drawn in the row's text color - the same in every font
|
|
20
|
+
CANCEL_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="8" cy="8" r="6.25" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' #: String
|
|
21
|
+
|
|
22
|
+
GLYPHS = {done: "✓", failed: "✗", canceled: "⊘", pending: "·"}.freeze #: Hash[Symbol, String]
|
|
23
|
+
private_constant :GLYPHS
|
|
24
|
+
|
|
25
|
+
# The id of the element the tree is drawn in - what a Turbo Stream targets
|
|
26
|
+
attr_reader :id #: String
|
|
27
|
+
|
|
28
|
+
# @rbs @cancel_url: String?
|
|
29
|
+
# @rbs @cancel_token: String?
|
|
30
|
+
# @rbs @clock: ^() -> Float
|
|
31
|
+
|
|
32
|
+
# Draws into an element with the id `id`. Without a `cancel_url`, no task offers a cancel
|
|
33
|
+
# button. `clock` is for tests to pin down
|
|
34
|
+
#: (id: String, ?cancel_url: String?, ?cancel_token: String?, ?clock: ^() -> Float) -> void
|
|
35
|
+
def initialize(id:, cancel_url: nil, cancel_token: nil, clock: -> { Process.clock_gettime(Process::CLOCK_REALTIME) })
|
|
36
|
+
@id = id
|
|
37
|
+
@cancel_url = cancel_url
|
|
38
|
+
@cancel_token = cancel_token
|
|
39
|
+
@clock = clock
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The whole tree in its element - or the subtree at `from`, drawn as a tree of its own
|
|
43
|
+
#: (State, ?path) -> String
|
|
44
|
+
def render(snapshot, from = [])
|
|
45
|
+
rows = snapshot.rows(from).map do |path, node|
|
|
46
|
+
depth = path.length - from.length
|
|
47
|
+
node.is_a?(Fold) ? fold(path, node, depth:) : row(snapshot, path, node, depth:)
|
|
48
|
+
end
|
|
49
|
+
%(<div id="#{h(@id)}" class="tree" data-task-progress-at="#{@clock.call}">#{rows.join}</div>)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One task's row, `depth` levels in
|
|
53
|
+
#: (State, path, Node, ?depth: Integer) -> String
|
|
54
|
+
def row(snapshot, path, node, depth: path.length)
|
|
55
|
+
status = (node.status == :done && !snapshot.complete?(path)) ? :active : node.status
|
|
56
|
+
glyph = GLYPHS[status]
|
|
57
|
+
icon = glyph ? "<span>#{glyph}</span>" : '<span class="spin"></span>'
|
|
58
|
+
indeterminate = !!(node.children.empty? && node.indeterminate && node.status == :active)
|
|
59
|
+
percent = snapshot.percent(path)
|
|
60
|
+
classes = ["row", status, ("stale" if node.stale)].compact.join(" ")
|
|
61
|
+
<<~HTML.delete("\n")
|
|
62
|
+
<div class="#{classes}" id="#{h(@id)}-#{h(path.empty? ? "root" : path.join("-"))}" data-path="#{h(path.join("-"))}">
|
|
63
|
+
<div class="label" style="padding-left: #{depth * 18}px">
|
|
64
|
+
<span class="icon">#{icon}</span><span class="name">#{h(node.name)}</span><span class="message">#{h(message(node))}</span>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="bar#{" indeterminate" if indeterminate}"><span style="width: #{percent}%"></span></div>
|
|
67
|
+
#{cancel(path, node)}
|
|
68
|
+
<div class="measure">#{measure(path, node, percent, indeterminate)}</div>
|
|
69
|
+
</div>
|
|
70
|
+
HTML
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Siblings folded into one row, see State#rows - drawn as a row like any other, so the
|
|
74
|
+
# columns line up
|
|
75
|
+
#: (path, Fold, ?depth: Integer) -> String
|
|
76
|
+
def fold(path, fold, depth: path.length)
|
|
77
|
+
done = fold.status == :done
|
|
78
|
+
<<~HTML.delete("\n")
|
|
79
|
+
<div class="row folded #{done ? "done" : "pending"}" id="#{h(@id)}-#{h(path.join("-"))}" data-path="#{h(path.join("-"))}">
|
|
80
|
+
<div class="label" style="padding-left: #{depth * 18}px">
|
|
81
|
+
<span class="icon"><span>#{GLYPHS[done ? :done : :pending]}</span></span><span class="name">#{h(fold.name)}</span><span class="message"></span>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="bar"><span style="width: #{done ? 100 : 0}%"></span></div>
|
|
84
|
+
<span class="cancel-slot"></span>
|
|
85
|
+
<div class="measure"></div>
|
|
86
|
+
</div>
|
|
87
|
+
HTML
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
#: (String?) -> String
|
|
93
|
+
def h(text) = CGI.escapeHTML(text.to_s)
|
|
94
|
+
|
|
95
|
+
#: (Node) -> String
|
|
96
|
+
def message(node)
|
|
97
|
+
said = (node.status == :failed) ? node.error&.dig(:message) : [pace(node), node.message].compact.reject(&:empty?).join(" · ")
|
|
98
|
+
[("attempt #{node.attempt}" if node.attempt.to_i > 1), said].compact.reject(&:empty?).join(" · ")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The rate over the run so far, and the time left at it
|
|
102
|
+
#: (Node) -> String?
|
|
103
|
+
def pace(node)
|
|
104
|
+
started = node.started_at
|
|
105
|
+
return nil unless node.status == :active && node.children.empty? && started && node.count.positive?
|
|
106
|
+
seconds = @clock.call - started
|
|
107
|
+
return nil if seconds < 1
|
|
108
|
+
rate = node.count / seconds
|
|
109
|
+
per = if node.unit == "bytes"
|
|
110
|
+
"#{TaskProgress.human_bytes(rate)}/s"
|
|
111
|
+
else
|
|
112
|
+
((rate < 10) ? format("%.1f/s", rate) : "#{rate.floor}/s")
|
|
113
|
+
end
|
|
114
|
+
total = node.total
|
|
115
|
+
return per unless total && total > node.count
|
|
116
|
+
"#{per}, #{TaskProgress.duration((total - node.count) / rate)} left"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (path, Node, Integer, bool) -> String
|
|
120
|
+
def measure(path, node, percent, indeterminate)
|
|
121
|
+
total = node.total
|
|
122
|
+
if node.children.empty? && total
|
|
123
|
+
(node.unit == "bytes") ? "#{TaskProgress.human_bytes(node.count)} / #{TaskProgress.human_bytes(total)}" : "#{node.count} / #{total}"
|
|
124
|
+
elsif indeterminate
|
|
125
|
+
clock(node)
|
|
126
|
+
else
|
|
127
|
+
"#{percent}%"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Ticks on in the browser between frames, from the time the tree was drawn
|
|
132
|
+
#: (Node) -> String
|
|
133
|
+
def clock(node)
|
|
134
|
+
started = node.started_at or return ""
|
|
135
|
+
seconds = [(node.finished_at || @clock.call) - started, 0].max
|
|
136
|
+
ended = %( finished="#{node.finished_at}") if node.finished_at
|
|
137
|
+
%(<task-progress-clock started="#{started}" drawn="#{@clock.call}"#{ended}>#{format("%.1fs", seconds)}</task-progress-clock>)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The cancel column is there on every row, so the bars line up
|
|
141
|
+
#: (path, Node) -> String
|
|
142
|
+
def cancel(path, node)
|
|
143
|
+
return '<span class="cancel-slot"></span>' unless @cancel_url && node.cancelable && !node.finished?
|
|
144
|
+
<<~HTML.delete("\n")
|
|
145
|
+
<form class="cancel-slot" action="#{h(@cancel_url)}" method="post" data-turbo="true">
|
|
146
|
+
<input type="hidden" name="path" value="#{h(path.join("-"))}">
|
|
147
|
+
<input type="hidden" name="token" value="#{h(@cancel_token)}">
|
|
148
|
+
<button type="submit" class="cancel" aria-label="Cancel #{h(node.name)}">#{CANCEL_ICON}</button>
|
|
149
|
+
</form>
|
|
150
|
+
HTML
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ActiveJob's own serializers name Duration, DateTime and the rest as they load, and only Rails
|
|
4
|
+
# loads those first - so outside Rails all of ActiveSupport has to come along
|
|
5
|
+
require "active_support/all"
|
|
6
|
+
require "active_job"
|
|
7
|
+
|
|
8
|
+
class TaskProgress
|
|
9
|
+
# Lets a tree from TaskProgress.handoff go to an ActiveJob as an argument like any other - no
|
|
10
|
+
# ids to pass, nothing to look up:
|
|
11
|
+
#
|
|
12
|
+
# progress = TaskProgress.handoff("Importing", cancelable: true)
|
|
13
|
+
# ImportJob.perform_later(csv_import, progress)
|
|
14
|
+
#
|
|
15
|
+
# class ImportJob < ApplicationJob
|
|
16
|
+
# def perform(csv_import, progress)
|
|
17
|
+
# progress.subtask("Rows", total: csv_import.rows.size) { |rows| ... }
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# It goes into the queue as its key. Out of the queue it comes adopted - see TaskProgress.adopt -
|
|
22
|
+
# and the job's work is the root's: done when `perform` returns, failed when it raises, and a
|
|
23
|
+
# cancel stops it without failing the job. A retry adopts it anew, as attempt 2, and starts
|
|
24
|
+
# it over. The Railtie installs all of this; without Rails, call `install` once ActiveJob loads.
|
|
25
|
+
#
|
|
26
|
+
# Only a root that was handed off can go: a task that lives in this process alone has nowhere
|
|
27
|
+
# for a worker elsewhere to write to
|
|
28
|
+
class JobSerializer < ActiveJob::Serializers::ObjectSerializer
|
|
29
|
+
# Teaches ActiveJob to take trees as arguments, and runs every job that gets one as the tree's work
|
|
30
|
+
def self.install
|
|
31
|
+
ActiveJob::Serializers.add_serializers(self)
|
|
32
|
+
ActiveJob::Base.prepend(Performing)
|
|
33
|
+
ActiveJob::Base.around_perform do |job, perform|
|
|
34
|
+
TaskProgress.working_on(job.arguments) do
|
|
35
|
+
perform.call
|
|
36
|
+
# A `before_perform` that halts stops the chain inside this one: the work never ran
|
|
37
|
+
job.task_progress_halted!
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Arguments are taken up as they are deserialized, which is before `around_perform` - and a job
|
|
43
|
+
# can stop in between: an argument that cannot be found, a `before_perform` that halts. Every
|
|
44
|
+
# tree taken up for a job is seen to the end once the job is through, whatever became of it,
|
|
45
|
+
# so none of them is left beating for work that is not being done
|
|
46
|
+
module Performing
|
|
47
|
+
# A callback halted the job - ActiveSupport says so through this hook
|
|
48
|
+
def halted_callback_hook(*)
|
|
49
|
+
@task_progress_halted = true
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Fails the trees the job took up, if a callback halted it before its work
|
|
54
|
+
def task_progress_halted!
|
|
55
|
+
return unless @task_progress_halted
|
|
56
|
+
arguments.flat_map { it.is_a?(Hash) ? it.values : [it] }.grep(TaskProgress).each do |root|
|
|
57
|
+
root.fail!("The job stopped before it began its work") unless root.finished?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Runs the job, and ends what it took up and did not end
|
|
62
|
+
def perform_now
|
|
63
|
+
(Thread.current[:task_progress_adopted] ||= []).push([])
|
|
64
|
+
super
|
|
65
|
+
ensure
|
|
66
|
+
adopted = Thread.current[:task_progress_adopted]&.pop || []
|
|
67
|
+
adopted.each do |root|
|
|
68
|
+
root.fail!($! || "The job stopped before it began its work") unless root.finished?
|
|
69
|
+
root.log.close
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whether it is a TaskProgress
|
|
75
|
+
def serialize?(argument) = argument.is_a?(TaskProgress)
|
|
76
|
+
|
|
77
|
+
# Its key, and nothing else
|
|
78
|
+
def serialize(task)
|
|
79
|
+
key = task.key or raise ActiveJob::SerializationError,
|
|
80
|
+
"#{task.inspect} lives in this process alone - hand a tree to a job with TaskProgress.handoff"
|
|
81
|
+
super("key" => key)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The tree at the key, adopted
|
|
85
|
+
def deserialize(hash)
|
|
86
|
+
TaskProgress.adopt(hash.fetch("key")).tap { Thread.current[:task_progress_adopted]&.last&.push(it) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# What it serializes
|
|
90
|
+
def klass = TaskProgress
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# The changes one transaction made, so that it can be undone whole
|
|
6
|
+
class Journal # :nodoc:
|
|
7
|
+
# @rbs @saved: Hash[path, [Node, Integer]?]
|
|
8
|
+
|
|
9
|
+
#: () -> void
|
|
10
|
+
def initialize = @saved = {}
|
|
11
|
+
|
|
12
|
+
# Remembers the node as it was before the transaction first touched it - nil for one it opened.
|
|
13
|
+
# A shallow copy, and how many children it had: a transaction only ever adds children at the
|
|
14
|
+
# end, or swaps the list for a new one, so that is enough to put the list back - and a parent
|
|
15
|
+
# of a hundred thousand tasks is not copied whole every time one of them moves
|
|
16
|
+
#: (path, Node?) -> void
|
|
17
|
+
def touch(path, node)
|
|
18
|
+
@saved[path] = (node && [node.dup, node.children.length]) unless @saved.key?(path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Every path it touched, opened or removed
|
|
22
|
+
#: () -> Array[path]
|
|
23
|
+
def paths = @saved.keys
|
|
24
|
+
|
|
25
|
+
#: (Hash[path, Node]) -> void
|
|
26
|
+
def rollback(nodes)
|
|
27
|
+
@saved.each do |path, saved|
|
|
28
|
+
next nodes.delete(path) unless saved
|
|
29
|
+
node, length = saved
|
|
30
|
+
node.children.slice!(length..)
|
|
31
|
+
nodes[path] = node
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|