spark-connect 0.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -27
  3. data/README.md +29 -11
  4. data/lib/spark_connect/catalog.rb +157 -12
  5. data/lib/spark_connect/channel_builder.rb +119 -12
  6. data/lib/spark_connect/client.rb +233 -17
  7. data/lib/spark_connect/column.rb +143 -10
  8. data/lib/spark_connect/conf.rb +16 -0
  9. data/lib/spark_connect/data_frame.rb +428 -23
  10. data/lib/spark_connect/functions.rb +1947 -493
  11. data/lib/spark_connect/grouped_data.rb +33 -7
  12. data/lib/spark_connect/merge.rb +146 -0
  13. data/lib/spark_connect/na_functions.rb +43 -9
  14. data/lib/spark_connect/observation.rb +26 -5
  15. data/lib/spark_connect/pipelines.rb +101 -0
  16. data/lib/spark_connect/plan.rb +52 -0
  17. data/lib/spark_connect/proto/spark/connect/base_pb.rb +6 -1
  18. data/lib/spark_connect/proto/spark/connect/base_services_pb.rb +2 -0
  19. data/lib/spark_connect/proto/spark/connect/catalog_pb.rb +11 -1
  20. data/lib/spark_connect/proto/spark/connect/commands_pb.rb +1 -1
  21. data/lib/spark_connect/proto/spark/connect/expressions_pb.rb +3 -1
  22. data/lib/spark_connect/proto/spark/connect/pipelines_pb.rb +5 -1
  23. data/lib/spark_connect/proto/spark/connect/relations_pb.rb +4 -1
  24. data/lib/spark_connect/proto/spark/connect/types_pb.rb +3 -1
  25. data/lib/spark_connect/reader.rb +98 -17
  26. data/lib/spark_connect/session.rb +232 -30
  27. data/lib/spark_connect/stat_functions.rb +19 -9
  28. data/lib/spark_connect/storage_level.rb +79 -0
  29. data/lib/spark_connect/streaming.rb +335 -20
  30. data/lib/spark_connect/table_arg.rb +73 -0
  31. data/lib/spark_connect/tvf.rb +78 -0
  32. data/lib/spark_connect/types.rb +149 -0
  33. data/lib/spark_connect/version.rb +2 -2
  34. data/lib/spark_connect/writer.rb +80 -10
  35. data/lib/spark_connect.rb +4 -0
  36. data/proto/spark/connect/base.proto +93 -1
  37. data/proto/spark/connect/catalog.proto +81 -0
  38. data/proto/spark/connect/commands.proto +7 -0
  39. data/proto/spark/connect/expressions.proto +28 -0
  40. data/proto/spark/connect/pipelines.proto +92 -5
  41. data/proto/spark/connect/relations.proto +71 -1
  42. data/proto/spark/connect/types.proto +19 -0
  43. metadata +6 -2
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SparkConnect
4
+ # How a cached {DataFrame} (or table) is stored: in memory and/or on disk,
5
+ # off-heap, deserialized, and with how many replicas. Mirrors PySpark's
6
+ # `StorageLevel`; use the predefined constants (`StorageLevel::MEMORY_AND_DISK`, ...).
7
+ #
8
+ # @example
9
+ # df.persist(SparkConnect::StorageLevel::DISK_ONLY)
10
+ # df.storage_level #=> StorageLevel(true, false, false, false, 1)
11
+ class StorageLevel
12
+ Proto = SparkConnect::Proto
13
+
14
+ # @return [Boolean]
15
+ attr_reader :use_disk, :use_memory, :use_off_heap, :deserialized
16
+ # @return [Integer]
17
+ attr_reader :replication
18
+
19
+ def initialize(use_disk, use_memory, use_off_heap, deserialized, replication = 1)
20
+ @use_disk = use_disk
21
+ @use_memory = use_memory
22
+ @use_off_heap = use_off_heap
23
+ @deserialized = deserialized
24
+ @replication = replication
25
+ end
26
+
27
+ # @return [Spark::Connect::StorageLevel]
28
+ def to_proto
29
+ Proto::StorageLevel.new(use_disk: use_disk, use_memory: use_memory, use_off_heap: use_off_heap,
30
+ deserialized: deserialized, replication: replication)
31
+ end
32
+
33
+ # @param proto [Spark::Connect::StorageLevel]
34
+ # @return [StorageLevel]
35
+ def self.from_proto(proto)
36
+ new(proto.use_disk, proto.use_memory, proto.use_off_heap, proto.deserialized, proto.replication)
37
+ end
38
+
39
+ # @return [Boolean] whether `other` is a StorageLevel with the same flags.
40
+ def ==(other)
41
+ other.is_a?(StorageLevel) && to_a == other.to_a
42
+ end
43
+ alias eql? ==
44
+
45
+ # @return [Integer]
46
+ def hash = to_a.hash
47
+
48
+ # @return [Array] `[use_disk, use_memory, use_off_heap, deserialized, replication]`.
49
+ def to_a = [use_disk, use_memory, use_off_heap, deserialized, replication]
50
+
51
+ # @return [String] e.g. `"StorageLevel(true, true, false, false, 1)"`.
52
+ def to_s
53
+ "StorageLevel(#{to_a.join(', ')})"
54
+ end
55
+ alias inspect to_s
56
+
57
+ # Not stored.
58
+ NONE = new(false, false, false, false)
59
+ # On disk only.
60
+ DISK_ONLY = new(true, false, false, false)
61
+ # On disk only, replicated on two nodes.
62
+ DISK_ONLY_2 = new(true, false, false, false, 2)
63
+ # On disk only, replicated on three nodes.
64
+ DISK_ONLY_3 = new(true, false, false, false, 3)
65
+ # In memory only (serialized).
66
+ MEMORY_ONLY = new(false, true, false, false)
67
+ # In memory only, replicated on two nodes.
68
+ MEMORY_ONLY_2 = new(false, true, false, false, 2)
69
+ # In memory (serialized), spilling to disk.
70
+ MEMORY_AND_DISK = new(true, true, false, false)
71
+ # In memory and disk, replicated on two nodes.
72
+ MEMORY_AND_DISK_2 = new(true, true, false, false, 2)
73
+ # Off-heap memory.
74
+ OFF_HEAP = new(true, true, true, false, 1)
75
+ # In memory (deserialized), spilling to disk; the default of {DataFrame#cache}
76
+ # and {DataFrame#persist}.
77
+ MEMORY_AND_DISK_DESER = new(true, true, false, true)
78
+ end
79
+ end
@@ -11,12 +11,15 @@ module SparkConnect
11
11
  class DataStreamReader
