waterdrop 2.8.5 → 2.8.7

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: 724d3ad251e8ffee9c1fa855dd65059c3fdcedc954f3acb27bedfa226ee4d9f9
4
- data.tar.gz: 43f902292f2f14a1f40de8650f1549648a821b5faae02978cb3ea179eb4e6100
3
+ metadata.gz: c6fd5bbc4f935b3d55794ad4f62970ab9feab3d4ebd100f9394c1bb456b8a9b5
4
+ data.tar.gz: 00ab23a92637285766a29741324b65422b77aba3522aa812b0f72350e3061585
5
5
  SHA512:
6
- metadata.gz: 41dd3a79e7d0d6bba6c95ff6c60ba01511a118c069038a93b544c37a389b6e26868654074849fe9671f399ebb114f49537c1237f4431be463f25d9f52925e596
7
- data.tar.gz: 51e22d8a542075b1e1c83c5c339b89b20f1db313614b6edbf573e59387b43bcd516d9e30e5f613b02691786ae8dc8166bc471581bc8d780eb6eafbe17353b666
6
+ metadata.gz: 0a41a342c8b16e167f1292ea6068647ac100f21f7f632342280a0bf69debcee3e6929f070e63b0fde2c28b85454edc6845d66895376a953c3ccaad48873646df
7
+ data.tar.gz: 89956ae428a553f91d91a8064954dea2095cb81457fd78f1565e31ec47e31799b823942931a08f1e6f2d0a7f04b7f730d5d1856fe01fd4de9a57055b5e301360
@@ -6,9 +6,7 @@ concurrency:
6
6
 
7
7
  on:
8
8
  pull_request:
9
- branches: [ main, master ]
10
- push:
11
- branches: [ main, master ]
9
+ branches: [ master ]
12
10
  schedule:
13
11
  - cron: '0 1 * * *'
14
12
 
@@ -20,6 +18,8 @@ jobs:
20
18
  timeout-minutes: 15
21
19
  runs-on: ubuntu-latest
22
20
  needs: diffend
21
+ env:
22
+ BUNDLE_FORCE_RUBY_PLATFORM: ${{ matrix.force_ruby_platform }}
23
23
  strategy:
24
24
  fail-fast: false
25
25
  matrix:
@@ -29,6 +29,9 @@ jobs:
29
29
  - '3.3'
30
30
  - '3.2'
31
31
  - '3.1'
32
+ force_ruby_platform:
33
+ - true
34
+ - false
32
35
  include:
33
36
  - ruby: '3.4'
34
37
  coverage: 'true'
@@ -43,14 +46,15 @@ jobs:
43
46
  - name: Remove platform-specific ffi entries for Ruby previews
44
47
  if: contains(matrix.ruby, '3.5')
45
48
  run: |
46
- ruby -i -ne 'puts $_ unless /^\s*ffi \(.*-.*\)$/' Gemfile.lock
49
+ sed -i '/^\s*ffi (.*-.*)$/d' Gemfile.lock
47
50
 
48
51
  - name: Set up Ruby
49
- uses: ruby/setup-ruby@a4effe49ee8ee5b8b5091268c473a4628afb5651 # v1.245.0
52
+ uses: ruby/setup-ruby@2a7b30092b0caf9c046252510f9273b4875f3db9 # v1.254.0
50
53
  with:
51
54
  ruby-version: ${{matrix.ruby}}
52
55
  bundler-cache: true
53
56
  bundler: 'latest'
57
+ self-hosted: false
54
58
 
55
59
  - name: Run Kafka with Docker Compose
56
60
  run: |
@@ -75,6 +79,9 @@ jobs:
75
79
  GITHUB_COVERAGE: ${{matrix.coverage}}
76
80
  run: bundle exec rspec
77
81
 
82
+ - name: Run integration tests
83
+ run: ./bin/integrations
84
+
78
85
  - name: Check Kafka logs for unexpected warnings
79
86
  run: bin/verify_kafka_warnings
80
87
 
@@ -83,6 +90,7 @@ jobs:
83
90
 
84
91
  diffend:
85
92
  timeout-minutes: 5
93
+
86
94
  runs-on: ubuntu-latest
87
95
  strategy:
88
96
  fail-fast: false
@@ -91,9 +99,11 @@ jobs:
91
99
  with:
92
100
  fetch-depth: 0
93
101
  - name: Set up Ruby
94
- uses: ruby/setup-ruby@a4effe49ee8ee5b8b5091268c473a4628afb5651 # v1.245.0
102
+ uses: ruby/setup-ruby@2a7b30092b0caf9c046252510f9273b4875f3db9 # v1.254.0
95
103
  with:
96
104
  ruby-version: 3.4
105
+ self-hosted: false
106
+
97
107
  - name: Install latest bundler
98
108
  run: gem install bundler --no-document
99
109
  - name: Install Diffend plugin
@@ -124,3 +134,20 @@ jobs:
124
134
  fi
125
135
  - name: Run Coditsu
126
136
  run: ./coditsu_script.sh
