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