pinot-client 1.26.0 → 1.28.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: da3d726029acb7167db3fef1a14951e4f4c46beac51f32e88e1e7782eab64fa1
4
+ data.tar.gz: 4271e56a42a4d269bdf8de68a4c631e8320ec2eec4a36deeb3b635e10e8dc933
5
5
  SHA512:
6
- metadata.gz: 73ec7a9ae84e96f71618f430ed542527fb9f9e16f64b1d0d78a1a8bcb0ba1950f6d7117072f0318ffcc08fdb0df2143be268667d6b96e4914d52fc4f0448c0fa
7
- data.tar.gz: 9fe83c991b5ef549ce915dbe4af55b50143d17d6d9660915d5fd6308798541f81068e557317b8b88af167b55a63ea7781ef14dfff428cd51b671df7d2a0267b2
6
+ metadata.gz: ef69651b14484fceeec26edca916ede91f04ea3224f24d449e2cf022a3bb847e9ca0d429908daad14e10daa72c9bd4719f4e2400dff06431dca1c6126c90bd7e
7
+ data.tar.gz: 8ab20c872830e2387245fadebae91e2739b5e30eb959a9769346df4652e0a3e18d673da2e6a3f88ae5753e55e41e7ed21ae709827d920e338e569444ba4a878a
@@ -0,0 +1,82 @@
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
+ @listener = Pinot::Instrumentation.subscribe(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.unsubscribe(@listener) if @listener
61
+ @listener = nil
62
+ @installed = false
63
+ end
64
+
65
+ def self.notify(event)
66
+ payload = {
67
+ sql: event[:query],
68
+ name: event[:table],
69
+ duration: event[:duration_ms],
70
+ success: event[:success]
71
+ }
72
+
73
+ if (err = event[:error])
74
+ payload[:exception] = [err.class.name, err.message]
75
+ payload[:exception_object] = err
76
+ end
77
+
78
+ ::ActiveSupport::Notifications.instrument(EVENT_NAME, payload)
79
+ end
80
+ private_class_method :notify
81
+ end
82
+ 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,23 +1,92 @@
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, Pinot::OpenTelemetry, and any custom
5
+ # observability layer.
6
+ #
7
+ # == Subscribing (multiple listeners supported)
8
+ #
9
+ # listener = Pinot::Instrumentation.subscribe(->(event) do
10
+ # MyMetrics.record(event[:table], event[:duration_ms], event[:success])
11
+ # end)
12
+ #
13
+ # # Remove later:
14
+ # Pinot::Instrumentation.unsubscribe(listener)
15
+ #
16
+ # == Legacy single-callback API (still supported)
17
+ #
18
+ # Pinot::Instrumentation.on_query = ->(event) { ... }
19
+ # Pinot::Instrumentation.on_query = nil # remove
20
+ #
21
+ # == Around-execution hook (for OTel and similar span-based tools)
22
+ #
23
+ # The `around` hook wraps the entire query execution — the block yields the
24
+ # query, and the hook is responsible for calling Instrumentation.notify when
25
+ # done. Only one around hook can be registered at a time.
26
+ #
27
+ # Pinot::Instrumentation.around = ->(table:, query:) do
28
+ # MyTracer.in_span("pinot") { yield }
29
+ # end
30
+ #
31
+ # == Event Hash keys
32
+ #
33
+ # :table => String — table name passed to execute_sql
34
+ # :query => String — SQL string
35
+ # :duration_ms => Float — wall-clock time in milliseconds
36
+ # :success => Boolean — false when an exception was raised
37
+ # :error => Exception or nil — the exception on failure, nil on success
2
38
  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
39
+ @listeners = []
40
+ @around = nil
11
41
 
42
+ # Add a post-execution listener. Returns the listener so it can be passed
43
+ # to unsubscribe later.
44
+ def self.subscribe(listener)
45
+ @listeners << listener
46
+ listener
47
+ end
48
+
49
+ # Remove a previously subscribed listener.
50
+ def self.unsubscribe(listener)
51
+ @listeners.delete(listener)
52
+ end
53
+
54
+ # Register an around-execution wrapper. Only one wrapper is supported;
55
+ # the new value replaces any previous one. Set to nil to remove.
56
+ def self.around=(wrapper)
57
+ @around = wrapper
58
+ end
59
+
60
+ def self.around
61
+ @around
62
+ end
63
+
64
+ # Legacy single-callback setter. Replaces all listeners with the given
65
+ # callback (or clears them when nil).
12
66
  def self.on_query=(callback)
13
- @on_query = callback
67
+ @listeners = callback ? [callback] : []
14
68
  end
15
69
 
70
+ # Returns the first registered listener (legacy compat).
16
71
  def self.on_query
17
- @on_query
72
+ @listeners.first
18
73
  end
19
74
 
20
75
  def self.instrument(table:, query:)
76
+ if @around
77
+ @around.call(table: table, query: query) { yield }
78
+ else
79
+ _timed_instrument(table: table, query: query) { yield }
80
+ end
81
+ end
82
+
83
+ # Fire all registered listeners with an event hash. Called by the default
84
+ # instrument path and by the OTel around wrapper.
85
+ def self.notify(event)
86
+ @listeners.each { |l| l.call(event) }
87
+ end
88
+
89
+ private_class_method def self._timed_instrument(table:, query:)
21
90
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
91
  result = yield
23
92
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
@@ -28,9 +97,5 @@ module Pinot
28
97
  notify(table: table, query: query, duration_ms: duration_ms, success: false, error: e)
29
98
  raise
30
99
  end
31
-
32
- def self.notify(event)
33
- @on_query&.call(event)
34
- end
35
100
  end
36
101
  end
@@ -0,0 +1,160 @@
1
+ require "pinot"
2
+
3
+ module Pinot
4
+ # Opt-in OpenTelemetry bridge.
5
+ #
6
+ # == Setup
7
+ #
8
+ # Add to an initializer after the opentelemetry SDK is configured:
9
+ #
10
+ # require "pinot/open_telemetry"
11
+ # Pinot::OpenTelemetry.install!
12
+ #
13
+ # == What it does
14
+ #
15
+ # Each call to Connection#execute_sql (and anything built on top of it —
16
+ # execute_sql_with_params, execute_many, PreparedStatement) creates an OTel
17
+ # span named "pinot.query" with attributes following the OpenTelemetry
18
+ # semantic conventions for database spans:
19
+ #
20
+ # db.system = "pinot"
21
+ # db.statement = "<sql>" (the full SQL string)
22
+ # db.name = "<table>" (the Pinot table name)
23
+ # db.operation = "SELECT" (first token of the SQL)
24
+ #
25
+ # On failure the span is marked with error status and the exception is
26
+ # recorded on the span.
27
+ #
28
+ # == Trace-context propagation
29
+ #
30
+ # When installed, every outbound HTTP request to a broker is injected with
31
+ # W3C Trace Context headers (traceparent / tracestate) so distributed traces
32
+ # flow through Pinot. This relies on OpenTelemetry.propagation being
33
+ # configured (the default SDK sets this up automatically).
34
+ #
35
+ # == Feature flag
36
+ #
37
+ # The bridge can be toggled at runtime without reinstalling:
38
+ #
39
+ # Pinot::OpenTelemetry.enabled = false # pause tracing (e.g. in tests)
40
+ # Pinot::OpenTelemetry.enabled = true # resume
41
+ # Pinot::OpenTelemetry.enabled? # => true / false
42
+ #
43
+ # == Lifecycle
44
+ #
45
+ # Pinot::OpenTelemetry.install! # idempotent
46
+ # Pinot::OpenTelemetry.installed? # => true
47
+ # Pinot::OpenTelemetry.uninstall! # removes hooks; leaves transport unpatched
48
+ #
49
+ # Note: this gem does NOT depend on opentelemetry-api or opentelemetry-sdk.
50
+ # Both must be present and initialized before install! is called.
51
+ module OpenTelemetry
52
+ SPAN_NAME = "pinot.query"
53
+ DB_SYSTEM = "pinot"
54
+ TRACER_NAME = "pinot-client"
55
+
56
+ @installed = false
57
+ @enabled = true
58
+
59
+ # Enable or disable tracing at runtime without uninstalling.
60
+ def self.enabled=(val)
61
+ @enabled = val ? true : false
62
+ end
63
+
64
+ def self.enabled?
65
+ @enabled
66
+ end
67
+
68
+ def self.install!
69
+ return if installed?
70
+
71
+ _install_around_hook
72
+ _patch_transport
73
+ @installed = true
74
+ end
75
+
76
+ def self.installed?
77
+ @installed
78
+ end
79
+
80
+ def self.uninstall!
81
+ ::Pinot::Instrumentation.around = nil
82
+ @installed = false
83
+ # Note: JsonHttpTransport prepend is permanent once applied (Ruby limitation).
84
+ # Disable the propagator by unsetting the flag — it no-ops when disabled.
85
+ end
86
+
87
+ # -------------------------------------------------------------------------
88
+ # Internal helpers
89
+ # -------------------------------------------------------------------------
90
+
91
+ def self._install_around_hook
92
+ ::Pinot::Instrumentation.around = method(:_around)
93
+ end
94
+ private_class_method :_install_around_hook
95
+
96
+ def self._around(table:, query:)
97
+ unless @enabled
98
+ # Bypass tracing; still time and notify listeners.
99
+ return ::Pinot::Instrumentation.send(:_timed_instrument, table: table, query: query) { yield }
100
+ end
101
+
102
+ tracer = ::OpenTelemetry.tracer_provider.tracer(TRACER_NAME, ::Pinot::VERSION)
103
+ attrs = _span_attributes(table, query)
104
+
105
+ tracer.in_span(SPAN_NAME, attributes: attrs, kind: :client) do |span|
106
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
107
+ begin
108
+ result = yield
109
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
110
+ ::Pinot::Instrumentation.notify(
111
+ table: table, query: query, duration_ms: duration_ms, success: true, error: nil
112
+ )
113
+ result
114
+ rescue => e
115
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
116
+ span.record_exception(e)
117
+ span.status = ::OpenTelemetry::Trace::Status.error(e.message)
118
+ ::Pinot::Instrumentation.notify(
119
+ table: table, query: query, duration_ms: duration_ms, success: false, error: e
120
+ )
121
+ raise
122
+ end
123
+ end
124
+ end
125
+ private_class_method :_around
126
+
127
+ def self._span_attributes(table, query)
128
+ attrs = {
129
+ "db.system" => DB_SYSTEM,
130
+ "db.statement" => query,
131
+ "db.name" => table.to_s
132
+ }
133
+ op = query.to_s.lstrip.split(/\s+/, 2).first&.upcase
134
+ attrs["db.operation"] = op if op && !op.empty?
135
+ attrs
136
+ end
137
+ private_class_method :_span_attributes
138
+
139
+ # Patch JsonHttpTransport to inject W3C trace-context headers into every
140
+ # outbound HTTP request. Applied once via Module#prepend.
141
+ def self._patch_transport
142
+ return if ::Pinot::JsonHttpTransport.ancestors.include?(TraceContextInjector)
143
+ ::Pinot::JsonHttpTransport.prepend(TraceContextInjector)
144
+ end
145
+ private_class_method :_patch_transport
146
+
147
+ # Prepended into JsonHttpTransport. Injects traceparent/tracestate into
148
+ # request headers when the bridge is enabled and a current span exists.
149
+ module TraceContextInjector
150
+ def execute(broker_address, request, extra_request_headers: {})
151
+ otel = ::Pinot::OpenTelemetry
152
+ return super unless otel.installed? && otel.enabled?
153
+
154
+ carrier = {}
155
+ ::OpenTelemetry.propagation.inject(carrier)
156
+ super(broker_address, request, extra_request_headers: extra_request_headers.merge(carrier))
157
+ end
158
+ end
159
+ end
160
+ 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.28.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
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.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Xiang Fu
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-19 00:00:00.000000000 Z
11
+ date: 2026-04-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -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
@@ -115,6 +116,7 @@ files:
115
116
  - lib/pinot/grpc_transport.rb
116
117
  - lib/pinot/instrumentation.rb
117
118
  - lib/pinot/logger.rb
119
+ - lib/pinot/open_telemetry.rb
118
120
  - lib/pinot/paginator.rb
119
121
  - lib/pinot/prepared_statement.rb
120
122
  - lib/pinot/proto/broker_service.proto