137
+
138
+ ci-success:
139
+ name: CI Success
140
+ runs-on: ubuntu-latest
141
+ if: always()
142
+ needs:
143
+ - diffend
144
+ - coditsu
145
+ - specs
146
+ steps:
147
+ - name: Check all jobs passed
148
+ if: |
149
+ contains(needs.*.result, 'failure') ||
150
+ contains(needs.*.result, 'cancelled') ||
151
+ contains(needs.*.result, 'skipped')
152
+ run: exit 1
153
+ - run: echo "All CI checks passed!"
@@ -24,7 +24,7 @@ jobs:
24
24
  fetch-depth: 0
25
25
 
26
26
  - name: Set up Ruby
27
- uses: ruby/setup-ruby@a4effe49ee8ee5b8b5091268c473a4628afb5651 # v1.245.0
27
+ uses: ruby/setup-ruby@2a7b30092b0caf9c046252510f9273b4875f3db9 # v1.254.0
28
28
  with:
29
29
  bundler-cache: false
30
30
 
@@ -0,0 +1,37 @@
1
+ name: Trigger Wiki Refresh
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ push:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ trigger-wiki-refresh:
11
+ runs-on: >-
12
+ ${{
13
+ (github.event_name != 'pull_request' ||
14
+ github.event.pull_request.head.repo.full_name == github.repository)
15
+ && fromJSON('["self-hosted", "linux", "x64", "qemu"]')
16
+ || 'ubuntu-latest'
17
+ }}
18
+
19
+ environment: wiki-trigger
20
+ if: github.repository_owner == 'karafka'
21
+ steps:
22
+ - name: Trigger wiki refresh
23
+ uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
24
+ with:
25
+ token: ${{ secrets.WIKI_REPO_TOKEN }}
26
+ repository: karafka/wiki
27
+ event-type: sync-trigger
28
+ client-payload: |
29
+ {
30
+ "repository": "${{ github.repository }}",
31
+ "event_name": "${{ github.event_name }}",
32
+ "release_tag": "${{ github.event.release.tag_name || '' }}",
33
+ "release_name": "${{ github.event.release.name || '' }}",
34
+ "commit_sha": "${{ github.sha }}",
35
+ "commit_message": "Trigger Wiki Refresh",
36
+ "triggered_by": "${{ github.actor }}"
37
+ }
@@ -4,8 +4,15 @@ on:
4
4
  paths:
5
5
  - '.github/workflows/**'
6
6
  jobs:
7
- verify:
8
- runs-on: ubuntu-latest
7
+ verify_action_pins:
8
+ runs-on: >-
9
+ ${{
10
+ (github.event_name != 'pull_request' ||
11
+ github.event.pull_request.head.repo.full_name == github.repository)
12
+ && fromJSON('["self-hosted", "linux", "x64", "qemu"]')
13
+ || 'ubuntu-latest'
14
+ }}
15
+
9
16
  steps:
10
17
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
11
18
  - name: Check SHA pins
data/.rspec CHANGED
@@ -1 +1,2 @@
1
1
  --require spec_helper
2
+ --exclude-pattern "spec/integrations/**/*_spec.rb"
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.4
1
+ 3.4.5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # WaterDrop changelog
2
2
 
