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 +4 -4
- data/.github/workflows/ci.yml +28 -4
- data/README.md +16 -0
- data/lib/collector.rb +140 -10
- data/lib/version.rb +1 -1
- data/spec/collector_spec.rb +493 -39
- metadata +3 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ca84c9c7461e1f0aefa5463e6796774e3fcb41d909430f0447bf9c240609753
|
|
4
|
+
data.tar.gz: e0f59d7466411b2dcfff3799f521271950dfab15e1322b5d15aaaca66b621ea7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a32643a51fbdd0acb92d4b7e3c7f88db9f2749316918867e700e6582ac0956814a0789a6df1934f71017623716b55b69c8510886d3b5156f2be9183b474ddfc5
|
|
7
|
+
data.tar.gz: e222ceed0499f6bac7622f690cc71815d095252dc6158c7db48007e595ba57b8e617bcc7e033030910ae7088071ee555f699ba93ab701753db053c0c01728f13
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -14,12 +14,12 @@ jobs:
|
|
|
14
14
|
strategy:
|
|
15
15
|
matrix:
|
|
16
16
|
ruby-version:
|
|
17
|
-
- 3.
|
|
18
|
-
- "3.
|
|
19
|
-
- 2
|
|
17
|
+
- "3.4"
|
|
18
|
+
- "3.3"
|
|
19
|
+
- "3.2"
|
|
20
20
|
|
|
21
21
|
steps:
|
|
22
|
-
- uses: actions/checkout@
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
puts "#{source}
|
|
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
data/spec/collector_spec.rb
CHANGED
|
@@ -1,63 +1,517 @@
|
|
|
1
1
|
require File.expand_path(File.join(File.dirname(__FILE__), "spec_helper"))
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
15
|
+
class LatchSource
|
|
16
|
+
attr_reader :update_interval
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
29
|
+
def set_value(value)
|
|
30
|
+
@lock.synchronize { @value = value }
|
|
31
|
+
end
|
|
20
32
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
def block_next_fetch!
|
|
34
|
+
@lock.synchronize { @block_next = true }
|
|
35
|
+
end
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
def wait_until_started
|
|
38
|
+
@started.pop
|
|
27
39
|
end
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
def release!
|
|
42
|
+
@release << true
|
|
43
|
+
end
|
|
32
44
|
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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:
|
|
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:
|
|
143
|
-
signing_key:
|
|
140
|
+
rubygems_version: 4.0.16
|
|
144
141
|
specification_version: 4
|
|
145
142
|
summary: Dashboard framework
|
|
146
143
|
test_files: []
|