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 +4 -4
- data/lib/pinot/active_support_notifications.rb +82 -0
- data/lib/pinot/circuit_breaker.rb +32 -4
- data/lib/pinot/connection.rb +92 -0
- data/lib/pinot/connection_factory.rb +34 -0
- data/lib/pinot/instrumentation.rb +79 -14
- data/lib/pinot/open_telemetry.rb +160 -0
- data/lib/pinot/paginator.rb +35 -7
- data/lib/pinot/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da3d726029acb7167db3fef1a14951e4f4c46beac51f32e88e1e7782eab64fa1
|
|
4
|
+
data.tar.gz: 4271e56a42a4d269bdf8de68a4c631e8320ec2eec4a36deeb3b635e10e8dc933
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
2
|
+
# Per-broker circuit breaker implementing the classic three-state machine:
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
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
|
data/lib/pinot/connection.rb
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
-
@
|
|
67
|
+
@listeners = callback ? [callback] : []
|
|
14
68
|
end
|
|
15
69
|
|
|
70
|
+
# Returns the first registered listener (legacy compat).
|
|
16
71
|
def self.on_query
|
|
17
|
-
@
|
|
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
|
data/lib/pinot/paginator.rb
CHANGED
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
module Pinot
|
|
2
|
-
#
|
|
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
|
|
6
|
-
#
|
|
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
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
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.
|
|
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-
|
|
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
|