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/js/task_tree.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// The browser half of TaskProgress: folds batches into a tree the way TaskProgress::State
|
|
2
|
+
// does, and works out percents by the same rules. Framework-free - the Stimulus and React
|
|
3
|
+
// demos both sit on top of it.
|
|
4
|
+
//
|
|
5
|
+
// followEventSourceAt(url, onChange) a TaskProgress::SSE stream, drawn at most once a frame
|
|
6
|
+
// followPollingAt(url, onChange) a TaskProgress::Poll endpoint, for where SSE does not get through
|
|
7
|
+
// followProgress(progress, onChange) what TaskProgress.for_page hands a page - SSE, or polling if that fails
|
|
8
|
+
// batchFeed(onChange) the same, for batches you get some other way
|
|
9
|
+
// followLocal(localTasks, onChange) tasks running in this browser, on their own - uploads, say
|
|
10
|
+
// tree.graft(path, localTasks) draws tasks running in this browser under a server task
|
|
11
|
+
|
|
12
|
+
import { RenderQueue } from "./render_queue.js"
|
|
13
|
+
import { pathKey as key } from "./paths.js"
|
|
14
|
+
|
|
15
|
+
export { pathKey, pathFromKey } from "./paths.js"
|
|
16
|
+
const parentKey = (path) => key(path.slice(0, -1))
|
|
17
|
+
|
|
18
|
+
const blankNode = (path, at) => ({
|
|
19
|
+
path, name: "", total: null, count: 0, percent: 0, weight: 1, expect: null, indeterminate: false, cancelable: false, attempt: null,
|
|
20
|
+
unit: null, message: null, status: "pending", error: null, stale: false,
|
|
21
|
+
opened_at: at, started_at: null, finished_at: null, children: []
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// The wire format's version, as TaskProgress::Wire::VERSION - a batch from a newer writer is
|
|
25
|
+
// left alone rather than half understood
|
|
26
|
+
export const WIRE_VERSION = 1
|
|
27
|
+
|
|
28
|
+
// Siblings past FOLD_OVER are folded by TaskTree#rows, keeping FOLD_KEEP of the done and of the
|
|
29
|
+
// waiting ones - as TaskProgress::State#rows does
|
|
30
|
+
export const FOLD_OVER = 12
|
|
31
|
+
export const FOLD_KEEP = 3
|
|
32
|
+
|
|
33
|
+
const TIMES = ["opened_at", "started_at", "finished_at"]
|
|
34
|
+
const shifted = (value, shift) => {
|
|
35
|
+
if (!shift) return value
|
|
36
|
+
const moved = { ...value }
|
|
37
|
+
for (const field of TIMES) if (moved[field] != null) moved[field] -= shift
|
|
38
|
+
return moved
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The least time between two long polls, in milliseconds
|
|
42
|
+
const LONG_POLL_GAP = 250
|
|
43
|
+
|
|
44
|
+
const FINISHED = new Set(["done", "failed", "canceled"])
|
|
45
|
+
const ENDED_WITHOUT_DONE = new Set(["failed", "canceled"])
|
|
46
|
+
const ATTRIBUTES = ["name", "total", "weight", "expect", "indeterminate", "unit", "cancelable", "attempt"]
|
|
47
|
+
const FIELDS = [...ATTRIBUTES, "count", "percent", "message", "status", "error", "stale", "opened_at", "started_at", "finished_at"]
|
|
48
|
+
|
|
49
|
+
export class TaskTree {
|
|
50
|
+
// `clock` gives seconds since the epoch, and only matters for elapsed times
|
|
51
|
+
constructor({ clock = () => Date.now() / 1000 } = {}) {
|
|
52
|
+
this.nodes = new Map()
|
|
53
|
+
this.log = null
|
|
54
|
+
this.seq = null
|
|
55
|
+
this.stale = false
|
|
56
|
+
this.clock = clock
|
|
57
|
+
this.offset = null
|
|
58
|
+
this.grafts = []
|
|
59
|
+
this.onLocalChange = null // set by a RenderQueue, to draw what a graft changes
|
|
60
|
+
this.local = null // the LocalTasks this whole tree mirrors, see followLocal
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Now, by the clock of whoever wrote the tree - so an elapsed time is never off by the skew
|
|
64
|
+
// between that machine and this one
|
|
65
|
+
now() { return this.clock() - (this.offset ?? 0) }
|
|
66
|
+
|
|
67
|
+
// Returns "applied", "duplicate" for a batch it already has, or "gap" when the batch does not follow the last one - the tree is then
|
|
68
|
+
// stale, and stays so until a batch with prev 0 (a fresh squash) replaces it. A batch in
|
|
69
|
+
// another version of the format is "unsupported", and changes nothing
|
|
70
|
+
apply(batch) {
|
|
71
|
+
if (batch.v !== WIRE_VERSION) return "unsupported"
|
|
72
|
+
if (batch.prev === 0) {
|
|
73
|
+
this.nodes = new Map()
|
|
74
|
+
} else if (batch.log === this.log && batch.seq <= this.seq) {
|
|
75
|
+
return "duplicate"
|
|
76
|
+
} else if (batch.log !== this.log || batch.prev !== this.seq) {
|
|
77
|
+
this.stale = true
|
|
78
|
+
return "gap"
|
|
79
|
+
}
|
|
80
|
+
for (const [path, op, value] of batch.ops) this.#applyOp(path, op, value, batch.at)
|
|
81
|
+
this.#measureOffset(batch)
|
|
82
|
+
this.log = batch.log
|
|
83
|
+
this.seq = batch.seq
|
|
84
|
+
this.stale = false
|
|
85
|
+
for (const graft of this.grafts) this.#tend(graft)
|
|
86
|
+
return "applied"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Draws tasks running in this browser - a LocalTasks - as a child of the server task at
|
|
90
|
+
// `path`. Only this tree sees them. The graft is kept apart from what the server sends, so a
|
|
91
|
+
// reset or a reconnect, which starts the server's tree over, puts it back; and it waits for
|
|
92
|
+
// its task if that has not arrived yet. Returns a function that takes it off again.
|
|
93
|
+
//
|
|
94
|
+
// A server task with browser work still open under it is not complete - it reads 99% and
|
|
95
|
+
// is drawn as running even once the server has called it done. A server task that fails or
|
|
96
|
+
// is canceled cancels the browser work under it
|
|
97
|
+
graft(path, local) {
|
|
98
|
+
const graft = { anchor: [...path], local, id: local.log }
|
|
99
|
+
this.grafts.push(graft)
|
|
100
|
+
const unsubscribe = local.subscribe((batch) => this.#graftOps(graft, batch.ops, batch.at))
|
|
101
|
+
this.#tend(graft)
|
|
102
|
+
return () => {
|
|
103
|
+
unsubscribe()
|
|
104
|
+
this.grafts = this.grafts.filter((g) => g !== graft)
|
|
105
|
+
const top = [...graft.anchor, graft.id]
|
|
106
|
+
if (!this.get(top)) return
|
|
107
|
+
const prefix = key(top)
|
|
108
|
+
for (const k of [...this.nodes.keys()]) if (k === prefix || k.startsWith(prefix + "-")) this.nodes.delete(k)
|
|
109
|
+
const anchor = this.get(graft.anchor)
|
|
110
|
+
if (anchor) anchor.children = anchor.children.filter((id) => id !== graft.id)
|
|
111
|
+
this.onLocalChange?.([[top, "prune"]])
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Cancels a task running in this browser, by its path in this tree. Server tasks are not
|
|
116
|
+
// this tree's to end - see requestCancel
|
|
117
|
+
cancelLocal(path, reason) {
|
|
118
|
+
if (this.local) return this.local.at(path)?.cancel(reason)
|
|
119
|
+
const graft = this.grafts.find((g) => key([...g.anchor, g.id]) === key(path.slice(0, g.anchor.length + 1)))
|
|
120
|
+
graft?.local.at(path.slice(graft.anchor.length + 1))?.cancel(reason)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Puts a graft back if the server's tree lost it, and cancels it if its server task ended badly
|
|
124
|
+
#tend(graft) {
|
|
125
|
+
const anchor = this.get(graft.anchor)
|
|
126
|
+
if (!anchor) return
|
|
127
|
+
if (!this.get([...graft.anchor, graft.id])) {
|
|
128
|
+
const squash = graft.local.squash()
|
|
129
|
+
this.#graftOps(graft, squash.ops, squash.at)
|
|
130
|
+
}
|
|
131
|
+
if (ENDED_WITHOUT_DONE.has(anchor.status) && !graft.local.root.finished) graft.local.root.cancel(anchor.message ?? `${anchor.name} ${anchor.status}`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#graftOps(graft, ops, at) {
|
|
135
|
+
if (!this.get(graft.anchor)) return
|
|
136
|
+
const top = [...graft.anchor, graft.id]
|
|
137
|
+
// Lost to a reset or a prune since: the local tree already holds this change, so put it back whole
|
|
138
|
+
if (!this.get(top) && !(ops[0]?.[0].length === 0 && (ops[0][1] === "open" || ops[0][1] === "put"))) {
|
|
139
|
+
const squash = graft.local.squash()
|
|
140
|
+
return this.#graftOps(graft, squash.ops, squash.at)
|
|
141
|
+
}
|
|
142
|
+
// Times here are on this browser's clock; the tree keeps the server's, so they move over
|
|
143
|
+
const shift = this.offset ?? 0
|
|
144
|
+
const moved = ops.map(([path, op, value]) => [[...top, ...path], op, op === "put" ? shifted(value, shift) : value])
|
|
145
|
+
for (const [path, op, value] of moved) this.#applyOp(path, op, value, at - shift)
|
|
146
|
+
this.onLocalChange?.(moved)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get(path) { return this.nodes.get(key(path)) }
|
|
150
|
+
|
|
151
|
+
get root() { return this.nodes.get("") }
|
|
152
|
+
|
|
153
|
+
childrenOf(node) { return node.children.map((id) => this.nodes.get(key([...node.path, id]))) }
|
|
154
|
+
|
|
155
|
+
// Pre-order, children by position
|
|
156
|
+
*each(node = this.root) {
|
|
157
|
+
if (!node) return
|
|
158
|
+
yield node
|
|
159
|
+
for (const child of this.childrenOf(node)) yield* this.each(child)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// What to draw, in the order of `each` - but where a task has more than `foldOver` children, the
|
|
163
|
+
// ones that are done (save the last `keep` to finish) are one row ahead of the rest, and the
|
|
164
|
+
// ones still waiting (save the first `keep`) one row after them, see TaskProgress::State#rows.
|
|
165
|
+
// A folded row is { fold: true, path, status, count }, its path its parent's and `~done` or
|
|
166
|
+
// `~pending`, which no task id can be
|
|
167
|
+
*rows(node = this.root, { foldOver = FOLD_OVER, keep = FOLD_KEEP } = {}) {
|
|
168
|
+
if (!node) return
|
|
169
|
+
yield node
|
|
170
|
+
const kids = this.childrenOf(node)
|
|
171
|
+
const options = { foldOver, keep }
|
|
172
|
+
if (kids.length <= foldOver) {
|
|
173
|
+
for (const kid of kids) yield* this.rows(kid, options)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const done = kids.filter((kid) => this.complete(kid))
|
|
177
|
+
const kept = new Set([...done].sort((a, b) => (b.finished_at ?? 0) - (a.finished_at ?? 0)).slice(0, keep))
|
|
178
|
+
const pending = kids.filter((kid) => kid.status === "pending")
|
|
179
|
+
const folded = new Set([...done.filter((kid) => !kept.has(kid)), ...pending.slice(keep)])
|
|
180
|
+
if (done.length > kept.size) yield { fold: true, path: [...node.path, "~done"], status: "done", count: done.length - kept.size }
|
|
181
|
+
for (const kid of kids) if (!folded.has(kid)) yield* this.rows(kid, options)
|
|
182
|
+
if (pending.length > keep) yield { fold: true, path: [...node.path, "~pending"], status: "pending", count: pending.length - keep }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Done, and everything under it ended - see TaskProgress::State#complete?
|
|
186
|
+
complete(node) {
|
|
187
|
+
if (node?.status !== "done") return false
|
|
188
|
+
return this.childrenOf(node).every((child) => ENDED_WITHOUT_DONE.has(child.status) || this.complete(child))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fraction(node) {
|
|
192
|
+
if (!node) return 0
|
|
193
|
+
if (node.status === "done") return 1
|
|
194
|
+
const missing = this.#missing(node)
|
|
195
|
+
if (node.children.length > 0 || missing > 0) {
|
|
196
|
+
let weights = missing
|
|
197
|
+
let sum = 0
|
|
198
|
+
for (const child of this.childrenOf(node)) {
|
|
199
|
+
weights += child.weight
|
|
200
|
+
sum += child.weight * this.fraction(child)
|
|
201
|
+
}
|
|
202
|
+
return weights === 0 ? 0 : sum / weights
|
|
203
|
+
}
|
|
204
|
+
if (node.total != null) return node.total === 0 ? 0 : Math.min(Math.max(node.count / node.total, 0), 1)
|
|
205
|
+
if (node.indeterminate) return 0
|
|
206
|
+
return node.percent / 100
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A whole percent that reaches 100 only once everything under the node is done. Math.round
|
|
210
|
+
// rounds .5 up like Ruby's round does for the positive numbers we have here
|
|
211
|
+
percentOf(node) {
|
|
212
|
+
if (this.complete(node)) return 100
|
|
213
|
+
return Math.min(Math.round(this.fraction(node) * 100), 99)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Each batch says what the writer's clock read when it committed. The smallest gap to ours has
|
|
217
|
+
// the least time in transit in it, so that is the one kept
|
|
218
|
+
#measureOffset(batch) {
|
|
219
|
+
if (!batch.at) return
|
|
220
|
+
const offset = this.clock() - batch.at
|
|
221
|
+
this.offset = batch.log !== this.log || this.offset === null ? offset : Math.min(this.offset, offset)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#missing(node) { return Math.max((node.expect ?? 0) - node.children.length, 0) }
|
|
225
|
+
|
|
226
|
+
#start(path, at) {
|
|
227
|
+
for (let length = path.length; length >= 0; length--) {
|
|
228
|
+
const node = this.nodes.get(key(path.slice(0, length)))
|
|
229
|
+
if (node.status === "pending") node.status = "active"
|
|
230
|
+
node.started_at ??= at
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#adopt(path) {
|
|
235
|
+
if (path.length === 0) return
|
|
236
|
+
const parent = this.nodes.get(parentKey(path))
|
|
237
|
+
const id = path[path.length - 1]
|
|
238
|
+
if (!parent.children.includes(id)) parent.children.push(id)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#applyOp(path, op, value, at) {
|
|
242
|
+
const node = this.nodes.get(key(path))
|
|
243
|
+
switch (op) {
|
|
244
|
+
case "open": {
|
|
245
|
+
const fresh = blankNode(path, at)
|
|
246
|
+
for (const field of ATTRIBUTES) if (value[field] !== undefined) fresh[field] = value[field]
|
|
247
|
+
this.nodes.set(key(path), fresh)
|
|
248
|
+
this.#adopt(path)
|
|
249
|
+
break
|
|
250
|
+
}
|
|
251
|
+
case "meta":
|
|
252
|
+
for (const field of [...ATTRIBUTES, "stale"]) if (value[field] !== undefined) node[field] = value[field]
|
|
253
|
+
break
|
|
254
|
+
case "set": this.#start(path, at); node.percent = value; break
|
|
255
|
+
case "add": this.#start(path, at); node.count = Math.max(node.count + value, 0); break
|
|
256
|
+
case "note": this.#start(path, at); node.message = value ?? null; break
|
|
257
|
+
case "say": break // a line for a log pane - nothing in the tree changes
|
|
258
|
+
case "done":
|
|
259
|
+
case "fail":
|
|
260
|
+
this.#start(path, at)
|
|
261
|
+
node.finished_at = at
|
|
262
|
+
node.status = op === "done" ? "done" : "failed"
|
|
263
|
+
if (op === "fail") node.error = value
|
|
264
|
+
break
|
|
265
|
+
case "cancel": // calling off what never began does not begin it
|
|
266
|
+
node.finished_at = at
|
|
267
|
+
node.status = "canceled"
|
|
268
|
+
if (value != null) node.message = value // an empty reason too, as State does
|
|
269
|
+
break
|
|
270
|
+
case "prune": {
|
|
271
|
+
if (!node) break
|
|
272
|
+
const prefix = key(path)
|
|
273
|
+
for (const k of [...this.nodes.keys()]) {
|
|
274
|
+
if (k !== prefix && (prefix === "" || k.startsWith(prefix + "-"))) this.nodes.delete(k)
|
|
275
|
+
}
|
|
276
|
+
node.children = []
|
|
277
|
+
break
|
|
278
|
+
}
|
|
279
|
+
case "put": {
|
|
280
|
+
const fresh = blankNode(path, at)
|
|
281
|
+
for (const field of FIELDS) if (value[field] !== undefined) fresh[field] = value[field]
|
|
282
|
+
fresh.children = node ? node.children : []
|
|
283
|
+
this.nodes.set(key(path), fresh)
|
|
284
|
+
if (!node) this.#adopt(path)
|
|
285
|
+
break
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Nothing more will come: the root has finished - or the tree is empty after a batch, its task
|
|
292
|
+
// pruned away before anyone asked
|
|
293
|
+
const ended = (tree) => tree.seq != null && (!tree.root || FINISHED.has(tree.root.status))
|
|
294
|
+
|
|
295
|
+
// Folds batches pushed into it from anywhere - a WebSocket, ActionCable, a poller - and calls
|
|
296
|
+
// onChange(tree, changes) at most once a frame with what they changed, see render_queue.js.
|
|
297
|
+
// `push` returns what the tree made of the batch: on a "gap" it is for the caller to start
|
|
298
|
+
// over, by fetching a fresh squash (a batch with prev 0)
|
|
299
|
+
// `initial` is a batch to start from - the snapshot a page was rendered with, say - so the tree
|
|
300
|
+
// is drawn before anything has come in
|
|
301
|
+
export function batchFeed(onChange, { schedule, clock, initial } = {}) {
|
|
302
|
+
const tree = new TaskTree(clock ? { clock } : {})
|
|
303
|
+
const queue = new RenderQueue(tree, onChange, { schedule })
|
|
304
|
+
if (initial) queue.apply(initial)
|
|
305
|
+
return { tree, push: (batch) => queue.apply(batch), close: () => queue.close() }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Follows a TaskProgress::SSE stream at `url`, see batchFeed. On a gap it reconnects, and
|
|
309
|
+
// the server starts it over from a fresh squash. It stops once the root has finished
|
|
310
|
+
//
|
|
311
|
+
// `onUnreachable` is called, once, when nothing has come within `unreachableAfter` seconds of
|
|
312
|
+
// connecting, or the connection failed before anything did - a proxy that buffers, a host that
|
|
313
|
+
// cuts long responses. followProgress falls back to polling on it
|
|
314
|
+
export function followEventSourceAt(url, onChange, { onUnreachable, unreachableAfter = 5, ...options } = {}) {
|
|
315
|
+
const following = batchFeed(onChange, options)
|
|
316
|
+
const { tree } = following
|
|
317
|
+
let source
|
|
318
|
+
let heard = false
|
|
319
|
+
let timer = null
|
|
320
|
+
const unreachable = () => {
|
|
321
|
+
if (heard || !onUnreachable) return
|
|
322
|
+
heard = true // once
|
|
323
|
+
clearTimeout(timer)
|
|
324
|
+
onUnreachable()
|
|
325
|
+
}
|
|
326
|
+
const connect = () => {
|
|
327
|
+
source = new EventSource(url)
|
|
328
|
+
source.addEventListener("batch", (event) => {
|
|
329
|
+
heard = true
|
|
330
|
+
clearTimeout(timer)
|
|
331
|
+
if (following.push(JSON.parse(event.data)) === "gap") {
|
|
332
|
+
source.close()
|
|
333
|
+
connect()
|
|
334
|
+
}
|
|
335
|
+
if (ended(tree)) source.close()
|
|
336
|
+
})
|
|
337
|
+
source.addEventListener("error", unreachable)
|
|
338
|
+
}
|
|
339
|
+
connect()
|
|
340
|
+
if (onUnreachable) timer = setTimeout(unreachable, unreachableAfter * 1000)
|
|
341
|
+
return { tree, close: () => { clearTimeout(timer); source.close(); following.close() } }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Follows what TaskProgress.for_page hands a page - { events, poll, cancel, snapshot } - drawn
|
|
345
|
+
// from its snapshot at once. `transport` is "sse", "poll", or "auto": SSE, falling back to
|
|
346
|
+
// polling (every `poll` seconds, or long polling with `wait`) when the stream cannot be reached. `tree` is always the one being followed now
|
|
347
|
+
// Draws tasks that run in this browser on their own, with no server tree around them - uploads,
|
|
348
|
+
// say. Every row is local: its cancel button cancels the task here
|
|
349
|
+
export function followLocal(local, onChange, options = {}) {
|
|
350
|
+
const feed = batchFeed(onChange, { ...options, initial: local.squash() })
|
|
351
|
+
feed.tree.local = local
|
|
352
|
+
const unsubscribe = local.subscribe((batch) => feed.push(batch))
|
|
353
|
+
return { tree: feed.tree, close: () => { unsubscribe(); feed.close() } }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function followProgress(progress, onChange, { transport = "auto", poll = 1, wait = 0, ...options } = {}) {
|
|
357
|
+
const initial = progress.snapshot
|
|
358
|
+
const polling = (from) => followPollingAt(progress.poll, onChange, { poll, wait, ...options, initial: from })
|
|
359
|
+
if (transport === "poll" || !progress.events) return polling(initial)
|
|
360
|
+
let current = followEventSourceAt(progress.events, onChange, {
|
|
361
|
+
...options,
|
|
362
|
+
initial,
|
|
363
|
+
onUnreachable: transport === "auto" ? () => {
|
|
364
|
+
current.close()
|
|
365
|
+
current = polling(initial)
|
|
366
|
+
} : undefined
|
|
367
|
+
})
|
|
368
|
+
return {
|
|
369
|
+
get tree() { return current.tree },
|
|
370
|
+
close: () => current.close()
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Polls a TaskProgress::Poll endpoint at `url`, see batchFeed - for where an EventSource does
|
|
375
|
+
// not get through. Every answer is the whole tree, and its ETag goes back in If-None-Match, so a
|
|
376
|
+
// tree that has not changed costs a 304. Polls every `poll` seconds; with `wait` (seconds too)
|
|
377
|
+
// the server holds each poll until something changes, up to that long, and the next one goes
|
|
378
|
+
// out at once - long polling. A poll that fails is tried again later and later, up to
|
|
379
|
+
// half a minute apart. It stops once the root has finished
|
|
380
|
+
export function followPollingAt(url, onChange, { poll: every = 1, wait = 0, ...options } = {}) {
|
|
381
|
+
const interval = every * 1000
|
|
382
|
+
const following = batchFeed(onChange, options)
|
|
383
|
+
const { tree } = following
|
|
384
|
+
const target = wait ? `${url}${url.includes("?") ? "&" : "?"}wait=${wait}` : url
|
|
385
|
+
let etag = null
|
|
386
|
+
let stopped = false
|
|
387
|
+
let timer = null
|
|
388
|
+
let controller = null
|
|
389
|
+
let delay = interval
|
|
390
|
+
const poll = async () => {
|
|
391
|
+
controller = new AbortController()
|
|
392
|
+
try {
|
|
393
|
+
const response = await fetch(target, { headers: etag ? { "if-none-match": etag } : {}, cache: "no-store", signal: controller.signal })
|
|
394
|
+
if (response.status === 200) {
|
|
395
|
+
const batch = await response.json()
|
|
396
|
+
etag = response.headers.get("etag")
|
|
397
|
+
following.push(batch)
|
|
398
|
+
} else if (response.status !== 304) {
|
|
399
|
+
throw new Error(`Polling ${url} got a ${response.status}`)
|
|
400
|
+
}
|
|
401
|
+
delay = interval
|
|
402
|
+
} catch {
|
|
403
|
+
if (stopped) return
|
|
404
|
+
delay = Math.min(Math.max(delay, interval) * 2, 30000)
|
|
405
|
+
}
|
|
406
|
+
if (stopped || ended(tree)) return
|
|
407
|
+
// Long polling goes straight back - but not faster than this, however busy the tree
|
|
408
|
+
timer = setTimeout(poll, wait && delay === interval ? LONG_POLL_GAP : delay)
|
|
409
|
+
}
|
|
410
|
+
poll()
|
|
411
|
+
return {
|
|
412
|
+
tree,
|
|
413
|
+
close: () => {
|
|
414
|
+
stopped = true
|
|
415
|
+
clearTimeout(timer)
|
|
416
|
+
controller?.abort()
|
|
417
|
+
following.close()
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Asks the server to cancel a task, by creating a cancellation: posts JSON with the task's path
|
|
423
|
+
// (its pathKey) and the token the server issued for this tree. The server decides -
|
|
424
|
+
// a task is only ever ended by its own tree - so nothing changes here until the `cancel` comes
|
|
425
|
+
// back down the stream. A request that comes too late is not an error: the server takes it all
|
|
426
|
+
// the same, and the row has already finished anyway
|
|
427
|
+
//
|
|
428
|
+
// On a Rails page it sends the page's CSRF token along, so `protect_from_forgery` lets it through
|
|
429
|
+
export function requestCancel(url, path, { token } = {}) {
|
|
430
|
+
const csrf = globalThis.document?.querySelector('meta[name="csrf-token"]')?.content
|
|
431
|
+
return fetch(url, {
|
|
432
|
+
method: "POST",
|
|
433
|
+
headers: { "content-type": "application/json", ...(csrf ? { "x-csrf-token": csrf } : {}) },
|
|
434
|
+
body: JSON.stringify({ path: key(path), token })
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function humanBytes(n) {
|
|
439
|
+
const scale = [["TB", 1024 ** 4], ["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]]
|
|
440
|
+
const found = scale.find(([, size]) => n >= size)
|
|
441
|
+
if (!found) return `${n} bytes`
|
|
442
|
+
const value = Math.round((n / found[1]) * 10) / 10
|
|
443
|
+
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${found[0]}`
|
|
444
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "pp"
|
|
5
|
+
|
|
6
|
+
class TaskProgress
|
|
7
|
+
# Assertions for Minitest - and ActiveSupport::TestCase, which is Minitest underneath - about
|
|
8
|
+
# what work reported. Each failure shows the whole tree, see TaskProgress#outline.
|
|
9
|
+
#
|
|
10
|
+
# class ImportTest < Minitest::Test
|
|
11
|
+
# include TaskProgress::Assertions
|
|
12
|
+
#
|
|
13
|
+
# def test_import
|
|
14
|
+
# import = TaskProgress.new("Import")
|
|
15
|
+
# Importer.new(progress: import).run
|
|
16
|
+
# assert_task_complete import
|
|
17
|
+
# assert_task_failed import.subtasks.first, with: /row 12/
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
module Assertions
|
|
21
|
+
# @rbs!
|
|
22
|
+
# def assert: (untyped, ?String) -> void
|
|
23
|
+
|
|
24
|
+
# The task is done, and everything under it ended
|
|
25
|
+
#: (TaskProgress, ?String?) -> void
|
|
26
|
+
def assert_task_complete(task, message = nil)
|
|
27
|
+
assert task.complete?, message || "Expected '#{task.name}' to be complete, but it is #{task.status}#{TaskProgress::Assertions.tree(task)}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The task failed - with an error whose message matches `with`, if given
|
|
31
|
+
#: (TaskProgress, ?with: (String | Regexp)?, ?message: String?) -> void
|
|
32
|
+
def assert_task_failed(task, with: nil, message: nil)
|
|
33
|
+
error = task.error
|
|
34
|
+
matched = with.nil? || (with.is_a?(Regexp) ? with.match?(error&.dig(:message).to_s) : error&.dig(:message) == with)
|
|
35
|
+
assert task.failed? && matched,
|
|
36
|
+
message || "Expected '#{task.name}' to have failed#{" with #{with.inspect}" if with}, but it is #{task.status}#{" with #{error[:message].inspect}" if error}#{TaskProgress::Assertions.tree(task)}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The task was canceled
|
|
40
|
+
#: (TaskProgress, ?String?) -> void
|
|
41
|
+
def assert_task_canceled(task, message = nil)
|
|
42
|
+
assert task.canceled?, message || "Expected '#{task.name}' to have been canceled, but it is #{task.status}#{TaskProgress::Assertions.tree(task)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The tree, as a failure message ends with it
|
|
46
|
+
#: (TaskProgress) -> String
|
|
47
|
+
def self.tree(task) = "\n\n#{PP.pp(task.root.outline, +"", 100)}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# The unit of commit, see below
|
|
6
|
+
Batch = Data.define(
|
|
7
|
+
:log, #: String?
|
|
8
|
+
:prev, #: Integer
|
|
9
|
+
:seq, #: Integer
|
|
10
|
+
:at, #: Float
|
|
11
|
+
:events #: Array[Event]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# The unit of commit: everything one transaction changed, in order. `seq` counts batches,
|
|
15
|
+
# not events, and `prev` names the batch it follows - so a reader that sees a `prev` other
|
|
16
|
+
# than the `seq` it last saw knows it missed something. `prev` 0 means "from the start",
|
|
17
|
+
# which is also how a squashed replay announces itself
|
|
18
|
+
class Batch
|
|
19
|
+
# Whether it starts over from nothing, as a squash does
|
|
20
|
+
#: () -> bool
|
|
21
|
+
def reset? = prev.zero?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# An output for one subtree, which it sees as a tree of its own: the branch's node becomes the
|
|
6
|
+
# root, `[*branch, "x3k0"]` becomes `["x3k0"]`, and batches that do not touch the branch are left
|
|
7
|
+
# out. That is a Mount run backwards - a Mount fed from a Branch grafts the subtree back whole.
|
|
8
|
+
#
|
|
9
|
+
# Since batches get left out, a branch numbers its own: `log` is the log's id with the branch
|
|
10
|
+
# path appended, and its `prev`/`seq` follow on from each other just like the log's do.
|
|
11
|
+
#
|
|
12
|
+
# Pruning an ancestor takes the branch's node away. Usually for good, since a pruned path is
|
|
13
|
+
# never given out again - and the branch then sends one last reset with nothing in it, and
|
|
14
|
+
# falls silent. But a Mount starting over (a remote that restarted, a stream resynced) prunes
|
|
15
|
+
# its subtree and puts the same tasks back in the same batch: the branch's node comes back,
|
|
16
|
+
# and the branch starts over with it.
|
|
17
|
+
#
|
|
18
|
+
# Its first batch always starts from nothing, `prev` 0: one for a node that comes into being
|
|
19
|
+
# after the branch was made carries the node's `open`
|
|
20
|
+
class Branch # :nodoc:
|
|
21
|
+
# The output the branch feeds
|
|
22
|
+
attr_reader :output #: output
|
|
23
|
+
# The branch's task, in the whole tree
|
|
24
|
+
attr_reader :path #: path
|
|
25
|
+
# The log id its batches carry: the log's, with the path appended
|
|
26
|
+
attr_reader :id #: String
|
|
27
|
+
|
|
28
|
+
# @rbs @seq: Integer
|
|
29
|
+
# @rbs @gone: bool
|
|
30
|
+
# @rbs @started: bool
|
|
31
|
+
|
|
32
|
+
# The log id of the branch at `path`
|
|
33
|
+
#: (String, path) -> String
|
|
34
|
+
def self.id(log_id, path) = [log_id, *path].join("-")
|
|
35
|
+
|
|
36
|
+
# For an output, the branch at `path` of the log `log_id`, numbering on from `seq`
|
|
37
|
+
#: (output, path, String, Integer, ?started: bool) -> void
|
|
38
|
+
def initialize(output, path, log_id, seq, started: true)
|
|
39
|
+
@output = output
|
|
40
|
+
@path = path
|
|
41
|
+
@id = Branch.id(log_id, path)
|
|
42
|
+
@seq = seq
|
|
43
|
+
@gone = false
|
|
44
|
+
@started = started
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Passes on what of the batch is in the branch, moved to its root
|
|
48
|
+
#: (Batch, Log) -> void
|
|
49
|
+
def call(batch, log)
|
|
50
|
+
return if @gone
|
|
51
|
+
reset = batch.reset? || !@started
|
|
52
|
+
pruned = false
|
|
53
|
+
events = [] #: Array[Event]
|
|
54
|
+
batch.events.each do |event|
|
|
55
|
+
if event.op == :prune && event.path.length < @path.length && @path[0, event.path.length] == event.path
|
|
56
|
+
pruned = reset = true
|
|
57
|
+
events.clear
|
|
58
|
+
elsif event.path[0, @path.length] == @path
|
|
59
|
+
# Put back after the prune - the branch starts over from it
|
|
60
|
+
pruned = false if event.path.length == @path.length && (event.op == :put || event.op == :open)
|
|
61
|
+
events << Event.new(path: event.path.drop(@path.length).freeze, op: event.op, value: event.value)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
if pruned
|
|
65
|
+
@gone = true
|
|
66
|
+
events.clear
|
|
67
|
+
end
|
|
68
|
+
return if events.empty? && !reset
|
|
69
|
+
@started = true
|
|
70
|
+
@seq += 1
|
|
71
|
+
@output.call(Batch.new(log: @id, prev: reset ? 0 : @seq - 1, seq: @seq, at: batch.at, events: events.freeze), log)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# Puts a thread between the log and a slow output. The first batch goes through at once,
|
|
6
|
+
# and whatever piles up while the output works (or during `interval` after it) goes out
|
|
7
|
+
# squashed into one - so a database write a few times a second keeps up with any number of
|
|
8
|
+
# workers, and never holds one of them up
|
|
9
|
+
class BufferedOutput
|
|
10
|
+
# @rbs @output: output
|
|
11
|
+
# @rbs @interval: Integer | Float
|
|
12
|
+
# @rbs @queue: Thread::Queue[Batch]
|
|
13
|
+
# @rbs @log: Log?
|
|
14
|
+
# @rbs @squasher: Squasher
|
|
15
|
+
# @rbs @thread: Thread
|
|
16
|
+
|
|
17
|
+
# For the slow output, sending to it at most every `interval` seconds
|
|
18
|
+
#: (output, ?interval: Integer | Float) -> void
|
|
19
|
+
def initialize(output, interval: 0.25)
|
|
20
|
+
@output = output
|
|
21
|
+
@interval = interval
|
|
22
|
+
@queue = Thread::Queue.new
|
|
23
|
+
@log = nil
|
|
24
|
+
@squasher = Squasher.new
|
|
25
|
+
@thread = Thread.new { run }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Takes one batch, to deliver from its own thread
|
|
29
|
+
#: (Batch, Log) -> void
|
|
30
|
+
def call(batch, log)
|
|
31
|
+
@log = log
|
|
32
|
+
@queue << batch
|
|
33
|
+
rescue ClosedQueueError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Delivers what is still queued, then stops
|
|
38
|
+
#: () -> void
|
|
39
|
+
def close
|
|
40
|
+
@queue.close
|
|
41
|
+
@thread.join
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
#: () -> void
|
|
47
|
+
def run
|
|
48
|
+
while (batch = @queue.pop)
|
|
49
|
+
batches = [batch]
|
|
50
|
+
while !@queue.empty? && (more = @queue.pop)
|
|
51
|
+
batches << more
|
|
52
|
+
end
|
|
53
|
+
log = @log or next
|
|
54
|
+
begin
|
|
55
|
+
@output.call(@squasher.squash(batches), log)
|
|
56
|
+
rescue => e
|
|
57
|
+
log.on_error.call(e, @output)
|
|
58
|
+
end
|
|
59
|
+
sleep(@interval) unless @queue.closed?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|