12
12
  Proto = SparkConnect::Proto
13
13
 
14
+ SOURCE_NAME_PATTERN = /\A[a-zA-Z0-9_]+\z/
15
+
14
16
  # @param session [SparkSession]
15
17
  def initialize(session)
16
18
  @session = session
17
19
  @format = nil
18
20
  @schema = nil
19
21
  @options = {}
22
+ @source_name = nil
20
23
  end
21
24
 
22
25
  # @return [self] set the streaming source format (`"rate"`, `"kafka"`, ...).
@@ -27,19 +30,37 @@ module SparkConnect
27
30
 
28
31
  # @return [self] set the input schema (a {Types::StructType} or DDL string).
29
32
  def schema(schema)
30
- @schema = schema.is_a?(Types::StructType) ? schema.simple_string : schema.to_s
33
+ # Like PySpark, a StructType is sent as its JSON form.
34
+ @schema = schema.is_a?(Types::StructType) ? schema.json : schema.to_s
31
35
  self
32
36
  end
33
37
 
34
- # @return [self] set a single source option.
38
+ # @return [self] set a single source option (a `nil` value is ignored).
35
39
  def option(key, value)
36
- @options[key.to_s] = value.to_s
40
+ @options[key.to_s] = value.to_s unless value.nil?
37
41
  self
38
42
  end
39
43
 
40
44
  # @return [self] set multiple source options.
41
45
  def options(opts)
