clickhouse-ruby 0.2.0 → 0.3.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.
@@ -167,6 +167,91 @@ module ClickhouseRuby
167
167
  result.first["version"]
168
168
  end
169
169
 
170
+ # Returns the query execution plan for a SQL query
171
+ #
172
+ # Uses EXPLAIN to show how ClickHouse will execute the query.
173
+ #
174
+ # @param sql [String] the SQL query to explain
175
+ # @param type [Symbol] type of explain (:plan, :pipeline, :estimate, :ast, :syntax)
176
+ # @param settings [Hash] ClickHouse settings
177
+ # @return [Array<Hash>] the query plan rows
178
+ #
179
+ # @example Basic explain
180
+ # client.explain('SELECT * FROM events WHERE date > today()')
181
+ # # => [{"explain" => "Expression ..."}]
182
+ #
183
+ # @example Pipeline explain
184
+ # client.explain('SELECT count() FROM events', type: :pipeline)
185
+ #
186
+ # @example Estimate query cost
187
+ # client.explain('SELECT * FROM events', type: :estimate)
188
+ def explain(sql, type: :plan, settings: {})
189
+ explain_keyword = case type
190
+ when :plan then "EXPLAIN"
191
+ when :pipeline then "EXPLAIN PIPELINE"
192
+ when :estimate then "EXPLAIN ESTIMATE"
193
+ when :ast then "EXPLAIN AST"
194
+ when :syntax then "EXPLAIN SYNTAX"
195
+ else
196
+ raise ArgumentError, "Unknown explain type: #{type}. Valid types: :plan, :pipeline, :estimate, :ast, :syntax"
197
+ end
198
+
199
+ explain_sql = "#{explain_keyword} #{sql.strip}"
200
+ execute(explain_sql, settings: settings).to_a
201
+ end
202
+
203
+ # Returns detailed health status of the client and server
204
+ #
205
+ # Provides comprehensive health information including server status,
206
+ # connection pool state, and server metrics.
207
+ #
208
+ # @return [Hash] health status
209
+ #
210
+ # @example
211
+ # client.health_check
212
+ # # => {
213
+ # # status: :healthy,
214
+ # # server_reachable: true,
215
+ # # server_version: "24.1.0",
216
+ # # pool: { available: 3, in_use: 2, total: 5 },
217
+ # # uptime_seconds: 3600
218
+ # # }
219
+ def health_check
220
+ started_at = Instrumentation.monotonic_time
221
+ server_reachable = ping
222
+ version = nil
223
+ server_metrics = {}
224
+
225
+ if server_reachable
226
+ begin
227
+ version = server_version
228
+
229
+ # Get server uptime and metrics
230
+ metrics_result = execute(<<~SQL, settings: { max_execution_time: 5 })
231
+ SELECT
232
+ uptime() AS uptime_seconds,
233
+ currentDatabase() AS current_database
234
+ SQL
235
+ server_metrics = metrics_result.first if metrics_result.any?
236
+ rescue StandardError
237
+ # Ignore errors fetching extended info
238
+ end
239
+ end
240
+
241
+ pool_health = @pool.health_check
242
+ check_duration_ms = Instrumentation.duration_ms(started_at)
243
+
244
+ {
245
+ status: server_reachable ? :healthy : :unhealthy,
246
+ server_reachable: server_reachable,
247
+ server_version: version,
248
+ current_database: server_metrics["current_database"],
249
+ server_uptime_seconds: server_metrics["uptime_seconds"],
250
+ pool: pool_health,
251
+ check_duration_ms: check_duration_ms.round(2),
252
+ }
253
+ end
254
+
170
255
  # Closes all connections in the pool
171
256
  #
172
257
  # Call this when shutting down to clean up resources.
@@ -258,6 +343,9 @@ module ClickhouseRuby
258
343
  # @param format [String] response format (default: JSONCompact)
259
344
  # @return [Result] query results
260
345
  def execute_internal(sql, settings: {}, format: DEFAULT_FORMAT)
346
+ started_at = Instrumentation.monotonic_time
347
+ row_count = 0
348
+
261
349
  # Build the query with format
262
350
  query_with_format = "#{sql.strip} FORMAT #{format}"
263
351
 
@@ -268,7 +356,16 @@ module ClickhouseRuby
268
356
  response = execute_request(query_with_format, params)
269
357
 
270
358
  # Parse response based on format
271
- parse_response(response, sql, format)
359
+ result = parse_response(response, sql, format)
360
+ row_count = result.size
361
+
362
+ # Instrument successful query
363
+ instrument_query_complete(sql, settings, started_at, row_count)
364
+
365
+ result
366
+ rescue StandardError => e
367
+ instrument_query_error(sql, settings, started_at, e)
368
+ raise
272
369
  end
