dboard 3.0.1 → 3.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5b007fa0f768847e5a5155f08205ca9b8bc3ec1afbe60dfbc21d0e0ba7fb279
4
- data.tar.gz: 665d3e307f3af7c5192e30b2864b195e2f842d4165acd9f7f5ad062439191ace
3
+ metadata.gz: d1b44cea534d5b5317f70d02d39b43cd2d072b3eca3446243f0b98bba7569823
4
+ data.tar.gz: d70dee83fae380054f04250252e9956e29630b7f05a9eb49f4f14e536891ee82
5
5
  SHA512:
6
- metadata.gz: 166b2aa677c0234e1de2574df03f17072adab4e09c7bd68e26a8be03640f1b4ac50ccd3246657c383cd741611823c3efbd8fdd299a582a17d534a3c40164e5cf
7
- data.tar.gz: aa99aae44e6a0a968150f42438dca61893f45960df655a06722a93d0ff52a2e3daa6abb56ed9f827b8647f6a718b0f5c230cb117c83e696fd22868852d5402e4
6
+ metadata.gz: 264dc148bf21c0dbce7173c42f412c5d55d54c27ff26124ac692485c4d7538e66dafd920adc31bb79a595b8069bcd635f07513bc7736ce81205038d5020b0e14
7
+ data.tar.gz: 7640bd50fab1e0a0f43641462657482ba996bff8e0185e6d76f6537a6c2fafdcfe5467201cbd8f45fe5508bc396ddcb5b5bef61ed525c82e80a39236f61237e2
@@ -14,12 +14,12 @@ jobs:
14
14
  strategy:
15
15
  matrix:
16
16
  ruby-version:
17
- - 3.1
18
- - "3.0"
19
- - 2.7
17
+ - "3.4"
18
+ - "3.3"
19
+ - "3.2"
20
20
 
21
21
  steps:
22
- - uses: actions/checkout@v3
22
+ - uses: actions/checkout@v7
23
23
  - name: Set up Ruby ${{ matrix.ruby-version }}
24
24
  uses: ruby/setup-ruby@v1
25
25
  with:
data/README.md CHANGED
@@ -21,6 +21,16 @@ Things dboard do for you:
21
21
  * Provides a javascript client that knows how to talk to the API (for now it's only included in the [example app](https://github.com/joakimk/dboard_example))
22
22
  * Only calls your javascript widgets when there is new data.
23
23
 
24
+ Refreshing sources:
25
+
26
+ Each source defines `update_interval` (seconds); the collector polls it on that cadence and pushes the result to the dashboard.
27
+
28
+ To refresh a source between scheduled polls (e.g. from an inbound webhook), call `Dboard::Collector.request_update(:key)`, where `:key` is the key the source was registered under. On-demand refreshes are throttled: the first fires immediately and rapid repeats collapse into at most one refresh per floor. The floor defaults to 30 seconds; a source can lower it with `min_update_interval` (seconds), capped at its `update_interval`.
29
+
30
+ For a targeted refresh, pass an argument: `Dboard::Collector.request_update(:key, arg)`. A source receives it by defining `fetch(args = nil)`: `args` is `nil` for a full refresh and an array of the requested arguments for a targeted one, so the source can refresh only what changed (e.g. the single project named in a webhook).
31
+
32
+ Publishing is wholesale: the collector always replaces the stored blob for a key, so a targeted refresh must return the full merged blob (reading current state from the cache if needed), not only the part it refreshed.
33
+
24
34
  Data flow:
25
35
 
26
36
  +-----------------+ +--------------------+
data/lib/collector.rb CHANGED
@@ -5,6 +5,12 @@ module Dboard
5
5
  class Collector
6
6
  include Singleton
7
7
 
8
+ DEFAULT_MIN_INTERVAL = 30 # seconds
9
+
10
+ FULL = Object.new.freeze # a no-arg request; compared by identity so a caller-supplied value never collides
11
+ SKIP = Object.new.freeze # a throttled/empty attempt that must not fetch
12
+ private_constant :FULL, :SKIP
13
+
8
14
  attr_reader :sources
9
15
 
10
16
  def self.register_source(key, source_instance)
@@ -19,6 +25,10 @@ module Dboard
19
25
  instance.register_error_callback(callback)
20
26
  end
21
27
 
28
+ def self.request_update(key, arg = nil)
29
+ instance.request_update(key, arg)
30
+ end
31
+
22
32
  def self.start
23
33
  instance.start
24
34
  end
@@ -27,6 +37,11 @@ module Dboard
27
37
  @sources = {}
28
38
  @after_update_callback = lambda {}
29
39
  @error_callback = lambda { |exception| }
40
+ @mutex = Mutex.new
41
+ @last_update_at = {}
42
+ @active = {}
43
+ @pending = {}
44
+ @refresh_locks = {}
30
45
  end
31
46
 
32
47
  def start
@@ -35,7 +50,8 @@ module Dboard
35
50
  wait_a_little_bit_to_not_start_all_fetches_at_once
36
51
 
37
52
  loop do
38
- update_in_thread(source, instance)
53
+ request_update(source)
54
+ sleep instance.update_interval
39
55
  end
40
56
  end
41
57
  end
@@ -44,6 +60,7 @@ module Dboard
44
60
 
45
61
  def register_source(key, instance)
46
62
  @sources.merge!({ key => instance })
63
+ @refresh_locks[key] = Mutex.new
47
64
  end
48
65
 
49
66
  def register_after_update_callback(callback)
@@ -54,10 +71,33 @@ module Dboard
54
71
  @error_callback = callback
55
72
  end
56
73
 
57
- # Public because the old tests depend on it
58
- def update_source(source, instance)
74
+ def request_update(key, arg = nil)
75
+ instance = @sources.fetch(key)
76
+ floor = min_interval_for(instance)
77
+ entry = arg.nil? ? FULL : arg
78
+ leading = nil
79
+ delay = @mutex.synchronize {
80
+ now = monotonic_now
81
+ (@pending[key] ||= []) << entry
82
+ case decide_request(@active[key], @last_update_at[key], floor, now)
83
+ when :coalesce
84
+ return
85
+ when :refresh_now
86
+ @active[key] = true
87
+ leading = @pending[key]
88
+ @pending[key] = []
89
+ 0
90
+ when :schedule
91
+ @active[key] = true
92
+ floor - (now - @last_update_at[key])
93
+ end
94
+ }
95
+ spawn { run_worker(key, instance, floor, delay, leading) }
96
+ end
97
+
98
+ def update_source(source, instance, batch = nil)
59
99
  begin
60
- data = instance.fetch
100
+ data = fetch_source(instance, batch)
61
101
  publish_data(source, data)
62
102
  ensure
63
103
  @after_update_callback.call
@@ -70,18 +110,101 @@ module Dboard
70
110
 
71
111
  private
72
112
 
73
- def update_in_thread(source, instance)
74
- time = Time.now
75
- puts "#{source} updating..."
76
- update_source(source, instance)
77
- elapsed_time = Time.now - time
78
- time_until_next_update = instance.update_interval - elapsed_time
79
- time_until_next_update = 0 if time_until_next_update < 0
80
- puts "#{source} done in #{elapsed_time} seconds, will update again in #{time_until_next_update} seconds (interval: #{instance.update_interval})."
81
- sleep time_until_next_update
113
+ def fetch_source(instance, batch)
114
+ full_batch?(batch) ? instance.fetch : instance.fetch(batch)
115
+ end
116
+
117
+ def full_batch?(batch)
118
+ batch.nil? || batch.any? { |entry| entry.equal?(FULL) }
119
+ end
120
+
121
+ def describe_batch(batch)
122
+ full_batch?(batch) ? "full" : "targeted: #{batch.join(", ")}"
123
+ end
124
+
125
+ def decide_request(active, last_update_at, floor, now)
126
+ return :coalesce if active
127
+ return :refresh_now if last_update_at.nil? || (now - last_update_at) >= floor
128
+
129
+ :schedule
130
+ end
131
+
132
+ def run_worker(key, instance, floor, delay, leading)
133
+ cleared = false
134
+ loop do
135
+ sleep delay if delay && delay > 0
136
+ fired = perform_refresh(key, instance, floor, leading: leading, drain: leading.nil?)
137
+ delay = @mutex.synchronize {
138
+ remaining = floor - (monotonic_now - @last_update_at[key])
139
+ remaining = 0 if remaining < 0
140
+ pending = @pending[key]
141
+ if fired
142
+ leading = nil
143
+ if pending && !pending.empty?
144
+ remaining # trailing: drain the burst that arrived while we were active
145
+ else
146
+ @active[key] = false
147
+ cleared = true
148
+ nil
149
+ end
150
+ elsif leading || (pending && !pending.empty?)
151
+ remaining # throttled; retry the retained leading snapshot or the queued args after the floor
152
+ else
153
+ @active[key] = false
154
+ cleared = true
155
+ nil
156
+ end
157
+ }
158
+ break if cleared
159
+ end
82
160
  rescue Exception => ex
83
- puts "Something failed outside the update_source method. #{ex.message}"
161
+ puts "Something failed in the update worker for #{key}: #{ex.message}"
84
162
  puts ex.backtrace
163
+ ensure
164
+ @mutex.synchronize { @active[key] = false } unless cleared
165
+ end
166
+
167
+ def perform_refresh(source, instance, min_interval, leading: nil, drain: false)
168
+ @refresh_locks.fetch(source).synchronize {
169
+ batch = @mutex.synchronize {
170
+ now = monotonic_now
171
+ last = @last_update_at[source]
172
+ next SKIP unless last.nil? || (now - last) >= min_interval
173
+
174
+ if leading
175
+ resolved = leading
176
+ elsif drain
177
+ queued = (@pending[source] ||= [])
178
+ next SKIP if queued.empty?
179
+
180
+ resolved = queued
181
+ @pending[source] = []
182
+ else
183
+ resolved = nil
184
+ end
185
+ @last_update_at[source] = now
186
+ resolved
187
+ }
188
+ return false if batch.equal?(SKIP)
189
+
190
+ started = monotonic_now
191
+ update_source(source, instance, batch)
192
+ puts "#{source} refreshed (#{describe_batch(batch)}) in #{(monotonic_now - started).round(1)}s"
193
+ true
194
+ }
195
+ end
196
+
197
+ def min_interval_for(instance)
198
+ override = instance.respond_to?(:min_update_interval) ? instance.min_update_interval : DEFAULT_MIN_INTERVAL
199
+ [ override, instance.update_interval ].min
200
+ end
201
+
202
+ def spawn(&block)
203
+ Thread.new(&block)
204
+ end
205
+
206
+ def monotonic_now
207
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
85
208
  end
86
209
 
87
210
  def publish_data(source, data)
data/lib/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dboard
2
- VERSION = "3.0.1"
2
+ VERSION = "3.2.1"
3
3
  end
@@ -1,63 +1,470 @@
1
1
  require File.expand_path(File.join(File.dirname(__FILE__), "spec_helper"))
2
2
 
3
- describe Dboard::Collector, ".register_source" do
4
- before do
5
- Dboard::Collector.instance.sources.clear
3
+ class FakeClock
4
+ attr_reader :now
5
+
6
+ def initialize
7
+ @now = 0.0
8
+ end
9
+
10
+ def advance(seconds)
11
+ @now += seconds
6
12
  end
13
+ end
14
+
15
+ class LatchSource
16
+ attr_reader :update_interval
7
17
 
8
- let(:new_relic) { double(:new_relic) }
9
- let(:callback) { double(:callback) }
18
+ def initialize(update_interval:)
19
+ @update_interval = update_interval
20
+ @lock = Mutex.new
21
+ @started = Queue.new
22
+ @release = Queue.new
23
+ @block_next = false
24
+ @value = nil
25
+ @fetch_values = []
26
+ @fetch_times = []
27
+ end
10
28
 
11
- it "can register a source" do
12
- allow(new_relic).to receive(:update_interval).and_return(5)
13
- Dboard::Collector.register_source :new_relic, new_relic
14
- expect(Dboard::Collector.instance.sources).to eq({ new_relic: new_relic })
29
+ def set_value(value)
30
+ @lock.synchronize { @value = value }
15
31
  end
16
32
 
17
- it "can register an after update callback" do
18
- allow(new_relic).to receive(:fetch).and_return({ db: "100%" })
19
- Dboard::Collector.register_after_update_callback callback
33
+ def block_next_fetch!
34
+ @lock.synchronize { @block_next = true }
35
+ end
20
36
 
21
- expect(callback).to receive(:call)
22
- allow(Dboard::Publisher).to receive(:publish)
23
- Dboard::Collector.instance.update_source(:new_relic, new_relic)
37
+ def wait_until_started
38
+ @started.pop
39
+ end
24
40
 
25
- # since it is a singleton, and this callbacks leaks into the other tests
26
- Dboard::Collector.register_after_update_callback(lambda {})
41
+ def release!
42
+ @release << true
27
43
  end
28
44
 
29
- it "can register an error callback" do
30
- error = RuntimeError.new("error")
31
- allow(new_relic).to receive(:fetch).and_raise(error)
45
+ def fetch_count
46
+ @lock.synchronize { @fetch_values.size }
47
+ end
32
48
 
33
- Dboard::Collector.register_error_callback callback
49
+ def fetch_values
50
+ @lock.synchronize { @fetch_values.dup }
51
+ end
34
52
 
35
- expect(callback).to receive(:call).with(error)
36
- allow(Dboard::Publisher).to receive(:publish)
37
- allow_any_instance_of(Dboard::Collector).to receive(:puts)
38
- Dboard::Collector.instance.update_source(:new_relic, new_relic)
53
+ def fetch_times
54
+ @lock.synchronize { @fetch_times.dup }
55
+ end
39
56
 
40
- # since it is a singleton, and this callbacks leaks into the other tests
41
- Dboard::Collector.register_error_callback(lambda { |_| })
57
+ def fetch
58
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ block_here = @lock.synchronize {
60
+ @fetch_values << @value
61
+ @fetch_times << now
62
+ blocking = @block_next
63
+ @block_next = false
64
+ blocking
65
+ }
66
+ if block_here
67
+ @started << true
68
+ @release.pop
69
+ end
70
+ { value: fetch_values.last }
42
71
  end
43
72
  end
44
73
 
45
- describe Dboard::Collector, "update_source" do
46
- before do
47
- Dboard::Collector.instance.sources.clear
74
+ RSpec.describe Dboard::Collector do
75
+ describe ".register_source" do
76
+ before do
77
+ Dboard::Collector.instance.sources.clear
78
+ end
79
+
80
+ let(:new_relic) { double(:new_relic) }
81
+ let(:callback) { double(:callback) }
82
+
83
+ it "can register a source" do
84
+ allow(new_relic).to receive(:update_interval).and_return(5)
85
+ Dboard::Collector.register_source :new_relic, new_relic
86
+ expect(Dboard::Collector.instance.sources).to eq({ new_relic: new_relic })
87
+ end
88
+
89
+ it "can register an after update callback" do
90
+ allow(new_relic).to receive(:fetch).and_return({ db: "100%" })
91
+ allow(callback).to receive(:call)
92
+ allow(Dboard::Publisher).to receive(:publish)
93
+ Dboard::Collector.register_after_update_callback callback
94
+
95
+ Dboard::Collector.instance.update_source(:new_relic, new_relic)
96
+
97
+ expect(callback).to have_received(:call)
98
+
99
+ # since it is a singleton, and this callbacks leaks into the other tests
100
+ Dboard::Collector.register_after_update_callback(lambda {})
101
+ end
102
+
103
+ it "can register an error callback" do
104
+ error = RuntimeError.new("error")
105
+ allow(new_relic).to receive(:fetch).and_raise(error)
106
+ allow(callback).to receive(:call)
107
+ allow(Dboard::Publisher).to receive(:publish)
108
+ allow_any_instance_of(Dboard::Collector).to receive(:puts)
109
+ Dboard::Collector.register_error_callback callback
110
+
111
+ Dboard::Collector.instance.update_source(:new_relic, new_relic)
112
+
113
+ expect(callback).to have_received(:call).with(error)
114
+
115
+ # since it is a singleton, and this callbacks leaks into the other tests
116
+ Dboard::Collector.register_error_callback(lambda { |_| })
117
+ end
48
118
  end
49
119
 
50
- let(:new_relic) { double(:new_relic) }
120
+ describe "update_source" do
121
+ before do
122
+ Dboard::Collector.instance.sources.clear
123
+ end
124
+
125
+ let(:new_relic) { double(:new_relic) }
126
+
127
+ it "collects and publishes data from sources" do
128
+ allow(new_relic).to receive(:fetch).and_return({ db: "100%" })
129
+ allow(Dboard::Publisher).to receive(:publish)
51
130
 
52
- it "collects and publishes data from sources" do
53
- allow(new_relic).to receive(:fetch).and_return({ db: "100%" })
54
- expect(Dboard::Publisher).to receive(:publish).with(:new_relic, { db: "100%" })
55
- Dboard::Collector.instance.update_source(:new_relic, new_relic)
131
+ Dboard::Collector.instance.update_source(:new_relic, new_relic)
132
+
133
+ expect(Dboard::Publisher).to have_received(:publish).with(:new_relic, { db: "100%" })
134
+ end
135
+
136
+ it "prints out debugging info" do
137
+ allow(new_relic).to receive(:fetch).and_raise(Exception.new("some error"))
138
+ allow(Dboard::Collector.instance).to receive(:puts)
139
+
140
+ Dboard::Collector.instance.update_source(:new_relic, new_relic)
141
+
142
+ expect(Dboard::Collector.instance).to have_received(:puts).twice
143
+ end
56
144
  end
57
145
 
58
- it "prints out debugging info" do
59
- allow(new_relic).to receive(:fetch).and_raise(Exception.new("some error"))
60
- expect(Dboard::Collector.instance).to receive(:puts).twice
61
- Dboard::Collector.instance.update_source(:new_relic, new_relic)
146
+ describe "throttled on-demand refresh" do
147
+ let(:collector) { Dboard::Collector.instance }
148
+
149
+ before do
150
+ reset_collector!
151
+ allow(collector).to receive(:puts)
152
+ allow(Dboard::Publisher).to receive(:publish)
153
+ end
154
+
155
+ after do
156
+ join_threads
157
+ reset_collector!
158
+ end
159
+
160
+ describe "the refresh decision" do
161
+ it "refreshes immediately on the first trigger" do
162
+ expect(collector.send(:decide_request, false, nil, 30, 100)).to eq(:refresh_now)
163
+ end
164
+
165
+ it "refreshes again once the floor has elapsed" do
166
+ expect(collector.send(:decide_request, false, 100, 30, 130)).to eq(:refresh_now)
167
+ end
168
+
169
+ it "schedules a trailing when triggered within the floor" do
170
+ expect(collector.send(:decide_request, false, 100, 30, 110)).to eq(:schedule)
171
+ end
172
+
173
+ it "coalesces a trigger that arrives while a refresh is active or scheduled" do
174
+ expect(collector.send(:decide_request, true, 100, 30, 110)).to eq(:coalesce)
175
+ end
176
+ end
177
+
178
+ describe "the effective floor" do
179
+ it "defaults to DEFAULT_MIN_INTERVAL" do
180
+ source = double(:source, update_interval: 100)
181
+ expect(collector.send(:min_interval_for, source)).to eq(Dboard::Collector::DEFAULT_MIN_INTERVAL)
182
+ end
183
+
184
+ it "uses a source's min_update_interval override" do
185
+ source = double(:source, update_interval: 100, min_update_interval: 10)
186
+ expect(collector.send(:min_interval_for, source)).to eq(10)
187
+ end
188
+
189
+ it "never exceeds the update_interval" do
190
+ too_short = double(:source, update_interval: 5)
191
+ big_override = double(:source, update_interval: 5, min_update_interval: 30)
192
+ expect(collector.send(:min_interval_for, too_short)).to eq(5)
193
+ expect(collector.send(:min_interval_for, big_override)).to eq(5)
194
+ end
195
+ end
196
+
197
+ describe "coalescing (deterministic clock)" do
198
+ it "turns a burst into exactly one leading and one trailing refresh" do
199
+ source = double(:source, update_interval: 100)
200
+ allow(source).to receive(:fetch).and_return({ ok: true })
201
+ collector.register_source(:src, source)
202
+ use_fake_clock
203
+ workers = capture_worker_blocks
204
+
205
+ 10.times { collector.request_update(:src) }
206
+ workers.each(&:call)
207
+
208
+ expect(source).to have_received(:fetch).twice
209
+ end
210
+
211
+ it "still fires the trailing when the floor is clamped to a fast update_interval" do
212
+ source = double(:source, update_interval: 5)
213
+ allow(source).to receive(:fetch).and_return({ ok: true })
214
+ collector.register_source(:src, source)
215
+ use_fake_clock
216
+ workers = capture_worker_blocks
217
+
218
+ 3.times { collector.request_update(:src) }
219
+ workers.each(&:call)
220
+
221
+ expect(source).to have_received(:fetch).twice
222
+ end
223
+
224
+ it "resets in-flight state when a fetch raises, so a later trigger still refreshes" do
225
+ source = double(:source, update_interval: 100)
226
+ calls = 0
227
+ allow(source).to receive(:fetch) do
228
+ calls += 1
229
+ raise "boom" if calls == 1
230
+
231
+ { ok: true }
232
+ end
233
+ collector.register_source(:src, source)
234
+ clock = use_fake_clock
235
+ run_workers_inline
236
+
237
+ collector.request_update(:src)
238
+ expect(collector.instance_variable_get(:@active)[:src]).to be_falsey
239
+
240
+ clock.advance(30)
241
+ collector.request_update(:src)
242
+
243
+ expect(source).to have_received(:fetch).twice
244
+ end
245
+ end
246
+
247
+ describe "the shared clock" do
248
+ it "re-evaluates a scheduled trailing when a poll refreshes during its sleep" do
249
+ source = double(:source, update_interval: 100, min_update_interval: 10)
250
+ clock = FakeClock.new
251
+ fetch_times = []
252
+ allow(source).to receive(:fetch) { fetch_times << clock.now; { ok: true } }
253
+ collector.register_source(:src, source)
254
+ allow(collector).to receive(:monotonic_now) { clock.now }
255
+
256
+ polled = false
257
+ allow(collector).to receive(:sleep) do |seconds|
258
+ if polled
259
+ clock.advance(seconds)
260
+ else
261
+ polled = true
262
+ clock.advance(4)
263
+ collector.instance_variable_get(:@last_update_at)[:src] = clock.now
264
+ end
265
+ end
266
+
267
+ workers = capture_worker_blocks
268
+ 2.times { collector.request_update(:src) }
269
+ workers.each(&:call)
270
+
271
+ expect(fetch_times.length).to eq(2)
272
+ expect(fetch_times.last).to be >= 4 + 10
273
+ end
274
+ end
275
+
276
+ describe "with arguments" do
277
+ it "passes the argument to fetch as a one-element batch and publishes the result" do
278
+ source = double(:source, update_interval: 100)
279
+ allow(source).to receive(:fetch).and_return({ ok: "x" })
280
+ collector.register_source(:src, source)
281
+ use_fake_clock
282
+ run_workers_inline
283
+
284
+ collector.request_update(:src, "x")
285
+
286
+ expect(source).to have_received(:fetch).with([ "x" ])
287
+ expect(Dboard::Publisher).to have_received(:publish).with(:src, { ok: "x" })
288
+ end
289
+
290
+ it "forwards the argument through the class-level request_update" do
291
+ source = double(:source, update_interval: 100)
292
+ allow(source).to receive(:fetch).and_return({ ok: true })
293
+ collector.register_source(:src, source)
294
+ use_fake_clock
295
+ run_workers_inline
296
+
297
+ Dboard::Collector.request_update(:src, "x")
298
+
299
+ expect(source).to have_received(:fetch).with([ "x" ])
300
+ end
301
+
302
+ it "splits a burst of arguments into a leading batch and a trailing batch" do
303
+ source = double(:source, update_interval: 100)
304
+ allow(source).to receive(:fetch).and_return({ ok: true })
305
+ collector.register_source(:src, source)
306
+ use_fake_clock
307
+ workers = capture_worker_blocks
308
+
309
+ collector.request_update(:src, "x")
310
+ collector.request_update(:src, "y")
311
+ collector.request_update(:src, "z")
312
+ workers.each(&:call)
313
+
314
+ expect(source).to have_received(:fetch).with([ "x" ]).once
315
+ expect(source).to have_received(:fetch).with([ "y", "z" ]).once
316
+ end
317
+
318
+ it "refreshes the whole source when no argument is given" do
319
+ source = double(:source, update_interval: 100)
320
+ allow(source).to receive(:fetch).and_return({ ok: true })
321
+ collector.register_source(:src, source)
322
+ use_fake_clock
323
+ run_workers_inline
324
+
325
+ collector.request_update(:src)
326
+
327
+ expect(source).to have_received(:fetch).with(no_args)
328
+ end
329
+
330
+ it "does a full refresh when a no-arg request is coalesced with pending arguments" do
331
+ source = double(:source, update_interval: 100)
332
+ allow(source).to receive(:fetch).and_return({ ok: true })
333
+ collector.register_source(:src, source)
334
+ use_fake_clock
335
+ workers = capture_worker_blocks
336
+
337
+ collector.request_update(:src, "a")
338
+ collector.request_update(:src, "b")
339
+ collector.request_update(:src)
340
+ workers.each(&:call)
341
+
342
+ expect(source).to have_received(:fetch).with([ "a" ]).once
343
+ expect(source).to have_received(:fetch).with(no_args).once
344
+ end
345
+
346
+ it "treats a caller-supplied :full argument as an ordinary partial arg" do
347
+ source = double(:source, update_interval: 100)
348
+ allow(source).to receive(:fetch).and_return({ ok: true })
349
+ collector.register_source(:src, source)
350
+ use_fake_clock
351
+ run_workers_inline
352
+
353
+ collector.request_update(:src, :full)
354
+
355
+ expect(source).to have_received(:fetch).with([ :full ])
356
+ end
357
+
358
+ it "refreshes a source whose fetch takes no arguments" do
359
+ source = Class.new do
360
+ def update_interval
361
+ 100
362
+ end
363
+
364
+ def fetch
365
+ { ok: true }
366
+ end
367
+ end.new
368
+ collector.register_source(:src, source)
369
+ use_fake_clock
370
+ run_workers_inline
371
+
372
+ collector.request_update(:src)
373
+
374
+ expect(Dboard::Publisher).to have_received(:publish).with(:src, { ok: true })
375
+ end
376
+
377
+ it "retries the retained leading snapshot when a poll refreshes before it runs" do
378
+ source = double(:source, update_interval: 100)
379
+ fetch_args = []
380
+ allow(source).to receive(:fetch) { |*args| fetch_args << args.first; { ok: true } }
381
+ collector.register_source(:src, source)
382
+ clock = use_fake_clock
383
+ workers = capture_worker_blocks
384
+
385
+ collector.request_update(:src, "p")
386
+ collector.instance_variable_get(:@last_update_at)[:src] = clock.now
387
+ workers.each(&:call)
388
+
389
+ expect(fetch_args).to eq([ [ "p" ] ])
390
+ end
391
+ end
392
+
393
+ describe "under real threads" do
394
+ it "coalesces a burst arriving during the leading fetch into one trailing that captures the final state" do
395
+ source = LatchSource.new(update_interval: 0.15)
396
+ source.set_value("v1")
397
+ source.block_next_fetch!
398
+ collector.register_source(:src, source)
399
+ capture_real_threads
400
+
401
+ collector.request_update(:src)
402
+ source.wait_until_started
403
+
404
+ source.set_value("v2")
405
+ 10.times { collector.request_update(:src) }
406
+
407
+ source.release!
408
+ join_threads
409
+
410
+ expect(source.fetch_count).to eq(2)
411
+ expect(source.fetch_values.last).to eq("v2")
412
+ expect(source.fetch_times[1] - source.fetch_times[0]).to be >= 0.15 - 0.02
413
+ end
414
+
415
+ it "throttles a sustained stream of triggers to roughly one refresh per floor" do
416
+ source = LatchSource.new(update_interval: 0.1)
417
+ collector.register_source(:src, source)
418
+ capture_real_threads
419
+
420
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
421
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
422
+ collector.request_update(:src)
423
+ sleep 0.01
424
+ end
425
+ join_threads
426
+
427
+ expect(source.fetch_count).to be_between(2, 8)
428
+ end
429
+ end
430
+
431
+ private
432
+
433
+ def reset_collector!
434
+ collector.sources.clear
435
+ %i[@last_update_at @active @pending @refresh_locks].each { |ivar| collector.instance_variable_get(ivar).clear }
436
+ collector.register_after_update_callback(lambda {})
437
+ collector.register_error_callback(lambda { |_| })
438
+ end
439
+
440
+ def use_fake_clock
441
+ clock = FakeClock.new
442
+ allow(collector).to receive(:monotonic_now) { clock.now }
443
+ allow(collector).to receive(:sleep) { |seconds| clock.advance(seconds) }
444
+ clock
445
+ end
446
+
447
+ def run_workers_inline
448
+ allow(collector).to receive(:spawn) { |&block| block.call }
449
+ end
450
+
451
+ def capture_worker_blocks
452
+ blocks = []
453
+ allow(collector).to receive(:spawn) { |&block| blocks << block }
454
+ blocks
455
+ end
456
+
457
+ def capture_real_threads
458
+ @threads = []
459
+ allow(collector).to receive(:spawn) do |&block|
460
+ thread = Thread.new(&block)
461
+ @threads << thread
462
+ thread
463
+ end
464
+ end
465
+
466
+ def join_threads
467
+ (@threads || []).each { |thread| thread.join(3) }
468
+ end
62
469
  end
63
470
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joakim Kolsjö
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-06-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: dalli
@@ -124,7 +123,6 @@ licenses:
124
123
  - MIT
125
124
  metadata:
126
125
  rubygems_mfa_required: 'true'
127
- post_install_message:
128
126
  rdoc_options: []
129
127
  require_paths:
130
128
  - lib
@@ -139,8 +137,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
139
137
  - !ruby/object:Gem::Version
140
138
  version: '0'
141
139
  requirements: []
142
- rubygems_version: 3.5.9
143
- signing_key:
140
+ rubygems_version: 4.0.16
144
141
  specification_version: 4
145
142
  summary: Dashboard framework
146
143
  test_files: []