42
- opts.each { |k, v| @options[k.to_s] = v.to_s }
46
+ opts.each { |k, v| option(k, v) }
47
+ self
48
+ end
49
+
50
+ # Name this streaming source (Spark 4.2+). The name identifies the source
51
+ # in checkpoint metadata, so sources can be added, removed, or reordered
52
+ # without breaking checkpoint compatibility.
53
+ #
54
+ # @param source_name [String] letters, digits, and underscores only.
55
+ # @return [self]
56
+ def name(source_name)
57
+ raise IllegalArgumentError, "source name must be a String, got #{source_name.class}" unless source_name.is_a?(String)
58
+ unless SOURCE_NAME_PATTERN.match?(source_name)
59
+ raise IllegalArgumentError,
60
+ "Invalid streaming source name #{source_name.inspect}: only ASCII letters, digits, and underscores are allowed"
61
+ end
62
+
63
+ @source_name = source_name
43
64
  self
44
65
  end
45
66
 
@@ -51,9 +72,23 @@ module SparkConnect
51
72
  ds = Proto::Read::DataSource.new(options: @options, paths: path ? [path.to_s] : [])
52
73
  ds.format = @format if @format
53
74
  ds.schema = @schema if @schema
75
+ ds.source_name = @source_name if @source_name
54
76
  stream_relation(data_source: ds)
55
77
  end
56
78
 
79
+ # The row-level changes (Change Data Capture) of a table as a streaming
80
+ # DataFrame (Spark 4.2+). Configure the start with options such as
81
+ # `startingVersion` / `startingTimestamp`.
82
+ #
83
+ # @param table_name [String]
84
+ # @return [DataFrame]
85
+ def changes(table_name)
86
+ raise AnalysisError.new("User specified schema not supported with `changes`.", error_class: "_LEGACY_ERROR_TEMP_1189") if @schema
87
+
88
+ rc = Proto::RelationChanges.new(unparsed_identifier: table_name.to_s, options: @options, is_streaming: true)
89
+ DataFrame.new(@session, PlanBuilder.relation(@session, relation_changes: rc))
90
+ end
91
+
57
92
  # Load a streaming DataFrame from a registered table.
58
93
  # @return [DataFrame]
59
94
  def table(name)
@@ -66,6 +101,8 @@ module SparkConnect
66
101
  def parquet(path) = format("parquet").load(path)
67
102
  def orc(path) = format("orc").load(path)
68
103
  def text(path) = format("text").load(path)
104
+ # @return [DataFrame] a streaming XML source (set `option("rowTag", ...)`).
105
+ def xml(path) = format("xml").load(path)
69
106
 
70
107
  private
71
108
 
@@ -78,9 +115,8 @@ module SparkConnect
78
115
  # Writes a streaming {DataFrame} to a streaming sink and starts the query.
79
116
  # Returned by {DataFrame#write_stream}. Mirrors PySpark's `DataStreamWriter`.
80
117
  #
81
- # `foreach`/`foreach_batch` are intentionally unsupported: they require
82
- # user-defined functions, whose Spark Connect protobuf definitions are not yet
83
- # finalized.
118
+ # `foreach`/`foreach_batch` are intentionally unsupported: they run a
119
+ # user-defined function on the server, which this client cannot ship.
84
120
  #
85
121
  # @example
86
122
  # query = df.write_stream
@@ -104,6 +140,7 @@ module SparkConnect
104
140
  @trigger = nil
105
141
  @path = nil
106
142
  @table = nil
143
+ @clustering = []
107
144
  end
108
145
 
109
146
  # @return [self] set the sink format (`"console"`, `"memory"`, `"kafka"`, ...).
@@ -118,15 +155,15 @@ module SparkConnect
118
155
  self
119
156
  end
120
157
 
121
- # @return [self] set a single sink option.
158
+ # @return [self] set a single sink option (a `nil` value is ignored).
122
159
  def option(key, value)
123
- @options[key.to_s] = value.to_s
160
+ @options[key.to_s] = value.to_s unless value.nil?
124
161
  self
125
162
  end
126
163
 
127
164
  # @return [self] set multiple sink options.
128
165
  def options(opts)
129
- opts.each { |k, v| @options[k.to_s] = v.to_s }
166
+ opts.each { |k, v| option(k, v) }
130
167
  self
131
168
  end
