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 +4 -4
- data/CHANGELOG.md +55 -3
- data/Gemfile +2 -0
- data/Guardfile +1 -0
- data/README.md +2 -3
- data/examples/basic.rb +6 -6
- data/examples/echo_server.rb +51 -0
- data/examples/forked.rb +20 -0
- data/examples/long_running.rb +32 -0
- data/lib/brow/backoff_policy.rb +35 -5
- data/lib/brow/client.rb +45 -86
- data/lib/brow/message_batch.rb +12 -3
- data/lib/brow/transport.rb +88 -41
- data/lib/brow/version.rb +1 -1
- data/lib/brow/worker.rb +157 -29
- data/lib/brow.rb +4 -4
- metadata +5 -4
- data/lib/brow/prefixed_logger.rb +0 -25
- data/lib/brow/test_queue.rb +0 -29
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1e823ebd67a0230133814bbdd5adc0463f2a6e2fe730b54ee2b55ecb925d347f
|
4
|
+
data.tar.gz: 5168d92d78eae8958098cacee8b0c4a25b140a52a6a3b08f7bd1e8e9388056ba
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: aeaa195ffd0be0945d088219d0dd60451d5ef445bee52f35c3f6d379dc2fbbc7ec5784fe0cf0cfd32a771235e87ae607b85173ef264cb4107311026c613befae
|
7
|
+
data.tar.gz: '09f9ae0b7030d6daab14014d31e705d2df68d099ea5fe66a217dcde23fba3b9fededab0f25663651f354174efd2b0e23aa01cbc8faa0e0c6394ab1805ef1567a'
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,57 @@
|
|
1
|
-
|
1
|
+
# Changelog
|
2
2
|
|
3
|
-
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
4
|
|
5
|
-
|
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
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.
|
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: "
|
5
|
+
url: "http://localhost:#{EchoServer.instance.port}",
|
6
|
+
batch_size: 10,
|
5
7
|
})
|
6
8
|
|
7
|
-
|
8
|
-
client.
|
9
|
-
|
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
|
data/examples/forked.rb
ADDED
@@ -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
|
data/lib/brow/backoff_policy.rb
CHANGED
@@ -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 =
|
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
|
30
|
-
|
31
|
-
|
32
|
-
@
|
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
|
-
#
|
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
|
60
|
+
# Public: Enqueues an event to eventually be transported to backend service.
|
46
61
|
#
|
47
|
-
#
|
62
|
+
# data - The Hash of data.
|
48
63
|
#
|
49
|
-
# Returns Boolean of whether the
|
50
|
-
def
|
51
|
-
|
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
|
data/lib/brow/message_batch.rb
CHANGED
@@ -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
|
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
|
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
|
data/lib/brow/transport.rb
CHANGED
@@ -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
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
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 =
|
38
|
-
@retries = options
|
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
|
-
|
48
|
-
|
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
|
-
|
89
|
+
logger.debug { "#{LOG_PREFIX} Sending request for #{batch.length} items" }
|
56
90
|
|
57
|
-
last_response, exception = retry_with_backoff(
|
91
|
+
last_response, exception = retry_with_backoff(retries) do
|
58
92
|
response = send_request(batch)
|
59
|
-
|
60
|
-
|
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
|
-
|
68
|
-
exception.backtrace.each { |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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
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
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
|
-
#
|
20
|
-
#
|
21
|
-
#
|
22
|
-
#
|
23
|
-
#
|
24
|
-
#
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
@
|
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
|
-
|
38
|
-
return if @queue.empty?
|
127
|
+
batch = MessageBatch.new(max_size: batch_size)
|
39
128
|
|
40
|
-
|
41
|
-
|
42
|
-
end
|
129
|
+
loop do
|
130
|
+
message = queue.pop
|
43
131
|
|
44
|
-
|
45
|
-
|
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
|
-
|
144
|
+
send_batch(batch) if batch.full?
|
145
|
+
end
|
48
146
|
end
|
49
147
|
ensure
|
50
|
-
|
148
|
+
transport.shutdown
|
51
149
|
end
|
52
150
|
|
53
|
-
|
54
|
-
|
55
|
-
|
151
|
+
private
|
152
|
+
|
153
|
+
def forked?
|
154
|
+
pid != Process.pid
|
56
155
|
end
|
57
156
|
|
58
|
-
|
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
|
-
|
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
|
-
|
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
|
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-
|
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
|
data/lib/brow/prefixed_logger.rb
DELETED
@@ -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
|
data/lib/brow/test_queue.rb
DELETED
@@ -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
|