ch_connect 0.2.2 → 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.
- checksums.yaml +4 -4
- data/.standard.yml +5 -0
- data/CHANGELOG.md +10 -1
- data/README.md +156 -87
- data/ext/ch_connect_native/ch_connect_native.c +1001 -0
- data/ext/ch_connect_native/extconf.rb +32 -0
- data/lib/ch_connect/config.rb +60 -19
- data/lib/ch_connect/connection.rb +382 -13
- data/lib/ch_connect/response.rb +0 -8
- data/lib/ch_connect/version.rb +1 -1
- data/lib/ch_connect.rb +14 -11
- data/vendor/clickhouse-c/LICENSE +203 -0
- data/vendor/clickhouse-c/VENDOR.md +24 -0
- data/vendor/clickhouse-c/clickhouse-async.h +301 -0
- data/vendor/clickhouse-c/clickhouse-client.h +995 -0
- data/vendor/clickhouse-c/clickhouse-compression.h +634 -0
- data/vendor/clickhouse-c/clickhouse.h +3394 -0
- metadata +19 -14
- data/lib/ch_connect/body_reader.rb +0 -79
- data/lib/ch_connect/http_transport.rb +0 -59
- data/lib/ch_connect/native_format_parser.rb +0 -405
- data/lib/ch_connect/transport_result.rb +0 -12
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
abort "ch_connect requires MRI Ruby" unless RUBY_ENGINE == "ruby"
|
|
4
|
+
|
|
5
|
+
require "mkmf"
|
|
6
|
+
|
|
7
|
+
vendor_dir = File.expand_path("../../vendor/clickhouse-c", __dir__)
|
|
8
|
+
$INCFLAGS << " -I#{vendor_dir}"
|
|
9
|
+
|
|
10
|
+
append_cflags("-O2")
|
|
11
|
+
append_cflags("-std=c11")
|
|
12
|
+
|
|
13
|
+
# Help mkmf find Homebrew-installed libraries on macOS.
|
|
14
|
+
if RUBY_PLATFORM.include?("darwin")
|
|
15
|
+
%w[lz4 zstd].each do |pkg|
|
|
16
|
+
prefix = `brew --prefix #{pkg} 2>/dev/null`.strip
|
|
17
|
+
next if prefix.empty? || !File.directory?(prefix)
|
|
18
|
+
|
|
19
|
+
$INCFLAGS << " -I#{prefix}/include"
|
|
20
|
+
$LDFLAGS << " -L#{prefix}/lib"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
have_lz4 = have_header("lz4.h") && have_library("lz4", "LZ4_decompress_safe")
|
|
25
|
+
have_zstd = have_header("zstd.h") && have_library("zstd", "ZSTD_decompress")
|
|
26
|
+
|
|
27
|
+
$defs << (have_lz4 ? "-DCHC_EXT_HAVE_LZ4=1" : "-DCHC_NO_LZ4")
|
|
28
|
+
$defs << (have_zstd ? "-DCHC_EXT_HAVE_ZSTD=1" : "-DCHC_NO_ZSTD")
|
|
29
|
+
|
|
30
|
+
message "ch_connect_native: lz4=#{have_lz4} zstd=#{have_zstd} (TLS is handled in Ruby via openssl stdlib)\n"
|
|
31
|
+
|
|
32
|
+
create_makefile("ch_connect/ch_connect_native")
|
data/lib/ch_connect/config.rb
CHANGED
|
@@ -10,26 +10,37 @@ module ChConnect
|
|
|
10
10
|
#
|
|
11
11
|
# @example Using URL
|
|
12
12
|
# config = ChConnect::Config.new
|
|
13
|
-
# config.url = "
|
|
13
|
+
# config.url = "clickhouse://user:pass@localhost:9000/mydb"
|
|
14
14
|
class Config
|
|
15
|
+
URL_SCHEMES = {
|
|
16
|
+
"clickhouse" => false,
|
|
17
|
+
"tcp" => false,
|
|
18
|
+
"clickhouses" => true,
|
|
19
|
+
"tcps" => true
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
15
22
|
DEFAULTS = {
|
|
16
|
-
scheme: "http",
|
|
17
23
|
host: "localhost",
|
|
18
|
-
port:
|
|
24
|
+
port: nil,
|
|
25
|
+
compression: :lz4,
|
|
26
|
+
ssl: false,
|
|
27
|
+
ssl_verify: true,
|
|
28
|
+
ssl_ca: nil,
|
|
19
29
|
database: "default",
|
|
20
|
-
username: "",
|
|
30
|
+
username: "default",
|
|
21
31
|
password: "",
|
|
22
32
|
connection_timeout: 5,
|
|
23
33
|
read_timeout: 60,
|
|
24
34
|
write_timeout: 60,
|
|
25
|
-
keep_alive_timeout:
|
|
35
|
+
keep_alive_timeout: 60,
|
|
26
36
|
pool_size: 100,
|
|
27
37
|
pool_timeout: 5,
|
|
28
38
|
max_retries: 3,
|
|
39
|
+
retry_base_interval: 0.05,
|
|
40
|
+
retry_max_interval: 1.0,
|
|
29
41
|
instrumenter: NullInstrumenter.new
|
|
30
42
|
}.freeze
|
|
31
43
|
|
|
32
|
-
# @return [String] URL scheme (http or https)
|
|
33
44
|
# @return [String] ClickHouse server hostname
|
|
34
45
|
# @return [Integer] ClickHouse server port
|
|
35
46
|
# @return [String] Database name
|
|
@@ -38,47 +49,77 @@ module ChConnect
|
|
|
38
49
|
# @return [Integer] Connection timeout in seconds
|
|
39
50
|
# @return [Integer] Read timeout in seconds
|
|
40
51
|
# @return [Integer] Write timeout in seconds
|
|
41
|
-
# @return [
|
|
52
|
+
# @return [Numeric, nil] Maximum pooled connection idle time in seconds
|
|
42
53
|
# @return [Integer] Connection pool size
|
|
43
54
|
# @return [Integer] Pool checkout timeout in seconds
|
|
44
|
-
# @return [Integer] Max
|
|
55
|
+
# @return [Integer] Max retries for establishment failures and opted-in
|
|
56
|
+
# idempotent query transport failures
|
|
57
|
+
# @return [Numeric] Initial retry delay in seconds; zero disables backoff
|
|
58
|
+
# @return [Numeric] Maximum retry delay in seconds
|
|
45
59
|
# @return [#instrument] Instrumenter for query instrumentation
|
|
46
|
-
|
|
60
|
+
# @return [Integer] Native protocol port
|
|
61
|
+
# @return [Symbol, nil] Block compression: :lz4 (default), :zstd or nil
|
|
62
|
+
# @return [Boolean] Use TLS (default: false)
|
|
63
|
+
# @return [Boolean] Verify the server certificate when ssl is enabled (default: true)
|
|
64
|
+
# @return [String, nil] Path to a CA certificate file for TLS verification (default: system CA store)
|
|
65
|
+
attr_accessor :compression, :ssl, :ssl_verify, :ssl_ca, :host, :database, :username, :password, :connection_timeout, :read_timeout, :write_timeout, :keep_alive_timeout, :pool_size, :pool_timeout, :max_retries, :retry_base_interval, :retry_max_interval, :instrumenter
|
|
66
|
+
attr_writer :port
|
|
47
67
|
|
|
48
68
|
# Creates a new configuration instance.
|
|
49
69
|
#
|
|
50
70
|
# @param params [Hash] configuration options
|
|
51
|
-
# @option params [String] :scheme URL scheme (default: "http")
|
|
52
71
|
# @option params [String] :host server hostname (default: "localhost")
|
|
53
|
-
# @option params [Integer] :port
|
|
72
|
+
# @option params [Integer, nil] :port native endpoint port (default: 9000, or 9440 with TLS)
|
|
54
73
|
# @option params [String] :database database name (default: "default")
|
|
55
|
-
# @option params [String] :username authentication username (default: "")
|
|
74
|
+
# @option params [String] :username authentication username (default: "default")
|
|
56
75
|
# @option params [String] :password authentication password (default: "")
|
|
57
76
|
# @option params [Integer] :connection_timeout connection timeout in seconds (default: 5)
|
|
58
77
|
# @option params [Integer] :read_timeout read timeout in seconds (default: 60)
|
|
59
78
|
# @option params [Integer] :write_timeout write timeout in seconds (default: 60)
|
|
60
|
-
# @option params [
|
|
79
|
+
# @option params [Numeric, nil] :keep_alive_timeout pooled TCP connection idle timeout (default: 60)
|
|
61
80
|
# @option params [Integer] :pool_size connection pool size (default: 100)
|
|
62
81
|
# @option params [Integer] :pool_timeout pool checkout timeout (default: 5)
|
|
63
|
-
# @option params [Integer] :max_retries max
|
|
82
|
+
# @option params [Integer] :max_retries max retries for establishment
|
|
83
|
+
# failures and opted-in idempotent query transport failures (default: 3)
|
|
84
|
+
# @option params [Numeric] :retry_base_interval initial retry delay in
|
|
85
|
+
# seconds, with exponential backoff and jitter (default: 0.05)
|
|
86
|
+
# @option params [Numeric] :retry_max_interval maximum retry delay in
|
|
87
|
+
# seconds (default: 1.0)
|
|
64
88
|
def initialize(params = {})
|
|
65
89
|
DEFAULTS.merge(params).each do |key, value|
|
|
66
90
|
send("#{key}=", value)
|
|
67
91
|
end
|
|
68
92
|
end
|
|
69
93
|
|
|
94
|
+
# Returns the explicitly configured port or the default for the active
|
|
95
|
+
# TLS mode.
|
|
96
|
+
def port
|
|
97
|
+
@port || default_port
|
|
98
|
+
end
|
|
99
|
+
|
|
70
100
|
# Sets configuration from a URL string.
|
|
71
101
|
#
|
|
72
102
|
# @param url [String] ClickHouse connection URL
|
|
73
103
|
# @return [void]
|
|
74
104
|
def url=(url)
|
|
75
105
|
uri = URI(url)
|
|
76
|
-
|
|
77
|
-
|
|
106
|
+
ssl = URL_SCHEMES.fetch(uri.scheme) do
|
|
107
|
+
raise ArgumentError, "unsupported ClickHouse URL scheme: #{uri.scheme.inspect}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@ssl = ssl
|
|
78
111
|
@port = uri.port
|
|
79
|
-
@
|
|
80
|
-
|
|
81
|
-
@
|
|
112
|
+
@host = uri.host
|
|
113
|
+
database = uri.path.delete_prefix("/")
|
|
114
|
+
@database = database unless database.empty?
|
|
115
|
+
@username = uri.user if uri.user
|
|
116
|
+
@password = uri.password if uri.password
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def default_port
|
|
122
|
+
ssl ? 9440 : 9000
|
|
82
123
|
end
|
|
83
124
|
end
|
|
84
125
|
end
|
|
@@ -1,12 +1,233 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "connection_pool"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
3
7
|
module ChConnect
|
|
4
|
-
# A
|
|
8
|
+
# A pooled ClickHouse connection built on the clickhouse-c ioless client:
|
|
9
|
+
# the C extension is a pure protocol state machine + block decoder, and all
|
|
10
|
+
# socket I/O, TLS, and timeouts live here in Ruby. Result blocks are parsed
|
|
11
|
+
# in C.
|
|
5
12
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
13
|
+
# Maintains a pool of connections (config.pool_size); the native protocol
|
|
14
|
+
# handles one query at a time per connection, so concurrent queries each
|
|
15
|
+
# check out their own connection.
|
|
9
16
|
class Connection
|
|
17
|
+
# Connection establishment failures are safe to retry because no query
|
|
18
|
+
# bytes have been sent to the server yet.
|
|
19
|
+
class EstablishmentError < ConnectionError; end
|
|
20
|
+
private_constant :EstablishmentError
|
|
21
|
+
|
|
22
|
+
COMPRESSION_AVAILABLE = {
|
|
23
|
+
nil => true,
|
|
24
|
+
:lz4 => NativeClient::LZ4_AVAILABLE,
|
|
25
|
+
:zstd => NativeClient::ZSTD_AVAILABLE
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
READ_CHUNK = 64 * 1024
|
|
29
|
+
WRITE_CHUNK = 64 * 1024
|
|
30
|
+
PARAM_ESCAPES = {
|
|
31
|
+
"\0" => "\\0", "\a" => "\\a", "\b" => "\\b", "\e" => "\\e",
|
|
32
|
+
"\f" => "\\f", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t",
|
|
33
|
+
"\v" => "\\v", "'" => "\\'", "\\" => "\\\\"
|
|
34
|
+
}.freeze
|
|
35
|
+
PARAM_ESCAPE_PATTERN = /[\0\a\b\e\f\n\r\t\v'\\]/
|
|
36
|
+
RESERVED_SETTINGS = {
|
|
37
|
+
"network_compression_method" => "set config.compression instead",
|
|
38
|
+
"output_format_native_encode_types_in_binary_format" => "required by the native decoder"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# A pooled slot owning one socket + protocol state machine pair.
|
|
42
|
+
# Connects lazily and replaces the connection when it is broken or was
|
|
43
|
+
# abandoned mid-query (interrupt, timeout, killed thread) — the C client
|
|
44
|
+
# tracks both via broken?.
|
|
45
|
+
# @api private
|
|
46
|
+
class Slot
|
|
47
|
+
def initialize(config)
|
|
48
|
+
@config = config
|
|
49
|
+
@socket = nil
|
|
50
|
+
@client = nil
|
|
51
|
+
@pid = Process.pid
|
|
52
|
+
@read_buf = String.new(capacity: READ_CHUNK)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def query(sql, params, settings)
|
|
56
|
+
ensure_connected
|
|
57
|
+
|
|
58
|
+
@client.send_query(sql, params, settings)
|
|
59
|
+
flush_output
|
|
60
|
+
loop do
|
|
61
|
+
case @client.recv_step
|
|
62
|
+
when :done then break
|
|
63
|
+
when :want_read then @client.feed(read_chunk)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
@client.take_result
|
|
67
|
+
rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
|
|
68
|
+
raise ConnectionError, "#{e.class}: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def broken? = !@client || @client.broken? || @socket&.closed?
|
|
72
|
+
|
|
73
|
+
def close
|
|
74
|
+
if @pid != Process.pid && @socket.is_a?(OpenSSL::SSL::SSLSocket)
|
|
75
|
+
# A fork duplicates the fd. Closing the SSLSocket would send a TLS
|
|
76
|
+
# close_notify on the parent's live session; close only this process's
|
|
77
|
+
# raw fd instead.
|
|
78
|
+
safe_close(@socket.to_io)
|
|
79
|
+
else
|
|
80
|
+
safe_close(@socket)
|
|
81
|
+
end
|
|
82
|
+
@client&.close
|
|
83
|
+
@socket = nil
|
|
84
|
+
@client = nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def ensure_connected
|
|
90
|
+
if @client
|
|
91
|
+
raise EstablishmentError, "pooled connection is broken" if broken?
|
|
92
|
+
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
establish_connection
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def establish_connection
|
|
100
|
+
@socket = connect_socket
|
|
101
|
+
@client = NativeClient.new(
|
|
102
|
+
@config.database,
|
|
103
|
+
@config.username,
|
|
104
|
+
@config.password,
|
|
105
|
+
@config.compression
|
|
106
|
+
)
|
|
107
|
+
handshake
|
|
108
|
+
rescue ConnectionError, IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
|
|
109
|
+
close
|
|
110
|
+
raise EstablishmentError, "#{e.class}: #{e.message}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def connect_socket
|
|
114
|
+
socket = Socket.tcp(@config.host, @config.port, connect_timeout: @config.connection_timeout)
|
|
115
|
+
wrapped = nil
|
|
116
|
+
begin
|
|
117
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
118
|
+
wrapped = @config.ssl ? tls_wrap(socket) : socket
|
|
119
|
+
ensure
|
|
120
|
+
# a failed TLS handshake would otherwise leak the raw socket:
|
|
121
|
+
# @socket is not assigned yet, so discard has nothing to close.
|
|
122
|
+
# (ensure, not rescue: Thread#kill / Timeout skip rescue clauses)
|
|
123
|
+
safe_close(socket) if wrapped.nil?
|
|
124
|
+
end
|
|
125
|
+
wrapped
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def tls_wrap(socket)
|
|
129
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
130
|
+
if @config.ssl_verify
|
|
131
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
132
|
+
ctx.verify_hostname = true
|
|
133
|
+
if @config.ssl_ca
|
|
134
|
+
ctx.ca_file = @config.ssl_ca
|
|
135
|
+
else
|
|
136
|
+
ctx.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
ssl = OpenSSL::SSL::SSLSocket.new(socket, ctx)
|
|
143
|
+
ssl.hostname = @config.host
|
|
144
|
+
ssl.sync_close = true
|
|
145
|
+
|
|
146
|
+
deadline = monotonic_now + @config.connection_timeout
|
|
147
|
+
loop do
|
|
148
|
+
readiness = ssl.connect_nonblock(exception: false)
|
|
149
|
+
break unless readiness == :wait_readable || readiness == :wait_writable
|
|
150
|
+
|
|
151
|
+
wait_or_fail(ssl, readiness, deadline - monotonic_now, "TLS handshake timeout")
|
|
152
|
+
end
|
|
153
|
+
ssl
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handshake
|
|
157
|
+
deadline = monotonic_now + @config.connection_timeout
|
|
158
|
+
loop do
|
|
159
|
+
state = @client.handshake_step
|
|
160
|
+
flush_output
|
|
161
|
+
break if state == :done
|
|
162
|
+
|
|
163
|
+
# the handshake is part of connecting: bound it by connection_timeout
|
|
164
|
+
remaining = deadline - monotonic_now
|
|
165
|
+
raise ConnectionError, "native handshake timeout" if remaining <= 0
|
|
166
|
+
|
|
167
|
+
@client.feed(read_chunk(remaining))
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def flush_output
|
|
172
|
+
deadline = monotonic_now + @config.write_timeout if @config.write_timeout
|
|
173
|
+
while (out = @client.take_output)
|
|
174
|
+
offset = 0
|
|
175
|
+
while offset < out.bytesize
|
|
176
|
+
if deadline && deadline - monotonic_now <= 0
|
|
177
|
+
raise ConnectionError, "write timeout"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
pending = out.byteslice(offset, [WRITE_CHUNK, out.bytesize - offset].min)
|
|
181
|
+
case (written = @socket.write_nonblock(pending, exception: false))
|
|
182
|
+
when :wait_readable, :wait_writable
|
|
183
|
+
remaining = deadline && (deadline - monotonic_now)
|
|
184
|
+
wait_or_fail(@socket, written, remaining, "write timeout")
|
|
185
|
+
else
|
|
186
|
+
raise ConnectionError, "connection closed while writing" if written == 0
|
|
187
|
+
|
|
188
|
+
offset += written
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Reads into a reusable buffer: feed copies the bytes synchronously,
|
|
195
|
+
# so the buffer never needs to survive past the next read.
|
|
196
|
+
def read_chunk(timeout = @config.read_timeout)
|
|
197
|
+
deadline = monotonic_now + timeout if timeout
|
|
198
|
+
loop do
|
|
199
|
+
case (chunk = @socket.read_nonblock(READ_CHUNK, @read_buf, exception: false))
|
|
200
|
+
when :wait_readable, :wait_writable
|
|
201
|
+
remaining = deadline && (deadline - monotonic_now)
|
|
202
|
+
wait_or_fail(@socket, chunk, remaining, "read timeout")
|
|
203
|
+
when nil
|
|
204
|
+
raise ConnectionError, "connection closed by server"
|
|
205
|
+
else
|
|
206
|
+
return chunk
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# readiness is :wait_readable / :wait_writable — also the names of the
|
|
212
|
+
# IO wait methods.
|
|
213
|
+
def wait_or_fail(io, readiness, timeout, message)
|
|
214
|
+
raise ConnectionError, message if timeout && timeout <= 0
|
|
215
|
+
|
|
216
|
+
raise ConnectionError, message unless io.to_io.public_send(readiness, timeout)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def monotonic_now
|
|
220
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def safe_close(io)
|
|
224
|
+
io&.close
|
|
225
|
+
rescue IOError, SystemCallError
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
private_constant :Slot
|
|
230
|
+
|
|
10
231
|
# @return [Config] the configuration used by this connection
|
|
11
232
|
attr_reader :config
|
|
12
233
|
|
|
@@ -14,22 +235,170 @@ module ChConnect
|
|
|
14
235
|
#
|
|
15
236
|
# @param config [Config] configuration instance (defaults to global config)
|
|
16
237
|
def initialize(config = ChConnect.config)
|
|
17
|
-
@config = config
|
|
18
|
-
|
|
238
|
+
@config = config.dup.freeze
|
|
239
|
+
validate_compression!
|
|
240
|
+
@codec_name = @config.compression&.to_s
|
|
241
|
+
@pool = ConnectionPool.new(
|
|
242
|
+
size: @config.pool_size,
|
|
243
|
+
timeout: @config.pool_timeout,
|
|
244
|
+
auto_reload_after_fork: true
|
|
245
|
+
) do
|
|
246
|
+
Slot.new(@config)
|
|
247
|
+
end
|
|
19
248
|
end
|
|
20
249
|
|
|
21
|
-
# Executes a SQL query
|
|
250
|
+
# Executes and instruments a SQL query.
|
|
22
251
|
#
|
|
23
252
|
# @param sql [String] SQL query to execute
|
|
24
|
-
# @param
|
|
25
|
-
# @
|
|
26
|
-
# @
|
|
253
|
+
# @param params [Hash, nil] query parameters (name => value)
|
|
254
|
+
# @param settings [Hash, nil] per-query ClickHouse settings
|
|
255
|
+
# @param idempotent [Boolean] retry transport failures on a fresh connection
|
|
256
|
+
# up to config.max_retries (default: false)
|
|
257
|
+
# @return [Response] fully parsed response
|
|
27
258
|
# @raise [QueryError] if the query fails
|
|
28
|
-
|
|
259
|
+
# @raise [ConnectionError] if a connection or transport operation fails
|
|
260
|
+
def query(sql, params: nil, settings: nil, idempotent: false)
|
|
29
261
|
@config.instrumenter.instrument("query.clickhouse", {sql: sql}) do
|
|
30
|
-
|
|
31
|
-
|
|
262
|
+
execute_query(sql, params, settings, idempotent)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Closes all pooled connections.
|
|
267
|
+
#
|
|
268
|
+
# @return [void]
|
|
269
|
+
def close
|
|
270
|
+
@pool.shutdown(&:close)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def execute_query(sql, user_params, user_settings, idempotent)
|
|
276
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
277
|
+
params = format_params(user_params)
|
|
278
|
+
settings = format_settings(user_settings)
|
|
279
|
+
retries = 0
|
|
280
|
+
reap_idle_connections
|
|
281
|
+
|
|
282
|
+
columns, types, rows, summary = begin
|
|
283
|
+
@pool.with do |slot|
|
|
284
|
+
slot.query(sql, params, settings)
|
|
285
|
+
ensure
|
|
286
|
+
@pool.discard_current_connection(&:close) if slot.broken?
|
|
287
|
+
end
|
|
288
|
+
rescue EstablishmentError
|
|
289
|
+
raise if retries >= @config.max_retries
|
|
290
|
+
retries += 1
|
|
291
|
+
backoff_before_retry(retries)
|
|
292
|
+
retry
|
|
293
|
+
rescue ConnectionError
|
|
294
|
+
raise unless idempotent
|
|
295
|
+
raise if retries >= @config.max_retries
|
|
296
|
+
retries += 1
|
|
297
|
+
backoff_before_retry(retries)
|
|
298
|
+
retry
|
|
299
|
+
rescue ConnectionPool::TimeoutError => e
|
|
300
|
+
raise ConnectionError, "could not obtain a TCP connection from the pool: #{e.message}"
|
|
301
|
+
end
|
|
302
|
+
summary[:client_elapsed_ns] = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - started_at
|
|
303
|
+
Response.new(columns: columns, types: types, rows: rows, summary: summary)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def reap_idle_connections
|
|
307
|
+
timeout = @config.keep_alive_timeout
|
|
308
|
+
@pool.reap(idle_seconds: timeout, &:close) if timeout
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def backoff_before_retry(retry_number)
|
|
312
|
+
base = @config.retry_base_interval
|
|
313
|
+
return unless base.positive?
|
|
314
|
+
|
|
315
|
+
ceiling = [base * (2**(retry_number - 1)), @config.retry_max_interval].min
|
|
316
|
+
sleep(ceiling * (0.5 + rand * 0.5))
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def validate_compression!
|
|
320
|
+
available = COMPRESSION_AVAILABLE.fetch(@config.compression) do
|
|
321
|
+
raise Error, "unknown compression: #{@config.compression.inspect} (use :lz4, :zstd or nil)"
|
|
322
|
+
end
|
|
323
|
+
compression = @config.compression
|
|
324
|
+
unless available
|
|
325
|
+
raise Error, "compression = #{compression.inspect} but the extension was built without lib#{compression} (install #{compression} and reinstall the gem, or set config.compression = nil)"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Converts params into native protocol substitutions. Values go through
|
|
330
|
+
# Field::restoreFromDump on the server, so everything is sent as a quoted
|
|
331
|
+
# string and converted by the placeholder type (same convention as
|
|
332
|
+
# clickhouse-cpp).
|
|
333
|
+
def format_params(params)
|
|
334
|
+
return nil if params.nil? || params.empty?
|
|
335
|
+
|
|
336
|
+
params.map { |name, value| [name.to_s, quote_param(value)] }
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def quote_param(value)
|
|
340
|
+
# Query parameters use Field::restoreFromDump. Its nullable marker is a
|
|
341
|
+
# quoted, doubly escaped text NULL, matching ClickHouse's native client
|
|
342
|
+
# protocol (the dump parser consumes the first escape layer).
|
|
343
|
+
return "'\\\\N'" if value.nil?
|
|
344
|
+
|
|
345
|
+
inner_dump = value.is_a?(Array) ? dump_array_value(value) : escape_param_string(value.to_s)
|
|
346
|
+
outer_dump = inner_dump.gsub(/['\\]/) { |char| "\\#{char}" }
|
|
347
|
+
"'".b << outer_dump << "'"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def dump_array_value(value)
|
|
351
|
+
case value
|
|
352
|
+
when nil then "NULL"
|
|
353
|
+
when Array
|
|
354
|
+
dump = +"[".b
|
|
355
|
+
value.each_with_index do |element, index|
|
|
356
|
+
dump << "," unless index.zero?
|
|
357
|
+
dump << dump_array_value(element)
|
|
358
|
+
end
|
|
359
|
+
dump << "]"
|
|
360
|
+
when String, Symbol then quote_array_string(value.to_s)
|
|
361
|
+
when Time, DateTime
|
|
362
|
+
raise ArgumentError, "#{value.class} array parameters are ambiguous; pass a formatted String"
|
|
363
|
+
when Date then quote_array_string(value.to_s)
|
|
364
|
+
when true then "true"
|
|
365
|
+
when false then "false"
|
|
366
|
+
when Integer, Float, BigDecimal then value.to_s
|
|
367
|
+
else
|
|
368
|
+
raise ArgumentError, "unsupported array parameter element: #{value.class}"
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def quote_array_string(value)
|
|
373
|
+
"'".b << escape_param_string(value) << "'"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def escape_param_string(value)
|
|
377
|
+
value.b.gsub(PARAM_ESCAPE_PATTERN, PARAM_ESCAPES)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Settings travel as name/value strings in the Query packet. The wire's
|
|
381
|
+
# compression flag is boolean and the server picks the response codec
|
|
382
|
+
# from network_compression_method, so when compression is on we set its
|
|
383
|
+
# default to the configured codec.
|
|
384
|
+
def format_settings(user_settings)
|
|
385
|
+
settings = []
|
|
386
|
+
settings << ["network_compression_method", @codec_name] if @codec_name
|
|
387
|
+
|
|
388
|
+
user_settings&.each do |key, value|
|
|
389
|
+
name = key.to_s
|
|
390
|
+
if (guidance = RESERVED_SETTINGS[name])
|
|
391
|
+
raise ArgumentError, "setting #{name.inspect} is managed by ch_connect; #{guidance}"
|
|
392
|
+
end
|
|
393
|
+
formatted = case value
|
|
394
|
+
when true then "1"
|
|
395
|
+
when false then "0"
|
|
396
|
+
else value.to_s
|
|
397
|
+
end
|
|
398
|
+
settings << [name, formatted]
|
|
32
399
|
end
|
|
400
|
+
|
|
401
|
+
settings.empty? ? nil : settings
|
|
33
402
|
end
|
|
34
403
|
end
|
|
35
404
|
end
|
data/lib/ch_connect/response.rb
CHANGED
|
@@ -11,14 +11,6 @@ module ChConnect
|
|
|
11
11
|
Response = Data.define(:columns, :types, :rows, :summary) do
|
|
12
12
|
include Enumerable
|
|
13
13
|
|
|
14
|
-
# @param columns [Array<Symbol>] column names
|
|
15
|
-
# @param types [Array<Symbol>] column types
|
|
16
|
-
# @param rows [Array<Array>] row data
|
|
17
|
-
# @param summary [Hash, nil] ClickHouse query summary with symbol keys
|
|
18
|
-
def initialize(columns: [], types: [], rows: [], summary: nil)
|
|
19
|
-
super
|
|
20
|
-
end
|
|
21
|
-
|
|
22
14
|
# Iterates over rows as hashes with symbol keys.
|
|
23
15
|
# @yield [Hash] each row as a hash
|
|
24
16
|
# @return [Enumerator] if no block given
|
data/lib/ch_connect/version.rb
CHANGED
data/lib/ch_connect.rb
CHANGED
|
@@ -3,27 +3,18 @@
|
|
|
3
3
|
require_relative "ch_connect/version"
|
|
4
4
|
require_relative "ch_connect/null_instrumenter"
|
|
5
5
|
require_relative "ch_connect/config"
|
|
6
|
-
require_relative "ch_connect/transport_result"
|
|
7
|
-
require_relative "ch_connect/http_transport"
|
|
8
|
-
require_relative "ch_connect/connection"
|
|
9
6
|
require_relative "ch_connect/response"
|
|
10
|
-
require_relative "ch_connect/body_reader"
|
|
11
|
-
require_relative "ch_connect/native_format_parser"
|
|
12
7
|
|
|
13
8
|
# Ruby client for ClickHouse database with Native format support.
|
|
14
9
|
#
|
|
15
10
|
# @example Basic usage
|
|
16
11
|
# ChConnect.configure do |config|
|
|
17
12
|
# config.host = "localhost"
|
|
18
|
-
# config.port =
|
|
13
|
+
# config.port = 9000
|
|
19
14
|
# end
|
|
20
15
|
#
|
|
21
16
|
# conn = ChConnect::Connection.new
|
|
22
17
|
# response = conn.query("SELECT 1")
|
|
23
|
-
#
|
|
24
|
-
# @example Using connection pool
|
|
25
|
-
# pool = ChConnect::Pool.new
|
|
26
|
-
# response = pool.query("SELECT * FROM users")
|
|
27
18
|
module ChConnect
|
|
28
19
|
# Base error class for all ChConnect errors
|
|
29
20
|
class Error < StandardError; end
|
|
@@ -31,6 +22,9 @@ module ChConnect
|
|
|
31
22
|
# Raised when a query fails (syntax error, unknown table, etc.)
|
|
32
23
|
class QueryError < Error; end
|
|
33
24
|
|
|
25
|
+
# Raised on network/connection failures
|
|
26
|
+
class ConnectionError < Error; end
|
|
27
|
+
|
|
34
28
|
# Raised when encountering an unsupported ClickHouse data type
|
|
35
29
|
class UnsupportedTypeError < Error; end
|
|
36
30
|
|
|
@@ -46,6 +40,15 @@ module ChConnect
|
|
|
46
40
|
# @yield [Config] the configuration instance
|
|
47
41
|
# @return [void]
|
|
48
42
|
def self.configure
|
|
49
|
-
yield
|
|
43
|
+
yield config
|
|
50
44
|
end
|
|
51
45
|
end
|
|
46
|
+
|
|
47
|
+
begin
|
|
48
|
+
require "ch_connect/ch_connect_native"
|
|
49
|
+
rescue LoadError => e
|
|
50
|
+
raise ChConnect::Error, "ch_connect requires the compiled native extension: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
ChConnect.private_constant :NativeClient
|
|
53
|
+
|
|
54
|
+
require_relative "ch_connect/connection"
|