brow 0.1.0 → 0.4.1

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: 8f08f2a47a1034332966c9ef42acdecc5629052fd8255857a61cdaa8483be502
4
- data.tar.gz: f42dac7bdfe22a48b7e7d5e0ef5ecb91407505f9ef0fb91393a263b194a8845c
3
+ metadata.gz: 1e823ebd67a0230133814bbdd5adc0463f2a6e2fe730b54ee2b55ecb925d347f
4
+ data.tar.gz: 5168d92d78eae8958098cacee8b0c4a25b140a52a6a3b08f7bd1e8e9388056ba
5
5
  SHA512:
6
- metadata.gz: abb687ce5fe388f7c87826752255ff1227dd275365ed599d3e78519f1402a2bf4a6010e9f6da307f8f7265be8a69e6f26baea43e30b7bd9adc2cd9f3253ca447
7
- data.tar.gz: 57722a879c3fa49461ffbf8334a5674333fce12bb30dc2037b7dcb09f65d3e2335e57d4abb47359a7ed2b032154e1e9494e92c3a5f1a9e00a99cd0627feb1488
6
+ metadata.gz: aeaa195ffd0be0945d088219d0dd60451d5ef445bee52f35c3f6d379dc2fbbc7ec5784fe0cf0cfd32a771235e87ae607b85173ef264cb4107311026c613befae
7
+ data.tar.gz: '09f9ae0b7030d6daab14014d31e705d2df68d099ea5fe66a217dcde23fba3b9fededab0f25663651f354174efd2b0e23aa01cbc8faa0e0c6394ab1805ef1567a'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
- ## [Unreleased]
1
+ # Changelog
2
2
 
3
- ## [0.1.0] - 2021-10-14
3
+ All notable changes to this project will be documented in this file.
4
4
 
