statsd-instrument 3.2.1 → 3.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6790fe7a0ed5dd8f89e8916d331548dce3a2fcfbf049221a074ee17f87742826
4
- data.tar.gz: c74dd8549a0210341f624228ee24c7ba61b8a070ef9b476dac452b7ebbe8af27
3
+ metadata.gz: 62f90038a90bccc54c0fe5ac2fe1a2449e8662183e9b9a0cac209e5ae22a07c7
4
+ data.tar.gz: e620363a10bff05710ce52f6869364e378a0979e2ef53cc86c56f8c7fe491d91
5
5
  SHA512:
6
- metadata.gz: 4c0b2d0f48c8acab57dfe9ea5b05ba689e6c5b6ebe634948060b00ec59070767acc6ed548bfde7a76c75ece200b6ba15ecea5b187b90efb3bb2830a47dd5d566
7
- data.tar.gz: 7f2fe38052d38f2bf90a66837cf380ee2c4e8363222920cf80cd5b4aac1170f1ad737a44cb336dd55116ccf74a6e563d65f4ab88abedf5f24dacb345ccc89bc8
6
+ metadata.gz: 18836250885562c7862db1e515c8f8433e43cf795b886800e8a40fae7e7ebead2a120656e6f7654d5c3b87c9f364d3861441593f42dfda7fa69479146b800842
7
+ data.tar.gz: 6915ee31b5bab72a8d52ef588f6fa90e1df4e821e1aaa96cf7523b1392d49bdc56003de4e3f09e818aee21e2d094e9671b2b2297e7b3f597ca193dafbf03824a
@@ -0,0 +1,22 @@
1
+ name: Contributor License Agreement (CLA)
2
+
3
+ on:
4
+ pull_request_target:
5
+ types: [opened, synchronize]
6
+ issue_comment:
7
+ types: [created]
8
+
9
+ jobs:
10
+ cla:
11
+ runs-on: ubuntu-latest
12
+ if: |
13
+ (github.event.issue.pull_request
14
+ && !github.event.issue.pull_request.merged_at
15
+ && contains(github.event.comment.body, 'signed')
16
+ )
17
+ || (github.event.pull_request && !github.event.pull_request.merged)
18
+ steps:
19
+ - uses: Shopify/shopify-cla-action@v1
20
+ with:
21
+ github-token: ${{ secrets.GITHUB_TOKEN }}
22
+ cla-token: ${{ secrets.CLA_TOKEN }}
data/.rubocop.yml CHANGED
@@ -17,6 +17,12 @@ Naming/FileName:
17
17
  Exclude:
18
18
  - lib/statsd-instrument.rb
19
19
 
20
+ Metrics/ParameterLists:
21
+ Enabled: false
22
+
23
+ Style/WhileUntilModifier:
24
+ Enabled: false
25
+
20
26
  # Enable our own cops on our own repo
21
27
 
22
28
  StatsD/MetricReturnValue:
data/CHANGELOG.md CHANGED
@@ -6,7 +6,15 @@ section below.
6
6
 
7
7
  ### Unreleased changes
8
8
 
