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
data/MANUAL.md
ADDED
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
# TaskProgress manual
|
|
2
|
+
|
|
3
|
+
Everything TaskProgress does, and how. The [README](README.md) is the short version, and [RECIPES](RECIPES.md) has whole setups to copy from.
|
|
4
|
+
|
|
5
|
+
- [How it works](#how-it-works)
|
|
6
|
+
- [Tasks](#tasks)
|
|
7
|
+
- [How far along a task is](#how-far-along-a-task-is)
|
|
8
|
+
- [Finishing](#finishing)
|
|
9
|
+
- [Bars, loops and IO](#bars-loops-and-io)
|
|
10
|
+
- [On the command line](#on-the-command-line)
|
|
11
|
+
- [Outputs](#outputs)
|
|
12
|
+
- [Streaming a branch](#streaming-a-branch)
|
|
13
|
+
- [Other processes and Ractors](#other-processes-and-ractors)
|
|
14
|
+
- [Handing work off](#handing-work-off)
|
|
15
|
+
- [Canceling](#canceling)
|
|
16
|
+
- [Writing work that can be canceled](#writing-work-that-can-be-canceled)
|
|
17
|
+
- [Canceling from outside](#canceling-from-outside)
|
|
18
|
+
- [Server-Sent Events](#server-sent-events)
|
|
19
|
+
- [Polling](#polling)
|
|
20
|
+
- [A handed-off tree on a page](#a-handed-off-tree-on-a-page)
|
|
21
|
+
- [The browser client](#the-browser-client)
|
|
22
|
+
- [Turbo](#turbo)
|
|
23
|
+
- [Big trees](#big-trees)
|
|
24
|
+
- [The wire format](#the-wire-format)
|
|
25
|
+
- [Testing](#testing)
|
|
26
|
+
- [The bench](#the-bench)
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
A tree of tasks lives in a **log**. Nothing in the tree is changed directly: every change is an **event**, and events are committed in **batches** - one batch per transaction, numbered by `seq`, each naming the one before it in `prev`. The log holds one lock, so changes from any number of threads land in one order.
|
|
31
|
+
|
|
32
|
+
The tree as it stands is what you get by folding every batch, in order. A batch with `prev` 0 starts over from nothing, which is how a **squash** announces itself: the whole tree so far as one batch, which is what a reader that joins late starts from. Folding the squash gives back the same tree.
|
|
33
|
+
|
|
34
|
+
**Outputs** get every batch, in order, outside the lock - so a slow output never holds up a worker that is waiting for the lock, though it does hold up whoever committed (wrap it in `BufferedOutput` if that matters). A terminal redraw, a pipe to a parent process, an SSE stream to a browser and a Sossego stream are all outputs.
|
|
35
|
+
|
|
36
|
+
A task is named by its **path** from the root: `[]` is the root, `["x3k0", "00af"]` a child of the root's child `x3k0`. Each id is four random base36 characters, unique among its siblings, and never reused within a log - so a path is a sound key for a React row, and a pruned task's path never turns up on another one. A handle (`TaskProgress`) is just a log and a path: cheap to make, and equal to any other handle for the same task.
|
|
37
|
+
|
|
38
|
+
Timestamps are for display only. Order is `seq`, `prev`, the order of events in a batch, and the order children were opened in.
|
|
39
|
+
|
|
40
|
+
`require "task_progress"` loads one file. Each class loads when it is first used, and brings what it needs from the standard library with it - `json` comes with `Wire` and `Mount`, `openssl` with `Token` - so a program that only keeps a tree in memory never loads either. `TaskProgress.eager_load!` loads everything at once, for a server that forks its workers and wants them to share the code; `TrackedIO` is skipped when `progressive_io` is not installed.
|
|
41
|
+
|
|
42
|
+
## Tasks
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
root = TaskProgress.new("Import") # a tree with no outputs
|
|
46
|
+
root = TaskProgress.new("Import", outputs: [output]) # see Outputs
|
|
47
|
+
root = TaskProgress.new("Import", outputs: [TaskProgress::SummaryOutput.new { |percent:, message:| job.update!(percent:, message:) }])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`TaskProgress.null` is a silent root, for a library's default.
|
|
51
|
+
|
|
52
|
+
### Opening tasks
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
task = root.subtask("Rows", total: 120)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Option | |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `total:` | Counted: driven with `advance`/`tick`, reads `count / total`. |
|
|
61
|
+
| `weight:` | How much of the parent it is; 1 by default. |
|
|
62
|
+
| `expect:` | How many children it will have. Those not opened yet count as zero, so the bar does not jump back when they open. |
|
|
63
|
+
| `indeterminate:` | No measure of its own: counts as zero until done, and draws as a spinner or a sliding bar. |
|
|
64
|
+
| `unit:` | `"bytes"` makes counts read as `12 MB / 40 MB`. |
|
|
65
|
+
| `cancelable:` | May be offered for canceling - see [Canceling from outside](#canceling-from-outside). |
|
|
66
|
+
| `autofinish:` | `false` stops a counted task finishing at its total - see [Finishing](#finishing). |
|
|
67
|
+
|
|
68
|
+
With a block, `subtask` runs the block as the task's work and returns its value (see `run`).
|
|
69
|
+
|
|
70
|
+
### Changing them
|
|
71
|
+
|
|
72
|
+
| | |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `update(percent, message:)` | Sets the percent of an uncounted task. 100 finishes it. |
|
|
75
|
+
| `advance(n = 1, message:)`, `tick(message:)` | Adds to the count. |
|
|
76
|
+
| `note(message)` | The latest thing it says, shown on its row. |
|
|
77
|
+
| `puts(message)` | A line printed above the tree, which stays. The tree does not keep it - see [Outputs](#outputs). |
|
|
78
|
+
| `total=`, `name=` | |
|
|
79
|
+
| `done!(message)`, `fail!(error, **extra)`, `cancel!(reason)` | See [Finishing](#finishing) and [Canceling](#canceling). |
|
|
80
|
+
| `transaction { }` | Everything in the block lands as one batch, or none of it does. |
|
|
81
|
+
|
|
82
|
+
`fail!` takes an exception, a message, or nothing. An exception keeps its class, message, backtrace and up to three causes. `extra` goes along with the error: booleans and integers only, a reader drops the rest.
|
|
83
|
+
|
|
84
|
+
Anything that starts work - a percent, a count, a note, finishing - starts the task and every task above it. A pending task only becomes active when something happens to it.
|
|
85
|
+
|
|
86
|
+
### Reading them
|
|
87
|
+
|
|
88
|
+
`name`, `status` (`:pending`, `:active`, `:done`, `:failed`, `:canceled`), `percent`, `fraction` (unrounded, 0..1), `count`, `total`, `message`, `error`, `attempt`, `failed?`, `canceled?`, `finished?`, `complete?`, `cancelable?`, `parent`, `root`, `subtasks`, `find(path)`, `outline` and `node` (the whole record, frozen). Reads come from a frozen snapshot, so they are safe from any thread.
|
|
89
|
+
|
|
90
|
+
`finished?` is the task's own status: done, failed or canceled. `complete?` is the whole subtree: the task done, and everything under it ended - which is what reads 100%. `cancelable?` is the attribute, and not finished yet.
|
|
91
|
+
|
|
92
|
+
`find` takes a path under this task, as an Array of ids or as the ids joined with a dash (`"x3k0-00af"`) - the way the browser client keys its rows.
|
|
93
|
+
|
|
94
|
+
### Transactions
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
root.transaction do
|
|
98
|
+
current.done!
|
|
99
|
+
following.update(0, message: "Starting")
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Readers see both changes or neither, never a frame with nothing running in between. A transaction opened inside another one on the same Fiber joins it. A block that raises rolls back everything it did. The block holds the log's lock throughout: keep it short, and never wait in it on another thread that writes to the same log.
|
|
104
|
+
|
|
105
|
+
## How far along a task is
|
|
106
|
+
|
|
107
|
+
- A task with children is the weighted mean of its children; children it was told to `expect` and has not opened yet count as zero.
|
|
108
|
+
- A counted task without children is `count / total`.
|
|
109
|
+
- An indeterminate one counts as zero until it is done.
|
|
110
|
+
- Anything else goes by its percent.
|
|
111
|
+
- A task reads 100% only once it is **complete**: done itself, and everything under it ended. Until then it is held at 99, so a task that is 99.6% of the way never rounds up to look finished.
|
|
112
|
+
|
|
113
|
+
A task whose children all finished is not complete until it is done itself - it may have more to do after them. A failed or canceled child does not hold back a parent that went on to finish regardless.
|
|
114
|
+
|
|
115
|
+
## Finishing
|
|
116
|
+
|
|
117
|
+
Every task finishes by an event of its own - `done`, `fail` or `cancel`. You rarely send them by hand:
|
|
118
|
+
|
|
119
|
+
- A **block** (`subtask { }`, `run { }`, `cli { }`) is done when it returns, failed with the error when it raises, and canceled on Ctrl-C.
|
|
120
|
+
- A **counted task** is done once its count reaches its total. Pass `autofinish: false` when the total is a guess and the work may run past it. Ticks past an automatic finish are ignored.
|
|
121
|
+
- **`done!`** finishes the task and every task still open under it, each with a `done` of its own, deepest first, in one batch. A mounted subtree is left to its remote.
|
|
122
|
+
- **`fail!`** cancels what is still open under the task - those tasks did not fail, they were cut short.
|
|
123
|
+
- **`bar`, and `cli` without a block,** are finished when the program exits: done if it ends well, failed with the error if it is going down with one.
|
|
124
|
+
- A **mount** is finished by its remote, or by the parent - see [Other processes](#other-processes-and-ractors).
|
|
125
|
+
|
|
126
|
+
Finishing a finished task does nothing. Anything else on one raises.
|
|
127
|
+
|
|
128
|
+
## Bars, loops and IO
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
bar = TaskProgress.bar("Importing", total: rows.size) # one bar, no block; finishes at its total
|
|
132
|
+
bar = TaskProgress.bar("Streaming") # no total: counts and spins
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`bar` takes `io:` (`$stderr` by default) and `quiet:`, and picks its output like `cli` does.
|
|
136
|
+
|
|
137
|
+
### iterate
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
TaskProgress.bar("Importing", total: rows.size).iterate(rows) { |row| import(row) } # a bar over a collection
|
|
141
|
+
root.iterate(files, "Files") { |file| copy(file) } # a subtask
|
|
142
|
+
root.iterate(rows, "Rows").map { transform(it) } # without a block, an Enumerator
|
|
143
|
+
root.iterate(downloader, "Download", total: bytes, step: CHUNK)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The total comes from `size`, never `count` - so a lazy enumerator stays lazy, and one with no size just counts. The task is done when the loop ends and failed with the error if the block raises. It advances in steps of a thousandth of the total, not once per item, so a loop over cheap items is not held up by its own progress; each item counts `step:` (1).
|
|
147
|
+
|
|
148
|
+
### track_io
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
download.run { IO.copy_stream(download.track_io(file), dest) }
|
|
152
|
+
download.track_io(file) { |io| ZipKit::FileReader.read_zip_structure(io: io) } # done when the block returns
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Reading the IO advances the task by bytes: `read`, line reads, `readpartial` and so `IO.copy_stream`. Readers seek - ZipKit reads the end of an archive first - so only the furthest position counts. It moves in steps of 0.1% of the total. It needs the optional `progressive_io` gem and an IO with a position; for a pipe or a socket, `advance(chunk.bytesize)` as you go.
|
|
156
|
+
|
|
157
|
+
## On the command line
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
TaskProgress.cli("Import") { |root| ... } # the block is the root's work
|
|
161
|
+
root = TaskProgress.cli("Import") # or not: then finished at exit
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`cli` picks its output from where it runs:
|
|
165
|
+
|
|
166
|
+
- **Spawned by `Mount#spawn`** (`TASK_PROGRESS_FD` is set): batches up the pipe, and cancel requests read from the control pipe.
|
|
167
|
+
- **On a terminal**: `TerminalOutput`.
|
|
168
|
+
- **Piped, or on a terminal with `CI` set**: `ExecutionLogOutput`.
|
|
169
|
+
- **`quiet: true`**: nothing.
|
|
170
|
+
|
|
171
|
+
### Terminal
|
|
172
|
+
|
|
173
|
+
Redraws the tree in place from a ticker thread of its own, ten times a second, so spinners turn while nothing reports. A finished subtree folds into its one line; under a finished node only what failed shows, so a failure leaves the way down to it. When the tree is taller than the screen, finished lines are left out first, then the ones not started. Counted tasks show their rate and time left. When the root finishes the tree is drawn one last time and left on screen, or erased with `erase: true`. Lines a task `puts` go above the tree and stay.
|
|
174
|
+
|
|
175
|
+
### Execution log
|
|
176
|
+
|
|
177
|
+
For a CI log, or anything read top to bottom after the fact: one finished line per thing worth saying, never a redraw, each led by the overall percent and the time since the root opened.
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
[ 31% 3.0s] Import › Rows: row 119
|
|
181
|
+
[100% 9.7s] ✓ Import › Rows 9.7s
|
|
182
|
+
[ 67% 10.2s] ✗ Import › Engine: watermarking failed
|
|
183
|
+
[ 70% 10.3s] ⊘ Import › Publish: Ctrl-C
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Finishing, failing, canceling and `puts` lines go out at once, every time - though of a task and the ones under it ending in one batch, only the task says so. Notes and counts are only ever the latest news, so at most one of those goes out per `interval` (3 seconds), the newest. After `heartbeat` (60 seconds) with nothing to say it says it is still going, so a CI runner does not take a long quiet step for a hang.
|
|
187
|
+
|
|
188
|
+
## Outputs
|
|
189
|
+
|
|
190
|
+
An output is anything with `call(batch, log)`. It gets every batch the log commits, in order, outside the lock, and may read `log.snapshot` for the tree as it now stands.
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
root.add_output(output) # the tree so far as one squashed batch, then every batch after it
|
|
194
|
+
root.add_output(output, replay: false) # only what comes next
|
|
195
|
+
root.add_output { |batch, log| ... }
|
|
196
|
+
root.remove_output(output)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
An output that raises does not stop the others; `log.on_error = ->(error, output) { }` hears about it (it warns by default).
|
|
200
|
+
|
|
201
|
+
| Output | |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `TerminalOutput.new(io, interval: 0.1, erase: false)` | The redrawn tree. `interval: nil` redraws on every batch, with no thread. |
|
|
204
|
+
| `ExecutionLogOutput.new(io, interval: 3, heartbeat: 60)` | Finished lines, for CI and pipes. |
|
|
205
|
+
| `SummaryOutput.new { \|percent:, message:\| }` | The root's percent and the latest thing said, called only when either changes. For a progress column in a database. |
|
|
206
|
+
| `NDJSONOutput.new(io)` | One batch per line, for a Mount at the other end of a pipe. |
|
|
207
|
+
| `PublishingOutput.new(stream, event_name: "task_progress")` | Each batch as a JSON-ready Hash to anything with `publish(name, payload)` - a Sossego stream. |
|
|
208
|
+
| `StreamOutput.new(stream, key, heartbeat:)` | Each batch to a stream, with snapshots for late readers - what `handoff` and `adopt` write through, see [Handing work off](#handing-work-off). |
|
|
209
|
+
| `RecordingOutput.new` | Keeps every batch, for a test - see [Testing](#testing). |
|
|
210
|
+
| `BufferedOutput.new(output, interval: 0.25)` | A thread between the log and a slow output. What piles up while it works goes out squashed into one batch: the prunes, then a put for every node touched. |
|
|
211
|
+
| `RactorOutput.new(port)` | Each batch as a deep frozen copy to a `Ractor::Port`. Ruby 4.0 only. |
|
|
212
|
+
| `TaskProgress::SSE` | Not an output but a Rack body - see [Server-Sent Events](#server-sent-events). |
|
|
213
|
+
|
|
214
|
+
`puts` lines travel as a `say` event, which changes nothing in the tree: a squash drops them, the way a terminal's scrollback is gone once a program exits. The terminal and the execution log print them; a mount and a buffer pass them on.
|
|
215
|
+
|
|
216
|
+
## Streaming a branch
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
upload.add_output(output)
|
|
220
|
+
TaskProgress::SSE.new(upload)
|
|
221
|
+
root.log.squashed(upload.path)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Any task can be streamed as a tree of its own: the task becomes the root at `[]`, batches that do not touch it are left out, and the branch numbers its batches itself - its log id is the log's with the path appended - so a reader sees no gaps. That is a Mount run backwards: a Mount fed from a branch grafts it back whole. Pruning an ancestor ends the branch, with one last empty reset.
|
|
225
|
+
|
|
226
|
+
## Other processes and Ractors
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
engine = root.mount("Engine", weight: 3, cancelable: true)
|
|
230
|
+
status = engine.spawn(RbConfig.ruby, "engine.rb", grace: 5) # returns its Process::Status
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A **mount** is a node whose subtree is fed from elsewhere. The remote's root lands on the mount node and its `["x3k0"]` on `[*mount, "x3k0"]` - mounting is prefixing, and a relay through several processes prefixes once per hop. The mount node is sealed: only its Mount writes under it, and the remote cannot rename or reweigh it. Each remote batch is committed here as one batch, or dropped whole if it does not apply. The remote's times are moved onto this clock by the gap between its commit time and ours.
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
mount << batch # a Batch, its Hash form, or a line of NDJSON
|
|
237
|
+
# => :applied, :duplicate, :gap or :rejected
|
|
238
|
+
mount.read(io) # reads NDJSON until EOF
|
|
239
|
+
mount.stale?
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
A batch already applied is a duplicate - ordinary after a reconnect. One that does not follow the last is a gap: the subtree is marked stale, and stays so until a batch with `prev` 0 replaces it whole. Anything the remote sends is distrusted (see [The wire format](#the-wire-format)).
|
|
243
|
+
|
|
244
|
+
### Spawn
|
|
245
|
+
|
|
246
|
+
`spawn` starts a command with `TASK_PROGRESS_FD=3` pointing at a pipe it follows, and `TASK_PROGRESS_CONTROL_FD=4` pointing at one it writes cancel requests to. The child uses `TaskProgress.cli`, which finds both; neither pipe is handed down to processes the child starts. Its stdout and stderr stay its own.
|
|
247
|
+
|
|
248
|
+
- A child that exits without finishing its root fails the mount. One that exits with an error fails it with that.
|
|
249
|
+
- Canceling the mount asks the child to cancel its root, and sends TERM if it is still running `grace` seconds later. The pid is only ever signaled while it is still ours.
|
|
250
|
+
- Interrupted (Ctrl-C, a timeout), `spawn` terminates the child at once and cancels or fails the mount.
|
|
251
|
+
- The parent may end the mount itself, when the remote went away: `mount.task.done!`, `fail!`, `cancel!`. Whatever the remote sends after that changes nothing.
|
|
252
|
+
|
|
253
|
+
### Ractors
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
port = Ractor::Port.new
|
|
257
|
+
Ractor.new(port) do |port|
|
|
258
|
+
root = TaskProgress.new("worker", outputs: [TaskProgress::RactorOutput.new(port)])
|
|
259
|
+
...
|
|
260
|
+
end
|
|
261
|
+
mount << port.receive
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Ractors need Ruby 4.0. On 3.4 they corrupt the VM - a `super` into a C constructor while Ractors run brings the process down, sometimes minutes later - so `RactorOutput` refuses to start there. Everything else runs on 3.4.
|
|
265
|
+
|
|
266
|
+
A Ractor may be the first to touch a class - `RactorOutput`, `Log`, `State` - and so load it: on 4.0 a `require` from a Ractor other than the main one is handed to the main Ractor, which loads the file while the Ractor waits. The main Ractor can be blocked on `port.receive` and it still works. To keep loading out of the Ractors altogether, call `TaskProgress.eager_load!` before starting them.
|
|
267
|
+
|
|
268
|
+
## Handing work off
|
|
269
|
+
|
|
270
|
+
Create a tree here, and let whatever does the work - a job, a worker, another machine - write to it there:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
import = TaskProgress.handoff("Importing", cancelable: true) # in the controller
|
|
274
|
+
ImportJob.perform_later(upload, import) # ActiveJob takes the task itself
|
|
275
|
+
|
|
276
|
+
class ImportJob < ApplicationJob
|
|
277
|
+
def perform(upload, import) # ...and gets it back adopted
|
|
278
|
+
import.subtask("Rows", total: upload.rows) { |rows| ... }
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The tree lives in a **stream** - Redis in production - under its `key`, so anything that reaches the same stream can follow it: a page, an SSE or polling endpoint, a terminal, a parent's tree. Until a worker takes it up it waits as `pending`, which is what a page shows while the job sits in the queue.
|
|
284
|
+
|
|
285
|
+
| | |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `TaskProgress.handoff(name, **attributes)` | A root to hand off. `root.key` names it: `"task_progress:{k7p2qd9z4m}"`. |
|
|
288
|
+
| `TaskProgress.adopt(key) { \|root\| ... }` | Takes it up, as a root of its own with the name and attributes it was handed off with. The block is its work, as with `run`. Without a block, call `root.log.close` when the work is over. |
|
|
289
|
+
| `TaskProgress.follow(key)` | A tree mirroring it, kept up to date on a thread of its own. `log.close` stops following. |
|
|
290
|
+
| `TaskProgress.fetch(key)` | The same, read once - for a page, or a single poll. |
|
|
291
|
+
| `mount.follow_stream(stream, key)` | Mounts it under a node of a live tree, see [Other processes](#other-processes-and-ractors). |
|
|
292
|
+
|
|
293
|
+
Adopting **starts the tree over**: the worker writes a fresh log whose first batch has `prev` 0, and followers replace what they had. That is what makes a retry clean. Whoever wrote before - the side that handed it off, a run that was given up on - is ignored from then on (a mount fed from the stream answers its batches with `:superseded`, a fifth outcome next to those of `<<`), so two writers never interleave.
|
|
294
|
+
|
|
295
|
+
### The stream store
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
TaskProgress.stream_store = TaskProgress::RedisStreamStore.new(RedisClient.config(url: ENV["REDIS_URL"]).new_pool)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The stream store is where handed-off trees are kept: one connection, set once, the way `Rails.cache` is - and every tree in it a stream of its own, under a key of its own, which nothing else reads or writes. Two users' imports share a Redis and a connection pool, and nothing more. Any call that touches it takes `stream_store:`, for a tree, a tenant or a test kept somewhere else.
|
|
302
|
+
|
|
303
|
+
Until it is set, `TaskProgress.stream_store` is a `MemoryStreamStore` - good for one process (the tests, the bench, an app that runs its jobs in the process that serves its pages) and for nothing else. In production (`RAILS_ENV`, `RACK_ENV` or `APP_ENV`) an unset one raises rather than quietly keep trees where no other process can see them; set a `MemoryStreamStore` yourself if that is what you mean. In Rails, `config.task_progress.stream_store` sets it too. `RedisStreamStore` takes a [redis-client](https://github.com/redis-rb/redis-client) connection or pool, or a redis-rb `Redis`; neither is a dependency, and nothing loads one. It needs Redis 6.2 or later. A follower waiting for news holds its connection while it waits, so size the pool for the followers you expect at once; a redis-rb `Redis` is a single connection, which a follower would block for everything else.
|
|
304
|
+
|
|
305
|
+
Under one key, `StreamOutput` keeps:
|
|
306
|
+
|
|
307
|
+
| Key | |
|
|
308
|
+
|---|---|
|
|
309
|
+
| `task_progress:{id}` | Every batch, one stream entry each. |
|
|
310
|
+
| `…:snapshot` | The tree squashed into one batch, and the entry it is as of. A follower starts here. |
|
|
311
|
+
| `…:beat` | There while the worker is alive, gone a few seconds after it is not. |
|
|
312
|
+
| `…:control` | Cancel requests, the other way. |
|
|
313
|
+
|
|
314
|
+
The braces make the id a Redis Cluster hash tag, so all five land on one node. `RedisStreamStore.new(redis, namespace: "tenant-42")` puts every key under a namespace as well, for apps or tenants that share a Redis and must stay apart even so. A snapshot is taken at the first batch, every two seconds after and when the root finishes, and each one trims the stream back to the one before - so however spammy the work, the stream stays a couple of seconds long. Once the root has finished, it all expires after a day (`retain:`). A worker sends its batches squashed, ten times a second at most (`interval:`).
|
|
315
|
+
|
|
316
|
+
Another kind of stream store is anything with `append(key, data) -> id`, `read(key, after:, timeout:) -> [[id, data]]`, `get`, `set(key, value, expires_in:)`, `delete`, `trim(key, before:)` and `expire(key, seconds)`.
|
|
317
|
+
|
|
318
|
+
### When the worker goes away
|
|
319
|
+
|
|
320
|
+
A worker beats every second (`heartbeat:`), and takes the cancel requests that came while it does. A follower that has seen it beat and then hears nothing marks the tree stale, and after a minute of that (`give_up_after:`) fails it - "Lost touch with whoever was working on it". A tree nobody has taken up yet is waiting, not silent, and never goes stale.
|
|
321
|
+
|
|
322
|
+
A failed tree is still followed: if the work is **retried**, the new run replaces the old one, its root says which attempt it is (`root.node.attempt`, drawn as "attempt 2" from the second on), and nothing of the lost run stays mixed in. A tree that is done or canceled is followed no more.
|
|
323
|
+
|
|
324
|
+
Canceling a follower's root, or one of its cancelable tasks, sends the request back through the stream; the worker cancels it the next time it beats, and the cancel comes back down.
|
|
325
|
+
|
|
326
|
+
### ActiveJob
|
|
327
|
+
|
|
328
|
+
With Rails, the Railtie does all of this; without it, call `TaskProgress::JobSerializer.install` once ActiveJob is loaded.
|
|
329
|
+
|
|
330
|
+
- A tree from `handoff` goes to a job as an argument like any other, and into the queue as its key. A task that lives in this process alone cannot: `ActiveJob::SerializationError`.
|
|
331
|
+
- Out of the queue it comes adopted, and the job's `perform` is the root's work: done when it returns, failed with the error when it raises. A canceled task stops the job without failing it.
|
|
332
|
+
- A retry - `retry_on`, or the queue's own - adopts it again, as the next attempt.
|
|
333
|
+
|
|
334
|
+
### Other job runners
|
|
335
|
+
|
|
336
|
+
Pass the key, and adopt it with a block:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
Importer.perform_async(upload.id, import.key)
|
|
340
|
+
|
|
341
|
+
def perform(upload_id, key)
|
|
342
|
+
TaskProgress.adopt(key) { |import| ... }
|
|
343
|
+
end
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`TaskProgress.working_on(arguments) { ... }` is what wraps every ActiveJob: it runs the block as the work of every adopted root among the arguments, and closes them after - for a runner with middleware of its own.
|
|
347
|
+
|
|
348
|
+
### Watching from a terminal
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
$ task_progress watch "task_progress:{k7p2qd9z4m}" --redis redis://localhost:6379
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Draws the tree wherever it runs - a terminal, or a CI log - until it finishes, and exits 0 if it was done. Ctrl-C stops watching and leaves the work alone. Needs the redis-client gem.
|
|
355
|
+
|
|
356
|
+
## Canceling
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
task.cancel!("Not needed")
|
|
360
|
+
task.canceled?
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`cancel!` cancels the task and every task still open under it, each with a `cancel` of its own carrying the reason, in one batch, from any thread. Calling off a task that never began does not begin it. Canceling reaches into mounts: a mount canceled from here asks its child to stop.
|
|
364
|
+
|
|
365
|
+
A cancel never stops a thread from outside. It marks the task canceled, and the work stops when it next notices: whoever touches a canceled task next - a tick, a note, opening a subtask - gets a `TaskProgress::Canceled`. `run` takes that as the end of that task's work and returns `nil`; the code after it carries on. A task further up being canceled stops the work in between as well.
|
|
366
|
+
|
|
367
|
+
`Canceled` is an `Exception`, like `Interrupt`, so a `rescue => e` around one step cannot swallow it and carry on with the next. Ctrl-C (`Interrupt`) in a `run` block cancels rather than fails.
|
|
368
|
+
|
|
369
|
+
```ruby
|
|
370
|
+
task.on_cancel { |reason| ... }
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Calls the block once the task is canceled - at once, if it already was. A task that ends any other way drops it. It runs on whichever thread delivers the cancel, after it was committed: keep it short, and let it unblock the work rather than do the work. One that raises does not stop the others.
|
|
374
|
+
|
|
375
|
+
## Writing work that can be canceled
|
|
376
|
+
|
|
377
|
+
Most work notices a cancel on its own; the rest needs a line or two.
|
|
378
|
+
|
|
379
|
+
**Work that reports as it goes needs nothing.**
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
root.subtask("Rows", total: rows.size, cancelable: true) do |t|
|
|
383
|
+
rows.each { import(it) && t.tick }
|
|
384
|
+
end
|
|
385
|
+
root.subtask("Publish") { publish! } # still runs if Rows was canceled
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
**Clean up in `ensure`, or rescue `Canceled` for what only a cancel should undo.** Re-raise it, so `run` sees it:
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
root.subtask("Export", cancelable: true) do |t|
|
|
392
|
+
File.open(tmp, "w") { |f| rows.each { f.puts(it) && t.tick } }
|
|
393
|
+
rescue TaskProgress::Canceled
|
|
394
|
+
File.delete(tmp) # no half-written export
|
|
395
|
+
raise
|
|
396
|
+
end
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Work that reports rarely can ask.** `canceled?` reads the tree without raising:
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
poll.run { sleep(1) until poll.canceled? || ready? }
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**Work that blocks needs unblocking.** A call that waits - an HTTP request, a socket read, a queue - will not touch its task until it returns. Give it an `on_cancel` that ends the wait. Once a task is canceled, whatever its work raises as it unwinds is taken as the cancel working, not as a failure:
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
fetch.run do |t|
|
|
409
|
+
http = Net::HTTP.start(host)
|
|
410
|
+
t.on_cancel { http.finish } # the request in flight raises, and the task ends as canceled
|
|
411
|
+
http.get(path)
|
|
412
|
+
end
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
For a wait nothing can interrupt, raise into the thread: `me = Thread.current; t.on_cancel { me.raise(TaskProgress::Canceled.new(t)) }`.
|
|
416
|
+
|
|
417
|
+
**Once you have caught it, stop.** The exception is the cancel: canceling only marks the task, and `Canceled` unwinding the work out of its loop is what actually stops it. Swallow it and carry on, and the work runs to the end while every reader of the tree is told it was canceled - a catch-all `rescue Exception` around each row imports all 100 rows of a task canceled at the 22nd. A `rescue Exception => e; retry` wrapper is worse: it retries into the canceled task, and spins.
|
|
418
|
+
|
|
419
|
+
Re-raising is the simplest way to stop, since `run` then ends the task. Breaking out, or returning from the block, does as well - which makes room to finish the step at hand first:
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
rows.each do |row|
|
|
423
|
+
import(row)
|
|
424
|
+
rows.tick
|
|
425
|
+
rescue TaskProgress::Canceled
|
|
426
|
+
break # this row is whole - stop before the next one
|
|
427
|
+
end
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
**Threads and child processes follow.** Workers on threads of their own each stop at their next tick. A child process gets the request over its control pipe, and the same patterns apply inside it. A child too stuck to notice is sent TERM after the mount's `grace`, which Ruby raises inside the work like any other exception.
|
|
431
|
+
|
|
432
|
+
**Afterwards, `canceled?` tells what happened**, since `run` returns `nil` for a canceled task:
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
report = root.subtask("Report", cancelable: true) { |t| build_report(t) }
|
|
436
|
+
mail(report) unless root.subtasks.last.canceled?
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
## Canceling from outside
|
|
440
|
+
|
|
441
|
+
A browser, or a parent process, can only *ask*. The tree that owns the task decides, and its answer is the `cancel` event coming back up the stream - nothing waits for a reply.
|
|
442
|
+
|
|
443
|
+
```ruby
|
|
444
|
+
root.request_cancel(path, "From the browser")
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
| Returns | |
|
|
448
|
+
|---|---|
|
|
449
|
+
| `:canceled` | It was, along with everything still open under it. |
|
|
450
|
+
| `:forwarded` | It lives in a mounted child, which was asked. Its cancel comes back up the stream, if it comes. |
|
|
451
|
+
| `:moot` | There is nothing left to cancel: no such task, or it has already ended, or the child it ran in has exited. |
|
|
452
|
+
| `:refused` | It is not `cancelable`. |
|
|
453
|
+
|
|
454
|
+
It decides in one transaction against the tree as it stands, so it cannot race the task finishing: the outcome is canceled or moot, never both. A request that comes too late is moot, not an error - by the time it arrives the task may well have finished and its process exited. A request for a task under a mount goes down that mount's control pipe, and on through nested mounts, each hop taking off its own prefix; the child decides in turn. When the parent asks for a child's root, the mount was ended there, so the child cancels its root whether it is cancelable or not.
|
|
455
|
+
|
|
456
|
+
`accept_cancel_requests(io)` reads requests off any IO, one JSON line each (`{"cancel": [ids], "reason": "..."}`), on a thread of its own. `cli` uses it for the control pipe.
|
|
457
|
+
|
|
458
|
+
### Over HTTP
|
|
459
|
+
|
|
460
|
+
Only `cancelable` tasks can be asked for. Sign the right to ask, so a cancel cannot be forged or replayed:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
tokens = TaskProgress::Token.new(ENV.fetch("TASK_PROGRESS_SECRET")) # 32 bytes or more
|
|
464
|
+
token = tokens.issue(root.log.id, expires_in: 3600) # into the page, next to the stream URL
|
|
465
|
+
tokens.verify(params[:token]) # => what it was issued for, or nil
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
A token is an HMAC-SHA256 over a subject - a log id, a run - and an expiry, checked in constant time. It says the bearer may ask for tasks in that tree to be canceled; which tasks is for `cancelable` to say. Issue one per run, so a page left open from one run cannot reach the next.
|
|
469
|
+
|
|
470
|
+
`CancellationsApp` takes the requests for a tree that runs in another process, as Rails' cancel buttons do. A cancellation is a resource you create, not a call:
|
|
471
|
+
|
|
472
|
+
```
|
|
473
|
+
POST /task_progress/cancellations path=x3k0-00af&token=... (a form, or JSON)
|
|
474
|
+
202 {"path": "x3k0-00af", "state": "requested"} (a Turbo form gets an empty Turbo Stream instead)
|
|
475
|
+
403 no token, a mangled one, or an expired one
|
|
476
|
+
422 a malformed path
|
|
477
|
+
405 anything but a POST
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
It cannot cancel anything itself: it leaves the request in the store, where the run it is for picks it up - or does not, if it has ended, and the request expires after ten minutes. Either way the outcome shows in the tree.
|
|
481
|
+
|
|
482
|
+
For a tree in the serving process, call `request_cancel` and answer with what came of it - the bench does, in `examples/web/app.rb`. For one handed off, `TaskProgress.fetch(key).request_cancel(path)` sends the request through the stream, see [RECIPES](RECIPES.md).
|
|
483
|
+
|
|
484
|
+
## Server-Sent Events
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
TaskProgress::SSE.response(task) # Rack
|
|
488
|
+
TaskProgress::SSE.response(key) # a tree handed off - 404 for a key with nothing at it
|
|
489
|
+
|
|
490
|
+
headers.merge!(TaskProgress::SSE::HEADERS) # Rails
|
|
491
|
+
self.response_body = TaskProgress::SSE.new(task)
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
First the tree so far, squashed into one batch with `prev` 0, then every batch as it commits - each in a `batch` event whose `id:` is its seq. A browser that reconnects starts over from a fresh squash, which is always right and costs a few puts. It pings every `heartbeat:` seconds (15) while nothing happens, tells the browser to come back after `reconnect:` seconds (2) if the connection drops, and ends once the task it streams has finished - or at once, with an empty tree, for a task that is no longer there. Given a subtask, it streams only that branch.
|
|
495
|
+
|
|
496
|
+
Given a key, it follows the tree handed off there for as long as it streams, and stops when the stream ends or the server closes it. A client that stops reading gets a fresh squash when it reads on, rather than a backlog that grows for ever.
|
|
497
|
+
|
|
498
|
+
## Polling
|
|
499
|
+
|
|
500
|
+
For where SSE does not get through - a proxy that buffers, a host that cuts long responses, a client that is not a browser:
|
|
501
|
+
|
|
502
|
+
```ruby
|
|
503
|
+
TaskProgress::Poll.response(task, env) # Rack
|
|
504
|
+
TaskProgress::Poll.response(key, env) # a tree handed off - 404 for a key with nothing at it
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Every answer is the whole tree, squashed into one batch with `prev` 0, with an ETag that says which tree it is. Sent back in `If-None-Match` - weak (`W/"..."`, as a compressing proxy makes it) or not - it gets a 304 until something changes. With `?wait=N` the request is held up to N seconds (`max_wait:`, 25) for a change - long polling, which holds a server thread that long, but answers the moment there is news.
|
|
508
|
+
|
|
509
|
+
```js
|
|
510
|
+
followPollingAt("/jobs/42/poll", (tree, changes) => draw(tree), { poll: 1 }) // every second
|
|
511
|
+
followPollingAt("/jobs/42/poll", draw, { wait: 25 }) // long polling
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Everything is in seconds. A poll that fails is tried again later and later, up to half a minute apart; long polls go out a quarter of a second apart at the least. Polling stops once the root has finished, or the task is gone. The Stimulus controller polls given a `poll` value (seconds between polls) or a `wait` value (to long-poll); `TaskTreeView` and `useTaskTree` take `poll` and `wait` too.
|
|
515
|
+
|
|
516
|
+
## A handed-off tree on a page
|
|
517
|
+
|
|
518
|
+
```ruby
|
|
519
|
+
TaskProgress.for_page(video.progress_key)
|
|
520
|
+
# => {events: "/task_progress/trees/eyJ…/events", poll: "…/poll", cancel: "…/cancellations",
|
|
521
|
+
# snapshot: {v: 1, log: …, prev: 0, seq: …, ops: […]}}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Everything a page needs to show a handed-off tree, as JSON-ready data: a prop for an Inertia or React page, `data-task-tree-progress-value` for the Stimulus controller, `useTaskTree(progress)` / `<TaskTreeView progress>` / `followProgress(progress, draw)` in the browser. `snapshot` is the tree as it stands, so the page draws it before anything comes down the stream. The client follows it over Server-Sent Events, and falls back to polling by itself when those cannot be reached - an error, or nothing at all for five seconds, as from a proxy that buffers.
|
|
525
|
+
|
|
526
|
+
**The tree's key never reaches the page.** The links carry a `Token` - an HMAC over the key and an expiry (`expires_in:`, an hour) - and `TreesApp` answers them:
|
|
527
|
+
|
|
528
|
+
```
|
|
529
|
+
GET /task_progress/trees/:token/events Server-Sent Events
|
|
530
|
+
GET /task_progress/trees/:token/poll polled or long-polled
|
|
531
|
+
POST /task_progress/trees/:token/cancellations path=x3k0-00af
|
|
532
|
+
202 requested 200 moot 409 not cancelable 403 a bad token 404 no such tree
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Issuing the token is the authorization: call `for_page` where you have decided the page may see the tree, as you would before rendering anything else of the record's. The Railtie mounts `TreesApp` (`config.task_progress.trees_path`, nil for none) and signs with the app's `secret_key_base`. Outside Rails, mount it yourself and set `TaskProgress.tokens = TaskProgress::Token.new(secret)` and, if it is not at `/task_progress/trees`, `TaskProgress.trees_path`.
|
|
536
|
+
|
|
537
|
+
A cancel asked of a tree no worker has taken up yet waits for one, which takes it up before it begins the work - so the work never starts.
|
|
538
|
+
|
|
539
|
+
## The browser client
|
|
540
|
+
|
|
541
|
+
Plain ES modules in `js/`, shipped with the gem: `TaskProgress.js_path`. Serve the directory under any prefix - imports inside it are relative. With [Assiette](https://github.com/julik/assiette):
|
|
542
|
+
|
|
543
|
+
```ruby
|
|
544
|
+
use Assiette::Server, root: "public", additional_directory_mappings: {"/task_progress" => TaskProgress.js_path}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
The Stimulus controller and the React component import `@hotwired/stimulus`, `react` and `react-dom/client` by bare name - from `node_modules` in a Vite app, from the import map on a page without a build step. There is no JSX in them.
|
|
548
|
+
|
|
549
|
+
### Getting it into an app
|
|
550
|
+
|
|
551
|
+
**With importmap-rails** there is nothing to do. The Railtie serves `js/` at `/task_progress/js`, and pins it in the app's import map under the same names a bundler would use: `task_progress`, `task_progress/react`, `task_progress/stimulus`, `task_progress/local_tasks`, `task_progress/row_view` and `task_progress/clock`. None of them is preloaded. A pin of the same name in your `config/importmap.rb` wins. `config.task_progress.importmap = false` leaves the map alone.
|
|
552
|
+
|
|
553
|
+
**With a bundler** - Vite, esbuild, Inertia - vendor a copy:
|
|
554
|
+
|
|
555
|
+
```
|
|
556
|
+
$ bin/rails task_progress:vendor # into vendor/javascript/task_progress
|
|
557
|
+
$ npm install ./vendor/javascript/task_progress
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
The copy is a package of its own, named `task_progress`, with the `.d.ts` declarations next to the modules: `import { TaskTreeView } from "task_progress/react"`, `import "task_progress/task_tree.css"`. Commit it. It does not update with the gem, so run the task again after an update. Rails warns at boot while the vendored copy is from another version than the gem it runs with. `config.task_progress.vendor_path` moves it, and `DIR=` sends one run elsewhere.
|
|
561
|
+
|
|
562
|
+
Outside Rails, `task_progress vendor [DIR]` does the same - into `task_progress/` in the current directory, or `vendor/javascript/task_progress` when it is a Rails app's root. `TaskProgress.vendor_js(dir)` from Ruby. Each run replaces what the last left there. A directory holding files of anyone else's is left alone.
|
|
563
|
+
|
|
564
|
+
### task_tree.js
|
|
565
|
+
|
|
566
|
+
```js
|
|
567
|
+
import { followEventSourceAt, followPollingAt, batchFeed, requestCancel, pathKey, TaskTree } from "/task_progress/task_tree.js"
|
|
568
|
+
|
|
569
|
+
const following = followEventSourceAt("/jobs/42/progress", (tree, changes) => draw(tree))
|
|
570
|
+
following.close()
|
|
571
|
+
|
|
572
|
+
const feed = batchFeed((tree, changes) => draw(tree)) // for batches from anywhere: a WebSocket, ActionCable
|
|
573
|
+
feed.push(batch) // => "applied", "duplicate" or "gap"
|
|
574
|
+
|
|
575
|
+
tree.apply(batch) // one batch, folded in place
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
A path's key is its ids joined with a dash - `pathKey(["x3k0", "00af"])` is `"x3k0-00af"`, and `pathFromKey` splits it back. Ids never contain a dash, so it needs no escaping in a URL, a class name or a data attribute. The root's key is the empty string. As a DOM id, prefix it (`task-x3k0-00af`): an id that starts with a digit needs `CSS.escape` in a selector.
|
|
579
|
+
|
|
580
|
+
`TaskTree` folds batches the same way the Ruby side does, and a test holds the two to the same percent for every node. `tree.apply(batch)` returns `"applied"`, `"duplicate"` for one it already has, or `"gap"` - the tree is then `stale` until a batch with `prev` 0 arrives. `followEventSourceAt` reconnects on a gap, and the server starts it over from a fresh squash; with `batchFeed` that is yours to do.
|
|
581
|
+
|
|
582
|
+
A batch in a version of the wire format other than its own is `"unsupported"`, and changes nothing. `get(path)`, `root`, `childrenOf(node)`, `each()` (pre-order), `rows()` (what to draw, see [Big trees](#big-trees)), `percentOf(node)`, `complete(node)`, `fraction(node)`. Elapsed times are measured on the writer's clock: `now()` takes off the smallest gap seen between this clock and a batch's commit time, which has the least time in transit in it.
|
|
583
|
+
|
|
584
|
+
### Drawing once a frame
|
|
585
|
+
|
|
586
|
+
A stream can carry thousands of batches a second. Every batch is still folded as it arrives, but `onChange` is called at most once an animation frame, with what changed since the last call:
|
|
587
|
+
|
|
588
|
+
| `changes` | |
|
|
589
|
+
|---|---|
|
|
590
|
+
| `batches` | How many were folded. |
|
|
591
|
+
| `touched` | Keys (`pathKey(path)`) of the nodes changed, and every one above them. |
|
|
592
|
+
| `events` | What a renderer must not miss even if a later batch in the same frame hides it, in order: `done`, `fail` (with the error), `cancel`, `say`, `reset`, `gap`. |
|
|
593
|
+
| `structure` | Rows came, went or moved. |
|
|
594
|
+
|
|
595
|
+
`RenderQueue` in `render_queue.js` does this; pass `{ schedule }` for a clock other than `requestAnimationFrame`.
|
|
596
|
+
|
|
597
|
+
### Tasks that run in the browser
|
|
598
|
+
|
|
599
|
+
Some work happens in the browser - hashing files before an upload, encoding a video. `LocalTasks` writes tasks there the way the server writes its own, and `graft` draws them under a server task:
|
|
600
|
+
|
|
601
|
+
```js
|
|
602
|
+
import { LocalTasks } from "/task_progress/local_tasks.js"
|
|
603
|
+
|
|
604
|
+
const work = new LocalTasks("Hash files", { cancelable: true, weight: 2 })
|
|
605
|
+
const ungraft = tree.graft(uploadTask.path, work) // or the Stimulus controller's graft(path, work)
|
|
606
|
+
const file = work.root.subtask("video.mp4", { total: blob.size, unit: "bytes", cancelable: true })
|
|
607
|
+
file.advance(chunk.byteLength) // done at its total
|
|
608
|
+
work.root.done()
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
A task does not stop your work by itself - whether a cancel does is yours to wire, or not. `task.signal` is an `AbortSignal` that aborts with the reason once the task is canceled, for whatever you choose to hand it to; `onCancel(handler)` calls you instead; and `canceled` is there for a loop to check:
|
|
612
|
+
|
|
613
|
+
```js
|
|
614
|
+
const response = await fetch(url, { signal: file.signal }) // a cancel aborts the fetch
|
|
615
|
+
file.onCancel((reason) => worker.terminate()) // or does whatever you say
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
The rules are the server's: a counted task is done at its total, `done()` closes everything still open under it, `fail()` and `cancel()` cancel it. Changing a task that has ended does nothing, so a loop checks `canceled`. Task ids start with `_`, which a server id never does: a graft cannot clash with the tree it is grafted to, and `rowView` marks its rows `local`.
|
|
619
|
+
|
|
620
|
+
A graft is drawn as a child of its server task and counts towards it. It is kept apart from what the server sends, so a reset or a reconnect puts it back, and it waits for its task if that has not arrived yet. Two rules hold it to the server's tree:
|
|
621
|
+
|
|
622
|
+
- A server task with browser work still open under it is not through: it reads 99%, and is drawn as running, however soon the server calls it done.
|
|
623
|
+
- A server task that fails or is canceled cancels the browser work under it, and its `onCancel` handlers run.
|
|
624
|
+
|
|
625
|
+
Only this page sees a graft - other viewers, the terminal and the server's own percent know nothing of it. The cancel button on a local task cancels it here, never asking the server. Sharing browser work with the server is in [TODO](TODO.md).
|
|
626
|
+
|
|
627
|
+
React takes grafts as a prop, `<TaskTreeView url={url} grafts={[{ path, local: work }]} />` - an array written inline is fine.
|
|
628
|
+
|
|
629
|
+
Browser work needs no server task to hang from. `followLocal(work, onChange)` follows it on its own, as `followProgress` follows a server's tree; `<TaskTreeView local={work} />` and the Stimulus controller's `show(work)` draw it. Every row of such a tree is local, the top one included, so each cancel button cancels here. [Upload progress, all in the browser](RECIPES.md#upload-progress-all-in-the-browser) is the whole of it.
|
|
630
|
+
|
|
631
|
+
#### Uploads
|
|
632
|
+
|
|
633
|
+
The case nearly everyone has: the server opens a task for the files it waits on, and the browser uploads them. `trackUpload(xhr, task)` follows an `XMLHttpRequest`'s upload events - `fetch` has none for request bodies - and ends the task the way the request ends:
|
|
634
|
+
|
|
635
|
+
```js
|
|
636
|
+
import { LocalTasks, trackUpload } from "/task_progress/local_tasks.js"
|
|
637
|
+
|
|
638
|
+
// The server opened "Receive files" for this, and streams the job's tree to the page
|
|
639
|
+
const uploads = new LocalTasks("Upload from this browser", { cancelable: true })
|
|
640
|
+
application.getControllerForElementAndIdentifier(treeElement, "task-tree").graft(receiveFiles.path, uploads)
|
|
641
|
+
|
|
642
|
+
const sent = [...fileInput.files].map((file) => {
|
|
643
|
+
const xhr = new XMLHttpRequest()
|
|
644
|
+
xhr.open("PUT", `/jobs/42/files/${encodeURIComponent(file.name)}`)
|
|
645
|
+
const task = trackUpload(xhr, uploads.root.subtask(file.name, { unit: "bytes", cancelable: true }), { abortOnCancel: true })
|
|
646
|
+
xhr.send(file)
|
|
647
|
+
return task.whenFinished()
|
|
648
|
+
})
|
|
649
|
+
const endings = await Promise.all(sent) // "done", "failed" or "canceled", one per file
|
|
650
|
+
endings.every((ending) => ending === "done") ? uploads.root.done() : uploads.root.fail(new Error("Not every file made it"))
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
- The task's total is the body's size, learned from the first progress event, and its count the bytes sent. Sending the last byte does not finish it - the server has yet to answer.
|
|
654
|
+
- A 2xx answer finishes it. Any other status fails it with that status, as do a network error and a timeout. An abort cancels it.
|
|
655
|
+
- It only reports, unless told otherwise: with `abortOnCancel: true`, canceling the task aborts the request - its cancel button, `uploads.root.cancel()`, or the server canceling "Receive files", which cancels the whole graft and every upload with it. Without it, a canceled task keeps its ending however the upload goes on.
|
|
656
|
+
- Until every upload has ended and `uploads.root` is done, "Receive files" stays below 100%, even once the server calls it done.
|
|
657
|
+
|
|
658
|
+
`whenFinished()` resolves with how a task ended, and `setTotal(n)` sets a total learned only once the work has started.
|
|
659
|
+
|
|
660
|
+
### row_view.js
|
|
661
|
+
|
|
662
|
+
`rowView(tree, node)` is what one row shows, worked out once for every renderer: `key`, `path`, `depth`, `name`, `status` (a task done with something still running under it shows as `active`), `stale`, `local`, `percent`, `indeterminate`, `measure` (`12 MB / 40 MB`, `33 / 40`, `42%`, an elapsed time), `message` (with the rate and time left for a counted task), `icon`, `cancelable`.
|
|
663
|
+
|
|
664
|
+
### Stimulus
|
|
665
|
+
|
|
666
|
+
```html
|
|
667
|
+
<div data-controller="task-tree"
|
|
668
|
+
data-task-tree-url-value="/jobs/42/progress"
|
|
669
|
+
data-task-tree-cancel-url-value="/jobs/42/cancellations"
|
|
670
|
+
data-task-tree-cancel-token-value="..."></div>
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
```js
|
|
674
|
+
import TaskTreeController from "/task_progress/stimulus_controller.js"
|
|
675
|
+
application.register("task-tree", TaskTreeController)
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
`show(localTasks)` draws tasks running in this browser on their own, in place of a stream, until a value changes - from an outlet, `this.taskTreeOutlet.show(uploads)`. Changing the url value follows the new stream. Draws once a frame at most, and then only the rows that changed. Fires `task-tree:render` after each draw, and `task-tree:cancel` (with the task's `path` and `name`) when a cancel button is pressed - prevent it to handle the cancel yourself; otherwise it creates a cancellation at the cancel-url value.
|
|
679
|
+
|
|
680
|
+
### React
|
|
681
|
+
|
|
682
|
+
```js
|
|
683
|
+
import { TaskTreeView, useTaskTree, createTaskTreeRoot } from "/task_progress/react_task_tree.js"
|
|
684
|
+
|
|
685
|
+
<TaskTreeView url="/jobs/42/progress" cancelUrl="/jobs/42/cancellations" cancelToken={token} />
|
|
686
|
+
<TaskTreeView url="/jobs/42/progress" onCancel={async (row) => ...} /> // return false to release the button
|
|
687
|
+
<TaskTreeView local={uploads} /> // a LocalTasks, with no server
|
|
688
|
+
const tree = useTaskTree(url, { poll, wait, grafts }) // draw it yourself
|
|
689
|
+
createTaskTreeRoot(element).render({ url, cancelUrl, cancelToken }) // on a page that is not a React app
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
### Rows and cancel buttons
|
|
693
|
+
|
|
694
|
+
`task_tree.css` styles the rows both renderers draw: one fixed height, so nothing jumps as glyphs change. The palette sits on `:where(.tree)`, which weighs nothing - any selector of yours overrides it; `--stripe` is the light stripe of the barber pole a task with no measure is drawn as, which crawls along while it runs and stands still for whoever prefers reduced motion. The terminal draws the same pole, `╱╱╱░░░`. A cancelable task that has not finished gets a cancel button right of its bar; the column is there on every row, so bars line up. Pressed, the button stays pressed until the cancel comes down the stream - or comes back to life if the server says no.
|
|
695
|
+
|
|
696
|
+
`requestCancel(url, path, { token })` posts `{path, token}` as JSON, for your own buttons.
|
|
697
|
+
|
|
698
|
+
## Turbo
|
|
699
|
+
|
|
700
|
+
A tree can be drawn by Turbo, with no JavaScript of your own: the server draws it as HTML, and Turbo morphs each frame into the page. It is the easiest way to show a job's progress in a Rails app, and it works across processes - the job draws, Action Cable carries the frames to whichever server holds the page.
|
|
701
|
+
|
|
702
|
+
### In Rails
|
|
703
|
+
|
|
704
|
+
```ruby
|
|
705
|
+
TaskProgress.broadcast(csv_import, "Importing") { |root| ... } # in a job, anywhere
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
```erb
|
|
709
|
+
<%= task_progress_tree @csv_import %> <%# in its page %>
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
The tree belongs to a record - `csv_import` is a `CsvImport`, but any Active Record model will do, usually the one whose page shows the progress. The record names the Action Cable stream, the element's id, and the cache entry the page draws from first.
|
|
713
|
+
|
|
714
|
+
Then put `task_tree.css` on the page, and import `task_progress_clock.js` for elapsed times to tick. The Railtie serves `js/` as it is at `/task_progress/js` (`config.task_progress.js_path`, nil for none): `<link rel="stylesheet" href="/task_progress/js/task_tree.css">`, and `import "task_progress/clock"` - pinned already, with importmap-rails.
|
|
715
|
+
|
|
716
|
+
- **Every frame is the whole tree**, drawn by HTMLRenderer and sent as `<turbo-stream action="replace" method="morph">`, at most every `interval:` seconds (0.25) however spammy the job is. A Turbo Stream has no seq and no gaps to notice; a page that missed a frame, or joined late, is set right by the next one. Morphing changes only what differs, so a bar eases to its new width.
|
|
717
|
+
- **The last frame is kept in `Rails.cache`**, under `task_progress/<dom_id>`, for the helper to draw before the first broadcast arrives.
|
|
718
|
+
- **The stream is `[record, :task_progress]`**, and the tree's element has the id `dom_id(record, :task_progress)`.
|
|
719
|
+
- **Cancel buttons are plain forms.** A cancelable task's button posts its path and a signed token (Token, keyed off the app's `secret_key_base`) to `/task_progress/cancellations`, a route the Railtie adds. The job is in another process, so the route cannot cancel it: it leaves the request in `Rails.cache`, where the job looks for it every `poll:` seconds (1) and decides, as it does for any request. Its next frame shows the cancel. Set `config.task_progress.cancellations_path` to put the route elsewhere, or to nil for none.
|
|
720
|
+
|
|
721
|
+
`to:`, `store:`, `tokens:`, `cancel_url:` and `renderer:` override what Rails would give; `interval:` and `poll:` are the seconds between frames, and between looks for cancel requests. With a block, `broadcast` runs it as the root's work and returns its value; without one it returns the root, finished at exit.
|
|
722
|
+
|
|
723
|
+
### Without Rails
|
|
724
|
+
|
|
725
|
+
Nothing of the above needs Rails - only the defaults do:
|
|
726
|
+
|
|
727
|
+
```ruby
|
|
728
|
+
TaskProgress.broadcast("import-tree", "Importing", to: ->(frame) { hub.publish(frame) }, store: TaskProgress::MemoryCache.new) { ... }
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
`to:` gets each frame, a `<turbo-stream>` element as a String; `store:` is anything with `read`, `write(key, value, expires_in:)` and `delete`, as `Rails.cache` and `MemoryCache` are. `CancellationsApp.new(store:, tokens:)` is the Rack app behind the cancel buttons.
|
|
732
|
+
|
|
733
|
+
For a tree in the serving process, `TurboSSE` streams it straight to a page, over Server-Sent Events:
|
|
734
|
+
|
|
735
|
+
```ruby
|
|
736
|
+
TaskProgress::TurboSSE.response(task, renderer: TaskProgress::HTMLRenderer.new(id: "tree"), source: "tree-source")
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
```html
|
|
740
|
+
<turbo-stream-source id="tree-source" src="/jobs/42/turbo"></turbo-stream-source>
|
|
741
|
+
<div id="tree" class="tree"></div>
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
When the task finishes it sends the last frame and removes the element with the id `source:` - the `<turbo-stream-source>` - so the browser does not reconnect to a finished stream. Without `source:` the browser comes back every `reconnect:` seconds (2), and gets the last frame each time. Given a key instead of a task, it follows the tree handed off there. The bench's third pane is drawn this way.
|
|
745
|
+
|
|
746
|
+
### Drawing rows your own way
|
|
747
|
+
|
|
748
|
+
HTMLRenderer draws the rows the browser client draws, with the same classes. Subclass it and override `row(snapshot, path, node, depth:)`, or pass `renderer:` anything that answers `id` and `render(snapshot, from = [])` - an object that renders one of your partials with `ApplicationController.render`, say.
|
|
749
|
+
|
|
750
|
+
## Big trees
|
|
751
|
+
|
|
752
|
+
Where a task has more than 12 children, every renderer - the terminal, `HTMLRenderer`, the Stimulus controller, the React component - folds them: what is done (save the last three to finish) into one row ahead of the rest, "✓ 9,812 more done", and what is still waiting (save the first three) into one after them, "· 340 more waiting". What is running, failed or canceled is always drawn. `State#rows` and `TaskTree#rows()` make the rows, the same ones on both sides; a folded row's path is its parent's with `~done` or `~pending` after it, which no task id can be.
|
|
753
|
+
|
|
754
|
+
The tree itself keeps every task. What that costs, from `examples/benchmark.rb` on an M-series Mac:
|
|
755
|
+
|
|
756
|
+
| | |
|
|
757
|
+
|---|---|
|
|
758
|
+
| `tick` with no outputs | ~110,000 a second |
|
|
759
|
+
| `tick` adopted, to a stream | ~80,000 a second |
|
|
760
|
+
| `tick` from 4 threads on one tree | ~85,000 a second |
|
|
761
|
+
| `subtask` with 100,000 siblings | ~68,000 a second |
|
|
762
|
+
| Memory | ~800 bytes a task |
|
|
763
|
+
| Squashing 100,000 tasks | 0.9s |
|
|
764
|
+
| Drawing 100,000 siblings, folded, as HTML | 0.3s |
|
|
765
|
+
|
|
766
|
+
A change copies only the tasks it touched, so a snapshot of a big tree costs what changed, not the whole tree. A tree of a hundred thousand tasks is fine to work on; drawing it many times a second is not - put the drawing behind a `BufferedOutput`, as `broadcast` and `adopt` do.
|
|
767
|
+
|
|
768
|
+
## The wire format
|
|
769
|
+
|
|
770
|
+
One batch per line:
|
|
771
|
+
|
|
772
|
+
```json
|
|
773
|
+
{"v":1,"log":"k7p2qd9z4m","prev":11,"seq":12,"at":1730000000.123,"ops":[[["x3k0"],"add",5],[["x3k0"],"note","Reading"]]}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
`v` is the version of the format: 1, until a change comes that a reader of today could not ignore. Readers - `Wire` and `task_tree.js` alike - take their own version only, and drop a batch in any other the way they drop a garbled one. So in a deploy where old workers and new web processes overlap, a newer writer's tree is left alone rather than drawn wrong.
|
|
777
|
+
|
|
778
|
+
| Op | Value | |
|
|
779
|
+
|---|---|---|
|
|
780
|
+
| `open` | `{name, total, weight, expect, indeterminate, unit, cancelable}` | A new node. |
|
|
781
|
+
| `meta` | any of open's, and `stale` | Attributes changed after the fact. |
|
|
782
|
+
| `set` | 0..100 | A percent. |
|
|
783
|
+
| `add` | an integer | A delta to the count. |
|
|
784
|
+
| `note` | text | A message. |
|
|
785
|
+
| `done` | - | |
|
|
786
|
+
| `fail` | an error hash | `{class, message, backtrace, cause}` |
|
|
787
|
+
| `cancel` | text or - | The reason. |
|
|
788
|
+
| `prune` | - | Drop every descendant of the node. |
|
|
789
|
+
| `put` | a whole node | What a squash is made of. |
|
|
790
|
+
| `say` | text | A line to print; changes nothing in the tree. |
|
|
791
|
+
|
|
792
|
+
Reading is for writers we do not trust - a subprocess, a stream relayed from elsewhere - so nothing is taken at its word. A batch with anything wrong in it is dropped whole: `prev` must be below `seq`; at most 100,000 ops and 32 path levels; every id `[0-9a-z]{1,16}`; text scrubbed to UTF-8 and cut at 2,000 characters; numbers clamped; at most 200 backtrace lines and 3 causes; and nothing in an error is ever turned into a class. A line over 4 MB is skipped, and one cut off by a writer that died is never read. Log ids, like task ids, are lower case only, so neither can clash with another on a filesystem that ignores case.
|
|
793
|
+
|
|
794
|
+
`Wire.dump(batch)`, `Wire.parse(line)`, `Wire.to_h(batch)`, `Wire.from_h(hash)`, `Wire.each_batch(io)`.
|
|
795
|
+
|
|
796
|
+
## Testing
|
|
797
|
+
|
|
798
|
+
```ruby
|
|
799
|
+
class ImportTest < Minitest::Test
|
|
800
|
+
include TaskProgress::Assertions
|
|
801
|
+
|
|
802
|
+
def test_import
|
|
803
|
+
recording = TaskProgress::RecordingOutput.new
|
|
804
|
+
import = TaskProgress.new("Import", outputs: [recording])
|
|
805
|
+
Importer.new(progress: import).run
|
|
806
|
+
assert_task_complete import
|
|
807
|
+
assert_task_failed import.subtasks.last, with: /row 12/
|
|
808
|
+
assert_includes recording.said, "Skipped 3 rows"
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
`task.outline` is the task and everything under it as plain Hashes - name, status and percent, and count, total, message and error where there are any - to compare against, or print:
|
|
814
|
+
|
|
815
|
+
```ruby
|
|
816
|
+
{name: "Import", status: :done, percent: 100, children: [
|
|
817
|
+
{name: "Rows", status: :failed, percent: 40, count: 4, total: 10, error: "RuntimeError: row 5"}]}
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
`assert_task_complete`, `assert_task_failed(task, with:)` and `assert_task_canceled` show the whole tree's outline when they fail. `RecordingOutput` keeps every batch: `batches`, `events(op)`, `said` and `outline`.
|
|
821
|
+
|
|
822
|
+
For the tests of code that draws or relays trees:
|
|
823
|
+
|
|
824
|
+
- `TerminalOutput.new(io, interval: nil)` redraws on every batch, with no thread. `term.lines(snapshot)` gives the lines without the terminal control.
|
|
825
|
+
- `TaskProgress::Log.new(clock: -> { t += 1.0 })` makes times predictable, and `random: Random.new(1)` task ids.
|
|
826
|
+
- `root.log.snapshot` is the tree as it stands, and `TaskProgress::State.fold(events)` folds events of your own.
|
|
827
|
+
- An output is any lambda: `root.add_output { |batch, _| batches << batch }`.
|
|
828
|
+
|
|
829
|
+
The gem's own suite also drives the bench in a headless Chrome, through [Ferrum](https://github.com/rubycdp/ferrum) (`test/browser_test.rb`): it starts the bench on Puma in the test process, clicks a scenario, presses cancel buttons and reads the rows off both panes - that they end alike, that a cancel reaches the server, that a cut stream resumes whole, and that work grafted from the page holds the job below 100%. Without a Chrome it skips.
|
|
830
|
+
|
|
831
|
+
## The bench
|
|
832
|
+
|
|
833
|
+
`bin/dev` starts it on http://localhost:9292 (`PORT=` for another). Each scenario puts one part of TaskProgress under a light:
|
|
834
|
+
|
|
835
|
+
| Scenario | |
|
|
836
|
+
|---|---|
|
|
837
|
+
| Import | A counted download, workers on threads, a task with no measure, an engine in a subprocess under its own mount. The download, the workers, the wait, the engine and its watermarking step - inside the subprocess - can each be canceled. |
|
|
838
|
+
| Import (engine fails) | The engine raises halfway; its error crosses the pipe, fails the mount and then the job. |
|
|
839
|
+
| 16 threads, one tree | Thousands of batches a second - the inspector keeps up, with no gaps, at one draw a frame. |
|
|
840
|
+
| 120 tasks in random order | A wide tree filled in by eight threads; the terminal's trimming. |
|
|
841
|
+
| Six levels deep | Weighted means all the way down. |
|
|
842
|
+
| Nothing to count | Indeterminate tasks: spinners, elapsed time, the latest note. |
|
|
843
|
+
| Transactions | Each step hands over to the next in one batch, so no frame shows two running, or none. |
|
|
844
|
+
| Squashed relay | A tree relayed through `BufferedOutput` and `PublishingOutput` into a mount, as a job publishing to Sossego would. |
|
|
845
|
+
| Gap, stale, recover | A mount loses a batch, goes stale, and recovers from a fresh squash. |
|
|
846
|
+
| Ractors | Four Ractors, each mounted here. Ruby 4.0. |
|
|
847
|
+
| Handed off, lost, retried | A job handed off to a worker process through Redis - or a thread, without one - and mounted from the stream. Halfway the worker is killed: the tree goes stale, then fails; a retry adopts it as attempt 2 and finishes. |
|
|
848
|
+
|
|
849
|
+
Both renderers follow the same job side by side, over a stream of their own each, with a stream inspector counting frames, ops, resets, gaps, duplicates, reconnects, batches and renders a second. You can cancel a job or any cancelable task, cut the stream every N frames to watch it resume, have the Stimulus pane long-poll instead of streaming, or join a running job late. "Run work in this browser" grafts pretend file hashing under the job's root in both panes: the job stays below 100% until it is through, however soon the server finishes. The page and its scripts are served by Assiette, a development dependency only.
|
|
850
|
+
|
|
851
|
+
The terminal demo is `ruby -Ilib examples/cli_demo.rb`, and the same piped: `ruby -Ilib examples/cli_demo.rb | cat`.
|