splitclient-rb 8.11.1 → 8.11.2.pre.rc1

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: f75f1490dbebf79ecb7799bb96a29f180ed5259036edbd4351b3db9f1f2e1cba
4
- data.tar.gz: e8f83fe01180e3a4a7487c82fd2a2c58cafdc971b951b9eb0c0c472351b00c4d
3
+ metadata.gz: 3f9a0707737415faaa93d90c63acefa18a154e72974150009071d8b7764735a4
4
+ data.tar.gz: 50fc9fe6dc2c51dd07bffc00b80a8f1fbcb82e7c2e51f2817666beaba5d8e5dc
5
5
  SHA512:
6
- metadata.gz: 7d8a983e3f53437d2749ac0a7f60b83e2d124001f396b19389ee19cb63d2cee404ef4253835586414a793fbb737c803869db51f2114b0931ea7f474aa9bdfc1d
7
- data.tar.gz: b6cc38e9b8d9d54b6d7858f02780059dc9b26dab95e9c022c5f92311621fc0057c4cddc1a1c2ea07b9831a27799ec5665cf5ef4b6cbc43dc0e27b4264179f51c
6
+ metadata.gz: 9ee3627c64357eb9ebd6ec7365371841387faa310d8bdcc8c012968a4274a50d7981551eb4e4bf632de562ff1141f9b4e2bf4ba504959bae3b54e1ef08519563
7
+ data.tar.gz: 5524bc84ef91500be12e09d9e457aec36c3d6058c37975ad2e6e7193fcbab45cd8cfbc8f99aa06e6ac2cb25696a3cfdb660c1a039003151809fba36c45b852c4
data/.gitignore CHANGED
@@ -56,3 +56,5 @@ dump.rdb
56
56
  release.sh
57
57
 
58
58
  .scannerwork