273
370
 
274
371
  # Internal insert without retry wrapper
@@ -280,6 +377,9 @@ module ClickhouseRuby
280
377
  # @param format [Symbol] insert format
281
378
  # @return [Boolean] true if successful
282
379
  def insert_internal(table, rows, columns: nil, settings: {}, format: :json_each_row)
380
+ started_at = Instrumentation.monotonic_time
381
+ row_count = rows.size
382
+
283
383
  # Determine columns from first row if not specified
284
384
  columns ||= rows.first.keys.map(&:to_s)
285
385
 
@@ -312,7 +412,13 @@ module ClickhouseRuby
312
412
  handle_response(response, sql)
313
413
  end
314
414
 
415
+ # Instrument successful insert
416
+ instrument_insert_complete(table, row_count, settings, started_at)
417
+
315
418
  true
419
+ rescue StandardError => e
420
+ instrument_insert_error(table, row_count, settings, started_at, e)
421
+ raise
316
422
  end
317
423
 
318
424
  # Builds query parameters including database and settings
@@ -574,5 +680,110 @@ module ClickhouseRuby
574
680
  "(code: #{code || "unknown"}, http: #{http_status}, sql: #{truncate_sql(sql, 200)})",
575
681
  )
576
682
  end
683
+
684
+ # ========================================================================
685
+ # Instrumentation helpers
686
+ # ========================================================================
687
+
688
+ # Instruments a successful query completion
689
+ #
690
+ # @param sql [String] the SQL query
691
+ # @param settings [Hash] query settings
692
+ # @param started_at [Float] monotonic start time
693
+ # @param row_count [Integer] number of rows returned
694
+ def instrument_query_complete(sql, settings, started_at, row_count)
695
+ duration_ms = Instrumentation.duration_ms(started_at)
696
+
697
+ payload = {
698
+ sql: truncate_sql(sql, 500),
699
+ settings: settings,
700
+ row_count: row_count,
701
+ duration_ms: duration_ms,
702
+ }
703
+
704
+ Instrumentation.publish(Instrumentation::EVENTS[:query_complete], payload)
705
+ log_query_timing(sql, duration_ms) if @logger && @config.log_level == :debug
706
+ end
707
+
708
+ # Instruments a query error
709
+ #
710
+ # @param sql [String] the SQL query
711
+ # @param settings [Hash] query settings
712
+ # @param started_at [Float] monotonic start time
713
+ # @param error [StandardError] the error
714
+ def instrument_query_error(sql, settings, started_at, error)
715
+ duration_ms = Instrumentation.duration_ms(started_at)
716
+
717
+ payload = {
718
+ sql: truncate_sql(sql, 500),
719
+ settings: settings,
720
+ duration_ms: duration_ms,
721
+ exception: [error.class.name, error.message],
722
+ }
723
+
724
+ Instrumentation.publish(Instrumentation::EVENTS[:query_error], payload)
725
+ end
726
+
727
+ # Instruments a successful insert completion
728
+ #
729
+ # @param table [String] the table name
730
+ # @param row_count [Integer] number of rows inserted
731
+ # @param settings [Hash] query settings
732
+ # @param started_at [Float] monotonic start time
733
+ def instrument_insert_complete(table, row_count, settings, started_at)
734
+ duration_ms = Instrumentation.duration_ms(started_at)
735
+
736
+ payload = {
737
+ table: table,
738
+ row_count: row_count,
739
+ settings: settings,
740
+ duration_ms: duration_ms,
741
+ }
742
+
743
+ Instrumentation.publish(Instrumentation::EVENTS[:insert_complete], payload)
744
+ log_insert_timing(table, row_count, duration_ms) if @logger && @config.log_level == :debug
745
+ end
746
+
747
+ # Instruments an insert error
748
+ #
749
+ # @param table [String] the table name
750
+ # @param row_count [Integer] number of rows attempted
751
+ # @param settings [Hash] query settings
752
+ # @param started_at [Float] monotonic start time
753
+ # @param error [StandardError] the error
754
+ def instrument_insert_error(table, row_count, settings, started_at, error)
755
+ duration_ms = Instrumentation.duration_ms(started_at)
756
+
757
+ payload = {
758
+ table: table,
759
+ row_count: row_count,
760
+ settings: settings,
761
+ duration_ms: duration_ms,
762
+ exception: [error.class.name, error.message],
763
+ }
764
+
765
+ Instrumentation.publish(Instrumentation::EVENTS[:insert_complete], payload)
766
+ end
767
+
768
+ # Logs query timing at debug level
769
+ #
770
+ # @param sql [String] the SQL query
771
+ # @param duration_ms [Float] duration in milliseconds
772
+ def log_query_timing(sql, duration_ms)
773
+ @logger.debug(
774
+ "[ClickhouseRuby] Query completed in #{duration_ms.round(2)}ms: #{truncate_sql(sql, 100)}",
775
+ )
776
+ end
777
+
778
+ # Logs insert timing at debug level
779
+ #
780
+ # @param table [String] the table name
781
+ # @param row_count [Integer] number of rows
782
+ # @param duration_ms [Float] duration in milliseconds
783
+ def log_insert_timing(table, row_count, duration_ms)
784
+ @logger.debug(
785
+ "[ClickhouseRuby] Insert #{row_count} rows into #{table} in #{duration_ms.round(2)}ms",
786
+ )
787
+ end
577
788
  end
