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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +173 -1
  3. data/README.md +37 -13
  4. data/lib/spark_connect/arrow.rb +5 -1
  5. data/lib/spark_connect/catalog.rb +157 -12
  6. data/lib/spark_connect/channel_builder.rb +119 -12
  7. data/lib/spark_connect/client.rb +237 -17
  8. data/lib/spark_connect/column.rb +143 -10
  9. data/lib/spark_connect/conf.rb +17 -1
  10. data/lib/spark_connect/data_frame.rb +447 -25
  11. data/lib/spark_connect/functions.rb +1948 -494
  12. data/lib/spark_connect/grouped_data.rb +33 -7
  13. data/lib/spark_connect/merge.rb +146 -0
  14. data/lib/spark_connect/na_functions.rb +43 -9
  15. data/lib/spark_connect/observation.rb +26 -5
  16. data/lib/spark_connect/pipelines.rb +101 -0
  17. data/lib/spark_connect/plan.rb +52 -0
  18. data/lib/spark_connect/proto/spark/connect/base_pb.rb +6 -1
  19. data/lib/spark_connect/proto/spark/connect/base_services_pb.rb +2 -0
  20. data/lib/spark_connect/proto/spark/connect/catalog_pb.rb +11 -1
  21. data/lib/spark_connect/proto/spark/connect/commands_pb.rb +1 -1
  22. data/lib/spark_connect/proto/spark/connect/expressions_pb.rb +3 -1
  23. data/lib/spark_connect/proto/spark/connect/pipelines_pb.rb +5 -1
  24. data/lib/spark_connect/proto/spark/connect/relations_pb.rb +4 -1
  25. data/lib/spark_connect/proto/spark/connect/types_pb.rb +3 -1
  26. data/lib/spark_connect/reader.rb +98 -17
  27. data/lib/spark_connect/session.rb +232 -30
  28. data/lib/spark_connect/stat_functions.rb +19 -9
  29. data/lib/spark_connect/storage_level.rb +79 -0
  30. data/lib/spark_connect/streaming.rb +335 -20
  31. data/lib/spark_connect/table_arg.rb +73 -0
  32. data/lib/spark_connect/tvf.rb +78 -0
  33. data/lib/spark_connect/types.rb +149 -0
  34. data/lib/spark_connect/version.rb +2 -2
  35. data/lib/spark_connect/writer.rb +80 -10
  36. data/lib/spark_connect.rb +4 -0
  37. data/proto/spark/connect/base.proto +93 -1
  38. data/proto/spark/connect/catalog.proto +81 -0
  39. data/proto/spark/connect/commands.proto +7 -0
  40. data/proto/spark/connect/expressions.proto +28 -0
  41. data/proto/spark/connect/pipelines.proto +92 -5
  42. data/proto/spark/connect/relations.proto +71 -1
  43. data/proto/spark/connect/types.proto +19 -0
  44. 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
- # Any parameter whose name begins with `x-` is forwarded verbatim as gRPC
22
- # request metadata.
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["token"]
56
- @user_id = @params["user_id"]
57
- @session_id = @params["session_id"]
58
- @use_ssl = parse_bool(@params["use_ssl"]) || !@token.nil?
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 (bearer token
77
- # plus any `x-*` parameters).
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 if k.start_with?(PARAM_PREFIX) }
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
- args = { "grpc.primary_user_agent" => user_agent }.merge(channel_args)
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
@@ -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
- def initialize(channel_builder, session_id: nil, max_retries: 10, retry_base_delay: 0.05, max_retry_delay: 10.0)
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, metadata: @metadata) }
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, metadata: @metadata) }
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, metadata: @metadata) }
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, metadata: @metadata)
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 execute(plan)
164
- operation_id = SecureRandom.uuid
165
- req = Proto::ExecutePlanRequest.new(
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
- result = ExecuteResult.new([], nil, nil, [], nil, 0)
175
- result.pipeline_events = []
176
- with_retries do
177
- responses = @stub.execute_plan(req, metadata: @metadata)
178
- responses.each do |resp|
179
- @server_side_session_id = resp.server_side_session_id unless resp.server_side_session_id.empty?
180
- accumulate(result, resp)
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