59
+ docs
60
+ examples
@@ -0,0 +1,14 @@
1
+ inputSet:
2
+ name: ruby-client
3
+ identifier: rubyclient
4
+ orgIdentifier: PROD
5
+ projectIdentifier: Harness_Split
6
+ pipeline:
7
+ identifier: rubyclientrepo
8
+ properties:
9
+ ci:
10
+ codebase:
11
+ build:
12
+ type: PR
13
+ spec:
14
+ number: <+trigger.prNumber>
@@ -0,0 +1,182 @@
1
+ pipeline:
2
+ name: ruby-client-repo
3
+ identifier: rubyclientrepo
4
+ projectIdentifier: Harness_Split
5
+ orgIdentifier: PROD
6
+ tags: {}
7
+ properties:
8
+ ci:
9
+ codebase:
10
+ connectorRef: fmegithubharnessgitops
11
+ repoName: ruby-client
12
+ build:
13
+ type: branch
14
+ spec:
15
+ branch: <+input>
16
+ stages:
17
+ - stage:
18
+ name: Build and Test
19
+ identifier: Build_and_Test
20
+ description: ""
21
+ type: CI
22
+ spec:
23
+ cloneCodebase: true
24
+ caching:
25
+ enabled: true
26
+ override: true
27
+ platform:
28
+ os: Linux
29
+ arch: Amd64
30
+ runtime:
31
+ type: Cloud
32
+ spec:
33
+ size: flex
34
+ execution:
35
+ steps:
36
+ - step:
37
+ type: Run
38
+ name: Install Ruby
39
+ identifier: Install_Ruby
40
+ spec:
41
+ shell: Bash
42
+ command: |-
43
+ curl https://mise.run | sh
44
+ export PATH="$HOME/.local/bin:$PATH"
45
+ mise use --global ruby@<+matrix.rubyVersion>
46
+ ruby --version
47
+ gem --version
48
+ - step:
49
+ type: Run
50
+ name: Start Redis
51
+ identifier: Start_Redis
52
+ spec:
53
+ shell: Sh
54
+ command: |-
55
+ apt-get install -y redis-server -qq
56
+ redis-server --daemonize yes --port 6379
57
+ redis-cli ping
58
+ - step:
59
+ type: Run
60
+ name: Wait for Redis
61
+ identifier: Wait_for_Redis
62
+ spec:
63
+ shell: Sh
64
+ command: |-
65
+ for i in $(seq 1 15); do
66
+ redis-cli ping && break
67
+ echo "Waiting for Redis... attempt $i"
68
+ sleep 1
69
+ done
70
+ - step:
71
+ type: Run
72
+ name: Install Dependencies
73
+ identifier: Install_Dependencies
74
+ spec:
75
+ shell: Bash
76
+ command: |-
77
+ export PATH="$HOME/.local/bin:$PATH"
78
+ eval "$(mise activate bash)"
79
+ gem install bundler:2.4.19 --no-document
80
+ bundle install --jobs 4 --retry 3
81
+ - step:
82
+ type: Run
83
+ name: Run Tests
84
+ identifier: Run_Tests
85
+ spec:
86
+ shell: Bash
87
+ command: |-
88
+ export PATH="$HOME/.local/bin:$PATH"
89
+ eval "$(mise activate bash)"
90
+ bundle exec rake
91
+ - step:
92
+ type: Run
93
+ name: Fix Coverage Paths
94
+ identifier: Fix_Coverage_Paths
95
+ when:
96
+ stageStatus: Success
97
+ condition: <+matrix.rubyVersion> == "3.2.2"
98
+ spec:
99
+ shell: Bash
100
+ command: |-
101
+ export PATH="$HOME/.local/bin:$PATH"
102
+ eval "$(mise activate bash)"
103
+ cd coverage
104
+ sed -i "s@${HARNESS_WORKSPACE}@/harness/workspace/@g" .resultset.json
105
+ ruby -rjson -e '
106
+ sqube = JSON.load(File.read(".resultset.json"))["RSpec"]["coverage"].transform_values {|lines| lines["lines"]}
107
+ total = { "RSpec" => { "coverage" => sqube, "timestamp" => Time.now.to_i }}
108
+ puts JSON.dump(total)
109
+ ' > .resultset.sonarqube.json
110
+ - step:
111
+ type: Run
112
+ name: SonarQube Scan
113
+ identifier: SonarQube_Scan
114
+ when:
115
+ stageStatus: Success
116
+ condition: <+matrix.rubyVersion> == "3.2.2"
117
+ spec:
118
+ shell: Sh
119
+ command: |-
120
+ export SONAR_SCANNER_VERSION=5.0.1.3006
121
+ curl -sSLo sonar-scanner.zip \
122
+ "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-linux.zip"
123
+ unzip -q sonar-scanner.zip
124
+ export PATH="$PWD/sonar-scanner-${SONAR_SCANNER_VERSION}-linux/bin:$PATH"
125
+ apt-get install -y openjdk-17-jdk -qq
126
+ sonar-scanner \
127
+ -Dsonar.projectKey=ruby-client \
128
+ -Dsonar.sources=lib \
129
+ -Dsonar.tests=spec \
130
+ -Dsonar.host.url=https://sonar.harness.io \
131
+ -Dsonar.token=<+secrets.getValue("sonarqube-token")> \
132
+ -Dsonar.ruby.coverage.reportPaths=coverage/.resultset.sonarqube.json \
133
+ -Dsonar.coverage.exclusions="spec/**,**/vendor/**" \
134
+ -Dsonar.exclusions="**/vendor/**,**/.git/**"
135
+ - step:
136
+ type: Run
137
+ name: Post Quality Gate
138
+ identifier: Post_Quality_Gate
139
+ when:
140
+ stageStatus: Success
141
+ condition: <+matrix.rubyVersion> == "3.2.2"
142
+ spec:
143
+ shell: Bash
144
+ command: |-
145
+ #!/bin/bash
146
+ set -e
147
+
148
+ SONAR_TOKEN="<+secrets.getValue("sonarqube-token")>"
149
+ SONAR_URL="https://sonar.harness.io"
150
+ SONAR_PROJECT_KEY="ruby-client"
151
+ GITHUB_TOKEN="<+secrets.getValue("github-devops-token")>"
152
+ GITHUB_REPO="ruby-client"
153
+ COMMIT_SHA="<+codebase.commitSha>"
154
+
155
+ STATUS_JSON=$(curl -sf \
156
+ -H "Authorization: Bearer ${SONAR_TOKEN}" \
157
+ "${SONAR_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}")
158
+
159
+ QG_STATUS=$(echo "$STATUS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['projectStatus']['status'])")
160
+
161
+ case "$QG_STATUS" in
162
+ OK) GH_STATE="success"; DESCRIPTION="Quality gate passed" ;;
163
+ ERROR) GH_STATE="failure"; DESCRIPTION="Quality gate failed" ;;
164
+ WARN) GH_STATE="success"; DESCRIPTION="Quality gate passed with warnings" ;;
165
+ *) GH_STATE="pending"; DESCRIPTION="Quality gate status unknown" ;;
166
+ esac
167
+
168
+ TARGET_URL="${SONAR_URL}/dashboard?id=${SONAR_PROJECT_KEY}"
169
+
170
+ curl -sf -X POST \
171
+ -H "Authorization: token ${GITHUB_TOKEN}" \
172
+ -H "Accept: application/vnd.github.v3+json" \
173
+ -H "Content-Type: application/json" \
174
+ -d "{\"state\":\"${GH_STATE}\",\"description\":\"${DESCRIPTION}\",\"context\":\"sonarqube/quality-gate\",\"target_url\":\"${TARGET_URL}\"}" \
175
+ "https://api.github.com/repos/splitio/${GITHUB_REPO}/statuses/${COMMIT_SHA}"
176
+
177
+ echo "Posted SonarQube quality gate status: ${GH_STATE}"
178
+ strategy:
179
+ matrix:
180
+ rubyVersion:
181
+ - "2.7.8"
182
+ - "3.2.2"
data/.rubocop.yml CHANGED
@@ -25,7 +25,7 @@ Metrics/ParameterLists:
25
25
  Exclude:
26
26
  - lib/splitclient-rb/engine/sync_manager.rb
27
27
 
28
- Metrics/LineLength:
28
+ Layout/LineLength:
29
29
  Max: 135
30
30
 
31
31
  Metrics/BlockLength:
@@ -10,9 +10,25 @@ module SplitIoClient
10
10
  @api_key = api_key
11
11
  @back_off = Engine::BackOff.new(@config.auth_retry_back_off_base, 1)
12
12
  @telemetry_runtime_producer = telemetry_runtime_producer
13
+ # Serializes start_sse: it is reachable from the push status handler thread and
14
+ # from the token refresh thread concurrently.
15
+ @start_sse_mutex = Mutex.new
13
16
  end
14
17
 
15
18
  def start_sse