578
789
  end
@@ -69,11 +69,26 @@ module ClickhouseRuby
69
69
  # @return [Object] the block's return value
70
70
  # @raise [PoolTimeout] if no connection becomes available
71
71
  def with_connection
72
+ started_at = Instrumentation.monotonic_time
72
73
  conn = checkout
74
+ wait_time_ms = Instrumentation.duration_ms(started_at)
75
+
76
+ # Publish pool checkout event
77
+ Instrumentation.publish(Instrumentation::EVENTS[:pool_checkout], {
78
+ pool_id: object_id,
79
+ wait_time_ms: wait_time_ms,
80
+ connection_id: conn.object_id,
81
+ },)
82
+
73
83
  begin
74
84
  yield conn
75
85
  ensure
76
86
  checkin(conn)
87
+ # Publish pool checkin event
88
+ Instrumentation.publish(Instrumentation::EVENTS[:pool_checkin], {
89
+ pool_id: object_id,
90
+ connection_id: conn.object_id,
91
+ },)
77
92
  end
78
93
  end
79
94
 
@@ -109,6 +124,13 @@ module ClickhouseRuby
109
124
  remaining = deadline - Time.now
110
125
  if remaining <= 0
111
126
  @total_timeouts += 1
127
+ # Publish pool timeout event
128
+ Instrumentation.publish(Instrumentation::EVENTS[:pool_timeout], {
129
+ pool_id: object_id,
130
+ wait_time_ms: @timeout * 1000,
131
+ pool_size: @size,
132
+ in_use: @in_use.size,
133
+ },)
112
134
  raise PoolTimeout, "Could not obtain a connection from the pool within #{@timeout} seconds " \
113
135
  "(pool size: #{@size}, in use: #{@in_use.size})"
114
136
  end
@@ -250,7 +272,57 @@ module ClickhouseRuby
250
272
  total_connections: @all_connections.size,
251
273
  total_checkouts: @total_checkouts,
252
274
  total_timeouts: @total_timeouts,
253
- uptime_seconds: Time.now - @created_at,
275
+ uptime_seconds: (Time.now - @created_at).round(2),
276
+ }
277
+ end
278
+ end
279
+
280
+ # Returns detailed pool statistics for monitoring
281
+ #
282
+ # Provides comprehensive metrics suitable for Prometheus/StatsD export.
283
+ #
284
+ # @return [Hash] detailed pool statistics
285
+ #
286
+ # @example
287
+ # pool.detailed_stats
288
+ # # => {
289
+ # # capacity: 5,
290
+ # # connections: { total: 5, available: 3, in_use: 2 },
291
+ # # utilization_percent: 40.0,
292
+ # # checkouts: { total: 1000, rate_per_minute: 16.7 },
293
+ # # timeouts: { total: 5, rate_per_minute: 0.08 },
294
+ # # uptime_seconds: 3600
295
+ # # }
296
+ def detailed_stats
297
+ @mutex.synchronize do
298
+ uptime = Time.now - @created_at
299
+ uptime_minutes = uptime / 60.0
300
+
301
+ in_use = @in_use.size
302
+ available = @available.size
303
+ total = @all_connections.size
304
+ utilization = total.positive? ? (in_use.to_f / total * 100).round(2) : 0.0
305
+
306
+ checkout_rate = uptime_minutes.positive? ? (@total_checkouts / uptime_minutes).round(2) : 0.0
307
+ timeout_rate = uptime_minutes.positive? ? (@total_timeouts / uptime_minutes).round(4) : 0.0
308
+
309
+ {
310
+ capacity: @size,
311
+ connections: {
312
+ total: total,
313
+ available: available,
314
+ in_use: in_use,
315
+ },
316
+ utilization_percent: utilization,
317
+ checkouts: {
318
+ total: @total_checkouts,
319
+ rate_per_minute: checkout_rate,
320
+ },
321
+ timeouts: {
322
+ total: @total_timeouts,
323
+ rate_per_minute: timeout_rate,
324
+ },
325
+ uptime_seconds: uptime.round(2),
254
326
  }
