pinot-client 1.26.0 → 1.27.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: 0c6d47938639a210b77881492e1155d26728be12f987b9721712f16503183ec3
4
- data.tar.gz: 9e14c467edbba3ad7fff1e20f6b288ac810895c5069549cd33f08e2972b8d739
3
+ metadata.gz: dfd22c5e76430105c17c3a1279b002084aebb894d4c561913908a4d00038b519
4
+ data.tar.gz: e668583cdd1afde9b9b35dab28c46b2e707643911a0800cdb98950ac1de2731b
5
5
  SHA512:
6
- metadata.gz: 73ec7a9ae84e96f71618f430ed542527fb9f9e16f64b1d0d78a1a8bcb0ba1950f6d7117072f0318ffcc08fdb0df2143be268667d6b96e4914d52fc4f0448c0fa
7
- data.tar.gz: 9fe83c991b5ef549ce915dbe4af55b50143d17d6d9660915d5fd6308798541f81068e557317b8b88af167b55a63ea7781ef14dfff428cd51b671df7d2a0267b2
6
+ metadata.gz: 1aba59c0e39a601dacccfa2f413b9405ad0835e94555272a8f7bf8177b421fd0eac861144fb44d24c69ec41f51834b67a786ed4bd29afb48ae6667671f5ce112
7
+ data.tar.gz: c81b393e62ffba2390a7c21a52fb0b0a3f0fdfd0421b3a78d9bcbcc772a35f2613e451f58a28cdcb9ecd30c6120aa114f94ae3a80c825594a0cc9baa98a521ca
@@ -0,0 +1,81 @@
1
+ require "pinot"
2
+
3
+ module Pinot
4
+ # Opt-in ActiveSupport::Notifications bridge for Rails applications.
5
+ #
6
+ # == Setup
7
+ #
8
+ # Add one line to an initializer (e.g. config/initializers/pinot.rb):
9
+ #
10
+ # require "pinot/active_support_notifications"
11
+ # Pinot::ActiveSupportNotifications.install!
12
+ #
13
+ # That's it. Every query executed via Connection#execute_sql (including those
14
+ # from execute_sql_with_params, execute_many, and PreparedStatement) will
15
+ # publish a "sql.pinot" event on the ActiveSupport::Notifications bus.
16
+ #
17
+ # == Subscribing
18
+ #
19
+ # ActiveSupport::Notifications.subscribe("sql.pinot") do |name, start, finish, id, payload|
20
+ # Rails.logger.debug "[Pinot] #{payload[:name]} — #{payload[:sql]} (#{payload[:duration].round(1)} ms)"
21
+ # end
22
+ #
23
+ # == Payload keys
24
+ #
25
+ # :sql — the SQL query string
26
+ # :name — the Pinot table name (empty string for table-less queries)
27
+ # :duration — execution time in milliseconds (Float)
28
+ # :success — true on success, false when an exception was raised
29
+ # :exception — [ExceptionClassName, message] on error, absent on success
30
+ # (follows the ActiveSupport::Notifications convention)
31
+ # :exception_object — the raw exception on error, absent on success
32
+ # (follows the ActiveSupport::Notifications convention)
33
+ #
34
+ # == Lifecycle
35
+ #
36
+ # The bridge is installed idempotently:
37
+ #
38
+ # Pinot::ActiveSupportNotifications.install! # register
39
+ # Pinot::ActiveSupportNotifications.installed? # => true
40
+ # Pinot::ActiveSupportNotifications.uninstall! # deregister (e.g. in tests)
41
+ #
42
+ # Note: this gem does NOT depend on activesupport. The bridge requires
43
+ # ActiveSupport::Notifications to already be defined at install! time (which
44
+ # is always the case in a Rails process).
45
+ module ActiveSupportNotifications
46
+ EVENT_NAME = "sql.pinot"
47
+
48
+ def self.install!
49
+ return if installed?
50
+
51
+ Pinot::Instrumentation.on_query = method(:notify)
52
+ @installed = true
53
+ end
54
+
55
+ def self.installed?
56
+ @installed || false
57
+ end
58
+
59
+ def self.uninstall!
60
+ Pinot::Instrumentation.on_query = nil
61
+ @installed = false
62
+ end
63
+
64
+ def self.notify(event)
65
+ payload = {
66
+ sql: event[:query],
67
+ name: event[:table],
68
+ duration: event[:duration_ms],
69
+ success: event[:success]
70
+ }
71
+
72
+ if (err = event[:error])
73
+ payload[:exception] = [err.class.name, err.message]
74
+ payload[:exception_object] = err
75
+ end
76
+
77
+ ::ActiveSupport::Notifications.instrument(EVENT_NAME, payload)
78
+ end
79
+ private_class_method :notify
80
+ end
81
+ end
@@ -1,8 +1,34 @@
1
1
  module Pinot