19
+ @start_sse_mutex.synchronize { start_sse_unsynchronized }
20
+ end
21
+
22
+ def stop_sse
23
+ @sse_handler.stop
24
+ SplitIoClient::Helpers::ThreadHelper.stop(:schedule_next_token_refresh, @config)
25
+ rescue StandardError => e
26
+ @config.logger.error(e.inspect)
27
+ end
28
+
29
+ private
30
+
31
+ def start_sse_unsynchronized
16
32
  response = @auth_api_client.authenticate(@api_key)
17
33
  @config.logger.debug("Auth service response push_enabled: #{response[:push_enabled]}") if @config.debug_enabled
18
34
 
@@ -33,18 +49,14 @@ module SplitIoClient
33
49
  true
34
50
  rescue StandardError => e
35
51
  @config.logger.error("start_sse: #{e.inspect}")
52
+ false
36
53
  end
37
54
 
38
- def stop_sse
39
- @sse_handler.stop
40
- SplitIoClient::Helpers::ThreadHelper.stop(:schedule_next_token_refresh, @config)
41
- rescue StandardError => e
42
- @config.logger.error(e.inspect)
43
- end
44
-
45
- private
46
-
47
55
  def schedule_next_token_refresh(time)
56
+ # Reap first: start_sse is reachable both from the push status handler thread and
57
+ # from refresh_token_task itself, so without this an orphaned timer thread can
58
+ # survive with no handle to cancel it and fire its own reconnect later.
59
+ SplitIoClient::Helpers::ThreadHelper.reap(:schedule_next_token_refresh, @config)
48
60
  @config.threads[:schedule_next_token_refresh] = Thread.new { refresh_token_task(time) }
49
61
  end
50
62
 
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SplitIoClient
4
+ module Engine
5
+ # Governs how aggressively streaming reconnects are attempted.
6
+ #
7
+ # Extracted for FME-18143, where a customer's SDK reconnected up to ~64 times per
8
+ # second and accumulated ~10,000 threads. Two properties matter here:
9
+ #
10
+ # * A connection only earns a back off reset if it stayed up long enough to count
11
+ # as a genuine success. Resetting on every PUSH_CONNECTED meant a connection
12
+ # that lived for milliseconds still produced a zero second retry.
13
+ # * Only one reconnect may be in flight at a time, and errors describing an
14
+ # already-replaced connection must not each start another one.
15
+ class ReconnectPolicy
16
+ # How long a connection must survive before it counts as stable.
17
+ MIN_UPTIME_FOR_BACKOFF_RESET = 30
18
+
19
+ def initialize(config, status_queue, back_off = nil)
20
+ @config = config
21
+ @status_queue = status_queue
22
+ @back_off = back_off || Engine::BackOff.new(1, 3)
23
+ @connected_at = Concurrent::AtomicReference.new(nil)
24
+ @reconnecting = Concurrent::AtomicBoolean.new(false)
25
+ end
26
+
27
+ # Monotonic clock: a backwards wall-clock jump (NTP correction, container
28
+ # migration) must not make uptime go negative and permanently block the
29
+ # back off reset.
30
+ def connected
31
+ @connected_at.set(monotonic_now)
32
+ end
33
+
34
+ # Whether a disconnect should be acted on at all. Returns false for a disconnect
35
+ # of an already-disconnected stream, and for a reconnect when one is already in
36
+ # flight (typically driven by the token refresh thread) -- running two
37
+ # concurrently is what multiplied connection attempts.
38
+ def actionable?(sse_connected, reconnect)
39
+ unless sse_connected || reconnect
40
+ log_debug('Streaming already disconnected.')
41
+ return false
42
+ end
43
+ return true unless reconnect && !acquire
44
+
45
+ log_debug('Streaming reconnect already in progress, ignoring.')
46
+ false
47
+ end
48
+
49
+ # Returns true only for the caller that acquired the right to reconnect; a
50
+ # concurrent caller gets false. AtomicBoolean#make_true is the compare-and-set:
51
+ # it returns true only for the transition from false to true.
52
+ def acquire
53
+ @reconnecting.make_true
54
+ end
55
+
56
+ def release
57
+ @reconnecting.make_false
58
+ end
59
+
60
+ # Resets the back off only if the connection that just dropped was stable, so a
61
+ # flapping connection keeps backing off instead of retrying immediately.
62
+ def record_disconnect
63
+ connected_at = @connected_at.get_and_set(nil)
64
+ return if connected_at.nil?
65
+
66
+ uptime = monotonic_now - connected_at
67
+ return if uptime < MIN_UPTIME_FOR_BACKOFF_RESET
68
+
69
+ log_debug("Streaming connection was up for #{uptime.round(1)}s, resetting back off.")
70
+ @back_off.reset
71
+ end
72
+
73
+ def interval
74
+ @back_off.interval
75
+ end
76
+
77
+ # Drops queued retryable errors, which refer to the connection just replaced.
78
+ # Acting on each of them would start one more reconnect apiece. Other statuses
79
+ # are preserved in order.
80
+ def discard_stale_errors
81
+ kept = []
82
+ discarded = 0
83
+
84
+ until @status_queue.empty?
85
+ status = @status_queue.pop(true)
86
+ if status == Constants::PUSH_RETRYABLE_ERROR
87
+ discarded += 1
88
+ else
89
+ kept << status
90
+ end
91
+ end
92
+
93
+ kept.each { |kept_status| @status_queue.push(kept_status) }
94
+ log_debug("Discarded #{discarded} stale streaming errors.") if discarded.positive?
95
+ discarded
96
+ rescue ThreadError
97
+ discarded
98
+ end
99
+
100
+ private
101
+
102
+ def log_debug(message)
103
+ @config.logger.debug(message) if @config.debug_enabled
104
+ end
105
+
106
+ def monotonic_now
107
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -17,7 +17,7 @@ module SplitIoClient
17
17
  @push_manager = push_manager