3
+ ## 2.8.7 (2025-09-02)
4
+ - [Enhancement] Disable Nagle algorithm by default (improves latency / aligned with librdkafka)
5
+ - [Change] Normalize how libs and dependencies are required (no functional change for the end user)
6
+
7
+ ## 2.8.6 (2025-08-18)
8
+ - [Feature] Add `idle_disconnect_timeout` config option to automatically disconnect idle producers after a configurable timeout period.
9
+ - [Feature] Add support for [async](https://github.com/socketry/async) gems ecosystem with proper fiber yielding during blocking operations.
10
+ - [Feature] Add integration testing infrastructure with `bin/integrations` runner for testing external ecosystem compatibility.
11
+ - [Enhancement] Introduce the `WaterDrop::Producer#disconnect` so users can write custom logic to save on connections then producer is only used from time to time.
12
+ - [Enhancement] Introduce `WaterDrop::Producer#inspect` that is mutex-safe.
13
+ - [Enhancement] Raise errors on detected Ruby warnings.
14
+ - [Enhancement] Optimize producer for Ruby shapes.
15
+ - [Enhancement] Add integration spec to validate fiber yielding behavior with async gems.
16
+ - [Change] Require `karafka-rdkafka` `>=` `0.20.0`.
17
+ - [Change] Add new CI action to trigger auto-doc refresh.
18
+
3
19
  ## 2.8.5 (2025-06-23)
4
20
  - [Enhancement] Normalize topic + partition logs format (single place).
5
21
  - [Fix] A producer is not idempotent unless the enable.idempotence config is `true` (ferrous26).
data/Gemfile CHANGED
@@ -17,4 +17,5 @@ group :test do
17
17
  gem 'ostruct'
18
18
  gem 'rspec'
19
19
  gem 'simplecov'
20
+ gem 'warning'
20
21
  end
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- waterdrop (2.8.5)
4
+ waterdrop (2.8.7)
5
5
  karafka-core (>= 2.4.9, < 3.0.0)
6
- karafka-rdkafka (>= 0.19.2)
6
+ karafka-rdkafka (>= 0.20.0)
7
7
  zeitwerk (~> 2.3)
8
8
 
9
9
  GEM
@@ -23,16 +23,43 @@ GEM
23
23
  ffi (1.17.2-x86_64-darwin)
24
24
  ffi (1.17.2-x86_64-linux-gnu)
25
25
  ffi (1.17.2-x86_64-linux-musl)
26
- karafka-core (2.4.11)
27
- karafka-rdkafka (>= 0.17.6, < 0.20.0)
26
+ json (2.13.2)
27
+ karafka-core (2.5.6)
28
+ karafka-rdkafka (>= 0.20.0)
28
29
  logger (>= 1.6.0)
29
- karafka-rdkafka (0.19.5)
30
+ karafka-rdkafka (0.21.0)
30
31
  ffi (~> 1.15)
32
+ json (> 2.0)
33
+ logger
34
+ mini_portile2 (~> 2.6)
35
+ rake (> 12)
36
+ karafka-rdkafka (0.21.0-aarch64-linux-gnu)
37
+ ffi (~> 1.15)
38
+ json (> 2.0)
39
+ logger
40
+ mini_portile2 (~> 2.6)
41
+ rake (> 12)
42
+ karafka-rdkafka (0.21.0-arm64-darwin)
43
+ ffi (~> 1.15)
44
+ json (> 2.0)
45
+ logger
46
+ mini_portile2 (~> 2.6)
47
+ rake (> 12)
48
+ karafka-rdkafka (0.21.0-x86_64-linux-gnu)
49
+ ffi (~> 1.15)
50
+ json (> 2.0)
51
+ logger
52
+ mini_portile2 (~> 2.6)
53
+ rake (> 12)
54
+ karafka-rdkafka (0.21.0-x86_64-linux-musl)
55
+ ffi (~> 1.15)
56
+ json (> 2.0)
57
+ logger
31
58
  mini_portile2 (~> 2.6)
32
59
  rake (> 12)
33
60
  logger (1.7.0)
34
61
  mini_portile2 (2.8.9)
35
- ostruct (0.6.2)
62
+ ostruct (0.6.3)
36
63
  rake (13.3.0)
37
64
  rspec (3.13.1)
38
65
  rspec-core (~> 3.13.0)
@@ -53,6 +80,7 @@ GEM
53
80
  simplecov_json_formatter (~> 0.1)
54
81
  simplecov-html (0.13.1)
55
82
  simplecov_json_formatter (0.1.4)
83
+ warning (1.5.0)
56
84
  zeitwerk (2.6.18)
57
85
 
58
86
  PLATFORMS
@@ -73,8 +101,9 @@ DEPENDENCIES
73
101
  ostruct
74
102
  rspec
75
103
  simplecov
104
+ warning
76
105
  waterdrop!
77
106
  zeitwerk (~> 2.6.18)
78
107
 
79
108
  BUNDLED WITH
80
- 2.6.7
109
+ 2.7.0
data/README.md CHANGED
@@ -15,6 +15,7 @@ It:
15
15
  - Supports producing to multiple clusters
16
16
  - Supports multiple delivery policies
17
17
  - Supports per-topic configuration alterations (variants)
18
+ - Works with [async](https://github.com/socketry/async) gems ecosystem
18
19
  - Works with Kafka `1.0+` and Ruby `3.1+`
19
20
  - Works with and without Karafka
20
21
 
data/bin/integrations ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Runner to run integration specs
4
+
5
+ # All integration specs run with their own bundler context to avoid dependency conflicts.
6
+ # All WaterDrop integration specs are pristine by default since they use isolated Gemfiles.
7
+ raise 'This code needs to be executed WITHOUT bundle exec' if Kernel.const_defined?(:Bundler)
8
+
9
+ require 'open3'
10
+ require 'fileutils'
11
+ require 'pathname'
12
+ require 'tmpdir'
13
+
14
+ ROOT_PATH = Pathname.new(File.expand_path(File.join(File.dirname(__FILE__), '../')))
15
+
16
+ # How may bytes do we want to keep from the stdout in the buffer for when we need to print it
17
+ MAX_BUFFER_OUTPUT = 307_200
18
+
19
+ # Abstraction around a single test scenario execution process
20
+ class Scenario
21
+ # How long a scenario can run before we kill it
22
+ # This is a fail-safe just in case something would hang
23
+ MAX_RUN_TIME = 5 * 60 # 5 minutes tops
24
+
25
+ # Expected exit codes for each integration test
26
+ # All WaterDrop integration tests should exit with 0 on success, 1 on failure
27
+ EXIT_CODES = {
28
+ default: [0]
29
+ }.freeze
30
+
31
+ private_constant :MAX_RUN_TIME, :EXIT_CODES
32
+
33
+ # Creates scenario instance and runs in the background process
34
+ #
35
+ # @param path [String] path to the scenarios file
36
+ def initialize(path)
37
+ @path = path
38
+ # First 1024 characters from stdout
39
+ @stdout_head = ''
40
+ # Last 1024 characters from stdout
41
+ @stdout_tail = ''
42
+ end
43
+
44
+ # Starts running given scenario in a separate process
45
+ def start
46
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(init_and_build_cmd)
47
+ @started_at = current_time
48
+ end
49
+
50
+ # @return [String] integration spec name
51
+ def name
52
+ @path.gsub("#{ROOT_PATH}/spec/integrations/", '')
53
+ end
54
+
55
+
56
+ # @return [Boolean] did this scenario finished or is it still running
57
+ def finished?
58
+ # If the thread is running too long, kill it
59
+ if current_time - @started_at > MAX_RUN_TIME
60
+ begin
61
+ Process.kill('TERM', pid)
62
+ # It may finish right after we want to kill it, that's why we ignore this
63
+ rescue Errno::ESRCH
64
+ end
65
+ end
66
+
67
+ # We read it so it won't grow as we use our default logger that prints to both test.log and
68
+ # to stdout. Otherwise after reaching the buffer size, it would hang
69
+ buffer = ''
70
+ @stdout.read_nonblock(MAX_BUFFER_OUTPUT, buffer, exception: false)
71
+ @stdout_head = buffer if @stdout_head.empty?
72
+ @stdout_tail << buffer
73
+ @stdout_tail = @stdout_tail[-MAX_BUFFER_OUTPUT..-1] || @stdout_tail
74
+
75
+ !@wait_thr.alive?
76
+ end
77
+
78
+ # @return [Boolean] did this scenario finish successfully or not
79
+ def success?
80
+ expected_exit_codes = EXIT_CODES[name] || EXIT_CODES[:default]
81
+
82
+ expected_exit_codes.include?(exit_code)
83
+ end
84
+
85
+ # @return [Integer] pid of the process of this scenario
86
+ def pid
87
+ @wait_thr.pid
88
+ end
89
+
90
+ # @return [Integer] exit code of the process running given scenario
91
+ def exit_code
92
+ # There may be no exit status if we killed the thread
93
+ @wait_thr.value&.exitstatus || 123
94
+ end
95
+
96
+ # @return [String] exit status of the process
97
+ def exit_status
98
+ @wait_thr.value.to_s
99
+ end
100
+
101
+ # Prints a status report when scenario is finished and stdout if it failed
102
+ def report
103
+ if success?
104
+ print "\e[#{32}m#{'.'}\e[0m"
105
+ else
106
+ buffer = ''
107
+
108
+ @stderr.read_nonblock(MAX_BUFFER_OUTPUT, buffer, exception: false)
109
+
110
+ puts
111
+ puts "\e[#{31}m#{'[FAILED]'}\e[0m #{name}"
112
+ puts "Time taken: #{current_time - @started_at} seconds"
113
+ puts "Exit code: #{exit_code}"
114
+ puts "Exit status: #{exit_status}"
115
+ puts @stdout_head
116
+ puts '...'
117
+ puts @stdout_tail
118
+ puts buffer
119
+ puts
120
+ end
121
+ end
122
+
123
+ # @return [Float] number of seconds that a given spec took to run
124
+ def time_taken
125
+ @finished_at - @started_at
126
+ end
127
+
128
+ # Close all the files that are open, so they do not pile up
129
+ def close
130
+ @finished_at = current_time
131
+ @stdin.close
132
+ @stdout.close
133
+ @stderr.close
134
+ end
135
+
136
+ private
137
+
138
+ # Sets up a proper environment for a given spec to run and returns the run command
139
+ # All WaterDrop integration specs run in pristine mode with isolated Gemfiles
140
+ # @return [String] run command
141
+ def init_and_build_cmd
142
+ scenario_dir = File.dirname(@path)
143
+ # We copy the spec into a temp dir, not to pollute the spec location with logs, etc
144
+ temp_dir = Dir.mktmpdir
145
+ file_name = File.basename(@path)
146
+
147
+ FileUtils.cp_r("#{scenario_dir}/.", temp_dir)
148
+
149
+ <<~CMD
150
+ cd #{temp_dir} &&
151
+ WATERDROP_GEM_DIR=#{ROOT_PATH} \
152
+ bundle install &&
153
+ BUNDLE_AUTO_INSTALL=true \
154
+ WATERDROP_GEM_DIR=#{ROOT_PATH} \
155
+ bundle exec ruby #{file_name}
156
+ CMD
157
+ end
158
+
159
+ # @return [Float] current machine time
160
+ def current_time
161
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
162
+ end
163
+ end
164
+
165
+ # Load all the specs
166
+ specs = Dir[ROOT_PATH.join('spec/integrations/**/*_spec.rb')]
167
+
168
+ FILTER_TYPE = ARGV[0] == '--exclude' ? 'exclude' : 'include'
169
+
170
+ # Remove the exclude flag
171
+ ARGV.shift if FILTER_TYPE == '--exclude'
172
+
173
+ # If filters is provided, apply
174
+ # Allows to provide several filters one after another and applies all of them
175
+ ARGV.each do |filter|
176
+ specs.delete_if do |name|
177
+ case FILTER_TYPE
178
+ when 'include'
179
+ !name.include?(filter)
180
+ when 'exclude'
181
+ name.include?(filter)
182
+ else
183
+ raise 'Invalid filter type'
184
+ end
185
+ end
186
+ end
187
+
188
+ # Randomize order
189
+ seed = (ENV['SPECS_SEED'] || rand(0..10_000)).to_i
190
+
191
+ puts "Random seed: #{seed}"
192
+
193
+ scenarios = specs
194
+ .shuffle(random: Random.new(seed))
195
+ .map { |integration| Scenario.new(integration) }
196
+
197
+ raise ArgumentError, "No integration specs with filters: #{ARGV.join(', ')}" if scenarios.empty?
198
+
199
+ puts "Running #{scenarios.size} scenarios"
200
+
201
+ finished_scenarios = []
202
+
203
+ scenarios.each do |scenario|
204
+ scenario.start
205
+
206
+ # Wait for this scenario to finish before moving to the next one
207
+ until scenario.finished?
208
+ sleep(0.1)
209
+ end
210
+
211
+ scenario.report
212
+ scenario.close
213
+ finished_scenarios << scenario
214
+ end
215
+
216
+ # Report longest scenarios
217
+ puts
218
+ puts "\nLongest scenarios:\n\n"
219
+
220
+ finished_scenarios.sort_by(&:time_taken).reverse.first(10).each do |long_scenario|
221
+ puts "[#{'%6.2f' % long_scenario.time_taken}] #{long_scenario.name}"
222
+ end
223
+
224
+ failed_scenarios = finished_scenarios.reject(&:success?)
225
+
226
+ if failed_scenarios.empty?
227
+ puts
228
+ else
229
+ # Report once more on the failed jobs
230
+ # This will only list scenarios that failed without printing their stdout here.
231
+ puts
232
+ puts "\nFailed scenarios:\n\n"
233
+
234
+ failed_scenarios.each do |scenario|
235
+ puts "\e[#{31}m#{'[FAILED]'}\e[0m #{scenario.name}"
236
+ end
237
+
238
+ puts
239
+
240
+ # Exit with 1 if not all scenarios were successful
241
+ exit 1
242
+ end
@@ -9,7 +9,7 @@ allowed_patterns=(
9
9
  )
10
10
 
11
11
  # Get all warnings
12
- warnings=$(docker logs --since=0 kafka | grep WARN)
12
+ warnings=$(docker logs --since=0 kafka | grep "] WARN ")
13
13
  exit_code=0
14
14
 
15
15
  while IFS= read -r line; do
@@ -19,6 +19,7 @@ en:
19
19
  max_attempts_on_transaction_command_format: must be an integer that is equal or bigger than 1
20
20
  reload_on_transaction_fatal_error_format: must be boolean
21
21
  oauth.token_provider_listener_format: 'must be false or respond to #on_oauthbearer_token_refresh'
22
+ idle_disconnect_timeout_format: 'must be an integer that is equal to 0 or bigger than 30 000 (30 seconds)'
22
23
 
23
24
  variant:
24
25
  missing: must be present
@@ -17,7 +17,10 @@ module WaterDrop
17
17
  # sync delivery
18
18
  'message.timeout.ms': 50_000,
19
19
  # Must be more or equal to `message.timeout.ms` defaults
20
- 'transaction.timeout.ms': 55_000
20
+ 'transaction.timeout.ms': 55_000,
21
+ # Lowers latency. Default in newer librdkafka but we want to make sure it is shipped to
22
+ # users despite what librdkafka they run on
23
+ 'socket.nagle.disable': true
21
24
  }.freeze
22
25
 
23
26
  private_constant :KAFKA_DEFAULTS
@@ -76,6 +79,12 @@ module WaterDrop
76
79
  # to keep going or should we stop. Since we will open a new instance and the failed transaction
77
80
  # anyhow rolls back, we should be able to safely reload.
78
81
  setting :reload_on_transaction_fatal_error, default: true
82
+ # option [Integer] Idle disconnect timeout in milliseconds. When set to 0, idle disconnection
83
+ # is disabled. When set to a positive value, WaterDrop will automatically disconnect
84
+ # producers that haven't sent any messages for the specified time period. This helps preserve
85
+ # TCP connections in low-intensity scenarios. Minimum value is 30 seconds (30_000 ms) to
86
+ # prevent overly aggressive disconnections.
87
+ setting :idle_disconnect_timeout, default: 0
79
88
 
80
89
  # option [Boolean] should we send messages. Setting this to false can be really useful when
81
90
  # testing and or developing because when set to false, won't actually ping Kafka but will
@@ -27,6 +27,9 @@ module WaterDrop
27
27
  required(:wait_backoff_on_transaction_command) { |val| val.is_a?(Numeric) && val >= 0 }
28
28
  required(:max_attempts_on_transaction_command) { |val| val.is_a?(Integer) && val >= 1 }
29
29
  required(:reload_on_transaction_fatal_error) { |val| [true, false].include?(val) }
30
+ required(:idle_disconnect_timeout) do |val|
31
+ val.is_a?(Integer) && (val.zero? || val >= 30_000)
32
+ end
30
33
 
31
34
  nested(:oauth) do
32
35
  required(:token_provider_listener) do |val|
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WaterDrop
4
+ module Instrumentation
5
+ # Idle disconnector listener that monitors producer activity and automatically disconnects
6
+ # idle producers to preserve TCP connections
7
+ #
8
+ # This listener subscribes to statistics.emitted events and tracks the txmsgs (transmitted
9
+ # messages) count. If the producer doesn't send any messages for a configurable timeout
10
+ # period, it will automatically disconnect the producer.
11
+ #
12
+ # @note We do not have to worry about the running transactions or buffer being used because
13
+ # the disconnect is graceful and will not disconnect unless it is allowed to. This is why
14
+ # we can simplify things and take interest only in txmsgs.
15
+ #
16
+ # @note For convenience, WaterDrop provides a config shortcut. Instead of manually subscribing
17
+ # this listener, you can simply set `config.idle_disconnect_timeout` in your producer config.
18
+ #
19
+ # @example Using config shortcut (recommended)
20
+ # WaterDrop::Producer.new do |config|
21
+ # config.idle_disconnect_timeout = 5 * 60 * 1000 # 5 minutes
22
+ # end
23
+ #
24
+ # @example Manual listener usage with 5 minute timeout
25
+ # producer.monitor.subscribe(
26
+ # WaterDrop::Instrumentation::IdleDisconnectorListener.new(
27
+ # producer,
28
+ # disconnect_timeout: 5 * 60 * 1000)
29
+ # )
30
+ #
31
+ # @example Usage with custom timeout
32
+ # idle_disconnector = WaterDrop::Instrumentation::IdleDisconnectorListener.new(
33
+ # producer,
34
+ # disconnect_timeout: 10 * 60 * 1000
35
+ # )
36
+ # producer.monitor.subscribe(idle_disconnector)
37
+ class IdleDisconnectorListener
38
+ include ::Karafka::Core::Helpers::Time
39
+
40
+ # @param producer [WaterDrop::Producer] the producer instance to monitor
41
+ # @param disconnect_timeout [Integer] timeout in milliseconds before disconnecting
42
+ # (default: 5 minutes). Be aware that if you set it to a value lower than statistics
43
+ # publishing interval (5 seconds by default) it may be to aggressive in closing
44
+ def initialize(producer, disconnect_timeout: 5 * 60 * 1_000)
45
+ @producer = producer
46
+ @disconnect_timeout = disconnect_timeout
47
+ # We set this initially to -1 so any statistics change triggers a change to prevent an
48
+ # early shutdown
49
+ @last_txmsgs = -1
50
+ @last_activity_time = monotonic_now
51
+ end
52
+
53
+ # This method is called automatically when the listener is subscribed to the monitor
54
+ # using producer.monitor.subscribe(listener_instance)
55
+ #
56
+ # @param event [Hash] the statistics event containing producer statistics
57
+ def on_statistics_emitted(event)
58
+ call(event[:statistics])
59
+ end
60
+
61
+ private
62
+
63
+ # Handles statistics.emitted events to monitor message transmission activity
64
+ # @param statistics [Hash] producer librdkafka statistics
65
+ def call(statistics)
66
+ current_txmsgs = statistics.fetch('txmsgs', 0)
67
+ current_time = monotonic_now
68
+
69
+ # Update activity if messages changed
70
+ if current_txmsgs != @last_txmsgs
71
+ @last_txmsgs = current_txmsgs
72
+ @last_activity_time = current_time
73
+
74
+ return
75
+ end
76
+
77
+ # Check for timeout and attempt disconnect
78
+ return unless (current_time - @last_activity_time) >= @disconnect_timeout
79
+
80
+ if @producer.disconnectable?
81
+ # Since the statistics operations happen from the rdkafka native thread. we cannot close
82
+ # it from itself as you cannot join on yourself as it would cause a deadlock. We spawn
83
+ # a thread to do this
84
+ # We do an early check if producer is in a viable state for a disconnect so in case its
85
+ # internal state would prevent us from disconnecting, we won't be spamming with new
86
+ # thread creation
87
+ Thread.new do
88
+ @producer.disconnect
89
+ rescue StandardError => e
90
+ @producer.monitor.instrument(
91
+ 'error.occurred',
92
+ producer_id: @producer.id,
93
+ error: e,
94
+ type: 'producer.disconnect.error'
95
+ )
96
+ end
97
+ end
98
+
99
+ # We change this always because:
100
+ # - if we were able to disconnect, this should give us time before any potential future
101
+ # attempts. While they should not happen because events won't be published on a
102
+ # disconnected producer, this may still with frequent events be called post disconnect
103
+ # - if we were not able to disconnect, it means that there was something in the producer
104
+ # state that prevent it, and we consider this as activity as well
105
+ @last_activity_time = current_time
106
+ end
107
+ end
108
+ end
109
+ end
@@ -132,6 +132,18 @@ module WaterDrop
132
132
  info(event, 'Closing producer')
133
133
  end
134
134
 
135
+ # @param event [Dry::Events::Event] event that happened with the details
136
+ def on_producer_disconnecting(event)
137
+ info(event, 'Disconnecting producer')
138
+ end
139
+
140
+ # @param event [Dry::Events::Event] event that happened with the details
141
+ # @note While this says "Disconnecting producer", it produces a nice message with time taken:
142
+ # "Disconnecting producer took 5 ms" indicating it happened in the past.
143
+ def on_producer_disconnected(event)
144
+ info(event, 'Disconnected producer')
145
+ end
146
+
135
147
  # @param event [Dry::Events::Event] event that happened with the details
136
148
  def on_producer_reloaded(event)
137
149
  info(event, 'Producer successfully reloaded')
@@ -11,6 +11,8 @@ module WaterDrop
11
11
  producer.closing
12
12
  producer.closed
13
13
  producer.reloaded
14
+ producer.disconnecting
15
+ producer.disconnected
14
16
 
15
17
  message.produced_async
16
18
  message.produced_sync
@@ -9,6 +9,8 @@ module WaterDrop
9
9
  initial
10
10
  configured
11
11
  connected
12
+ disconnecting
13
+ disconnected
12
14
  closing
13
15
  closed
14
16
  ].freeze
