dboard 3.0.1 → 3.2.0

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: 2ca84c9c7461e1f0aefa5463e6796774e3fcb41d909430f0447bf9c240609753
4
+ data.tar.gz: e0f59d7466411b2dcfff3799f521271950dfab15e1322b5d15aaaca66b621ea7
5
5
  SHA512:
6
- metadata.gz: 166b2aa677c0234e1de2574df03f17072adab4e09c7bd68e26a8be03640f1b4ac50ccd3246657c383cd741611823c3efbd8fdd299a582a17d534a3c40164e5cf
7
- data.tar.gz: aa99aae44e6a0a968150f42438dca61893f45960df655a06722a93d0ff52a2e3daa6abb56ed9f827b8647f6a718b0f5c230cb117c83e696fd22868852d5402e4
6
+ metadata.gz: a32643a51fbdd0acb92d4b7e3c7f88db9f2749316918867e700e6582ac0956814a0789a6df1934f71017623716b55b69c8510886d3b5156f2be9183b474ddfc5
7
+ data.tar.gz: e222ceed0499f6bac7622f690cc71815d095252dc6158c7db48007e595ba57b8e617bcc7e033030910ae7088071ee555f699ba93ab701753db053c0c01728f13
@@ -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:
@@ -27,3 +27,27 @@ jobs:
27
27
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
28
28
  - name: Run tests
29
29
  run: bundle exec rake
30
+
31
+ release:
32
+ if: github.ref_name == github.event.repository.default_branch
33
+ needs: test
34
+ environment: Rubygems
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v7
38
+ - name: Set up Ruby
39
+ uses: ruby/setup-ruby@v1
40
+ with:
41
+ ruby-version: "3.4"
42
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
43
+ - name: Publish gem
44
+ run: |
45
+ mkdir -p $HOME/.gem
46
+ touch $HOME/.gem/credentials
47
+ chmod 0600 $HOME/.gem/credentials
48
+ printf -- '---\n:rubygems_api_key: %s\n' "$RUBYGEMS_API_KEY" > "$HOME/.gem/credentials"
49
+ sed -i -E "s/(VERSION = \"[0-9.]+)\"/\1.${GITHUB_RUN_NUMBER}\"/" lib/version.rb # Add build number to version
50
+ gem build *.gemspec
51
+ gem push *.gem
52
+ env:
53
+ RUBYGEMS_API_KEY: "${{secrets.RUBYGEMS_API_KEY}}"
data/README.md CHANGED
@@ -21,6 +21,22 @@ 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), and the collector polls it on that cadence.
27
+
28
+ You can also trigger an out-of-band refresh (e.g. from an inbound webhook) with `Dboard::Collector.request_update(:key)`, where `:key` is the key the source was registered under. This is throttled: the first trigger refreshes immediately, and further triggers inside the floor window are coalesced into a single trailing refresh, so a flood of triggers never causes more than one refresh per floor. The poll and manual triggers share one clock, so a manual refresh lets the next poll cycle skip its fetch.
29
+
30
+ The floor defaults to 30 seconds. A source may override it by defining `min_update_interval` (seconds). The effective floor is capped at the source's `update_interval`, so it can never exceed the poll interval.
31
+
32
+ Targeted refreshes:
33
+
34
+ `request_update` also takes an optional argument for a targeted refresh: `Dboard::Collector.request_update(:key, arg)`. The argument is opaque; the framework forwards it verbatim to the source and never inspects it. Omitting it (or passing `nil`) means a full refresh of the whole source.
35
+
36
+ A source opts in by defining `fetch(args = nil)`. It receives `nil` for a full refresh (a scheduled poll or a no-arg `request_update`) and an array of the accumulated arguments for a targeted refresh. Arguments are batched the same way triggers are coalesced: the leading refresh carries the first argument, and any arguments that arrive during the active window are delivered together in the one trailing refresh. A no-arg (full) request queued during a burst wins over pending arguments, since a full refresh already covers them. The poll shares the same clock and floor as targeted refreshes, so both together still fetch at most once per floor.
37
+
38
+ Publishing stays wholesale: the collector always replaces the stored blob for the key. A source doing a targeted refresh must therefore read its current state (e.g. from the cache) and return the full merged blob, not only the refreshed part.
39
+
24
40
  Data flow:
25
41
 