132
169
 
@@ -135,6 +172,27 @@ module SparkConnect
135
172
  @partitioning = cols.flatten.map(&:to_s)
136
173
  self
137
174
  end
175
+ alias partitionBy partition_by
176
+
177
+ # @return [self] cluster the output table by these columns.
178
+ def cluster_by(*cols)
179
+ @clustering = cols.flatten.map(&:to_s)
180
+ self
181
+ end
182
+ alias clusterBy cluster_by
183
+
184
+ # Not supported: `foreach` runs a user-defined function on the server.
185
+ # @raise [NotImplementedError]
186
+ def foreach(*_args)
187
+ raise NotImplementedError, "foreach sinks require user-defined functions, which the Ruby client does not support"
188
+ end
189
+
190
+ # Not supported: `foreach_batch` runs a user-defined function on the server.
191
+ # @raise [NotImplementedError]
192
+ def foreach_batch(*_args)
193
+ raise NotImplementedError, "foreachBatch sinks require user-defined functions, which the Ruby client does not support"
194
+ end
195
+ alias foreachBatch foreach_batch
138
196
 
139
197
  # @return [self] name the streaming query (required by the memory sink).
140
198
  def query_name(name)
@@ -148,22 +206,35 @@ module SparkConnect
148
206
  # @param once [Boolean, nil] process all available data once and stop.
149
207
  # @param available_now [Boolean, nil] process all available data in (possibly) multiple batches, then stop.
150
208
  # @param continuous [String, nil] continuous-processing checkpoint interval.
209
+ # @param real_time [String, nil] real-time mode with the given batch
210
+ # duration, e.g. `"5 minutes"` (Spark 4.2+; supported sources/sinks only).
151
211
  # @return [self]
152
- def trigger(processing_time: nil, once: nil, available_now: nil, continuous: nil)
212
+ def trigger(processing_time: nil, once: nil, available_now: nil, continuous: nil, real_time: nil)
213
+ given = { processing_time: processing_time, once: once, available_now: available_now,
214
+ continuous: continuous, real_time: real_time, }.compact
215
+ raise IllegalArgumentError, "trigger requires exactly one of #{given.keys.inspect}" if given.size > 1
216
+ raise IllegalArgumentError, "trigger requires one of processing_time, once, available_now, continuous, real_time" if given.empty?
217
+
218
+ key, value = given.first
153
219
  @trigger =
154
- if processing_time then [:processing_time_interval, processing_time.to_s]
155
- elsif once then [:once, true]
156
- elsif available_now then [:available_now, true]
157
- elsif continuous then [:continuous_checkpoint_interval, continuous.to_s]
220
+ case key
221
+ when :processing_time then [:processing_time_interval, non_empty!(key, value)]
222
+ when :once then [:once, true!(key, value)]
223
+ when :available_now then [:available_now, true!(key, value)]
224
+ when :continuous then [:continuous_checkpoint_interval, non_empty!(key, value)]
225
+ when :real_time then [:real_time_batch_duration, non_empty!(key, value)]
158
226
  end
159
227
  self
160
228
  end
161
229
 
162
- # Start the streaming query to a file/data path.
230
+ # Start the streaming query to a file/data path. The keyword arguments are
231
+ # shortcuts for {#format}, {#output_mode}, {#partition_by}, {#query_name},
232
+ # and {#options}.
163
233
  #
164
234
  # @param path [String, nil]
165
235
  # @return [StreamingQuery]
166
- def start(path = nil)
236
+ def start(path = nil, format: nil, output_mode: nil, partition_by: nil, query_name: nil, **options)
237
+ apply_start_options(format, output_mode, partition_by, query_name, options)
167
238
  @path = path if path
168
239
  run
169
240
  end
@@ -172,7 +243,8 @@ module SparkConnect
172
243
  #
173
244
  # @param name [String]
174
245
  # @return [StreamingQuery]
175
- def to_table(name)
246
+ def to_table(name, format: nil, output_mode: nil, partition_by: nil, query_name: nil, **options)
247
+ apply_start_options(format, output_mode, partition_by, query_name, options)
176
248
  @table = name.to_s
177
249
  run
