pinot-client 1.28.0 → 1.30.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: da3d726029acb7167db3fef1a14951e4f4c46beac51f32e88e1e7782eab64fa1
4
- data.tar.gz: 4271e56a42a4d269bdf8de68a4c631e8320ec2eec4a36deeb3b635e10e8dc933
3
+ metadata.gz: cf947863b8d33ffa5c029bdd62e51f3451b76320a0b5f46c1d48ba8994827767
4
+ data.tar.gz: 433c4df6222017a3e32069410e550146ffa249ba6b34c9cb12bbd49b89933191
5
5
  SHA512:
6
- metadata.gz: ef69651b14484fceeec26edca916ede91f04ea3224f24d449e2cf022a3bb847e9ca0d429908daad14e10daa72c9bd4719f4e2400dff06431dca1c6126c90bd7e
7
- data.tar.gz: 8ab20c872830e2387245fadebae91e2739b5e30eb959a9769346df4652e0a3e18d673da2e6a3f88ae5753e55e41e7ed21ae709827d920e338e569444ba4a878a
6
+ metadata.gz: ada0f4344c5482daafefdf4cb062e80601723119b2732df5d5e116d1247931076db9eed6f3c393e6af3befcde14dedaac66a7a1fa90e7a0a6e8a65ce81e4eab0
7
+ data.tar.gz: fe0699c5d2fe75b1cb0aeeb832dcb57eb8b7fe44e33801171c76a7006e99cee08f34476134d1e87e2dccce8695a8dfc4b7b642436c5faebbe60246c3cbebd627
@@ -43,7 +43,7 @@ module Pinot
43
43
  # ActiveSupport::Notifications to already be defined at install! time (which
44
44
  # is always the case in a Rails process).
45
45
  module ActiveSupportNotifications
46
- EVENT_NAME = "sql.pinot"
46
+ EVENT_NAME = "sql.pinot".freeze
47
47
 
48
48
  def self.install!
49
49
  return if installed?
@@ -64,10 +64,10 @@ module Pinot
64
64
 
65
65
  def self.notify(event)
66
66
  payload = {
67
- sql: event[:query],
68
- name: event[:table],
67
+ sql: event[:query],
68
+ name: event[:table],
69
69
  duration: event[:duration_ms],
70
- success: event[:success]
70
+ success: event[:success]
71
71
  }
72
72
 
73
73
  if (err = event[:error])
@@ -34,7 +34,8 @@ module Pinot
34
34
  OPEN = :open
35
35
  HALF_OPEN = :half_open
36
36
 
37
- BrokerCircuitOpenError = Class.new(BrokerNotFoundError)
37
+ class BrokerCircuitOpenError < BrokerNotFoundError
38
+ end
38
39
 
39
40
  attr_reader :state, :failure_count
40
41
 
@@ -48,14 +49,14 @@ module Pinot
48
49
  end
49
50
 
50
51
  # Call the block; record success/failure and enforce open-circuit rejection.
51
- def call(broker_address)
52
+ def call(_broker_address)
52
53
  @mutex.synchronize { check_state! }
53
54
  begin
54
55
  result = yield
55
56
  @mutex.synchronize { on_success }
56
57
  result
57
58
  rescue BrokerUnavailableError, Errno::ECONNRESET, Errno::ECONNREFUSED,
58
- Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
59
+ Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
59
60
  @mutex.synchronize { on_failure }
60
61
  raise
61
62
  end
@@ -126,7 +127,7 @@ module Pinot
126
127
  @mutex.synchronize do
127
128
  @breakers[broker_address] ||= CircuitBreaker.new(
128
129
  failure_threshold: @failure_threshold,
129
- open_timeout: @open_timeout
130
+ open_timeout: @open_timeout
130
131
  )
131
132
  end
132
133
  end
data/lib/pinot/config.rb CHANGED
@@ -89,9 +89,7 @@ module Pinot
89
89
  raise ConfigurationError, "query_timeout_ms must be positive, got: #{query_timeout_ms}"
90
90
  end
91
91
 
92
- if !pool_size.nil? && pool_size < 1
93
- raise ConfigurationError, "pool_size must be at least 1, got: #{pool_size}"
94
- end
92
+ raise ConfigurationError, "pool_size must be at least 1, got: #{pool_size}" if !pool_size.nil? && pool_size < 1
95
93
 
96
94
  if !keep_alive_timeout.nil? && keep_alive_timeout <= 0
97
95
  raise ConfigurationError, "keep_alive_timeout must be positive, got: #{keep_alive_timeout}"
@@ -34,9 +34,7 @@ module Pinot
34
34
  @circuit_breaker_registry = circuit_breaker_registry
35
35
  end
36
36
 
37
- def use_multistage_engine=(val)
38
- @use_multistage_engine = val
39
- end
37
+ attr_writer :use_multistage_engine
40
38
 
41
39
  def open_trace
42
40
  @trace = true
@@ -91,8 +89,6 @@ module Pinot
91
89
  def execute_sql_with_params(table, query_pattern, params, query_timeout_ms: nil, headers: {})
92
90
  query = format_query(query_pattern, params)
93
91
  execute_sql(table, query, query_timeout_ms: query_timeout_ms, headers: headers)
94
- rescue => e
95
- raise e
96
92
  end
97
93
 
98
94
  # Execute multiple queries in parallel and return results in the same order.
@@ -121,7 +117,7 @@ module Pinot
121
117
  def execute_many(queries, max_concurrency: nil)
122
118
  return [] if queries.empty?
123
119
 
124
- results = Array.new(queries.size)
120
+ results = Array.new(queries.size)
125
121
  # Queue acts as a counting semaphore: pre-filled with N tokens.
126
122
  sem = max_concurrency ? build_semaphore(max_concurrency) : nil
127
123
 
@@ -131,14 +127,14 @@ module Pinot
131
127
  timeout_ms = item[:query_timeout_ms] || item["query_timeout_ms"]
132
128
 
133
129
  Thread.new do
134
- sem&.pop # acquire
130
+ sem&.pop # acquire
135
131
  begin
136
132
  resp = execute_sql(table, query, query_timeout_ms: timeout_ms)
137
133
  results[idx] = QueryResult.new(table: table, query: query, response: resp, error: nil)
138
- rescue => e
134
+ rescue StandardError => e
139
135
  results[idx] = QueryResult.new(table: table, query: query, response: nil, error: e)
140
136
  ensure
141
- sem&.push(:token) # release
137
+ sem&.push(:token) # release
142
138
  end
143
139
  end
144
140
  end
@@ -166,11 +162,42 @@ module Pinot
166
162
  @transport.http_client,
167
163
  broker,
168
164
  query,
169
- page_size: page_size,
165
+ page_size: page_size,
170
166
  extra_headers: extra_headers
171
167
  )
172
168
  end
173
169
 
170
+ # Check whether a broker is reachable and responding to queries.
171
+ #
172
+ # Runs a lightweight broker-side liveness check: first tries the dedicated
173
+ # +/health+ HTTP endpoint (returns 200 when the broker is healthy), and falls
174
+ # back to executing "SELECT 1 FROM DUAL" if the endpoint is unavailable.
175
+ #
176
+ # Returns +true+ when the broker responds successfully, +false+ on any
177
+ # error (connection refused, timeout, non-200 response, etc.). Never raises.
178
+ #
179
+ # Intended for Kubernetes readiness / liveness probes and health-check
180
+ # endpoints in Rails / Rack applications:
181
+ #
182
+ # get "/healthz" do
183
+ # conn.healthy? ? [200, "OK"] : [503, "Pinot unavailable"]
184
+ # end
185
+ #
186
+ # @param table [String, nil] table used for broker selection (nil = any broker)
187
+ # @param timeout_ms [Integer] per-check timeout in ms (default 2000)
188
+ # @return [Boolean]
189
+ def healthy?(table: nil, timeout_ms: 2_000)
190
+ broker = @broker_selector.select_broker(table || "")
191
+ base = broker.start_with?("http://", "https://") ? broker : "http://#{broker}"
192
+ client = HttpClient.new(timeout: timeout_ms / 1000.0)
193
+ resp = client.get("#{base}/health", headers: {})
194
+ resp.code.to_i == 200
195
+ rescue StandardError
196
+ false
197
+ ensure
198
+ client&.close
199
+ end
200
+
174
201
  # Create a PreparedStatement from a query template with +?+ placeholders.
175
202
  #
176
203
  # stmt = conn.prepare("myTable", "SELECT * FROM myTable WHERE id = ? AND name = ?")
@@ -185,8 +212,10 @@ module Pinot
185
212
  def prepare(table, query_template)
186
213
  raise ArgumentError, "table name cannot be empty" if table.nil? || table.strip.empty?
187
214
  raise ArgumentError, "query template cannot be empty" if query_template.nil? || query_template.strip.empty?
215
+
188
216
  count = query_template.count("?")
189
217
  raise ArgumentError, "query template must contain at least one parameter placeholder (?)" if count == 0
218
+
190
219
  PreparedStatementImpl.new(connection: self, table: table, query_template: query_template)
191
220
  end
192
221
 
@@ -196,12 +225,13 @@ module Pinot
196
225
  if placeholders != params.length
197
226
  raise "failed to format query: number of placeholders in queryPattern (#{placeholders}) does not match number of params (#{params.length})"
198
227
  end
228
+
199
229
  parts = pattern.split("?", -1)
200
230
  result = ""
201
231
  params.each_with_index do |param, i|
202
232
  formatted = begin
203
233
  format_arg(param)
204
- rescue => e
234
+ rescue StandardError => e
205
235
  raise "failed to format query: failed to format parameter: #{e.message}"
206
236
  end
207
237
  result += parts[i] + formatted
@@ -213,11 +243,7 @@ module Pinot
213
243
  case value
214
244
  when String
215
245
  "'#{value.gsub("'", "''")}'"
216
- when Integer
217
- value.to_s
218
- when Float
219
- value.to_s
220
- when TrueClass, FalseClass
246
+ when Integer, Float, TrueClass, FalseClass
221
247
  value.to_s
222
248
  when BigDecimal
223
249
  s = value.to_s("F")
@@ -239,9 +265,10 @@ module Pinot
239
265
  q
240
266
  end
241
267
 
242
- def run_with_circuit_breaker(broker, &block)
268
+ def run_with_circuit_breaker(broker, &)
243
269
  return yield unless @circuit_breaker_registry
244
- @circuit_breaker_registry.for(broker).call(broker, &block)
270
+
271
+ @circuit_breaker_registry.for(broker).call(broker, &)
245
272
  end
246
273
 
247
274
  def logger
@@ -138,7 +138,7 @@ module Pinot
138
138
 
139
139
  CircuitBreakerRegistry.new(
140
140
  failure_threshold: config.circuit_breaker_threshold || 5,
141
- open_timeout: config.circuit_breaker_timeout || 30
141
+ open_timeout: config.circuit_breaker_timeout || 30
142
142
  )
143
143
  end
144
144
  private_class_method :build_circuit_breaker_registry
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Pinot
6
6
  class ControllerBasedBrokerSelector < TableAwareBrokerSelector
7
- CONTROLLER_API_PATH = "/v2/brokers/tables?state=ONLINE"
7
+ CONTROLLER_API_PATH = "/v2/brokers/tables?state=ONLINE".freeze
8
8
  DEFAULT_UPDATE_FREQ_MS = 1000
9
9
 
10
10
  def initialize(config, http_client = nil, logger: nil)
@@ -27,9 +27,8 @@ module Pinot
27
27
  addr = address.to_s
28
28
  if addr.include?("://")
29
29
  scheme = addr.split("://").first
30
- unless %w[http https].include?(scheme)
31
- raise ConfigurationError, "unsupported controller URL scheme: #{scheme}"
32
- end
30
+ raise ConfigurationError, "unsupported controller URL scheme: #{scheme}" unless %w[http https].include?(scheme)
31
+
33
32
  addr.chomp("/") + CONTROLLER_API_PATH
34
33
  else
35
34
  "http://#{addr.chomp("/")}#{CONTROLLER_API_PATH}"
@@ -40,13 +39,11 @@ module Pinot
40
39
 
41
40
  def fetch_and_update
42
41
  headers = { "Accept" => "application/json" }
43
- .merge(@config.extra_controller_api_headers || {})
42
+ .merge(@config.extra_controller_api_headers || {})
44
43
 
45
44
  resp = @internal_http.get(@controller_url, headers: headers)
46
45
 
47
- unless resp.code.to_i == 200
48
- raise TransportError, "controller API returned HTTP status code #{resp.code}"
49
- end
46
+ raise TransportError, "controller API returned HTTP status code #{resp.code}" unless resp.code.to_i == 200
50
47
 
51
48
  body = resp.body
52
49
  begin
@@ -70,7 +67,7 @@ module Pinot
70
67
  sleep interval
71
68
  begin
72
69
  fetch_and_update
73
- rescue => e
70
+ rescue StandardError => e
74
71
  logger.warn "Pinot controller refresh failed: #{e.message}"
75
72
  end
76
73
  end
@@ -47,7 +47,7 @@ module Pinot
47
47
  meta.merge!(@config.extra_metadata)
48
48
 
49
49
  Pinot::Broker::Grpc::BrokerRequest.new(
50
- sql: request.query,
50
+ sql: request.query,
51
51
  metadata: meta
52
52
  )
53
53
  end
@@ -92,7 +92,7 @@ module Pinot
92
92
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
93
93
  notify(table: table, query: query, duration_ms: duration_ms, success: true, error: nil)
94
94
  result
95
- rescue => e
95
+ rescue StandardError => e
96
96
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
97
97
  notify(table: table, query: query, duration_ms: duration_ms, success: false, error: e)
98
98
  raise
@@ -49,9 +49,9 @@ module Pinot
49
49
  # Note: this gem does NOT depend on opentelemetry-api or opentelemetry-sdk.
50
50
  # Both must be present and initialized before install! is called.
51
51
  module OpenTelemetry
52
- SPAN_NAME = "pinot.query"
53
- DB_SYSTEM = "pinot"
54
- TRACER_NAME = "pinot-client"
52
+ SPAN_NAME = "pinot.query".freeze
53
+ DB_SYSTEM = "pinot".freeze
54
+ TRACER_NAME = "pinot-client".freeze
55
55
 
56
56
  @installed = false
57
57
  @enabled = true
@@ -80,7 +80,7 @@ module Pinot
80
80
  def self.uninstall!
81
81
  ::Pinot::Instrumentation.around = nil
82
82
  @installed = false
83
- # Note: JsonHttpTransport prepend is permanent once applied (Ruby limitation).
83
+ # NOTE: JsonHttpTransport prepend is permanent once applied (Ruby limitation).
84
84
  # Disable the propagator by unsetting the flag — it no-ops when disabled.
85
85
  end
86
86
 
@@ -111,7 +111,7 @@ module Pinot
111
111
  table: table, query: query, duration_ms: duration_ms, success: true, error: nil
112
112
  )
113
113
  result
114
- rescue => e
114
+ rescue StandardError => e
115
115
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
116
116
  span.record_exception(e)
117
117
  span.status = ::OpenTelemetry::Trace::Status.error(e.message)
@@ -126,9 +126,9 @@ module Pinot
126
126
 
127
127
  def self._span_attributes(table, query)
128
128
  attrs = {
129
- "db.system" => DB_SYSTEM,
129
+ "db.system" => DB_SYSTEM,
130
130
  "db.statement" => query,
131
- "db.name" => table.to_s
131
+ "db.name" => table.to_s
132
132
  }
133
133
  op = query.to_s.lstrip.split(/\s+/, 2).first&.upcase
134
134
  attrs["db.operation"] = op if op && !op.empty?
@@ -140,6 +140,7 @@ module Pinot
140
140
  # outbound HTTP request. Applied once via Module#prepend.
141
141
  def self._patch_transport
142
142
  return if ::Pinot::JsonHttpTransport.ancestors.include?(TraceContextInjector)
143
+
143
144
  ::Pinot::JsonHttpTransport.prepend(TraceContextInjector)
144
145
  end
145
146
  private_class_method :_patch_transport
@@ -53,7 +53,7 @@ module Pinot
53
53
  @extra_headers = extra_headers
54
54
 
55
55
  @request_id = nil
56
- @cursor_base = nil # "http://host:port" — set after first response
56
+ @cursor_base = nil # "http://host:port" — set after first response
57
57
  @exhausted = false
58
58
  end
59
59
 
@@ -136,11 +136,13 @@ module Pinot
136
136
 
137
137
  def broker_base(address)
138
138
  return address if address.start_with?("http://", "https://")
139
+
139
140
  "http://#{address}"
140
141
  end
141
142
 
142
143
  def broker_base_from_response(resp)
143
144
  return nil unless resp.broker_host && resp.broker_port
145
+
144
146
  "http://#{resp.broker_host}:#{resp.broker_port}"
145
147
  end
146
148
 
@@ -52,9 +52,8 @@ module Pinot
52
52
  def set(index, value)
53
53
  @mutex.synchronize do
54
54
  raise PreparedStatementClosedError, "prepared statement is closed" if @closed
55
- unless index >= 1 && index <= @param_count
56
- raise "parameter index #{index} is out of range [1, #{@param_count}]"
57
- end
55
+ raise "parameter index #{index} is out of range [1, #{@param_count}]" unless index.between?(1, @param_count)
56
+
58
57
  @parameters[index - 1] = value
59
58
  end
60
59
  nil
@@ -63,13 +62,14 @@ module Pinot
63
62
  def execute(headers: {})
64
63
  @mutex.synchronize do
65
64
  raise PreparedStatementClosedError, "prepared statement is closed" if @closed
65
+
66
66
  @parameters.each_with_index do |p, i|
67
67
  raise "parameter at index #{i + 1} is not set" if p.nil?
68
68
  end
69
69
  end
70
70
  query = begin
71
71
  build_query(@parameters)
72
- rescue => e
72
+ rescue StandardError => e
73
73
  raise "failed to build query: #{e.message}"
74
74
  end
75
75
  @connection.execute_sql(@table, query, headers: headers)
@@ -77,12 +77,11 @@ module Pinot
77
77
 
78
78
  def execute_with_params(*params, headers: {})
79
79
  @mutex.synchronize { raise PreparedStatementClosedError, "prepared statement is closed" if @closed }
80
- if params.length != @param_count
81
- raise "expected #{@param_count} parameters, got #{params.length}"
82
- end
80
+ raise "expected #{@param_count} parameters, got #{params.length}" if params.length != @param_count
81
+
83
82
  query = begin
84
83
  build_query(params)
85
- rescue => e
84
+ rescue StandardError => e
86
85
  raise "failed to build query: #{e.message}"
87
86
  end
88
87
  @connection.execute_sql(@table, query, headers: headers)
@@ -91,6 +90,7 @@ module Pinot
91
90
  def clear_parameters
92
91
  @mutex.synchronize do
93
92
  raise PreparedStatementClosedError, "prepared statement is closed" if @closed
93
+
94
94
  @parameters.fill(nil)
95
95
  end
96
96
  nil
@@ -105,14 +105,13 @@ module Pinot
105
105
  end
106
106
 
107
107
  def build_query(params)
108
- if params.length != @param_count
109
- raise "expected #{@param_count} parameters, got #{params.length}"
110
- end
108
+ raise "expected #{@param_count} parameters, got #{params.length}" if params.length != @param_count
109
+
111
110
  result = ""
112
111
  params.each_with_index do |param, i|
113
112
  formatted = begin
114
113
  @connection.format_arg(param)
115
- rescue => e
114
+ rescue StandardError => e
116
115
  raise "failed to format parameter: #{e.message}"
117
116
  end
118
117
  result += @parts[i] + formatted
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
4
  # source: broker_service.proto
4
5
 
5
- require 'google/protobuf'
6
-
6
+ require "google/protobuf"
7
7
 
8
8
  descriptor_data = "\n\x14\x62roker_service.proto\x12\x11pinot.broker.grpc\"\x8f\x01\n\rBrokerRequest\x12\x0b\n\x03sql\x18\x01 \x01(\t\x12@\n\x08metadata\x18\x02 \x03(\x0b\x32..pinot.broker.grpc.BrokerRequest.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\":\n\x0e\x42rokerResponse\x12\x17\n\x0fresult_row_size\x18\x01 \x01(\x05\x12\x0f\n\x07payload\x18\x02 \x01(\x0c\x32m\n\x1cPinotClientGrpcBrokerService\x12M\n\x06Submit\x12 .pinot.broker.grpc.BrokerRequest\x1a!.pinot.broker.grpc.BrokerResponseb\x06proto3"
9
9
 
10
- pool = ::Google::Protobuf::DescriptorPool.generated_pool
10
+ pool = Google::Protobuf::DescriptorPool.generated_pool
11
11
  pool.add_serialized_file(descriptor_data)
12
12
 
13
13
  module Pinot
@@ -1,20 +1,19 @@
1
1
  # Generated by the protocol buffer compiler. DO NOT EDIT!
2
2
  # Source: broker_service.proto for package 'pinot.broker.grpc'
3
3
 
4
- require 'grpc'
5
- require_relative 'broker_service_pb'
4
+ require "grpc"
5
+ require_relative "broker_service_pb"
6
6
 
7
7
  module Pinot
8
8
  module Broker
9
9
  module Grpc
10
10
  module PinotClientGrpcBrokerService
11
11
  class Service
12
-
13
12
  include ::GRPC::GenericService
14
13
 
15
14
  self.marshal_class_method = :encode
16
15
  self.unmarshal_class_method = :decode
17
- self.service_name = 'pinot.broker.grpc.PinotClientGrpcBrokerService'
16
+ self.service_name = "pinot.broker.grpc.PinotClientGrpcBrokerService"
18
17
 
19
18
  rpc :Submit, ::Pinot::Broker::Grpc::BrokerRequest, ::Pinot::Broker::Grpc::BrokerResponse
20
19
  end
@@ -0,0 +1,105 @@
1
+ require "pinot"
2
+ require "pinot/active_support_notifications"
3
+
4
+ module Pinot
5
+ # Rails integration via Railtie — zero configuration required.
6
+ #
7
+ # Automatically activated when the gem is required inside a Rails app.
8
+ # Just add `gem "pinot-client"` to your Gemfile.
9
+ #
10
+ # == What it does
11
+ #
12
+ # 1. **ActiveSupport::Notifications bridge** — every Pinot query fires a
13
+ # "sql.pinot" event on the AS::N bus, picked up by Rails log subscribers,
14
+ # Skylight, Scout APM, etc.
15
+ #
16
+ # 2. **OpenTelemetry bridge** — when the opentelemetry-api gem is present,
17
+ # creates "pinot.query" spans and injects W3C trace-context headers into
18
+ # every outbound broker request.
19
+ #
20
+ # 3. **X-Request-Id propagation** — inserts Pinot::RequestIdMiddleware into
21
+ # the Rack stack. The current request's X-Request-Id is forwarded as an
22
+ # HTTP header on every broker call so Pinot broker logs can be correlated
23
+ # with application request logs.
24
+ #
25
+ # == Opting out of individual features
26
+ #
27
+ # # config/initializers/pinot.rb
28
+ # Rails.application.config.pinot.notifications = false
29
+ # Rails.application.config.pinot.open_telemetry = false
30
+ # Rails.application.config.pinot.request_id = false
31
+ #
32
+ # == Manual setup (non-Rails / opt-out of Railtie entirely)
33
+ #
34
+ # require "pinot/active_support_notifications"
35
+ # Pinot::ActiveSupportNotifications.install!
36
+ #
37
+ # require "pinot/open_telemetry"
38
+ # Pinot::OpenTelemetry.install!
39
+ #
40
+ # # In your Rack middleware stack:
41
+ # use Pinot::RequestIdMiddleware
42
+ class Railtie < ::Rails::Railtie
43
+ initializer "pinot.install_notifications" do |app|
44
+ opts = app.config.pinot
45
+ next if opts[:notifications] == false
46
+
47
+ require "pinot/active_support_notifications"
48
+ ActiveSupportNotifications.install!
49
+ end
50
+
51
+ initializer "pinot.install_open_telemetry" do |app|
52
+ opts = app.config.pinot
53
+ next if opts[:open_telemetry] == false
54
+
55
+ begin
56
+ require "opentelemetry"
57
+ require "pinot/open_telemetry"
58
+ OpenTelemetry.install!
59
+ rescue LoadError
60
+ # opentelemetry-api gem not present — skip silently
61
+ end
62
+ end
63
+
64
+ initializer "pinot.request_id_propagation" do |app|
65
+ opts = app.config.pinot
66
+ next if opts[:request_id] == false
67
+
68
+ app.config.middleware.use(RequestIdMiddleware)
69
+ Connection.prepend(RequestIdInjector)
70
+ end
71
+ end
72
+
73
+ # Rack middleware that captures X-Request-Id from the inbound HTTP request
74
+ # and stores it in a thread-local for the duration of the request.
75
+ #
76
+ # Inserted automatically by Pinot::Railtie. For non-Rails Rack apps:
77
+ #
78
+ # use Pinot::RequestIdMiddleware
79
+ class RequestIdMiddleware
80
+ RACK_HEADER = "HTTP_X_REQUEST_ID".freeze
81
+
82
+ def initialize(app)
83
+ @app = app
84
+ end
85
+
86
+ def call(env)
87
+ Thread.current[:pinot_request_id] = env[RACK_HEADER]
88
+ @app.call(env)
89
+ ensure
90
+ Thread.current[:pinot_request_id] = nil
91
+ end
92
+ end
93
+
94
+ # Prepended into Connection when the Railtie is active.
95
+ # Automatically merges the current request's X-Request-Id into every
96
+ # outbound Pinot query as an HTTP header, without requiring callers to
97
+ # pass it explicitly.
98
+ module RequestIdInjector
99
+ def execute_sql(table, query, query_timeout_ms: nil, headers: {})
100
+ rid = Thread.current[:pinot_request_id]
101
+ merged = rid && !headers.key?("X-Request-Id") ? headers.merge("X-Request-Id" => rid) : headers
102
+ super(table, query, query_timeout_ms: query_timeout_ms, headers: merged)
103
+ end
104
+ end
105
+ end
@@ -1,9 +1,9 @@
1
1
  require "bigdecimal"
2
2
 
3
3
  module Pinot
4
- INT32_MAX = 2_147_483_647
4
+ INT32_MAX = 2_147_483_647
5
5
  INT32_MIN = -2_147_483_648
6
- INT64_MAX = 9_223_372_036_854_775_807
6
+ INT64_MAX = 9_223_372_036_854_775_807
7
7
  INT64_MIN = -9_223_372_036_854_775_808
8
8
  FLOAT32_MAX = 3.4028235e+38
9
9
 
@@ -110,14 +110,17 @@ module Pinot
110
110
  if raw.include?(".") || raw.include?("e") || raw.include?("E")
111
111
  # Floating point string — check if it's a whole number
112
112
  bd = BigDecimal(raw)
113
- return 0 if bd.infinite? || bd.nan? rescue return 0
113
+ return 0 if bd.infinite? || bd.nan?
114
+
114
115
  int_val = bd.to_i
115
116
  return 0 unless bd == BigDecimal(int_val.to_s)
116
117
  return 0 if int_val > INT32_MAX || int_val < INT32_MIN
118
+
117
119
  int_val.to_i
118
120
  else
119
121
  int_val = Integer(raw)
120
122
  return 0 if int_val > INT32_MAX || int_val < INT32_MIN
