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 +4 -4
- data/.github/workflows/ci.yml +4 -4
- data/README.md +10 -0
- data/lib/collector.rb +137 -14
- data/lib/version.rb +1 -1
- data/spec/collector_spec.rb +446 -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: d1b44cea534d5b5317f70d02d39b43cd2d072b3eca3446243f0b98bba7569823
|
|
4
|
+
data.tar.gz: d70dee83fae380054f04250252e9956e29630b7f05a9eb49f4f14e536891ee82
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 264dc148bf21c0dbce7173c42f412c5d55d54c27ff26124ac692485c4d7538e66dafd920adc31bb79a595b8069bcd635f07513bc7736ce81205038d5020b0e14
|
|
7
|
+
data.tar.gz: 7640bd50fab1e0a0f43641462657482ba996bff8e0185e6d76f6537a6c2fafdcfe5467201cbd8f45fe5508bc396ddcb5b5bef61ed525c82e80a39236f61237e2
|
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:
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
data/spec/collector_spec.rb
CHANGED
|
@@ -1,63 +1,470 @@
|
|
|
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
|
|
14
|
+
|
|
15
|
+
class LatchSource
|
|
16
|
+
attr_reader :update_interval
|
|
7
17
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
def block_next_fetch!
|
|
34
|
+
@lock.synchronize { @block_next = true }
|
|
35
|
+
end
|
|
20
36
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
def wait_until_started
|
|
38
|
+
@started.pop
|
|
39
|
+
end
|
|
24
40
|
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
def release!
|
|
42
|
+
@release << true
|
|
27
43
|
end
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
45
|
+
def fetch_count
|
|
46
|
+
@lock.synchronize { @fetch_values.size }
|
|
47
|
+
end
|
|
32
48
|
|
|
33
|
-
|
|
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)
|
|
51
130
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 "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.
|
|
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:
|
|
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: []
|