@@ -22,11 +24,12 @@ module WaterDrop
22
24
  end
23
25
 
24
26
  # @return [Boolean] true if producer is in a active state. Active means, that we can start
25
- # sending messages. Actives states are connected (connection established) or configured,
26
- # which means, that producer is configured, but connection with Kafka is
27
- # not yet established.
27
+ # sending messages. Active states are connected (connection established), configured
28
+ # which means, that producer is configured, but connection with Kafka is not yet
29
+ # established or disconnected, meaning it was working but user disconnected for his own
30
+ # reasons though sending could reconnect and continue.
28
31
  def active?
29
- connected? || configured?
32
+ connected? || configured? || disconnecting? || disconnected?
30
33
  end
31
34
 
32
35
  # @return [String] current status as a string
@@ -50,6 +50,12 @@ module WaterDrop
50
50
  @connecting_mutex = Mutex.new
51
51
  @operating_mutex = Mutex.new
52
52
  @transaction_mutex = Mutex.new
53
+ @id = nil
54
+ @monitor = nil
55
+ @contract = nil
56
+ @default_variant = nil
57
+ @client = nil
58
+ @closing_thread_id = nil
53
59
 
54
60
  @status = Status.new
55
61
  @messages = []
@@ -73,6 +79,18 @@ module WaterDrop
73
79
  @monitor = @config.monitor