123
+
121
124
  int_val
122
125
  end
123
126
  rescue ArgumentError, TypeError
@@ -133,16 +136,18 @@ module Pinot
133
136
  begin
134
137
  if raw.include?(".") || raw.include?("e") || raw.include?("E")
135
138
  bd = BigDecimal(raw)
136
- return 0 if bd.infinite? || bd.nan? rescue return 0
139
+ return 0 if bd.infinite? || bd.nan?
140
+
137
141
  int_val = bd.to_i
138
142
  return 0 unless bd == BigDecimal(int_val.to_s)
139
- return 0 if int_val > INT64_MAX || int_val < INT64_MIN
140
- int_val
143
+
141
144
  else
142
145
  int_val = Integer(raw)
143
- return 0 if int_val > INT64_MAX || int_val < INT64_MIN
144
- int_val
146
+
145
147
  end
148
+ return 0 if int_val > INT64_MAX || int_val < INT64_MIN
149
+
150
+ int_val
146
151
  rescue ArgumentError, TypeError
147
152
  0
148
153
  end
@@ -156,8 +161,10 @@ module Pinot
156
161
  begin
157
162
  f = Float(raw)
158
163
  return 0.0 if f.infinite? || f.nan?
164
+
159
165
  f32 = f.to_f
160
166
  return 0.0 if f32.abs > FLOAT32_MAX
167
+
161
168
  f32
162
169
  rescue ArgumentError, TypeError
163
170
  0.0
@@ -172,6 +179,7 @@ module Pinot
172
179
  begin
173
180
  f = Float(raw)
174
181
  return 0.0 if f.infinite? || f.nan?
182
+
175
183
  f
176
184
  rescue ArgumentError, TypeError
177
185
  0.0
@@ -0,0 +1,122 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Pinot
6
+ # Thin client for the Pinot Controller REST API — table listing and schema
7
+ # introspection. Useful for tooling, migrations, and debugging column types.
8
+ #
9
+ # == Usage
10
+ #
11
+ # client = Pinot::SchemaClient.new("http://controller:9000")
12
+ #
13
+ # client.list_tables # => ["baseballStats", "orders", ...]
14
+ # client.get_schema("baseballStats") # => Hash (raw schema JSON)
15
+ # client.get_table_config("baseballStats")# => Hash (raw tableConfig JSON)
16
+ # client.table_exists?("orders") # => true / false
17
+ #
18
+ # == Authentication / extra headers
19
+ #
20
+ # client = Pinot::SchemaClient.new(
21
+ # "https://controller:9000",
22
+ # headers: { "Authorization" => "Bearer <token>" }
23
+ # )
24
+ #
25
+ # The client is intentionally stateless and lightweight — it uses a shared
26
+ # HttpClient (connection pool) but does not perform background polling.
27
+ class SchemaClient
28
+ TABLES_PATH = "/tables".freeze
29
+ SCHEMA_PATH = "/schemas/%<table>s".freeze
30
+ TABLE_CONFIG_PATH = "/tables/%<table>s".freeze
31
+
32
+ # @param controller_address [String] base URL e.g. "controller:9000" or
33
+ # "http://controller:9000" or "https://controller:9000"
34
+ # @param headers [Hash] extra HTTP headers for every request
35
+ # @param http_client [HttpClient, nil] optional pre-configured client
36
+ def initialize(controller_address, headers: {}, http_client: nil)
37
+ @base = normalize_address(controller_address)
38
+ @headers = { "Accept" => "application/json" }.merge(headers)
39
+ @http = http_client || HttpClient.new
40
+ end
41
+
42
+ # Returns an array of all table names known to the controller.
43
+ #
44
+ # @return [Array<String>]
45
+ def list_tables
46
+ body = get_json(TABLES_PATH)
47
+ body["tables"] || []
48
+ end
49
+
50
+ # Returns the schema for a table as a Hash.
51
+ #
52
+ # @param table [String] table name (without _OFFLINE / _REALTIME suffix)
53
+ # @return [Hash] raw schema JSON
54
+ # @raise [TableNotFoundError] if the table or schema does not exist (404)
55
+ # @raise [TransportError] on other non-200 responses
56
+ def get_schema(table)
57
+ get_json(format(SCHEMA_PATH, table: table))
58
+ end
59
+
60
+ # Returns the full table config (including segmentsConfig, tableIndexConfig,
61
+ # tenants, etc.) for a table as a Hash.
62
+ #
63
+ # @param table [String] table name (without _OFFLINE / _REALTIME suffix)
64
+ # @return [Hash] raw tableConfig JSON
65
+ # @raise [TableNotFoundError] if the table does not exist (404)
66
+ # @raise [TransportError] on other non-200 responses
67
+ def get_table_config(table)
68
+ get_json(format(TABLE_CONFIG_PATH, table: table))
69
+ end
70
+
71
+ # Returns true when the controller knows about the given table.
72
+ #
73
+ # @param table [String]
74
+ # @return [Boolean]
75
+ def table_exists?(table)
76
+ get_table_config(table)
77
+ true
78
+ rescue TableNotFoundError
79
+ false
80
+ end
81
+
82
+ # Returns the column names and their data types for a table.
83
+ #
84
+ # Convenience wrapper around get_schema that returns a flat Hash:
85
+ # { "playerId" => "INT", "playerName" => "STRING", ... }
86
+ #
87
+ # @param table [String]
88
+ # @return [Hash{String => String}]
89
+ def column_types(table)
90
+ schema = get_schema(table)
91
+ dims = schema["dimensionFieldSpecs"] || []
92
+ metrics = schema["metricFieldSpecs"] || []
93
+ date_time = schema["dateTimeFieldSpecs"] || []
94
+ (dims + metrics + date_time).to_h do |spec|
95
+ [spec["name"], spec["dataType"]]
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def normalize_address(address)
102
+ addr = address.to_s.chomp("/")
103
+ addr.start_with?("http://", "https://") ? addr : "http://#{addr}"
104
+ end
105
+
106
+ def get_json(path)
107
+ url = "#{@base}#{path}"
108
+ resp = @http.get(url, headers: @headers)
109
+
110
+ case resp.code.to_i
111
+ when 200
112
+ JSON.parse(resp.body)
113
+ when 404
114
+ raise TableNotFoundError, "not found: #{path}"
115
+ else
116
+ raise TransportError, "controller returned HTTP #{resp.code} for #{path}"
117
+ end
118
+ rescue JSON::ParserError => e
119
+ raise TransportError, "invalid JSON from controller: #{e.message}"
120
+ end
121
+ end
122
+ end
@@ -14,6 +14,7 @@ module Pinot
14
14
 
15
15
  def select_broker(_table)
16
16
  raise BrokerNotFoundError, "no pre-configured broker lists" if @broker_list.empty?
17
+
17
18
  @mutex.synchronize do
18
19
  broker = @broker_list[@index % @broker_list.size]
19
20
  @index += 1
@@ -2,8 +2,8 @@ module Pinot
2
2
  class TableAwareBrokerSelector
3
3
  include BrokerSelector
4
4
 
5
- OFFLINE_SUFFIX = "_OFFLINE"
6
- REALTIME_SUFFIX = "_REALTIME"
5
+ OFFLINE_SUFFIX = "_OFFLINE".freeze
6
+ REALTIME_SUFFIX = "_REALTIME".freeze
7
7
 
8
8
  def initialize
9
9
  @mutex = Mutex.new
@@ -20,11 +20,13 @@ module Pinot
20
20
  @mutex.synchronize do
21
21
  if table_name.empty?
22
22
  raise BrokerNotFoundError, "no available broker" if @all_broker_list.empty?
23
+
23
24
  return @all_broker_list.sample
24
25
  end
25
26
  brokers = @table_broker_map[table_name]
26
27
  raise TableNotFoundError, "unable to find table: #{table}" unless brokers
27
28
  raise BrokerNotFoundError, "no available broker for table: #{table}" if brokers.empty?
29
+
28
30
  brokers.sample
29
31
  end
30
32
  end
@@ -50,10 +50,18 @@ module Pinot
50
50
  end
51
51
 
52
52
  def close
53
- @reaper.kill rescue nil
53
+ begin
54
+ @reaper.kill
55
+ rescue StandardError
56
+ nil
57
+ end
54
58
  @pool_mutex.synchronize do
55
59
  @pool.each_value do |entries|
56
- entries.each { |entry| entry.http.finish rescue nil }
60
+ entries.each do |entry|
61
+ entry.http.finish
62
+ rescue StandardError
63
+ nil
64
+ end
57
65
  end
58
66
  @pool.clear
59
67
  end
@@ -69,8 +77,12 @@ module Pinot
69
77
  result = yield http
70
78
  checkin(key, http)
71
79
  result
72
- rescue => e
73
- http.finish rescue nil
80
+ rescue StandardError => e
81
+ begin
82
+ http.finish
83
+ rescue StandardError
84
+ nil
85
+ end
74
86
  raise e
75
87
  end
76
88
  end
@@ -85,7 +97,11 @@ module Pinot
85
97
  fresh = entry.http
86
98
  break
87
99
  else
88
- entry.http.finish rescue nil
100
+ begin
101
+ entry.http.finish
102
+ rescue StandardError
103
+ nil
104
+ end
89
105
  end
90
106
  end
91
107
  fresh
@@ -99,7 +115,11 @@ module Pinot
99
115
  if pool_for_key.size < @max_pool_size
100
116
  pool_for_key.push(PoolEntry.new(http, Process.clock_gettime(Process::CLOCK_MONOTONIC)))
101
117
  else
102
- http.finish rescue nil
118
+ begin
119
+ http.finish
120
+ rescue StandardError
121
+ nil
122
+ end
103
123
  end
104
124
  end
105
125
  end
@@ -121,7 +141,11 @@ module Pinot
121
141
  @pool.each_value do |entries|
122
142
  entries.reject! do |entry|
123
143
  if now - entry.checked_in_at >= @keep_alive_timeout
124
- entry.http.finish rescue nil
144
+ begin
145
+ entry.http.finish
146
+ rescue StandardError
147
+ nil
148
+ end
125
149
  true
126
150
  else
127
151
  false
@@ -156,11 +180,11 @@ module Pinot
156
180
  http.cert = OpenSSL::X509::Certificate.new(File.read(@tls_config.client_cert_file))
157
181
  http.key = OpenSSL::PKey.read(File.read(@tls_config.client_key_file))
158
182
  end
159
- if @tls_config.insecure_skip_verify
160
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
161
- else
162
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
163
- end
183
+ http.verify_mode = if @tls_config.insecure_skip_verify
184
+ OpenSSL::SSL::VERIFY_NONE
185
+ else
186
+ OpenSSL::SSL::VERIFY_PEER
187
+ end
164
188
  end
165
189
  else
166
190
  http.use_ssl = false
@@ -216,9 +240,9 @@ module Pinot
216
240
  url = build_url(broker_address, request.query_format)
217
241
  body = build_body(request)
218
242
  headers = DEFAULT_HEADERS
219
- .merge(@extra_headers)
220
- .merge("X-Correlation-Id" => SecureRandom.uuid)
221
- .merge(extra_request_headers)
243
+ .merge(@extra_headers)
244
+ .merge("X-Correlation-Id" => SecureRandom.uuid)
245
+ .merge(extra_request_headers)
222
246
 
223
247
  resp = @http_client.post(url, body: body, headers: headers)
224
248
 
@@ -245,11 +269,11 @@ module Pinot
245
269
  broker_response
246
270
  rescue *RETRYABLE_HTTP_ERRORS, *RETRYABLE_ERRORS => e
247
271
  if attempts < max_attempts
248
- sleep_ms = (@retry_interval_ms || 200) * (2 ** (attempts - 1))
272
+ sleep_ms = (@retry_interval_ms || 200) * (2**(attempts - 1))
249
273
  sleep(sleep_ms / 1000.0)
250
274
  retry
251
275
  end
252
- raise Net::ReadTimeout === e || Net::WriteTimeout === e ? QueryTimeoutError.new(e.message) : e
276
+ raise(Net::ReadTimeout === e || Net::WriteTimeout === e ? QueryTimeoutError.new(e.message) : e)
253
277
  end
254
278
  end
255
279
 
@@ -282,9 +306,7 @@ module Pinot
282
306
  if request.query_format == "sql"
283
307
  parts << "groupByMode=sql;responseFormat=sql"
284
308
  parts << "useMultistageEngine=true" if request.use_multistage_engine
285
- if @timeout_ms && @timeout_ms > 0
286
- parts << "timeoutMs=#{@timeout_ms}"
287
- end
309
+ parts << "timeoutMs=#{@timeout_ms}" if @timeout_ms && @timeout_ms > 0
288
310
  parts << "timeoutMs=#{request.query_timeout_ms}" if request.query_timeout_ms
289
311
  end
290
312
  parts.join(";")
data/lib/pinot/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Pinot
2
- VERSION = "1.28.0"
2
+ VERSION = "1.30.0".freeze
3
3
  end
@@ -1,12 +1,11 @@
1
1
  require "json"
2
- require "set"
3
2
  require_relative "table_aware_broker_selector"
4
3
  require_relative "errors"
5
4
 
6
5
  module Pinot
7
6
  class ZookeeperBrokerSelector < TableAwareBrokerSelector
8
7
  # ZK path where Pinot stores broker external view
9
- BROKER_EXTERNAL_VIEW_PATH = "/EXTERNALVIEW/brokerResource"
8
+ BROKER_EXTERNAL_VIEW_PATH = "/EXTERNALVIEW/brokerResource".freeze
10
9
 
11
10
  def initialize(zk_path:, zk_client: nil)
12
11
  super()
@@ -39,9 +38,13 @@ module Pinot
39
38
  end
40
39
 
41
40
  def setup_watcher
42
- @zk.register(BROKER_EXTERNAL_VIEW_PATH) do |event|
43
- fetch_and_update rescue nil
44
- setup_watcher # re-register watch after each trigger
41
+ @zk.register(BROKER_EXTERNAL_VIEW_PATH) do |_event|
42
+ begin
43
+ fetch_and_update
44
+ rescue StandardError
45
+ nil
46
+ end
47
+ setup_watcher # re-register watch after each trigger
45
48
  end
46
49
  # Set initial watch
47
50
  @zk.exists?(BROKER_EXTERNAL_VIEW_PATH, watch: true)
@@ -61,12 +64,15 @@ module Pinot
61
64
  brokers = []
62
65
  broker_map.each do |broker_key, state|
63
66
  next unless state == "ONLINE"
67
+
64
68
  # Broker key format: Broker_<hostname>_<port>
65
69
  # Use the last segment as port and the second-to-last as host
66
70
  parts = broker_key.split("_")
67
71
  next if parts.length < 2
72
+
68
73
  port = parts.last
69
74
  next unless port =~ /\A\d+\z/
75
+
70
76
  host = parts[-2]
71
77
  brokers << "#{host}:#{port}"
72
78
  end
data/lib/pinot.rb CHANGED
@@ -24,6 +24,7 @@ require_relative "pinot/paginator"
24
24
  require_relative "pinot/connection"
25
25
  require_relative "pinot/prepared_statement"
26
26
  require_relative "pinot/connection_factory"
27
+ require_relative "pinot/schema_client"
27
28
 
28
29
  require_relative "pinot/grpc_config"
29
30
  begin
@@ -34,5 +35,7 @@ end
34
35
 
35
36
  begin
36
37
  require_relative "pinot/zookeeper_broker_selector"
37
- rescue LoadError
38
+ rescue LoadError # rubocop:disable Lint/SuppressedException
38
39
  end
40
+
41
+ require_relative "pinot/railtie" if defined?(Rails::Railtie)
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.28.0
4
+ version: 1.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Xiang Fu
@@ -94,6 +94,48 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0.8'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.65'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.65'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: bundler-audit
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.9'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.9'
97
139
  description: A Ruby client for Apache Pinot, mirroring the Go client API
98
140
  email:
99
141
  executables: []
@@ -123,8 +165,10 @@ files:
123
165
  - lib/pinot/proto/broker_service_pb.rb
124
166
  - lib/pinot/proto/broker_service_services_pb.rb
125
167
  - lib/pinot/query_result.rb
168
+ - lib/pinot/railtie.rb
126
169
  - lib/pinot/request.rb
127
170
  - lib/pinot/response.rb
171
+ - lib/pinot/schema_client.rb
128
172
  - lib/pinot/simple_broker_selector.rb
129
173
  - lib/pinot/table_aware_broker_selector.rb
130
174
  - lib/pinot/tls_config.rb