26
42
  +-----------------+ +--------------------+
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
@@ -44,6 +59,7 @@ module Dboard
44
59
 
45
60
  def register_source(key, instance)
46
61
  @sources.merge!({ key => instance })
62
+ @refresh_locks[key] = Mutex.new
47
63
  end
48
64
 
49
65
  def register_after_update_callback(callback)
@@ -54,10 +70,33 @@ module Dboard
54
70
  @error_callback = callback
55
71
  end
56
72
 
57
- # Public because the old tests depend on it
58
- def update_source(source, instance)
73
+ def request_update(key, arg = nil)
74
+ instance = @sources.fetch(key)
75
+ floor = min_interval_for(instance)
76
+ entry = arg.nil? ? FULL : arg
77
+ leading = nil
78
+ delay = @mutex.synchronize {
79
+ now = monotonic_now
80
+ (@pending[key] ||= []) << entry
81
+ case decide_request(@active[key], @last_update_at[key], floor, now)
82
+ when :coalesce
83
+ return
84
+ when :refresh_now
85
+ @active[key] = true
86
+ leading = @pending[key]
87
+ @pending[key] = []
88
+ 0
89
+ when :schedule
90
+ @active[key] = true
91
+ floor - (now - @last_update_at[key])
92
+ end
93
+ }
94
+ spawn { run_worker(key, instance, floor, delay, leading) }
95
+ end
96
+
97
+ def update_source(source, instance, batch = nil)
59
98
  begin
60
- data = instance.fetch
99
+ data = fetch_source(instance, batch)
61
100
  publish_data(source, data)
62
101
  ensure
63
102
  @after_update_callback.call
@@ -70,20 +109,111 @@ module Dboard
70
109
 
71
110
  private
72
111
 
112
+ def fetch_source(instance, batch)
113
+ if batch.nil? || batch.any? { |entry| entry.equal?(FULL) }
114
+ instance.fetch
115
+ else
116
+ instance.fetch(batch)
117
+ end
118
+ end
119
+
120
+ def decide_request(active, last_update_at, floor, now)
121
+ return :coalesce if active
122
+ return :refresh_now if last_update_at.nil? || (now - last_update_at) >= floor
123
+
124
+ :schedule
125
+ end
126
+
127
+ def run_worker(key, instance, floor, delay, leading)
128
+ cleared = false
129
+ loop do
130
+ sleep delay if delay && delay > 0
131
+ fired = perform_refresh(key, instance, floor, leading: leading, drain: leading.nil?)
132
+ delay = @mutex.synchronize {
133
+ remaining = floor - (monotonic_now - @last_update_at[key])
134
+ remaining = 0 if remaining < 0
135
+ pending = @pending[key]
136
+ if fired
137
+ leading = nil
138
+ if pending && !pending.empty?
139
+ remaining # trailing: drain the burst that arrived while we were active
140
+ else
141
+ @active[key] = false
142
+ cleared = true
143
+ nil
144
+ end
145
+ elsif leading || (pending && !pending.empty?)
146
+ remaining # throttled; retry the retained leading snapshot or the queued args after the floor
147
+ else
148
+ @active[key] = false
149
+ cleared = true
150
+ nil
151
+ end
152
+ }
153
+ break if cleared
154
+ end
155
+ rescue Exception => ex
156
+ puts "Something failed in the update worker for #{key}: #{ex.message}"
157
+ puts ex.backtrace
158
+ ensure
159
+ @mutex.synchronize { @active[key] = false } unless cleared
160
+ end
161
+
162
+ def perform_refresh(source, instance, min_interval, leading: nil, drain: false)
163
+ @refresh_locks.fetch(source).synchronize {
164
+ batch = @mutex.synchronize {
165
+ now = monotonic_now
166
+ last = @last_update_at[source]
167
+ next SKIP unless last.nil? || (now - last) >= min_interval
168
+
169
+ if leading
170
+ resolved = leading
171
+ elsif drain
172
+ queued = (@pending[source] ||= [])
173
+ next SKIP if queued.empty?
174
+
175
+ resolved = queued
176
+ @pending[source] = []
177
+ else
178
+ resolved = nil
179
+ end
180
+ @last_update_at[source] = now
181
+ resolved
182
+ }
183
+ return false if batch.equal?(SKIP)
184
+
185
+ update_source(source, instance, batch)
186
+ true
187
+ }
188
+ end
189
+
73
190
  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})."