9
- _Nothing yet_
9
+ ## Version 3.3.0
10
+
11
+ - UDP Batching now has a max queue size and emitter threads will block if the queue
12
+ reaches the limit. This is to prevent the queue from growing unbounded.
13
+ More generally the UDP batching mode was optimized to improve throughput and to
14
+ flush the queue more eagerly (#309).
15
+ - Added `STATSD_BUFFER_CAPACITY` configuration.
16
+ - Added `STATSD_MAX_PACKET_SIZE` configuration.
17
+ - Require `set` explicitly, to avoid breaking tests for users of this library (#311)
10
18
 
11
19
  ## Version 3.2.1
12
20
 
data/README.md CHANGED
@@ -45,6 +45,14 @@ The following environment variables are supported:
45
45
  - `STATSD_FLUSH_INTERVAL`: (default: `1.0`) The interval in seconds at which
46
46
  events are sent in batch. Only applicable to the UDP configuration. If set
47
47
  to `0.0`, metrics are sent immediately.
48
+ - `STATSD_BUFFER_CAPACITY`: (default: `5000`) The maximum amount of events that
49
+ may be buffered before emitting threads will start to block. Increasing this
50
+ value may help for application generating spikes of events. However if the
51
+ application emit events faster than they can be sent, increasing it won't help.
52
+ - `STATSD_MAX_PACKET_SIZE`: (default: `1472`) The maximum size of UDP packets.
53
+ If your network is properly configured to handle larger packets you may try
54
+ to increase this value for better performance, but most network can't handle
55
+ larger packets.
48
56
 
49
57
  ## StatsD keys
50
58
 
@@ -16,28 +16,47 @@ else
16
16
  end
17
17
 
18
18
  intermediate_results_filename = "#{Dir.tmpdir}/statsd-instrument-benchmarks/#{File.basename($PROGRAM_NAME)}"
19
+ log_filename = "#{Dir.tmpdir}/statsd-instrument-benchmarks/#{File.basename($PROGRAM_NAME)}.log"
19
20
  FileUtils.mkdir_p(File.dirname(intermediate_results_filename))
20
21
 
21
22
  # Set up an UDP listener to which we can send StatsD packets
22
23
  receiver = UDPSocket.new
23
24
  receiver.bind("localhost", 0)
24
25
 
25
- StatsD.singleton_client = StatsD::Instrument::Environment.new(
26
+ log_file = File.open(log_filename, "w+", level: Logger::WARN)
27
+ StatsD.logger = Logger.new(log_file)
28
+
29
+ udp_client = StatsD::Instrument::Environment.new(ENV.to_h.merge(
26
30
  "STATSD_ADDR" => "#{receiver.addr[2]}:#{receiver.addr[1]}",
27
31
  "STATSD_IMPLEMENTATION" => "dogstatsd",
28
32
  "STATSD_ENV" => "production",
29
- ).client
33
+ "STATSD_FLUSH_INTERVAL" => "0",
34
+ )).client
35
+
36
+ batched_udp_client = StatsD::Instrument::Environment.new(ENV.to_h.merge(
37
+ "STATSD_ADDR" => "#{receiver.addr[2]}:#{receiver.addr[1]}",
38
+ "STATSD_IMPLEMENTATION" => "dogstatsd",
39
+ "STATSD_ENV" => "production",
40
+ )).client
41
+
42
+ def send_metrics(client)
43
+ client.increment("StatsD.increment", 10)
44
+ client.measure("StatsD.measure") { 1 + 1 }
45
+ client.gauge("StatsD.gauge", 12.0, tags: ["foo:bar", "quc"])
46
+ client.set("StatsD.set", "value", tags: { foo: "bar", baz: "quc" })
47
+ if client.datagram_builder_class == StatsD::Instrument::DogStatsDDatagramBuilder
48
+ client.event("StasD.event", "12345")
49
+ client.service_check("StatsD.service_check", "ok")
50
+ end
51
+ end
30
52
 
31
53
  report = Benchmark.ips do |bench|
32
- bench.report("StatsD metrics to local UDP receiver (branch: #{branch}, sha: #{revision[0, 7]})") do
33
- StatsD.increment("StatsD.increment", 10)
34
- StatsD.measure("StatsD.measure") { 1 + 1 }
35
- StatsD.gauge("StatsD.gauge", 12.0, tags: ["foo:bar", "quc"])
36
- StatsD.set("StatsD.set", "value", tags: { foo: "bar", baz: "quc" })
37
- if StatsD.singleton_client.datagram_builder_class == StatsD::Instrument::DogStatsDDatagramBuilder
38
- StatsD.event("StasD.event", "12345")
39
- StatsD.service_check("StatsD.service_check", "ok")
40
- end
54
+ bench.report("local UDP sync (branch: #{branch}, sha: #{revision[0, 7]})") do
55
+ send_metrics(udp_client)
56
+ end
57
+
58
+ bench.report("local UDP batched (branch: #{branch}, sha: #{revision[0, 7]})") do
59
+ send_metrics(batched_udp_client)
41
60
  end
42
61
 
43
62
  # Store the results in between runs
@@ -57,3 +76,11 @@ elsif ENV["KEEP_RESULTS"]
57
76
  else
58
77
  File.unlink(intermediate_results_filename)
59
78
  end
79
+
80
+ log_file.close
81
+ logs = File.read(log_filename)
82
+ unless logs.empty?
83
+ puts
84
+ puts "==== logs ===="
85
+ puts logs
86
+ end
@@ -6,11 +6,15 @@ module StatsD
6
6
  # to become the new default in the next major release of this library.
7
7
  class BatchedUDPSink
8
8
  DEFAULT_FLUSH_INTERVAL = 1.0
9
- MAX_PACKET_SIZE = 508
9
+ DEFAULT_THREAD_PRIORITY = 100
10
+ DEFAULT_FLUSH_THRESHOLD = 50
11
+ DEFAULT_BUFFER_CAPACITY = 5_000
12
+ # https://docs.datadoghq.com/developers/dogstatsd/high_throughput/?code-lang=ruby#ensure-proper-packet-sizes
13
+ DEFAULT_MAX_PACKET_SIZE = 1472
10
14
 
11
- def self.for_addr(addr, flush_interval: DEFAULT_FLUSH_INTERVAL)
15
+ def self.for_addr(addr, **kwargs)
12
16
  host, port_as_string = addr.split(":", 2)
13
- new(host, Integer(port_as_string), flush_interval: flush_interval)
17
+ new(host, Integer(port_as_string), **kwargs)
14
18
  end
15
19
 
16
20
  attr_reader :host, :port
@@ -21,10 +25,26 @@ module StatsD
21
25
  end
22
26
  end
23
27
 
24
- def initialize(host, port, flush_interval: DEFAULT_FLUSH_INTERVAL)
28
+ def initialize(
29
+ host,
30
+ port,
31
+ flush_interval: DEFAULT_FLUSH_INTERVAL,
32
+ thread_priority: DEFAULT_THREAD_PRIORITY,
33
+ flush_threshold: DEFAULT_FLUSH_THRESHOLD,
34
+ buffer_capacity: DEFAULT_BUFFER_CAPACITY,
35
+ max_packet_size: DEFAULT_MAX_PACKET_SIZE
36
+ )
25
37
  @host = host
26
38
  @port = port
27
- @dispatcher = Dispatcher.new(host, port, flush_interval)
39
+ @dispatcher = Dispatcher.new(
40
+ host,
41
+ port,
42
+ flush_interval,
43
+ flush_threshold,
44
+ buffer_capacity,
45
+ thread_priority,
46
+ max_packet_size,
47
+ )
28
48
  ObjectSpace.define_finalizer(self, self.class.finalize(@dispatcher))
29
49
  end
30
50
 
@@ -37,6 +57,10 @@ module StatsD
37
57
  self
38
58
  end
39
59
 
60
+ def shutdown(*args)
61
+ @dispatcher.shutdown(*args)
62
+ end
63
+
40
64
  class Dispatcher
41
65
  BUFFER_CLASS = if !::Object.const_defined?(:RUBY_ENGINE) || RUBY_ENGINE == "ruby"
42
66
  ::Array
@@ -50,33 +74,54 @@ module StatsD
50
74
  Concurrent::Array
51
75
  end
52
76
 
53
- def initialize(host, port, flush_interval)
77
+ def initialize(host, port, flush_interval, flush_threshold, buffer_capacity, thread_priority, max_packet_size)
54
78
  @host = host
55
79
  @port = port
56
80
  @interrupted = false
57
81
  @flush_interval = flush_interval
82
+ @flush_threshold = flush_threshold
83
+ @buffer_capacity = buffer_capacity
84
+ @thread_priority = thread_priority
85
+ @max_packet_size = max_packet_size
58
86
  @buffer = BUFFER_CLASS.new
59
87
  @dispatcher_thread = Thread.new { dispatch }
88
+ @pid = Process.pid
89
+ @monitor = Monitor.new
90
+ @condition = @monitor.new_cond
60
91
  end
61
92
 
62
93
  def <<(datagram)
63
- unless @dispatcher_thread&.alive?
64
- # If the dispatcher thread is dead, we assume it is because
65
- # the process was forked. So to avoid ending datagrams twice
66
- # we clear the buffer.
67
- # However if the main the main thread is dead, we won't be able
68
- # to spawn a new thread, so we fallback to sending our datagram directly.
69
- if Thread.main.alive?
70
- @buffer.clear
71
- @dispatcher_thread = Thread.new { dispatch }
72
- else
73
- @buffer << datagram
74
- flush
75
- return self
94
+ if thread_healthcheck
95
+ @buffer << datagram
96
+
97
+ # To avoid sending too many signals when the thread is already flushing
98
+ # We only signal when the queue size is a multiple of `flush_threshold`
99
+ if @buffer.size % @flush_threshold == 0
100
+ wakeup_thread
101
+ end
102
+
103
+ # A SizedQueue would be perfect, except that it doesn't have a timeout
104
+ # Ref: https://bugs.ruby-lang.org/issues/18774
105
+ if @buffer.size >= @buffer_capacity
106
+ StatsD.logger.warn do
107
+ "[#{self.class.name}] Max buffer size reached (#{@buffer_capacity}), pausing " \
108
+ "thread##{Thread.current.object_id}"
109
+ end
110
+ before = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
111
+ @monitor.synchronize do
112
+ while @buffer.size >= @buffer_capacity && @dispatcher_thread.alive?
113
+ @condition.wait(0.01)
114
+ end
115
+ end
116
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - before
117
+ StatsD.logger.warn do
118
+ "[#{self.class.name}] thread##{Thread.current.object_id} resumed after #{duration.round(2)}ms"
119
+ end
76
120
  end
121
+ else
122
+ flush
77
123
  end
78
124
 
79
- @buffer << datagram
80
125
  self
81
126
  end
82
127
 
@@ -91,6 +136,24 @@ module StatsD
91
136
 
92
137
  private
93
138
 
139
+ def wakeup_thread
140
+ begin
141
+ @monitor.synchronize do
142
+ @condition.signal
143
+ end
144
+ rescue ThreadError
145
+ # Can't synchronize from trap context
146
+ Thread.new { wakeup_thread }.join
147
+ return
148
+ end
149
+
150
+ begin
151
+ @dispatcher_thread&.run
152
+ rescue ThreadError # Somehow the thread just died
153
+ thread_healthcheck
154
+ end
155
+ end
156
+
94
157
  NEWLINE = "\n".b.freeze
95
158
  def flush
96
159
  return if @buffer.empty?
@@ -98,24 +161,54 @@ module StatsD
98
161
  datagrams = @buffer.shift(@buffer.size)
99
162
 
100
163
  until datagrams.empty?
101
- packet = String.new(datagrams.pop, encoding: Encoding::BINARY, capacity: MAX_PACKET_SIZE)
164
+ packet = String.new(datagrams.shift, encoding: Encoding::BINARY, capacity: @max_packet_size)
102
165
 
103
- until datagrams.empty? || packet.bytesize + datagrams.first.bytesize + 1 > MAX_PACKET_SIZE
166
+ until datagrams.empty? || packet.bytesize + datagrams.first.bytesize + 1 > @max_packet_size
104
167
  packet << NEWLINE << datagrams.shift
105
168
  end
106
-
107
169
  send_packet(packet)
108
170
  end
109
171
  end
110
172
 
173
+ def thread_healthcheck
174
+ # TODO: We have a race condition on JRuby / Truffle here. It could cause multiple
175
+ # dispatcher threads to be spawned, which would cause problems.
176
+ # However we can't simply lock here as we might be called from a trap context.
177
+ unless @dispatcher_thread&.alive?
178
+ # If the main the main thread is dead the VM is shutting down so we won't be able
179
+ # to spawn a new thread, so we fallback to sending our datagram directly.
180
+ return false unless Thread.main.alive?
181
+
182
+ # If the dispatcher thread is dead, it might be because the process was forked.
183
+ # So to avoid sending datagrams twice we clear the buffer.
184
+ if @pid != Process.pid
185
+ StatsD.logger.info { "[#{self.class.name}] Restarting the dispatcher thread after fork" }
186
+ @pid = Process.pid
187
+ @buffer.clear
188
+ else
189
+ StatsD.logger.info { "[#{self.class.name}] Restarting the dispatcher thread" }
190
+ end
191
+ @dispatcher_thread = Thread.new { dispatch }.tap { |t| t.priority = @thread_priority }
192
+ end
193
+ true
194
+ end
195
+
111
196
  def dispatch
112
197
  until @interrupted
113
198
  begin
114
199
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
200
  flush
201
+
202
+ # Other threads may have queued more events while we were doing IO
203
+ flush while @buffer.size > @flush_threshold
204
+
116
205
  next_sleep_duration = @flush_interval - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start)
117
206
 
118
- sleep(next_sleep_duration) if next_sleep_duration > 0
207
+ if next_sleep_duration > 0
208
+ @monitor.synchronize do
209
+ @condition.wait(next_sleep_duration)
210
+ end
211
+ end
119
212
  rescue => error
120
213
  report_error(error)
121
214
  end
@@ -133,19 +226,21 @@ module StatsD
133
226
 
134
227
  def send_packet(packet)
135
228
  retried = false
136
- socket.send(packet, 0)
137
- rescue SocketError, IOError, SystemCallError => error
138
- StatsD.logger.debug do
139
- "[#{self.class.name}] Resetting connection because of #{error.class}: #{error.message}"
140
- end
141
- invalidate_socket
142
- if retried
143
- StatsD.logger.warning do
144
- "[#{self.class.name}] Events were dropped because of #{error.class}: #{error.message}"
229
+ begin
230
+ socket.send(packet, 0)
231
+ rescue SocketError, IOError, SystemCallError => error
232
+ StatsD.logger.debug do
233
+ "[#{self.class.name}] Resetting connection because of #{error.class}: #{error.message}"
234
+ end
235
+ invalidate_socket
236
+ if retried
237
+ StatsD.logger.warn do
238
+ "[#{self.class.name}] Events were dropped because of #{error.class}: #{error.message}"
239
+ end
240
+ else
241
+ retried = true
242
+ retry
145
243
  end
146
- else
147
- retried = true
148
- retry
149
244
  end
150
245
  end
151
246
 
@@ -79,7 +79,15 @@ module StatsD
79
79
  end
80
80
 
81
81
  def statsd_flush_interval
82
- Float(env.fetch("STATSD_FLUSH_INTERVAL", 1.0))
82
+ Float(env.fetch("STATSD_FLUSH_INTERVAL", StatsD::Instrument::BatchedUDPSink::DEFAULT_FLUSH_INTERVAL))
83
+ end
84
+
85
+ def statsd_buffer_capacity
86
+ Float(env.fetch("STATSD_BUFFER_CAPACITY", StatsD::Instrument::BatchedUDPSink::DEFAULT_BUFFER_CAPACITY))
87
+ end
88
+
89
+ def statsd_max_packet_size
90
+ Float(env.fetch("STATSD_MAX_PACKET_SIZE", StatsD::Instrument::BatchedUDPSink::DEFAULT_MAX_PACKET_SIZE))
83
91
  end
84
92
 
85
93
  def client
@@ -90,7 +98,12 @@ module StatsD
90
98
  case environment
91
99
  when "production", "staging"
92
100
  if statsd_flush_interval > 0.0
93
- StatsD::Instrument::BatchedUDPSink.for_addr(statsd_addr, flush_interval: statsd_flush_interval)
101
+ StatsD::Instrument::BatchedUDPSink.for_addr(
102
+ statsd_addr,
103
+ flush_interval: statsd_flush_interval,
104
+ buffer_capacity: statsd_buffer_capacity,
105
+ max_packet_size: statsd_max_packet_size,
106
+ )
94
107
  else
95
108
  StatsD::Instrument::UDPSink.for_addr(statsd_addr)
96
109
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module StatsD
4
6
  module Instrument
5
7
  # @private
@@ -26,22 +26,27 @@ module StatsD
26
26
  def <<(datagram)
27
27
  with_socket { |socket| socket.send(datagram, 0) }
28
28
  self
29
- rescue ThreadError
30
- # In cases where a TERM or KILL signal has been sent, and we send stats as
31
- # part of a signal handler, locks cannot be acquired, so we do our best
32
- # to try and send the datagram without a lock.
33
- socket.send(datagram, 0) > 0
34
29
  rescue SocketError, IOError, SystemCallError => error
35
30
  StatsD.logger.debug do
36
31
  "[StatsD::Instrument::UDPSink] Resetting connection because of #{error.class}: #{error.message}"
37
32
  end
38
33
  invalidate_socket
34
+ self
39
35
  end
40
36
 
41
37
  private
42
38
 
39
+ def synchronize(&block)
40
+ @mutex.synchronize(&block)
41
+ rescue ThreadError
42
+ # In cases where a TERM or KILL signal has been sent, and we send stats as
43
+ # part of a signal handler, locks cannot be acquired, so we do our best
44
+ # to try and send the datagram without a lock.
45
+ yield
46
+ end
47
+
43
48
  def with_socket
44
- @mutex.synchronize { yield(socket) }
49
+ synchronize { yield(socket) }
45
50
  end
46
51
 
47
52
  def socket
@@ -53,7 +58,7 @@ module StatsD
53
58
  end
54
59
 
55
60
  def invalidate_socket
56
- @mutex.synchronize do
61
+ synchronize do
57
62
  @socket = nil
58
63
  end
59
64
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StatsD
4
4
  module Instrument
5
- VERSION = "3.2.1"
5
+ VERSION = "3.3.0"
6
6
  end
7
7
  end
@@ -32,17 +32,19 @@ module UDPSinkTests
32
32
  refute(udp_sink.sample?(0.5))
33
33
  end
34
34
 
35
- def test_parallelism
35
+ def test_concurrency
36
36
  udp_sink = build_sink(@host, @port)
37
- 50.times.map { |i| Thread.new { udp_sink << "foo:#{i}|c" << "bar:#{i}|c" } }
37
+ threads = 50.times.map { |i| Thread.new { udp_sink << "foo:#{i}|c" << "bar:#{i}|c" } }
38
38
  datagrams = []
39
39
 
40
40
  while @receiver.wait_readable(2)
41
- datagram, _source = @receiver.recvfrom(4000)
41
+ datagram, _source = @receiver.recvfrom(4096)
42
42
  datagrams += datagram.split("\n")
43
43
  end
44
44
 
45
45
  assert_equal(100, datagrams.size)
46
+ ensure
47
+ threads&.each(&:kill)
46
48
  end
47
49
 
48
50
  class SimpleFormatter < ::Logger::Formatter
@@ -53,31 +55,39 @@ module UDPSinkTests
53
55
 
54
56
  def test_sends_datagram_in_signal_handler
55
57
  udp_sink = build_sink(@host, @port)
56
- Signal.trap("USR1") { udp_sink << "exiting:1|c" }
57
-
58
- pid = fork do
59
- sleep(5)
58
+ Signal.trap("USR1") do
59
+ udp_sink << "exiting:1|c"
60
+ udp_sink << "exiting:1|d"
60
61
  end
61
62
 
63
+ Process.kill("USR1", Process.pid)
64
+ assert_equal(["exiting:1|c", "exiting:1|d"], read_datagrams(2))
65
+ ensure
62
66
  Signal.trap("USR1", "DEFAULT")
63
-
64
- Process.kill("USR1", pid)
65
- @receiver.wait_readable(1)
66
- assert_equal("exiting:1|c", @receiver.recvfrom_nonblock(100).first)
67
- Process.kill("KILL", pid)
68
- rescue NotImplementedError
69
- pass("Fork is not implemented on #{RUBY_PLATFORM}")
70
67
  end
71
68
 
72
69
  def test_sends_datagram_before_exit
73
70
  udp_sink = build_sink(@host, @port)
74
- fork do
71
+ pid = fork do
75
72
  udp_sink << "exiting:1|c"
76
- Process.exit(0)
73
+ udp_sink << "exiting:1|d"
77
74
  end
75
+ Process.wait(pid)
76
+ assert_equal(["exiting:1|c", "exiting:1|d"], read_datagrams(2))
77
+ rescue NotImplementedError
78
+ pass("Fork is not implemented on #{RUBY_PLATFORM}")
79
+ end
78
80
 
79
- @receiver.wait_readable(1)
80
- assert_equal("exiting:1|c", @receiver.recvfrom_nonblock(100).first)
81
+ def test_sends_datagram_in_at_exit_callback
82
+ udp_sink = build_sink(@host, @port)
83
+ pid = fork do
84
+ at_exit do
85
+ udp_sink << "exiting:1|c"
86
+ udp_sink << "exiting:1|d"
87
+ end
88
+ end
89
+ Process.wait(pid)
90
+ assert_equal(["exiting:1|c", "exiting:1|d"], read_datagrams(2))
81
91
  rescue NotImplementedError
82
92
  pass("Fork is not implemented on #{RUBY_PLATFORM}")
83
93
  end
@@ -86,11 +96,11 @@ module UDPSinkTests
86
96
  udp_sink = build_sink(@host, @port)
87
97
  fork do
88
98
  udp_sink << "exiting:1|c"
99
+ udp_sink << "exiting:1|d"
89
100
  Process.kill("TERM", Process.pid)
90
101
  end
91
102
 
92
- @receiver.wait_readable(1)
93
- assert_equal("exiting:1|c", @receiver.recvfrom_nonblock(100).first)
103
+ assert_equal(["exiting:1|c", "exiting:1|d"], read_datagrams(2))
94
104
  rescue NotImplementedError
95
105
  pass("Fork is not implemented on #{RUBY_PLATFORM}")
96
106
  end
@@ -101,6 +111,18 @@ module UDPSinkTests
101
111
  @sink_class.new(host, port)
102
112
  end
103
113
 
114
+ def read_datagrams(count, timeout: 2)
115
+ datagrams = []
116
+ count.times do
117
+ if @receiver.wait_readable(timeout)
118
+ datagrams += @receiver.recvfrom_nonblock(1000).first.lines(chomp: true)
119
+ else
120
+ break
121
+ end
122
+ end
123
+ datagrams
124
+ end
125
+
104
126
  class UDPSinkTest < Minitest::Test
105
127
  include UDPSinkTests
106
128
 
@@ -145,7 +167,7 @@ module UDPSinkTests
145
167
  end
146
168
  end
147
169
 
148
- class BatchedUDPSinkTest < Minitest::Test
170
+ module BatchedUDPSinkTests
149
171
  include UDPSinkTests
150
172
 
151
173
  def setup
@@ -154,28 +176,63 @@ module UDPSinkTests
154
176
  @host = @receiver.addr[2]
155
177
  @port = @receiver.addr[1]
156
178
  @sink_class = StatsD::Instrument::BatchedUDPSink
179
+ @sinks = []
157
180
  end
158
181
 
159
182
  def teardown
160
183
  @receiver.close
184
+ @sinks.each(&:shutdown)
161
185
  end
162
186
 
163
- def test_parallelism_buffering
187
+ private
188
+
189
+ def build_sink(host = @host, port = @port)
190
+ sink = @sink_class.new(host, port, flush_threshold: default_flush_threshold, buffer_capacity: 50)
191
+ @sinks << sink
192
+ sink
193
+ end
194
+
195
+ def default_flush_threshold
196
+ StatsD::Instrument::BatchedUDPSink::DEFAULT_FLUSH_THRESHOLD
197
+ end
198
+ end
199
+
200
+ class BatchedUDPSinkTest < Minitest::Test
201
+ include BatchedUDPSinkTests
202
+
203
+ def test_concurrency_buffering
164
204
  udp_sink = build_sink(@host, @port)
165
- 50.times.map do |i|
205
+ threads = 50.times.map do |i|
166
206
  Thread.new do
167
207
  udp_sink << "foo:#{i}|c" << "bar:#{i}|c" << "baz:#{i}|c" << "plop:#{i}|c"
168
208
  end
169
209
  end
210
+ threads.each(&:join)
211
+ assert_equal(200, read_datagrams(10, timeout: 2).size)
212
+ ensure
213
+ threads&.each(&:kill)
214
+ end
215
+ end
170
216
 
171
- datagrams = []
217
+ class LowThresholdBatchedUDPSinkTest < Minitest::Test
218
+ include BatchedUDPSinkTests
172
219
 
173
- while @receiver.wait_readable(2)
174
- datagram, _source = @receiver.recvfrom(1000)
175
- datagrams += datagram.split("\n")
176
- end
220
+ def test_sends_datagram_when_termed
221
+ # When the main thread exit, the dispatcher thread is aborted
222
+ # and there's no exceptions or anything like that to rescue.
223
+ # So if the dispatcher thread poped some events from the buffer
224
+ # but didn't sent them yet, then they may be lost.
225
+ skip("Unfortunately this can't be guaranteed")
226
+ end
227
+ alias_method :test_sends_datagram_in_at_exit_callback, :test_sends_datagram_when_termed
228
+ alias_method :test_sends_datagram_before_exit, :test_sends_datagram_when_termed
229
+
230
+ private
177
231
 
178
- assert_equal(200, datagrams.size)
232
+ # We run the same tests again, but this time we wake up the dispatcher
233
+ # thread on every call to make sure trap context is properly handled
234
+ def default_flush_threshold
235
+ 1
179
236
  end
180
237
  end
181
238
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statsd-instrument
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.1
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jesse Storimer
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2022-07-07 00:00:00.000000000 Z
13
+ date: 2022-07-26 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: concurrent-ruby
@@ -35,8 +35,8 @@ extensions: []
35
35
  extra_rdoc_files: []
36
36
  files:
37
37
  - ".github/CODEOWNERS"
38
- - ".github/probots.yml"
39
38
  - ".github/workflows/benchmark.yml"
39
+ - ".github/workflows/cla.yml"
40
40
  - ".github/workflows/lint.yml"
41
41
  - ".github/workflows/tests.yml"
42
42
  - ".gitignore"
data/.github/probots.yml DELETED
@@ -1,2 +0,0 @@
1
- enabled:
2
- - cla