178
250
  end
@@ -180,10 +252,30 @@ module SparkConnect
180
252
 
181
253
  private
182
254
 
255
+ def apply_start_options(format, output_mode, partition_by, query_name, options)
256
+ self.format(format) if format
257
+ self.output_mode(output_mode) if output_mode
258
+ self.partition_by(partition_by) if partition_by
259
+ self.query_name(query_name) if query_name
260
+ self.options(options) unless options.empty?
261
+ end
262
+
263
+ def non_empty!(key, value)
264
+ raise IllegalArgumentError, "trigger #{key} must be a non-empty String" unless value.is_a?(String) && !value.strip.empty?
265
+
266
+ value.strip
267
+ end
268
+
269
+ def true!(key, value)
270
+ raise IllegalArgumentError, "trigger #{key} must be true" unless value == true
271
+
272
+ true
273
+ end
274
+
183
275
  def run
184
276
  op = WSO.new(
185
277
  input: @df.relation, format: @format || "", options: @options,
186
- partitioning_column_names: @partitioning
278
+ partitioning_column_names: @partitioning, clustering_column_names: @clustering
187
279
  )
188
280
  op.output_mode = @output_mode if @output_mode
189
281
  op.query_name = @query_name if @query_name
@@ -195,6 +287,9 @@ module SparkConnect
195
287
  wsr = result.write_stream_result
196
288
  raise SparkConnectError, "Server did not return a streaming query handle" unless wsr
197
289
 
290
+ if wsr.query_started_event_json && !wsr.query_started_event_json.empty?
291
+ @df.session.streams.post_event(StreamingQueryListener.event_from_json(:started, wsr.query_started_event_json))
292
+ end
198
293
  StreamingQuery.new(@df.session, wsr.query_id, wsr.name)
199
294
  end
200
295
  end
@@ -209,6 +304,7 @@ module SparkConnect
209
304
  attr_reader :id
210
305
  # @return [String] the run id (unique per start).
211
306
  attr_reader :run_id
307
+ alias runId run_id
212
308
  # @return [String, nil] the query name, if one was set.
213
309
  attr_reader :name
214
310
 
@@ -239,16 +335,20 @@ module SparkConnect
239
335
  def active?
240
336
  status["isActive"]
241
337
  end
338
+ alias is_active active?
339
+ alias isActive active?
242
340
 
243
341
  # @return [Array<Hash>] parsed JSON progress objects for recent micro-batches.
244
342
  def recent_progress
245
343
  command(recent_progress: true).recent_progress.recent_progress_json.map { |j| JSON.parse(j) }
246
344
  end
345
+ alias recentProgress recent_progress
247
346
 
248
347
  # @return [Hash, nil] the most recent progress object, if any.
249
348
  def last_progress
250
349
  command(last_progress: true).recent_progress.recent_progress_json.map { |j| JSON.parse(j) }.last
251
350
  end
351
+ alias lastProgress last_progress
252
352
 
253
353
  # Block until the query terminates, or until `timeout_ms` elapses.
254
354
  #
@@ -266,6 +366,7 @@ module SparkConnect
266
366
  command(process_all_available: true)
267
367
  nil
268
368
  end
369
+ alias processAllAvailable process_all_available
269
370
 
270
371
  # Stop the query. @return [void]
271
372
  def stop
@@ -276,7 +377,10 @@ module SparkConnect
276
377
  # @return [String, nil] the query's exception message, if it has failed.
277
378
  def exception
278
379
  result = command(exception: true).exception
279
- result.exception_message && result.exception_message.empty? ? nil : result.exception_message
380
+ # The server omits the result (or its message) when the query has not failed.
381
+ return nil if result.nil? || !result.has_exception_message? || result.exception_message.empty?
382
+
383
+ result.exception_message
280
384
  end
281
385
 
282
386
  # @return [String] the query's execution plan.
@@ -306,6 +410,44 @@ module SparkConnect
306
410
  # @param session [SparkSession]
307
411
  def initialize(session)
308
412
  @session = session
