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,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
|