74
80
  @contract = Contracts::Message.new(max_payload_size: @config.max_payload_size)
75
81
  @default_variant = Variant.new(self, default: true)
82
+
83
+ return @status.configured! if @config.idle_disconnect_timeout.zero?
84
+
85
+ # Setup idle disconnect listener if configured so we preserve tcp connections on rarely
86
+ # used producers
87
+ disconnector = Instrumentation::IdleDisconnectorListener.new(
88
+ self,
89
+ disconnect_timeout: @config.idle_disconnect_timeout
90
+ )
91
+
92
+ @monitor.subscribe(disconnector)
93
+
76
94
  @status.configured!
77
95
  end
78
96
 
@@ -178,6 +196,74 @@ module WaterDrop
178
196
  @middleware ||= config.middleware
179
197
  end
180
198
 
199
+ # Disconnects the producer from Kafka while keeping it configured for potential reconnection
200
+ #
201
+ # This method safely disconnects the underlying Kafka client while preserving the producer's
202
+ # configuration. Unlike `#close`, this allows the producer to be reconnected later by calling
203
+ # methods that require the client. The disconnection will only proceed if certain safety
204
+ # conditions are met.
205
+ #
206
+ # This API can be used to preserve connections on low-intensity producer instances, etc.
207
+ #
208
+ # @return [Boolean] true if disconnection was successful, false if disconnection was not
209
+ # possible due to safety conditions (active transactions, ongoing operations, pending
210
+ # messages in buffer, or if already disconnected)
211
+ #
212
+ # @note This method will refuse to disconnect if:
213
+ # - There are pending messages in the internal buffer
214
+ # - There are operations currently in progress
215
+ # - A transaction is currently active
216
+ # - The client is not currently connected
217
+ # - Required mutexes are locked by other operations
218
+ #
219
+ # @note After successful disconnection, the producer status changes to disconnected but
220
+ # remains configured, allowing for future reconnection when client access is needed.
221
+ def disconnect
222
+ return false unless disconnectable?
223
+
224
+ # Use the same mutex pattern as the regular close method to prevent race conditions
225
+ @transaction_mutex.synchronize do
226
+ @operating_mutex.synchronize do
227
+ @buffer_mutex.synchronize do
228
+ return false unless @client
229
+ return false unless @status.connected?
230
+ return false unless @messages.empty?
231
+ return false unless @operations_in_progress.value.zero?
232
+
233
+ @status.disconnecting!
234
+ @monitor.instrument('producer.disconnecting', producer_id: id)
235
+
236
+ @monitor.instrument('producer.disconnected', producer_id: id) do
237
+ # Close the client
238
+ @client.close
239
+ @client = nil
240
+
241
+ # Reset connection status but keep producer configured
242
+ @status.disconnected!
243
+ end
244
+
245
+ true
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ # Is the producer in a state from which we can disconnect
252
+ #
253
+ # @return [Boolean] is producer in a state that potentially allows for a disconnect
254
+ #
255
+ # @note This is a best effort method. The proper checks happen also when disconnecting behind
256
+ # all the needed mutexes
257
+ def disconnectable?
258
+ return false unless @client
259
+ return false unless @status.connected?
260
+ return false unless @messages.empty?
261
+ return false if @transaction_mutex.locked?
262
+ return false if @operating_mutex.locked?
263
+
264
+ true
265
+ end
266
+
181
267
  # Flushes the buffers in a sync way and closes the producer