255
327
  end
256
328
  end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickhouseRuby
4
+ # Instrumentation module for observability and monitoring
5
+ #
6
+ # Provides ActiveSupport::Notifications integration when available,
7
+ # with graceful fallback for non-Rails applications.
8
+ #
9
+ # @example Subscribe to events (with ActiveSupport)
10
+ # ActiveSupport::Notifications.subscribe(/clickhouse_ruby/) do |name, start, finish, id, payload|
11
+ # duration = finish - start
12
+ # Rails.logger.info "#{name} took #{duration}s"
13
+ # end
14
+ #
15
+ # @example Subscribe to specific events
16
+ # ActiveSupport::Notifications.subscribe('clickhouse_ruby.query.complete') do |*args|
17
+ # event = ActiveSupport::Notifications::Event.new(*args)
18
+ # puts "Query: #{event.payload[:sql]} took #{event.duration}ms"
19
+ # end
20
+ #
21
+ module Instrumentation
22
+ # Event names for instrumentation
23
+ EVENTS = {
24
+ query_start: "clickhouse_ruby.query.start",
25
+ query_complete: "clickhouse_ruby.query.complete",
26
+ query_error: "clickhouse_ruby.query.error",
27
+ insert_start: "clickhouse_ruby.insert.start",
28
+ insert_complete: "clickhouse_ruby.insert.complete",
29
+ pool_checkout: "clickhouse_ruby.pool.checkout",
30
+ pool_checkin: "clickhouse_ruby.pool.checkin",
31
+ pool_timeout: "clickhouse_ruby.pool.timeout",
32
+ }.freeze
33
+
34
+ class << self
35
+ # Check if ActiveSupport::Notifications is available
36
+ #
37
+ # @return [Boolean] true if ActiveSupport::Notifications is available
38
+ def available?
39
+ defined?(ActiveSupport::Notifications)
40
+ end
41
+
42
+ # Instrument a block of code with timing and event notification
43
+ #
44
+ # When ActiveSupport::Notifications is available, publishes an event
45
+ # with the given name and payload. Otherwise, still tracks timing
46
+ # for logging purposes.
47
+ #
48
+ # @param event_name [String] the event name to publish
49
+ # @param payload [Hash] additional payload data
50
+ # @yield the block to instrument
51
+ # @return [Object] the result of the block
52
+ #
53
+ # @example
54
+ # Instrumentation.instrument('clickhouse_ruby.query.complete', sql: 'SELECT 1') do
55
+ # execute_query
56
+ # end
57
+ def instrument(event_name, payload = {})
58
+ if available?
59
+ ActiveSupport::Notifications.instrument(event_name, payload) { yield }
60
+ else
61
+ instrument_without_as(event_name, payload) { yield }
62
+ end
63
+ end
64
+
65
+ # Publish an event without a block (for start/error events)
66
+ #
67
+ # @param event_name [String] the event name to publish
68
+ # @param payload [Hash] the event payload
69
+ # @return [void]
70
+ def publish(event_name, payload = {})
71
+ return unless available?
72
+
73
+ ActiveSupport::Notifications.publish(event_name, payload)
74
+ end
75
+
76
+ # Returns a monotonic timestamp for duration calculation
77
+ #
78
+ # Uses Process.clock_gettime for accurate timing that isn't
79
+ # affected by system clock changes.
80
+ #
81
+ # @return [Float] monotonic timestamp in seconds
82
+ def monotonic_time
83
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ end
85
+
86
+ # Calculate duration in milliseconds from a start time
87
+ #
88
+ # @param started_at [Float] monotonic start time
89
+ # @return [Float] duration in milliseconds
90
+ def duration_ms(started_at)
91
+ (monotonic_time - started_at) * 1000
92
+ end
93
+
94
+ private
95
+
96
+ # Fallback instrumentation when ActiveSupport is not available
97
+ #
98
+ # Still tracks timing so it can be used for logging.
99
+ #
100
+ # @param event_name [String] the event name
101
+ # @param payload [Hash] the payload
102
+ # @yield the block to execute
103
+ # @return [Object] the result of the block
104
+ def instrument_without_as(_event_name, payload)
105
+ started_at = monotonic_time
106
+ result = yield
107
+ payload[:duration_ms] = duration_ms(started_at)
108
+ result
109
+ rescue StandardError => e
110
+ payload[:duration_ms] = duration_ms(started_at)
111
+ payload[:exception] = [e.class.name, e.message]
112
+ raise
113
+ end
114
+ end
115
+
116
+ # Helper module for including instrumentation in classes
117
+ module Helpers
118
+ private
119
+
120
+ # Instrument a query operation
121
+ #
122
+ # @param sql [String] the SQL query
123
+ # @param settings [Hash] query settings
124
+ # @yield the block to execute
125
+ # @return [Object] the result
126
+ def instrument_query(sql, settings: {})
127
+ payload = {
128
+ sql: sql,
129
+ settings: settings,
130
+ connection_id: object_id,
131
+ }
132
+
133
+ Instrumentation.instrument(EVENTS[:query_complete], payload) { yield }
134
+ end
135
+
136
+ # Instrument an insert operation
137
+ #
138
+ # @param table [String] the table name
139
+ # @param row_count [Integer] number of rows
140
+ # @param settings [Hash] query settings
141
+ # @yield the block to execute
142
+ # @return [Object] the result
143
+ def instrument_insert(table, row_count:, settings: {})
144
+ payload = {
145
+ table: table,
146
+ row_count: row_count,
147
+ settings: settings,
148
+ connection_id: object_id,
149
+ }
150
+
151
+ Instrumentation.instrument(EVENTS[:insert_complete], payload) { yield }
152
+ end
153
+
154
+ # Instrument a pool checkout operation
155
+ #
156
+ # @yield the block to execute
157
+ # @return [Object] the result
158
+ def instrument_pool_checkout
159
+ payload = { pool_id: object_id }
160
+
161
+ Instrumentation.instrument(EVENTS[:pool_checkout], payload) { yield }
162
+ end
163
+
164
+ # Publish a pool timeout event
165
+ #
166
+ # @param wait_time [Float] how long we waited before timeout
167
+ # @return [void]
168
+ def publish_pool_timeout(wait_time:)
169
+ Instrumentation.publish(EVENTS[:pool_timeout], {
170
+ pool_id: object_id,
171
+ wait_time_ms: wait_time * 1000,
172
+ },)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClickhouseRuby
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "clickhouse_ruby/version"
4
4
  require_relative "clickhouse_ruby/errors"
