purplelight 0.1.17 → 0.1.19

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.
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'bundler/setup'
5
+ require 'date'
5
6
  require 'fileutils'
6
7
  require 'json'
7
8
  require 'stringio'
@@ -28,6 +29,41 @@ PARQUET_DOCS = Array.new(10_000) do |index|
28
29
  'nested' => { 'value' => index }
29
30
  }
30
31
  end.freeze
32
+ PARQUET_READ_DOCS = Array.new(10_000) do |index|
33
+ object_id = BSON::ObjectId.from_string(format('%024x', index + 1))
34
+ nullable = (index % 11).zero?
35
+ {
36
+ 'row_id' => 4_294_967_296 + index,
37
+ 'u8' => index % 256,
38
+ 'u16' => 1_000 + index,
39
+ 'u32' => 1_000_000_000 + index,
40
+ 'u64' => 5_000_000_000 + index,
41
+ 'i8' => (index % 256) - 128,
42
+ 'i16' => index - 32_768,
43
+ 'i32' => index - 2_000_000_000,
44
+ 'i64' => index - 9_000_000_000,
45
+ 'flag' => index.even?,
46
+ 'nullable_flag' => (nullable ? nil : index.even?),
47
+ 'score' => index * 0.25,
48
+ 'nullable_score' => (nullable ? nil : index * 0.5),
49
+ 'category' => "category-#{index % 8}",
50
+ 'symbol' => :"symbol-#{index % 8}",
51
+ 'unique_text' => format('row-%<index>08d-value-%<encoded>08x',
52
+ index:, encoded: index * 2_654_435_761),
53
+ 'utf8_text' => "日本語-#{index % 32}",
54
+ 'long_text' => "#{format('%08d', index)}-#{'abcdefghijklmnopqrstuvwxyz' * 4}",
55
+ 'object_id' => object_id,
56
+ 'mixed_id' => (index.even? ? object_id : "literal-#{index}"),
57
+ 'nested' => { 'index' => index, 'flag' => index.even? },
58
+ 'date' => Date.new(2020, 1, 1) + (index % 365),
59
+ 'time' => Time.utc(2020, 1, 1) + index,
60
+ 'int_list' => (nullable ? nil : [index, index + 1, index + 2]),
61
+ 'nested_int_list' => (nullable ? nil : [[index, index + 1], [index + 2]]),
62
+ 'string_list' => (nullable ? nil : ["tag-#{index % 16}", "tag-#{(index + 1) % 16}"]),
63
+ 'object_id_list' => (nullable ? nil : [object_id]),
64
+ 'hash_list' => (nullable ? nil : [{ 'index' => index }])
65
+ }
66
+ end.freeze
31
67
  ENCODED_JSONL = DOCS.map { |document| "#{JSON.generate(document)}\n" }.join * 10