413
+ @listener_bus = StreamingQueryListenerBus.new(session)
414
+ end
415
+
416
+ # Register a {StreamingQueryListener} (or any object responding to some of
417
+ # `on_query_started`, `on_query_progress`, `on_query_idle`,
418
+ # `on_query_terminated`, or their camelCase PySpark names). Events are
419
+ # delivered on a background thread, like PySpark's client-side listener bus.
420
+ #
421
+ # @param listener [Object]
422
+ # @return [void]
423
+ def add_listener(listener)
424
+ @listener_bus.add(listener)
425
+ nil
426
+ end
427
+ alias addListener add_listener
428
+
429
+ # Unregister a listener added with {#add_listener}. @return [void]
430
+ def remove_listener(listener)
431
+ @listener_bus.remove(listener)
432
+ nil
433
+ end
434
+ alias removeListener remove_listener
435
+
436
+ # @return [Array<Object>] the registered listeners.
437
+ def listeners
438
+ @listener_bus.listeners
439
+ end
440
+
441
+ # Remove all listeners and stop the listener thread. @return [void]
442
+ def close
443
+ @listener_bus.close
444
+ nil
445
+ end
446
+
447
+ # Deliver a client-side event (e.g. QueryStartedEvent) to the listeners.
448
+ # @api private
449
+ def post_event(event)
450
+ @listener_bus.post(event)
309
451
  end
310
452
 
311
453
  # @return [Array<StreamingQuery>] the currently active queries.
@@ -348,4 +490,177 @@ module SparkConnect
348
490
  @session.client.execute_command(Proto::Command.new(streaming_query_manager_command: cmd)).streaming_manager_result
349
491
  end
350
492
  end