182
268
  # @param force [Boolean] should we force closing even with outstanding messages after the
183
269
  # max wait timeout
@@ -260,6 +346,37 @@ module WaterDrop
260
346
  close(force: true)
261
347
  end
262
348
 
349
+ # @return [String] mutex-safe inspect details
350
+ def inspect
351
+ # Basic info that's always safe to access
352
+ parts = []
353
+ parts << "id=#{@id.inspect}"
354
+ parts << "status=#{@status}" if @status
355
+
356
+ # Try to get buffer info safely
357
+ if @buffer_mutex.try_lock
358
+ begin
359
+ parts << "buffer_size=#{@messages.size}"
360
+ ensure
361
+ @buffer_mutex.unlock
362
+ end
363
+ else
364
+ parts << 'buffer_size=busy'
365
+ end
366
+
367
+ # Check if client is connected without triggering connection
368
+ parts << if @status.connected?
369
+ 'connected=true'
370
+ else
371
+ 'connected=false'
372
+ end
373
+
374
+ parts << "operations=#{@operations_in_progress.value}"
375
+ parts << 'in_transaction=true' if @transaction_mutex.locked?
376
+
377
+ "#<#{self.class.name}:#{format('%#x', object_id)} #{parts.join(' ')}>"
378
+ end
379
+
263
380
  private