32
68
  ENCODED_BATCH = Purplelight::WriterJSONL::EncodedBatch.new(
33
69
  data: ENCODED_JSONL,
@@ -334,6 +370,58 @@ Dir.mktmpdir('purplelight-microbench') do |directory|
334
370
  writer.write_many(PARQUET_DOCS)
335
371
  writer.close
336
372
  end
373
+ parquet_read_writer = Purplelight::WriterParquet.new(
374
+ directory:,
375
+ prefix: 'parquet-read',
376
+ compression: :zstd,
377
+ row_group_size: 10_000,
378
+ single_file: true
379
+ )
380
+ parquet_read_writer.write_many(PARQUET_READ_DOCS)
381
+ parquet_read_writer.close
382
+ parquet_read_path = File.join(directory, 'parquet-read.parquet')
383
+ parquet_schema_reader = Parquet::ArrowFileReader.new(parquet_read_path)
384
+ parquet_column_indices = parquet_schema_reader.schema.fields.each_with_index.to_h do |field, index|
385
+ [field.name, index]
386
+ end.freeze
387
+ parquet_schema_reader.close
388
+
389
+ harness.register('parquet_read_full_table', paths: :writer_parquet, iterations: 10) do
390
+ reader = Parquet::ArrowFileReader.new(parquet_read_path)
391
+ reader.use_threads = true
392
+ table = reader.read_table
393
+ reader.close
394
+ raise 'Parquet full read row mismatch' unless table.n_rows == PARQUET_READ_DOCS.length
395
+ end
396
+
397
+ harness.register('parquet_read_row_groups', paths: :writer_parquet, iterations: 10) do
398
+ reader = Parquet::ArrowFileReader.new(parquet_read_path)
399
+ reader.use_threads = true
400
+ rows = reader.each_row_group.sum(&:n_rows)
401
+ reader.close
402
+ raise 'Parquet row-group read mismatch' unless rows == PARQUET_READ_DOCS.length
403
+ end
404
+
405
+ projected_indices = %w[row_id flag score].map { |name| parquet_column_indices.fetch(name) }.freeze
406
+ harness.register('parquet_read_projection', paths: :writer_parquet, iterations: 25) do
407
+ reader = Parquet::ArrowFileReader.new(parquet_read_path)
408
+ reader.use_threads = true
409
+ rows = reader.n_row_groups.times.sum do |index|
410
+ reader.read_row_group(index, projected_indices).n_rows
411
+ end
412
+ reader.close
413
+ raise 'Parquet projected read mismatch' unless rows == PARQUET_READ_DOCS.length
414
+ end
415
+
416
+ parquet_column_indices.each do |column, index|
417
+ harness.register("parquet_read_#{column}", paths: :writer_parquet, iterations: 50) do
418
+ reader = Parquet::ArrowFileReader.new(parquet_read_path)
419
+ reader.use_threads = true
420
+ values = reader.read_column_data(index)
421
+ reader.close
422
+ raise "Parquet #{column} read mismatch" unless values.length == PARQUET_READ_DOCS.length
423
+ end
424
+ end
337
425
 
338
426
  baseline_index = ARGV.index('--baseline')
339
427
  output_index = ARGV.index('--output')
@@ -43,8 +43,13 @@ module Purplelight
43
43
  end
44
44
  end
45
45
 
46
- def close
46
+ def close(discard: false)
47
47
  @mutex.synchronize do
48
+ if discard
49
+ @queue.clear
50
+ @sizes.clear
51
+ @bytes = 0
52
+ end
48
53
  @closed = true
49
54
  @cv.broadcast
50
55
  end
@@ -131,6 +131,12 @@ module Purplelight
131
131
 
132
132
  # Reader queue
133
133
  queue = ByteQueue.new(max_bytes: @queue_size_bytes)
134
+ failure_mutex = Mutex.new
135
+ worker_failure = nil
136
+ record_failure = lambda do |error|
137
+ failure_mutex.synchronize { worker_failure ||= error }
138
+ queue.close(discard: true)
139
+ end
134
140
 
135
141
  # Writer
136
142
  writer_count = @format == :jsonl ? [@writer_threads.to_i, 1].max : 1
@@ -163,9 +169,15 @@ module Purplelight
163
169
  readers = partition_filters.each_with_index.map do |pf, idx|
164
170
  Thread.new do
165
171
  local_telemetry = @telemetry_enabled ? Telemetry.new(enabled: true) : Telemetry::NULL
166
- read_partition(idx: idx, filter_spec: pf, queue: queue, batch_size: @batch_size, manifest: manifest, telemetry: local_telemetry)
167
- # Merge per-thread telemetry
168
- @telemetry.merge!(local_telemetry) if @telemetry_enabled
172
+ begin
173
+ read_partition(idx: idx, filter_spec: pf, queue: queue, batch_size: @batch_size,
174
+ manifest: manifest, telemetry: local_telemetry)
175
+ rescue StandardError => e
176
+ record_failure.call(e)
177
+ ensure
178
+ # Merge per-thread telemetry
179
+ @telemetry.merge!(local_telemetry) if @telemetry_enabled
180
+ end
169
181
  end
170
182
  end
171
183
 
@@ -182,8 +194,14 @@ module Purplelight
182
194
 
183
195
  worker_writer.write_many(batch)
184
196
  end
197
+ rescue StandardError => e
198
+ record_failure.call(e)
185
199
  ensure
186
- worker_writer.close
200
+ begin
201
+ worker_writer.close
202
+ rescue StandardError => e
203
+ record_failure.call(e)
204
+ end
187
205
  end
188
206
  end
189
207
 
@@ -212,6 +230,9 @@ module Purplelight
212
230
  else
213
231
  @running = false
214
232
  end
233
+ worker_error = failure_mutex.synchronize { worker_failure }
234
+ raise worker_error if worker_error
235
+
215
236
  if @telemetry_enabled
216
237
  total = @telemetry.timers.values.sum
217
238
  breakdown = @telemetry.timers
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Purplelight
4
- VERSION = '0.1.17'
4
+ VERSION = '0.1.19'
5
5
  end
@@ -13,6 +13,8 @@ module Purplelight
13
13
  # WriterParquet writes Parquet files via Apache Arrow when available.
14
14
  class WriterParquet
15
15
  DEFAULT_ROW_GROUP_SIZE = 10_000
16
+ DICTIONARY_CARDINALITY_LIMIT = 16
17
+ DICTIONARY_MIN_REPETITIONS = 4
16
18
 
17
19
  def initialize(directory:, prefix:, compression: :zstd, row_group_size: DEFAULT_ROW_GROUP_SIZE, logger: nil,
18
20
  manifest: nil, single_file: true, schema: nil, rotate_rows: nil)
@@ -63,6 +65,8 @@ module Purplelight
63
65
  @buffer_head = 0
64
66
  @arrow_schema = nil
65
67
  @arrow_data_types = nil
68
+ @dictionary_paths = nil
69
+ @dictionary_paths_resolved = false
66
70
  @writer_path = nil
67
71
  end
68
72
 
@@ -79,10 +83,21 @@ module Purplelight
79
83
 
80
84
  def build_record_batch(documents)
81
85
  @columns ||= infer_columns(documents)
86
+ resolve_dictionary_paths = !@dictionary_paths_resolved
87
+ dictionary_paths = [] if resolve_dictionary_paths && @columns.none? { |name| name.include?('.') }
88
+ @dictionary_paths_resolved = true if resolve_dictionary_paths
82
89
  arrays = @columns.each_with_index.map do |name, index|
83
90
  values = documents.map { |document| extract_value(document, name) }
84
- build_arrow_array(values, @arrow_data_types&.at(index))
91
+ array = build_arrow_array(values, @arrow_data_types&.at(index))
92
+ if dictionary_paths
93
+ data_type = array.value_data_type
94
+ path = list_dictionary_path(name, data_type)
95
+ path ||= name if data_type.is_a?(Arrow::StringDataType) && low_cardinality_strings?(values)
96
+ dictionary_paths << path if path
97
+ end
98
+ array
85
99
  end
100
+ @dictionary_paths = dictionary_paths if resolve_dictionary_paths
86
101
  batch = if @arrow_schema
87
102
  Arrow::RecordBatch.new(@arrow_schema, arrays)
88
103
  else
@@ -103,7 +118,7 @@ module Purplelight
103
118
  return build_string_array(values)
104
119
  end
105
120
  return build_string_array(values) if data_type.is_a?(Arrow::StringDataType) ||
106
- first.is_a?(String) || first.is_a?(Hash)
121
+ first.is_a?(String) || first.is_a?(Symbol) || first.is_a?(Hash)
107
122
  if (data_type.is_a?(Arrow::ListDataType) || first.is_a?(Array)) &&
108
123
  values.all? { |value| value.nil? || value.is_a?(Array) } &&
109
124
  (data_type || values.any? { |value| value && !value.empty? })
@@ -287,10 +302,45 @@ module Purplelight
287
302
  end
288
303
  end
289
304
 
305
+ def list_dictionary_path(name, data_type)
306
+ return unless data_type.is_a?(Arrow::ListDataType)
307
+
308
+ path = name.dup
309
+ while data_type.is_a?(Arrow::ListDataType)
310
+ path << '.list.element'
311
+ data_type = data_type.field.data_type
312
+ end
313
+ return if data_type.is_a?(Arrow::BooleanDataType) || data_type.is_a?(Arrow::NullDataType)
314
+
315
+ path
316
+ end
317
+
318
+ def low_cardinality_strings?(values)
319
+ first = values.find { |value| !value.nil? }
320
+ string_like = first.is_a?(String) || first.is_a?(Symbol)
321
+ return false unless string_like
322
+
323
+ distinct = {}
324
+ count = 0
325
+ values.each do |value|
326
+ next if value.nil?
327
+ return false unless value.is_a?(String) || value.is_a?(Symbol)
328
+
329
+ count += 1
330
+ distinct[value] = true
331
+ return false if distinct.length > DICTIONARY_CARDINALITY_LIMIT
332
+ end
333
+ !distinct.empty? && count >= distinct.length * DICTIONARY_MIN_REPETITIONS
334
+ end
335
+
290
336
  def write_record_batch(batch, path)
291
337
  unless @pq_writer
292
338
  properties = build_writer_properties_for_compression(@compression)
293
339
  properties ||= Parquet::WriterProperties.new
340
+ if @dictionary_paths
341
+ properties.disable_dictionary
342
+ @dictionary_paths.each { |column_path| properties.enable_dictionary(column_path) }
343
+ end
294
344
  properties.max_row_group_length = @row_group_size
295
345
  @pq_writer = Parquet::ArrowFileWriter.open(batch.schema, path, properties)
296
346
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: purplelight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.17
4
+ version: 0.1.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Nicholson