task_progress 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/MANUAL.md +851 -0
- data/README.md +158 -0
- data/RECIPES.md +413 -0
- data/config/importmap.rb +17 -0
- data/exe/task_progress +51 -0
- data/js/local_tasks.d.ts +44 -0
- data/js/local_tasks.js +229 -0
- data/js/package.json +31 -0
- data/js/paths.d.ts +5 -0
- data/js/paths.js +7 -0
- data/js/react_task_tree.d.ts +49 -0
- data/js/react_task_tree.js +123 -0
- data/js/render_queue.d.ts +24 -0
- data/js/render_queue.js +79 -0
- data/js/row_view.d.ts +25 -0
- data/js/row_view.js +69 -0
- data/js/stimulus_controller.d.ts +10 -0
- data/js/stimulus_controller.js +137 -0
- data/js/task_progress_clock.d.ts +2 -0
- data/js/task_progress_clock.js +33 -0
- data/js/task_tree.css +51 -0
- data/js/task_tree.d.ts +111 -0
- data/js/task_tree.js +444 -0
- data/lib/task_progress/assertions.rb +49 -0
- data/lib/task_progress/batch.rb +23 -0
- data/lib/task_progress/branch.rb +74 -0
- data/lib/task_progress/buffered_output.rb +63 -0
- data/lib/task_progress/cancellations_app.rb +76 -0
- data/lib/task_progress/event.rb +46 -0
- data/lib/task_progress/execution_log_output.rb +183 -0
- data/lib/task_progress/fold.rb +18 -0
- data/lib/task_progress/html_renderer.rb +153 -0
- data/lib/task_progress/job_serializer.rb +92 -0
- data/lib/task_progress/journal.rb +35 -0
- data/lib/task_progress/log.rb +263 -0
- data/lib/task_progress/memory_cache.rb +31 -0
- data/lib/task_progress/memory_stream_store.rb +142 -0
- data/lib/task_progress/mount.rb +448 -0
- data/lib/task_progress/ndjson_output.rb +24 -0
- data/lib/task_progress/node.rb +52 -0
- data/lib/task_progress/poll.rb +87 -0
- data/lib/task_progress/publishing_output.rb +30 -0
- data/lib/task_progress/ractor_output.rb +23 -0
- data/lib/task_progress/rails_defaults.rb +46 -0
- data/lib/task_progress/railtie.rb +91 -0
- data/lib/task_progress/recording_output.rb +51 -0
- data/lib/task_progress/redis_stream_store.rb +131 -0
- data/lib/task_progress/squasher.rb +59 -0
- data/lib/task_progress/sse.rb +114 -0
- data/lib/task_progress/state.rb +319 -0
- data/lib/task_progress/stream_output.rb +222 -0
- data/lib/task_progress/stream_reader.rb +76 -0
- data/lib/task_progress/summary_output.rb +37 -0
- data/lib/task_progress/terminal_output.rb +241 -0
- data/lib/task_progress/token.rb +58 -0
- data/lib/task_progress/tracked_io.rb +68 -0
- data/lib/task_progress/transaction.rb +41 -0
- data/lib/task_progress/trees_app.rb +66 -0
- data/lib/task_progress/turbo_helper.rb +18 -0
- data/lib/task_progress/turbo_sse.rb +103 -0
- data/lib/task_progress/turbo_stream_output.rb +61 -0
- data/lib/task_progress/version.rb +7 -0
- data/lib/task_progress/wire.rb +211 -0
- data/lib/task_progress.rb +1036 -0
- data/sig/generated/task_progress/assertions.rbs +36 -0
- data/sig/generated/task_progress/batch.rbs +33 -0
- data/sig/generated/task_progress/branch.rbs +47 -0
- data/sig/generated/task_progress/buffered_output.rbs +38 -0
- data/sig/generated/task_progress/cancellations_app.rbs +41 -0
- data/sig/generated/task_progress/event.rbs +20 -0
- data/sig/generated/task_progress/execution_log_output.rbs +93 -0
- data/sig/generated/task_progress/fold.rbs +25 -0
- data/sig/generated/task_progress/html_renderer.rbs +71 -0
- data/sig/generated/task_progress/journal.rbs +25 -0
- data/sig/generated/task_progress/log.rbs +140 -0
- data/sig/generated/task_progress/memory_cache.rbs +28 -0
- data/sig/generated/task_progress/memory_stream_store.rbs +83 -0
- data/sig/generated/task_progress/mount.rbs +200 -0
- data/sig/generated/task_progress/ndjson_output.rbs +18 -0
- data/sig/generated/task_progress/node.rbs +66 -0
- data/sig/generated/task_progress/poll.rbs +50 -0
- data/sig/generated/task_progress/publishing_output.rbs +24 -0
- data/sig/generated/task_progress/ractor_output.rbs +17 -0
- data/sig/generated/task_progress/rails_defaults.rbs +33 -0
- data/sig/generated/task_progress/recording_output.rbs +41 -0
- data/sig/generated/task_progress/redis_stream_store.rbs +75 -0
- data/sig/generated/task_progress/squasher.rbs +20 -0
- data/sig/generated/task_progress/sse.rbs +73 -0
- data/sig/generated/task_progress/state.rbs +165 -0
- data/sig/generated/task_progress/stream_output.rbs +142 -0
- data/sig/generated/task_progress/stream_reader.rbs +46 -0
- data/sig/generated/task_progress/summary_output.rbs +19 -0
- data/sig/generated/task_progress/terminal_output.rbs +117 -0
- data/sig/generated/task_progress/token.rbs +41 -0
- data/sig/generated/task_progress/tracked_io.rbs +48 -0
- data/sig/generated/task_progress/transaction.rbs +33 -0
- data/sig/generated/task_progress/trees_app.rbs +40 -0
- data/sig/generated/task_progress/turbo_helper.rbs +14 -0
- data/sig/generated/task_progress/turbo_sse.rbs +69 -0
- data/sig/generated/task_progress/turbo_stream_output.rbs +51 -0
- data/sig/generated/task_progress/version.rbs +6 -0
- data/sig/generated/task_progress/wire.rbs +102 -0
- data/sig/generated/task_progress.rbs +553 -0
- metadata +178 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
class TaskProgress
|
|
7
|
+
# The sequencer. Every change to a tree goes through `transaction`, which applies its events
|
|
8
|
+
# under the one lock and commits them as a numbered Batch - so the lock is what puts the
|
|
9
|
+
# changes from many threads into one order, and a batch is what crosses a process, a Ractor
|
|
10
|
+
# or the wire as one piece.
|
|
11
|
+
#
|
|
12
|
+
# Outputs get the batches strictly in order, but outside that lock: a worker never waits on
|
|
13
|
+
# another's output while it holds the tree. Delivery is synchronous to whoever committed, so
|
|
14
|
+
# a slow output slows its committer down - wrap it in BufferedOutput when it must not
|
|
15
|
+
class Log
|
|
16
|
+
# The id its batches carry - ten lower case characters, unless given
|
|
17
|
+
attr_reader :id #: String
|
|
18
|
+
# The seq of the last batch it committed
|
|
19
|
+
attr_reader :seq #: Integer
|
|
20
|
+
# The stream key the tree is written to or read from, when it is - see TaskProgress.handoff
|
|
21
|
+
attr_reader :key #: String?
|
|
22
|
+
|
|
23
|
+
# Lower case only, like the task ids, so neither can clash with another on a filesystem that
|
|
24
|
+
# ignores case (macOS, Windows) should one ever end up in a file name
|
|
25
|
+
ID_CHARS = Ractor.make_shareable([*"0".."9", *"a".."z"]) #: Array[String]
|
|
26
|
+
|
|
27
|
+
# @rbs @clock: ^() -> Float
|
|
28
|
+
# @rbs @random: Random
|
|
29
|
+
# @rbs @lock: Thread::Mutex
|
|
30
|
+
# @rbs @delivery: Thread::Mutex
|
|
31
|
+
# @rbs @state: State
|
|
32
|
+
# @rbs @outputs: Array[output]
|
|
33
|
+
# @rbs @outbox: Array[[Batch, Array[output]]]
|
|
34
|
+
# @rbs @snapshot: State?
|
|
35
|
+
# @rbs @touched: Set[path]
|
|
36
|
+
# @rbs @on_cancel: Hash[path, Array[^(String?) -> void]]
|
|
37
|
+
# @rbs @on_error: ^(Exception, untyped) -> void
|
|
38
|
+
# @rbs @changed: Thread::ConditionVariable
|
|
39
|
+
# @rbs @closers: Array[^() -> void]
|
|
40
|
+
|
|
41
|
+
# An empty log. `clock` gives the time to stamp batches with, and `random` draws task ids - both for tests to pin down
|
|
42
|
+
#: (?id: String | Symbol, ?key: String?, ?clock: ^() -> Float, ?random: Random) -> void
|
|
43
|
+
def initialize(id: SecureRandom.alphanumeric(10, chars: ID_CHARS), key: nil, clock: -> { Process.clock_gettime(Process::CLOCK_REALTIME) }, random: Random.new)
|
|
44
|
+
@id = id.to_s
|
|
45
|
+
@key = key
|
|
46
|
+
@changed = ConditionVariable.new
|
|
47
|
+
@closers = []
|
|
48
|
+
@clock = clock
|
|
49
|
+
@random = random
|
|
50
|
+
@lock = Mutex.new
|
|
51
|
+
@delivery = Mutex.new
|
|
52
|
+
@state = State.new
|
|
53
|
+
@seq = 0
|
|
54
|
+
@outputs = [] #: Array[output]
|
|
55
|
+
@outbox = []
|
|
56
|
+
@snapshot = nil
|
|
57
|
+
@touched = Set.new
|
|
58
|
+
@on_cancel = {}
|
|
59
|
+
@on_error = ->(error, output) { warn("TaskProgress: #{output.inspect} raised #{error.class}: #{error.message}") }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Hears about an output that raised, with the error and the output; it warns by default
|
|
63
|
+
attr_accessor :on_error #: ^(Exception, untyped) -> void
|
|
64
|
+
|
|
65
|
+
# Runs the block under the lock, collecting what it adds with `tx.<<`; it commits as one
|
|
66
|
+
# batch, or not at all if the block raises. A transaction opened inside another one on the
|
|
67
|
+
# same fiber joins it. The block holds the lock throughout - keep it short, and never wait
|
|
68
|
+
# in it on another thread that writes to this log. `at` is the time it happens at - now,
|
|
69
|
+
# unless it relays what happened elsewhere, earlier
|
|
70
|
+
#: [T] (?at: Float?) { (Transaction) -> T } -> T
|
|
71
|
+
def transaction(at: nil) # :nodoc:
|
|
72
|
+
if (tx = current)
|
|
73
|
+
return yield(tx)
|
|
74
|
+
end
|
|
75
|
+
# An interrupt from Thread#raise (or Timeout) is let in only while the block runs, where a
|
|
76
|
+
# rollback undoes it. Anywhere else it waits for the commit to finish, so the state and the
|
|
77
|
+
# log can never part ways
|
|
78
|
+
result = Thread.handle_interrupt(Exception => :never) do
|
|
79
|
+
@lock.synchronize do
|
|
80
|
+
opened = Transaction.new(@state, at || @clock.call, @random)
|
|
81
|
+
self.current = opened
|
|
82
|
+
begin
|
|
83
|
+
value = Thread.handle_interrupt(Exception => :immediate) { yield(opened) }
|
|
84
|
+
commit_locked(opened)
|
|
85
|
+
value
|
|
86
|
+
rescue Exception # rubocop:disable Lint/RescueException -- an interrupt rolls back as much as an error does
|
|
87
|
+
@state.rollback(opened.journal)
|
|
88
|
+
raise
|
|
89
|
+
ensure
|
|
90
|
+
self.current = nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
deliver
|
|
95
|
+
result
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Commits the events as one batch
|
|
99
|
+
#: (*Event) -> void
|
|
100
|
+
def append(*events) = transaction { |tx| events.each { tx << it } } # :nodoc:
|
|
101
|
+
|
|
102
|
+
# An output is anything with `call(batch, log)`. Unless `replay: false`, it first gets the
|
|
103
|
+
# tree so far as one squashed batch, `prev` 0, and then every batch after it. With a `path`
|
|
104
|
+
# it gets only that subtree, as a tree of its own - see Branch
|
|
105
|
+
#: (?output?, ?replay: bool, ?path: path) ?{ (Batch, Log) -> void } -> output
|
|
106
|
+
def add_output(output = nil, replay: true, path: [], &block)
|
|
107
|
+
# A block is a Proc, which is an output like any other
|
|
108
|
+
output = output || (_ = block) || raise(ArgumentError, "An output, or a block")
|
|
109
|
+
@lock.synchronize do
|
|
110
|
+
target = path.empty? ? output : Branch.new(output, path.dup.freeze, @id, @seq, started: replay && @state.key?(path))
|
|
111
|
+
if replay && @state.key?(path)
|
|
112
|
+
@outbox << [Batch.new(log: @id, prev: 0, seq: @seq, at: @clock.call, events: @state.squash(path).freeze), [target].freeze]
|
|
113
|
+
end
|
|
114
|
+
@outputs = [*@outputs, target].freeze
|
|
115
|
+
end
|
|
116
|
+
deliver
|
|
117
|
+
output
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Takes the output as it was given to `add_output`. It hears nothing more once this returns -
|
|
121
|
+
# save a batch being delivered to it on another thread just then
|
|
122
|
+
#: (output) -> void
|
|
123
|
+
def remove_output(output)
|
|
124
|
+
@lock.synchronize { @outputs = @outputs.reject { it.equal?(output) || (it.is_a?(Branch) && it.output.equal?(output)) }.freeze }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Calls the block with the reason once the task at `path` is canceled - at once, if it already
|
|
128
|
+
# was. A task that ends any other way drops it. The block runs on whichever thread delivers
|
|
129
|
+
# the cancel, after it has been committed: keep it short, and let it unblock the work (close a
|
|
130
|
+
# connection, raise into a thread) rather than do the work
|
|
131
|
+
#: (path) { (String?) -> void } -> void
|
|
132
|
+
def on_cancel(path, &block)
|
|
133
|
+
now = @lock.synchronize do
|
|
134
|
+
node = @state[path]
|
|
135
|
+
next false if node.nil? || (node.finished? && node.status != :canceled)
|
|
136
|
+
next node.message || true if node.status == :canceled
|
|
137
|
+
(@on_cancel[path] ||= []) << block
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
block.call((now == true) ? nil : now) if now
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A frozen copy of the tree, safe to read from anywhere - and from inside an output too,
|
|
144
|
+
# since outputs run outside the lock
|
|
145
|
+
#: () -> State
|
|
146
|
+
def snapshot
|
|
147
|
+
return @state if @lock.owned?
|
|
148
|
+
@lock.synchronize do
|
|
149
|
+
snapshot = @snapshot
|
|
150
|
+
if snapshot.nil? || @touched.any?
|
|
151
|
+
snapshot = @snapshot = @state.snapshot(snapshot, (@touched if snapshot))
|
|
152
|
+
@touched = Set.new
|
|
153
|
+
end
|
|
154
|
+
snapshot
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The tree so far as one batch of puts - what a late reader starts from. With a `path`, the
|
|
159
|
+
# subtree there, rooted at `[]` the way a Branch would send it
|
|
160
|
+
#: (?path) -> Batch
|
|
161
|
+
def squashed(path = [])
|
|
162
|
+
@lock.synchronize do
|
|
163
|
+
events = @state.squash(path).map { it.with(path: it.path.drop(path.length).freeze) }
|
|
164
|
+
Batch.new(log: path.empty? ? @id : Branch.id(@id, path), prev: 0, seq: @seq, at: @clock.call, events: events.freeze)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Waits up to `timeout` seconds for a batch after `seq` to commit, and returns the seq then
|
|
169
|
+
#: (Integer, Integer | Float) -> Integer
|
|
170
|
+
def wait_for_change(seq, timeout)
|
|
171
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
172
|
+
@lock.synchronize do
|
|
173
|
+
while @seq == seq && (left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
|
|
174
|
+
@changed.wait(@lock, left)
|
|
175
|
+
end
|
|
176
|
+
@seq
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Runs the block when the log is closed - to stop a thread that feeds it, or flush an output
|
|
181
|
+
#: () { () -> void } -> void
|
|
182
|
+
def on_close(&block) = @lock.synchronize { @closers << block }
|
|
183
|
+
|
|
184
|
+
# Stops what was started for the tree - following a stream, beating, buffering - for when
|
|
185
|
+
# nobody needs it any more. The tree itself stays as it is
|
|
186
|
+
#: () -> void
|
|
187
|
+
def close
|
|
188
|
+
closers = @lock.synchronize { @closers.slice!(0..) || [] }
|
|
189
|
+
closers.each(&:call)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Its id and seq
|
|
193
|
+
#: () -> String
|
|
194
|
+
def inspect = "#<#{self.class} #{@id} seq=#{@seq}>"
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
#: () -> Transaction?
|
|
199
|
+
def current = Thread.current[:task_progress_transactions]&.[](self)
|
|
200
|
+
|
|
201
|
+
# Thread#[] is fiber-local and, unlike Fiber storage, a new thread or fiber does not inherit
|
|
202
|
+
# it - which is what we want, since only the fiber that took the lock may join
|
|
203
|
+
#: (Transaction?) -> void
|
|
204
|
+
def current=(tx)
|
|
205
|
+
fresh = {} #: Hash[Log, Transaction]
|
|
206
|
+
map = (Thread.current[:task_progress_transactions] ||= fresh.compare_by_identity)
|
|
207
|
+
tx ? map[self] = tx : map.delete(self)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
#: (Batch) -> void
|
|
211
|
+
def call_cancel_callbacks(batch)
|
|
212
|
+
batch.events.each do |event|
|
|
213
|
+
next unless %i[cancel done fail].include?(event.op)
|
|
214
|
+
callbacks = @lock.synchronize { @on_cancel.delete(event.path) } or next
|
|
215
|
+
next unless event.op == :cancel
|
|
216
|
+
callbacks.each do |callback|
|
|
217
|
+
callback.call(event.value)
|
|
218
|
+
rescue => e
|
|
219
|
+
@on_error.call(e, callback)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
#: (Transaction) -> void
|
|
225
|
+
def commit_locked(tx)
|
|
226
|
+
# A transaction that changed a node without an event - sealing a mount - still changes what a snapshot shows
|
|
227
|
+
@touched.merge(tx.journal.paths)
|
|
228
|
+
return if tx.events.empty?
|
|
229
|
+
batch = Batch.new(log: @id, prev: @seq, seq: @seq + 1, at: tx.at, events: tx.events.freeze)
|
|
230
|
+
@seq = batch.seq
|
|
231
|
+
@outbox << [batch, @outputs]
|
|
232
|
+
@changed.broadcast
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# One deliverer at a time, taking the outbox in order. A commit made from inside an output
|
|
236
|
+
# finds its own fiber already delivering, and leaves its batch for the loop it is inside of
|
|
237
|
+
#
|
|
238
|
+
# Each batch goes to all its outputs with interrupts held off, so one can never reach some
|
|
239
|
+
# outputs and not others. An interrupt waits for the batch in hand, not for the whole outbox
|
|
240
|
+
#: () -> void
|
|
241
|
+
def deliver
|
|
242
|
+
return if @delivery.owned?
|
|
243
|
+
@delivery.synchronize do
|
|
244
|
+
loop do
|
|
245
|
+
delivered = Thread.handle_interrupt(Exception => :never) do
|
|
246
|
+
entry = @lock.synchronize { @outbox.shift } or next false
|
|
247
|
+
batch, outputs = entry
|
|
248
|
+
outputs.each do |output|
|
|
249
|
+
# One removed since the batch was committed hears no more of it
|
|
250
|
+
next unless @outputs.any? { it.equal?(output) }
|
|
251
|
+
output.call(batch, self)
|
|
252
|
+
rescue => e
|
|
253
|
+
@on_error.call(e, output)
|
|
254
|
+
end
|
|
255
|
+
call_cancel_callbacks(batch) unless @on_cancel.empty?
|
|
256
|
+
true
|
|
257
|
+
end
|
|
258
|
+
break unless delivered
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# A store kept in this process, for where `Rails.cache` is not to hand - tests, a Rack app that
|
|
6
|
+
# runs its jobs in-process. Anything with `read`, `write` and `delete` will do as a store; this
|
|
7
|
+
# is the smallest one, and is safe to share between threads
|
|
8
|
+
class MemoryCache
|
|
9
|
+
# @rbs @entries: Hash[String, untyped]
|
|
10
|
+
# @rbs @lock: Thread::Mutex
|
|
11
|
+
|
|
12
|
+
# An empty store
|
|
13
|
+
#: () -> void
|
|
14
|
+
def initialize
|
|
15
|
+
@entries = {}
|
|
16
|
+
@lock = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# What was written under `key`, or nil
|
|
20
|
+
#: (String) -> untyped
|
|
21
|
+
def read(key) = @lock.synchronize { @entries[key] }
|
|
22
|
+
|
|
23
|
+
# Keeps `value` under `key`; options, such as `expires_in:`, are taken and ignored
|
|
24
|
+
#: (String, untyped, **untyped) -> void
|
|
25
|
+
def write(key, value, **) = @lock.synchronize { @entries[key] = value }
|
|
26
|
+
|
|
27
|
+
# Forgets what is under `key`
|
|
28
|
+
#: (String) -> void
|
|
29
|
+
def delete(key) = @lock.synchronize { @entries.delete(key) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class TaskProgress
|
|
5
|
+
# A stream store kept in this process - a stream per tree, each under its own key: what
|
|
6
|
+
# TaskProgress.stream_store is until told otherwise, and what the tests and the bench run on.
|
|
7
|
+
# It does what RedisStreamStore does - append-only entries under a
|
|
8
|
+
# key, read from an id on and waited for, trimmed from the front, plus plain values that
|
|
9
|
+
# expire - so a tree handed off here behaves as one handed off through Redis, as long as the
|
|
10
|
+
# job runs in the same process.
|
|
11
|
+
#
|
|
12
|
+
# Ids are "n-0", like Redis's, and grow across all keys. Safe to share between threads.
|
|
13
|
+
#
|
|
14
|
+
# `set`, `trim`, `delete` and `expire` take a `guard:` - a key and the value it must hold - and
|
|
15
|
+
# do nothing, returning false, unless it does. Checked and done in one step, which is what keeps
|
|
16
|
+
# a writer another has replaced from writing over it, see StreamOutput
|
|
17
|
+
class MemoryStreamStore
|
|
18
|
+
# @rbs @entries: Hash[String, Array[[Integer, String]]]
|
|
19
|
+
# @rbs @values: Hash[String, String]
|
|
20
|
+
# @rbs @expiries: Hash[String, Float]
|
|
21
|
+
# @rbs @counter: Integer
|
|
22
|
+
# @rbs @lock: Thread::Mutex
|
|
23
|
+
# @rbs @appended: Thread::ConditionVariable
|
|
24
|
+
# @rbs @namespace: String?
|
|
25
|
+
|
|
26
|
+
# An empty store. With a `namespace`, its keys go under it - see RedisStreamStore
|
|
27
|
+
#: (?namespace: String?) -> void
|
|
28
|
+
def initialize(namespace: nil)
|
|
29
|
+
@namespace = namespace
|
|
30
|
+
@entries = {}
|
|
31
|
+
@values = {}
|
|
32
|
+
@expiries = {}
|
|
33
|
+
@counter = 0
|
|
34
|
+
@lock = Mutex.new
|
|
35
|
+
@appended = ConditionVariable.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Adds `data` at the end of the stream at `key`, and returns its id
|
|
39
|
+
#: (String, String) -> String
|
|
40
|
+
def append(key, data)
|
|
41
|
+
key = ns(key)
|
|
42
|
+
@lock.synchronize do
|
|
43
|
+
expire_locked(key)
|
|
44
|
+
id = @counter += 1
|
|
45
|
+
(@entries[key] ||= []) << [id, data]
|
|
46
|
+
@appended.broadcast
|
|
47
|
+
"#{id}-0"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The entries after the id `after` - all of them when it is nil - as `[id, data]` pairs. With
|
|
52
|
+
# a `timeout`, waits up to that many seconds for one to come when there are none yet
|
|
53
|
+
#: (String, ?after: String?, ?timeout: (Integer | Float)?) -> Array[[String, String]]
|
|
54
|
+
def read(key, after: nil, timeout: nil)
|
|
55
|
+
key = ns(key)
|
|
56
|
+
floor = after.to_i
|
|
57
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
|
|
58
|
+
@lock.synchronize do
|
|
59
|
+
loop do
|
|
60
|
+
expire_locked(key)
|
|
61
|
+
found = (@entries[key] || []).select { |id, _| id > floor }
|
|
62
|
+
return found.map { |id, data| ["#{id}-0", data] } if found.any? || deadline.nil?
|
|
63
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
64
|
+
return [] if left <= 0
|
|
65
|
+
@appended.wait(@lock, left)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The value at `key`, or nil
|
|
71
|
+
#: (String) -> String?
|
|
72
|
+
def get(key) = ns(key).then { |key| @lock.synchronize { expire_locked(key) || @values[key] } }
|
|
73
|
+
|
|
74
|
+
# Keeps `value` at `key`, for `expires_in` seconds if given
|
|
75
|
+
#: (String, String, ?expires_in: (Integer | Float)?, ?guard: [String, String]?) -> bool
|
|
76
|
+
def set(key, value, expires_in: nil, guard: nil)
|
|
77
|
+
key = ns(key)
|
|
78
|
+
guarded(guard) do
|
|
79
|
+
@values[key] = value
|
|
80
|
+
expires_in ? @expiries[key] = now + expires_in : @expiries.delete(key)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Forgets the streams and values at these keys
|
|
85
|
+
#: (*String, ?guard: [String, String]?) -> bool
|
|
86
|
+
def delete(*keys, guard: nil)
|
|
87
|
+
keys = keys.map { ns(it) }
|
|
88
|
+
guarded(guard) { keys.each { forget(it) } }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Drops the entries before the id `before`
|
|
92
|
+
#: (String, before: String, ?guard: [String, String]?) -> bool
|
|
93
|
+
def trim(key, before:, guard: nil)
|
|
94
|
+
key = ns(key)
|
|
95
|
+
floor = before.to_i
|
|
96
|
+
guarded(guard) { @entries[key]&.reject! { |id, _| id < floor } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Forgets what is at `key` - a stream or a value - `seconds` from now
|
|
100
|
+
#: (String, Integer | Float, ?guard: [String, String]?) -> bool
|
|
101
|
+
def expire(key, seconds, guard: nil)
|
|
102
|
+
key = ns(key)
|
|
103
|
+
guarded(guard) { @expiries[key] = now + seconds if @entries.key?(key) || @values.key?(key) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Runs the block under the lock if the guard holds, and says whether it did
|
|
109
|
+
#: ([String, String]?) { () -> void } -> bool
|
|
110
|
+
def guarded(guard)
|
|
111
|
+
@lock.synchronize do
|
|
112
|
+
if guard
|
|
113
|
+
expire_locked(ns(guard[0]))
|
|
114
|
+
next false unless @values[ns(guard[0])] == guard[1]
|
|
115
|
+
end
|
|
116
|
+
yield
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (String) -> String
|
|
122
|
+
def ns(key) = @namespace ? "#{@namespace}:#{key}" : key
|
|
123
|
+
|
|
124
|
+
#: () -> Float
|
|
125
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
126
|
+
|
|
127
|
+
# Always nil, so a reader can `||` past it
|
|
128
|
+
#: (String) -> nil
|
|
129
|
+
def expire_locked(key)
|
|
130
|
+
expiry = @expiries[key]
|
|
131
|
+
forget(key) if expiry && expiry <= now
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#: (String) -> void
|
|
136
|
+
def forget(key)
|
|
137
|
+
@entries.delete(key)
|
|
138
|
+
@values.delete(key)
|
|
139
|
+
@expiries.delete(key)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|