18
18
  @status_queue = status_queue
19
19
  @sse_connected = Concurrent::AtomicBoolean.new(false)
20
- @back_off = Engine::BackOff.new(1, 3)
20
+ @reconnect_policy = Engine::ReconnectPolicy.new(config, status_queue)
21
21
  end
22
22
 
23
23
  def start
@@ -110,30 +110,37 @@ module SplitIoClient
110
110
  @synchronizer.start_periodic_fetch
111
111
  record_telemetry(Telemetry::Domain::Constants::SYNC_MODE, SYNC_MODE_POLLING)
112
112
  rescue StandardError => e
113
- @config.logger.error("process_connected error: #{e.inspect}")
113
+ @config.logger.error("process_forced_stop error: #{e.inspect}")
114
114
  end
115
115
 
116
116
  def process_disconnect(reconnect)
117
- unless @sse_connected.value || reconnect
118
- @config.logger.debug('Streaming already disconnected.') if @config.debug_enabled
119
- return
120
- end
117
+ return unless @reconnect_policy.actionable?(@sse_connected.value, reconnect)
121
118
 
119
+ @reconnect_policy.record_disconnect
122
120
  @sse_connected.make_false
123
121
  @sse_handler.stop_workers
124
122
  @synchronizer.start_periodic_fetch
125
123
  record_telemetry(Telemetry::Domain::Constants::SYNC_MODE, SYNC_MODE_POLLING)
126
124
 
127
- if reconnect
128
- wait_interval = @back_off.interval
129
- @config.logger.debug("Retrying streaming connection in: #{wait_interval} seconds")
130
- sleep(wait_interval)
131
- @push_manager.stop_sse
132
- @synchronizer.sync_all
133
- @push_manager.start_sse
134
- end
125
+ reconnect_streaming if reconnect
135
126
  rescue StandardError => e
136
127
  @config.logger.error("process_disconnect error: #{e.inspect}")
128
+ @reconnect_policy.release if reconnect
129
+ end
130
+
131
+ def reconnect_streaming
132
+ wait_interval = @reconnect_policy.interval
133
+ log_if_debug("Retrying streaming connection in: #{wait_interval} seconds")
134
+ sleep(wait_interval)
135
+ @push_manager.stop_sse
136
+ @synchronizer.sync_all
137
+
138
+ # Only discard queued errors once streaming is actually back up. If the retry
139
+ # failed, those errors are still the only thing that will drive the next
140
+ # attempt, so they must be left alone.
141
+ @reconnect_policy.discard_stale_errors if @push_manager.start_sse
142
+ ensure
143
+ @reconnect_policy.release
137
144
  end
138
145
 
139
146
  def record_telemetry(type, data)
@@ -148,36 +155,39 @@ module SplitIoClient
148
155
  end
149
156
 
150
157
  def incoming_push_status_handler
158
+ # Rescue lives inside the loop: a bug in any handler (including one
159
+ # that lacks its own rescue) would otherwise exit the loop and
160
+ # permanently stop the SDK from reacting to push status changes.
151
161
  while (status = @status_queue.pop)
152
- @config.logger.debug("Push status handler dequeue #{status}") if @config.debug_enabled
153
-
154
- case status
155
- when Constants::PUSH_CONNECTED
156
- @back_off.reset
157
- process_connected
158
- when Constants::PUSH_RETRYABLE_ERROR
159
- process_disconnect(true)
160
- when Constants::PUSH_FORCED_STOP
161
- process_forced_stop
162
- when Constants::PUSH_NONRETRYABLE_ERROR
163
- process_disconnect(false)
164
- when Constants::PUSH_SUBSYSTEM_DOWN
165
- process_subsystem_down
166
- when Constants::PUSH_SUBSYSTEM_READY
167
- process_subsystem_ready
168
- when Constants::PUSH_SUBSYSTEM_OFF
169
- process_push_shutdown
170
- else
171
- log_if_debug('Incorrect push status type.')
162
+ begin
163
+ @config.logger.debug("Push status handler dequeue #{status}") if @config.debug_enabled
164
+ case status
165
+ when Constants::PUSH_CONNECTED
166
+ # Deliberately not resetting the back off here. Whether this
167
+ # connection earns a reset depends on how long it survives,
168
+ # which is only known once it drops -- see
169
+ # ReconnectPolicy#record_disconnect.
170
+ @reconnect_policy.connected
171
+ process_connected
172
+ when Constants::PUSH_RETRYABLE_ERROR then process_disconnect(true)
173
+ when Constants::PUSH_FORCED_STOP then process_forced_stop
174
+ when Constants::PUSH_NONRETRYABLE_ERROR then process_disconnect(false)
175
+ when Constants::PUSH_SUBSYSTEM_DOWN then process_subsystem_down
176
+ when Constants::PUSH_SUBSYSTEM_READY then process_subsystem_ready
177
+ when Constants::PUSH_SUBSYSTEM_OFF then process_push_shutdown
178
+ else log_if_debug('Incorrect push status type.')
179
+ end
180
+ rescue StandardError => e
181
+ @config.logger.error("Push status handler error for #{status}: #{e.inspect}")
172
182
  end
173
183
  end