191
+ puts "#{source} polling..."
192
+ fired = perform_refresh(source, instance, instance.update_interval)
193
+ time_until_next_update = @mutex.synchronize {
194
+ remaining = instance.update_interval - (monotonic_now - @last_update_at[source])
195
+ remaining < 0 ? 0 : remaining
196
+ }
197
+ puts "#{source} #{fired ? "updated" : "still fresh"}, will poll again in #{time_until_next_update} seconds (interval: #{instance.update_interval})."
81
198
  sleep time_until_next_update
82
199
  rescue Exception => ex
83
200
  puts "Something failed outside the update_source method. #{ex.message}"
84
201
  puts ex.backtrace
85
202
  end
86
203
 
204
+ def min_interval_for(instance)
205
+ override = instance.respond_to?(:min_update_interval) ? instance.min_update_interval : DEFAULT_MIN_INTERVAL
206
+ [ override, instance.update_interval ].min
207
+ end
208
+
209
+ def spawn(&block)
210
+ Thread.new(&block)
211
+ end
212
+
213
+ def monotonic_now
214
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
215
+ end
216
+
87
217
  def publish_data(source, data)
88
218
  Publisher.publish(source, data)
89
219
  end
data/lib/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dboard
2
- VERSION = "3.0.1"
2
+ VERSION = "3.2.0"
3
3
  end
@@ -1,63 +1,517 @@
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
7
14
 
8
- let(:new_relic) { double(:new_relic) }
9
- let(:callback) { double(:callback) }
15
+ class LatchSource
16
+ attr_reader :update_interval
10
17
 
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 })
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 = []
15
27
  end
16
28
 
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
29
+ def set_value(value)
30
+ @lock.synchronize { @value = value }
31
+ end
20
32
 
21
- expect(callback).to receive(:call)
22
- allow(Dboard::Publisher).to receive(:publish)
23
- Dboard::Collector.instance.update_source(:new_relic, new_relic)
33
+ def block_next_fetch!
34
+ @lock.synchronize { @block_next = true }
35
+ end
24
36
 
25
- # since it is a singleton, and this callbacks leaks into the other tests
26
- Dboard::Collector.register_after_update_callback(lambda {})
37
+ def wait_until_started
38
+ @started.pop
27
39
  end
28
40
 
29
- it "can register an error callback" do
30
- error = RuntimeError.new("error")
31
- allow(new_relic).to receive(:fetch).and_raise(error)
41
+ def release!
42
+ @release << true
43
+ end
32
44
 
