rb_snowflake_client 1.6.1 → 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 +4 -4
- data/.github/workflows/arby-review.yml +108 -0
- data/CHANGELOG.md +24 -0
- data/README.md +29 -0
- data/lib/ruby_snowflake/client/threaded_in_memory_strategy.rb +11 -21
- data/lib/ruby_snowflake/client.rb +31 -28
- data/lib/ruby_snowflake/errors.rb +21 -0
- data/lib/ruby_snowflake/partition_future.rb +35 -0
- data/lib/ruby_snowflake/streaming_result.rb +26 -19
- data/lib/ruby_snowflake/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05c5fef52239a7d043627e57e3a205ef07092452b7c7a47779ad21150637ec66
|
|
4
|
+
data.tar.gz: f98da0cd8848820a984753175fd9c22d60f41d137da780de0d8d08f99fa4632a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f577b2fef1a891a37a0a793a53ed4d24d63e665fc5d6e114332f836a4f12a26494f002bf6a589c1b921df253ccac10d81ad122636e8a0d053f16efe4f1c3313d
|
|
7
|
+
data.tar.gz: 4cb2888d8274a883689b448ba6acf315b683ba2692612b1d3753921f082ee14cd5292216c436a4e7d4f5b3ef3893af3a946333a6abff710f42c2246400736855
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# For a PUBLIC repo: copy this one file into its .github/workflows/ instead of review.yml,
|
|
2
|
+
# review-manual-request.yml and review-dismiss-stale.yml. A public repo can't use actions or reusable
|
|
3
|
+
# workflows from rinsed-org/review-bot-workflows (an internal repo), so it doesn't run Arby itself. It asks
|
|
4
|
+
# rinsed-org/review-bot-public-runner (internal) to, by dispatching that repo's review workflow with nothing
|
|
5
|
+
# but this repo's name and the PR number. The runner reads this repo's settings (limit-approval-to,
|
|
6
|
+
# blocking-severities, ...) from its own config file and runs the reusable workflow in cross-repo mode, which
|
|
7
|
+
# reviews this PR in public repo mode and posts the review here as Arby. Per-repo setup: add this repo to the
|
|
8
|
+
# runner's config, then add this file. See the bot's README, "Public repositories".
|
|
9
|
+
#
|
|
10
|
+
# Nothing PR-controlled reaches the dispatch: only github.repository and the PR number, passed through env.
|
|
11
|
+
# No checkout, no PR code. The token is minted from the Arby Dispatcher app (Actions: write, installed only on
|
|
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.
|
|
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.
|
|
20
|
+
name: Arby review (public repo)
|
|
21
|
+
|
|
22
|
+
on:
|
|
23
|
+
pull_request_target:
|
|
24
|
+
types: [opened, synchronize, reopened, ready_for_review, review_requested]
|
|
25
|
+
|
|
26
|
+
# Nothing by default; each job asks for exactly what it needs.
|
|
27
|
+
permissions: {}
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
dispatch:
|
|
31
|
+
name: Request Arby review
|
|
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)
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
timeout-minutes: 5
|
|
39
|
+
# Only the app token is used, so the job's own token needs nothing.
|
|
40
|
+
permissions: {}
|
|
41
|
+
steps:
|
|
42
|
+
- name: Mint a dispatch token
|
|
43
|
+
id: token
|
|
44
|
+
uses: actions/create-github-app-token@v3
|
|
45
|
+
with:
|
|
46
|
+
client-id: ${{ secrets.ARBY_DISPATCH_CLIENT_ID }}
|
|
47
|
+
private-key: ${{ secrets.ARBY_DISPATCH_PRIVATE_KEY }}
|
|
48
|
+
owner: rinsed-org
|
|
49
|
+
repositories: review-bot-public-runner
|
|
50
|
+
permission-actions: write
|
|
51
|
+
- name: Dispatch the review
|
|
52
|
+
env:
|
|
53
|
+
GH_TOKEN: ${{ steps.token.outputs.token }}
|
|
54
|
+
REPO: ${{ github.repository }}
|
|
55
|
+
PR: ${{ github.event.pull_request.number }}
|
|
56
|
+
run: |
|
|
57
|
+
# The REST endpoint directly, with an explicit ref: `gh workflow run` would first look up the runner
|
|
58
|
+
# repo's default branch, which needs Contents read, and this token has only Actions: write.
|
|
59
|
+
gh api --method POST repos/rinsed-org/review-bot-public-runner/actions/workflows/review.yml/dispatches \
|
|
60
|
+
-f ref=master -f "inputs[repo]=$REPO" -f "inputs[pr]=$PR"
|
|
61
|
+
|
|
62
|
+
# A COPY of rinsed-org/review-bot-workflows' dismiss-stale/action.yml, inlined because a public repo can't
|
|
63
|
+
# use that internal action. The two must change together. Its own job, so it stays a cheap check with no
|
|
64
|
+
# dependency on Arby, the app or the runner repo: make THIS job ("Dismiss stale Arby approvals") the
|
|
65
|
+
# required status check, as in review-dismiss-stale.yml. It runs on the job's default token and consumes
|
|
66
|
+
# nothing PR-controlled beyond the PR number.
|
|
67
|
+
dismiss:
|
|
68
|
+
# The required check is keyed on this name; keep it exactly as is.
|
|
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.
|
|
74
|
+
concurrency:
|
|
75
|
+
group: arby-dismiss-stale-${{ github.event.pull_request.number }}
|
|
76
|
+
cancel-in-progress: true
|
|
77
|
+
timeout-minutes: 5
|
|
78
|
+
runs-on: ubuntu-latest
|
|
79
|
+
permissions:
|
|
80
|
+
pull-requests: write
|
|
81
|
+
steps:
|
|
82
|
+
- name: Dismiss stale review-bot approvals
|
|
83
|
+
shell: bash
|
|
84
|
+
env:
|
|
85
|
+
GH_TOKEN: ${{ github.token }}
|
|
86
|
+
REPO: ${{ github.repository }}
|
|
87
|
+
PR: ${{ github.event.pull_request.number }}
|
|
88
|
+
BOT_LOGIN: rinsed-review-bot
|
|
89
|
+
run: |
|
|
90
|
+
set -euo pipefail
|
|
91
|
+
if [ -z "$PR" ]; then
|
|
92
|
+
echo "::error::No pull request in the event payload — call this from a pull_request_target workflow."
|
|
93
|
+
exit 1
|
|
94
|
+
fi
|
|
95
|
+
# Skip approvals for the PR's current head: the review workflow runs on the same event with no
|
|
96
|
+
# ordering, so a delayed dismiss job must not kill an approval Arby already gave the new commits. The
|
|
97
|
+
# head is fetched at execution time (not from the event payload) so a run delayed past a newer push
|
|
98
|
+
# can't dismiss that newer push's approval either.
|
|
99
|
+
head_sha=$(gh api "repos/$REPO/pulls/$PR" --jq .head.sha)
|
|
100
|
+
gh api "repos/$REPO/pulls/$PR/reviews" --paginate \
|
|
101
|
+
--jq ".[] | select(.user.login == \"$BOT_LOGIN\" and .state == \"APPROVED\"
|
|
102
|
+
and .commit_id != \"$head_sha\") | .id" |
|
|
103
|
+
while read -r review_id; do
|
|
104
|
+
echo "Dismissing stale approval (review $review_id)"
|
|
105
|
+
# The API rejects blank (or whitespace-only) messages, so this is as terse as GitHub allows.
|
|
106
|
+
gh api --method PUT "repos/$REPO/pulls/$PR/reviews/$review_id/dismissals" \
|
|
107
|
+
--field message="Dismissing stale review" --field event="DISMISS"
|
|
108
|
+
done
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ 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
|
+
|
|
28
|
+
## [1.6.2] - 2026-10-19
|
|
29
|
+
### Fixed
|
|
30
|
+
- Compatibility with `json` 3.0, where `JSON.parse` options are keyword-only: every query raised
|
|
31
|
+
`ArgumentError: wrong number of arguments (given 2, expected 1)` because the parse options were passed
|
|
32
|
+
as a positional Hash. They're now passed as keywords, which works on `json` 2.x and 3.x. (#191)
|
|
33
|
+
|
|
10
34
|
## [1.6.1] - 2026-08-18
|
|
11
35
|
### Security
|
|
12
36
|
- Bumped `json` to patch a format-string injection CVE (#169)
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
@@ -300,12 +302,12 @@ module RubySnowflake
|
|
|
300
302
|
end
|
|
301
303
|
|
|
302
304
|
def retrieve_result_set(query_start_time, query, response, streaming, query_timeout)
|
|
303
|
-
json_body = JSON.parse(response.body, JSON_PARSE_OPTIONS)
|
|
305
|
+
json_body = JSON.parse(response.body, **JSON_PARSE_OPTIONS)
|
|
304
306
|
statement_handle = json_body["statementHandle"]
|
|
305
307
|
|
|
306
308
|
if response.code == POLLING_RESPONSE_CODE
|
|
307
309
|
result_response = poll_for_completion_or_timeout(query_start_time, query, statement_handle, query_timeout)
|
|
308
|
-
json_body = JSON.parse(result_response.body, JSON_PARSE_OPTIONS)
|
|
310
|
+
json_body = JSON.parse(result_response.body, **JSON_PARSE_OPTIONS)
|
|
309
311
|
end
|
|
310
312
|
|
|
311
313
|
num_threads = number_of_threads_to_use(json_body["resultSetMetaData"]["partitionInfo"].size)
|
|
@@ -330,9 +332,10 @@ module RubySnowflake
|
|
|
330
332
|
)
|
|
331
333
|
end
|
|
332
334
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
data[next_index]
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
data[index].each do |row|
|
|
34
|
+
yield wrap_row(row)
|
|
35
|
+
end
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
# After iterating over the current partition, clear the data to release memory
|
|
38
|
+
data[index].clear
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
+
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-
|
|
11
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|
|
@@ -120,6 +120,7 @@ extensions: []
|
|
|
120
120
|
extra_rdoc_files: []
|
|
121
121
|
files:
|
|
122
122
|
- ".github/dependabot.yml"
|
|
123
|
+
- ".github/workflows/arby-review.yml"
|
|
123
124
|
- ".github/workflows/ci.yml"
|
|
124
125
|
- ".github/workflows/release-gh-packages.yml"
|
|
125
126
|
- ".github/workflows/release-rubygems.yml"
|
|
@@ -137,6 +138,8 @@ files:
|
|
|
137
138
|
- lib/ruby_snowflake/client/single_thread_in_memory_strategy.rb
|
|
138
139
|
- lib/ruby_snowflake/client/streaming_result_strategy.rb
|
|
139
140
|
- lib/ruby_snowflake/client/threaded_in_memory_strategy.rb
|
|
141
|
+
- lib/ruby_snowflake/errors.rb
|
|
142
|
+
- lib/ruby_snowflake/partition_future.rb
|
|
140
143
|
- lib/ruby_snowflake/result.rb
|
|
141
144
|
- lib/ruby_snowflake/row.rb
|
|
142
145
|
- lib/ruby_snowflake/streaming_result.rb
|