174
- rescue StandardError => e
175
- @config.logger.error("Push status handler error: #{e.inspect}")
176
184
  end
177
- end
178
185
 
179
- def log_if_debug(msg)
180
- @config.logger.debug(msg) if @config.debug_enabled
186
+ # Was defined on the enclosing module, so every call raised NoMethodError and
187
+ # killed the push status handler thread (its rescue sits outside the loop).
188
+ def log_if_debug(msg)
189
+ @config.logger.debug(msg) if @config.debug_enabled
190
+ end
181
191
  end
182
192
  end
183
193
  end
@@ -6,6 +6,10 @@ module SplitIoClient
6
6
  def self.stop(thread_sym, config)
7
7
  thread = config.threads[thread_sym]
8
8
 
9
+ # Never kill the calling thread: stop_sse is reachable from inside the token
10
+ # refresh thread, which would otherwise terminate itself mid-flight.
11
+ return if thread == Thread.current
12
+
9
13
  unless thread.nil?
10
14
  config.logger.debug("Stopping #{thread_sym} thread...") if config.debug_enabled
11
15
  Thread.kill(thread)
@@ -14,6 +18,19 @@ module SplitIoClient
14
18
  config.logger.error(e.inspect)
15
19
  end
16
20
 
21
+ # Terminates the thread currently held in +thread_sym+ before its handle is
22
+ # replaced. Thread handles live in a single slot per symbol, so overwriting one
23
+ # while the thread is still running makes it unreachable and leaks it forever.
24
+ def self.reap(thread_sym, config, timeout = 1)
25
+ thread = config.threads[thread_sym]
26
+ return if thread.nil? || thread == Thread.current || !thread.alive?
27
+
28
+ config.logger.warn("#{thread_sym} thread is still alive, terminating it before starting a new one.")
29
+ Thread.kill(thread) unless thread.join(timeout)
30
+ rescue StandardError => e
31
+ config.logger.error(e.inspect)
32
+ end
33
+
17
34
  def self.alive?(thread_sym, config)
18
35
  thread = config.threads[thread_sym]
19
36
 
@@ -32,20 +32,36 @@ module SplitIoClient
32
32
  @status_queue = status_queue
33
33
  @read_timeout = read_timeout
34
34
  @connected = Concurrent::AtomicBoolean.new(false)
35
- @first_event = Concurrent::AtomicBoolean.new(true)
35
+ # Set while close is tearing a connection down on purpose, so the reader
36
+ # thread does not report the resulting socket error as a retryable failure.
37
+ @shutdown = Concurrent::AtomicBoolean.new(false)
38
+ # Incremented for every connection attempt. A reader thread whose generation
39
+ # is no longer current has been superseded and must exit without side effects.
40
+ @generation = Concurrent::AtomicFixnum.new(0)
36
41
  @socket = nil
42
+ # Serialises concurrent close calls so the second caller does not
43
+ # re-push the status or double-close a socket the first caller
44
+ # already claimed.
45
+ @close_mutex = Mutex.new
37
46
  end
38
47
 
39
48
  def close(status = nil)
40
- return if @socket.nil?
41
-
42
- @config.logger.debug("Closing SSEClient socket") if @config.debug_enabled
43
- push_status(status)
44
- @connected.make_false
45
- @socket.sync_close = true if @socket.is_a? OpenSSL::SSL::SSLSocket
46
- @socket.close
47
- @config.logger.debug("SSEClient socket state #{@socket.state}") if @socket.is_a?(OpenSSL::SSL::SSLSocket) && @config.debug_enabled
48
- @socket = nil
49
+ socket = nil
50
+ @close_mutex.synchronize do
51
+ return if @socket.nil?
52
+
53
+ @config.logger.debug('Closing SSEClient socket') if @config.debug_enabled
54
+ @shutdown.make_true
55
+ push_status(status)
56
+ @connected.make_false
57
+
58
+ socket = @socket
59
+ @socket = nil
60
+ end
61
+
62
+ socket.sync_close = true if socket.is_a? OpenSSL::SSL::SSLSocket
63
+ socket.close
64
+ @config.logger.debug("SSEClient socket state #{socket.state}") if socket.is_a?(OpenSSL::SSL::SSLSocket) && @config.debug_enabled
49
65
  rescue StandardError => e
50
66
  @config.logger.error("SSEClient close Error: #{e.inspect}")
51
67
  end
@@ -60,7 +76,10 @@ module SplitIoClient
60
76
  latch = Concurrent::CountDownLatch.new(1)
61
77
  connect_thread(latch)
62
78
 
63
- return false unless latch.wait(CONNECT_TIMEOUT)
79
+ unless latch.wait(CONNECT_TIMEOUT)
80
+ @config.logger.warn("SSE connection attempt did not complete within #{CONNECT_TIMEOUT} seconds.")
81
+ return false
82
+ end
64
83
 
65
84
  connected?
66
85
  rescue StandardError => e
@@ -75,95 +94,143 @@ module SplitIoClient
75
94
  private
76
95
 
77
96
  def connect_thread(latch)
97
+ # Never orphan the previous reader thread: its handle lives in a single slot
98
+ # and would otherwise become unreachable, leaking the thread forever.
99
+ Helpers::ThreadHelper.reap(:connect_stream, @config)
100
+
101
+ generation = @generation.increment
102
+ @shutdown.make_false
103
+
78
104
  @config.threads[:connect_stream] = Thread.new do
79
105
  @config.logger.info('Starting connect_stream thread ...')