264
381
 
265
382
  # Ensures that we don't run any operations when the producer is not configured or when it
@@ -3,5 +3,5 @@
3
3
  # WaterDrop library
4
4
  module WaterDrop
5
5
  # Current WaterDrop version
6
- VERSION = '2.8.5'
6
+ VERSION = '2.8.7'
7
7
  end
data/lib/waterdrop.rb CHANGED
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # External components
4
- # delegate should be removed because we don't need it, we just add it because of ruby-kafka
5
- %w[
6
- delegate
7
- forwardable
8
- json
9
- zeitwerk
10
- securerandom
11
- karafka-core
12
- pathname
13
- ].each { |lib| require lib }
4
+ require 'delegate'
5
+ require 'forwardable'
6
+ require 'json'
7
+ require 'zeitwerk'
8
+ require 'securerandom'
9
+ require 'karafka-core'
10
+ require 'pathname'
14
11
 
15
12
  # WaterDrop library
16
13
  module WaterDrop
data/renovate.json CHANGED
@@ -7,12 +7,24 @@
7
7
  "enabled": true,
8
8
  "pinDigests": true
9
9
  },
10
+ "includePaths": [
11
+ "Gemfile",
12
+ "waterdrop.gemspec",
13
+ "spec/integrations/**/Gemfile"
14
+ ],
10
15
  "packageRules": [
11
16
  {
12
17
  "matchManagers": [
13
18
  "github-actions"
14
19
  ],
15
20
  "minimumReleaseAge": "7 days"
21
+ },
22
+ {
23
+ "matchFileNames": [
24
+ "spec/integrations/**/Gemfile"
25
+ ],
26
+ "groupName": "integration test dependencies",
27
+ "commitMessageTopic": "integration test dependencies"
16
28
  }
17
29
  ]