5
- - Initial release
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.4.1] - 2021-11-05
9
+
10
+ - Move progname to logg message so it always appears (5ad1596df79f14d06d7b6dc508b1d5a9282aeebb and 531a999067d398daadc4b13d501e7044bcd3b709)
11
+ - Fix echo server in examples (53eea5af37a888666e8740eac196832b3f67dfc7).
12
+
13
+ ## [0.4.0] - 2021-11-04
14
+
15
+ ### Added
16
+
17
+ - Allow configuring most options from ENV variables by default (fb7819b0237a81e573677f3050446a4f41e8fb47).
18
+ - Extra early return to avoid mutex lock if thread is alive (ac7dcfe54ee83b18e0df5ab3778a077584c843bd).
19
+ - Validation on many of the configuration options (c50b11a2917272a87937f8aa86007816a87c63a2 and 07e2581397f870249a347d4d68e4fce172d33cef).
20
+
21
+ ### Changed
22
+
23
+ - Stop stringifying keys. Just enqueue whatever is passed and let JSON do the rest (2e63d5328e048f0fad9fc41ca0935f97fb5ada2f).
24
+ - A bunch of test stuff to make them faster and less flaky.
25
+
26
+ ## [0.3.0] - 2021-10-29
27
+
28
+ https://github.com/jnunemaker/brow/pull/4
29
+
30
+ ### Fixed
31
+
32
+ - Fixed thread churn. Upon digging in, I realized that the previous code was creating a bunch of threads. Basically one for each batch, which seems far from ideal. I'm surprised it worked that way. This changes it to be one worker thread that just sits there forever in a loop. When a batch is full, it transports it. When shutdown happens, a shutdown message is enqueued and the worker breaks the loop.
33
+ - Moved worker thread management to `Worker` from `Client`.
34
+ - Back off policy is now reset after `Transport#send_batch` completes. Previously it wasn't, which meant the next interval would get to the max and stay there.
35
+
36
+ ### Changed
37
+
38
+ - Switched to stringify data keys instead of symbolize. Old versions of ruby didn't gc symbols so that was a memory leak. Might be fixed now, but strings are fine here so lets roll with them.
39
+ - Removed test mode and test queue. I didn't like this implementation and neither did @bkeepers. We'll come up with something new and better soon like Brow::Clients::Memory.new or something.
40
+
41
+ ## [0.2.0] - 2021-10-25
42
+
43
+ ### Changed
44
+
45
+ - [c25dce](https://github.com/jnunemaker/brow/commit/c25dcedcab2b75cfe28a561e80e537fefae6cc52) `record` is now `push`.
46
+
47
+ ### Fixed
48
+
49
+ - [eceb02](https://github.com/jnunemaker/brow/commit/eceb02f810cc5ace7d7540c957fc1cf924849629) Fixed problems with shutdown (required a flush to get whatever batches were in progress) and forking (caused queue to not get worked off).
50
+
51
+ ### Added
52
+
53
+ - [c7f7e4](https://github.com/jnunemaker/brow/commit/c7f7e42b0d6bfa9fa96bac58fda0ef94f93d223d) `BackoffPolicy` now gets `options` so you can pass those to `Client` and they'll make it all the way through.
54
+
55
+ ## [0.1.0] - 2021-10-20
56
+
57
+ - Initial release. Let's face it I just wanted to squat on the gem name.
data/Gemfile CHANGED
@@ -5,8 +5,10 @@ gemspec
5
5
 
6
6
  gem "rake", "~> 13.0"
7
7
  gem "minitest", "~> 5.0"
8
+ gem "maxitest", "~> 4.1"
8
9
  gem "minitest-heat", "~> 0.0"
9
10
  gem "webmock", "~> 3.10.0"
11
+ gem "climate_control", "~> 0.2.0"
10
12
 
11
13
  group(:guard) do
12
14
  gem "guard", "~> 2.18.0"
data/Guardfile CHANGED
@@ -32,6 +32,7 @@ guard :minitest do
32
32
  watch(%r{^test/(.*)\/?(.*)_test\.rb$})
33
33
  watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}#{m[2]}_test.rb" }
34
34
  watch(%r{^test/test_helper\.rb$}) { 'test' }
35
+ watch(%r{^test/support/fake_server\.rb$}) { 'test' }
35
36
 
36
37
  # with Minitest::Spec
37
38
  # watch(%r{^spec/(.*)_spec\.rb$})
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Brow
2
2
 
3
- A generic background thread worker for shipping events via https to some API backend.
3
+ A generic background thread worker for shipping events via https to some API backend. It'll get events to your API by the sweat of its brow.
4
4
 
5
5
  I've been wanting to build something like this for a while. This might be a terrible start. But its a start.
6
6
 
@@ -36,14 +36,13 @@ client = Brow::Client.new({
36
36
  })
37
37
 
38
38
  50.times do |n|
39
- client.record({
39
+ client.push({
40
40
  number: n,
41
41
  now: Time.now.utc,
42
42
  })
43
43
  end
44
44
 
45
45
  # batch of 50 events sent to api url above as json
46
- client.flush
47
46
  ```
48
47
 
49
48
  ## Development
data/examples/basic.rb CHANGED
@@ -1,14 +1,14 @@
1
1
  require_relative "../lib/brow"
2
+ require_relative "echo_server"
2
3
 
3
4
  client = Brow::Client.new({
4
- url: "https://requestbin.net/r/rna67for",
5
+ url: "http://localhost:#{EchoServer.instance.port}",
6
+ batch_size: 10,
5
7
  })
6
8
 
7
- 50.times do |n|
8
- client.record({
9
- number: n,
9
+ 5.times do |n|
10
+ client.push({
11
+ n: n,
10
12
  now: Time.now.utc,
11
13
  })
12
14
  end
13
-
14
- client.flush
@@ -0,0 +1,51 @@
1
+ # Usage: bundle exec ruby examples/echo_server.rb
2
+ #
3
+ # By default this starts in thread that other example scripts can use.
4
+ #
5
+ # By setting FOREGROUND=1, this will run in the foreground instead of
6
+ # background thread.
7
+ #
8
+ # FOREGROUND=1 bundle exec ruby examples/echo_server.rb
9
+ require "socket"
10
+ require "thread"
11
+ require "logger"
12
+ require "json"
13
+ require "singleton"
14
+ require "webrick"
15
+
16
+ class EchoServer
17
+ include Singleton
18
+
19
+ attr_reader :port, :thread
20
+
21
+ def initialize
22
+ @logger = Logger.new(STDOUT)
23
+ @logger.level = Logger::INFO
24
+ @port = ENV.fetch("PORT", 9999)
25
+ @started = false
26
+
27
+ @server = WEBrick::HTTPServer.new({
28
+ Port: @port,
29
+ StartCallback: -> { @started = true },
30
+ Logger: WEBrick::Log.new(@logger, WEBrick::Log::INFO),
31
+ AccessLog: [
32
+ [@logger, WEBrick::AccessLog::COMMON_LOG_FORMAT],
33
+ ],
34
+ })
35
+
36
+ @server.mount_proc '/' do |request, response|
37
+ @logger.debug JSON.parse(request.body).inspect
38
+ response.header["Content-Type"] = "application/json"
39
+ response.body = "{}"
40
+ end
41
+
42
+ @thread = Thread.new { @server.start }
43
+ Timeout.timeout(10) { :wait until @started }
44
+ end
45
+ end
46
+
47
+ EchoServer.instance
48
+
49
+ if ENV.fetch("FOREGROUND", "0") == "1"
50
+ EchoServer.instance.thread.join
51
+ end
@@ -0,0 +1,20 @@
1
+ require_relative "../lib/brow"
2
+ require_relative "echo_server"
3
+
4
+ client = Brow::Client.new({
5
+ url: "http://localhost:#{EchoServer.instance.port}",
6
+ batch_size: 10,
7
+ })
8
+
9
+ client.push({
10
+ now: Time.now.utc,
11
+ parent: true,
12
+ })
13
+
14
+ pid = fork {
15
+ client.push({
16
+ now: Time.now.utc,
17
+ child: true,
18
+ })
19
+ }
20
+ Process.waitpid pid, 0
@@ -0,0 +1,32 @@
1
+ require_relative "../lib/brow"
2
+
3
+ port = ENV.fetch("PORT") { 9999 }
4
+
5
+ if ENV.fetch("START_SERVER", "1") == "1"
6
+ require_relative "echo_server"
7
+ port = EchoServer.instance.port
8
+ end
9
+
10
+ Brow.logger = Logger.new(STDOUT)
11
+ Brow.logger.level = Logger::INFO
12
+
13
+ client = Brow::Client.new({
14
+ url: "http://localhost:#{port}",
15
+ batch_size: 1_000,
16
+ })
17
+
18
+ running = true
19
+
20
+ trap("INT") {
21
+ puts "Shutting down"
22
+ running = false
23
+ }
24
+
25
+ while running
26
+ rand(10_000).times { client.push("foo" => "bar") }
27
+
28
+ puts "Queue size: #{client.worker.queue.size}"
29
+
30
+ # Pretend to work
31
+ sleep(rand)
32
+ end
@@ -6,7 +6,7 @@ module Brow
6
6
  MIN_TIMEOUT_MS = 100
7
7
 
8
8
  # Private: The default maximum timeout between intervals in milliseconds.
9
- MAX_TIMEOUT_MS = 10000
9
+ MAX_TIMEOUT_MS = 10_000
10
10
 
11
11
  # Private: The value to multiply the current interval with for each
12
12
  # retry attempt.
@@ -16,6 +16,12 @@ module Brow
16
16
  # retry interval.
17
17
  RANDOMIZATION_FACTOR = 0.5
18
18
 
19
+ # Private
20
+ attr_reader :min_timeout_ms, :max_timeout_ms, :multiplier, :randomization_factor
21
+
22
+ # Private
23
+ attr_reader :attempts
24
+
19
25
  # Public: Create new instance of backoff policy.
20
26
  #
21
27
  # options - The Hash of options.
@@ -26,10 +32,30 @@ module Brow
26
32
  # :randomization_factor - The randomization factor to use to create a range
27
33
  # around the retry interval.
28
34
  def initialize(options = {})
29
- @min_timeout_ms = options[:min_timeout_ms] || MIN_TIMEOUT_MS
30
- @max_timeout_ms = options[:max_timeout_ms] || MAX_TIMEOUT_MS
31
- @multiplier = options[:multiplier] || MULTIPLIER
32
- @randomization_factor = options[:randomization_factor] || RANDOMIZATION_FACTOR
35
+ @min_timeout_ms = options.fetch(:min_timeout_ms) {
36
+ ENV.fetch("BROW_BACKOFF_MIN_TIMEOUT_MS", MIN_TIMEOUT_MS).to_i
37
+ }
38
+ @max_timeout_ms = options.fetch(:max_timeout_ms) {
39
+ ENV.fetch("BROW_BACKOFF_MAX_TIMEOUT_MS", MAX_TIMEOUT_MS).to_i
40
+ }
41
+ @multiplier = options.fetch(:multiplier) {
42
+ ENV.fetch("BROW_BACKOFF_MULTIPLIER", MULTIPLIER).to_f
43
+ }
44
+ @randomization_factor = options.fetch(:randomization_factor) {
45
+ ENV.fetch("BROW_BACKOFF_RANDOMIZATION_FACTOR", RANDOMIZATION_FACTOR).to_f
46
+ }
47
+
48
+ unless @min_timeout_ms >= 0
49
+ raise ArgumentError, ":min_timeout_ms must be >= 0 but was #{@min_timeout_ms.inspect}"
50
+ end
51
+
52
+ unless @max_timeout_ms >= 0
53
+ raise ArgumentError, ":max_timeout_ms must be >= 0 but was #{@max_timeout_ms.inspect}"
54
+ end
55
+
56
+ unless @min_timeout_ms <= max_timeout_ms
57
+ raise ArgumentError, ":min_timeout_ms (#{@min_timeout_ms.inspect}) must be <= :max_timeout_ms (#{@max_timeout_ms.inspect})"
58
+ end
33
59
 
34
60
  @attempts = 0
35
61
  end
@@ -44,6 +70,10 @@ module Brow
44
70
  [interval, @max_timeout_ms].min
45
71
  end
46
72
 
73
+ def reset
74
+ @attempts = 0
75
+ end
76
+
47
77
  private
48
78
 
49
79
  def add_jitter(base, randomization_factor)
data/lib/brow/client.rb CHANGED
@@ -1,110 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'thread'
4
3
  require 'time'
5
4
 
6
5
  require_relative 'utils'
7
6
  require_relative 'worker'
8
- require_relative 'test_queue'
9
7
 
10
8
  module Brow
11
9
  class Client
12
- # Private: Default # of items that can be in queue before we start dropping data.
13
- MAX_QUEUE_SIZE = 10_000
14
-
15
10
  # Public: Create a new instance of a client.
16
11
  #
17
12
  # options - The Hash of options.
13
+ # :url - The URL where all batches of data should be transported.
18
14
  # :max_queue_size - The maximum number of calls to be remain queued.
15
+ # :logger - The Logger to use to log useful information about what is
16
+ # going on.
17
+ # :queue - The Queue to use to store data until it can be batched up and
18
+ # transported to the API.
19
+ # :worker - The Worker that will pop items off the queue, batch them up
20
+ # and transport them to the API.
21
+ # :transport - The Transport to use to transport batches to the API.
22
+ # :headers - The Hash of headers to include when transporting batches to
23
+ # the API. These could be used for auth or whatever.
24
+ # :retries - The Integer number of times the transport should retry a call
25
+ # before giving up.
26
+ # :read_timeout - The number of seconds to wait when reading data before
27
+ # giving up.
28
+ # :open_timeout - The number of seconds to wait when opening a connection
29
+ # to the API.
30
+ # :backoff_policy - The BackoffPolicy to use to determine when the next
31
+ # retry should occur when the transport fails to send a
32
+ # batch of data to the API.
33
+ # :min_timeout_ms - The minimum number of milliseconds to wait before
34
+ # retrying a failed call to the API.
35
+ # :max_timeout_ms - The maximum number of milliseconds to wait before
36
+ # retrying a failed call to the API.
37
+ # :multiplier - The value to multily the current interval with for each
38
+ # retry attempt.
39
+ # :randomization_factor - The value to use to create a range of jitter
40
+ # around the retry interval.
41
+ # :batch - The MessageBatch used to batch up several events to be
42
+ # transported in one call to the API.
43
+ # :shutdown_timeout - The number of seconds to wait for the worker thread
44
+ # to join when shutting down.
45
+ # :shutdown_automatically - Should the worker shutdown automatically or
46
+ # manually. If true, shutdown is automatic. If
47
+ # false, you'll need to handle this on your own.
48
+ # :max_size - The maximum number of items a batch can contain before it
49
+ # should be transported to the API. Only used if not :batch
50
+ # is provided.
19
51
  # :on_error - The Proc that handles error calls from the API.
20
52
  def initialize(options = {})
21
53
  options = Brow::Utils.symbolize_keys(options)
22
-
23
- @worker_thread = nil
24
- @worker_mutex = Mutex.new
25
- @test = options[:test]
26
- @max_queue_size = options[:max_queue_size] || MAX_QUEUE_SIZE
27
- @logger = options.fetch(:logger) { Brow.logger }
28
- @queue = options.fetch(:queue) { Queue.new }
29
- @worker = options.fetch(:worker) { Worker.new(@queue, options) }
30
-
31
- at_exit { @worker_thread && @worker_thread[:should_exit] = true }
54
+ @worker = options.fetch(:worker) { Worker.new(options) }
32
55
  end
33
56
 
34
- # Public: Synchronously waits until the worker has flushed the queue.
35
- #
36
- # Use only for scripts which are not long-running, and will
37
- # specifically exit.
38
- def flush
39
- while !@queue.empty? || @worker.requesting?
40
- ensure_worker_running
41
- sleep(0.1)
42
- end
43
- end
57
+ # Private
58
+ attr_reader :worker
44
59
 
45
- # Public: Enqueues the event.
60
+ # Public: Enqueues an event to eventually be transported to backend service.
46
61
  #
47
- # event - The Hash of event data.
62
+ # data - The Hash of data.
48
63
  #
49
- # Returns Boolean of whether the item was added to the queue.
50
- def record(event)
51
- raise ArgumentError, "event must be a Hash" unless event.is_a?(Hash)
52
-
53
- event = Brow::Utils.symbolize_keys(event)
54
- event = Brow::Utils.isoify_dates(event)
55
- enqueue event
56
- end
57
-
58
- # Public: Returns the number of messages in the queue.
59
- def queued_messages
60
- @queue.length
61
- end
62
-
63
- # Public: For test purposes only. If test: true is passed to #initialize
64
- # then all recording of events will go to test queue in memory so they can
65
- # be verified with assertions.
66
- def test_queue
67
- unless @test
68
- raise 'Test queue only available when setting :test to true.'
69
- end
70
-
71
- @test_queue ||= TestQueue.new
72
- end
73
-
74
- private
75
-
76
- # Private: Enqueues the event.
77
- #
78
- # Returns Boolean of whether the item was added to the queue.
79
- def enqueue(action)
80
- if @test
81
- test_queue << action
82
- return true
83
- end
84
-
85
- if @queue.length < @max_queue_size
86
- @queue << action
87
- ensure_worker_running
88
-
89
- true
90
- else
91
- @logger.warn 'Queue is full, dropping events. The :max_queue_size configuration parameter can be increased to prevent this from happening.'
92
- false
93
- end
94
- end
95
-
96
- def ensure_worker_running
97
- return if worker_running?
98
- @worker_mutex.synchronize do
99
- return if worker_running?
100
- @worker_thread = Thread.new do
101
- @worker.run
102
- end
103
- end
104
- end
105
-
106
- def worker_running?
107
- @worker_thread && @worker_thread.alive?
64
+ # Returns Boolean of whether the data was added to the queue.
65
+ def push(data)
66
+ worker.push(data)
108
67
  end
109
68
  end
110
69
  end
@@ -22,12 +22,21 @@ module Brow
22
22
 
23
23
  def_delegators :@messages, :empty?
24
24
  def_delegators :@messages, :length
25
+ def_delegators :@messages, :size
26
+ def_delegators :@messages, :count
25
27
 
26
- attr_reader :uuid, :json_size
28
+ attr_reader :uuid, :json_size, :max_size
27
29
 
28
30
  def initialize(options = {})
29
31
  clear
30
- @max_size = options[:max_size] || MAX_SIZE
32
+ @max_size = options.fetch(:max_size) {
33
+ ENV.fetch("BROW_BATCH_SIZE", MAX_SIZE).to_i
34
+ }
35
+
36
+ unless @max_size > 0
37
+ raise ArgumentError, ":max_size must be > 0 but was #{@max_size.inspect}"
38
+ end
39
+
31
40
  @logger = options.fetch(:logger) { Brow.logger }
32
41
  end
33
42
 
@@ -41,7 +50,7 @@ module Brow
41
50
  message_json_size = message_json.bytesize
42
51
 
43
52
  if message_too_big?(message_json_size)
44
- @logger.error('a message exceeded the maximum allowed size')
53
+ @logger.error { "#{LOG_PREFIX} a message exceeded the maximum allowed size" }
45
54
  else
46
55
  @messages << message
47
56
  @json_size += message_json_size + 1 # One byte for the comma
@@ -3,94 +3,130 @@
3
3
  require 'net/http'
4
4
  require 'net/https'
5
5
  require 'json'
6
+ require 'set'
6
7
 
7
8
  require_relative 'response'
8
9
  require_relative 'backoff_policy'
9
10
 
10
11
  module Brow
11
12
  class Transport
13
+ # Private: Default number of times to retry request.
12
14
  RETRIES = 10
13
- HEADERS = {
14
- "Accept" => "application/json",
15
- "Content-Type" => "application/json",
16
- "User-Agent" => "brow-ruby/#{Brow::VERSION}",
17
- "Client-Language" => "ruby",
18
- "Client-Language-Version" => "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})",
19
- "Client-Platform" => RUBY_PLATFORM,
20
- "Client-Engine" => defined?(RUBY_ENGINE) ? RUBY_ENGINE : "",
21
- "Client-Pid" => Process.pid.to_s,
22
- "Client-Thread" => Thread.current.object_id.to_s,
23
- "Client-Hostname" => Socket.gethostname,
24
- }
25
-
26
- attr_reader :url
15
+
16
+ # Private: Default read timeout on requests.
17
+ READ_TIMEOUT = 8
18
+
19
+ # Private: Default open timeout on requests.
20
+ OPEN_TIMEOUT = 4
21
+
22
+ # Private: Default write timeout on requests.
23
+ WRITE_TIMEOUT = 4
24
+
25
+ # Private: URL schemes that this transport supports.
26
+ VALID_HTTP_SCHEMES = Set["http", "https"].freeze
27
+
28
+ # Private
29
+ attr_reader :url, :headers, :retries, :logger, :backoff_policy, :http
27
30
 
28
31
  def initialize(options = {})
29
- @url = options[:url] || raise(ArgumentError, ":url is required to be present so we know where to send batches")
32
+ @url = options.fetch(:url) {
33
+ ENV.fetch("BROW_URL") {
34
+ raise ArgumentError, ":url is required to be present so we know where to send batches"
35
+ }
36
+ }
30
37
  @uri = URI.parse(@url)
31
38
 
39
+ unless VALID_HTTP_SCHEMES.include?(@uri.scheme)
40
+ raise ArgumentError, ":url was must be http(s) scheme but was #{@uri.scheme.inspect}"
41
+ end
42
+
32
43
  # Default path if people forget a slash.
33
44
  if @uri.path.nil? || @uri.path.empty?
34
45
  @uri.path = "/"
35
46
  end
36
47
 
37
- @headers = HEADERS.merge(options[:headers] || {})
38
- @retries = options[:retries] || RETRIES
48
+ @headers = options[:headers] || {}
49
+ @retries = options.fetch(:retries) {
50
+ ENV.fetch("BROW_RETRIES", RETRIES).to_i
51
+ }
52
+
53
+ unless @retries >= 0
54
+ raise ArgumentError, ":retries must be >= to 0 but was #{@retries.inspect}"
55
+ end
39
56
 
40
57
  @logger = options.fetch(:logger) { Brow.logger }
41
58
  @backoff_policy = options.fetch(:backoff_policy) {
42
- Brow::BackoffPolicy.new
59
+ Brow::BackoffPolicy.new(options)
43
60
  }
44
61
 
45
62
  @http = Net::HTTP.new(@uri.host, @uri.port)
46
63
  @http.use_ssl = @uri.scheme == "https"
47
- @http.read_timeout = options[:read_timeout] || 8
48
- @http.open_timeout = options[:open_timeout] || 4
64
+
65
+ read_timeout = options.fetch(:read_timeout) {
66
+ ENV.fetch("BROW_READ_TIMEOUT", READ_TIMEOUT).to_f
67
+ }
68
+ @http.read_timeout = read_timeout if read_timeout
69
+
70
+ open_timeout = options.fetch(:open_timeout) {
71
+ ENV.fetch("BROW_OPEN_TIMEOUT", OPEN_TIMEOUT).to_f
72
+ }
73
+ @http.open_timeout = open_timeout if open_timeout
74
+
75
+ if RUBY_VERSION >= '2.6.0'
76
+ write_timeout = options.fetch(:write_timeout) {
77
+ ENV.fetch("BROW_WRITE_TIMEOUT", WRITE_TIMEOUT).to_f
78
+ }
79
+ @http.write_timeout = write_timeout if write_timeout
80
+ else
81
+ Kernel.warn("Warning: option :write_timeout requires Ruby version 2.6.0 or later")
82
+ end
49
83
  end
50
84
 
51
85
  # Sends a batch of messages to the API
52
86
  #
53
87
  # @return [Response] API response
54
88
  def send_batch(batch)
55
- @logger.debug("Sending request for #{batch.length} items")
89
+ logger.debug { "#{LOG_PREFIX} Sending request for #{batch.length} items" }
56
90
 
57
- last_response, exception = retry_with_backoff(@retries) do
91
+ last_response, exception = retry_with_backoff(retries) do
58
92
  response = send_request(batch)
59
- status_code = response.code.to_i
60
- should_retry = should_retry_request?(status_code, response.body)
61
- @logger.debug("Response status code: #{status_code}")
62
-
63
- [Response.new(status_code, nil), should_retry]
93
+ logger.debug { "#{LOG_PREFIX} Response: status=#{response.code}, body=#{response.body}" }
94
+ [Response.new(response.code.to_i, nil), retry?(response)]
64
95
  end
65
96
 
66
97
  if exception
67
- @logger.error(exception.message)
68
- exception.backtrace.each { |line| @logger.error(line) }
98
+ logger.error { "#{LOG_PREFIX} #{exception.message}" }
99
+ exception.backtrace.each { |line| logger.error(line) }
69
100
  Response.new(-1, exception.to_s)
70
101
  else
71
102
  last_response
72
103
  end
104
+ ensure
105
+ backoff_policy.reset
106
+ batch.clear
73
107
  end
74
108
 
75
109
  # Closes a persistent connection if it exists
76
110
  def shutdown
111
+ logger.info { "#{LOG_PREFIX} Transport shutting down" }
77
112
  @http.finish if @http.started?
78
113
  end
79
114
 
80
115
  private
81
116
 
82
- def should_retry_request?(status_code, body)
117
+ def retry?(response)
118
+ status_code = response.code.to_i
83
119
  if status_code >= 500
84
120
  # Server error. Retry and log.
85
- @logger.info("Server error: status=#{status_code}, body=#{body}")
121
+ logger.info { "#{LOG_PREFIX} Server error: status=#{status_code}, body=#{response.body}" }
86
122
  true
87
123
  elsif status_code == 429
88
- # Rate limited
89
- @logger.info "Rate limit error"
124
+ # Rate limited. Retry and log.
125
+ logger.info { "#{LOG_PREFIX} Rate limit error: body=#{response.body}" }
90
126
  true
91
127
  elsif status_code >= 400
92
128
  # Client error. Do not retry, but log.
93
- @logger.error("Client error: status=#{status_code}, body=#{body}")
129
+ logger.error { "#{LOG_PREFIX} Client error: status=#{status_code}, body=#{response.body}" }
94
130
  false
95
131
  else
96
132
  false
@@ -112,13 +148,13 @@ module Brow
112
148
  result, should_retry = yield
113
149
  return [result, nil] unless should_retry
114
150
  rescue StandardError => error
115
- @logger.debug "Request error: #{error}"
151
+ logger.debug { "#{LOG_PREFIX} Request error: #{error}" }
116
152
  should_retry = true
117
153
  caught_exception = error
118
154
  end
119
155
 
120
156
  if should_retry && (retries_remaining > 1)
121
- @logger.debug("Retrying request, #{retries_remaining} retries left")
157
+ logger.debug { "#{LOG_PREFIX} Retrying request, #{retries_remaining} retries left" }
122
158
  sleep(@backoff_policy.next_interval.to_f / 1000)
123
159
  retry_with_backoff(retries_remaining - 1, &block)
124
160
  else
@@ -126,12 +162,23 @@ module Brow
126
162
  end
127
163
  end
128
164
 
129
- # Sends a request for the batch, returns [status_code, body]
130
165
  def send_request(batch)
131
- payload = batch.to_json
132
- @http.start unless @http.started? # Maintain a persistent connection
133
- request = Net::HTTP::Post.new(@uri.path, @headers)
134
- @http.request(request, payload)
166
+ headers = {
167
+ "Accept" => "application/json",
168
+ "Content-Type" => "application/json",
169
+ "User-Agent" => "Brow v#{Brow::VERSION}",
170
+ "Client-Language" => "ruby",
171
+ "Client-Language-Version" => "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})",
172
+ "Client-Platform" => RUBY_PLATFORM,
173
+ "Client-Engine" => defined?(RUBY_ENGINE) ? RUBY_ENGINE : "",
174
+ "Client-Hostname" => Socket.gethostname,
175
+ "Client-Pid" => Process.pid.to_s,
176
+ "Client-Thread" => Thread.current.object_id.to_s,
177
+ }.merge(@headers)
178
+
179
+ @http.start unless @http.started?
180
+ request = Net::HTTP::Post.new(@uri.path, headers)
181
+ @http.request(request, batch.to_json)
135
182
  end
136
183
  end
137
184
  end
data/lib/brow/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brow
4
- VERSION = "0.1.0"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/brow/worker.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'thread'
4
+
3
5
  require_relative 'message_batch'
4
6
  require_relative 'transport'
5
7
  require_relative 'utils'
@@ -7,60 +9,186 @@ require_relative 'utils'
7
9
  module Brow
8
10
  # Internal: The Worker to pull items off the queue and put them
9
11
  class Worker
12
+ # Private: Noop default on error proc.
10
13
  DEFAULT_ON_ERROR = proc { |response| }
11
14
 
15
+ # Private: Object to enqueue to signal shutdown for worker.
16
+ SHUTDOWN = :__ಠ_ಠ__
17
+
18
+ # Private: Default number of seconds to wait to shutdown worker thread.
19
+ SHUTDOWN_TIMEOUT = 5
20
+
21
+ # Private: Default # of items that can be in queue before we start dropping data.
22
+ MAX_QUEUE_SIZE = 10_000
23
+
24
+ # Private
25
+ attr_reader :thread, :queue, :pid, :mutex, :on_error, :batch_size, :max_queue_size
26
+
27
+ # Private
28
+ attr_reader :logger, :transport, :shutdown_timeout
29
+
12
30
  # Internal: Creates a new worker
13
31
  #
14
32
  # The worker continuously takes messages off the queue and makes requests to
15
33
  # the api.
16
34
  #
17
- # queue - Queue synchronized between client and worker
18
35
  # options - The Hash of worker options.
19
- # batch_size - Fixnum of how many items to send in a batch.
20
- # on_error - Proc of what to do on an error.
21
- # transport - The Transport object to deliver batches.
22
- # logger - The Logger object for all log messages.
23
- # batch - The MessageBatch to collect messages and deliver batches
24
- # via Transport.
25
- def initialize(queue, options = {})
26
- @queue = queue
27
- @lock = Mutex.new
36
+ # :queue - Queue synchronized between client and worker
37
+ # :on_error - Proc of what to do on an error.
38
+ # :batch_size - Fixnum of how many items to send in a batch.
39
+ # :transport - The Transport object to deliver batches.
40
+ # :logger - The Logger object for all log messages.
41
+ # :batch - The MessageBatch to collect messages and deliver batches
42
+ # via Transport.
43
+ # :shutdown_timeout - The number of seconds to wait for the worker thread
44
+ # to join when shutting down.
45
+ # :start_automatically - Should the client start the worker thread
46
+ # automatically and keep it running.
47
+ # :shutdown_automatically - Should the client shutdown automatically or
48
+ # manually. If true, shutdown is automatic. If
49
+ # false, you'll need to handle this on your own.
50
+ def initialize(options = {})
51
+ @thread = nil
52
+ @queue = options.fetch(:queue) { Queue.new }
53
+ @pid = Process.pid
54
+ @mutex = Mutex.new
28
55
  options = Brow::Utils.symbolize_keys(options)
29
56
  @on_error = options[:on_error] || DEFAULT_ON_ERROR
30
- @transport = options.fetch(:transport) { Transport.new(options) }
31
57
  @logger = options.fetch(:logger) { Brow.logger }
32
- @batch = options.fetch(:batch) { MessageBatch.new(max_size: options[:batch_size]) }
58
+ @transport = options.fetch(:transport) { Transport.new(options) }
59
+
60
+ @batch_size = options.fetch(:batch_size) {
61
+ ENV.fetch("BROW_BATCH_SIZE", MessageBatch::MAX_SIZE).to_i
62
+ }
63
+ @max_queue_size = options.fetch(:max_queue_size) {
64
+ ENV.fetch("BROW_MAX_QUEUE_SIZE", MAX_QUEUE_SIZE).to_i
65
+ }
66
+ @shutdown_timeout = options.fetch(:shutdown_timeout) {
67
+ ENV.fetch("BROW_SHUTDOWN_TIMEOUT", SHUTDOWN_TIMEOUT).to_f
68
+ }
69
+
70
+ if @batch_size <= 0
71
+ raise ArgumentError, ":batch_size must be greater than 0"
72
+ end
73
+
74
+ if @max_queue_size <= 0
75
+ raise ArgumentError, ":max_queue_size must be greater than 0"
76
+ end
77
+
78
+ if @shutdown_timeout <= 0
79
+ raise ArgumentError, ":shutdown_timeout must be greater than 0"
80
+ end
81
+
82
+ @start_automatically = options.fetch(:start_automatically, true)
83
+
84
+ if options.fetch(:shutdown_automatically, true)
85
+ at_exit { stop }
86
+ end
87
+ end
88
+
89
+ def push(data)
90
+ raise ArgumentError, "data must be a Hash" unless data.is_a?(Hash)
91
+ start if @start_automatically
92
+
93
+ data = Utils.isoify_dates(data)
94
+
95
+ if queue.length < max_queue_size
96
+ queue << data
97
+ true
98
+ else
99
+ logger.warn { "#{LOG_PREFIX} Queue is full, dropping events. The :max_queue_size configuration parameter can be increased to prevent this from happening." }
100
+ false
101
+ end
102
+ end
103
+
104
+ def start
105
+ reset if forked?
106
+ ensure_worker_running
107
+ end
108
+
109
+ def stop
110
+ queue << SHUTDOWN
111
+
112
+ if @thread
113
+ begin
114
+ if @thread.join(shutdown_timeout)
115
+ logger.info { "#{LOG_PREFIX} Worker thread [#{@thread.object_id}] joined sucessfully" }
116
+ else
117
+ logger.info { "#{LOG_PREFIX} Worker thread [#{@thread.object_id}] did not join successfully" }
118
+ end
119
+ rescue => error
120
+ logger.info { "#{LOG_PREFIX} Worker thread [#{@thread.object_id}] error shutting down: #{error.inspect}" }
121
+ end
122
+ end
33
123
  end
34
124
 
35
125
  # Internal: Continuously runs the loop to check for new events
36
126
  def run
37
- until Thread.current[:should_exit]
38
- return if @queue.empty?
127
+ batch = MessageBatch.new(max_size: batch_size)
39
128
 
40
- @lock.synchronize do
41
- consume_message_from_queue! until @batch.full? || @queue.empty?
42
- end
129
+ loop do
130
+ message = queue.pop
43
131
 
44
- response = @transport.send_batch @batch
45
- @on_error.call(response) unless response.status == 200
132
+ case message
133
+ when SHUTDOWN
134
+ logger.info { "#{LOG_PREFIX} Worker shutting down" }
135
+ send_batch(batch) unless batch.empty?
136
+ break
137
+ else
138
+ begin
139
+ batch << message
140
+ rescue MessageBatch::JSONGenerationError => error
141
+ on_error.call(Response.new(-1, error))
142
+ end
46
143
 
47
- @lock.synchronize { @batch.clear }
144
+ send_batch(batch) if batch.full?
145
+ end
48
146
  end
49
147
  ensure
50
- @transport.shutdown
148
+ transport.shutdown
51
149
  end
52
150
 
53
- # Internal: Check whether we have outstanding requests.
54
- def requesting?
55
- @lock.synchronize { !@batch.empty? }
151
+ private
152
+
153
+ def forked?
154
+ pid != Process.pid
56
155
  end
57
156
 
58
- private
157
+ def ensure_worker_running
158
+ # Return early if thread is alive and avoid the mutex lock and unlock.
159
+ return if thread_alive?
160
+
161
+ # If another thread is starting worker thread, then return early so this
162
+ # thread can enqueue and move on with life.
163
+ return unless mutex.try_lock
164
+
165
+ begin
166
+ return if thread_alive?
167
+ @thread = Thread.new { run }
168
+ logger.debug { "#{LOG_PREFIX} Worker thread [#{@thread.object_id}] started" }
169
+ ensure
170
+ mutex.unlock
171
+ end
172
+ end
173
+
174
+ def thread_alive?
175
+ @thread && @thread.alive?
176
+ end
177
+
178
+ def reset
179
+ @pid = Process.pid
180
+ mutex.unlock if mutex.locked?
181
+ queue.clear
182
+ end
183
+
184
+ def send_batch(batch)
185
+ response = transport.send_batch(batch)
186
+
187
+ unless response.status == 200
188
+ on_error.call(response)
189
+ end
59
190
 
60
- def consume_message_from_queue!
61
- @batch << @queue.pop
62
- rescue MessageBatch::JSONGenerationError => error
63
- @on_error.call(Response.new(-1, error))
191
+ response
64
192
  end
65
193
  end
66
194
  end
data/lib/brow.rb CHANGED
@@ -6,17 +6,18 @@ require "logger"
6
6
  module Brow
7
7
  class Error < StandardError; end
8
8
 
9
+ # Private
10
+ LOG_PREFIX = "[brow]"
11
+
9
12
  # Public: Returns the logger instance to use for logging of things.
10
13
  def self.logger
11
14
  return @logger if @logger
12
15
 
13
- base_logger = if defined?(Rails)
16
+ @logger = if defined?(Rails)
14
17
  Rails.logger
15
18
  else
16
19
  Logger.new(STDOUT)
17
20
  end
18
-
19
- @logger = PrefixedLogger.new(base_logger, "[brow]")
20
21
  end
21
22
 
22
23
  # Public: Sets the logger instance to use for logging things.
@@ -26,4 +27,3 @@ module Brow
26
27
  end
27
28
 
28
29
  require_relative "brow/client"
29
- require_relative "brow/prefixed_logger"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nunemaker
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-10-20 00:00:00.000000000 Z
11
+ date: 2021-11-06 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -26,13 +26,14 @@ files:
26
26
  - bin/console
27
27
  - bin/setup
28
28
  - examples/basic.rb
29
+ - examples/echo_server.rb
30
+ - examples/forked.rb
31
+ - examples/long_running.rb
29
32
  - lib/brow.rb
30
33
  - lib/brow/backoff_policy.rb
31
34
  - lib/brow/client.rb
32
35
  - lib/brow/message_batch.rb
33
- - lib/brow/prefixed_logger.rb
34
36
  - lib/brow/response.rb
35
- - lib/brow/test_queue.rb
36
37
  - lib/brow/transport.rb
37
38
  - lib/brow/utils.rb
38
39
  - lib/brow/version.rb
@@ -1,25 +0,0 @@
1
- module Brow
2
- # Internal: Wraps an existing logger and adds a prefix to all messages.
3
- class PrefixedLogger
4
- def initialize(logger, prefix)
5
- @logger = logger
6
- @prefix = prefix
7
- end
8
-
9
- def debug(message)
10
- @logger.debug("#{@prefix} #{message}")
11
- end
12
-
13
- def info(message)
14
- @logger.info("#{@prefix} #{message}")
15
- end
16
-
17
- def warn(message)
18
- @logger.warn("#{@prefix} #{message}")
19
- end
20
-
21
- def error(message)
22
- @logger.error("#{@prefix} #{message}")
23
- end
24
- end
25
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Brow
4
- # Public: The test queue to use if the `Client` is in test mode. Keeps all
5
- # messages in an array so you can add assertions.
6
- #
7
- # Be sure to reset before each test case.
8
- class TestQueue
9
- attr_reader :messages
10
-
11
- def initialize
12
- reset
13
- end
14
-
15
- def count
16
- messages.count
17
- end
18
- alias_method :size, :count
19
- alias_method :length, :count
20
-
21
- def <<(message)
22
- messages << message
23
- end
24
-
25
- def reset
26
- @messages = []
27
- end
28
- end
29
- end