2
- # Per-broker circuit breaker. States: CLOSED (normal), OPEN (rejecting), HALF_OPEN (probing).
2
+ # Per-broker circuit breaker implementing the classic three-state machine:
3
3
  #
4
- # failure_threshold - consecutive failures before opening (default 5)
5
- # open_timeout - seconds to stay OPEN before moving to HALF_OPEN (default 30)
4
+ # CLOSED — normal operation; failures are counted
5
+ # OPEN all calls rejected immediately with BrokerCircuitOpenError
6
+ # HALF_OPEN — one probe call allowed through; success → CLOSED, failure → OPEN
7
+ #
8
+ # A breaker opens after +failure_threshold+ consecutive transport-level failures
9
+ # (BrokerUnavailableError, connection resets, timeouts). It automatically
10
+ # transitions to HALF_OPEN after +open_timeout+ seconds.
11
+ #
12
+ # Use CircuitBreakerRegistry to share breakers across Connection instances.
13
+ #
14
+ # == Configuration
15
+ #
16
+ # config = Pinot::ClientConfig.new(
17
+ # broker_list: ["broker:8099"],
18
+ # circuit_breaker_enabled: true,
19
+ # circuit_breaker_threshold: 3, # open after 3 failures (default 5)
20
+ # circuit_breaker_timeout: 10 # reopen probe after 10 s (default 30)
21
+ # )
22
+ # conn = Pinot.from_config(config)
23
+ #
24
+ # == Error class
25
+ #
26
+ # Pinot::CircuitBreaker::BrokerCircuitOpenError
27
+ # — raised when the circuit is OPEN; inherits from BrokerNotFoundError
28
+ # so callers that already rescue BrokerNotFoundError get it for free.
29
+ #
30
+ # @param failure_threshold [Integer] consecutive failures before opening (default 5)
31
+ # @param open_timeout [Integer] seconds to wait before probing again (default 30)
6
32
  class CircuitBreaker
7
33
  CLOSED = :closed
8
34
  OPEN = :open
@@ -85,7 +111,9 @@ module Pinot
85
111
  end
86
112
  end
87
113
 
88
- # Registry of per-broker CircuitBreakers, shared across transport calls.
114
+ # Thread-safe registry that lazily creates and caches one CircuitBreaker per
115
+ # broker address string. Shared by all Connection instances built from the
116
+ # same ClientConfig so that failures from parallel queries accumulate correctly.
89
117
  class CircuitBreakerRegistry
90
118
  def initialize(failure_threshold: 5, open_timeout: 30)
91
119
  @failure_threshold = failure_threshold
@@ -1,7 +1,26 @@
1
1
  require "bigdecimal"
2
2
 
3
3
  module Pinot
4
+ # Main entry point for querying Apache Pinot over HTTP.
5
+ #
6
+ # Build a Connection via the factory helpers rather than instantiating directly:
7
+ #
8
+ # # Static broker list
9
+ # conn = Pinot.from_broker_list(["broker1:8099", "broker2:8099"])
10
+ #
11
+ # # Controller-managed broker discovery
12
+ # conn = Pinot.from_controller("controller:9000")
13
+ #
14
+ # # Full configuration
15
+ # conn = Pinot.from_config(Pinot::ClientConfig.new(
16
+ # broker_list: ["broker:8099"],
17
+ # query_timeout_ms: 5_000,
18
+ # use_multistage_engine: true,
19
+ # max_retries: 2,
20
+ # circuit_breaker_enabled: true
21
+ # ))
4
22
  class Connection
23
+ # @return [Integer, nil] default query timeout in milliseconds; can be overridden per-call
5
24
  attr_accessor :query_timeout_ms
6
25
 
