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,46 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # What TaskProgress.broadcast and the `task_progress_tree` helper use when Rails is there and
6
+ # nothing else is given: the record's dom_id, Turbo::StreamsChannel, Rails.cache, and tokens
7
+ # signed with a key the app's secret_key_base derives. Without Rails, nothing - and the caller
8
+ # says what to broadcast to. The record is streamed to as `[record, :task_progress]`
9
+ module RailsDefaults # :nodoc:
10
+ class << self
11
+ # The id the tree's element has, for a record or a string
12
+ #: (untyped) -> String
13
+ def id(target)
14
+ return target.to_s if target.is_a?(String) || target.is_a?(Symbol) || !rails?
15
+ ActionView::RecordIdentifier.dom_id(target, :task_progress) # steep:ignore
16
+ end
17
+
18
+ # The store key the latest frame is kept under
19
+ #: (untyped) -> String
20
+ def key(target) = "task_progress/#{id(target)}"
21
+
22
+ # The defaults for what is not given, or nothing outside Rails
23
+ #: (untyped) -> Hash[Symbol, untyped]
24
+ def for(target)
25
+ return {} unless rails?
26
+ defaults = {store: ::Rails.cache, tokens:, cancel_url: cancellations_path} # steep:ignore
27
+ return defaults unless defined?(::Turbo::StreamsChannel)
28
+ defaults.merge(to: ->(html) { ::Turbo::StreamsChannel.broadcast_stream_to(target, :task_progress, content: html) }) # steep:ignore
29
+ end
30
+
31
+ # Tokens for the cancel buttons, signed with a key derived from the app's secret
32
+ #: () -> Token
33
+ def tokens
34
+ Token.new(::Rails.application.key_generator.generate_key("task_progress cancellations")) # steep:ignore
35
+ end
36
+
37
+ # Where the cancel buttons post, or nil when the app has turned that off
38
+ #: () -> String?
39
+ def cancellations_path = (::Rails.application.config.task_progress.cancellations_path if rails?) # steep:ignore
40
+
41
+ # Whether this runs in a Rails app
42
+ #: () -> bool
43
+ def rails? = !!(defined?(::Rails.application) && ::Rails.application) # steep:ignore
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TaskProgress
4
+ # Loaded only inside a Rails app: trees as job arguments (see JobSerializer), the
5
+ # `task_progress_tree` helper in every view, the browser client at `/task_progress/js` (and on the
6
+ # asset path, and pinned in the import map when importmap-rails is in use), a route for the
7
+ # cancel buttons - at `/task_progress/cancellations`, unless `config.task_progress.cancellations_path`
8
+ # says elsewhere, or is nil for none - and `rails task_progress:vendor`
9
+ class Railtie < ::Rails::Railtie
10
+ config.task_progress = ActiveSupport::OrderedOptions.new
11
+ config.task_progress.cancellations_path = "/task_progress/cancellations"
12
+ # The browser client, as it is - an asset pipeline would fingerprint the file you import, and
13
+ # leave the relative imports inside it pointing at names it does not serve
14
+ config.task_progress.js_path = "/task_progress/js"
15
+ # Pins `task_progress`, `task_progress/react` and the rest to it, when importmap-rails is in use
16
+ config.task_progress.importmap = true
17
+ # Where `rails task_progress:vendor` copies the client to, for a bundler to take it from
18
+ config.task_progress.vendor_path = "vendor/javascript/task_progress"
19
+ # Where pages get handed-off trees from, by the links TaskProgress.for_page hands out
20
+ config.task_progress.trees_path = "/task_progress/trees"
21
+ # Where handed-off trees are kept - a TaskProgress::RedisStreamStore, say. Unset, see TaskProgress.stream_store
22
+ config.task_progress.stream_store = nil
23
+
24
+ initializer "task_progress.config" do |app|
25
+ TaskProgress.stream_store = app.config.task_progress.stream_store if app.config.task_progress.stream_store
26
+ TaskProgress.trees_path = app.config.task_progress.trees_path if app.config.task_progress.trees_path
27
+ end
28
+
29
+ config.after_initialize { TaskProgress.tokens = TaskProgress::RailsDefaults.tokens }
30
+
31
+ initializer "task_progress.jobs" do
32
+ ActiveSupport.on_load(:active_job) { TaskProgress::JobSerializer.install }
33
+ end
34
+
35
+ initializer "task_progress.helper" do
36
+ ActiveSupport.on_load(:action_view) { include TaskProgress::TurboHelper }
37
+ end
38
+
39
+ initializer "task_progress.assets" do |app|
40
+ app.config.assets.paths << TaskProgress.js_path if app.config.respond_to?(:assets)
41
+ end
42
+
43
+ # Before importmap-rails draws the app's own pins, so that a pin of the same name there wins
44
+ initializer "task_progress.importmap", before: "importmap" do |app|
45
+ options = app.config.task_progress
46
+ if app.config.respond_to?(:importmap) && options.importmap && options.js_path
47
+ app.config.importmap.paths << File.expand_path("../../config/importmap.rb", __dir__)
48
+ end
49
+ end
50
+
51
+ # A vendored copy is not updated with the gem - say so, rather than serve the old one
52
+ config.after_initialize do |app|
53
+ vendored = TaskProgress.vendored_js_version(app.root.join(app.config.task_progress.vendor_path).to_s)
54
+ if vendored && vendored != TaskProgress::VERSION
55
+ Rails.logger&.warn("task_progress #{TaskProgress::VERSION} is installed, but #{app.config.task_progress.vendor_path} has the client from #{vendored} - run `rails task_progress:vendor`")
56
+ end
57
+ end
58
+
59
+ rake_tasks do
60
+ namespace :task_progress do
61
+ desc "Copy the browser client into config.task_progress.vendor_path, or DIR"
62
+ task :vendor do
63
+ dir = ENV["DIR"] || Rails.root.join(Rails.application.config.task_progress.vendor_path).to_s
64
+ TaskProgress.vendor_js(dir)
65
+ puts "The task_progress client #{TaskProgress::VERSION} is in #{dir}"
66
+ end
67
+ end
68
+ end
69
+
70
+ initializer "task_progress.routes" do |app|
71
+ path = app.config.task_progress.cancellations_path
72
+ if path
73
+ app.routes.append do
74
+ mount TaskProgress::CancellationsApp.new(store: Rails.cache, tokens: TaskProgress::RailsDefaults.tokens) => path
75
+ end
76
+ end
77
+ trees = app.config.task_progress.trees_path
78
+ if trees
79
+ app.routes.append do
80
+ mount TaskProgress::TreesApp.new(tokens: TaskProgress::RailsDefaults.tokens) => trees
81
+ end
82
+ end
83
+ js = app.config.task_progress.js_path
84
+ if js
85
+ app.routes.append do
86
+ mount Rack::Files.new(TaskProgress.js_path, {"cache-control" => "no-cache"}) => js
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # Keeps every batch it gets, for a test to look through:
6
+ #
7
+ # recording = TaskProgress::RecordingOutput.new
8
+ # Importer.new(progress: TaskProgress.new("Import", outputs: [recording])).run
9
+ # assert_includes recording.said, "Skipped 3 rows"
10
+ # assert_equal 1, recording.events(:fail).length
11
+ #
12
+ # Safe to share between threads
13
+ class RecordingOutput
14
+ # @rbs @batches: Array[Batch]
15
+ # @rbs @lock: Thread::Mutex
16
+
17
+ # Nothing recorded yet
18
+ #: () -> void
19
+ def initialize
20
+ @batches = []
21
+ @lock = Mutex.new
22
+ end
23
+
24
+ # Records the batch
25
+ #: (Batch, Log) -> void
26
+ def call(batch, _log) = @lock.synchronize { @batches << batch }
27
+
28
+ # Every batch so far, in order
29
+ #: () -> Array[Batch]
30
+ def batches = @lock.synchronize { @batches.dup }
31
+
32
+ # Every event so far, in order - only those with the op `op`, if given
33
+ #: (?Symbol?) -> Array[Event]
34
+ def events(op = nil) = batches.flat_map(&:events).select { op.nil? || it.op == op }
35
+
36
+ # What the tree was told to `puts`, in order
37
+ #: () -> Array[String]
38
+ def said = events(:say).map { it.value.to_s }
39
+
40
+ # The tree the batches make, as an outline - see TaskProgress#outline
41
+ #: () -> Hash[Symbol, untyped]?
42
+ def outline
43
+ state = State.new
44
+ batches.each do |batch|
45
+ state = State.new if batch.reset?
46
+ batch.events.each { state.apply(it, at: batch.at) }
47
+ end
48
+ TaskProgress.outline(state, []) unless state.empty?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # A stream store in Redis: each tree a Redis stream of its own (XADD, XREAD, XTRIM), its values in plain keys. It
6
+ # takes a redis-client connection or pool (`RedisClient.config(url:).new_pool`), or a redis-rb
7
+ # `Redis` - neither is a dependency of this gem, and nothing here loads one.
8
+ #
9
+ # TaskProgress.stream_store = TaskProgress::RedisStreamStore.new(RedisClient.config(url: ENV["REDIS_URL"]).new_pool)
10
+ #
11
+ # Needs Redis 6.2 or later, for trimming by id. A reader that waits holds its connection for
12
+ # as long as it waits - a pool sized for the readers that follow at once keeps the rest moving.
13
+ # A redis-rb `Redis` is a single connection, so everything else on it waits too: give one that
14
+ # follows trees a client of its own.
15
+ #
16
+ # A write with a `guard:` - a key and the value it must hold - runs as a Lua script, so the
17
+ # check and the write are one step, see MemoryStreamStore.
18
+ #
19
+ # Every tree is under its own key already, so two users' trees never meet. A `namespace` puts
20
+ # all the keys under it as well - `tenant-42:task_progress:{...}` - for apps, or tenants, that
21
+ # share a Redis and must stay apart even so
22
+ class RedisStreamStore
23
+ # The guarded writes. KEYS[1] is the guard, ARGV[1] the value it must hold
24
+ GUARDED = {
25
+ set: "if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end " \
26
+ "if ARGV[3] == '' then redis.call('SET', KEYS[2], ARGV[2]) else redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3]) end return 1",
27
+ trim: "if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end redis.call('XTRIM', KEYS[2], 'MINID', ARGV[2]) return 1",
28
+ delete: "if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end for i = 2, #KEYS do redis.call('DEL', KEYS[i]) end return 1",
29
+ expire: "if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end redis.call('PEXPIRE', KEYS[2], ARGV[2]) return 1"
30
+ }.freeze #: Hash[Symbol, String]
31
+
32
+ # @rbs @redis: untyped
33
+ # @rbs @namespace: String?
34
+
35
+ # For a redis-client connection or pool, or a redis-rb client
36
+ #: (untyped, ?namespace: String?) -> void
37
+ def initialize(redis, namespace: nil)
38
+ @redis = redis
39
+ @namespace = namespace
40
+ end
41
+
42
+ # Adds `data` at the end of the stream at `key`, and returns its id
43
+ #: (String, String) -> String
44
+ def append(key, data) = @redis.call("XADD", ns(key), "*", "d", data).to_s
45
+
46
+ # The entries after the id `after` - all of them when it is nil - as `[id, data]` pairs. With
47
+ # a `timeout`, waits up to that many seconds for one to come when there are none yet
48
+ #: (String, ?after: String?, ?timeout: (Integer | Float)?) -> Array[[String, String]]
49
+ def read(key, after: nil, timeout: nil)
50
+ key = ns(key)
51
+ after ||= "0-0"
52
+ ms = (timeout * 1000).ceil if timeout&.positive?
53
+ reply = if ms.nil?
54
+ @redis.call("XREAD", "STREAMS", key, after)
55
+ elsif @redis.respond_to?(:blocking_call)
56
+ @redis.blocking_call(timeout.to_f + 1, "XREAD", "BLOCK", ms, "STREAMS", key, after)
57
+ else
58
+ # redis-rb knows to stretch its read timeout around a blocking command, but only in its own methods
59
+ @redis.xread(key, after, block: ms)
60
+ end
61
+ entries(reply, key)
62
+ end
63
+
64
+ # The value at `key`, or nil
65
+ #: (String) -> String?
66
+ def get(key) = @redis.call("GET", ns(key))
67
+
68
+ # Keeps `value` at `key`, for `expires_in` seconds if given
69
+ #: (String, String, ?expires_in: (Integer | Float)?, ?guard: [String, String]?) -> bool
70
+ def set(key, value, expires_in: nil, guard: nil)
71
+ key = ns(key)
72
+ ms = (expires_in * 1000).ceil if expires_in
73
+ return guarded(:set, guard, [key], [value, ms.to_s]) if guard
74
+ ms ? @redis.call("SET", key, value, "PX", ms) : @redis.call("SET", key, value)
75
+ true
76
+ end
77
+
78
+ # Forgets the streams and values at these keys
79
+ #: (*String, ?guard: [String, String]?) -> bool
80
+ def delete(*keys, guard: nil)
81
+ keys = keys.map { ns(it) }
82
+ return true if keys.empty?
83
+ return guarded(:delete, guard, keys, []) if guard
84
+ @redis.call("DEL", *keys)
85
+ true
86
+ end
87
+
88
+ # Drops the entries before the id `before`
89
+ #: (String, before: String, ?guard: [String, String]?) -> bool
90
+ def trim(key, before:, guard: nil)
91
+ key = ns(key)
92
+ return guarded(:trim, guard, [key], [before]) if guard
93
+ @redis.call("XTRIM", key, "MINID", before)
94
+ true
95
+ end
96
+
97
+ # Forgets what is at `key` - a stream or a value - `seconds` from now
98
+ #: (String, Integer | Float, ?guard: [String, String]?) -> bool
99
+ def expire(key, seconds, guard: nil)
100
+ key = ns(key)
101
+ ms = (seconds * 1000).ceil
102
+ return guarded(:expire, guard, [key], [ms.to_s]) if guard
103
+ @redis.call("PEXPIRE", key, ms)
104
+ true
105
+ end
106
+
107
+ private
108
+
109
+ #: (Symbol, [String, String], Array[String], Array[String]) -> bool
110
+ def guarded(script, guard, keys, args)
111
+ @redis.call("EVAL", GUARDED.fetch(script), keys.length + 1, ns(guard[0]), *keys, guard[1], *args).to_i == 1
112
+ end
113
+
114
+ #: (String) -> String
115
+ def ns(key) = @namespace ? "#{@namespace}:#{key}" : key
116
+
117
+ # XREAD answers in one of three shapes - RESP3 a Hash, RESP2 nested Arrays, redis-rb a Hash
118
+ # of Hashes - and nil when nothing came
119
+ #: (untyped, String) -> Array[[String, String]]
120
+ def entries(reply, key)
121
+ found = case reply
122
+ when Hash then reply[key]
123
+ when Array then reply.find { it[0] == key }&.last
124
+ end
125
+ (found || []).filter_map do |id, fields|
126
+ data = fields.is_a?(Hash) ? fields["d"] : fields.each_slice(2).find { it[0] == "d" }&.last
127
+ [id.to_s, data.to_s] if data
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # Squashes runs of batches into one. It mirrors the tree they build, so for a run it can
6
+ # send the prunes it saw and then a put for every node the run touched - which lands any
7
+ # reader that had the tree up to the run's `prev` exactly where the run left it, times
8
+ # and all, without replaying the steps in between. What was said is not in the tree, so
9
+ # it goes along after the puts
10
+ class Squasher # :nodoc:
11
+ # @rbs @state: State
12
+
13
+ # Starts from an empty tree
14
+ #: () -> void
15
+ def initialize = @state = State.new
16
+
17
+ # One batch that lands a reader where the run of batches would have
18
+ #: (Array[Batch]) -> Batch
19
+ def squash(batches)
20
+ prunes = [] #: Array[Event]
21
+ says = [] #: Array[Event]
22
+ touched = {} #: Hash[path, bool]
23
+ first = batches.first or raise ArgumentError, "Nothing to squash"
24
+ prev = first.prev
25
+ batches.each do |batch|
26
+ if batch.reset?
27
+ @state = State.new
28
+ prunes.clear
29
+ says.clear
30
+ touched.clear
31
+ prev = 0
32
+ end
33
+ journal = Journal.new
34
+ begin
35
+ batch.events.each { @state.apply(it, at: batch.at, journal:) }
36
+ rescue ArgumentError
37
+ # A batch that does not fit the tree it knows - it joined late, say - leaves that tree as
38
+ # it was. One alone goes on as it is, for whoever reads it to judge
39
+ @state.rollback(journal)
40
+ raise if batches.length > 1
41
+ return batch
42
+ end
43
+ batch.events.each do |event|
44
+ prunes << event if event.op == :prune
45
+ next says << event if event.op == :say
46
+ # set, add, note, done and fail start every node above theirs
47
+ (0..event.path.length).each { touched[event.path.take(it)] = true }
48
+ end
49
+ end
50
+ return first if batches.length == 1
51
+ last = batches.last or raise ArgumentError, "Nothing to squash"
52
+ puts = @state.each.filter_map { |path, node| Event.new(path:, op: :put, value: node.to_h) if touched[path] }
53
+ # The lines go after the puts, which bring their tasks back - but not for a task pruned
54
+ # since, which nothing after the prune would know
55
+ said = says.select { @state.key?(it.path) }
56
+ Batch.new(log: last.log, prev:, seq: last.seq, at: last.at, events: [*prunes, *puts, *said].freeze)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class TaskProgress
5
+ # A Rack response body streaming a tree as Server-Sent Events: first the tree so far, squashed
6
+ # into one batch with `prev` 0, then every batch as it commits - each in a `batch` event whose
7
+ # `id:` is its seq. A browser that reconnects starts over from a fresh squash, which is always
8
+ # right and costs a few puts. The stream ends once the root is finished. Given a subtask, it
9
+ # streams only that task's subtree, with the subtask as the root.
10
+ #
11
+ # # Rack
12
+ # TaskProgress::SSE.response(task)
13
+ # # Rails
14
+ # headers.merge!(TaskProgress::SSE::HEADERS)
15
+ # self.response_body = TaskProgress::SSE.new(task)
16
+ #
17
+ # Given a key instead of a task - a tree handed off with TaskProgress.handoff - it follows that
18
+ # tree from the stream for as long as it streams, and stops following when the stream ends or
19
+ # the server closes it. `response` answers 404 for a key with nothing at it
20
+ class SSE
21
+ # What an SSE response is sent with
22
+ HEADERS = {"content-type" => "text/event-stream", "cache-control" => "no-cache", "x-accel-buffering" => "no"}.freeze #: Hash[String, String]
23
+
24
+ # @rbs @source: TaskProgress | Log
25
+ # @rbs @heartbeat: Integer | Float
26
+ # @rbs @reconnect: Integer | Float
27
+ # @rbs @close: bool
28
+
29
+ # A whole Rack response: status, headers and this body - or a 404, for a key with nothing at it
30
+ #: (TaskProgress | Log | String, **untyped) -> [Integer, Hash[String, String], SSE | Array[String]]
31
+ def self.response(source, **)
32
+ [200, HEADERS.dup, new(source, **)]
33
+ rescue KeyError
34
+ [404, {"content-type" => "text/plain"}, ["Nothing to follow"]]
35
+ end
36
+
37
+ # One batch as an SSE event
38
+ #: (Batch) -> String
39
+ def self.frame(batch) = "id: #{batch.seq}\nevent: batch\ndata: #{Wire.dump(batch)}\n\n"
40
+
41
+ # Streams the task - its subtree only, for a subtask - or the tree handed off at a key, from
42
+ # `stream`. It pings every `heartbeat` seconds of quiet, and tells the browser to reconnect
43
+ # after `reconnect` seconds
44
+ #: (TaskProgress | Log | String, ?stream_store: untyped, ?heartbeat: Integer | Float, ?reconnect: Integer | Float) -> void
45
+ def initialize(source, stream_store: TaskProgress.stream_store, heartbeat: 15, reconnect: 2)
46
+ @close = source.is_a?(String)
47
+ @source = source.is_a?(String) ? TaskProgress.follow(source, stream_store:) : source
48
+ @heartbeat = heartbeat
49
+ @reconnect = reconnect
50
+ end
51
+
52
+ # Stops following a tree it followed by its key - Rack calls it when the response is over
53
+ #: () -> void
54
+ def close
55
+ log.close if @close
56
+ end
57
+
58
+ # Yields the stream a chunk at a time, until the task finishes - as a Rack body does
59
+ #: () { (String) -> void } -> void
60
+ def each
61
+ queue = Thread::Queue.new
62
+ behind = false
63
+ # A client that stops reading would have every batch pile up here. Past BACKLOG it gets
64
+ # one squash of the tree instead, as of when it reads on
65
+ output = @source.add_output(replay: true) do |batch, _log|
66
+ next queue << batch if queue.size < BACKLOG
67
+ behind = true
68
+ queue.clear
69
+ end
70
+ yield "retry: #{(@reconnect * 1000).round}\n\n"
71
+ # A task pruned away before anyone asked: a tree with nothing in it, and the end
72
+ return yield(SSE.frame(Batch.new(log: log.id, prev: 0, seq: [log.seq, 1].max, at: 0.0, events: []))) unless present?
73
+ loop do
74
+ batch = queue.pop(timeout: @heartbeat)
75
+ if behind
76
+ behind = false
77
+ batch = log.squashed(path)
78
+ end
79
+ next yield(": ping\n\n") if batch.nil?
80
+ yield SSE.frame(batch)
81
+ break if finishes_root?(batch)
82
+ end
83
+ ensure
84
+ @source.remove_output(output) if output
85
+ close
86
+ end
87
+
88
+ # How many batches wait for a slow client before it gets a squash instead
89
+ BACKLOG = 1000 #: Integer
90
+
91
+ private
92
+
93
+ #: () -> Log
94
+ def log = (source = @source).is_a?(Log) ? source : source.log
95
+
96
+ #: () -> path
97
+ def path = (source = @source).is_a?(Log) ? [] : source.path
98
+
99
+ #: () -> bool
100
+ def present? = log.snapshot.key?(path)
101
+
102
+ # Judged by what was sent, never by the snapshot: that runs ahead of delivery, and ending on
103
+ # it could cut the stream before the batch that finished the root went out. An empty reset
104
+ # is a branch whose task was pruned, which will not come back either
105
+ #: (Batch) -> bool
106
+ def finishes_root?(batch)
107
+ return true if batch.reset? && batch.events.empty?
108
+ batch.events.any? do |event|
109
+ event.path.empty? && (%i[done fail cancel].include?(event.op) ||
110
+ (event.op == :put && %i[done failed canceled].include?(event.value[:status])))
111
+ end
112
+ end
113
+ end
114
+ end