493
+
494
+ # Base class for streaming query listeners registered with
495
+ # {StreamingQueryManager#add_listener}. Override the callbacks you need; the
496
+ # camelCase PySpark names (`onQueryStarted`, ...) are honoured too.
497
+ class StreamingQueryListener
498
+ # @param event [QueryStartedEvent]
499
+ def on_query_started(event); end
500
+
501
+ # @param event [QueryProgressEvent]
502
+ def on_query_progress(event); end
503
+
504
+ # @param event [QueryIdleEvent]
505
+ def on_query_idle(event); end
506
+
507
+ # @param event [QueryTerminatedEvent]
508
+ def on_query_terminated(event); end
509
+
510
+ # A query started. `job_tags` is an Array of Strings.
511
+ QueryStartedEvent = Struct.new(:id, :run_id, :name, :timestamp, :job_tags, keyword_init: true)
512
+ # A micro-batch made progress. `progress` is the parsed progress Hash.
513
+ QueryProgressEvent = Struct.new(:progress, keyword_init: true)
514
+ # A query is idle, waiting for new data.
515
+ QueryIdleEvent = Struct.new(:id, :run_id, :timestamp, keyword_init: true)
516
+ # A query terminated, possibly with an exception message and error class.
517
+ QueryTerminatedEvent = Struct.new(:id, :run_id, :exception, :error_class_on_exception, keyword_init: true)
518
+
519
+ EVENT_TYPES = {
520
+ QUERY_PROGRESS_EVENT: :progress, QUERY_TERMINATED_EVENT: :terminated, QUERY_IDLE_EVENT: :idle,
521
+ }.freeze
522
+
523
+ CALLBACKS = {
524
+ QueryStartedEvent => %i[on_query_started onQueryStarted],
525
+ QueryProgressEvent => %i[on_query_progress onQueryProgress],
526
+ QueryIdleEvent => %i[on_query_idle onQueryIdle],
527
+ QueryTerminatedEvent => %i[on_query_terminated onQueryTerminated],
528
+ }.freeze
529
+
530
+ class << self
531
+ # Build an event from its server-side JSON.
532
+ # @api private
533
+ def event_from_json(kind, json)
534
+ j = JSON.parse(json)
535
+ case kind
536
+ when :started
537
+ QueryStartedEvent.new(id: j["id"], run_id: j["runId"], name: j["name"], timestamp: j["timestamp"],
538
+ job_tags: Array(j["jobTags"]))
539
+ when :progress then QueryProgressEvent.new(progress: j["progress"])
540
+ when :idle then QueryIdleEvent.new(id: j["id"], run_id: j["runId"], timestamp: j["timestamp"])
541
+ when :terminated
542
+ QueryTerminatedEvent.new(id: j["id"], run_id: j["runId"], exception: j["exception"],
543
+ error_class_on_exception: j["errorClassOnException"])
544
+ end
545
+ end
546
+
547
+ # @api private - invoke the listener callback for `event`, if defined.
548
+ # A camelCase callback (PySpark's name) wins over the inherited no-op
549
+ # snake_case one of this base class.
550
+ def dispatch(listener, event)
551
+ snake, camel = CALLBACKS.fetch(event.class)
552
+ if listener.respond_to?(camel) &&
553
+ (!listener.respond_to?(snake) || listener.method(snake).owner == StreamingQueryListener)
554
+ listener.public_send(camel, event)
555
+ elsif listener.respond_to?(snake)
556
+ listener.public_send(snake, event)
557
+ end
558
+ end
559
+ end
560
+ end
561
+
562
+ # The client-side listener bus behind {StreamingQueryManager#add_listener}:
563
+ # registers one server-side listener for the session and dispatches the
564
+ # events it streams back to every registered listener on a background thread.
565
+ # @api private
566
+ class StreamingQueryListenerBus
567
+ Proto = SparkConnect::Proto
568
+
569
+ def initialize(session)
570
+ @session = session
571
+ @listeners = []
572
+ @lock = Mutex.new
573
+ @thread = nil
574
+ end
575
+
576
+ # @return [Array<Object>] a snapshot of the registered listeners.
577
+ def listeners
578
+ @lock.synchronize { @listeners.dup }
579
+ end
580
+
581
+ # Register `listener`; the first one starts the server-side listener.
582
+ # @return [void]
583
+ def add(listener)
584
+ @lock.synchronize do
585
+ return if @listeners.include?(listener)
586
+
587
+ @listeners << listener
588
+ start_server_listener if @listeners.size == 1
589
+ end
590
+ end
591
+
592
+ # Unregister `listener`; the last one stops the server-side listener.
593
+ # @return [void]
594
+ def remove(listener)
595
+ thread = nil
596
+ @lock.synchronize do
597
+ return unless @listeners.include?(listener)
598
+
599
+ if @listeners.size == 1
600
+ bus_command(remove_listener_bus_listener: true)
601
+ thread = @thread
602
+ @thread = nil
603
+ end
604
+ @listeners.delete(listener)
605
+ end
606
+ # Join outside the lock: the handler thread takes it to dispatch events.
607
+ thread&.join(30)
608
+ end
609
+
610
+ # Unregister every listener. @return [void]
611
+ def close
612
+ listeners.each { |l| remove(l) }
613
+ end
614
+
615
+ # Deliver `event` to every listener (exceptions are reported as warnings).
616
+ # @return [void]
617
+ def post(event)
618
+ listeners.each do |listener|
619
+ StreamingQueryListener.dispatch(listener, event)
620
+ rescue StandardError => e
621
+ warn("spark-connect: streaming listener #{listener.class} raised #{e.class}: #{e.message}")
622
+ end
623
+ end
624
+
625
+ private
626
+
627
+ def bus_command(**kw)
628
+ cmd = Proto::Command.new(streaming_query_listener_bus_command: Proto::StreamingQueryListenerBusCommand.new(**kw))
629
+ @session.client.execute_command(cmd)
630
+ end
631
+
632
+ # Must be called with @lock held.
633
+ def start_server_listener
634
+ ready = Queue.new
635
+ cmd = Proto::Command.new(
636
+ streaming_query_listener_bus_command: Proto::StreamingQueryListenerBusCommand.new(add_listener_bus_listener: true)
637
+ )
638
+ @thread = Thread.new do
639
+ @session.client.stream_command(cmd) do |resp|
640
+ result = resp.streaming_query_listener_events_result
641
+ next unless result
642
+
643
+ ready << :added if result.listener_bus_listener_added
644
+ result.events.each do |e|
645
+ kind = StreamingQueryListener::EVENT_TYPES[e.event_type]
646
+ post(StreamingQueryListener.event_from_json(kind, e.event_json)) if kind
647
+ end
648
+ end
649
+ rescue StandardError => e
650
+ ready << e
651
+ warn("spark-connect: streaming listener bus stopped: #{e.class}: #{e.message}; re-add the listeners")
652
+ @lock.synchronize { @listeners.clear }
653
+ ensure
654
+ ready << :finished
655
+ end
656
+ status = ready.pop
657
+ return if status == :added
658
+
659
+ @thread = nil
660
+ @listeners.clear
661
+ raise status if status.is_a?(Exception)
662
+
663
+ raise SparkConnectError, "The server did not register the streaming query listener"
664
+ end
665
+ end
351
666
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SparkConnect
4
+ # A {DataFrame} passed as a `TABLE` argument to a table-valued function,
5
+ # created with {DataFrame#as_table}. It is a {Column} (a table-argument
6
+ # subquery expression), so it can be passed wherever a function argument is
7
+ # accepted, e.g. `F.call_function("my_tvf", df.as_table.partition_by("k"))`.
8
+ #
9
+ # Mirrors PySpark's `TableArg`: optionally partition the rows
10
+ # ({#partition_by} or {#with_single_partition}) and then order them within
11
+ # each partition ({#order_by}).
12
+ class TableArg < Column
13
+ Proto = SparkConnect::Proto
14
+
15
+ # @param df [DataFrame]
16
+ # @api private
17
+ def initialize(df, partition_spec: [], order_spec: [], with_single_partition: nil)
18
+ @df = df
19
+ @partition_spec = partition_spec
20
+ @order_spec = order_spec
21
+ @with_single_partition = with_single_partition
22
+ options = nil
23
+ unless partition_spec.empty? && order_spec.empty? && with_single_partition.nil?
24
+ options = Proto::SubqueryExpression::TableArgOptions.new(partition_spec: partition_spec, order_spec: order_spec)
25
+ options.with_single_partition = with_single_partition unless with_single_partition.nil?
26
+ end
27
+ super(df.subquery_expression(:SUBQUERY_TYPE_TABLE_ARG, table_arg_options: options).to_expr)
28
+ end
29
+
30
+ # Partition the table argument's rows by these columns.
31
+ # @param cols [Array<Column, String>]
32
+ # @return [TableArg]
33
+ def partition_by(*cols)
34
+ raise IllegalArgumentError, "Cannot call partition_by after partition_by or with_single_partition" if partitioned?
35
+
36
+ exprs = cols.flatten.map { |c| Functions._col(c).to_expr }
37
+ TableArg.new(@df, partition_spec: @partition_spec + exprs, order_spec: @order_spec,
38
+ with_single_partition: @with_single_partition)
39
+ end
40
+ alias partitionBy partition_by
41
+
42
+ # Order the rows within each partition (after {#partition_by} or
43
+ # {#with_single_partition}).
44
+ # @param cols [Array<Column, String>]
45
+ # @return [TableArg]
46
+ def order_by(*cols)
47
+ raise IllegalArgumentError, "Call partition_by or with_single_partition before order_by" unless partitioned?
48
+
49
+ orders = cols.flatten.map do |c|
50
+ expr = Functions._col(c).to_expr
51
+ expr.expr_type == :sort_order ? expr.sort_order : Functions._col(c).asc.to_expr.sort_order
52
+ end
53
+ TableArg.new(@df, partition_spec: @partition_spec, order_spec: @order_spec + orders,
54
+ with_single_partition: @with_single_partition)
55
+ end
56
+ alias orderBy order_by
57
+
58
+ # Pass all rows to a single partition.
59
+ # @return [TableArg]
60
+ def with_single_partition
61
+ raise IllegalArgumentError, "Cannot call with_single_partition after partition_by or with_single_partition" if partitioned?
62
+
63
+ TableArg.new(@df, partition_spec: @partition_spec, order_spec: @order_spec, with_single_partition: true)
64
+ end
65
+ alias withSinglePartition with_single_partition
66
+
67
+ private
68
+
69
+ def partitioned?
70
+ !@partition_spec.empty? || @with_single_partition == true
71
+ end
72
+ end
73
+ end