80
- new_status = connect_stream(latch)
106
+ new_status = connect_stream(latch, generation)
81
107
  push_status(new_status) unless new_status.nil?
82
108
  @config.logger.info('connect_stream thread finished.')
83
109
  end
84
110
  end
85
111
 
86
- def connect_stream(latch)
87
- return Constants::PUSH_RETRYABLE_ERROR unless socket_write(latch)
88
- while connected? || @first_event.value
89
- begin
90
- if IO.select([@socket], nil, nil, @read_timeout)
112
+ # Each reader thread owns the socket it opened. Nothing about the connection is
113
+ # shared with later attempts, so a superseded thread can neither read from nor
114
+ # steal bytes off a socket belonging to a newer connection.
115
+ def connect_stream(latch, generation = nil)
116
+ generation ||= @generation.value
117
+ socket = open_socket(latch)
118
+ return retryable_error(generation) if socket.nil?
119
+
120
+ first_event = true
121
+
122
+ while current_generation?(generation)
123
+ begin
124
+ if IO.select([socket], nil, nil, @read_timeout)
91
125
  begin
92
- partial_data = @socket.readpartial(10_000)
126
+ partial_data = socket.readpartial(10_000)
93
127
 
94
- first_event_status = read_first_event(partial_data, latch)
95
- return first_event_status unless first_event_status.nil?
128
+ if first_event
129
+ first_event = false
130
+ first_event_status = read_first_event(partial_data, latch, generation)
131
+ return first_event_status unless first_event_status.nil?
132
+ end
96
133
  rescue IO::WaitReadable => e
97
134
  @config.logger.debug("SSE client IO::WaitReadable transient error: #{e.inspect}") if @config.debug_enabled
98
- IO.select([@socket], nil, nil, @read_timeout)
135
+ IO.select([socket], nil, nil, @read_timeout)
99
136
  retry
100
- rescue Errno::EAGAIN => e
137
+ rescue Errno::EAGAIN => e
101
138
  @config.logger.debug("SSE client transient error: #{e.inspect}") if @config.debug_enabled
102
- IO.select([@socket], nil, nil, @read_timeout)
139
+ IO.select([socket], nil, nil, @read_timeout)
103
140
  retry
104
141
  rescue Errno::ETIMEDOUT => e
105
142
  @config.logger.error("SSE read operation timed out!: #{e.inspect}")
106
- return Constants::PUSH_RETRYABLE_ERROR
143
+ return retryable_error(generation)
107
144
  rescue EOFError => e
108
145
  @config.logger.error("SSE read operation EOF, server closed the connection, will reconnect: #{e.inspect}")
109
- return Constants::PUSH_RETRYABLE_ERROR
146
+ return retryable_error(generation)
110
147
  rescue Errno::EBADF, IOError => e
111
148
  @config.logger.error("SSE read operation EBADF or IOError: #{e.inspect}")
112
- return Constants::PUSH_RETRYABLE_ERROR
149
+ return retryable_error(generation)
113
150
  rescue StandardError => e
114
151
  @config.logger.error("SSE read operation StandardError: #{e.inspect}")
115
152
  return nil if ENV['SPLITCLIENT_ENV'] == 'test'
116
153
 
117
154
  @config.logger.error("Error reading partial data: #{e.inspect}")
118
- return Constants::PUSH_RETRYABLE_ERROR
155
+ return retryable_error(generation)
119
156
  end
120
157
  else
121
- @config.logger.error("SSE read operation timed out, no data available.")
122
- return Constants::PUSH_RETRYABLE_ERROR
158
+ @config.logger.error('SSE read operation timed out, no data available.')
159
+ return retryable_error(generation)
123
160
  end
124
- rescue Exception => e
125
- @config.logger.debug("SSE socket is not connected: #{e.inspect}") if @config.debug_enabled
126
- return Constants::PUSH_RETRYABLE_ERROR
161
+ # Was `rescue Exception` logging at debug level only. That combination hid
162
+ # the reason for ~14,880 of ~15,000 reconnects during the FME-18143 incident.
163
+ rescue StandardError => e
164
+ @config.logger.warn("SSE socket is not connected: #{e.inspect}")
165
+ return retryable_error(generation)
127
166
  end
128
167
 
129
168
  process_data(partial_data)
130
169
  end
131
- @config.logger.info("SSE read operation exited: #{connected?}")
132
170
 
171
+ @config.logger.debug('connect_stream superseded by a newer connection, exiting.') if @config.debug_enabled
133
172
  nil
134
173
  end
135
174
 
136
- def socket_write(latch)
137
- @first_event.make_true
138
- @socket = socket_connect
139
- @socket.puts(build_request(@uri))
140
- true
175
+ # Single decision point for "should this thread ask SyncManager to reconnect?".
176
+ # An intentional close, or a connection that has already been superseded, must
177
+ # stay silent -- reporting either as a retryable error is what turned one
178
+ # dropped connection into a self-sustaining reconnect storm.
179
+ def retryable_error(generation)
180
+ unless report_error?(generation)
181
+ @config.logger.debug('SSE connection ended intentionally, not requesting a reconnect.') if @config.debug_enabled
182
+ return nil
183
+ end
184
+
185
+ Constants::PUSH_RETRYABLE_ERROR
186
+ end
187
+
188
+ def report_error?(generation)
189
+ current_generation?(generation) && !@shutdown.value
190
+ end
191
+
192
+ def current_generation?(generation)
193
+ generation == @generation.value
194
+ end
195
+
196
+ def open_socket(latch)
197
+ socket = socket_connect
198
+ raise IOError, "unable to open socket to #{@uri.host}" if socket.nil?
199
+
200
+ @socket = socket
201
+ socket.puts(build_request(@uri))
202
+ socket
141
203
  rescue StandardError => e
