rb_snowflake_client 1.6.2 → 1.7.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: 14628efb496cd0a61e49675e329f38d075dd5bb201371df522b7a84e31a6beca
4
- data.tar.gz: b8b3618986b2d8d5a24d660270ab8860ebb985718375183c8ab73af0a89cd124
3
+ metadata.gz: 05c5fef52239a7d043627e57e3a205ef07092452b7c7a47779ad21150637ec66
4
+ data.tar.gz: f98da0cd8848820a984753175fd9c22d60f41d137da780de0d8d08f99fa4632a
5
5
  SHA512:
6
- metadata.gz: '00485ce43306464ac2d541a1349879d0e0130c4ced1dba7c785a0c1cd78319954e3eea734c6f77e4448d825f3ed42538f6ea042b1224a01d4b644d587cfa805b'
7
- data.tar.gz: 90a65fc0820e5783870540d335a1db672f1a01affb5160cf4d88ba5411217a4e45ad896a16b274061f1d892161863958ad2da1c6ae87a373506c7c59692426f4
6
+ metadata.gz: f577b2fef1a891a37a0a793a53ed4d24d63e665fc5d6e114332f836a4f12a26494f002bf6a589c1b921df253ccac10d81ad122636e8a0d053f16efe4f1c3313d
7
+ data.tar.gz: 4cb2888d8274a883689b448ba6acf315b683ba2692612b1d3753921f082ee14cd5292216c436a4e7d4f5b3ef3893af3a946333a6abff710f42c2246400736855
@@ -10,13 +10,18 @@
10
10
  # Nothing PR-controlled reaches the dispatch: only github.repository and the PR number, passed through env.
11
11
  # No checkout, no PR code. The token is minted from the Arby Dispatcher app (Actions: write, installed only on
12
12
  # the runner repo) and scoped to that one repo, so all it can do is start runs there. The run's own red/green
13
- # status shows in the runner repo; on this PR the result is Arby's review. Manual "re-request Arby" reviews
14
- # aren't supported here yet: push a commit to get a new review.
13
+ # status shows in the runner repo; on this PR the result is Arby's review.
14
+ #
15
+ # Requesting a review: someone with write access requests `rinsed-review-bot` as a reviewer (also on drafts,
16
+ # and also on a PR by an author limit-approval-to doesn't cover, such as an outside contributor's fork PR,
17
+ # which is otherwise not reviewed). The dispatch still carries only the repo and PR number; the runner checks
18
+ # the PR itself for a pending request for Arby made by someone with write access. Such a review is
19
+ # comment-only unless limit-approval-to covers the author.
15
20
  name: Arby review (public repo)
16
21
 
17
22
  on:
18
23
  pull_request_target:
19
- types: [opened, synchronize, reopened, ready_for_review]
24
+ types: [opened, synchronize, reopened, ready_for_review, review_requested]
20
25
 
21
26
  # Nothing by default; each job asks for exactly what it needs.
22
27
  permissions: {}
@@ -24,8 +29,11 @@ permissions: {}
24
29
  jobs:
25
30
  dispatch:
26
31
  name: Request Arby review
27
- # Drafts skip, as in review.yml.
28
- if: github.event.pull_request.draft == false
32
+ # A request aimed at Arby itself (a team request names no requested_reviewer), drafts included; otherwise
33
+ # non-drafts only, as in review.yml.
34
+ if: >-
35
+ (github.event.action == 'review_requested' && github.event.requested_reviewer.login == 'rinsed-review-bot')
36
+ || (github.event.action != 'review_requested' && github.event.pull_request.draft == false)
29
37
  runs-on: ubuntu-latest
30
38
  timeout-minutes: 5
31
39
  # Only the app token is used, so the job's own token needs nothing.
@@ -59,6 +67,10 @@ jobs:
59
67
  dismiss:
60
68
  # The required check is keyed on this name; keep it exactly as is.
61
69
  name: Dismiss stale Arby approvals