5
5
  require_relative "clickhouse_ruby/configuration"
6
+ require_relative "clickhouse_ruby/instrumentation"
6
7
  require_relative "clickhouse_ruby/types"
7
8
  require_relative "clickhouse_ruby/result"
8
9
  require_relative "clickhouse_ruby/retry_handler"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clickhouse-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mohamad Kamar
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
110
  version: '2.20'
111
+ - !ruby/object:Gem::Dependency
112
+ name: benchmark-ips
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '2.12'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '2.12'
111
125
  description: A lightweight Ruby client for ClickHouse with optional ActiveRecord integration.
112
126
  Provides a simple interface for querying, inserting, and managing ClickHouse databases.
113
127
  email:
@@ -123,14 +137,19 @@ files:
123
137
  - lib/clickhouse_ruby/active_record.rb
124
138
  - lib/clickhouse_ruby/active_record/arel_visitor.rb
125
139
  - lib/clickhouse_ruby/active_record/connection_adapter.rb
140
+ - lib/clickhouse_ruby/active_record/generators/migration_generator.rb
141
+ - lib/clickhouse_ruby/active_record/generators/templates/create_table.rb.tt
142
+ - lib/clickhouse_ruby/active_record/generators/templates/migration.rb.tt
126
143
  - lib/clickhouse_ruby/active_record/railtie.rb
127
144
  - lib/clickhouse_ruby/active_record/relation_extensions.rb
145
+ - lib/clickhouse_ruby/active_record/schema_dumper.rb
128
146
  - lib/clickhouse_ruby/active_record/schema_statements.rb
129
147
  - lib/clickhouse_ruby/client.rb
130
148
  - lib/clickhouse_ruby/configuration.rb
131
149
  - lib/clickhouse_ruby/connection.rb
132
150
  - lib/clickhouse_ruby/connection_pool.rb
133
151
  - lib/clickhouse_ruby/errors.rb
152
+ - lib/clickhouse_ruby/instrumentation.rb
134
153
  - lib/clickhouse_ruby/result.rb
135
154
  - lib/clickhouse_ruby/retry_handler.rb
136
155
  - lib/clickhouse_ruby/streaming_result.rb