rb_snowflake_client 1.4.0 → 1.6.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: 9c1581fededb6e083bc1f55249d806fca4a63462fafee98f9277986729dd1107
4
- data.tar.gz: e810744812f56a65d519d8a30ce96e97d2f1341479ee7aeb2a50766c0f541e90
3
+ metadata.gz: 8d4e6f5f28c211ce79c5c049125008efdbddca46ecb670f7bc724a10c11eb5ef
4
+ data.tar.gz: 42378b7321d2ce549b347b5f882d85af33f393a4a34e3032fa22e5bbbaa264e7
5
5
  SHA512:
6
- metadata.gz: 7cc78b78f3a5a3d056c826107aab889d23705ea76249800a7c098e43e8515db4fc9f85e11a2fae86cced570b1c5aaa869dded60495467bbb2b52241edd466c2e
7
- data.tar.gz: 6b57c2a14a03fe96711d86554eb13b86bd5865a1aafa629a30ea02b2c27fc72f743f8942fc0d4d35b590d824cbd110c2ad3efbe6ffc235037c7039213644e97c
6
+ metadata.gz: 33be4299bb251eb3be2ddef2301ab6e471c1a312d3a35dab3a2067d757125eec473aecf230bd84544992714e4dc7ab23b4a2129c8a460c5654eadb68de5f0109
7
+ data.tar.gz: 71ed8816683f48ed434fdb44515a3e92d0121d8935cc1e89c41a4accef4431ef5448c2feed0355df66cc04d17a1e88afcc7732a5a56af915d85e8345b84bd49a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## Unreleased
9
+
10
+ ## [1.6.0] - 2026-04-13
11
+ ### Added
12
+ - Support for passing a passphrase when using an encrypted private key for JWT authentication (#166)
13
+ ### Security
14
+ - Bumped `activesupport` to patch CVEs (#167)
15
+
16
+ ## [1.5.0] - 2025-10-14
17
+ ### Added
18
+ - Instrumentation feature added for Active Support users
19
+ - Added `query_timeout` as a per-query parameter, allowing timeout override on individual queries
20
+ ### Fixed
21
+ - `query_timeout` now properly sends timeout parameter to Snowflake API for server-side enforcement
22
+ - Streaming mode now releases consumed records, fixing memory leak. Note: if you were iterating over streaming results more than once, this is a breaking change (though that was not its intended usage).
23
+
8
24
  ## [1.4.0] - 2025-05-01
9
25
  ### Added
10
26
  - Enhanced Row API to implement Enumerable interface
data/Gemfile CHANGED
@@ -8,6 +8,10 @@ gemspec
8
8
  gem "bundler"
9
9
  gem "rake"
10
10
 
11
+ group :development, :test do
12
+ gem "activesupport", "~> 8.0.4", ">= 8.0.4.1"
13
+ end
14
+
11
15
  group :development do
12
16
  gem "parallel"
13
17
  gem "pry"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rb_snowflake_client (1.4.0)
4
+ rb_snowflake_client (1.6.0)
5
5
  bigdecimal (>= 3.0)
6
6
  concurrent-ruby (>= 1.2)
7
7
  connection_pool (>= 2.4)
@@ -13,42 +13,69 @@ PATH
13
13
  GEM
14
14
  remote: https://rubygems.org/
15
15
  specs:
16
- base64 (0.2.0)
17
- bigdecimal (3.1.9)
16
+ activesupport (8.0.5)
17
+ base64
18
+ benchmark (>= 0.3)
19
+ bigdecimal
20
+ concurrent-ruby (~> 1.0, >= 1.3.1)
21
+ connection_pool (>= 2.2.5)
22
+ drb
23
+ i18n (>= 1.6, < 2)
24
+ logger (>= 1.4.2)
25
+ minitest (>= 5.1)
26
+ securerandom (>= 0.3)
27
+ tzinfo (~> 2.0, >= 2.0.5)
28
+ uri (>= 0.13.1)
29
+ base64 (0.3.0)
30
+ benchmark (0.5.0)
31
+ bigdecimal (4.1.1)
18
32
  coderay (1.1.3)
19
- concurrent-ruby (1.3.5)
20
- connection_pool (2.5.3)
21
- diff-lcs (1.6.1)
33
+ concurrent-ruby (1.3.6)
34
+ connection_pool (3.0.2)
35
+ diff-lcs (1.6.2)
22
36
  dotenv (3.1.8)
23
- json (2.11.3)
24
- jwt (2.10.1)
37
+ drb (2.2.3)
38
+ i18n (1.14.8)
39
+ concurrent-ruby (~> 1.0)
40
+ json (2.15.1)
41
+ jwt (3.1.2)
25
42
  base64
43
+ logger (1.7.0)
26
44
  method_source (1.1.0)
45
+ minitest (6.0.3)
46
+ drb (~> 2.0)
47
+ prism (~> 1.5)
27
48
  parallel (1.27.0)
49
+ prism (1.9.0)
28
50
  pry (0.15.2)
29
51
  coderay (~> 1.1)
30
52
  method_source (~> 1.0)
31
- rake (13.2.1)
53
+ rake (13.3.0)
32
54
  retryable (3.0.5)
33
- rspec (3.13.0)
55
+ rspec (3.13.1)
34
56
  rspec-core (~> 3.13.0)
35
57
  rspec-expectations (~> 3.13.0)
36
58
  rspec-mocks (~> 3.13.0)
37
- rspec-core (3.13.3)
59
+ rspec-core (3.13.5)
38
60
  rspec-support (~> 3.13.0)
39
- rspec-expectations (3.13.3)
61
+ rspec-expectations (3.13.5)
40
62
  diff-lcs (>= 1.2.0, < 2.0)
41
63
  rspec-support (~> 3.13.0)
42
- rspec-mocks (3.13.2)
64
+ rspec-mocks (3.13.5)
43
65
  diff-lcs (>= 1.2.0, < 2.0)
44
66
  rspec-support (~> 3.13.0)
45
- rspec-support (3.13.2)
67
+ rspec-support (3.13.6)
68
+ securerandom (0.4.1)
69
+ tzinfo (2.0.6)
70
+ concurrent-ruby (~> 1.0)
71
+ uri (1.1.1)
46
72
 
47
73
  PLATFORMS
48
74
  arm64-darwin-22
49
75
  ruby
50
76
 
51
77
  DEPENDENCIES
78
+ activesupport (~> 8.0.4, >= 8.0.4.1)
52
79
  bundler
53
80
  parallel
54
81
  pry
@@ -57,4 +84,4 @@ DEPENDENCIES
57
84
  rspec
58
85
 
59
86
  BUNDLED WITH
60
- 2.5.10
87
+ 4.0.3
data/README.md CHANGED
@@ -48,6 +48,8 @@ Available ENV variables (see below in the config section for details)
48
48
  - `SNOWFLAKE_URI`
49
49
  - `SNOWFLAKE_PRIVATE_KEY_PATH` or `SNOWFLAKE_PRIVATE_KEY`
50
50
  - Use either the key or the path. Key takes precedence if both are provided.
51
+ - `SNOWFLAKE_PRIVATE_KEY_PASSPHRASE`
52
+ - Optional, if you are using an encrypted private key
51
53
  - `SNOWFLAKE_ORGANIZATION`
52
54
  - Optional, if you leave it off, the library will authenticate with an account name of only SNOWFLAKE_ACCOUNT
53
55
  - `SNOWFLAKE_ACCOUNT`
@@ -132,6 +134,14 @@ Queries by default use the primary role assigned to the account. If there are mu
132
134
  client.query("SELECT * FROM BIGTABLE", role: "MY_ROLE")
133
135
  ```
134
136
 
137
+ ## Query timeout
138
+
139
+ You can override the query timeout on a per-query basis. The timeout is specified in seconds and will be enforced by both Snowflake server-side and the client-side polling mechanism.
140
+
141
+ ```ruby
142
+ client.query("SELECT * FROM BIGTABLE", query_timeout: 30)
143
+ ```
144
+
135
145
  ## Binding parameters
136
146
 
137
147
  Say we have `BIGTABLE` with a `data` column of a type `VARIANT`.
@@ -140,16 +150,45 @@ Say we have `BIGTABLE` with a `data` column of a type `VARIANT`.
140
150
  json_string = '{"valid": "json"}'
141
151
  query = "insert into BIGTABLE(data) select parse_json(?)"
142
152
  bindings = {
143
- "1": {
144
- "type": "TEXT",
145
- "value": json_string
146
- }
147
- }
153
+ "1" => {
154
+ "type" => "TEXT",
155
+ "value" => "Other Event"
156
+ }
157
+ }
148
158
  client.query(query, bindings: bindings)
149
159
  ```
150
160
 
151
161
  For additional information about binding parameters refer to snowflake documentation: https://docs.snowflake.com/en/developer-guide/sql-api/submitting-requests#using-bind-variables-in-a-statement
152
162
 
163
+ ## Instrumentation
164
+
165
+ If ActiveSupport is available, this library additionally emits [notification events](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) around queries. You can subscribe to those to track timing, query counts, etc.
166
+
167
+ * `rb_snowflake_client.snowflake_query.finish`: published at query end
168
+
169
+ Events receive a payload with the following properties:
170
+ * `database`: snowflake database
171
+ * `schema`: snowflake schema
172
+ * `warehouse`: snowflake warehouse
173
+ * `query_id`: random UUID for the query
174
+ * `query_name`: argument passed to query/fetch
175
+ * `exception`: present if the query raised an error, see [Notifications documentation](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html#module-ActiveSupport::Notifications-label-Subscribers) for details
176
+ * `exception_object`: present if the query raised an error, see [Notifications documentation](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html#module-ActiveSupport::Notifications-label-Subscribers) for details
177
+
178
+ An example integration with [Datadog](https://www.rubydoc.info/gems/datadog) might look like this:
179
+
180
+ ```ruby
181
+ ActiveSupport::Notifications.subscribe("rb_snowflake_client.snowflake_query.finish") do |name, start, finish, id, payload|
182
+ span = Datadog::Tracing.trace(payload[:query_name] || "snowflake_query",
183
+ resource: "snowflake",
184
+ start_time: start,
185
+ tags: payload,
186
+ type: Datadog::Tracing::Metadata::Ext::AppTypes::TYPE_DB)
187
+
188
+ span.finish(finish)
189
+ end
190
+ ```
191
+
153
192
  # Configuration Options
154
193
 
155
194
  The client supports the following configuration options, each with their own getter/setter except connection pool options which must be set at construction. Additionally, all except logger can be configured with environment variables (see above, but the pattern is like: "SNOWFLAKE_HTTP_RETRIES". Configuration options can only be set on initialization through `new` or `from_env`.
@@ -228,6 +267,7 @@ or alternatively, use the client to verify:
228
267
  client = RubySnowflake::Client.new(
229
268
  "https://yourinstance.region.snowflakecomputing.com", # insert your URL here
230
269
  File.read("secrets/my_key.pem"), # path to your private key
270
+ "private-key-passphrase", # your private key passphrase, if it has one (defaults to nil)
231
271
  "snowflake-organization", # your account name (doesn't match your URL), using nil may be required depending on your snowflake account
232
272
  "snowflake-account", # typically your subdomain
233
273
  "snowflake-user", # Your snowflake user
@@ -8,12 +8,13 @@ module RubySnowflake
8
8
  class Client
9
9
  class KeyPairJwtAuthManager
10
10
  # requires text of a PEM formatted RSA private key
11
- def initialize(organization, account, user, private_key, jwt_token_ttl)
11
+ def initialize(organization, account, user, private_key, jwt_token_ttl, private_key_passphrase = nil)
12
12
  @organization = organization
13
13
  @account = account
14
14
  @user = user
15
15
  @private_key_pem = private_key
16
16
  @jwt_token_ttl = jwt_token_ttl
17
+ @private_key_passphrase = private_key_passphrase
17
18
 
18
19
  # start with an expired value to force creation
19
20
  @token_expires_at = Time.now.to_i - 1
@@ -26,8 +27,7 @@ module RubySnowflake
26
27
  @token_semaphore.acquire do
27
28
  now = Time.now.to_i
28
29
  @token_expires_at = now + @jwt_token_ttl
29
-
30
- private_key = OpenSSL::PKey.read(@private_key_pem)
30
+ private_key = OpenSSL::PKey.read(@private_key_pem, @private_key_passphrase)
31
31
 
32
32
  payload = {
33
33
  :iss => "#{account_name}.#{@user.upcase}.#{public_key_fingerprint}",
@@ -56,7 +56,7 @@ module RubySnowflake
56
56
  def public_key_fingerprint
57
57
  return @public_key_fingerprint unless @public_key_fingerprint.nil?
58
58
 
59
- public_key_der = OpenSSL::PKey::RSA.new(@private_key_pem).public_key.to_der
59
+ public_key_der = OpenSSL::PKey::RSA.new(@private_key_pem, @private_key_passphrase).public_key.to_der
60
60
  digest = OpenSSL::Digest::SHA256.new.digest(public_key_der)
61
61
  fingerprint = Base64.strict_encode64(digest)
62
62
 
@@ -12,6 +12,13 @@ require "retryable"
12
12
  require "securerandom"
13
13
  require "uri"
14
14
 
15
+ begin
16
+ require "active_support"
17
+ require "active_support/notifications"
18
+ rescue LoadError
19
+ # This isn't required
20
+ end
21
+
15
22
  require_relative "client/http_connection_wrapper"
16
23
  require_relative "client/key_pair_jwt_auth_manager"
17
24
  require_relative "client/single_thread_in_memory_strategy"
@@ -90,6 +97,7 @@ module RubySnowflake
90
97
  new(
91
98
  ENV.fetch("SNOWFLAKE_URI"),
92
99
  private_key,
100
+ ENV["SNOWFLAKE_PRIVATE_KEY_PASSPHRASE"],
93
101
  ENV.fetch("SNOWFLAKE_ORGANIZATION"),
94
102
  ENV.fetch("SNOWFLAKE_ACCOUNT"),
95
103
  ENV.fetch("SNOWFLAKE_USER"),
@@ -109,7 +117,7 @@ module RubySnowflake
109
117
  end
110
118
 
111
119
  def initialize(
112
- uri, private_key, organization, account, user, default_warehouse, default_database,
120
+ uri, private_key, private_key_passphrase = nil, organization, account, user, default_warehouse, default_database,
113
121
  default_role: nil,
114
122
  logger: DEFAULT_LOGGER,
115
123
  log_level: DEFAULT_LOG_LEVEL,
@@ -123,7 +131,7 @@ module RubySnowflake
123
131
  )
124
132
  @base_uri = uri
125
133
  @key_pair_jwt_auth_manager =
126
- KeyPairJwtAuthManager.new(organization, account, user, private_key, jwt_token_ttl)
134
+ KeyPairJwtAuthManager.new(organization, account, user, private_key, jwt_token_ttl, private_key_passphrase)
127
135
  @default_warehouse = default_warehouse
128
136
  @default_database = default_database
129
137
  @default_role = default_role
@@ -143,31 +151,35 @@ module RubySnowflake
143
151
  @_enable_polling_queries = false
144
152
  end
145
153
 
146
- def query(query, warehouse: nil, streaming: false, database: nil, schema: nil, bindings: nil, role: nil)
154
+ def query(query, warehouse: nil, streaming: false, database: nil, schema: nil, bindings: nil, role: nil, query_name: nil, query_timeout: nil)
147
155
  warehouse ||= @default_warehouse
148
156
  database ||= @default_database
149
157
  role ||= @default_role
158
+ query_timeout ||= @query_timeout
150
159
 
151
- query_start_time = Time.now.to_i
152
- response = nil
153
- connection_pool.with do |connection|
154
- request_body = {
155
- "warehouse" => warehouse&.upcase,
156
- "schema" => schema&.upcase,
157
- "database" => database&.upcase,
158
- "statement" => query,
159
- "bindings" => bindings,
160
- "role" => role
161
- }
162
-
163
- response = request_with_auth_and_headers(
164
- connection,
165
- Net::HTTP::Post,
166
- "/api/v2/statements?requestId=#{SecureRandom.uuid}&async=#{@_enable_polling_queries}",
167
- request_body.to_json
168
- )
160
+ with_instrumentation({ database:, schema:, warehouse:, query_name: }) do
161
+ query_start_time = Time.now.to_i
162
+ response = nil
163
+ connection_pool.with do |connection|
164
+ request_body = {
165
+ "warehouse" => warehouse&.upcase,
166
+ "schema" => schema&.upcase,
167
+ "database" => database&.upcase,
168
+ "statement" => query,
169
+ "bindings" => bindings,
170
+ "role" => role,
171
+ "timeout" => query_timeout
172
+ }
173
+
174
+ response = request_with_auth_and_headers(
175
+ connection,
176
+ Net::HTTP::Post,
177
+ "/api/v2/statements?requestId=#{SecureRandom.uuid}&async=#{@_enable_polling_queries}",
178
+ request_body.to_json
179
+ )
180
+ end
181
+ retrieve_result_set(query_start_time, query, response, streaming, query_timeout)
169
182
  end
170
- retrieve_result_set(query_start_time, query, response, streaming)
171
183
  end
172
184
 
173
185
  alias fetch query
@@ -251,7 +263,7 @@ module RubySnowflake
251
263
  end
252
264
  end
253
265
 
254
- def poll_for_completion_or_timeout(query_start_time, query, statement_handle)
266
+ def poll_for_completion_or_timeout(query_start_time, query, statement_handle, query_timeout)
255
267
  first_data_json_body = nil
256
268
 
257
269
  connection_pool.with do |connection|
@@ -259,7 +271,7 @@ module RubySnowflake
259
271
  sleep POLLING_INTERVAL
260
272
 
261
273
  elapsed_time = Time.now.to_i - query_start_time
262
- if elapsed_time > @query_timeout
274
+ if elapsed_time > query_timeout
263
275
  cancelled = attempt_to_cancel_and_silence_errors(connection, statement_handle)
264
276
  raise QueryTimeoutError.new("Query timed out. Query cancelled? #{cancelled}; Duration: #{elapsed_time}; Query: '#{query}'")
265
277
  end
@@ -287,12 +299,12 @@ module RubySnowflake
287
299
  false
288
300
  end
289
301
 
290
- def retrieve_result_set(query_start_time, query, response, streaming)
302
+ def retrieve_result_set(query_start_time, query, response, streaming, query_timeout)
291
303
  json_body = JSON.parse(response.body, JSON_PARSE_OPTIONS)
292
304
  statement_handle = json_body["statementHandle"]
293
305
 
294
306
  if response.code == POLLING_RESPONSE_CODE
295
- result_response = poll_for_completion_or_timeout(query_start_time, query, statement_handle)
307
+ result_response = poll_for_completion_or_timeout(query_start_time, query, statement_handle, query_timeout)
296
308
  json_body = JSON.parse(result_response.body, JSON_PARSE_OPTIONS)
297
309
  end
298
310
 
@@ -329,5 +341,15 @@ module RubySnowflake
329
341
  def number_of_threads_to_use(partition_count)
330
342
  [[1, (partition_count / @thread_scale_factor.to_f).ceil].max, @max_threads_per_query].min
331
343
  end
344
+
345
+ def with_instrumentation(tags, &block)
346
+ return block.call unless defined?(::ActiveSupport) && ::ActiveSupport
347
+
348
+ ::ActiveSupport::Notifications.instrument(
349
+ "rb_snowflake_client.snowflake_query.finish",
350
+ tags.merge(query_id: SecureRandom.uuid)) do
351
+ block.call
352
+ end
353
+ end
332
354
  end
333
355
  end
@@ -27,9 +27,19 @@ module RubySnowflake
27
27
  if data[index].is_a? Concurrent::Future
28
28
  data[index] = data[index].value # wait for it to finish
29
29
  end
30
+
30
31
  data[index].each do |row|
31
32
  yield wrap_row(row)
32
33
  end
34
+
35
+ # After iterating over the current partition, clear the data to release memory
36
+ data[index].clear
37
+
38
+ # Reassign to a symbol so:
39
+ # - When looking at the list of partitions in `data` it is easier to detect
40
+ # - Will raise an exception if `data.each` is attempted to be called again
41
+ # - It won't trigger prefetch detection as `next_index`
42
+ data[index] = :finished
33
43
  end
34
44
  end
35
45
 
@@ -1,3 +1,3 @@
1
1
  module RubySnowflake
2
- VERSION = "1.4.0"
2
+ VERSION = "1.6.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rb_snowflake_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rinsed
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-01 00:00:00.000000000 Z
11
+ date: 2026-04-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal