pinot-client 1.20.0 → 1.22.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: 9392ad85128da7b9c3e85dfe0311316174fe36b014d573784f0de7fd60e5e9f2
4
- data.tar.gz: dec15a5ce58afb3a5e9d21c374c0c089d8f7855d44df540dd94f2cf2fe7a34ac
3
+ metadata.gz: 8fa5d08fb08bee353252e2da56932948941faa5fdbdd4f3410e5ff8887755e04
4
+ data.tar.gz: c8bb9cbe06c5d209d8f9f63031776fc604a153730b4be207b7fd6d8a3b69e5f0
5
5
  SHA512:
6
- metadata.gz: 7eb05753626860442cc047f379a4a0a74306a72b9d3d15bdd8fcf0d4c8f743653dc8d6213883807ac9ffa7e23263581e0a32aeefe7c5fa5ece3fcd7f1298ace8
7
- data.tar.gz: f4b1d0c916e95e5c93c9792bc16cdedf85f90b582ca15c5c3c5fe6272253a24973e3bc629cb927501e7650b25eacba091218eb332bebbd22e028c08846217856
6
+ metadata.gz: 6d5c85b83effd2ce352b09c03d350eedd817415a18994ed23ef7be972f80dfb8d72cbbbace9c83bfddedb095b6695af0b8b4538f2f0023ee40844e6b554849a9
7
+ data.tar.gz: c450ffb393a8798ae3ea3e608cf2cb976bdf5aeb6f7145121764c41bed33069807fdb3087d02c3963552f79568fb6f2020088794cccf47f97218d10fe7041c99
@@ -0,0 +1,115 @@
1
+ module Pinot
2
+ # Per-broker circuit breaker. States: CLOSED (normal), OPEN (rejecting), HALF_OPEN (probing).
3
+ #
4
+ # failure_threshold - consecutive failures before opening (default 5)
5
+ # open_timeout - seconds to stay OPEN before moving to HALF_OPEN (default 30)
6
+ class CircuitBreaker
7
+ CLOSED = :closed
8
+ OPEN = :open
9
+ HALF_OPEN = :half_open
10
+
11
+ BrokerCircuitOpenError = Class.new(BrokerNotFoundError)
12
+
13
+ attr_reader :state, :failure_count
14
+
15
+ def initialize(failure_threshold: 5, open_timeout: 30)
16
+ @failure_threshold = failure_threshold
17
+ @open_timeout = open_timeout
18
+ @mutex = Mutex.new
19
+ @state = CLOSED
20
+ @failure_count = 0
21
+ @opened_at = nil
22
+ end
23
+
24
+ # Call the block; record success/failure and enforce open-circuit rejection.
25
+ def call(broker_address)
26
+ @mutex.synchronize { check_state! }
27
+ begin
28
+ result = yield
29
+ @mutex.synchronize { on_success }
30
+ result
31
+ rescue BrokerUnavailableError, Errno::ECONNRESET, Errno::ECONNREFUSED,
32
+ Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
33
+ @mutex.synchronize { on_failure }
34
+ raise
35
+ end
36
+ end
37
+
38
+ def open?
39
+ @mutex.synchronize { @state == OPEN }
40
+ end
41
+
42
+ def reset
43
+ @mutex.synchronize do
44
+ @state = CLOSED
45
+ @failure_count = 0
46
+ @opened_at = nil
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def check_state!
53
+ return if @state == CLOSED
54
+
55
+ if @state == OPEN
56
+ if elapsed_since_open >= @open_timeout
57
+ @state = HALF_OPEN
58
+ else
59
+ raise BrokerCircuitOpenError, "circuit open for broker (#{remaining_open_time.ceil}s remaining)"
60
+ end
61
+ end
62
+ # HALF_OPEN: allow the probe through
63
+ end
64
+
65
+ def on_success
66
+ @state = CLOSED
67
+ @failure_count = 0
68
+ @opened_at = nil
69
+ end
70
+
71
+ def on_failure
72
+ @failure_count += 1
73
+ if @state == HALF_OPEN || @failure_count >= @failure_threshold
74
+ @state = OPEN
75
+ @opened_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+ end
77
+ end
78
+
79
+ def elapsed_since_open
80
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @opened_at
81
+ end
82
+
83
+ def remaining_open_time
84
+ @open_timeout - elapsed_since_open
85
+ end
86
+ end
87
+
88
+ # Registry of per-broker CircuitBreakers, shared across transport calls.
89
+ class CircuitBreakerRegistry
90
+ def initialize(failure_threshold: 5, open_timeout: 30)
91
+ @failure_threshold = failure_threshold
92
+ @open_timeout = open_timeout
93
+ @breakers = {}
94
+ @mutex = Mutex.new
95
+ end
96
+
97
+ def for(broker_address)
98
+ @mutex.synchronize do
99
+ @breakers[broker_address] ||= CircuitBreaker.new(
100
+ failure_threshold: @failure_threshold,
101
+ open_timeout: @open_timeout
102
+ )
103
+ end
104
+ end
105
+
106
+ def open?(broker_address)
107
+ @mutex.synchronize { @breakers[broker_address]&.open? || false }
108
+ end
109
+
110
+ # Remove all state (useful for testing).
111
+ def reset_all
112
+ @mutex.synchronize { @breakers.clear }
113
+ end
114
+ end
115
+ end
data/lib/pinot/config.rb CHANGED
@@ -23,8 +23,13 @@ module Pinot
23
23
  attr_accessor :broker_list, :http_timeout, :query_timeout_ms, :extra_http_header,
24
24
  :use_multistage_engine, :controller_config, :logger, :tls_config,
25
25
  :grpc_config, :zookeeper_config,
26
- :max_retries, # Integer, default 0 (no retry)
27
- :retry_interval_ms # Integer ms base interval, default 200
26
+ :max_retries, # Integer, default 0 (no retry)
27
+ :retry_interval_ms, # Integer ms base interval, default 200
28
+ :pool_size, # max idle connections per broker, default 5
29
+ :keep_alive_timeout, # seconds before idle connection is reaped, default 30
30
+ :circuit_breaker_enabled, # bool, default false
31
+ :circuit_breaker_threshold, # consecutive failures before opening, default 5
32
+ :circuit_breaker_timeout # seconds circuit stays open, default 30
28
33
 
29
34
  def initialize(
30
35
  broker_list: [],
@@ -38,7 +43,12 @@ module Pinot
38
43
  grpc_config: nil,
39
44
  zookeeper_config: nil,
40
45
  max_retries: 0,
41
- retry_interval_ms: 200
46
+ retry_interval_ms: 200,
47
+ pool_size: nil,
48
+ keep_alive_timeout: nil,
49
+ circuit_breaker_enabled: false,
50
+ circuit_breaker_threshold: 5,
51
+ circuit_breaker_timeout: 30
42
52
  )
43
53
  @broker_list = broker_list
44
54
  @http_timeout = http_timeout
@@ -50,9 +60,13 @@ module Pinot
50
60
  @tls_config = tls_config
51
61
  @grpc_config = grpc_config
52
62
  @zookeeper_config = zookeeper_config
53
- @query_timeout_ms = query_timeout_ms
54
63
  @max_retries = max_retries
55
64
  @retry_interval_ms = retry_interval_ms
65
+ @pool_size = pool_size
66
+ @keep_alive_timeout = keep_alive_timeout
67
+ @circuit_breaker_enabled = circuit_breaker_enabled
68
+ @circuit_breaker_threshold = circuit_breaker_threshold
69
+ @circuit_breaker_timeout = circuit_breaker_timeout
56
70
  end
57
71
 
58
72
  def validate!
@@ -75,6 +89,14 @@ module Pinot
75
89
  raise ConfigurationError, "query_timeout_ms must be positive, got: #{query_timeout_ms}"
76
90
  end
77
91
 
92
+ if !pool_size.nil? && pool_size < 1
93
+ raise ConfigurationError, "pool_size must be at least 1, got: #{pool_size}"
94
+ end
95
+
96
+ if !keep_alive_timeout.nil? && keep_alive_timeout <= 0
97
+ raise ConfigurationError, "keep_alive_timeout must be positive, got: #{keep_alive_timeout}"
98
+ end
99
+
78
100
  self
79
101
  end
80
102
  end
@@ -4,13 +4,15 @@ module Pinot
4
4
  class Connection
5
5
  attr_accessor :query_timeout_ms
6
6
 
7
- def initialize(transport:, broker_selector:, use_multistage_engine: false, logger: nil, query_timeout_ms: nil)
7
+ def initialize(transport:, broker_selector:, use_multistage_engine: false, logger: nil,
8
+ query_timeout_ms: nil, circuit_breaker_registry: nil)
8
9
  @transport = transport
9
10
  @broker_selector = broker_selector
10
11
  @use_multistage_engine = use_multistage_engine
11
12
  @trace = false
12
13
  @logger = logger
13
14
  @query_timeout_ms = query_timeout_ms
15
+ @circuit_breaker_registry = circuit_breaker_registry
14
16
  end
15
17
 
16
18
  def use_multistage_engine=(val)
@@ -30,7 +32,9 @@ module Pinot
30
32
  logger.debug "Executing SQL on table=#{table}: #{query}"
31
33
  broker = @broker_selector.select_broker(table)
32
34
  effective_timeout = query_timeout_ms || @query_timeout_ms
33
- @transport.execute(broker, build_request(query, timeout_ms: effective_timeout))
35
+ run_with_circuit_breaker(broker) do
36
+ @transport.execute(broker, build_request(query, timeout_ms: effective_timeout))
37
+ end
34
38
  end
35
39
  end
36
40
 
@@ -97,6 +101,11 @@ module Pinot
97
101
 
98
102
  private
99
103
 
104
+ def run_with_circuit_breaker(broker, &block)
105
+ return yield unless @circuit_breaker_registry
106
+ @circuit_breaker_registry.for(broker).call(broker, &block)
107
+ end
108
+
100
109
  def logger
101
110
  @logger || Pinot::Logging.logger
102
111
  end
@@ -34,7 +34,7 @@ module Pinot
34
34
  selector = ZookeeperBrokerSelector.new(zk_path: config.zookeeper_config.zk_path)
35
35
  selector.init
36
36
 
37
- inner = http_client || HttpClient.new(timeout: config.http_timeout, tls_config: config.tls_config)
37
+ inner = http_client || build_http_client(config)
38
38
  transport = JsonHttpTransport.new(
39
39
  http_client: inner,
40
40
  extra_headers: config.extra_http_header || {},
@@ -49,11 +49,12 @@ module Pinot
49
49
  broker_selector: selector,
50
50
  use_multistage_engine: config.use_multistage_engine || false,
51
51
  logger: config.logger,
52
- query_timeout_ms: config.query_timeout_ms
52
+ query_timeout_ms: config.query_timeout_ms,
53
+ circuit_breaker_registry: build_circuit_breaker_registry(config)
53
54
  )
54
55
  end
55
56
 
56
- inner = http_client || HttpClient.new(timeout: config.http_timeout, tls_config: config.tls_config)
57
+ inner = http_client || build_http_client(config)
57
58
 
58
59
  transport = JsonHttpTransport.new(
59
60
  http_client: inner,
@@ -71,13 +72,24 @@ module Pinot
71
72
  broker_selector: selector,
72
73
  use_multistage_engine: config.use_multistage_engine || false,
73
74
  logger: config.logger,
74
- query_timeout_ms: config.query_timeout_ms
75
+ query_timeout_ms: config.query_timeout_ms,
76
+ circuit_breaker_registry: build_circuit_breaker_registry(config)
75
77
  )
76
78
 
77
79
  selector.init
78
80
  conn
79
81
  end
80
82
 
83
+ def self.build_http_client(config)
84
+ HttpClient.new(
85
+ timeout: config.http_timeout,
86
+ tls_config: config.tls_config,
87
+ pool_size: config.pool_size,
88
+ keep_alive_timeout: config.keep_alive_timeout
89
+ )
90
+ end
91
+ private_class_method :build_http_client
92
+
81
93
  def self.build_selector(config, http_client)
82
94
  if config.broker_list && !config.broker_list.empty?
83
95
  SimpleBrokerSelector.new(config.broker_list)
@@ -86,4 +98,14 @@ module Pinot
86
98
  end
87
99
  end
88
100
  private_class_method :build_selector
101
+
102
+ def self.build_circuit_breaker_registry(config)
103
+ return nil unless config.circuit_breaker_enabled
104
+
105
+ CircuitBreakerRegistry.new(
106
+ failure_threshold: config.circuit_breaker_threshold || 5,
107
+ open_timeout: config.circuit_breaker_timeout || 30
108
+ )
109
+ end
110
+ private_class_method :build_circuit_breaker_registry
89
111
  end
data/lib/pinot/errors.rb CHANGED
@@ -3,6 +3,9 @@ module Pinot
3
3
  class BrokerNotFoundError < Error; end
4
4
  class TableNotFoundError < Error; end
5
5
  class TransportError < Error; end
6
+ class BrokerUnavailableError < TransportError; end
7
+ class QueryTimeoutError < TransportError; end
8
+ class RateLimitError < TransportError; end
6
9
  class PreparedStatementClosedError < Error; end
7
10
  class ConfigurationError < Error; end
8
11
  end
@@ -6,14 +6,16 @@ require "openssl"
6
6
 
7
7
  module Pinot
8
8
  class HttpClient
9
- MAX_POOL_SIZE = 5
10
- KEEP_ALIVE_TIMEOUT = 30
9
+ DEFAULT_POOL_SIZE = 5
10
+ DEFAULT_KEEP_ALIVE_TIMEOUT = 30
11
11
 
12
12
  PoolEntry = Struct.new(:http, :checked_in_at)
13
13
 
14
- def initialize(timeout: nil, tls_config: nil)
14
+ def initialize(timeout: nil, tls_config: nil, pool_size: nil, keep_alive_timeout: nil)
15
15
  @timeout = timeout
16
16
  @tls_config = tls_config
17
+ @max_pool_size = pool_size || DEFAULT_POOL_SIZE
18
+ @keep_alive_timeout = keep_alive_timeout || DEFAULT_KEEP_ALIVE_TIMEOUT
17
19
  @pool = {}
18
20
  @pool_mutex = Mutex.new
19
21
  @reaper = start_reaper
@@ -70,7 +72,7 @@ module Pinot
70
72
  entries = @pool[key] ||= []
71
73
  fresh = nil
72
74
  while (entry = entries.pop)
73
- if now - entry.checked_in_at < KEEP_ALIVE_TIMEOUT
75
+ if now - entry.checked_in_at < @keep_alive_timeout
74
76
  fresh = entry.http
75
77
  break
76
78
  else
@@ -85,7 +87,7 @@ module Pinot
85
87
  def checkin(key, http)
86
88
  @pool_mutex.synchronize do
87
89
  pool_for_key = @pool[key] ||= []
88
- if pool_for_key.size < MAX_POOL_SIZE
90
+ if pool_for_key.size < @max_pool_size
89
91
  pool_for_key.push(PoolEntry.new(http, Process.clock_gettime(Process::CLOCK_MONOTONIC)))
90
92
  else
91
93
  http.finish rescue nil
@@ -96,7 +98,7 @@ module Pinot
96
98
  def start_reaper
97
99
  t = Thread.new do
98
100
  loop do
99
- sleep KEEP_ALIVE_TIMEOUT / 2.0
101
+ sleep @keep_alive_timeout / 2.0
100
102
  reap_stale_connections
101
103
  end
102
104
  end
@@ -109,7 +111,7 @@ module Pinot
109
111
  @pool_mutex.synchronize do
110
112
  @pool.each_value do |entries|
111
113
  entries.reject! do |entry|
112
- if now - entry.checked_in_at >= KEEP_ALIVE_TIMEOUT
114
+ if now - entry.checked_in_at >= @keep_alive_timeout
113
115
  entry.http.finish rescue nil
114
116
  true
115
117
  else
@@ -167,6 +169,15 @@ module Pinot
167
169
  Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
168
170
  ].freeze
169
171
 
172
+ # HTTP status codes that map to specific error classes and are safe to retry
173
+ HTTP_ERROR_MAP = {
174
+ "429" => RateLimitError,
175
+ "503" => BrokerUnavailableError,
176
+ "504" => BrokerUnavailableError
177
+ }.freeze
178
+
179
+ RETRYABLE_HTTP_ERRORS = [RateLimitError, BrokerUnavailableError].freeze
180
+
170
181
  def initialize(http_client:, extra_headers: {}, timeout_ms: nil, logger: nil,
171
182
  max_retries: 0, retry_interval_ms: 200)
172
183
  @http_client = http_client
@@ -194,9 +205,9 @@ module Pinot
194
205
 
195
206
  resp = @http_client.post(url, body: body, headers: headers)
196
207
 
197
- if resp.code == "503"
208
+ if (error_class = HTTP_ERROR_MAP[resp.code])
198
209
  logger.error "Pinot broker returned HTTP #{resp.code}"
199
- raise TransportError, "http exception with HTTP status code #{resp.code}"
210
+ raise error_class, "http exception with HTTP status code #{resp.code}"
200
211
  end
201
212
 
202
213
  unless resp.code.to_i == 200
@@ -209,7 +220,7 @@ module Pinot
209
220
  rescue JSON::ParserError => e
210
221
  raise e.message
211
222
  end
212
- rescue TransportError, *RETRYABLE_ERRORS => e
223
+ rescue *RETRYABLE_HTTP_ERRORS, *RETRYABLE_ERRORS => e
213
224
  if attempts < max_attempts
214
225
  sleep_ms = (@retry_interval_ms || 200) * (2 ** (attempts - 1))
215
226
  sleep(sleep_ms / 1000.0)
data/lib/pinot/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Pinot
2
- VERSION = "1.20.0"
2
+ VERSION = "1.22.0"
3
3
  end
data/lib/pinot.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "pinot/table_aware_broker_selector"
18
18
  require_relative "pinot/controller_response"
19
19
  require_relative "pinot/controller_based_broker_selector"
20
20
  require_relative "pinot/transport"
21
+ require_relative "pinot/circuit_breaker"
21
22
  require_relative "pinot/connection"
22
23
  require_relative "pinot/prepared_statement"
23
24
  require_relative "pinot/connection_factory"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pinot-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.20.0
4
+ version: 1.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Xiang Fu
@@ -104,6 +104,7 @@ files:
104
104
  - README.md
105
105
  - lib/pinot.rb
106
106
  - lib/pinot/broker_selector.rb
107
+ - lib/pinot/circuit_breaker.rb
107
108
  - lib/pinot/config.rb
108
109
  - lib/pinot/connection.rb
109
110
  - lib/pinot/connection_factory.rb