rack-influxdb 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 692f56fbc15ce9112783e0a51f3a323e32bcb1063db207d38684d66ef11f0c45
4
- data.tar.gz: b6d827637d9b7162a69b04682846ffbb372bce98af6b2fa547d6d2a0dd967864
3
+ metadata.gz: 56285fb58ca4b8a2a89e99d44d56226ef01fe04617c2211eb23036f5f913dd9c
4
+ data.tar.gz: 61c49b66c095a40936e1014ebb828b6aafe031d60b35a452bbb90ebe661bb2a3
5
5
  SHA512:
6
- metadata.gz: 12047f80e2e445e38b03304590ab8b77d197d6ab6061f3e68bef009dca49e40aad006cf2eac593ec33012f9af2dd3054fda4c9ab84eedc1fc9cb4ef98b33d02b
7
- data.tar.gz: 31cdc1e24c9e4931e545d93139a7fa4709fbc03597f3a6891f60bca98cbd695b2422fab94779af62f4f5716bf636c26891a734169fd31af69654cc3fd6b23145
6
+ metadata.gz: 0c477dc8d0368309191d354c5efcdc2d9892b5a1959eba15d04556d2a1bf2c204e2a79380928bcbd523cb1f1cb82b74aac13bc89e586462fb1ec93f790e94a25
7
+ data.tar.gz: c773adb6e3477eb96477f5e88afc579336b50657a12dcf066a410baab9273cb582bdab9490f283848fe58a08ced59b93ea279587340c01269a749dc615f30bea
data/README.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # rack-influxdb
2
+ ![Github Chimpy](https://github.com/user-attachments/assets/4f7c612e-2de7-45b4-bee7-dd54ab3e1d51)
2
3
 
3
4
  [![Continuous Integration](https://github.com/heychimpy/rack-influxdb/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/heychimpy/rack-influxdb/actions/workflows/test.yml)
5
+ [![codecov](https://codecov.io/gh/heychimpy/rack-influxdb/graph/badge.svg?token=L9NVAKCQWU)](https://codecov.io/gh/heychimpy/rack-influxdb)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop)
8
+ ![Reek Badge](https://img.shields.io/badge/code%20quality-reek-brightgreen?style=flat-square)
9
+ ![Brakeman Badge](https://img.shields.io/badge/security-brakeman-brightgreen?style=flat-square)
4
10
 
5
11
  This gem provides a Rack middleware for [InfluxDB](https://github.com/influxdata/influxdb).
6
12
 
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rubygems'
2
4
  require 'bundler/setup'
3
5
  require 'bundler/gem_tasks'
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class InfluxDB
5
+ # The main configuration for the gem.
3
6
  class Configuration
4
7
  attr_accessor :name, :tags, :url, :token, :options, :write_options,
5
8
  :fields, :handle_error
@@ -15,17 +18,24 @@ module Rack
15
18
  @tags = {}
16
19
  @url = ''
17
20
  @token = ''
18
- @fields = lambda { |env, response| return {} }
19
- @handle_error = lambda { |err| return }
20
- @options = {
21
+ @fields = ->(_env, _response) { {} }
22
+ @handle_error = ->(_err) { nil }
23
+ @options = default_options
24
+ @write_options = default_write_options
25
+ end
26
+
27
+ def default_options
28
+ {
21
29
  org: '',
22
30
  bucket: '',
23
31
  precision: InfluxDB2::WritePrecision::MILLISECOND
24
32
  }
33
+ end
25
34
 
26
- @write_options = InfluxDB2::WriteOptions.new(
35
+ def default_write_options
36
+ InfluxDB2::WriteOptions.new(
27
37
  write_type: InfluxDB2::WriteType::BATCHING,
28
- batch_size: 100,
38
+ batch_size: 100
29
39
  )
30
40
  end
31
41
  end
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  class InfluxDB
3
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
4
6
  end
5
7
  end
data/lib/rack/influxdb.rb CHANGED
@@ -1,11 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'influxdb-client'
2
4
  require 'rack/influxdb/configuration'
3
5
 
4
6
  module Rack
7
+ # The InfluxDB rack middleware class.
5
8
  class InfluxDB
6
- class << self
7
- attr_reader :configuration
9
+ # Caps how many computed points can be buffered while waiting to be
10
+ # written. If InfluxDB becomes slow or unreachable, the queue fills up
11
+ # and further points are dropped (see #enqueue) instead of growing
12
+ # without bound or blocking requests.
13
+ DEFAULT_QUEUE_SIZE = 1_000
8
14
 
15
+ class << self
9
16
  def configuration
10
17
  @configuration ||= Rack::InfluxDB::Configuration.new
11
18
  end
@@ -17,12 +24,28 @@ module Rack
17
24
 
18
25
  def initialize(app)
19
26
  @app = app
27
+ @queue = SizedQueue.new(DEFAULT_QUEUE_SIZE)
28
+ @worker_mutex = Mutex.new
29
+ @worker = nil
30
+
31
+ # Flush whatever is left in the queue when the process exits (e.g. on
32
+ # a graceful Puma shutdown), so the last few requests before shutdown
33
+ # aren't silently lost. This never blocks a request, only exit.
34
+ at_exit { shutdown }
20
35
  end
21
36
 
22
37
  def call(env)
23
38
  @app.call(env).tap { |response| write_request(env, response) }
24
39
  end
25
40
 
41
+ # Stops accepting new points and waits (briefly) for the worker to
42
+ # drain whatever is already queued. Safe to call multiple times and
43
+ # safe to call even if the worker was never started.
44
+ def shutdown
45
+ @queue.close unless @queue.closed?
46
+ @worker&.join(5)
47
+ end
48
+
26
49
  private
27
50
 
28
51
  def write_request(env, response)
@@ -31,31 +54,89 @@ module Rack
31
54
  # environment.
32
55
  return if config.token.to_s.empty?
33
56
 
34
- # Run the write logic inside a thread so we don't slow down
35
- # main request.
36
- Thread.new do
37
- begin
38
- InfluxDB2::Client.use(config.url, config.token, **config.options) do |c|
39
- write_api = c.create_write_api(write_options: config.write_options)
40
- write_api.write(data: point(env, response))
41
- end
42
- rescue => e
43
- config.handle_error.call(e)
44
- end
45
- end
46
- rescue => e
57
+ # Compute the point on the request thread so the queue - and the
58
+ # worker that reads from it - never has to hold a reference to the
59
+ # Rack env or response. Only the resulting Hash is handed off.
60
+ enqueue(point(env, response))
61
+ rescue StandardError => e
47
62
  # Let the app decide what needs to be done when an error occurs.
48
63
  config.handle_error.call(e)
49
64
  end
50
65
 
66
+ # Rather than spawning a `Thread.new` (and a new client/WriteApi) for
67
+ # every single request, requests push their computed point onto a
68
+ # queue that a single long-lived worker thread drains. This avoids the
69
+ # per-request cost of thread creation/teardown and the memory growth
70
+ # that comes from many short-lived threads each closing over a Rack
71
+ # env and response.
72
+ def enqueue(data)
73
+ start_worker
74
+ # Non-blocking push: if the queue is full (InfluxDB is slow/down) we
75
+ # drop the point instead of blocking the request or growing memory
76
+ # without bound.
77
+ @queue.push(data, true)
78
+ rescue ClosedQueueError, ThreadError
79
+ nil
80
+ end
81
+
82
+ # Lazily starts exactly one worker thread for this middleware instance.
83
+ #
84
+ # It's started lazily, on first request, rather than eagerly in
85
+ # `initialize`, to stay correct under Puma's `preload_app!`: the app
86
+ # (and this middleware) is built once in the master process, which
87
+ # then fork()s a child process per cluster worker. OS threads do not
88
+ # survive fork - only the forking thread continues to exist in the
89
+ # child - so a thread started eagerly in `initialize` would already be
90
+ # dead in every forked worker. Starting on first request instead means
91
+ # each process (master or forked worker) creates its own worker thread
92
+ # the first time it actually handles a request, so every worker
93
+ # process naturally ends up owning exactly one live worker thread and
94
+ # one queue.
95
+ def start_worker
96
+ return if @worker&.alive?
97
+
98
+ @worker_mutex.synchronize do
99
+ return if @worker&.alive?
100
+
101
+ @worker = Thread.new { process_queue }
102
+ end
103
+ end
104
+
105
+ # Runs on the single worker thread only. `client` and `write_api` are
106
+ # therefore each created exactly once, lazily, with no need for extra
107
+ # synchronization: nothing else ever touches them.
108
+ def process_queue
109
+ loop do
110
+ data = @queue.pop
111
+ # `pop` only returns nil once the queue has been closed (see
112
+ # #shutdown) and fully drained, which is our signal to stop.
113
+ break if data.nil?
114
+
115
+ write_api.write(data: data)
116
+ rescue StandardError => e
117
+ config.handle_error.call(e)
118
+ end
119
+ end
120
+
51
121
  def point(env, response)
52
122
  {
53
123
  name: config.name,
54
124
  tags: config.tags,
125
+ time: (Time.now.to_r * 1_000_000_000).to_i,
55
126
  fields: config.fields.call(env, response)
56
127
  }
57
128
  end
58
129
 
130
+ def write_api
131
+ @write_api ||= client.create_write_api(write_options: config.write_options)
132
+ end
133
+
134
+ def client
135
+ @client ||= InfluxDB2::Client.new(
136
+ config.url, config.token, **config.options
137
+ )
138
+ end
139
+
59
140
  def config
60
141
  Rack::InfluxDB.configuration
61
142
  end
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative './spec_helper'
4
+ require 'weakref'
2
5
 
3
6
  RSpec.describe Rack::InfluxDB do
4
7
  describe '.configuration' do
@@ -32,10 +35,6 @@ RSpec.describe Rack::InfluxDB do
32
35
  end
33
36
 
34
37
  describe '#call' do
35
- before do
36
- allow(Thread).to receive(:new).and_yield
37
- end
38
-
39
38
  it 'returns the initial status code' do
40
39
  get '/'
41
40
 
@@ -49,41 +48,121 @@ RSpec.describe Rack::InfluxDB do
49
48
  end
50
49
 
51
50
  context 'when no config token is given' do
52
- it 'does not call InfluxDB2::Client.use' do
53
- expect(InfluxDB2::Client).not_to receive(:use)
51
+ it 'does not call InfluxDB2::Client.new' do
52
+ expect(InfluxDB2::Client).not_to receive(:new)
54
53
 
55
54
  get '/'
56
55
  end
57
56
  end
58
57
 
59
58
  context 'when config token is given' do
59
+ let(:conf) { described_class.configuration }
60
+ let(:influx_client) { double('InfluxDB2::Client') }
61
+ let(:write_api) { double('InfluxDB2::WriteApi') }
62
+ let(:write_calls) { [] }
63
+ let(:write_mutex) { Mutex.new }
64
+
60
65
  before do
61
- allow(InfluxDB2::Client).to receive(:use)
66
+ allow(InfluxDB2::Client).to receive(:new).and_return(influx_client)
67
+ allow(influx_client).to receive(:create_write_api).and_return(write_api)
68
+ allow(write_api).to receive(:write) do |data:|
69
+ write_mutex.synchronize { write_calls << data }
70
+ end
62
71
 
63
72
  described_class.configure do |config|
64
- config.token = "token"
65
- config.url = "example.com"
73
+ config.token = 'token'
74
+ config.url = 'example.com'
66
75
  end
67
76
  end
68
77
 
69
- let(:conf) { described_class.configuration }
70
-
71
- it 'calls InfluxDB2::Client.use with right params' do
78
+ it 'calls InfluxDB2::Client.new with right params' do
72
79
  get '/'
73
80
 
81
+ wait_for { write_calls.any? }
82
+
74
83
  expect(InfluxDB2::Client)
75
- .to have_received(:use)
84
+ .to have_received(:new)
76
85
  .with(conf.url, conf.token, conf.options)
77
86
  end
87
+
88
+ it 'writes a data point to InfluxDB' do
89
+ get '/'
90
+
91
+ wait_for { write_calls.any? }
92
+
93
+ expect(write_calls).to include(hash_including(name: conf.name))
94
+ end
95
+
96
+ it 'reuses the same write API instead of creating a new one per request' do
97
+ middleware = described_class.new(->(_env) { [200, {}, ['Hello World']] })
98
+
99
+ 2.times { middleware.call(Rack::MockRequest.env_for('/')) }
100
+
101
+ wait_for { write_calls.size >= 2 }
102
+
103
+ expect(influx_client).to have_received(:create_write_api).once
104
+ end
105
+
106
+ it 'returns the response without waiting for the write to InfluxDB' do
107
+ allow(write_api).to receive(:write) do |data:|
108
+ sleep 0.3
109
+ write_mutex.synchronize { write_calls << data }
110
+ end
111
+
112
+ middleware = described_class.new(->(_env) { [200, {}, ['Hello World']] })
113
+
114
+ started_at = Time.now
115
+ middleware.call(Rack::MockRequest.env_for('/'))
116
+ elapsed = Time.now - started_at
117
+
118
+ expect(elapsed).to be < 0.1
119
+ end
120
+
121
+ it 'creates exactly one worker thread no matter how many requests come in' do
122
+ thread_count = 0
123
+ thread_count_mutex = Mutex.new
124
+ allow(Thread).to receive(:new).and_wrap_original do |original, *args, &block|
125
+ thread_count_mutex.synchronize { thread_count += 1 }
126
+ original.call(*args, &block)
127
+ end
128
+
129
+ middleware = described_class.new(->(_env) { [200, {}, ['Hello World']] })
130
+ 5.times { middleware.call(Rack::MockRequest.env_for('/')) }
131
+
132
+ wait_for { write_calls.size >= 5 }
133
+
134
+ expect(thread_count).to eq(1)
135
+ end
136
+
137
+ it 'does not retain the Rack env after the worker has processed it' do
138
+ middleware = described_class.new(->(_env) { [200, {}, ['Hello World']] })
139
+
140
+ # Building the env and the WeakRef inside a lambda, rather than in
141
+ # local variables of the example itself, lets `env` fall out of
142
+ # scope (and become eligible for GC) as soon as the lambda returns.
143
+ weak_env = lambda do
144
+ env = Rack::MockRequest.env_for('/')
145
+ ref = WeakRef.new(env)
146
+ middleware.call(env)
147
+ ref
148
+ end.call
149
+
150
+ wait_for { write_calls.any? }
151
+ GC.start
152
+
153
+ # `weakref_alive?` is falsy (nil or false, depending on Ruby version)
154
+ # once the referenced object has been collected.
155
+ expect(weak_env.weakref_alive?).to be_falsey
156
+ end
78
157
  end
79
158
 
80
159
  context 'when an error occurs' do
81
160
  before do
82
- allow(InfluxDB2::Client).to receive(:use).and_raise('Could not write')
161
+ allow(InfluxDB2::Client).to receive(:new).and_raise('Could not write')
83
162
 
84
163
  described_class.configure do |config|
85
- config.token = "token"
86
- config.url = "example.com"
164
+ config.token = 'token'
165
+ config.url = 'example.com'
87
166
  end
88
167
  end
89
168
 
@@ -98,14 +177,21 @@ RSpec.describe Rack::InfluxDB do
98
177
  end
99
178
 
100
179
  context 'when custom error handling is applied' do
101
- before do
180
+ it 'passes the raised error to the custom error handler' do
181
+ handled_errors = []
182
+ handled_mutex = Mutex.new
183
+
102
184
  described_class.configure do |config|
103
- config.handle_error = ->(e) { raise e }
185
+ config.handle_error = lambda do |e|
186
+ handled_mutex.synchronize { handled_errors << e }
187
+ end
104
188
  end
105
- end
106
189
 
107
- it 'it gets handled (re-raised)' do
108
- expect { get '/' }.to raise_error('Could not write')
190
+ get '/'
191
+
192
+ wait_for { handled_errors.any? }
193
+
194
+ expect(handled_errors.first.message).to eq('Could not write')
109
195
  end
110
196
  end
111
197
  end
data/spec/spec_helper.rb CHANGED
@@ -1,3 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'simplecov'
4
+ require 'simplecov-cobertura'
5
+
6
+ SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter
7
+ SimpleCov.start
8
+
1
9
  require 'bundler/setup'
2
10
 
3
11
  require 'rack'
@@ -10,10 +18,23 @@ def app
10
18
  # Include the InfluxDB middleware.
11
19
  use Rack::InfluxDB
12
20
 
13
- run lambda { |_env| [200, {}, ['Hello World']] }
21
+ run ->(_env) { [200, {}, ['Hello World']] }
14
22
  end.to_app
15
23
  end
16
24
 
25
+ # Polls `block` until it returns truthy, or raises after `timeout` seconds.
26
+ # Used to synchronize with work happening on the middleware's background
27
+ # worker thread without coupling tests to sleep durations.
28
+ def wait_for(timeout: 2)
29
+ deadline = Time.now + timeout
30
+
31
+ until yield
32
+ raise "wait_for timed out after #{timeout}s" if Time.now > deadline
33
+
34
+ sleep 0.01
35
+ end
36
+ end
37
+
17
38
  RSpec.configure do |config|
18
39
  config.include Rack::Test::Methods
19
40
 
metadata CHANGED
@@ -1,35 +1,42 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-influxdb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Henning Vogt
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-05-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: rack
13
+ name: base64
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '1.0'
20
- - - "<"
21
- - !ruby/object:Gem::Version
22
- version: '4'
18
+ version: '0'
23
19
  type: :runtime
24
20
  prerelease: false
25
21
  version_requirements: !ruby/object:Gem::Requirement
26
22
  requirements:
27
23
  - - ">="
28
24
  - !ruby/object:Gem::Version
29
- version: '1.0'
30
- - - "<"
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: csv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '4'
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
33
40
  - !ruby/object:Gem::Dependency
34
41
  name: influxdb-client
35
42
  requirement: !ruby/object:Gem::Requirement
@@ -44,6 +51,40 @@ dependencies:
44
51
  - - ">="
45
52
  - !ruby/object:Gem::Version
46
53
  version: '3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: logger
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rack
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '1.0'
75
+ - - "<"
76
+ - !ruby/object:Gem::Version
77
+ version: '4'
78
+ type: :runtime
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '1.0'
85
+ - - "<"
86
+ - !ruby/object:Gem::Version
87
+ version: '4'
47
88
  - !ruby/object:Gem::Dependency
48
89
  name: bundler
49
90
  requirement: !ruby/object:Gem::Requirement
@@ -124,7 +165,6 @@ homepage: https://github.com/heychimpy/rack-influxdb
124
165
  licenses:
125
166
  - MIT
126
167
  metadata: {}
127
- post_install_message:
128
168
  rdoc_options: []
129
169
  require_paths:
130
170
  - lib
@@ -132,15 +172,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
132
172
  requirements:
133
173
  - - ">="
134
174
  - !ruby/object:Gem::Version
135
- version: '2.7'
175
+ version: '3.0'
136
176
  required_rubygems_version: !ruby/object:Gem::Requirement
137
177
  requirements:
138
178
  - - ">="
139
179
  - !ruby/object:Gem::Version
140
180
  version: '0'
141
181
  requirements: []
142
- rubygems_version: 3.3.5
143
- signing_key:
182
+ rubygems_version: 4.0.11
144
183
  specification_version: 4
145
184
  summary: Log HTTP requests to InfluxDB
146
185
  test_files: