herringbone 0.3.0 → 0.5.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.
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # Writes Parquet the way the CSV gem writes CSV: name the columns, then append rows as Arrays.
5
+ # The column types are inferred from the first Schema::INFER_SAMPLE rows (see Schema.infer), so
6
+ # no schema has to be declared.
7
+ #
8
+ # File.open("people.parquet", "wb") do |file|
9
+ # Herringbone::SimpleWriter.open(file) do |sw|
10
+ # sw.headers!(:id, :name, :age)
11
+ # sw << [123, "John", 12]
12
+ # sw << { id: 124, name: "Jane" } # Hashes work too; missing columns are nulls
13
+ # end
14
+ # end
15
+ #
16
+ # Every column is nullable. A column that is nil in every sampled row becomes a string, and a file
17
+ # with headers but no rows has only string columns. Only the sample is held in memory; later rows
18
+ # are written as they come. A row that does not fit the inferred types raises SchemaMismatch,
19
+ # explaining what was inferred and how to declare the column, and the writer stops there (the
20
+ # file is left without a footer). Unlike CSV, the headers are mandatory: a Parquet file always
21
+ # has named columns.
22
+ #
23
+ # Columns declared in a block (Schema::Builder DSL) replace inferred ones, e.g. for a column that
24
+ # holds both numbers and text:
25
+ #
26
+ # Herringbone::SimpleWriter.new(io) { |s| s.string :code }
27
+ class SimpleWriter
28
+ # Opens a writer, yields it and closes it, finishing the file. If the block raises, the file is
29
+ # left unfinished (no footer), as with Writer.open. To declare columns, use #initialize and #close.
30
+ #
31
+ # @param io [IO, #write] destination; written sequentially, never closed
32
+ # @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
33
+ # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
34
+ # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
35
+ # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
36
+ # (other Writer options are passed on as well)
37
+ # @yield [writer] the open writer
38
+ # @yieldparam writer [SimpleWriter]
39
+ # @yieldreturn [Object] returned by open
40
+ # @return [SimpleWriter, Object] the writer without a block, the block's value with one
41
+ def self.open(io, **options)
42
+ writer = new(io, **options)
43
+ return writer unless block_given?
44
+ begin
45
+ result = yield writer
46
+ rescue Exception # rubocop:disable Lint/RescueException -- also abort on Interrupt
47
+ writer.abort
48
+ raise
49
+ end
50
+ writer.close
51
+ result
52
+ end
53
+
54
+ # @return [Array<String>, nil] the column names, nil until #headers! is called
55
+ attr_reader :headers
56
+
57
+ # @param io [IO, #write] destination; nothing is written to it until the column types are known
58
+ # @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
59
+ # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
60
+ # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
61
+ # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
62
+ # (other Writer options are passed on as well)
63
+ # @yield [s] optional, declares columns (named in #headers!) that replace inferred ones
64
+ # @yieldparam s [Schema::Builder] the builder to declare columns on
65
+ # @yieldreturn [void]
66
+ # @raise [MissingCodecError] when the codec's optional gem is not loaded
67
+ # @raise [ArgumentError] when the block takes no parameter
68
+ def initialize(io, **options, &overrides)
69
+ Schema::Builder.check_block!(overrides, "Herringbone::SimpleWriter.new(io) { |s| s.string :code }")
70
+ @headers = nil
71
+ @overrides = overrides
72
+ fix = "Herringbone::SimpleWriter.new(io) { |s| s.%s }"
73
+ @writer = InferringWriter.new(io, fix: fix, **options) { |sample| schema_for(sample) }
74
+ end
75
+
76
+ # Names the columns, in the order row Arrays list their values. Must be called once, before
77
+ # the first row.
78
+ #
79
+ # @param names [Array<String, Symbol>] column names
80
+ # @return [self]
81
+ # @raise [ArgumentError] when called twice, with no names, or with a name given twice
82
+ def headers!(*names)
83
+ raise ArgumentError, "headers! was already called" if @headers
84
+ names = names.flatten.map(&:to_s)
85
+ raise ArgumentError, "headers! needs at least one column name" if names.empty?
86
+ duplicates = names.tally.select { |_, n| n > 1 }.keys
87
+ raise ArgumentError, "Duplicate column names: #{duplicates.join(", ")}" unless duplicates.empty?
88
+ @headers = names.freeze
89
+ self
90
+ end
91
+
92
+ # Appends a row: an Array with one value per header, in header order, or a Hash keyed by
93
+ # header (String or Symbol keys; missing columns are nulls).
94
+ #
95
+ # @param row [Array, Hash]
96
+ # @return [self]
97
+ # @raise [ArgumentError] when #headers! was not called, an Array has the wrong length or a Hash
98
+ # has keys that are not headers
99
+ # @raise [SchemaMismatch] when a value does not fit its column's inferred type
100
+ def <<(row)
101
+ raise ArgumentError, "Call headers! with the column names before writing rows" unless @headers
102
+ @writer << values_of(row)
103
+ self
104
+ end
105
+
106
+ # @return [Integer] rows written so far, including those held back to infer the column types
107
+ def rows_written = @writer.rows_written
108
+
109
+ # Writes any rows still held back and finishes the file. The IO is not closed.
110
+ #
111
+ # @return [void]
112
+ # @raise [ArgumentError] when #headers! was never called
113
+ def close
114
+ raise ArgumentError, "Call headers! with the column names before closing" unless @headers
115
+ @writer.close
116
+ end
117
+
118
+ # Stops without finishing the file. What was already written stays in the IO.
119
+ #
120
+ # @return [void]
121
+ def abort = @writer.abort
122
+
123
+ # Short summary for the console, without the rows
124
+ #
125
+ # @return [String] the number of columns and the underlying writer's summary
126
+ def inspect
127
+ "#<#{self.class.name} columns=#{@headers&.size.inspect} writer=#{@writer.inspect}>"
128
+ end
129
+
130
+ private
131
+
132
+ # @param row [Array, Hash] row given to #<<
133
+ # @return [Array] its values in header order
134
+ # @raise [ArgumentError] when it does not match the headers
135
+ def values_of(row)
136
+ case row
137
+ when Array
138
+ unless row.size == @headers.size
139
+ raise ArgumentError, "Expected #{@headers.size} values (#{@headers.join(", ")}), got #{row.size}"
140
+ end
141
+ row
142
+ when Hash
143
+ unknown = row.keys.map(&:to_s) - @headers
144
+ raise ArgumentError, "Not among the headers (#{@headers.join(", ")}): #{unknown.join(", ")}" unless unknown.empty?
145
+ @headers.map { |name| row.fetch(name) { row[name.to_sym] } }
146
+ else
147
+ raise ArgumentError, "Rows must be Arrays or Hashes, got #{row.class}"
148
+ end
149
+ end
150
+
151
+ # @param sample [Array<Array>] held-back rows, values in header order
152
+ # @return [Schema] inferred from the sample; string columns when it is empty
153
+ # @raise [ArgumentError] when a column cannot be inferred or a declared column is not a header
154
+ def schema_for(sample)
155
+ rows = sample.empty? ? [@headers.to_h { |name| [name, nil] }] : sample.map { |values| @headers.zip(values).to_h }
156
+ schema = Schema.infer(rows, &@overrides)
157
+ extra = schema.fields.map(&:name) - @headers
158
+ raise ArgumentError, "Declared columns not among the headers: #{extra.join(", ")}" unless extra.empty?
159
+ schema
160
+ end
161
+ end
162
+ end
@@ -401,12 +401,12 @@ module Herringbone
401
401
  class << self
402
402
  # @return [Array<Field>] declared fields, sorted by id
403
403
  def fields
404
- @fields ||= []
404
+ @fields || []
405
405
  end
406
406
 
407
407
  # @return [Hash{Integer => Field}] declared fields by id, for decoding
408
408
  def fields_by_id
409
- @fields_by_id ||= fields.to_h { |f| [f.id, f] }
409
+ @fields_by_id || {}
410
410
  end
411
411
 
412
412
  # Declares a field and defines its accessor
@@ -417,9 +417,9 @@ module Herringbone
417
417
  # or a Struct subclass
418
418
  # @return [void]
419
419
  def field(id, name, type)
420
- fields << Field.new(id, name, type, :"@#{name}")
421
- fields.sort_by!(&:id)
422
- @fields_by_id = nil
420
+ # Shareable, so that structs can be decoded in any Ractor
421
+ @fields = Ractor.make_shareable((fields + [Field.new(id, name, type, :"@#{name}")]).sort_by(&:id))
422
+ @fields_by_id = Ractor.make_shareable(@fields.to_h { |f| [f.id, f] })
423
423
  attr_accessor name
424
424
  end
425
425
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Herringbone
4
4
  # Gem version, also written to the +created_by+ field of every file's footer
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
@@ -3,10 +3,10 @@
3
3
  module Herringbone
4
4
  # Writes Parquet files.
5
5
  #
6
- # schema = Herringbone::Schema.define do
7
- # int64 :id, null: false
8
- # string :name
9
- # list :tags, :string
6
+ # schema = Herringbone::Schema.define do |s|
7
+ # s.int64 :id, null: false
8
+ # s.string :name
9
+ # s.list :tags, :string
10
10
  # end
11
11
  # File.open("out.parquet", "wb") do |file|
12
12
  # Herringbone::Writer.open(file, schema) do |w|
@@ -56,14 +56,14 @@ module Herringbone
56
56
  }.freeze
57
57
 
58
58
  # Encoding id => physical types it may be used for, per the Parquet encodings spec
59
- VALID_ENCODINGS = {
59
+ VALID_ENCODINGS = Ractor.make_shareable({
60
60
  E::PLAIN => T::NAMES.keys,
61
61
  E::RLE => [T::BOOLEAN],
62
62
  E::DELTA_BINARY_PACKED => [T::INT32, T::INT64],
63
63
  E::DELTA_LENGTH_BYTE_ARRAY => [T::BYTE_ARRAY],
64
64
  E::DELTA_BYTE_ARRAY => [T::BYTE_ARRAY, T::FIXED_LEN_BYTE_ARRAY],
65
65
  E::BYTE_STREAM_SPLIT => [T::INT32, T::INT64, T::FLOAT, T::DOUBLE, T::FIXED_LEN_BYTE_ARRAY]
66
- }.freeze
66
+ })
67
67
 
68
68
  # Largest dictionary build_dictionary keeps; above it the chunk is written without a dictionary.
69
69
  # Byte-array columns are dictionary-encoded by ByteValues, which applies its own
@@ -132,7 +132,7 @@ module Herringbone
132
132
  # @param bloom_filters [Boolean, Array<String>, Hash{String => Boolean, Hash}, nil] see the class
133
133
  # description
134
134
  # @raise [ArgumentError] for an invalid IO, schema or option
135
- # @raise [MissingCodecError] when the codec's optional gem cannot be loaded
135
+ # @raise [MissingCodecError] when the codec's optional gem is not loaded
136
136
  # @raise [UnsupportedError] when the codec is not supported
