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,448 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
class TaskProgress
|
|
8
|
+
# Grafts a tree that lives elsewhere under one node of this one. The remote root lands on the
|
|
9
|
+
# mount node itself and its `["x3k0"]` on `[*mount, "x3k0"]`, so mounting is prefixing, and
|
|
10
|
+
# a relay through several processes prefixes once per hop.
|
|
11
|
+
#
|
|
12
|
+
# The mount node is sealed: only its Mount writes under it, so a remote can never touch a
|
|
13
|
+
# node outside its prefix - nor rename or reweigh the mount, which stay the parent's to set.
|
|
14
|
+
# Each remote batch is re-committed here as one batch, or dropped whole if it does not apply.
|
|
15
|
+
# A batch that does not follow the last one (a gap) marks the subtree stale, and it stays so
|
|
16
|
+
# until a batch with `prev` 0 replaces it whole.
|
|
17
|
+
#
|
|
18
|
+
# Fed from a stream (see #follow_stream) it goes by the latest writer instead: a batch with
|
|
19
|
+
# `prev` 0 from a log it has not seen is a new run - a job retried, a worker that took over -
|
|
20
|
+
# and replaces the subtree whole, even one that had finished, while what the old writer still
|
|
21
|
+
# sends is ignored
|
|
22
|
+
class Mount
|
|
23
|
+
# The log the mount node lives in
|
|
24
|
+
attr_reader :log #: Log
|
|
25
|
+
# The mount node
|
|
26
|
+
attr_reader :path #: path
|
|
27
|
+
|
|
28
|
+
# @rbs @control: IO?
|
|
29
|
+
# @rbs @last_seq: Integer?
|
|
30
|
+
# @rbs @control_lock: Thread::Mutex
|
|
31
|
+
# @rbs @pid_lock: Thread::Mutex
|
|
32
|
+
# @rbs @source: String?
|
|
33
|
+
# @rbs @stale: bool
|
|
34
|
+
# @rbs @remote_ended: bool
|
|
35
|
+
# @rbs @controlled: bool
|
|
36
|
+
# @rbs @reaped: bool
|
|
37
|
+
# @rbs @own: bool
|
|
38
|
+
# @rbs @following: bool
|
|
39
|
+
# @rbs @forward: (^(String) -> void)?
|
|
40
|
+
# @rbs @reader: StreamReader?
|
|
41
|
+
# @rbs @follower: Thread?
|
|
42
|
+
# @rbs @stopping: bool
|
|
43
|
+
# @rbs @silent: bool
|
|
44
|
+
# @rbs @gave_up: bool
|
|
45
|
+
# @rbs @generation: Integer
|
|
46
|
+
|
|
47
|
+
# For the node at `path` - see TaskProgress#mount, which opens one. Unless `own`, the
|
|
48
|
+
# remote's root names and weighs the mount node too - for a tree that is nothing but a mount,
|
|
49
|
+
# as TaskProgress.follow makes
|
|
50
|
+
#: (Log, path, ?own: bool) -> void
|
|
51
|
+
def initialize(log, path, own: true)
|
|
52
|
+
@log = log
|
|
53
|
+
@path = path
|
|
54
|
+
@own = own
|
|
55
|
+
@following = false
|
|
56
|
+
@forward = nil
|
|
57
|
+
@reader = nil
|
|
58
|
+
@follower = nil
|
|
59
|
+
@stopping = false
|
|
60
|
+
@silent = false
|
|
61
|
+
@gave_up = false
|
|
62
|
+
@generation = 0
|
|
63
|
+
@control = nil
|
|
64
|
+
@control_lock = Mutex.new
|
|
65
|
+
@pid_lock = Mutex.new
|
|
66
|
+
@source = nil
|
|
67
|
+
@last_seq = nil
|
|
68
|
+
@stale = false
|
|
69
|
+
@remote_ended = false
|
|
70
|
+
@controlled = false
|
|
71
|
+
@reaped = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The mount node, as a task of this tree
|
|
75
|
+
#: () -> TaskProgress
|
|
76
|
+
def task = TaskProgress.handle(@log, @path)
|
|
77
|
+
|
|
78
|
+
# Whether a batch went missing, and the subtree waits for a fresh squash
|
|
79
|
+
#: () -> bool
|
|
80
|
+
def stale? = @stale
|
|
81
|
+
|
|
82
|
+
# Takes a Batch, its Hash form, or a line of NDJSON. Returns :applied, :gap, :rejected, or
|
|
83
|
+
# :duplicate for one already applied - redelivery is ordinary after a reconnect or a replay.
|
|
84
|
+
# Following a stream, a batch from a writer another has since replaced is :superseded
|
|
85
|
+
#: (Batch | Hash[untyped, untyped] | String) -> Symbol
|
|
86
|
+
def <<(batch)
|
|
87
|
+
batch = coerce(batch) or return :rejected
|
|
88
|
+
starting_over = false
|
|
89
|
+
# Judged and applied in one transaction: a cancel or another batch arriving at the same
|
|
90
|
+
# moment comes wholly before this one or wholly after it
|
|
91
|
+
# It happened when the remote committed it. A pipe or a port is fresh news, so its clock is
|
|
92
|
+
# moved onto ours by the gap between the two; a stream hands on what may have been written
|
|
93
|
+
# long ago, by a machine whose clock is taken to agree with ours
|
|
94
|
+
at = (@following && batch.at.positive?) ? batch.at : nil
|
|
95
|
+
outcome = @log.transaction(at:) do |tx|
|
|
96
|
+
node = tx.state[@path] or next :rejected
|
|
97
|
+
source = @source
|
|
98
|
+
last_seq = @last_seq
|
|
99
|
+
new_run = batch.reset? && !source.nil? && batch.log != source
|
|
100
|
+
# A run given up on as lost that turns out alive is taken back, from a fresh squash
|
|
101
|
+
starting_over = @following && batch.reset? && (new_run || @gave_up)
|
|
102
|
+
if node.finished? && !starting_over
|
|
103
|
+
# Ended here - what the remote sent on its way out changes nothing. A lost run that
|
|
104
|
+
# speaks again gets read afresh, see above
|
|
105
|
+
next (@following && @gave_up && batch.log == source) ? :gap : :rejected
|
|
106
|
+
end
|
|
107
|
+
if batch.log == source && last_seq && batch.seq <= last_seq && !starting_over
|
|
108
|
+
# Said again - after a reconnect, a replay, or a resync to a snapshot it has since passed
|
|
109
|
+
next :duplicate
|
|
110
|
+
elsif @following && !batch.reset? && batch.log != source
|
|
111
|
+
next :superseded
|
|
112
|
+
elsif !batch.reset? && (batch.log != source || batch.prev != last_seq)
|
|
113
|
+
mark_stale(tx)
|
|
114
|
+
next :gap
|
|
115
|
+
end
|
|
116
|
+
offset = (!@following && batch.at.positive?) ? tx.at - batch.at : 0.0
|
|
117
|
+
events = translate(batch.events, offset) or next :rejected
|
|
118
|
+
events.unshift(Event.new(path: @path, op: :prune)) if batch.reset?
|
|
119
|
+
events.unshift(Event.new(path: @path, op: :put, value: fresh(tx.state))) if new_run || starting_over
|
|
120
|
+
events.each { tx << it }
|
|
121
|
+
tx << Event.new(path: @path, op: :meta, value: {stale: false}) if @stale
|
|
122
|
+
# Known before the batch is delivered, so an output that sees the mount end can tell who ended it
|
|
123
|
+
@remote_ended = false if starting_over
|
|
124
|
+
@remote_ended ||= batch.events.any? { it.path.empty? && (ENDINGS.include?(it.op) || (it.op == :put && FINISHED.include?(it.value[:status]))) }
|
|
125
|
+
@source = batch.log
|
|
126
|
+
@last_seq = batch.seq
|
|
127
|
+
@stale = false
|
|
128
|
+
@silent = false
|
|
129
|
+
@gave_up = false
|
|
130
|
+
:applied
|
|
131
|
+
end
|
|
132
|
+
# The run before may have ended, and its cancel went with it
|
|
133
|
+
arm_cancel if outcome == :applied && starting_over
|
|
134
|
+
outcome
|
|
135
|
+
rescue ArgumentError
|
|
136
|
+
:rejected
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Reads NDJSON batches off an IO until EOF
|
|
140
|
+
#: (IO) -> self
|
|
141
|
+
def read(io)
|
|
142
|
+
Wire.each_batch(io) { self << it }
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Runs a command with TASK_PROGRESS_FD pointing at a pipe, follows it until it exits, and
|
|
147
|
+
# returns its Process::Status. A child that exits without finishing its root fails the mount.
|
|
148
|
+
#
|
|
149
|
+
# A second pipe, TASK_PROGRESS_CONTROL_FD, carries cancel requests the other way, so tasks in
|
|
150
|
+
# the child can be canceled from here - see #request_cancel; TaskProgress.cli reads it. When
|
|
151
|
+
# the mount itself is canceled, the child is asked to cancel its root, and is sent TERM if it
|
|
152
|
+
# is still running `grace` seconds later. Interrupted (Ctrl-C, a timeout), it terminates the
|
|
153
|
+
# child at once
|
|
154
|
+
#: (*untyped command, ?grace: Integer | Float, **untyped options) -> Process::Status?
|
|
155
|
+
def spawn(*command, grace: 5, **options)
|
|
156
|
+
reader, writer = IO.pipe
|
|
157
|
+
control_reader, @control = IO.pipe
|
|
158
|
+
env = command.first.is_a?(Hash) ? command.shift : {} #: Hash[String, String]
|
|
159
|
+
status = nil
|
|
160
|
+
@reaped = false
|
|
161
|
+
pid = Process.spawn({**env, "TASK_PROGRESS_FD" => "3", "TASK_PROGRESS_CONTROL_FD" => "4"}, *command,
|
|
162
|
+
3 => writer, 4 => control_reader, **options)
|
|
163
|
+
writer.close
|
|
164
|
+
control_reader.close
|
|
165
|
+
@controlled = true
|
|
166
|
+
task.on_cancel { stop(pid, grace) unless @remote_ended }
|
|
167
|
+
read(reader)
|
|
168
|
+
status = reap(pid)
|
|
169
|
+
fail!("#{File.basename(command.first.to_s)} exited with #{status.exitstatus || status.termsig}") if status && !status.success?
|
|
170
|
+
fail!("#{File.basename(command.first.to_s)} exited without finishing") unless finished?
|
|
171
|
+
status
|
|
172
|
+
rescue Interrupt => e
|
|
173
|
+
cancel!(TaskProgress.interrupted_by(e))
|
|
174
|
+
raise
|
|
175
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
176
|
+
fail!(e)
|
|
177
|
+
raise
|
|
178
|
+
ensure
|
|
179
|
+
@controlled = false
|
|
180
|
+
@control_lock.synchronize { @control&.close unless @control&.closed? }
|
|
181
|
+
reader&.close
|
|
182
|
+
writer&.close unless writer&.closed?
|
|
183
|
+
if pid && status.nil?
|
|
184
|
+
signal(pid, :TERM)
|
|
185
|
+
reap(pid)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Follows the tree a StreamOutput writes to `stream` at `key` - what TaskProgress.handoff and
|
|
190
|
+
# TaskProgress.adopt write - on a thread of its own, checking every `poll` seconds. Cancel
|
|
191
|
+
# requests go back to the writer through the stream, and canceling the mount asks the writer
|
|
192
|
+
# to cancel its root.
|
|
193
|
+
#
|
|
194
|
+
# It also minds whether the writer is alive. Once one has been, a writer that stops beating
|
|
195
|
+
# marks the subtree stale, and fails the mount if it stays silent for `give_up_after`
|
|
196
|
+
# seconds - unless a new run comes along later and replaces it, as a retried job does. A
|
|
197
|
+
# tree nobody has taken up yet is waiting, not silent. Following ends once the mount is done
|
|
198
|
+
# or canceled, or the log is closed - a failed one is kept an eye on, for a retry
|
|
199
|
+
#
|
|
200
|
+
# With `watch: false` it takes what is there once, and leaves catching up again to
|
|
201
|
+
# #catch_up - for a tree read once, see TaskProgress.fetch
|
|
202
|
+
#: (untyped, String, ?poll: Integer | Float, ?give_up_after: Integer | Float, ?watch: bool) -> self
|
|
203
|
+
def follow_stream(stream, key, poll: 1, give_up_after: 60, watch: true)
|
|
204
|
+
@following = true
|
|
205
|
+
@controlled = true
|
|
206
|
+
@reader = StreamReader.new(stream, key)
|
|
207
|
+
@forward = ->(line) { stream.append(StreamOutput.control_key(key), line) }
|
|
208
|
+
arm_cancel
|
|
209
|
+
catch_up
|
|
210
|
+
# Read once, a tree that is under way with nobody beating for it can only be a lost one
|
|
211
|
+
mark_stale if !watch && @log.snapshot[@path]&.status == :active && !@reader&.alive?
|
|
212
|
+
return self unless watch
|
|
213
|
+
@log.on_close { stop_following }
|
|
214
|
+
@follower = Thread.new do
|
|
215
|
+
Thread.current.report_on_exception = false
|
|
216
|
+
watch(poll, give_up_after)
|
|
217
|
+
end
|
|
218
|
+
self
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Takes what the stream has for it - waiting up to `timeout` seconds when it has nothing yet -
|
|
222
|
+
# and returns how many batches it applied. #follow_stream does this over and over
|
|
223
|
+
#: (?timeout: (Integer | Float)?) -> Integer
|
|
224
|
+
def catch_up(timeout: nil) # :nodoc:
|
|
225
|
+
reader = @reader or raise ArgumentError, "Not following a stream - see #follow_stream"
|
|
226
|
+
reader.read(timeout:).count do |batch|
|
|
227
|
+
outcome = self << batch
|
|
228
|
+
reader.resync! if outcome == :gap
|
|
229
|
+
outcome == :applied
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Stops following, see #follow_stream - closing the log does it
|
|
234
|
+
#: () -> void
|
|
235
|
+
def stop_following # :nodoc:
|
|
236
|
+
@stopping = true
|
|
237
|
+
@follower&.join unless @follower == Thread.current
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Passes a cancel request on to the child, for the task at `path` in its own tree. Returns
|
|
241
|
+
# :forwarded, or :moot when there is no child to ask any more - it has exited, or this mount
|
|
242
|
+
# was never spawned. Whether the child cancels anything shows up the stream, if it does
|
|
243
|
+
#: (path, ?String?) -> (:forwarded | :moot)
|
|
244
|
+
def request_cancel(path, reason = nil) # :nodoc:
|
|
245
|
+
# Over a stream, a request names the run it is for, so it cannot reach another one by mistake
|
|
246
|
+
line = JSON.generate({cancel: path, reason:, run: (@source if @following)}.compact) + "\n"
|
|
247
|
+
if (forward = @forward)
|
|
248
|
+
forward.call(line)
|
|
249
|
+
return :forwarded
|
|
250
|
+
end
|
|
251
|
+
@control_lock.synchronize do
|
|
252
|
+
control = @control
|
|
253
|
+
return :moot if control.nil? || control.closed?
|
|
254
|
+
control.write_nonblock(line) # a child too stuck to read its requests is past asking
|
|
255
|
+
end
|
|
256
|
+
:forwarded
|
|
257
|
+
rescue IO::WaitWritable, IOError, SystemCallError
|
|
258
|
+
:moot
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
# The parent may still end the subtree - when the remote went away, say. From outside, end
|
|
264
|
+
# the mount's own task: `mount.task.done!`
|
|
265
|
+
#: () -> self
|
|
266
|
+
def done!
|
|
267
|
+
finish(:done) { |tx| tx << Event.new(path: @path, op: :done) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Fails the mount, and cancels what the remote left open under it
|
|
271
|
+
#: (?(Exception | String)?, **bool | Integer) -> self
|
|
272
|
+
def fail!(error = nil, **extra)
|
|
273
|
+
finish(:cancel) do |tx|
|
|
274
|
+
tx << Event.new(path: @path, op: :fail, value: Wire.error_to_h(error || "#{tx.state[@path]&.name} failed").merge(extra))
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Cancels the mount, and what the remote left open under it
|
|
279
|
+
#: (?String?) -> self
|
|
280
|
+
def cancel!(reason = nil)
|
|
281
|
+
finish(:cancel) { |tx| tx << Event.new(path: @path, op: :cancel, value: (TaskProgress.utf8(reason.to_s) if reason)) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Whether the mount node has ended
|
|
285
|
+
#: () -> bool
|
|
286
|
+
def finished? = task.finished?
|
|
287
|
+
|
|
288
|
+
#: (Integer | Float, Integer | Float) -> void
|
|
289
|
+
def watch(poll, give_up_after)
|
|
290
|
+
reader = @reader or return
|
|
291
|
+
beating = false
|
|
292
|
+
silent_since = nil #: Float?
|
|
293
|
+
until @stopping
|
|
294
|
+
begin
|
|
295
|
+
catch_up(timeout: poll)
|
|
296
|
+
status = @log.snapshot[@path]&.status
|
|
297
|
+
break if status == :done || status == :canceled
|
|
298
|
+
if reader.alive?
|
|
299
|
+
beating = true
|
|
300
|
+
silent_since = nil
|
|
301
|
+
speaks_again if @silent
|
|
302
|
+
# Given up on, and alive after all: read it afresh
|
|
303
|
+
reader.resync! if @gave_up
|
|
304
|
+
elsif beating && status != :failed
|
|
305
|
+
since = silent_since ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
306
|
+
@silent = true
|
|
307
|
+
mark_stale
|
|
308
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) - since >= give_up_after
|
|
309
|
+
@gave_up = true
|
|
310
|
+
fail!("Lost touch with whoever was working on it")
|
|
311
|
+
beating = false
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
rescue => e
|
|
315
|
+
# The stream is away for a moment, say - keep trying, and say so
|
|
316
|
+
@log.on_error.call(e, self)
|
|
317
|
+
sleep(poll)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Canceling the mount asks the remote to cancel its root - the run there is now, once
|
|
323
|
+
#: () -> void
|
|
324
|
+
def arm_cancel
|
|
325
|
+
generation = @generation += 1
|
|
326
|
+
task.on_cancel { |reason| request_cancel([], reason) if generation == @generation && !@remote_ended }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The writer beats again, and nothing went missing meanwhile
|
|
330
|
+
#: () -> void
|
|
331
|
+
def speaks_again
|
|
332
|
+
@silent = false
|
|
333
|
+
return unless @stale
|
|
334
|
+
@stale = false
|
|
335
|
+
@log.append(Event.new(path: @path, op: :meta, value: {stale: false}))
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# The mount node as a new run finds it: its own name and weight, nothing done yet
|
|
339
|
+
#: (State) -> Hash[Symbol, untyped]
|
|
340
|
+
def fresh(state)
|
|
341
|
+
node = state[@path] or return {name: "", status: :pending}
|
|
342
|
+
{name: node.name, weight: node.weight, cancelable: (node.cancelable if @own), status: :pending}.compact
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Ending the mount ends what the remote left open under it too, as Task#done! and #fail! do
|
|
346
|
+
#: (Symbol) { (Transaction) -> void } -> self
|
|
347
|
+
def finish(closing)
|
|
348
|
+
@log.transaction do |tx|
|
|
349
|
+
next if tx.state[@path]&.finished?
|
|
350
|
+
tx.state.open_under(@path).reverse_each { tx << Event.new(path: it, op: closing) }
|
|
351
|
+
yield tx
|
|
352
|
+
end
|
|
353
|
+
self
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
#: (Batch | Hash[untyped, untyped] | String) -> Batch?
|
|
357
|
+
def coerce(batch)
|
|
358
|
+
case batch
|
|
359
|
+
when Batch then batch
|
|
360
|
+
when Hash then Wire.from_h(batch)
|
|
361
|
+
when String then Wire.parse(batch)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# In the transaction given, or one of its own
|
|
366
|
+
#: (?Transaction?) -> void
|
|
367
|
+
def mark_stale(tx = nil)
|
|
368
|
+
return if @stale
|
|
369
|
+
@stale = true
|
|
370
|
+
event = Event.new(path: @path, op: :meta, value: {stale: true})
|
|
371
|
+
tx ? tx << event : @log.append(event)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# The remote's events, moved under the mount - or nil if one would land too deep
|
|
375
|
+
#: (Array[Event], Float) -> Array[Event]?
|
|
376
|
+
def translate(events, offset)
|
|
377
|
+
events.map do |event|
|
|
378
|
+
path = [*@path, *event.path]
|
|
379
|
+
return nil if path.length > Wire::MAX_DEPTH
|
|
380
|
+
value = (event.op == :put) ? rebased(event.value, offset) : event.value
|
|
381
|
+
# Without a control pipe nothing carries a cancel back to the remote, so none of its own
|
|
382
|
+
# tasks can be offered for cancelling from here
|
|
383
|
+
value = value.except(:cancelable) if !@controlled && %i[open meta put].include?(event.op)
|
|
384
|
+
next Event.new(path:, op: event.op, value:) unless event.path.empty?
|
|
385
|
+
next Event.new(path:, op: (event.op == :open) ? :meta : event.op, value:) unless @own
|
|
386
|
+
case event.op
|
|
387
|
+
when :open, :meta then Event.new(path:, op: :meta, value: value.except(:name, :weight))
|
|
388
|
+
when :put then Event.new(path:, op: :put, value: own_put(value))
|
|
389
|
+
else Event.new(path:, op: event.op, value:)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Asks the child to cancel its root, and sends TERM if it has not exited `grace` seconds on
|
|
395
|
+
#: (Integer, Integer | Float) -> void
|
|
396
|
+
def stop(pid, grace)
|
|
397
|
+
request_cancel([], task.message)
|
|
398
|
+
Thread.new do
|
|
399
|
+
sleep(grace)
|
|
400
|
+
signal(pid, :TERM)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# The pid is only ever signaled while it is still ours: reaping and signaling share a lock, so
|
|
405
|
+
# a signal can never reach another process that was given the pid after ours exited
|
|
406
|
+
#: (Integer, Symbol) -> void
|
|
407
|
+
def signal(pid, name)
|
|
408
|
+
@pid_lock.synchronize { Process.kill(name, pid) unless @reaped }
|
|
409
|
+
rescue Errno::ESRCH
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
#: (Integer) -> Process::Status?
|
|
414
|
+
def reap(pid)
|
|
415
|
+
# Never waiting with the lock held, which would keep `stop` from sending TERM to a child
|
|
416
|
+
# that will not exit by itself - but reaping only with it, so a signal cannot slip in between
|
|
417
|
+
loop do
|
|
418
|
+
status = @pid_lock.synchronize do
|
|
419
|
+
_, exited = Process.wait2(pid, Process::WNOHANG)
|
|
420
|
+
@reaped = true if exited
|
|
421
|
+
exited
|
|
422
|
+
end
|
|
423
|
+
return status if status
|
|
424
|
+
sleep(0.02)
|
|
425
|
+
end
|
|
426
|
+
rescue Errno::ECHILD
|
|
427
|
+
@reaped = true
|
|
428
|
+
nil
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
TIMES = %i[opened_at started_at finished_at].freeze #: Array[Symbol]
|
|
432
|
+
ENDINGS = %i[done fail cancel].freeze #: Array[Symbol]
|
|
433
|
+
FINISHED = %i[done failed canceled].freeze #: Array[Symbol]
|
|
434
|
+
private_constant :TIMES, :ENDINGS, :FINISHED
|
|
435
|
+
|
|
436
|
+
#: (Hash[Symbol, untyped], Float) -> Hash[Symbol, untyped]
|
|
437
|
+
def rebased(value, offset)
|
|
438
|
+
return value if offset.zero?
|
|
439
|
+
value.merge(value.slice(*TIMES).compact.transform_values { it + offset })
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
#: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
443
|
+
def own_put(value)
|
|
444
|
+
node = @log.snapshot[@path] or return value
|
|
445
|
+
value.merge(name: node.name, weight: node.weight, cancelable: node.cancelable).compact
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# One batch per line, for a Mount at the other end of a pipe. Each line goes out in one
|
|
6
|
+
# write, so a reader never sees a batch half-written unless the writer died writing it -
|
|
7
|
+
# and Wire.each_batch drops that one
|
|
8
|
+
class NDJSONOutput
|
|
9
|
+
# @rbs @io: IO | StringIO
|
|
10
|
+
|
|
11
|
+
# Writes to `io`, which it makes sync
|
|
12
|
+
#: (IO | StringIO) -> void
|
|
13
|
+
def initialize(io)
|
|
14
|
+
@io = io
|
|
15
|
+
@io.sync = true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Writes the batch as one line
|
|
19
|
+
#: (Batch, Log) -> void
|
|
20
|
+
def call(batch, _log)
|
|
21
|
+
@io.write(Wire.dump(batch) + "\n")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# One task, as the tree holds it, see below
|
|
6
|
+
Node = Struct.new(
|
|
7
|
+
:name, #: String
|
|
8
|
+
:total, #: Integer?
|
|
9
|
+
:count, #: Integer
|
|
10
|
+
:percent, #: Integer | Float
|
|
11
|
+
:weight, #: Float
|
|
12
|
+
:expect, #: Integer?
|
|
13
|
+
:indeterminate, #: bool?
|
|
14
|
+
:unit, #: String?
|
|
15
|
+
:cancelable, #: bool?
|
|
16
|
+
:attempt, #: Integer?
|
|
17
|
+
:message, #: String?
|
|
18
|
+
:status, #: Symbol
|
|
19
|
+
:error, #: Hash[Symbol, untyped]?
|
|
20
|
+
:stale, #: bool?
|
|
21
|
+
:opened_at, #: Float
|
|
22
|
+
:started_at, #: Float?
|
|
23
|
+
:finished_at, #: Float?
|
|
24
|
+
:children, #: Array[String]
|
|
25
|
+
:sealed, #: Mount?
|
|
26
|
+
:autofinish #: bool?
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# One task, as the tree holds it - what an output or a renderer reads: its name, status, count
|
|
30
|
+
# and the rest of State::FIELDS. `children` (their ids), `sealed` (the Mount that feeds it) and
|
|
31
|
+
# `autofinish` are the tree's own bookkeeping, and never go over the wire
|
|
32
|
+
class Node
|
|
33
|
+
# Ended by a `done`, `fail` or `cancel` of its own - whatever is still open under it
|
|
34
|
+
#: () -> bool
|
|
35
|
+
def finished? = status == :done || status == :failed || status == :canceled
|
|
36
|
+
|
|
37
|
+
# As a `put` carries it: State::FIELDS, without what is at its default
|
|
38
|
+
#: () -> Hash[Symbol, untyped]
|
|
39
|
+
def to_h
|
|
40
|
+
State::FIELDS.to_h { [it, self[it]] }.compact.tap do |h|
|
|
41
|
+
h.delete(:stale) unless stale
|
|
42
|
+
h.delete(:indeterminate) unless indeterminate
|
|
43
|
+
h.delete(:cancelable) unless cancelable
|
|
44
|
+
h.delete(:weight) if weight == 1.0 # rubocop:disable Lint/FloatComparison -- the default, set exactly, never computed
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A copy whose list of children can change without changing this one
|
|
49
|
+
#: () -> Node
|
|
50
|
+
def deep_dup = dup.tap { it.children = children.dup }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
class TaskProgress
|
|
9
|
+
# The tree over plain GETs, for where Server-Sent Events do not get through - a proxy that
|
|
10
|
+
# buffers, a host that cuts long responses, a client that is not a browser. Every answer is
|
|
11
|
+
# the whole tree, squashed into one batch with `prev` 0, and its ETag says which tree that is:
|
|
12
|
+
# a client that sends it back in `If-None-Match` gets a 304 until something changes.
|
|
13
|
+
#
|
|
14
|
+
# Asked with `?wait=N`, it holds the request up to N seconds (`max_wait` at most) for a change
|
|
15
|
+
# before saying 304 - long polling, which holds a server thread for that long, but answers
|
|
16
|
+
# the moment there is news. `followPollingAt` in `task_tree.js` does all of this.
|
|
17
|
+
#
|
|
18
|
+
# # Rack
|
|
19
|
+
# TaskProgress::Poll.response(task, env)
|
|
20
|
+
# # Rails
|
|
21
|
+
# status, headers, body = TaskProgress::Poll.response(task, request.env)
|
|
22
|
+
# render json: body.first, status:, headers:
|
|
23
|
+
#
|
|
24
|
+
# Given a key instead of a task - a tree handed off with TaskProgress.handoff - it reads that
|
|
25
|
+
# tree from the stream, following it for as long as a long poll waits, and answers 404 for a
|
|
26
|
+
# key with nothing at it
|
|
27
|
+
module Poll
|
|
28
|
+
# What a poll is answered with
|
|
29
|
+
HEADERS = {"content-type" => "application/json", "cache-control" => "no-cache"}.freeze #: Hash[String, String]
|
|
30
|
+
|
|
31
|
+
# A whole Rack response for the task's tree - its subtree, for a subtask - or for the tree
|
|
32
|
+
# handed off at a key, in `stream`
|
|
33
|
+
#: (TaskProgress | String, ?Hash[String, untyped], ?stream_store: untyped, ?max_wait: Integer | Float) -> [Integer, Hash[String, String], Array[String]]
|
|
34
|
+
def self.response(source, env = {}, stream_store: TaskProgress.stream_store, max_wait: 25)
|
|
35
|
+
waiting = wait(env, max_wait)
|
|
36
|
+
return answer(source, env, waiting) if source.is_a?(TaskProgress)
|
|
37
|
+
waiting.positive? ? TaskProgress.follow(source, stream_store:) { answer(it, env, waiting) } : answer(TaskProgress.fetch(source, stream_store:), env, 0.0)
|
|
38
|
+
rescue KeyError
|
|
39
|
+
[404, HEADERS, [JSON.generate({error: "Nothing to follow"})]]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: (TaskProgress, Hash[String, untyped], Float) -> [Integer, Hash[String, String], Array[String]]
|
|
43
|
+
def self.answer(task, env, waiting)
|
|
44
|
+
seen = seen(env)
|
|
45
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + waiting
|
|
46
|
+
loop do
|
|
47
|
+
seq = task.log.seq
|
|
48
|
+
batch = task.log.squashed(task.path)
|
|
49
|
+
tag = etag(batch)
|
|
50
|
+
return [200, HEADERS.merge("etag" => tag), [Wire.dump(batch)]] unless seen.include?(tag) || seen.include?("*")
|
|
51
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
|
+
# A task pruned away is as finished as it will ever be - its answer is a tree with nothing in it
|
|
53
|
+
node = task.log.snapshot[task.path]
|
|
54
|
+
return [304, HEADERS.merge("etag" => tag), []] if left <= 0 || node.nil? || node.finished?
|
|
55
|
+
task.log.wait_for_change(seq, left)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
private_class_method :answer
|
|
59
|
+
|
|
60
|
+
# Which tree a squash is: the same tree, however many batches it took, gets the same tag. Times
|
|
61
|
+
# are left out - a tree read from a stream has them moved onto this clock, a little differently
|
|
62
|
+
# every read, and they never change without something else changing with them
|
|
63
|
+
#: (Batch) -> String
|
|
64
|
+
def self.etag(batch)
|
|
65
|
+
ops = batch.events.map { [it.path, it.op, (it.op == :put) ? it.value.except(*TIMES) : it.value] }
|
|
66
|
+
%("#{Digest::SHA256.hexdigest(JSON.generate(ops))[0, 24]}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
TIMES = %i[opened_at started_at finished_at].freeze #: Array[Symbol]
|
|
70
|
+
private_constant :TIMES
|
|
71
|
+
|
|
72
|
+
# The tags a client already has. A proxy that compresses the answer makes the tag weak, W/"..."
|
|
73
|
+
#: (Hash[String, untyped]) -> Array[String]
|
|
74
|
+
def self.seen(env) = env["HTTP_IF_NONE_MATCH"].to_s.split(",").map { it.strip.delete_prefix("W/") }
|
|
75
|
+
private_class_method :seen
|
|
76
|
+
|
|
77
|
+
# The seconds asked for in `?wait=`, up to `max_wait`
|
|
78
|
+
#: (Hash[String, untyped], Integer | Float) -> Float
|
|
79
|
+
def self.wait(env, max_wait)
|
|
80
|
+
asked = URI.decode_www_form(env["QUERY_STRING"].to_s).to_h["wait"]
|
|
81
|
+
Float(asked.to_s, exception: false).to_f.clamp(0.0, max_wait.to_f)
|
|
82
|
+
rescue ArgumentError
|
|
83
|
+
0.0
|
|
84
|
+
end
|
|
85
|
+
private_class_method :wait
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# Hands each batch, as its JSON-ready Hash, to anything with `publish(event_name, payload)`
|
|
6
|
+
# - a Sossego::Stream, for one. A reader turns it back into a Batch with Wire.from_h, or
|
|
7
|
+
# feeds it to a Mount as it is
|
|
8
|
+
class PublishingOutput
|
|
9
|
+
# @rbs!
|
|
10
|
+
# interface _Publisher
|
|
11
|
+
# def publish: (String event_name, Hash[Symbol, untyped] payload) -> void
|
|
12
|
+
# end
|
|
13
|
+
|
|
14
|
+
# @rbs @target: _Publisher
|
|
15
|
+
# @rbs @event_name: String
|
|
16
|
+
|
|
17
|
+
# Publishes to `target` - a Sossego stream, or anything with `publish(event_name, payload)`
|
|
18
|
+
#: (_Publisher, ?event_name: String) -> void
|
|
19
|
+
def initialize(target, event_name: "task_progress")
|
|
20
|
+
@target = target
|
|
21
|
+
@event_name = event_name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Publishes the batch as a JSON-ready Hash
|
|
25
|
+
#: (Batch, Log) -> void
|
|
26
|
+
def call(batch, _log)
|
|
27
|
+
@target.publish(@event_name, Wire.to_h(batch))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# Sends each batch to a Ractor::Port, as a deep frozen copy - for a tree built inside a
|
|
6
|
+
# Ractor and mounted outside it: `mount << port.receive`. Ruby 4.0 and up only
|
|
7
|
+
class RactorOutput
|
|
8
|
+
# @rbs @port: untyped - a Ractor::Port, which RBS does not know yet
|
|
9
|
+
|
|
10
|
+
# Sends to a `Ractor::Port`. Raises NotImplementedError before Ruby 4.0
|
|
11
|
+
#: (untyped) -> void
|
|
12
|
+
def initialize(port)
|
|
13
|
+
raise NotImplementedError, "TaskProgress supports Ractors from Ruby 4.0 - on #{RUBY_VERSION} they crash the VM" unless RACTORS
|
|
14
|
+
@port = port
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Sends a deep frozen copy of the batch to the port
|
|
18
|
+
#: (Batch, Log) -> void
|
|
19
|
+
def call(batch, _log)
|
|
20
|
+
@port.send(::Ractor.make_shareable(batch, copy: true))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|