142
204
  @config.logger.error("Error during connecting to #{@uri.host}. Error: #{e.inspect}")
205
+ close_quietly(socket)
206
+ @socket = nil if @socket.equal?(socket)
143
207
  latch.count_down
144
- false
208
+ nil
145
209
  end
146
210
 
147
- def read_first_event(data, latch)
148
- return unless @first_event.value
149
-
211
+ def read_first_event(data, latch, generation = nil)
212
+ generation ||= @generation.value
150
213
  response_code = @event_parser.first_event(data)
151
214
  @config.logger.debug("SSE client first event code: #{response_code}") if @config.debug_enabled
152
215
 
153
- @first_event.make_false
154
-
155
216
  if response_code != OK_CODE
156
217
  @config.logger.error("SSE first event failed, code: #{response_code}")
157
218
  latch.count_down
158
- return Constants::PUSH_RETRYABLE_ERROR
219
+ return retryable_error(generation)
220
+ end
221
+
222
+ # Only the connection that is still current may announce itself as connected.
223
+ if current_generation?(generation)
224
+ @connected.make_true
225
+ @config.logger.debug('SSE client first event Connected is true') if @config.debug_enabled
226
+ @telemetry_runtime_producer.record_streaming_event(Telemetry::Domain::Constants::SSE_CONNECTION_ESTABLISHED, nil)
227
+ push_status(Constants::PUSH_CONNECTED)
228
+ elsif @config.debug_enabled
229
+ @config.logger.debug('Ignoring first event from a superseded SSE connection.')
159
230
  end
160
231
 
161
- @connected.make_true
162
- @config.logger.debug("SSE client first event Connected is true") if @config.debug_enabled
163
- @telemetry_runtime_producer.record_streaming_event(Telemetry::Domain::Constants::SSE_CONNECTION_ESTABLISHED, nil)
164
- push_status(Constants::PUSH_CONNECTED)
165
232
  latch.count_down
166
- return nil
233
+ nil
167
234
  end
168
235
 
169
236
  def socket_connect
@@ -184,9 +251,11 @@ module SplitIoClient
184
251
  retry
185
252
  end
186
253
  return ssl_socket
187
-
188
- rescue Exception => e
254
+ rescue StandardError => e
189
255
  @config.logger.error("socket connect error: #{e.inspect}")
256
+ # Without this the underlying descriptor is leaked on every failed
257
+ # attempt, which exhausts file descriptors during a reconnect storm.
258
+ close_quietly(tcp_socket)
190
259
  return nil
191
260
  end
192
261
  end
@@ -194,6 +263,12 @@ module SplitIoClient
194
263
  tcp_socket
195
264
  end
196
265
 
266
+ def close_quietly(socket)
267
+ socket.close unless socket.nil? || socket.closed?
268
+ rescue StandardError => e
269
+ @config.logger.debug("Error closing socket: #{e.inspect}") if @config.debug_enabled
270
+ end
271
+
197
272
  def process_data(partial_data)
198
273
  @config.logger.debug("Event partial data: #{partial_data}") if @config.debug_enabled
199
274
  return if partial_data.nil? || partial_data == KEEP_ALIVE_RESPONSE
@@ -247,7 +322,7 @@ module SplitIoClient
247
322
 
248
323
  def push_status(status)
249
324
  return if status.nil?
250
-
325
+
251
326
  @config.logger.debug("Pushing new sse status: #{status}") if @config.debug_enabled
252
327
  @status_queue.push(status)
253
328
  end
@@ -1,3 +1,3 @@
1
1
  module SplitIoClient
2
- VERSION = '8.11.1'
2
+ VERSION = '8.11.2.pre.rc1'
3
3
  end
@@ -126,6 +126,7 @@ require 'splitclient-rb/engine/models/valid_sdk_event.rb'
126
126
  require 'splitclient-rb/engine/models/event_active_subscriptions.rb'
127
127
  require 'splitclient-rb/engine/auth_api_client'
128
128
  require 'splitclient-rb/engine/back_off'
129
+ require 'splitclient-rb/engine/reconnect_policy'
129
130
  require 'splitclient-rb/engine/fallback_treatment_calculator.rb'
130
131
  require 'splitclient-rb/engine/push_manager'
131
132
  require 'splitclient-rb/engine/status_manager'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: splitclient-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.11.1
4
+ version: 8.11.2.pre.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Split Software
@@ -383,11 +383,9 @@ extensions:
383
383
  - ext/murmurhash/extconf.rb
384
384
  extra_rdoc_files: []
385
385
  files:
386
- - ".github/CODEOWNERS"
387
- - ".github/pull_request_template.md"
388
- - ".github/workflows/ci.yml"
389
- - ".github/workflows/update-license-year.yml"
390
386
  - ".gitignore"
387
+ - ".harness/orgs/PROD/projects/Harness_Split/pipelines/rubyclientrepo/input_sets/rubyclient.yaml"
388
+ - ".harness/pipeline.yaml"
391
389
  - ".rubocop.yml"
392
390
  - ".simplecov"
393
391
  - CHANGES.txt
@@ -518,6 +516,7 @@ files:
518
516
  - lib/splitclient-rb/engine/parser/evaluator.rb