33
- Dboard::Collector.register_error_callback callback
45
+ def fetch_count
46
+ @lock.synchronize { @fetch_values.size }
47
+ end
48
+
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)
130
+
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)
51
139
 
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)
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 "lets a manual refresh make the next poll cycle skip its fetch" do
249
+ source = double(:source, update_interval: 100)
250
+ allow(source).to receive(:fetch).and_return({ ok: true })
251
+ collector.register_source(:src, source)
252
+ use_fake_clock
253
+ run_workers_inline
254
+
255
+ collector.request_update(:src)
256
+ collector.send(:update_in_thread, :src, source)
257
+
258
+ expect(source).to have_received(:fetch).once
259
+ end
260
+
261
+ it "re-evaluates a scheduled trailing when a poll refreshes during its sleep" do
262
+ source = double(:source, update_interval: 100, min_update_interval: 10)
263
+ clock = FakeClock.new
264
+ fetch_times = []
265
+ allow(source).to receive(:fetch) { fetch_times << clock.now; { ok: true } }
266
+ collector.register_source(:src, source)
267
+ allow(collector).to receive(:monotonic_now) { clock.now }
268
+
269
+ polled = false
270
+ allow(collector).to receive(:sleep) do |seconds|
271
+ if polled
272
+ clock.advance(seconds)
273
+ else
274
+ polled = true
275
+ clock.advance(4)
276
+ collector.instance_variable_get(:@last_update_at)[:src] = clock.now
277
+ end
278
+ end
279
+
280
+ workers = capture_worker_blocks
281
+ 2.times { collector.request_update(:src) }
282
+ workers.each(&:call)
283
+
284
+ expect(fetch_times.length).to eq(2)
285
+ expect(fetch_times.last).to be >= 4 + 10
286
+ end
287
+ end
288
+
289
+ describe "with arguments" do
290
+ it "passes the argument to fetch as a one-element batch and publishes the result" do
291
+ source = double(:source, update_interval: 100)
292
+ allow(source).to receive(:fetch).and_return({ ok: "x" })
293
+ collector.register_source(:src, source)
294
+ use_fake_clock
295
+ run_workers_inline
296
+
297
+ collector.request_update(:src, "x")
298
+
299
+ expect(source).to have_received(:fetch).with([ "x" ])
300
+ expect(Dboard::Publisher).to have_received(:publish).with(:src, { ok: "x" })
301
+ end
302
+
303
+ it "forwards the argument through the class-level request_update" do
304
+ source = double(:source, update_interval: 100)
305
+ allow(source).to receive(:fetch).and_return({ ok: true })
306
+ collector.register_source(:src, source)
307
+ use_fake_clock
308
+ run_workers_inline
309
+
310
+ Dboard::Collector.request_update(:src, "x")
311
+
312
+ expect(source).to have_received(:fetch).with([ "x" ])
313
+ end
314
+
315
+ it "splits a burst of arguments into a leading batch and a trailing batch" do
316
+ source = double(:source, update_interval: 100)
317
+ allow(source).to receive(:fetch).and_return({ ok: true })
318
+ collector.register_source(:src, source)
319
+ use_fake_clock
320
+ workers = capture_worker_blocks
321
+
322
+ collector.request_update(:src, "x")
323
+ collector.request_update(:src, "y")
324
+ collector.request_update(:src, "z")
325
+ workers.each(&:call)
326
+
327
+ expect(source).to have_received(:fetch).with([ "x" ]).once
328
+ expect(source).to have_received(:fetch).with([ "y", "z" ]).once
329
+ end
330
+
331
+ it "refreshes the whole source when no argument is given" do
332
+ source = double(:source, update_interval: 100)
333
+ allow(source).to receive(:fetch).and_return({ ok: true })
334
+ collector.register_source(:src, source)
335
+ use_fake_clock
336
+ run_workers_inline
337
+
338
+ collector.request_update(:src)
339
+
340
+ expect(source).to have_received(:fetch).with(no_args)
341
+ end
342
+
343
+ it "does a full refresh when a no-arg request is coalesced with pending arguments" do
344
+ source = double(:source, update_interval: 100)
345
+ allow(source).to receive(:fetch).and_return({ ok: true })
346
+ collector.register_source(:src, source)
347
+ use_fake_clock
348
+ workers = capture_worker_blocks
349
+
350
+ collector.request_update(:src, "a")
351
+ collector.request_update(:src, "b")
352
+ collector.request_update(:src)
353
+ workers.each(&:call)
354
+
355
+ expect(source).to have_received(:fetch).with([ "a" ]).once
356
+ expect(source).to have_received(:fetch).with(no_args).once
357
+ end
358
+
359
+ it "treats a caller-supplied :full argument as an ordinary partial arg" do
360
+ source = double(:source, update_interval: 100)
361
+ allow(source).to receive(:fetch).and_return({ ok: true })
362
+ collector.register_source(:src, source)
363
+ use_fake_clock
364
+ run_workers_inline
365
+
366
+ collector.request_update(:src, :full)
367
+
368
+ expect(source).to have_received(:fetch).with([ :full ])
369
+ end
370
+
371
+ it "refreshes a source whose fetch takes no arguments" do
372
+ source = Class.new do
373
+ def update_interval
374
+ 100
375
+ end
376
+
377
+ def fetch
378
+ { ok: true }
379
+ end
380
+ end.new
381
+ collector.register_source(:src, source)
382
+ use_fake_clock
383
+ run_workers_inline
384
+
385
+ collector.request_update(:src)
386
+
387
+ expect(Dboard::Publisher).to have_received(:publish).with(:src, { ok: true })
388
+ end
389
+
390
+ it "leaves pending args untouched when a poll wins and delivers them on the next on-demand refresh" do
391
+ source = double(:source, update_interval: 100)
392
+ fetch_args = []
393
+ allow(source).to receive(:fetch) { |*args| fetch_args << args.first; { ok: true } }
394
+ collector.register_source(:src, source)
395
+ use_fake_clock
396
+ workers = capture_worker_blocks
397
+
398
+ collector.request_update(:src, "a")
399
+ collector.request_update(:src, "p")
400
+ expect(collector.instance_variable_get(:@pending)[:src]).to eq([ "p" ])
401
+
402
+ collector.send(:update_in_thread, :src, source)
403
+ expect(collector.instance_variable_get(:@pending)[:src]).to eq([ "p" ])
404
+
405
+ workers.each(&:call)
406
+
407
+ expect(fetch_args).to eq([ nil, [ "a" ], [ "p" ] ])
408
+ end
409
+
410
+ it "retries the retained leading snapshot when a poll refreshes before it runs" do
411
+ source = double(:source, update_interval: 100)
412
+ fetch_args = []
413
+ allow(source).to receive(:fetch) { |*args| fetch_args << args.first; { ok: true } }
414
+ collector.register_source(:src, source)
415
+ clock = use_fake_clock
416
+ workers = capture_worker_blocks
417
+
418
+ collector.request_update(:src, "p")
419
+ collector.instance_variable_get(:@last_update_at)[:src] = clock.now
420
+ workers.each(&:call)
421
+
422
+ expect(fetch_args).to eq([ [ "p" ] ])
423
+ end
424
+
425
+ it "shares one clock between a partial refresh and the poll" do
426
+ source = double(:source, update_interval: 100)
427
+ allow(source).to receive(:fetch).and_return({ ok: true })
428
+ collector.register_source(:src, source)
429
+ use_fake_clock
430
+ run_workers_inline
431
+
432
+ collector.request_update(:src, "p")
433
+ collector.send(:update_in_thread, :src, source)
434
+
435
+ expect(source).to have_received(:fetch).with([ "p" ]).once
436
+ expect(source).to have_received(:fetch).with(no_args).exactly(0).times
437
+ end
438
+ end
439
+
440
+ describe "under real threads" do
441
+ it "coalesces a burst arriving during the leading fetch into one trailing that captures the final state" do
442
+ source = LatchSource.new(update_interval: 0.15)
443
+ source.set_value("v1")
444
+ source.block_next_fetch!
445
+ collector.register_source(:src, source)
446
+ capture_real_threads
447
+
448
+ collector.request_update(:src)
449
+ source.wait_until_started
450
+
451
+ source.set_value("v2")
452
+ 10.times { collector.request_update(:src) }
453
+
454
+ source.release!
455
+ join_threads
456
+
457
+ expect(source.fetch_count).to eq(2)
458
+ expect(source.fetch_values.last).to eq("v2")
459
+ expect(source.fetch_times[1] - source.fetch_times[0]).to be >= 0.15 - 0.02
460
+ end
461
+
462
+ it "throttles a sustained stream of triggers to roughly one refresh per floor" do
463
+ source = LatchSource.new(update_interval: 0.1)
464
+ collector.register_source(:src, source)
465
+ capture_real_threads
466
+
467
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
468
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
469
+ collector.request_update(:src)
470
+ sleep 0.01
471
+ end
472
+ join_threads
473
+
474
+ expect(source.fetch_count).to be_between(2, 8)
475
+ end
476
+ end
477
+
478
+ private
479
+
480
+ def reset_collector!
481
+ collector.sources.clear
482
+ %i[@last_update_at @active @pending @refresh_locks].each { |ivar| collector.instance_variable_get(ivar).clear }
483
+ collector.register_after_update_callback(lambda {})
484
+ collector.register_error_callback(lambda { |_| })
485
+ end
486
+
487
+ def use_fake_clock
488
+ clock = FakeClock.new
489
+ allow(collector).to receive(:monotonic_now) { clock.now }
490
+ allow(collector).to receive(:sleep) { |seconds| clock.advance(seconds) }
491
+ clock
492
+ end
493
+
494
+ def run_workers_inline
495
+ allow(collector).to receive(:spawn) { |&block| block.call }
496
+ end
497
+
498
+ def capture_worker_blocks
499
+ blocks = []
500
+ allow(collector).to receive(:spawn) { |&block| blocks << block }
501
+ blocks
502
+ end
503
+
504
+ def capture_real_threads
505
+ @threads = []
506
+ allow(collector).to receive(:spawn) do |&block|
507
+ thread = Thread.new(&block)
508
+ @threads << thread
509
+ thread
510
+ end
511
+ end
512
+
513
+ def join_threads
514
+ (@threads || []).each { |thread| thread.join(3) }
515
+ end
62
516
  end
63
517
  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.0
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: []