70
+ # Runs on review requests too, though they add no commits. Skipping it there would report a newer,
71
+ # skipped check, which satisfies the required check even while the dismissal for the latest push failed
72
+ # or is still running. A request that cancels an in-flight run below is harmless: the new run reads the
73
+ # current head itself and dismisses everything the cancelled one would have.
62
74
  concurrency:
63
75
  group: arby-dismiss-stale-${{ github.event.pull_request.number }}
64
76
  cancel-in-progress: true
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## [1.7.0] - 2026-10-22
11
+ ### Added
12
+ - `query` accepts `parameters:` for Snowflake session parameters such as `QUERY_TAG` (#163, thanks Elayamani K)
13
+ - `*_OUTPUT_FORMAT` parameters raise `ArgumentError`: the client parses Snowflake's default date and time
14
+ encodings, and returns binary values in its default hex (#197)
15
+ ### Changed
16
+ - A failed partition fetch that isn't a pool timeout raises `RubySnowflake::Error` with the original as `cause`,
17
+ not the `ConnectionStarvedError` the threaded path raised in 1.6.2: code that rescues only
18
+ `ConnectionStarvedError` (e.g. to retry) should rescue `RubySnowflake::Error` or check `cause` (#197)
19
+ ### Fixed
20
+ - Streaming results no longer leak a thread per query (#163, thanks Nate Berkopec)
21
+ - Stopping a stream early doesn't wait for its prefetch to finish (#197)
22
+ - Threaded in-memory results no longer leak their threads (#197)
23
+ - A failed partition fetch always raises; it could yield a `nil` partition before (#197, thanks Justin Léger)
24
+ - A failed fetch or an interrupt cancels the partition fetches that haven't started (#197, thanks Justin Léger)
25
+ - No longer requires `benchmark`, which isn't a default gem from Ruby 4.0, so loading the client doesn't need it
26
+ in the bundle (#198)
27
+
10
28
  ## [1.6.2] - 2026-10-19
11
29
  ### Fixed
12
30
  - Compatibility with `json` 3.0, where `JSON.parse` options are keyword-only: every query raised
data/README.md CHANGED
@@ -160,6 +160,35 @@ client.query(query, bindings: bindings)
160
160
 
161
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
162
162
 
163
+ ## Session parameters
164
+
165
+ You can set Snowflake session parameters on a per-query basis by passing a hash to the `parameters` option. These parameters affect query behavior and can be used to set things like query tags, timezones, and other session-level settings.
166
+
167
+ ```ruby
168
+ client.query(
169
+ "SELECT * FROM BIGTABLE",
170
+ parameters: {
171
+ "QUERY_TAG" => "my_custom_tag",
172
+ "TIMEZONE" => "America/Los_Angeles"
173
+ }
174
+ )
175
+ ```
176
+
177
+ The ones that are useful here are:
178
+
179
+ - `QUERY_TAG`: tags the query, e.g. so you can find it in `QUERY_HISTORY`
180
+ - `TIMEZONE`: sets the session time zone
181
+ - `USE_CACHED_RESULT`: set to `false` to skip Snowflake's result cache
182
+ - `CLIENT_RESULT_CHUNK_SIZE`: the maximum size, in MB, of each chunk of query results
183
+
184
+ A few others need care:
185
+
186
+ - The `*_OUTPUT_FORMAT` parameters raise an `ArgumentError`: the client parses Snowflake's default date, time and timestamp encodings, and returns binary values in its default hex.
187
+ - `MULTI_STATEMENT_COUNT` lets you run several statements in one query, but the result is only the parent statement's summary, not each statement's rows.
188
+ - `ROWS_PER_RESULTSET` caps the number of rows returned.
189
+
190
+ For a full list of available parameters, refer to the Snowflake documentation: https://docs.snowflake.com/en/developer-guide/sql-api/reference
191
+
163
192
  ## Instrumentation
164
193
 
165
194
  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.
@@ -9,31 +9,21 @@ module RubySnowflake
9
9
  result[0] = statement_json_body["data"]
10
10
 
11
11
  thread_pool = Concurrent::FixedThreadPool.new(num_threads)
12
- partitions
13
- .each_with_index.map do |partition, index|
12
+ futures = []
13
+ begin
14
+ partitions.each_index do |index|
14
15
  next if index == 0 # already have the first partition
15
- [index, Concurrent::Future.execute(executor: thread_pool) { retreive_proc.call(index) }]
16
+ futures << [index, Concurrent::Future.execute(executor: thread_pool) { retreive_proc.call(index) }]
16
17
  end
17
- .each do |entry|
18
- next if entry.nil? # 0th index
19
18
 
20
- index, future = entry
21
- if future.rejected?
22
- if future.reason.is_a? RubySnowflake::Error
23
- raise future.reason
24
- else
25
- raise ConnectionStarvedError.new(
26
- "A partition request timed out. This is usually do to using the client in" \
27
- "multiple threads. The client uses a connection thread pool and if too many" \
28
- "requests are all done in threads at the same time, threads can get starved" \
29
- "of access to connections. The solution for this is to either increase the " \
30
- "max_connections parameter on the client or create a new client instance" \
31
- "with it's own connection pool to snowflake per thread. Rejection reason: #{future.reason.message}"
32
- )
33
- end
34
- end
35
- result[index] = future.value
19
+ futures.each do |index, future|
20
+ result[index] = PartitionFuture.value!(future)
36
21
  end
22
+ ensure
23
+ # On a failure or interrupt, skip the fetches that haven't started
24
+ futures.each { |_index, future| future.cancel }
25
+ thread_pool.shutdown # no wait: in-flight fetches finish and return their connections
26
+ end
37
27
  result
38
28
  end
39
29
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "base64"
4
- require "benchmark"
5
4
  require "bigdecimal"
6
5
  require "concurrent"
7
6
  require "connection_pool"
@@ -19,33 +18,17 @@ rescue LoadError
19
18
  # This isn't required
20
19
  end
21
20
 
21
+ require_relative "errors"
22
22
  require_relative "client/http_connection_wrapper"
23
23
  require_relative "client/key_pair_jwt_auth_manager"
24
24
  require_relative "client/single_thread_in_memory_strategy"
25
25
  require_relative "client/streaming_result_strategy"
26
26
  require_relative "client/threaded_in_memory_strategy"
27
+ require_relative "partition_future"
27
28
  require_relative "result"
28
29
  require_relative "streaming_result"
29
30
 
30
31
  module RubySnowflake
31
- class Error < StandardError
32
- def initialize(details)
33
- @details = details
34
- end
35
-
36
- def message
37
- @details.to_s
38
- end
39
- end
40
-
41
- class BadResponseError < Error ; end
42
- class ConnectionError < Error ; end
43
- class ConnectionStarvedError < Error ; end
44
- class MissingConfig < Error ; end
45
- class RetryableBadResponseError < Error ; end
46
- class RequestError < Error ; end
47
- class QueryTimeoutError < Error ; end
48
-
49
32
  class Client
50
33
  DEFAULT_LOGGER = Logger.new(STDOUT)
51
34
  DEFAULT_LOG_LEVEL = Logger::INFO
@@ -71,6 +54,9 @@ module RubySnowflake
71
54
  VALID_RESPONSE_CODES = %w(200 202).freeze
72
55
  POLLING_RESPONSE_CODE = "202"
73
56
  POLLING_INTERVAL = 2 # seconds
57
+ # Row parses Snowflake's default date and time encodings and returns binary as its default hex
58
+ OUTPUT_FORMAT_PARAMETER = /_OUTPUT_FORMAT\z/i
59
+ private_constant :OUTPUT_FORMAT_PARAMETER
74
60
 
75
61
  # can't be set after initialization
76
62
  attr_reader :connection_timeout, :max_connections, :logger, :max_threads_per_query, :thread_scale_factor, :http_retries, :query_timeout, :default_role
@@ -151,11 +137,12 @@ module RubySnowflake
151
137
  @_enable_polling_queries = false
152
138
  end
153
139
 
154
- def query(query, warehouse: nil, streaming: false, database: nil, schema: nil, bindings: nil, role: nil, query_name: nil, query_timeout: nil)
140
+ def query(query, warehouse: nil, streaming: false, database: nil, schema: nil, bindings: nil, role: nil, query_name: nil, query_timeout: nil, parameters: nil)
155
141
  warehouse ||= @default_warehouse
156
142
  database ||= @default_database
157
143
  role ||= @default_role
158
144
  query_timeout ||= @query_timeout
145
+ validate_parameters!(parameters)
159
146
 
160
147
  with_instrumentation({ database:, schema:, warehouse:, query_name: }) do
161
148
  query_start_time = Time.now.to_i
@@ -170,6 +157,8 @@ module RubySnowflake
170
157
  "role" => role,
171
158
  "timeout" => query_timeout
172
159
  }
160
+ # Omit rather than send null, which Snowflake doesn't document for parameters
161
+ request_body["parameters"] = parameters unless parameters.nil?
173
162
 
174
163
  response = request_with_auth_and_headers(
175
164
  connection,
@@ -198,6 +187,18 @@ module RubySnowflake
198
187
  private_class_method :env_option
199
188
 
200
189
  private
190
+ def validate_parameters!(parameters)
191
+ return if parameters.nil?
192
+ raise ArgumentError, "parameters must be a Hash, got #{parameters.class}" unless parameters.is_a?(Hash)
193
+
194
+ output_format_key = parameters.keys.find { |key| key.to_s.match?(OUTPUT_FORMAT_PARAMETER) }
195
+ return if output_format_key.nil?
196
+
197
+ raise ArgumentError,
198
+ "The #{output_format_key} parameter is not supported: this client parses Snowflake's default " \
199
+ "date, time and timestamp encodings, and returns binary values in its default hex"
200
+ end
201
+
201
202
  def connection_pool
202
203
  @connection_pool ||= ConnectionPool.new(size: @max_connections, timeout: @connection_timeout) do
203
204
  HttpConnectionWrapper.new(hostname, port).start
@@ -225,9 +226,10 @@ module RubySnowflake
225
226
  sleep: lambda {|n| 2**n }, # 1, 2, 4, 8, etc
226
227
  on: [RetryableBadResponseError, OpenSSL::SSL::SSLError],
227
228
  log_method: retryable_log_method) do
228
- response = nil
229
- bm = Benchmark.measure { response = connection.request(request) }
230
- logger.debug { "HTTP Request time: #{bm.real}" }
229
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
+ response = connection.request(request)
231
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
232
+ logger.debug { "HTTP Request time: #{elapsed}" }
231
233
  raise_on_bad_response(response)
232
234
  response
233
235
  end
@@ -330,9 +332,10 @@ module RubySnowflake
330
332
  )
331
333
  end
332
334
 
333
- partition_json = {}
334
- bm = Benchmark.measure { partition_json = JSON.parse(partition_response.body, **JSON_PARSE_OPTIONS) }
335
- logger.debug { "JSON parsing took: #{bm.real}" }
335
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
336
+ partition_json = JSON.parse(partition_response.body, **JSON_PARSE_OPTIONS)
337
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
338
+ logger.debug { "JSON parsing took: #{elapsed}" }
336
339
  partition_data = partition_json["data"]
337
340
 
338
341
  partition_data
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubySnowflake
4
+ class Error < StandardError
5
+ def initialize(details)
6
+ @details = details
7
+ end
8
+
9
+ def message
10
+ @details.to_s
11
+ end
12
+ end
13
+
14
+ class BadResponseError < Error ; end
15
+ class ConnectionError < Error ; end
16
+ class ConnectionStarvedError < Error ; end
17
+ class MissingConfig < Error ; end
18
+ class RetryableBadResponseError < Error ; end
19
+ class RequestError < Error ; end
20
+ class QueryTimeoutError < Error ; end
21
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connection_pool"
4
+
5
+ require_relative "errors"
6
+
7
+ module RubySnowflake
8
+ module PartitionFuture
9
+ # Waits for a partition fetch. RubySnowflake errors pass through, pool timeouts become
10
+ # ConnectionStarvedError and anything else a RubySnowflake::Error, with the original as cause.
11
+ # Only the fetch's own error is mapped, never an interrupt raised in the waiting thread.
12
+ def self.value!(future)
13
+ future.wait
14
+ return future.value unless future.rejected?
15
+
16
+ reason = future.reason
17
+ case reason
18
+ when RubySnowflake::Error
19
+ raise reason
20
+ when ConnectionPool::TimeoutError
21
+ raise ConnectionStarvedError.new(
22
+ "A partition request timed out. This is usually due to using the client in " \
23
+ "multiple threads. The client uses a connection thread pool and if too many " \
24
+ "requests are all done in threads at the same time, threads can get starved " \
25
+ "of access to connections. The solution for this is to either increase the " \
26
+ "max_connections parameter on the client or create a new client instance " \
27
+ "with its own connection pool to snowflake per thread. Rejection reason: #{reason.message}"
28
+ ), cause: reason
29
+ else
30
+ raise Error.new("Fetching a result partition failed: #{reason.class}: #{reason.message}"), cause: reason
31
+ end
32
+ end
33
+ end
34
+ private_constant :PartitionFuture
35
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "concurrent"
4
4
 
5
+ require_relative "partition_future"
5
6
  require_relative "result"
6
7
 
7
8
  module RubySnowflake
@@ -16,30 +17,36 @@ module RubySnowflake
16
17
 
17
18
  thread_pool = Concurrent::FixedThreadPool.new 1
18
19
 
19
- data.each_with_index do |_partition, index|
20
- next_index = [index+1, data.size-1].min
21
- if data[next_index].nil? # prefetch
22
- data[next_index] = Concurrent::Future.execute(executor: thread_pool) do
23
- @retreive_proc.call(next_index)
20
+ begin
21
+ data.each_with_index do |_partition, index|
22
+ next_index = [index+1, data.size-1].min
23
+ if data[next_index].nil? # prefetch
24
+ data[next_index] = Concurrent::Future.execute(executor: thread_pool) do
25
+ @retreive_proc.call(next_index)
26
+ end
24
27
  end
25
- end
26
28
 
27
- if data[index].is_a? Concurrent::Future
28
- data[index] = data[index].value # wait for it to finish
29
- end
29
+ if data[index].is_a? Concurrent::Future
30
+ data[index] = PartitionFuture.value!(data[index]) # wait for it to finish and propagate errors
31
+ end
30
32
 
31
- data[index].each do |row|
32
- yield wrap_row(row)
33
- end
33
+ data[index].each do |row|
34
+ yield wrap_row(row)
35
+ end
34
36
 
35
- # After iterating over the current partition, clear the data to release memory
36
- data[index].clear
37
+ # After iterating over the current partition, clear the data to release memory
38
+ data[index].clear
37
39
 
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
40
+ # Reassign to a symbol so:
41
+ # - When looking at the list of partitions in `data` it is easier to detect
42
+ # - Will raise an exception if `data.each` is attempted to be called again
43
+ # - It won't trigger prefetch detection as `next_index`
44
+ data[index] = :finished
45
+ end
46
+ ensure
47
+ # No wait: an early exit shouldn't block on a prefetch it won't read. The worker finishes
48
+ # it and exits; killing it could return a connection mid-response.
49
+ thread_pool.shutdown
43
50
  end
44
51
  end
45
52
 
@@ -1,3 +1,3 @@
1
1
  module RubySnowflake
2
- VERSION = "1.6.2"
2
+ VERSION = "1.7.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.6.2
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rinsed
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-20 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -138,6 +138,8 @@ files:
138
138
  - lib/ruby_snowflake/client/single_thread_in_memory_strategy.rb
139
139
  - lib/ruby_snowflake/client/streaming_result_strategy.rb
140
140
  - lib/ruby_snowflake/client/threaded_in_memory_strategy.rb
141
+ - lib/ruby_snowflake/errors.rb
142
+ - lib/ruby_snowflake/partition_future.rb
141
143
  - lib/ruby_snowflake/result.rb
142
144
  - lib/ruby_snowflake/row.rb
143
145
  - lib/ruby_snowflake/streaming_result.rb