137
137
  def initialize(io, schema, compression: :snappy, row_group_bytes: 16 * 1024 * 1024, row_group_rows: nil,
138
138
  page_bytes: 1024 * 1024, page_rows: 20_000, data_page_version: 1, dictionary: true, encodings: {},
@@ -164,6 +164,7 @@ module Herringbone
164
164
  @total_rows = 0
165
165
  @pos = 0
166
166
  @closed = false
167
+ @aborted = false
167
168
  @bytes_per_row = nil
168
169
  @io = check_io!(io)
169
170
  write_raw(MAGIC)
@@ -189,20 +190,20 @@ module Herringbone
189
190
  if buf.nil?
190
191
  shred(field, value, 0, 0)
191
192
  elsif value.nil?
192
- raise EncodeError, "Field #{name} is required but got nil" unless field.optional
193
+ raise EncodeError.new("Field #{name} is required but got nil", column: name) unless field.optional
193
194
  buf.defs << 0
194
195
  else
195
196
  begin
196
197
  buf.values << encoder.call(value)
197
198
  rescue ArgumentError, TypeError, NoMethodError, RangeError => e
198
- raise EncodeError, "Cannot write #{value.inspect} to #{name}: #{e.message}"
199
+ raise EncodeError.new("Cannot write #{value.inspect} to #{name}: #{e.message}", column: name, value: value)
199
200
  end
200
201
  buf.defs << field.def_level
201
202
  end
202
203
  end
203
204
  rescue EncodeError => e
204
205
  rollback_row(marks)
205
- raise EncodeError, "Row #{@total_rows + @buffered_rows}: #{e.message}"
206
+ raise EncodeError.new("Row #{@total_rows + @buffered_rows}: #{e.message}", row: @total_rows + @buffered_rows, column: e.column, value: e.value)
206
207
  rescue
207
208
  rollback_row(marks)
208
209
  raise
@@ -222,21 +223,82 @@ module Herringbone
222
223
  # @return [void]
223
224
  def flush_row_group
224
225
  return if @buffered_rows.zero?
226
+ write_row_group(@buffered_rows)
227
+ end
228
+
229
+ # A column chunk taken as it is from another file, for #write_row_group
230
+ #
231
+ # @!attribute chunk
232
+ # @return [Format::ColumnChunk] the chunk's footer entry in the source file
233
+ # @!attribute start
234
+ # @return [Integer] source file offset of the chunk's first page
235
+ # @!attribute bytes
236
+ # @return [String] the chunk's pages, headers included
237
+ # @!attribute column_index
238
+ # @return [String, nil] the chunk's encoded ColumnIndex
239
+ # @!attribute offset_index
240
+ # @return [Format::OffsetIndex, nil] the chunk's OffsetIndex, with source file offsets
241
+ # @!attribute bloom_filter
242
+ # @return [String, nil] the chunk's encoded bloom filter, header included
243
+ CopiedChunk = Struct.new(:chunk, :start, :bytes, :column_index, :offset_index, :bloom_filter)
244
+
245
+ # Internal (used by Redaction): writes a row group of +num_rows+ rows from the buffered values,
246
+ # except for the columns in +copies+, whose chunks are copied byte for byte from another file
247
+ # with their offsets rebased. Starts a new row group afterwards.
248
+ #
249
+ # @param num_rows [Integer] rows in the row group
250
+ # @param copies [Hash{Integer => CopiedChunk}] column index => chunk to copy instead of encoding
251
+ # @param codecs [Hash{Integer => Integer}] column index => codec id for an encoded chunk, instead
252
+ # of the +compression:+ option
253
+ # @param bloom_filters [Hash{Integer => Boolean}] column index => true to give an encoded chunk a
254
+ # bloom filter (default settings) when the +bloom_filters:+ option does not ask for one
255
+ # @param sorting_columns [Array<Format::SortingColumn>, nil] stored on the RowGroup as given
256
+ # @return [void]
257
+ # @raise [Error] when the writer is closed
258
+ def write_row_group(num_rows, copies: {}, codecs: {}, bloom_filters: {}, sorting_columns: nil)
259
+ raise Error, "Writer is closed" if @closed
225
260
  start = @pos
226
- chunks = @schema.columns.map { |col| write_column_chunk(col, @buffers[col.index]) }
261
+ chunks = @schema.columns.map do |col|
262
+ if (copy = copies[col.index])
263
+ copy_column_chunk(copy)
264
+ else
265
+ bloom = @bloom_filters[col.dotted_path]
266
+ bloom ||= bloom_filter_settings({}) if bloom_filters[col.index] && BloomFilter::TYPES.include?(col.type)
267
+ write_column_chunk(col, @buffers[col.index], codec: codecs.fetch(col.index, @codec), bloom: bloom)
268
+ end
269
+ end
227
270
  @row_groups << Format::RowGroup.new(
228
271
  columns: chunks,
229
272
  total_byte_size: chunks.sum { |c| c.meta_data.total_uncompressed_size },
230
- num_rows: @buffered_rows,
273
+ num_rows: num_rows,
274
+ sorting_columns: sorting_columns,
231
275
  file_offset: start,
232
276
  total_compressed_size: @pos - start,
233
277
  ordinal: @row_groups.size
234
278
  )
235
279
  write_bloom_filters
236
- @total_rows += @buffered_rows
280
+ @total_rows += num_rows
237
281
  reset_buffers
238
282
  end
239
283
 
284
+ # Internal (used by Redaction): shreds one value per row of a top-level field into the buffers,
285
+ # for #write_row_group. Other fields are left as they are, so the caller decides which columns
286
+ # are encoded and which are copied.
287
+ #
288
+ # @param name [String] top-level field name
289
+ # @param values [Array] the field's value for each row
290
+ # @return [void]
291
+ # @raise [ArgumentError] when the schema has no such field
292
+ # @raise [EncodeError] when a value cannot be written to the field
293
+ def buffer_field(name, values)
294
+ field = @schema.field(name) or raise ArgumentError, "No such field #{name.inspect}"
295
+ values.each_with_index do |value, i|
296
+ shred(field, value, 0, 0)
297
+ rescue EncodeError => e
298
+ raise EncodeError.new("Row #{@total_rows + i}: #{e.message}", row: @total_rows + i, column: e.column, value: e.value)
299
+ end
300
+ end
301
+
240
302
  # Flushes buffered rows, then writes the page indexes and the footer and flushes the IO.
241
303
  # Does nothing when already closed or aborted. The IO itself is not closed.
242
304
  #
@@ -267,9 +329,23 @@ module Herringbone
267
329
  #
268
330
  # @return [void]
269
331
  def abort
332
+ @aborted = true unless @closed
270
333
  @closed = true
271
334
  end
272
335
 
336
+ # Short summary for the console, without the buffered values
337
+ #
338
+ # @return [String] state (open, closed or aborted), rows written including buffered ones,
339
+ # row groups flushed and the codec
340
+ def inspect
341
+ state = if @aborted then "aborted"
342
+ elsif @closed then "closed"
343
+ else "open"
344
+ end
345
+ "#<#{self.class.name} #{state} rows_written=#{rows_written} row_groups=#{@row_groups.size} " \
346
+ "compression=#{Compression::NAMES.fetch(@codec, @codec).inspect}>"
347
+ end
348
+
273
349
  private
274
350
 
275
351
  # Pathname responds to #write too (writing a whole file by path), so it is rejected explicitly.
@@ -314,10 +390,12 @@ module Herringbone
314
390
  col.max_repetition_level.positive? ? String.new(encoding: Encoding::BINARY) : nil,
315
391
  new_values_store(col))
316
392
  end
393
+ # Column#encoder builds its Proc on every call
394
+ @encoders = @schema.columns.map(&:encoder)
317
395
  # Top-level, non-repeated leaves are written directly, everything else is shredded
318
396
  @plan = @schema.fields.map do |field|
319
397
  flat = field.leaf? && field.column.max_repetition_level.zero?
320
- [field, field.name, field.name.to_sym, flat ? @buffers[field.column.index] : nil, flat ? field.column.encoder : nil]
398
+ [field, field.name, field.name.to_sym, flat ? @buffers[field.column.index] : nil, flat ? @encoders[field.column.index] : nil]
321
399
  end
322
400
  @nested_buffers = @plan.reject { |p| p[3] }.flat_map { |p| p[0].leaves.map { |c| @buffers[c.index] } }
323
401
  @buffered_rows = 0
@@ -472,7 +550,7 @@ module Herringbone
472
550
  # map key is nil, or a leaf value cannot be encoded
473
551
  def shred(field, value, parent_def, rep)
474
552
  if value.nil?
475
- raise EncodeError, "Field #{field.node.path.join(".")} is required but got nil" unless field.optional
553
+ raise EncodeError.new("Field #{field.node.path.join(".")} is required but got nil", column: field.node.path.join(".")) unless field.optional
476
554
  field.leaves.each do |col|
477
555
  buf = @buffers[col.index]
478
556
  buf.defs << parent_def
@@ -488,14 +566,14 @@ module Herringbone
488
566
  buf.defs << d
489
567
  buf.reps&.<< rep
490
568
  begin
491
- buf.values << field.column.encoder.call(value)
569
+ buf.values << @encoders[field.column.index].call(value)
492
570
  rescue ArgumentError, TypeError, NoMethodError, RangeError => e
