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
|
@@ -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
|
-
|
|
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|
|
|
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
|
|
82
|
-
# user-defined
|
|
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|
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|