18
30
  }
data/waterdrop.gemspec CHANGED
@@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.licenses = %w[LGPL-3.0-only Commercial]
18
18
 
19
19
  spec.add_dependency 'karafka-core', '>= 2.4.9', '< 3.0.0'
20
- spec.add_dependency 'karafka-rdkafka', '>= 0.19.2'
20
+ spec.add_dependency 'karafka-rdkafka', '>= 0.20.0'
21
21
  spec.add_dependency 'zeitwerk', '~> 2.3'
22
22
 
23
23
  spec.required_ruby_version = '>= 3.1.0'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: waterdrop
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.8.5
4
+ version: 2.8.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld
@@ -35,14 +35,14 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: 0.19.2
38
+ version: 0.20.0
39
39
  type: :runtime
40
40
  prerelease: false
41
41
  version_requirements: !ruby/object:Gem::Requirement
42
42
  requirements:
43
43
  - - ">="
44
44
  - !ruby/object:Gem::Version
45
- version: 0.19.2
45
+ version: 0.20.0
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: zeitwerk
48
48
  requirement: !ruby/object:Gem::Requirement
@@ -72,6 +72,7 @@ files:
72
72
  - ".github/ISSUE_TEMPLATE/feature_request.md"
73
73
  - ".github/workflows/ci.yml"
74
74
  - ".github/workflows/push.yml"
75
+ - ".github/workflows/trigger-wiki-refresh.yml"
75
76
  - ".github/workflows/verify-action-pins.yml"
76
77
  - ".gitignore"
77
78
  - ".rspec"
@@ -83,6 +84,7 @@ files:
83
84
  - LICENSE
84
85
  - README.md
85
86
  - Rakefile
87
+ - bin/integrations
86
88
  - bin/verify_kafka_warnings
87
89
  - bin/verify_topics_naming
88
90
  - config/locales/errors.yml
@@ -103,6 +105,7 @@ files:
103
105
  - lib/waterdrop/instrumentation/callbacks/error.rb
104
106
  - lib/waterdrop/instrumentation/callbacks/oauthbearer_token_refresh.rb
105
107
  - lib/waterdrop/instrumentation/callbacks/statistics.rb
108
+ - lib/waterdrop/instrumentation/idle_disconnector_listener.rb
106
109
  - lib/waterdrop/instrumentation/logger_listener.rb
107
110
  - lib/waterdrop/instrumentation/monitor.rb
108
111
  - lib/waterdrop/instrumentation/notifications.rb
@@ -147,7 +150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
150
  - !ruby/object:Gem::Version
148
151
  version: '0'
149
152
  requirements: []
150
- rubygems_version: 3.6.7
153
+ rubygems_version: 3.6.9
151
154
  specification_version: 4
152
155
  summary: Kafka messaging made easy!
153
156
  test_files: []