493
- raise EncodeError, "Cannot write #{value.inspect} to #{field.column.dotted_path}: #{e.message}"
571
+ raise EncodeError.new("Cannot write #{value.inspect} to #{field.column.dotted_path}: #{e.message}", column: field.column.dotted_path, value: value)
494
572
  end
495
573
  when :struct
496
574
  field.children.each { |ch| shred(ch, lookup(value, ch.name), d, rep) }
497
575
  when :list
498
- raise EncodeError, "Expected an Array for #{field.node.path.join(".")}, got #{value.class}" unless value.respond_to?(:each_with_index)
576
+ raise EncodeError.new("Expected an Array for #{field.node.path.join(".")}, got #{value.class}", column: field.node.path.join("."), value: value) unless value.respond_to?(:each_with_index)
499
577
  if value.empty?
500
578
  field.leaves.each do |col|
501
579
  buf = @buffers[col.index]
@@ -508,7 +586,7 @@ module Herringbone
508
586
  end
509
587
  end
510
588
  when :map
511
- raise EncodeError, "Expected a Hash for #{field.node.path.join(".")}, got #{value.class}" unless value.respond_to?(:each_pair) || value.is_a?(Array)
589
+ raise EncodeError.new("Expected a Hash for #{field.node.path.join(".")}, got #{value.class}", column: field.node.path.join("."), value: value) unless value.respond_to?(:each_pair) || value.is_a?(Array)
512
590
  if value.empty?
513
591
  field.leaves.each do |col|
514
592
  buf = @buffers[col.index]
@@ -532,8 +610,11 @@ module Herringbone
532
610
  #
533
611
  # @param col [Schema::Column] column being written
534
612
  # @param buffer [ColumnBuffer] the column's buffered levels and values
613
+ # @param codec [Integer] codec id to compress the pages with
614
+ # @param bloom [Hash{Symbol => Numeric, nil}, nil] bloom filter settings (see
615
+ # #bloom_filter_settings), nil for no bloom filter
535
616
  # @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
536
- def write_column_chunk(col, buffer)
617
+ def write_column_chunk(col, buffer, codec: @codec, bloom: @bloom_filters[col.dotted_path])
537
618
  type = col.type
538
619
  path = col.dotted_path
539
620
  dict_values = nil
@@ -566,7 +647,7 @@ module Herringbone
566
647
  type: Format::PageType::DICTIONARY_PAGE,
567
648
  dictionary_page_header: Format::DictionaryPageHeader.new(num_values: dict_values.size, encoding: E::PLAIN)
568
649
  )
569
- uncompressed_total += write_page(header, plain)
650
+ uncompressed_total += write_page(header, plain, codec: codec)
570
651
  end
571
652
 
572
653
  data_offset = @pos
@@ -600,10 +681,10 @@ module Herringbone
600
681
  rep_bytes = max_rep.positive? ? Encodings::RLE.encode_hybrid(reps, max_rep.bit_length) : "".b
601
682
  def_bytes = max_def.positive? ? Encodings::RLE.encode_hybrid(defs, max_def.bit_length) : "".b
602
683
  uncompressed_total += if @data_page_version == 1
603
- write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding)
684
+ write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding, codec)
604
685
  else
605
686
  num_rows = max_rep.zero? ? n : reps.count(0)
606
- write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding)
687
+ write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding, codec)
607
688
  end
608
689
  pages << PageInfo.new(page_offset, @pos - page_offset, first_row, n - non_null, non_null, range)
609
690
  first_row += max_rep.zero? ? n : reps.count(0)
@@ -616,7 +697,7 @@ module Herringbone
616
697
  type: type,
617
698
  encodings: encodings.uniq,
618
699
  path_in_schema: col.path,
619
- codec: @codec,
700
+ codec: codec,
620
701
  num_values: buf.defs.size,
621
702
  total_uncompressed_size: uncompressed_total,
622
703
  total_compressed_size: @pos - chunk_start,
@@ -625,7 +706,7 @@ module Herringbone
625
706
  statistics: statistics_for(col, buf.defs, values || indices.uniq.map { |i| dict_values[i] }, order)
626
707
  )
627
708
  chunk = Format::ColumnChunk.new(file_offset: chunk_start, meta_data: meta)
