spark-connect 0.2.0 → 0.4.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/CHANGELOG.md +173 -1
- data/README.md +37 -13
- data/lib/spark_connect/arrow.rb +5 -1
- data/lib/spark_connect/catalog.rb +157 -12
- data/lib/spark_connect/channel_builder.rb +119 -12
- data/lib/spark_connect/client.rb +237 -17
- data/lib/spark_connect/column.rb +143 -10
- data/lib/spark_connect/conf.rb +17 -1
- data/lib/spark_connect/data_frame.rb +447 -25
- data/lib/spark_connect/functions.rb +1948 -494
- data/lib/spark_connect/grouped_data.rb +33 -7
- data/lib/spark_connect/merge.rb +146 -0
- data/lib/spark_connect/na_functions.rb +43 -9
- data/lib/spark_connect/observation.rb +26 -5
- data/lib/spark_connect/pipelines.rb +101 -0
- data/lib/spark_connect/plan.rb +52 -0
- data/lib/spark_connect/proto/spark/connect/base_pb.rb +6 -1
- data/lib/spark_connect/proto/spark/connect/base_services_pb.rb +2 -0
- data/lib/spark_connect/proto/spark/connect/catalog_pb.rb +11 -1
- data/lib/spark_connect/proto/spark/connect/commands_pb.rb +1 -1
- data/lib/spark_connect/proto/spark/connect/expressions_pb.rb +3 -1
- data/lib/spark_connect/proto/spark/connect/pipelines_pb.rb +5 -1
- data/lib/spark_connect/proto/spark/connect/relations_pb.rb +4 -1
- data/lib/spark_connect/proto/spark/connect/types_pb.rb +3 -1
- data/lib/spark_connect/reader.rb +98 -17
- data/lib/spark_connect/session.rb +232 -30
- data/lib/spark_connect/stat_functions.rb +19 -9
- data/lib/spark_connect/storage_level.rb +79 -0
- data/lib/spark_connect/streaming.rb +335 -20
- data/lib/spark_connect/table_arg.rb +73 -0
- data/lib/spark_connect/tvf.rb +78 -0
- data/lib/spark_connect/types.rb +149 -0
- data/lib/spark_connect/version.rb +2 -2
- data/lib/spark_connect/writer.rb +80 -10
- data/lib/spark_connect.rb +4 -0
- data/proto/spark/connect/base.proto +93 -1
- data/proto/spark/connect/catalog.proto +81 -0
- data/proto/spark/connect/commands.proto +7 -0
- data/proto/spark/connect/expressions.proto +28 -0
- data/proto/spark/connect/pipelines.proto +92 -5
- data/proto/spark/connect/relations.proto +71 -1
- data/proto/spark/connect/types.proto +19 -0
- metadata +10 -3
|
@@ -13,13 +13,18 @@ module SparkConnect
|
|
|
13
13
|
#
|
|
14
14
|
# Recognised parameters:
|
|
15
15
|
# * `token` - bearer token; implies TLS and adds an `authorization` header
|
|
16
|
+
# (defaults to the `SPARK_CONNECT_AUTHENTICATE_TOKEN` environment variable)
|
|
16
17
|
# * `user_id` - the Spark user id
|
|
17
18
|
# * `user_agent` - client user agent (default `spark-connect-ruby/<version>`)
|
|
18
19
|
# * `use_ssl` - `true`/`false`; force TLS on or off
|
|
19
20
|
# * `session_id` - reuse a specific server-side session id (UUID)
|
|
21
|
+
# * `grpc_keepalive_enabled` (default `true`), `grpc_keepalive_time_ms`
|
|
22
|
+
# (default 60000), `grpc_keepalive_timeout_ms` (default 20000), and
|
|
23
|
+
# `grpc_keepalive_without_calls` (default `true`) - gRPC/HTTP2 keepalive
|
|
24
|
+
# PINGs that detect a silently dead connection (Spark 4.3 client defaults)
|
|
20
25
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
26
|
+
# Every other parameter is forwarded verbatim as gRPC request metadata (like
|
|
27
|
+
# the official clients), e.g. `x-api-key=...`.
|
|
23
28
|
#
|
|
24
29
|
# @example
|
|
25
30
|
# cb = SparkConnect::ChannelBuilder.new("sc://localhost:15002")
|
|
@@ -29,6 +34,34 @@ module SparkConnect
|
|
|
29
34
|
DEFAULT_PORT = 15_002
|
|
30
35
|
PARAM_PREFIX = "x-"
|
|
31
36
|
|
|
37
|
+
PARAM_TOKEN = "token"
|
|
38
|
+
PARAM_USER_ID = "user_id"
|
|
39
|
+
PARAM_USER_AGENT = "user_agent"
|
|
40
|
+
PARAM_USE_SSL = "use_ssl"
|
|
41
|
+
PARAM_SESSION_ID = "session_id"
|
|
42
|
+
PARAM_GRPC_KEEPALIVE_ENABLED = "grpc_keepalive_enabled"
|
|
43
|
+
PARAM_GRPC_KEEPALIVE_TIME_MS = "grpc_keepalive_time_ms"
|
|
44
|
+
PARAM_GRPC_KEEPALIVE_TIMEOUT_MS = "grpc_keepalive_timeout_ms"
|
|
45
|
+
PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS = "grpc_keepalive_without_calls"
|
|
46
|
+
|
|
47
|
+
# Parameters consumed by the client itself and never sent as metadata.
|
|
48
|
+
RESERVED_PARAMS = [
|
|
49
|
+
PARAM_TOKEN, PARAM_USER_ID, PARAM_USER_AGENT, PARAM_USE_SSL, PARAM_SESSION_ID,
|
|
50
|
+
PARAM_GRPC_KEEPALIVE_ENABLED, PARAM_GRPC_KEEPALIVE_TIME_MS, PARAM_GRPC_KEEPALIVE_TIMEOUT_MS,
|
|
51
|
+
PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS,
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
GRPC_DEFAULT_KEEPALIVE_ENABLED = true
|
|
55
|
+
GRPC_DEFAULT_KEEPALIVE_TIME_MS = 60 * 1000
|
|
56
|
+
GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS = 20 * 1000
|
|
57
|
+
GRPC_DEFAULT_KEEPALIVE_WITHOUT_CALLS = true
|
|
58
|
+
|
|
59
|
+
# Like the official clients, allow messages (e.g. large Arrow batches) of up
|
|
60
|
+
# to 128 MiB instead of gRPC's 4 MiB receive default.
|
|
61
|
+
GRPC_MAX_MESSAGE_LENGTH_DEFAULT = 128 * 1024 * 1024
|
|
62
|
+
|
|
63
|
+
UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
64
|
+
|
|
32
65
|
# @return [String]
|
|
33
66
|
attr_reader :host
|
|
34
67
|
# @return [Integer]
|
|
@@ -52,10 +85,66 @@ module SparkConnect
|
|
|
52
85
|
@params = parse_params(param_str)
|
|
53
86
|
parse_endpoint(endpoint)
|
|
54
87
|
|
|
55
|
-
@token = @params["
|
|
56
|
-
@user_id = @params[
|
|
57
|
-
@session_id = @params[
|
|
58
|
-
|
|
88
|
+
@token = @params[PARAM_TOKEN] || ENV.fetch("SPARK_CONNECT_AUTHENTICATE_TOKEN", nil)
|
|
89
|
+
@user_id = @params[PARAM_USER_ID]
|
|
90
|
+
@session_id = @params[PARAM_SESSION_ID]
|
|
91
|
+
validate_session_id!(@session_id)
|
|
92
|
+
@use_ssl = parse_bool(@params[PARAM_USE_SSL]) || !@token.nil?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A copy of this builder with `session_id` replaced (or removed when nil).
|
|
96
|
+
# Used to create a fresh ({SparkSession#new_session}) or cloned
|
|
97
|
+
# ({SparkSession#clone_session}) session against the same endpoint.
|
|
98
|
+
#
|
|
99
|
+
# @param session_id [String, nil]
|
|
100
|
+
# @return [ChannelBuilder]
|
|
101
|
+
def with_session_id(session_id)
|
|
102
|
+
copy = dup
|
|
103
|
+
params = @params.dup
|
|
104
|
+
if session_id.nil?
|
|
105
|
+
params.delete(PARAM_SESSION_ID)
|
|
106
|
+
else
|
|
107
|
+
params[PARAM_SESSION_ID] = session_id
|
|
108
|
+
end
|
|
109
|
+
copy.instance_variable_set(:@params, params)
|
|
110
|
+
copy.instance_variable_set(:@session_id, session_id)
|
|
111
|
+
copy
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Boolean] whether gRPC keepalive PINGs are sent.
|
|
115
|
+
def keepalive_enabled?
|
|
116
|
+
bool_param(PARAM_GRPC_KEEPALIVE_ENABLED, GRPC_DEFAULT_KEEPALIVE_ENABLED)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Integer] idle time (ms) before a keepalive PING.
|
|
120
|
+
def keepalive_time_ms
|
|
121
|
+
int_param(PARAM_GRPC_KEEPALIVE_TIME_MS, GRPC_DEFAULT_KEEPALIVE_TIME_MS)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Integer] time (ms) to wait for a keepalive PING ack.
|
|
125
|
+
def keepalive_timeout_ms
|
|
126
|
+
int_param(PARAM_GRPC_KEEPALIVE_TIMEOUT_MS, GRPC_DEFAULT_KEEPALIVE_TIMEOUT_MS)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [Boolean] whether keepalive PINGs are sent without in-flight calls.
|
|
130
|
+
def keepalive_without_calls?
|
|
131
|
+
bool_param(PARAM_GRPC_KEEPALIVE_WITHOUT_CALLS, GRPC_DEFAULT_KEEPALIVE_WITHOUT_CALLS)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The gRPC channel arguments derived from the connection string.
|
|
135
|
+
# @return [Hash{String=>Object}]
|
|
136
|
+
def channel_args
|
|
137
|
+
args = {
|
|
138
|
+
"grpc.primary_user_agent" => user_agent,
|
|
139
|
+
"grpc.max_send_message_length" => GRPC_MAX_MESSAGE_LENGTH_DEFAULT,
|
|
140
|
+
"grpc.max_receive_message_length" => GRPC_MAX_MESSAGE_LENGTH_DEFAULT,
|
|
141
|
+
}
|
|
142
|
+
if keepalive_enabled?
|
|
143
|
+
args["grpc.keepalive_time_ms"] = keepalive_time_ms
|
|
144
|
+
args["grpc.keepalive_timeout_ms"] = keepalive_timeout_ms
|
|
145
|
+
args["grpc.keepalive_permit_without_calls"] = keepalive_without_calls? ? 1 : 0
|
|
146
|
+
end
|
|
147
|
+
args
|
|
59
148
|
end
|
|
60
149
|
|
|
61
150
|
# @return [Boolean] whether the channel uses TLS.
|
|
@@ -73,25 +162,25 @@ module SparkConnect
|
|
|
73
162
|
@params["user_agent"] || "spark-connect-ruby/#{SparkConnect::VERSION}"
|
|
74
163
|
end
|
|
75
164
|
|
|
76
|
-
# Per-request gRPC metadata derived from the connection string
|
|
77
|
-
# plus
|
|
165
|
+
# Per-request gRPC metadata derived from the connection string: the bearer
|
|
166
|
+
# token plus every parameter the client does not consume itself.
|
|
78
167
|
#
|
|
79
168
|
# @return [Hash{String=>String}]
|
|
80
169
|
def metadata
|
|
81
170
|
md = {}
|
|
82
171
|
md["authorization"] = "Bearer #{@token}" if @token
|
|
83
|
-
@params.each { |k, v| md[k] = v
|
|
172
|
+
@params.each { |k, v| md[k] = v unless RESERVED_PARAMS.include?(k) }
|
|
84
173
|
md
|
|
85
174
|
end
|
|
86
175
|
|
|
87
176
|
# Build the gRPC stub for the {Spark::Connect::SparkConnectService}.
|
|
88
177
|
#
|
|
89
|
-
# @param channel_args [Hash] extra gRPC channel arguments
|
|
178
|
+
# @param channel_args [Hash] extra gRPC channel arguments (override the
|
|
179
|
+
# defaults from {#channel_args}).
|
|
90
180
|
# @return [Spark::Connect::SparkConnectService::Stub]
|
|
91
181
|
def build_stub(channel_args: {})
|
|
92
182
|
creds = @use_ssl ? GRPC::Core::ChannelCredentials.new : :this_channel_is_insecure
|
|
93
|
-
|
|
94
|
-
Proto::SparkConnectService::Stub.new(target, creds, channel_args: args)
|
|
183
|
+
Proto::SparkConnectService::Stub.new(target, creds, channel_args: self.channel_args.merge(channel_args))
|
|
95
184
|
end
|
|
96
185
|
|
|
97
186
|
private
|
|
@@ -130,5 +219,23 @@ module SparkConnect
|
|
|
130
219
|
|
|
131
220
|
%w[true 1 yes].include?(value.to_s.downcase)
|
|
132
221
|
end
|
|
222
|
+
|
|
223
|
+
def bool_param(key, default)
|
|
224
|
+
value = @params[key]
|
|
225
|
+
value.nil? ? default : parse_bool(value)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def int_param(key, default)
|
|
229
|
+
value = @params[key]
|
|
230
|
+
return default if value.nil?
|
|
231
|
+
|
|
232
|
+
Integer(value, exception: false) || raise(ConnectionError, "Invalid integer for #{key}: #{value.inspect}")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def validate_session_id!(session_id)
|
|
236
|
+
return if session_id.nil? || UUID_PATTERN.match?(session_id)
|
|
237
|
+
|
|
238
|
+
raise ConnectionError, "session_id must be a UUID string, got #{session_id.inspect}"
|
|
239
|
+
end
|
|
133
240
|
end
|
|
134
241
|
end
|
data/lib/spark_connect/client.rb
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
5
|
module SparkConnect
|
|
6
|
+
# Progress of one Spark stage, reported to the handlers registered with
|
|
7
|
+
# {SparkSession#register_progress_handler} while a query runs.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute [r] stage_id
|
|
10
|
+
# @return [Integer]
|
|
11
|
+
# @!attribute [r] num_tasks
|
|
12
|
+
# @return [Integer]
|
|
13
|
+
# @!attribute [r] num_completed_tasks
|
|
14
|
+
# @return [Integer]
|
|
15
|
+
# @!attribute [r] num_bytes_read
|
|
16
|
+
# @return [Integer]
|
|
17
|
+
# @!attribute [r] done
|
|
18
|
+
# @return [Boolean]
|
|
19
|
+
StageInfo = Struct.new(:stage_id, :num_tasks, :num_completed_tasks, :num_bytes_read, :done, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# Per-RPC timeouts (in seconds; `nil` disables the timeout for that RPC),
|
|
22
|
+
# mirroring the Spark 4.3 clients' defaults. `ExecutePlan` (the query stream
|
|
23
|
+
# itself) never has a deadline, since a timeout there would kill the query.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# SparkConnect::SparkConnectClient.new(cb, rpc_deadlines: SparkConnect::RpcDeadlines.new(analyze_plan: 120))
|
|
27
|
+
RpcDeadlines = Struct.new(
|
|
28
|
+
:analyze_plan, :config, :interrupt, :release_session, :clone_session, :get_status,
|
|
29
|
+
keyword_init: true
|
|
30
|
+
) do
|
|
31
|
+
# @return [RpcDeadlines] the Spark 4.3 defaults (1 hour for AnalyzePlan, 10 minutes otherwise).
|
|
32
|
+
def self.defaults
|
|
33
|
+
new(analyze_plan: 3600, config: 600, interrupt: 600, release_session: 600, clone_session: 600, get_status: 600)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [RpcDeadlines] all per-RPC timeouts disabled.
|
|
37
|
+
def self.disabled
|
|
38
|
+
new
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
6
42
|
# The low-level Spark Connect client. Wraps the gRPC stub and exposes the four
|
|
7
43
|
# core RPC families used by the high-level API: {#execute_plan},
|
|
8
44
|
# {#execute_command}, {#analyze}, and {#config}. Higher layers
|
|
@@ -28,7 +64,7 @@ module SparkConnect
|
|
|
28
64
|
ExecuteResult = Struct.new(
|
|
29
65
|
:arrow_batches, :schema, :metrics, :observed_metrics, :sql_command_result, :row_count,
|
|
30
66
|
:write_stream_result, :streaming_query_result, :streaming_manager_result, :checkpoint_relation,
|
|
31
|
-
:pipeline_command_result, :pipeline_events
|
|
67
|
+
:pipeline_command_result, :pipeline_events, :operation_id
|
|
32
68
|
)
|
|
33
69
|
|
|
34
70
|
# @return [String] the client-side session id (UUID v4).
|
|
@@ -37,12 +73,16 @@ module SparkConnect
|
|
|
37
73
|
attr_reader :client_type
|
|
38
74
|
# @return [ChannelBuilder]
|
|
39
75
|
attr_reader :channel_builder
|
|
76
|
+
# @return [RpcDeadlines]
|
|
77
|
+
attr_reader :rpc_deadlines
|
|
40
78
|
|
|
41
79
|
# @param channel_builder [ChannelBuilder]
|
|
42
80
|
# @param session_id [String, nil] reuse a session id, otherwise generated.
|
|
43
81
|
# @param max_retries [Integer]
|
|
44
82
|
# @param retry_base_delay [Float] base backoff in seconds.
|
|
45
|
-
|
|
83
|
+
# @param rpc_deadlines [RpcDeadlines] per-RPC timeouts.
|
|
84
|
+
def initialize(channel_builder, session_id: nil, max_retries: 10, retry_base_delay: 0.05, max_retry_delay: 10.0,
|
|
85
|
+
rpc_deadlines: RpcDeadlines.defaults)
|
|
46
86
|
@channel_builder = channel_builder
|
|
47
87
|
@stub = channel_builder.build_stub
|
|
48
88
|
@metadata = channel_builder.metadata
|
|
@@ -52,8 +92,11 @@ module SparkConnect
|
|
|
52
92
|
@max_retries = max_retries
|
|
53
93
|
@retry_base_delay = retry_base_delay
|
|
54
94
|
@max_retry_delay = max_retry_delay
|
|
95
|
+
@rpc_deadlines = rpc_deadlines
|
|
55
96
|
@server_side_session_id = nil
|
|
56
97
|
@tags = []
|
|
98
|
+
@progress_handlers = []
|
|
99
|
+
@closed = false
|
|
57
100
|
end
|
|
58
101
|
|
|
59
102
|
# Operation tags attached to every subsequent execution (used with
|
|
@@ -84,6 +127,32 @@ module SparkConnect
|
|
|
84
127
|
nil
|
|
85
128
|
end
|
|
86
129
|
|
|
130
|
+
# ---- Progress handlers ---------------------------------------------------
|
|
131
|
+
|
|
132
|
+
# Register a callable notified of execution progress (see {StageInfo}).
|
|
133
|
+
# @param handler [#call]
|
|
134
|
+
# @return [void]
|
|
135
|
+
def register_progress_handler(handler)
|
|
136
|
+
raise IllegalArgumentError, "progress handler must respond to #call" unless handler.respond_to?(:call)
|
|
137
|
+
|
|
138
|
+
@progress_handlers << handler unless @progress_handlers.include?(handler)
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [void]
|
|
143
|
+
def remove_progress_handler(handler)
|
|
144
|
+
@progress_handlers.delete(handler)
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [void]
|
|
149
|
+
def clear_progress_handlers
|
|
150
|
+
@progress_handlers.clear
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ---- Core RPCs -----------------------------------------------------------
|
|
155
|
+
|
|
87
156
|
# Execute a relation plan and accumulate the streamed response.
|
|
88
157
|
#
|
|
89
158
|
# @param relation [Spark::Connect::Relation]
|
|
@@ -100,6 +169,22 @@ module SparkConnect
|
|
|
100
169
|
execute(PlanBuilder.command_plan(command))
|
|
101
170
|
end
|
|
102
171
|
|
|
172
|
+
# Execute a command and yield each raw `ExecutePlanResponse` as it arrives,
|
|
173
|
+
# without accumulating (and without retrying, since responses may already
|
|
174
|
+
# have been consumed). Used for long-running streams such as the streaming
|
|
175
|
+
# query listener bus.
|
|
176
|
+
#
|
|
177
|
+
# @param command [Spark::Connect::Command]
|
|
178
|
+
# @yieldparam response [Spark::Connect::ExecutePlanResponse]
|
|
179
|
+
# @return [void]
|
|
180
|
+
def stream_command(command, &)
|
|
181
|
+
req = execute_request(PlanBuilder.command_plan(command), SecureRandom.uuid)
|
|
182
|
+
@stub.execute_plan(req, metadata: @metadata).each(&)
|
|
183
|
+
nil
|
|
184
|
+
rescue GRPC::BadStatus => e
|
|
185
|
+
raise translate_error(e)
|
|
186
|
+
end
|
|
187
|
+
|
|
103
188
|
# Run an `AnalyzePlan` request.
|
|
104
189
|
#
|
|
105
190
|
# @param analyze_kw [Hash] exactly one `analyze` oneof keyword, e.g.
|
|
@@ -112,7 +197,7 @@ module SparkConnect
|
|
|
112
197
|
client_type: @client_type,
|
|
113
198
|
**analyze_kw
|
|
114
199
|
)
|
|
115
|
-
with_retries { @stub.analyze_plan(req,
|
|
200
|
+
with_retries { @stub.analyze_plan(req, **call_options(:analyze_plan)) }
|
|
116
201
|
end
|
|
117
202
|
|
|
118
203
|
# Run a `Config` request.
|
|
@@ -126,7 +211,7 @@ module SparkConnect
|
|
|
126
211
|
client_type: @client_type,
|
|
127
212
|
operation: operation
|
|
128
213
|
)
|
|
129
|
-
with_retries { @stub.config(req,
|
|
214
|
+
with_retries { @stub.config(req, **call_options(:config)) }
|
|
130
215
|
end
|
|
131
216
|
|
|
132
217
|
# Interrupt running operations.
|
|
@@ -141,7 +226,53 @@ module SparkConnect
|
|
|
141
226
|
req = Proto::InterruptRequest.new(
|
|
142
227
|
session_id: @session_id, user_context: @user_context, client_type: @client_type, **kw
|
|
143
228
|
)
|
|
144
|
-
with_retries { @stub.interrupt(req,
|
|
229
|
+
with_retries { @stub.interrupt(req, **call_options(:interrupt)) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The status of operations in this session (the `GetStatus` RPC, Spark 4.2+).
|
|
233
|
+
#
|
|
234
|
+
# @param operation_ids [Array<String>] operations to report; all operations
|
|
235
|
+
# of the session when empty.
|
|
236
|
+
# @param operation_extensions [Array<Google::Protobuf::Any>] per-operation
|
|
237
|
+
# extension requests.
|
|
238
|
+
# @param request_extensions [Array<Google::Protobuf::Any>] request-level extensions.
|
|
239
|
+
# @return [Spark::Connect::GetStatusResponse] each `operation_statuses`
|
|
240
|
+
# entry has an `operation_id` and a `state` (e.g. `:OPERATION_STATE_RUNNING`).
|
|
241
|
+
def get_operation_statuses(operation_ids = [], operation_extensions: [], request_extensions: [])
|
|
242
|
+
req = Proto::GetStatusRequest.new(
|
|
243
|
+
session_id: @session_id, user_context: @user_context, client_type: @client_type,
|
|
244
|
+
operation_status: Proto::GetStatusRequest::OperationStatusRequest.new(
|
|
245
|
+
operation_ids: Array(operation_ids).map(&:to_s), extensions: operation_extensions
|
|
246
|
+
),
|
|
247
|
+
extensions: request_extensions
|
|
248
|
+
)
|
|
249
|
+
req.client_observed_server_side_session_id = @server_side_session_id if @server_side_session_id
|
|
250
|
+
with_retries { @stub.get_status(req, **call_options(:get_status)) }
|
|
251
|
+
end
|
|
252
|
+
alias getOperationStatuses get_operation_statuses
|
|
253
|
+
|
|
254
|
+
# Clone this client's server-side session (the `CloneSession` RPC, Spark
|
|
255
|
+
# 4.1+) and return a client bound to the clone.
|
|
256
|
+
#
|
|
257
|
+
# @param new_session_id [String, nil] a UUID for the clone (generated when omitted).
|
|
258
|
+
# @return [SparkConnectClient]
|
|
259
|
+
def clone_session(new_session_id = nil)
|
|
260
|
+
req = Proto::CloneSessionRequest.new(session_id: @session_id, user_context: @user_context, client_type: @client_type)
|
|
261
|
+
req.new_session_id = new_session_id if new_session_id
|
|
262
|
+
req.client_observed_server_side_session_id = @server_side_session_id if @server_side_session_id
|
|
263
|
+
resp = with_retries { @stub.clone_session(req, **call_options(:clone_session)) }
|
|
264
|
+
if new_session_id && resp.new_session_id != new_session_id
|
|
265
|
+
raise SparkConnectError, "Server returned session id #{resp.new_session_id}, expected #{new_session_id}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
derive(resp.new_session_id)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A client against the same endpoint bound to a fresh, empty server-side
|
|
272
|
+
# session (no state is copied; no server round trip is made).
|
|
273
|
+
# @return [SparkConnectClient]
|
|
274
|
+
def new_session_client
|
|
275
|
+
derive(nil)
|
|
145
276
|
end
|
|
146
277
|
|
|
147
278
|
# Release this client's server-side session.
|
|
@@ -152,17 +283,49 @@ module SparkConnect
|
|
|
152
283
|
)
|
|
153
284
|
# Best-effort and non-retrying: this runs on teardown, so a dead server
|
|
154
285
|
# must not block the caller with the retry/backoff loop.
|
|
155
|
-
@stub.release_session(req,
|
|
286
|
+
@stub.release_session(req, **call_options(:release_session))
|
|
287
|
+
nil
|
|
288
|
+
rescue StandardError
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Close the gRPC channel. Idempotent.
|
|
293
|
+
# @return [void]
|
|
294
|
+
def close
|
|
295
|
+
return nil if @closed
|
|
296
|
+
|
|
297
|
+
@closed = true
|
|
298
|
+
channel = @stub.instance_variable_get(:@ch)
|
|
299
|
+
channel.close if channel.respond_to?(:close)
|
|
156
300
|
nil
|
|
157
301
|
rescue StandardError
|
|
158
302
|
nil
|
|
159
303
|
end
|
|
160
304
|
|
|
305
|
+
# @return [Boolean] whether {#close} has been called.
|
|
306
|
+
def closed?
|
|
307
|
+
@closed
|
|
308
|
+
end
|
|
309
|
+
|
|
161
310
|
private
|
|
162
311
|
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
312
|
+
def derive(session_id)
|
|
313
|
+
SparkConnectClient.new(
|
|
314
|
+
@channel_builder.with_session_id(session_id),
|
|
315
|
+
max_retries: @max_retries, retry_base_delay: @retry_base_delay, max_retry_delay: @max_retry_delay,
|
|
316
|
+
rpc_deadlines: @rpc_deadlines
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def call_options(rpc)
|
|
321
|
+
opts = { metadata: @metadata }
|
|
322
|
+
timeout = @rpc_deadlines&.public_send(rpc)
|
|
323
|
+
opts[:deadline] = Time.now + timeout if timeout
|
|
324
|
+
opts
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def execute_request(plan, operation_id)
|
|
328
|
+
Proto::ExecutePlanRequest.new(
|
|
166
329
|
session_id: @session_id,
|
|
167
330
|
user_context: @user_context,
|
|
168
331
|
operation_id: operation_id,
|
|
@@ -170,17 +333,33 @@ module SparkConnect
|
|
|
170
333
|
client_type: @client_type,
|
|
171
334
|
tags: @tags
|
|
172
335
|
)
|
|
336
|
+
end
|
|
173
337
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
338
|
+
def execute(plan)
|
|
339
|
+
operation_id = SecureRandom.uuid
|
|
340
|
+
req = execute_request(plan, operation_id)
|
|
341
|
+
|
|
342
|
+
# Build the accumulator *inside* the retry block so that a mid-stream
|
|
343
|
+
# failure (which restarts the gRPC stream from the beginning) starts from
|
|
344
|
+
# a clean slate. Accumulating into a result created outside the block
|
|
345
|
+
# would re-append already-seen batches and duplicate rows on retry.
|
|
346
|
+
progress = ProgressTracker.new(@progress_handlers, operation_id)
|
|
347
|
+
begin
|
|
348
|
+
with_retries do
|
|
349
|
+
result = ExecuteResult.new([], nil, nil, [], nil, 0)
|
|
350
|
+
result.pipeline_events = []
|
|
351
|
+
result.operation_id = operation_id
|
|
352
|
+
responses = @stub.execute_plan(req, metadata: @metadata)
|
|
353
|
+
responses.each do |resp|
|
|
354
|
+
@server_side_session_id = resp.server_side_session_id unless resp.server_side_session_id.empty?
|
|
355
|
+
progress.update(resp.execution_progress) if resp.execution_progress
|
|
356
|
+
accumulate(result, resp)
|
|
357
|
+
end
|
|
358
|
+
result
|
|
181
359
|
end
|
|
360
|
+
ensure
|
|
361
|
+
progress.finish
|
|
182
362
|
end
|
|
183
|
-
result
|
|
184
363
|
end
|
|
185
364
|
|
|
186
365
|
def accumulate(result, resp)
|
|
@@ -260,5 +439,46 @@ module SparkConnect
|
|
|
260
439
|
def grpc_code_name(error)
|
|
261
440
|
GRPC::Core::StatusCodes.constants.find { |c| GRPC::Core::StatusCodes.const_get(c) == error.code }&.to_s
|
|
262
441
|
end
|
|
442
|
+
|
|
443
|
+
# Tracks `ExecutionProgress` updates of one execution and notifies the
|
|
444
|
+
# registered progress handlers, like PySpark's `Progress` helper: on every
|
|
445
|
+
# update that reports tasks, and once more with `done: true` at the end.
|
|
446
|
+
# @api private
|
|
447
|
+
class ProgressTracker
|
|
448
|
+
def initialize(handlers, operation_id)
|
|
449
|
+
@handlers = handlers.dup
|
|
450
|
+
@operation_id = operation_id
|
|
451
|
+
@stages = []
|
|
452
|
+
@inflight = 0
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Record an `ExecutionProgress` update and notify the handlers.
|
|
456
|
+
def update(progress)
|
|
457
|
+
stages = progress.stages.map do |s|
|
|
458
|
+
StageInfo.new(stage_id: s.stage_id, num_tasks: s.num_tasks, num_completed_tasks: s.num_completed_tasks,
|
|
459
|
+
num_bytes_read: s.input_bytes_read, done: s.done)
|
|
460
|
+
end
|
|
461
|
+
return if stages.sum(&:num_tasks).zero?
|
|
462
|
+
|
|
463
|
+
@stages = stages
|
|
464
|
+
@inflight = progress.num_inflight_tasks
|
|
465
|
+
notify(false)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Notify the handlers that the execution finished.
|
|
469
|
+
def finish
|
|
470
|
+
notify(true)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
private
|
|
474
|
+
|
|
475
|
+
def notify(done)
|
|
476
|
+
@handlers.each do |h|
|
|
477
|
+
h.call(stages: @stages, inflight_tasks: @inflight, operation_id: @operation_id, done: done)
|
|
478
|
+
rescue StandardError => e
|
|
479
|
+
warn("spark-connect: progress handler raised #{e.class}: #{e.message}")
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
263
483
|
end
|
|
264
484
|
end
|