7
26
  def initialize(transport:, broker_selector:, use_multistage_engine: false, logger: nil,
@@ -27,6 +46,18 @@ module Pinot
27
46
  @trace = false
28
47
  end
29
48
 
49
+ # Execute a SQL query against +table+ and return a BrokerResponse.
50
+ #
51
+ # @param table [String] Pinot table name (used for broker selection)
52
+ # @param query [String] SQL query string
53
+ # @param query_timeout_ms [Integer, nil] per-call timeout override (ms); overrides
54
+ # the connection-level query_timeout_ms
55
+ # @param headers [Hash] extra HTTP headers merged into this request only
56
+ # @return [BrokerResponse]
57
+ # @raise [BrokerNotFoundError] no broker available for the table
58
+ # @raise [QueryTimeoutError] query exceeded the timeout
59
+ # @raise [BrokerUnavailableError] broker returned 503/504
60
+ # @raise [TransportError] other non-200 HTTP response
30
61
  def execute_sql(table, query, query_timeout_ms: nil, headers: {})
31
62
  Pinot::Instrumentation.instrument(table: table, query: query) do
32
63
  logger.debug "Executing SQL on table=#{table}: #{query}"
@@ -39,10 +70,24 @@ module Pinot
39
70
  end
40
71
  end
41
72
 
73
+ # Convenience wrapper around execute_sql with an explicit timeout.
74
+ # Equivalent to execute_sql(table, query, query_timeout_ms: timeout_ms).
42
75
  def execute_sql_with_timeout(table, query, timeout_ms)
43
76
  execute_sql(table, query, query_timeout_ms: timeout_ms)
44
77
  end
45
78
 
79
+ # Execute a parameterised query by substituting +params+ into +query_pattern+.
80
+ # Each +?+ placeholder in the pattern is replaced by the corresponding value
81
+ # using safe type-aware formatting (strings are quoted and escaped).
82
+ #
83
+ # @param table [String] Pinot table name
84
+ # @param query_pattern [String] SQL template, e.g. "SELECT * FROM t WHERE id = ?"
85
+ # @param params [Array] ordered values; supported types: String, Integer,
86
+ # Float, TrueClass, FalseClass, BigDecimal, Time
87
+ # @param query_timeout_ms [Integer, nil] per-call timeout override (ms)
88
+ # @param headers [Hash] extra HTTP headers for this request
89
+ # @return [BrokerResponse]
90
+ # @raise [RuntimeError] placeholder / param count mismatch or unsupported type
46
91
  def execute_sql_with_params(table, query_pattern, params, query_timeout_ms: nil, headers: {})
47
92
  query = format_query(query_pattern, params)
48
93
  execute_sql(table, query, query_timeout_ms: query_timeout_ms, headers: headers)
@@ -50,6 +95,29 @@ module Pinot
50
95
  raise e
51
96
  end
52
97
 
98
+ # Execute multiple queries in parallel and return results in the same order.
99
+ #
100
+ # Each element of +queries+ must be a Hash with keys +:table+ and +:query+
101
+ # (Strings or Symbols). An optional +:query_timeout_ms+ key overrides the
102
+ # per-query timeout.
103
+ #
104
+ # Each slot in the returned Array is a QueryResult with either a +response+ or
105
+ # an +error+ — failures are isolated so one bad query does not raise for the
106
+ # whole batch.
107
+ #
108
+ # results = conn.execute_many([
109
+ # { table: "orders", query: "SELECT count(*) FROM orders" },
110
+ # { table: "products", query: "SELECT count(*) FROM products" }
111
+ # ], max_concurrency: 4)
112
+ #
113
+ # results.each do |r|
114
+ # puts r.success? ? r.response.result_table.get_long(0, 0) : r.error.message
115
+ # end
116
+ #
117
+ # @param queries [Array<Hash>] query descriptors
118
+ # @param max_concurrency [Integer, nil] maximum simultaneous in-flight queries;
119
+ # nil means unlimited
120
+ # @return [Array<QueryResult>]
53
121
  def execute_many(queries, max_concurrency: nil)
54
122
  return [] if queries.empty?
55
123
 
@@ -79,6 +147,19 @@ module Pinot
79
147
  results
80
148
  end
81
149
 
150
+ # Return a Paginator for cursor-based iteration over large result sets.
151
+ #
152
+ # The query must include a LIMIT clause; the broker stores the result set
153
+ # and returns it in +page_size+ row slices on demand.
154
+ #
155
+ # paginator = conn.paginate("SELECT * FROM myTable LIMIT 50000", page_size: 500)
156
+ # paginator.each_row { |row| puts row.map(&:to_s).join(", ") }
157
+ #
158
+ # @param query [String] SQL query (should include LIMIT)
159
+ # @param page_size [Integer] rows per page (default Paginator::DEFAULT_PAGE_SIZE = 1000)
160
+ # @param table [String, nil] used only for broker selection; nil picks any broker
161
+ # @param extra_headers [Hash] merged into every HTTP request of this cursor session
162
+ # @return [Paginator]
82
163
  def paginate(query, page_size: Paginator::DEFAULT_PAGE_SIZE, table: nil, extra_headers: {})
83
164
  broker = @broker_selector.select_broker(table || "")
84
165
  Paginator.new(
@@ -90,6 +171,17 @@ module Pinot
90
171
  )
91
172
  end
92
173
 
174
+ # Create a PreparedStatement from a query template with +?+ placeholders.
175
+ #
176
+ # stmt = conn.prepare("myTable", "SELECT * FROM myTable WHERE id = ? AND name = ?")
177
+ # stmt.set(1, 42)
178
+ # stmt.set(2, "Alice")
179
+ # resp = stmt.execute
180
+ #
181
+ # @param table [String] Pinot table name (non-empty)
182
+ # @param query_template [String] SQL with one or more +?+ placeholders
183
+ # @return [PreparedStatementImpl]
184
+ # @raise [ArgumentError] if table or query_template is blank, or contains no placeholders
93
185
  def prepare(table, query_template)
94
186
  raise ArgumentError, "table name cannot be empty" if table.nil? || table.strip.empty?
95
187
  raise ArgumentError, "query template cannot be empty" if query_template.nil? || query_template.strip.empty?
@@ -1,9 +1,24 @@
1
1
  module Pinot
2
+ # Build a Connection from a static list of broker addresses.
3
+ #
4
+ # conn = Pinot.from_broker_list(["broker1:8099", "broker2:8099"])
5
+ #
6
+ # @param broker_list [Array<String>] broker host:port entries
7
+ # @param http_client [HttpClient, nil] optional pre-configured HTTP client
8
+ # @return [Connection]
2
9
  def self.from_broker_list(broker_list, http_client: nil)
3
10
  config = ClientConfig.new(broker_list: broker_list)
4
11
  from_config(config, http_client: http_client)
5
12
  end
6
13
 
14
+ # Build a Connection backed by a Pinot controller for automatic broker discovery.
15
+ # The controller is polled in the background to keep the broker list fresh.
16
+ #
17
+ # conn = Pinot.from_controller("controller:9000")
18
+ #
19
+ # @param controller_address [String] controller host:port (or http://host:port)
20
+ # @param http_client [HttpClient, nil] optional pre-configured HTTP client
21
+ # @return [Connection]
7
22
  def self.from_controller(controller_address, http_client: nil)
8
23
  config = ClientConfig.new(
9
24
  controller_config: ControllerConfig.new(controller_address: controller_address)
@@ -11,6 +26,25 @@ module Pinot
11
26
  from_config(config, http_client: http_client)
12
27
  end
13
28
 
29
+ # Build a Connection from a fully specified ClientConfig.
30
+ # This is the most flexible factory: it handles all transport types (HTTP,
31
+ # gRPC, ZooKeeper) and wires up the circuit breaker and retry logic from
32
+ # config flags.
33
+ #
34
+ # config = Pinot::ClientConfig.new(
35
+ # broker_list: ["broker:8099"],
36
+ # query_timeout_ms: 5_000,
37
+ # use_multistage_engine: true,
38
+ # max_retries: 2,
39
+ # retry_interval_ms: 100,
40
+ # circuit_breaker_enabled: true
41
+ # )
42
+ # conn = Pinot.from_config(config)
43
+ #
44
+ # @param config [ClientConfig] fully populated config object
45
+ # @param http_client [HttpClient, nil] optional pre-configured HTTP client
46
+ # @return [Connection]
47
+ # @raise [ConfigurationError] if no broker source is specified in config
14
48
  def self.from_config(config, http_client: nil)
15
49
  config.validate!
16
50
 
@@ -1,14 +1,23 @@
1
1
  module Pinot
2
+ # Low-level instrumentation hook that fires after every query executed via
3
+ # Connection#execute_sql. This is the extension point used by
4
+ # Pinot::ActiveSupportNotifications and any custom observability layer.
5
+ #
6
+ # Register a single callback:
7
+ #
8
+ # Pinot::Instrumentation.on_query = ->(event) do
9
+ # MyMetrics.record(event[:table], event[:duration_ms], event[:success])
10
+ # end
11
+ #
12
+ # The event Hash contains:
13
+ # :table => String — table name passed to execute_sql
14
+ # :query => String — SQL string
15
+ # :duration_ms => Float — wall-clock time in milliseconds
16
+ # :success => Boolean — false when an exception was raised
17
+ # :error => Exception or nil — the exception on failure, nil on success
18
+ #
19
+ # Only one callback can be registered at a time. Set on_query= to nil to remove it.
2
20
  module Instrumentation
3
- # Called around every query execution.
4
- # Implement by setting Pinot::Instrumentation.on_query = proc { |event| ... }
5
- # event is a Hash:
6
- # :table => String
7
- # :query => String
8
- # :duration_ms => Float
9
- # :success => Boolean
10
- # :error => Exception or nil
11
-
12
21
  def self.on_query=(callback)
13
22
  @on_query = callback
14
23
  end
@@ -1,15 +1,43 @@
1
1
  module Pinot
2
- # Implements Pinot's server-side cursor API.
2
+ # Cursor-based pagination using Pinot's native server-side cursor API.
3
3
  #
4
4
  # The broker stores the full result set and returns slices on demand.
5
- # All fetch requests after the first must go to the same broker that
6
- # owns the cursor state (brokerHost:brokerPort from the initial response).
5
+ # All fetch requests after the first are pinned to the broker that owns
6
+ # the cursor (brokerHost:brokerPort from the initial response), ensuring
7
+ # correct broker affinity.
7
8
  #
8
- # Usage:
9
- # paginator = conn.paginate("SELECT * FROM t LIMIT 10000", page_size: 100)
9
+ # == Obtaining a Paginator
10
+ #
11
+ # paginator = conn.paginate(
12
+ # "SELECT * FROM myTable WHERE col > 0",
13
+ # page_size: 500, # rows per page (default 1000)
14
+ # table: nil, # used only for broker selection; omit for single-broker setups
15
+ # extra_headers: {} # merged into every HTTP request
16
+ # )
17
+ #
18
+ # == Iteration
19
+ #
20
+ # # Page-by-page (each page is a BrokerResponse):
10
21
  # paginator.each_page { |resp| process(resp.result_table) }
11
- # paginator.each_row { |row| puts row.map(&:to_s).join(", ") }
12
- # paginator.delete # optional early cleanup; also called automatically on exhaustion
22
+ #
23
+ # # Row-by-row (each row is an Array of JsonNumber/String cells):
24
+ # paginator.each_row { |row| puts row.map(&:to_s).join(", ") }
25
+ #
26
+ # # Enumerable methods work because #each is aliased to #each_row:
27
+ # rows = paginator.to_a
28
+ # paginator.select { |row| row.first.to_s.to_i > 100 }
29
+ #
30
+ # == Cursor lifecycle
31
+ #
32
+ # The cursor is deleted from the broker automatically after the last page is
33
+ # consumed. Call #delete explicitly for early cleanup (e.g. break out of loop).
34
+ # DELETE failures are swallowed — cursors expire naturally on the broker side.
35
+ #
36
+ # == Protocol
37
+ #
38
+ # 1. POST /query/sql?getCursor=true&numRows=N — submit query, get first page + requestId
39
+ # 2. GET /responseStore/{id}/results?offset=K&numRows=N — fetch subsequent pages
40
+ # 3. DELETE /responseStore/{id} — release cursor (best-effort)
13
41
  class Paginator
14
42
  include Enumerable
15
43
 
data/lib/pinot/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Pinot
2
- VERSION = "1.26.0"
2
+ VERSION = "1.27.0"
3
3
  end
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.26.0
4
+ version: 1.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Xiang Fu
@@ -103,6 +103,7 @@ files:
103
103
  - LICENSE
104
104
  - README.md
105
105
  - lib/pinot.rb
106
+ - lib/pinot/active_support_notifications.rb
106
107
  - lib/pinot/broker_selector.rb
107
108
  - lib/pinot/circuit_breaker.rb
108
109
  - lib/pinot/config.rb