628
- if (bloom = @bloom_filters[path])
709
+ if bloom
629
710
  @pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values)]
630
711
  end
631
712
  @page_indexes << [chunk, column_index_for(col, pages, order), offset_index_for(pages)]
@@ -656,6 +737,37 @@ module Herringbone
656
737
  end)
657
738
  end
658
739
 
740
+ # Writes a chunk copied from another file. The pages, the ColumnIndex and the bloom filter
741
+ # hold no file offsets and go out as they are; the ColumnMetaData and the OffsetIndex do, so
742
+ # those are rebased onto where the chunk lands in this file.
743
+ #
744
+ # @param copy [CopiedChunk] the chunk to copy
745
+ # @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
746
+ def copy_column_chunk(copy)
747
+ start = @pos
748
+ shift = start - copy.start
749
+ meta = Format::ColumnMetaData.decode(copy.chunk.meta_data.encode).first
750
+ meta.data_page_offset += shift
751
+ dict = meta.dictionary_page_offset
752
+ # Some writers store 0 when there is no dictionary page
753
+ meta.dictionary_page_offset = dict&.positive? ? dict + shift : nil
754
+ meta.index_page_offset += shift if meta.index_page_offset
755
+ meta.total_compressed_size = copy.bytes.bytesize
756
+ meta.bloom_filter_offset = meta.bloom_filter_length = nil
757
+ write_raw(copy.bytes)
758
+ chunk = Format::ColumnChunk.new(file_offset: start, meta_data: meta)
759
+ @pending_bloom_filters << [meta, copy.bloom_filter] if copy.bloom_filter
760
+ offset_index = copy.offset_index && Format::OffsetIndex.new(
761
+ page_locations: copy.offset_index.page_locations.map do |loc|
762
+ Format::PageLocation.new(offset: loc.offset + shift, compressed_page_size: loc.compressed_page_size,
763
+ first_row_index: loc.first_row_index)
764
+ end,
765
+ unencoded_byte_array_data_bytes: copy.offset_index.unencoded_byte_array_data_bytes
766
+ )
767
+ @page_indexes << [chunk, copy.column_index, offset_index]
768
+ chunk
769
+ end
770
+
659
771
  # nil when the column has no defined sort order, or a page's values have no min/max (all NaN)
660
772
  #
661
773
  # @param col [Schema::Column] column the pages belong to
@@ -691,18 +803,20 @@ module Herringbone
691
803
  end
692
804
  end
693
805
 
694
- # Page indexes go after the last row group: all column indexes, then all offset indexes
806
+ # Page indexes go after the last row group: all column indexes, then all offset indexes.
807
+ # A copied chunk brings its ColumnIndex already encoded, and may come without either index.
695
808
  #
696
809
  # @return [void]
697
810
  def write_page_indexes
698
811
  @page_indexes.each do |chunk, column_index, _|
699
812
  next unless column_index
700
- bytes = column_index.encode
813
+ bytes = column_index.is_a?(String) ? column_index : column_index.encode
701
814
  chunk.column_index_offset = @pos
702
815
  chunk.column_index_length = bytes.bytesize
703
816
  write_raw(bytes)
704
817
  end
705
818
  @page_indexes.each do |chunk, _, offset_index|
819
+ next unless offset_index
706
820
  bytes = offset_index.encode
707
821
  chunk.offset_index_offset = @pos
708
822
  chunk.offset_index_length = bytes.bytesize
@@ -778,12 +892,13 @@ module Herringbone
778
892
  filter
779
893
  end
780
894
 
781
- # Bloom filters go right after the row group's column chunks, in column order
895
+ # Bloom filters go right after the row group's column chunks, in column order. A copied
896
+ # chunk brings its filter already encoded.
782
897
  #
783
898
  # @return [void]
784
899
  def write_bloom_filters
785
900
  @pending_bloom_filters.each do |meta, filter|
786
- bytes = filter.encode
901
+ bytes = filter.is_a?(String) ? filter : filter.encode
787
902
  meta.bloom_filter_offset = @pos
788
903
  meta.bloom_filter_length = bytes.bytesize
789
904
  write_raw(bytes)
