task_progress 0.0.1

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