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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE +21 -0
  4. data/MANUAL.md +851 -0
  5. data/README.md +158 -0
  6. data/RECIPES.md +413 -0
  7. data/config/importmap.rb +17 -0
  8. data/exe/task_progress +51 -0
  9. data/js/local_tasks.d.ts +44 -0
  10. data/js/local_tasks.js +229 -0
  11. data/js/package.json +31 -0
  12. data/js/paths.d.ts +5 -0
  13. data/js/paths.js +7 -0
  14. data/js/react_task_tree.d.ts +49 -0
  15. data/js/react_task_tree.js +123 -0
  16. data/js/render_queue.d.ts +24 -0
  17. data/js/render_queue.js +79 -0
  18. data/js/row_view.d.ts +25 -0
  19. data/js/row_view.js +69 -0
  20. data/js/stimulus_controller.d.ts +10 -0
  21. data/js/stimulus_controller.js +137 -0
  22. data/js/task_progress_clock.d.ts +2 -0
  23. data/js/task_progress_clock.js +33 -0
  24. data/js/task_tree.css +51 -0
  25. data/js/task_tree.d.ts +111 -0
  26. data/js/task_tree.js +444 -0
  27. data/lib/task_progress/assertions.rb +49 -0
  28. data/lib/task_progress/batch.rb +23 -0
  29. data/lib/task_progress/branch.rb +74 -0
  30. data/lib/task_progress/buffered_output.rb +63 -0
  31. data/lib/task_progress/cancellations_app.rb +76 -0
  32. data/lib/task_progress/event.rb +46 -0
  33. data/lib/task_progress/execution_log_output.rb +183 -0
  34. data/lib/task_progress/fold.rb +18 -0
  35. data/lib/task_progress/html_renderer.rb +153 -0
  36. data/lib/task_progress/job_serializer.rb +92 -0
  37. data/lib/task_progress/journal.rb +35 -0
  38. data/lib/task_progress/log.rb +263 -0
  39. data/lib/task_progress/memory_cache.rb +31 -0
  40. data/lib/task_progress/memory_stream_store.rb +142 -0
  41. data/lib/task_progress/mount.rb +448 -0
  42. data/lib/task_progress/ndjson_output.rb +24 -0
  43. data/lib/task_progress/node.rb +52 -0
  44. data/lib/task_progress/poll.rb +87 -0
  45. data/lib/task_progress/publishing_output.rb +30 -0
  46. data/lib/task_progress/ractor_output.rb +23 -0
  47. data/lib/task_progress/rails_defaults.rb +46 -0
  48. data/lib/task_progress/railtie.rb +91 -0
  49. data/lib/task_progress/recording_output.rb +51 -0
  50. data/lib/task_progress/redis_stream_store.rb +131 -0
  51. data/lib/task_progress/squasher.rb +59 -0
  52. data/lib/task_progress/sse.rb +114 -0
  53. data/lib/task_progress/state.rb +319 -0
  54. data/lib/task_progress/stream_output.rb +222 -0
  55. data/lib/task_progress/stream_reader.rb +76 -0
  56. data/lib/task_progress/summary_output.rb +37 -0
  57. data/lib/task_progress/terminal_output.rb +241 -0
  58. data/lib/task_progress/token.rb +58 -0
  59. data/lib/task_progress/tracked_io.rb +68 -0
  60. data/lib/task_progress/transaction.rb +41 -0
  61. data/lib/task_progress/trees_app.rb +66 -0
  62. data/lib/task_progress/turbo_helper.rb +18 -0
  63. data/lib/task_progress/turbo_sse.rb +103 -0
  64. data/lib/task_progress/turbo_stream_output.rb +61 -0
  65. data/lib/task_progress/version.rb +7 -0
  66. data/lib/task_progress/wire.rb +211 -0
  67. data/lib/task_progress.rb +1036 -0
  68. data/sig/generated/task_progress/assertions.rbs +36 -0
  69. data/sig/generated/task_progress/batch.rbs +33 -0
  70. data/sig/generated/task_progress/branch.rbs +47 -0
  71. data/sig/generated/task_progress/buffered_output.rbs +38 -0
  72. data/sig/generated/task_progress/cancellations_app.rbs +41 -0
  73. data/sig/generated/task_progress/event.rbs +20 -0
  74. data/sig/generated/task_progress/execution_log_output.rbs +93 -0
  75. data/sig/generated/task_progress/fold.rbs +25 -0
  76. data/sig/generated/task_progress/html_renderer.rbs +71 -0
  77. data/sig/generated/task_progress/journal.rbs +25 -0
  78. data/sig/generated/task_progress/log.rbs +140 -0
  79. data/sig/generated/task_progress/memory_cache.rbs +28 -0
  80. data/sig/generated/task_progress/memory_stream_store.rbs +83 -0
  81. data/sig/generated/task_progress/mount.rbs +200 -0
  82. data/sig/generated/task_progress/ndjson_output.rbs +18 -0
  83. data/sig/generated/task_progress/node.rbs +66 -0
  84. data/sig/generated/task_progress/poll.rbs +50 -0
  85. data/sig/generated/task_progress/publishing_output.rbs +24 -0
  86. data/sig/generated/task_progress/ractor_output.rbs +17 -0
  87. data/sig/generated/task_progress/rails_defaults.rbs +33 -0
  88. data/sig/generated/task_progress/recording_output.rbs +41 -0
  89. data/sig/generated/task_progress/redis_stream_store.rbs +75 -0
  90. data/sig/generated/task_progress/squasher.rbs +20 -0
  91. data/sig/generated/task_progress/sse.rbs +73 -0
  92. data/sig/generated/task_progress/state.rbs +165 -0
  93. data/sig/generated/task_progress/stream_output.rbs +142 -0
  94. data/sig/generated/task_progress/stream_reader.rbs +46 -0
  95. data/sig/generated/task_progress/summary_output.rbs +19 -0
  96. data/sig/generated/task_progress/terminal_output.rbs +117 -0
  97. data/sig/generated/task_progress/token.rbs +41 -0
  98. data/sig/generated/task_progress/tracked_io.rbs +48 -0
  99. data/sig/generated/task_progress/transaction.rbs +33 -0
  100. data/sig/generated/task_progress/trees_app.rbs +40 -0
  101. data/sig/generated/task_progress/turbo_helper.rbs +14 -0
  102. data/sig/generated/task_progress/turbo_sse.rbs +69 -0
  103. data/sig/generated/task_progress/turbo_stream_output.rbs +51 -0
  104. data/sig/generated/task_progress/version.rbs +6 -0
  105. data/sig/generated/task_progress/wire.rbs +102 -0
  106. data/sig/generated/task_progress.rbs +553 -0
  107. 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