@@ -904,9 +1019,10 @@ module Herringbone
904
1019
  # @param body [String] uncompressed page body
905
1020
  # @param compressed [String, nil] bytes to write as the page body, when already prepared (v2 data
906
1021
  # pages, whose levels stay uncompressed); +body+ is compressed otherwise
1022
+ # @param codec [Integer] codec id to compress +body+ with
907
1023
  # @return [Integer]
908
- def write_page(header, body, compressed = nil)
909
- compressed ||= Compression.compress(@codec, body)
1024
+ def write_page(header, body, compressed = nil, codec: @codec)
1025
+ compressed ||= Compression.compress(codec, body)
910
1026
  header.uncompressed_page_size ||= body.bytesize
911
1027
  header.compressed_page_size = compressed.bytesize
912
1028
  header.crc = Zlib.crc32(compressed).then { |c| (c >= 0x8000_0000) ? c - 0x1_0000_0000 : c }
@@ -923,8 +1039,9 @@ module Herringbone
923
1039
  # @param def_bytes [String] RLE-encoded definition levels, empty when the column has none
924
1040
  # @param encoded [String] encoded values
925
1041
  # @param encoding [Integer] encoding id of the values
1042
+ # @param codec [Integer] codec id to compress the page with
926
1043
  # @return [Integer] uncompressed size including the header
927
- def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding)
1044
+ def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec)
928
1045
  body = String.new(encoding: Encoding::BINARY)
929
1046
  body << [rep_bytes.bytesize].pack("V") << rep_bytes unless rep_bytes.empty?
930
1047
  body << [def_bytes.bytesize].pack("V") << def_bytes unless def_bytes.empty?
@@ -936,7 +1053,7 @@ module Herringbone
936
1053
  definition_level_encoding: E::RLE, repetition_level_encoding: E::RLE
937
1054
  )
938
1055
  )
939
- write_page(header, body)
1056
+ write_page(header, body, codec: codec)
940
1057
  end
941
1058
 
942
1059
  # A DATA_PAGE_V2: levels without length prefixes and uncompressed, then the compressed values
@@ -948,9 +1065,10 @@ module Herringbone
948
1065
  # @param def_bytes [String] RLE-encoded definition levels, empty when the column has none
949
1066
  # @param encoded [String] encoded values
950
1067
  # @param encoding [Integer] encoding id of the values
1068
+ # @param codec [Integer] codec id to compress the values with
951
1069
  # @return [Integer] uncompressed size including the header
952
- def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding)
953
- compressed = Compression.compress(@codec, encoded)
1070
+ def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding, codec)
1071
+ compressed = Compression.compress(codec, encoded)
954
1072
  header = Format::PageHeader.new(
955
1073
  type: Format::PageType::DATA_PAGE_V2,
956
1074
  uncompressed_page_size: rep_bytes.bytesize + def_bytes.bytesize + encoded.bytesize,
@@ -958,17 +1076,17 @@ module Herringbone
958
1076
  num_values: n, num_nulls: nulls, num_rows: rows, encoding: encoding,
959
1077
  definition_levels_byte_length: def_bytes.bytesize,
960
1078
  repetition_levels_byte_length: rep_bytes.bytesize,
961
- is_compressed: @codec != Format::Codec::UNCOMPRESSED
1079
+ is_compressed: codec != Format::Codec::UNCOMPRESSED
962
1080
  )
963
1081
  )
964
- write_page(header, "".b, rep_bytes + def_bytes + compressed)
1082
+ write_page(header, "".b, rep_bytes + def_bytes + compressed, codec: codec)
965
1083
  end
966
1084
 
967
1085
  # Statistics and column index bounds longer than this are truncated, see #truncate_min
968
1086
  STAT_TRUNCATE_BYTES = 64
969
1087
  # Sort key for types whose Ruby ordering already matches Parquet's; compared by identity to
970
1088
  # skip calling it
971
- IDENTITY = ->(v) { v }
1089
+ IDENTITY = Ractor.make_shareable(->(v) { v })
972
1090
 
973
1091
  # Chunk statistics: null count, plus min/max (flagged exact unless truncated) when the column
974
1092
  # has a sort order and non-NaN values