519
517
  - lib/splitclient-rb/engine/parser/partition.rb
520
518
  - lib/splitclient-rb/engine/push_manager.rb
519
+ - lib/splitclient-rb/engine/reconnect_policy.rb
521
520
  - lib/splitclient-rb/engine/status_manager.rb
522
521
  - lib/splitclient-rb/engine/sync_manager.rb
523
522
  - lib/splitclient-rb/engine/synchronizer.rb
data/.github/CODEOWNERS DELETED
@@ -1 +0,0 @@
1
- * @splitio/sdk
@@ -1,9 +0,0 @@
1
- # Ruby SDK
2
-
3
- ## What did you accomplish?
4
-
5
-
6
- ## How to test new changes?
7
-
8
-
9
- ## Extra Notes
@@ -1,90 +0,0 @@
1
- name: ci
2
- on:
3
- pull_request:
4
- branches:
5
- - development
6
- - master
7
- push:
8
- branches:
9
- - development
10
- - master
11
-
12
- concurrency:
13
- group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
14
- cancel-in-progress: true
15
-
16
- jobs:
17
- test:
18
- name: Test
19
- runs-on: ubuntu-latest
20
- services:
21
- redis:
22
- image: redis
23
- ports:
24
- - 6379:6379
25
- strategy:
26
- fail-fast: false
27
- matrix:
28
- version:
29
- - '2.5.0'
30
- - '3.2.2'
31
-
32
- steps:
33
- - name: Checkout code
34
- uses: actions/checkout@v3
35
- with:
36
- fetch-depth: 0
37
-
38
- - name: Set up Java
39
- uses: actions/setup-java@v2
40
- with:
41
- java-version: 17
42
- distribution: "temurin"
43
-
44
- - name: Setup Ruby ${{ matrix.version }}
45
- uses: ruby/setup-ruby@v1
46
- with:
47
- ruby-version: ${{ matrix.version }}
48
-
49
- - name: Install dependencies
50
- run: bundle install
51
-
52
- - name: Run tests
53
- run: bundle exec rake
54
-
55
- - name: Fix code coverage paths
56
- working-directory: coverage
57
- run: |
58
- sed -i 's@'$GITHUB_WORKSPACE'@/github/workspace/@g' .resultset.json
59
- ruby -rjson -e 'sqube = JSON.load(File.read(".resultset.json"))["RSpec"]["coverage"].transform_values {|lines| lines["lines"]}; total = { "RSpec" => { "coverage" => sqube, "timestamp" => Time.now.to_i }}; puts JSON.dump(total)' > .resultset.sonarqube.json
60
-
61
- - name: Set VERSION env
62
- run: echo "VERSION=$(cat lib/splitclient-rb/version.rb | grep VERSION | awk -F "'" '{print $2}')" >> $GITHUB_ENV
63
-
64
- - name: SonarQube Scan (Push)
65
- if: matrix.version == '3.2.2' && github.event_name == 'push'
66
- uses: SonarSource/sonarcloud-github-action@v5.0.0
67
- env:
68
- SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
69
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
70
- with:
71
- projectBaseDir: .
72
- args: >
73
- -Dsonar.host.url=${{ secrets.SONARQUBE_HOST }}
74
- -Dsonar.projectVersion=${{ env.VERSION }}
75
-
76
- - name: SonarQube Scan (Pull Request)
77
- if: matrix.version == '3.2.2' && github.event_name == 'pull_request'
78
- uses: SonarSource/sonarcloud-github-action@v5.0.0
79
- env:
80
- SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
81
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
82
- with:
83
- projectBaseDir: .
84
- args: >
85
- -Dsonar.java.source=17
86
- -Dsonar.host.url=${{ secrets.SONARQUBE_HOST }}
87
- -Dsonar.projectVersion=${{ env.VERSION }}
88
- -Dsonar.pullrequest.key=${{ github.event.pull_request.number }}
89
- -Dsonar.pullrequest.branch=${{ github.event.pull_request.head.ref }}
90
- -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }}
@@ -1,45 +0,0 @@
1
- name: Update License Year
2
-
3
- on:
4
- schedule:
5
- - cron: "0 3 1 1 *" # 03:00 AM on January 1
6
-
7
- permissions:
8
- contents: write
9
- pull-requests: write
10
-
11
- jobs:
12
- test:
13
- runs-on: ubuntu-latest
14
- steps:
15
- - name: Checkout
16
- uses: actions/checkout@v2
17
- with:
18
- fetch-depth: 0
19
-
20
- - name: Set Current year
21
- run: "echo CURRENT=$(date +%Y) >> $GITHUB_ENV"
22
-
23
- - name: Set Previous Year
24
- run: "echo PREVIOUS=$(($CURRENT-1)) >> $GITHUB_ENV"
25
-
26
- - name: Update LICENSE
27
- uses: jacobtomlinson/gha-find-replace@v2
28
- with:
29
- find: ${{ env.PREVIOUS }}
30
- replace: ${{ env.CURRENT }}
31
- include: "LICENSE"
32
- regex: false
33
-
34
- - name: Commit files
35
- run: |
36
- git config user.name 'github-actions[bot]'
37
- git config user.email 'github-actions[bot]@users.noreply.github.com'
38
- git commit -m "Updated License Year" -a
39
-
40
- - name: Create Pull Request
41
- uses: peter-evans/create-pull-request@v3
42
- with:
43
- token: ${{ secrets.GITHUB_TOKEN }}
44
- title: Update License Year
45
- branch: update-license