herringbone 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.
@@ -17,8 +17,9 @@ module Herringbone
17
17
  # end
18
18
  #
19
19
  # The output is any IO that responds to #write (a File, StringIO, Tempfile, socket, pipe...); it
20
- # is written sequentially and never seeked, rewound or closed by the writer. Herringbone does
21
- # not open files by path.
20
+ # is written sequentially and never seeked, rewound or closed by the writer; only #write is
21
+ # required, its return value is ignored, and #binmode and #flush are called when available.
22
+ # Herringbone does not open files by path.
22
23
  #
23
24
  # Options:
24
25
  # compression: :snappy (default), :zstd, :gzip, :lz4 (LZ4_RAW), :lz4_hadoop, :brotli, :none
@@ -40,34 +41,65 @@ module Herringbone
40
41
  #
41
42
  # Statistics and page indexes (ColumnIndex/OffsetIndex) are always written.
42
43
  class Writer
43
- MAGIC = "PAR1"
44
+ # Marker at the start and end of every Parquet file
45
+ MAGIC = "PAR1".b.freeze
46
+ # Shorthand for Format::Type (physical types)
44
47
  T = Format::Type
48
+ # Shorthand for Format::Encoding
45
49
  E = Format::Encoding
46
50
 
51
+ # Names accepted in the +encodings:+ option => Parquet encoding id
47
52
  ENCODING_NAMES = {
48
53
  plain: E::PLAIN, rle: E::RLE, delta_binary_packed: E::DELTA_BINARY_PACKED,
49
54
  delta_length_byte_array: E::DELTA_LENGTH_BYTE_ARRAY, delta_byte_array: E::DELTA_BYTE_ARRAY,
50
55
  byte_stream_split: E::BYTE_STREAM_SPLIT
51
56
  }.freeze
52
57
 
53
- VALID_ENCODINGS = {
58
+ # Encoding id => physical types it may be used for, per the Parquet encodings spec
59
+ VALID_ENCODINGS = Ractor.make_shareable({
54
60
  E::PLAIN => T::NAMES.keys,
55
61
  E::RLE => [T::BOOLEAN],
56
62
  E::DELTA_BINARY_PACKED => [T::INT32, T::INT64],
57
63
  E::DELTA_LENGTH_BYTE_ARRAY => [T::BYTE_ARRAY],
58
64
  E::DELTA_BYTE_ARRAY => [T::BYTE_ARRAY, T::FIXED_LEN_BYTE_ARRAY],
59
65
  E::BYTE_STREAM_SPLIT => [T::INT32, T::INT64, T::FLOAT, T::DOUBLE, T::FIXED_LEN_BYTE_ARRAY]
60
- }.freeze
66
+ })
61
67
 
68
+ # Largest dictionary build_dictionary keeps; above it the chunk is written without a dictionary.
69
+ # Byte-array columns are dictionary-encoded by ByteValues, which applies its own
70
+ # ByteValues::MAX_DICTIONARY_BYTES as values arrive.
62
71
  MAX_DICTIONARY_BYTES = 1024 * 1024
63
72
  # The row group byte size is first estimated after this many rows, then after every row group
64
73
  ESTIMATE_AFTER_ROWS = 1000
65
74
 
75
+ # @return [Schema] schema the rows are written with
66
76
  attr_reader :schema
67
77
 
68
78
  # Opens a writer on +io+. With a block, the file is finished (footer written) when the block
69
79
  # returns and the block's value is returned; if the block raises, the writer is aborted and no
70
80
  # footer is written. Without a block, call #close to finish. The IO is never closed.
81
+ #
82
+ # @param io [IO, #write] destination
83
+ # @param schema [Schema] schema of the rows
84
+ # @param options [Hash{Symbol => Object}] see the class description and #initialize
85
+ # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
86
+ # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
87
+ # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
88
+ # @option options [Integer] :page_bytes (1MB) approximate uncompressed data page size
89
+ # @option options [Integer] :page_rows (20_000) maximum rows per data page
90
+ # @option options [Integer] :data_page_version (1) 1 or 2
91
+ # @option options [Boolean, Array<String>] :dictionary (true) dictionary-encode all eligible columns,
92
+ # none, or only the listed dotted column paths
93
+ # @option options [Hash{String => Symbol}] :encodings ({}) dotted column path => value encoding
94
+ # for non-dictionary pages
95
+ # @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
96
+ # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
97
+ # columns to write split block bloom filters for
98
+ # @yield [writer] the open writer
99
+ # @yieldparam writer [Writer] writer to append rows to
100
+ # @yieldreturn [Object] returned by open
101
+ # @return [Writer, Object] the writer without a block, the block's value with one
102
+ # @raise [ArgumentError] for an invalid IO, schema or option
71
103
  def self.open(io, schema, **options)
72
104
  writer = new(io, schema, **options)
73
105
  return writer unless block_given?
@@ -81,6 +113,27 @@ module Herringbone
81
113
  result
82
114
  end
83
115
 
116
+ # Validates the options and writes the leading magic bytes to +io+.
117
+ #
118
+ # @param io [IO, #write] destination; switched to binmode when it supports that
119
+ # @param schema [Schema] schema of the rows
120
+ # @param compression [Symbol, Integer] codec name (see Herringbone.codecs) or Format::Codec id
121
+ # @param row_group_bytes [Integer] approximate buffered size that triggers a row group
122
+ # @param row_group_rows [Integer, nil] also flush a row group after this many rows
123
+ # @param page_bytes [Integer] approximate uncompressed data page size
124
+ # @param page_rows [Integer] maximum level entries per data page (pages of repeated columns
125
+ # extend to the next row start)
126
+ # @param data_page_version [Integer] 1 or 2
127
+ # @param dictionary [Boolean, Array<String>] true for every column except BOOLEAN, FLOAT, DOUBLE
128
+ # and those listed in +encodings+; false for none; or the dotted paths of the columns to encode
129
+ # @param encodings [Hash{String, Symbol => Symbol, Integer}] dotted column path => value encoding
130
+ # (a name from ENCODING_NAMES or an encoding id) for non-dictionary pages
131
+ # @param metadata [Hash{#to_s => #to_s}] key/value metadata for the footer
132
+ # @param bloom_filters [Boolean, Array<String>, Hash{String => Boolean, Hash}, nil] see the class
133
+ # description
134
+ # @raise [ArgumentError] for an invalid IO, schema or option
135
+ # @raise [MissingCodecError] when the codec's optional gem is not loaded
136
+ # @raise [UnsupportedError] when the codec is not supported
84
137
  def initialize(io, schema, compression: :snappy, row_group_bytes: 16 * 1024 * 1024, row_group_rows: nil,
85
138
  page_bytes: 1024 * 1024, page_rows: 20_000, data_page_version: 1, dictionary: true, encodings: {},
86
139
  metadata: {}, bloom_filters: nil)
@@ -100,8 +153,10 @@ module Herringbone
100
153
  raise ArgumentError, "data_page_version must be 1 or 2" unless [1, 2].include?(@data_page_version)
101
154
  @dictionary = dictionary
102
155
  @encodings = encodings.to_h { |path, enc| [path.to_s, encoding_id(path, enc)] }
103
- unknown = @encodings.keys - schema.columns.map(&:dotted_path)
156
+ columns = schema.columns.to_h { |c| [c.dotted_path, c] }
157
+ unknown = @encodings.keys - columns.keys
104
158
  raise ArgumentError, "encodings: no such column #{unknown.join(", ")}" unless unknown.empty?
159
+ @encodings.each { |path, enc| check_encoding!(columns[path], enc) }
105
160
  @metadata = metadata
106
161
  @bloom_filters = bloom_filter_config(bloom_filters)
107
162
  @pending_bloom_filters = [] # [ColumnMetaData, BloomFilter] for the row group being written
@@ -116,7 +171,14 @@ module Herringbone
116
171
  end
117
172
 
118
173
  # Appends a row: a Hash keyed by top-level field names (Strings or Symbols), an Array of values
119
- # in schema order, or an object responding to #attributes (ActiveRecord) or #to_h (Struct, Data)
174
+ # in schema order, or an object responding to #attributes (ActiveRecord) or #to_h (Struct, Data).
175
+ # A row that fails to encode leaves nothing behind in the buffers. Flushes a row group when the
176
+ # buffered rows reach the size limits.
177
+ #
178
+ # @param row [Hash, Array, #attributes, #to_h] row to append
179
+ # @return [self]
180
+ # @raise [Error] when the writer is closed
181
+ # @raise [EncodeError] when a required field is nil or missing, or a value cannot be encoded
120
182
  def <<(row)
121
183
  raise Error, "Writer is closed" if @closed
122
184
  row = row_hash(row)
@@ -127,21 +189,21 @@ module Herringbone
127
189
  if buf.nil?
128
190
  shred(field, value, 0, 0)
129
191
  elsif value.nil?
130
- raise EncodeError, "Field #{name} is required but got nil" unless field.optional
192
+ raise EncodeError.new("Field #{name} is required but got nil", column: name) unless field.optional
131
193
  buf.defs << 0
132
194
  else
133
195
  begin
134
196
  buf.values << encoder.call(value)
135
197
  rescue ArgumentError, TypeError, NoMethodError, RangeError => e
136
- raise EncodeError, "Cannot write #{value.inspect} to #{name}: #{e.message}"
198
+ raise EncodeError.new("Cannot write #{value.inspect} to #{name}: #{e.message}", column: name, value: value)
137
199
  end
138
200
  buf.defs << field.def_level
139
201
  end
140
202
  end
141
203
  rescue EncodeError => e
142
204
  rollback_row(marks)
143
- raise EncodeError, "Row #{@total_rows + @buffered_rows}: #{e.message}"
144
- rescue StandardError
205
+ raise EncodeError.new("Row #{@total_rows + @buffered_rows}: #{e.message}", row: @total_rows + @buffered_rows, column: e.column, value: e.value)
206
+ rescue
145
207
  rollback_row(marks)
146
208
  raise
147
209
  end
@@ -151,9 +213,13 @@ module Herringbone
151
213
  end
152
214
 
153
215
  # Number of rows written so far, including buffered ones
216
+ #
217
+ # @return [Integer]
154
218
  def rows_written = @total_rows + @buffered_rows
155
219
 
156
220
  # Writes any buffered rows as a row group
221
+ #
222
+ # @return [void]
157
223
  def flush_row_group
158
224
  return if @buffered_rows.zero?
159
225
  start = @pos
@@ -171,6 +237,10 @@ module Herringbone
171
237
  reset_buffers
172
238
  end
173
239
 
240
+ # Flushes buffered rows, then writes the page indexes and the footer and flushes the IO.
241
+ # Does nothing when already closed or aborted. The IO itself is not closed.
242
+ #
243
+ # @return [void]
174
244
  def close
175
245
  return if @closed
176
246
  flush_row_group
@@ -194,23 +264,47 @@ module Herringbone
194
264
 
195
265
  # Stops writing without finishing the file (no footer is written). Whatever was already written
196
266
  # to the IO stays there; discarding it is up to the caller.
267
+ #
268
+ # @return [void]
197
269
  def abort
198
270
  @closed = true
199
271
  end
200
272
 
201
273
  private
202
274
 
203
- # Pathname responds to #write too (writing a whole file by path), so it is rejected explicitly
275
+ # Pathname responds to #write too (writing a whole file by path), so it is rejected explicitly.
276
+ # A text-mode IO (a pipe, or a File opened with "w") transcodes what is written when
277
+ # Encoding.default_internal is set, as Rails does, and binary pages cannot be transcoded,
278
+ # so the IO is switched to binary mode.
279
+ #
280
+ # @param io [Object] candidate destination
281
+ # @return [IO, #write] +io+, in binary mode when it supports #binmode
282
+ # @raise [ArgumentError] when +io+ does not respond to #write, or is a String or Pathname
204
283
  def check_io!(io)
205
- return io if io.respond_to?(:write) && !io.is_a?(String) && !(defined?(Pathname) && io.is_a?(Pathname))
284
+ if io.respond_to?(:write) && !io.is_a?(String) && !(defined?(Pathname) && io.is_a?(Pathname))
285
+ io.binmode if io.respond_to?(:binmode)
286
+ return io
287
+ end
206
288
  raise ArgumentError, "Herringbone::Writer expects an IO that responds to #write " \
207
289
  "(e.g. File.open(path, \"wb\") or StringIO.new), got #{io.class}"
208
290
  end
209
291
 
210
292
  # Levels are kept as binary Strings (one byte per entry). Values are an Array for numeric and
211
293
  # boolean columns, and a compact ByteValues for BYTE_ARRAY / FIXED_LEN_BYTE_ARRAY columns.
294
+ #
295
+ # @!attribute defs
296
+ # @return [String, Array<Integer>] definition levels (unpacked to an Array while a chunk is written)
297
+ # @!attribute reps
298
+ # @return [String, Array<Integer>, nil] repetition levels, like +defs+; nil for non-repeated columns
299
+ # @!attribute values
300
+ # @return [Array, ByteValues] non-null physical values
212
301
  ColumnBuffer = Struct.new(:defs, :reps, :values)
213
302
 
303
+ # Starts a new row group: fresh buffers per column, and the per-field write plan
304
+ # (field, name, Symbol name, buffer and encoder for flat fields, or nils for shredded ones).
305
+ #
306
+ # @return [void]
307
+ # @raise [UnsupportedError] when a column has more than 255 definition or repetition levels
214
308
  def reset_buffers
215
309
  @buffers = @schema.columns.map do |col|
216
310
  if col.max_definition_level > 255 || col.max_repetition_level > 255
@@ -220,15 +314,19 @@ module Herringbone
220
314
  col.max_repetition_level.positive? ? String.new(encoding: Encoding::BINARY) : nil,
221
315
  new_values_store(col))
222
316
  end
317
+ # Column#encoder builds its Proc on every call
318
+ @encoders = @schema.columns.map(&:encoder)
223
319
  # Top-level, non-repeated leaves are written directly, everything else is shredded
224
320
  @plan = @schema.fields.map do |field|
225
321
  flat = field.leaf? && field.column.max_repetition_level.zero?
226
- [field, field.name, field.name.to_sym, flat ? @buffers[field.column.index] : nil, flat ? field.column.encoder : nil]
322
+ [field, field.name, field.name.to_sym, flat ? @buffers[field.column.index] : nil, flat ? @encoders[field.column.index] : nil]
227
323
  end
228
324
  @nested_buffers = @plan.reject { |p| p[3] }.flat_map { |p| p[0].leaves.map { |c| @buffers[c.index] } }
229
325
  @buffered_rows = 0
230
326
  end
231
327
 
328
+ # @param col [Schema::Column] column to buffer
329
+ # @return [ByteValues, Array] empty value store for the column
232
330
  def new_values_store(col)
233
331
  case col.type
234
332
  when T::BYTE_ARRAY then ByteValues.new(dictionary: use_dictionary?(col))
@@ -239,6 +337,8 @@ module Herringbone
239
337
 
240
338
  # Flushes when the buffered values reach row_group_bytes (or row_group_rows rows). The bytes per
241
339
  # row are estimated from the buffered values after the first rows, then refreshed per row group.
340
+ #
341
+ # @return [void]
242
342
  def check_row_group_size
243
343
  @bytes_per_row ||= estimate_bytes_per_row
244
344
  limit = row_limit_for(@bytes_per_row)
@@ -251,11 +351,15 @@ module Herringbone
251
351
  end
252
352
  end
253
353
 
354
+ # @param bytes_per_row [Integer] estimated buffered bytes per row
355
+ # @return [Integer] rows to buffer before the next size check: what fits in row_group_bytes,
356
+ # at least ESTIMATE_AFTER_ROWS, at most row_group_rows
254
357
  def row_limit_for(bytes_per_row)
255
358
  by_bytes = [@row_group_bytes / [bytes_per_row, 1].max, ESTIMATE_AFTER_ROWS].max
256
359
  @row_group_rows ? [@row_group_rows, by_bytes].min : by_bytes
257
360
  end
258
361
 
362
+ # @return [Integer] memory held by the buffers divided by the buffered rows
259
363
  def estimate_bytes_per_row
260
364
  bytes = @schema.columns.sum do |col|
261
365
  buf = @buffers[col.index]
@@ -263,29 +367,58 @@ module Herringbone
263
367
  held = if values.is_a?(ByteValues)
264
368
  values.memory_bytes
265
369
  else
266
- values.size * (col.type == T::INT96 ? 48 : 8)
370
+ values.size * ((col.type == T::INT96) ? 48 : 8)
267
371
  end
268
372
  held + buf.defs.bytesize + (buf.reps ? buf.reps.bytesize : 0)
269
373
  end
270
374
  bytes / [@buffered_rows, 1].max
271
375
  end
272
376
 
377
+ # Writes to the IO and tracks the file offset, since the IO is never asked for its position
378
+ #
379
+ # @param bytes [String] binary data
380
+ # @return [void]
273
381
  def write_raw(bytes)
274
382
  @io.write(bytes)
275
383
  @pos += bytes.bytesize
276
384
  end
277
385
 
386
+ # @param path [String, Symbol] column path, for the error message
387
+ # @param enc [Symbol, String, Integer] encoding name from ENCODING_NAMES, or an encoding id
388
+ # @return [Integer] encoding id
389
+ # @raise [ArgumentError] for an unknown encoding name, or an id that is not in VALID_ENCODINGS
278
390
  def encoding_id(path, enc)
279
- return enc if enc.is_a?(Integer)
391
+ if enc.is_a?(Integer)
392
+ return enc if VALID_ENCODINGS.key?(enc)
393
+ raise ArgumentError, "Encoding #{E::NAMES.fetch(enc, enc)} cannot be requested for #{path}"
394
+ end
280
395
  ENCODING_NAMES.fetch(enc.to_s.downcase.to_sym) { raise ArgumentError, "Unknown encoding #{enc.inspect} for #{path}" }
281
396
  end
282
397
 
398
+ # @param col [Schema::Column] column the encoding is requested for
399
+ # @param enc [Integer] encoding id, a key of VALID_ENCODINGS
400
+ # @return [void]
401
+ # @raise [ArgumentError] when the encoding is not valid for the column's physical type
402
+ def check_encoding!(col, enc)
403
+ return if VALID_ENCODINGS.fetch(enc).include?(col.type)
404
+ raise ArgumentError, "Encoding #{E::NAMES[enc]} is not valid for #{T::NAMES[col.type]} column #{col.dotted_path}"
405
+ end
406
+
407
+ # Reads a struct member, by String or Symbol key
408
+ #
409
+ # @param hash [Hash, #to_h, nil] struct value
410
+ # @param name [String] member name
411
+ # @return [Object, nil] member value, nil when +hash+ is nil or has no such key
412
+ # @raise [EncodeError] when +hash+ cannot be converted to a Hash
283
413
  def lookup(hash, name)
284
414
  return nil if hash.nil?
285
415
  hash = as_hash(hash, name) unless hash.is_a?(Hash)
286
416
  hash.fetch(name) { hash[name.to_sym] }
287
417
  end
288
418
 
419
+ # @param row [Hash, Array, #attributes, #to_h] row given to #<<
420
+ # @return [Hash] the row keyed by top-level field names
421
+ # @raise [EncodeError] when an Array has the wrong size or the row cannot be converted to a Hash
289
422
  def row_hash(row)
290
423
  case row
291
424
  when Hash then row
@@ -300,6 +433,10 @@ module Herringbone
300
433
  end
301
434
  end
302
435
 
436
+ # @param value [Hash, #to_h] value to convert
437
+ # @param what [String] description of the value, for the error message
438
+ # @return [Hash]
439
+ # @raise [EncodeError] when +value+ does not respond to #to_h
303
440
  def as_hash(value, what)
304
441
  return value if value.is_a?(Hash)
305
442
  raise EncodeError, "Expected a Hash for #{what}, got #{value.class}" unless value.respond_to?(:to_h)
@@ -307,6 +444,10 @@ module Herringbone
307
444
  end
308
445
 
309
446
  # Removes the entries a failed row left behind, so the buffers stay aligned
447
+ #
448
+ # @param marks [Array<Array(Integer, Integer, Integer)>, nil] sizes of defs, reps and values of
449
+ # each nested buffer before the row, nil when there are none
450
+ # @return [void]
310
451
  def rollback_row(marks)
311
452
  @plan.each do |field, _, _, buf|
312
453
  next unless buf && buf.defs.bytesize > @buffered_rows
@@ -323,9 +464,17 @@ module Herringbone
323
464
  end
324
465
 
325
466
  # Record shredding: turns a nested value into (definition level, repetition level, value) entries
467
+ #
468
+ # @param field [Schema::Field] field the value belongs to
469
+ # @param value [Object, nil] Ruby value of the field
470
+ # @param parent_def [Integer] definition level recorded when +value+ is nil
471
+ # @param rep [Integer] repetition level of the first entry this value produces
472
+ # @return [void]
473
+ # @raise [EncodeError] when a required value is nil, a list or map value has the wrong type, a
474
+ # map key is nil, or a leaf value cannot be encoded
326
475
  def shred(field, value, parent_def, rep)
327
476
  if value.nil?
328
- raise EncodeError, "Field #{field.node.path.join(".")} is required but got nil" unless field.optional
477
+ raise EncodeError.new("Field #{field.node.path.join(".")} is required but got nil", column: field.node.path.join(".")) unless field.optional
329
478
  field.leaves.each do |col|
330
479
  buf = @buffers[col.index]
331
480
  buf.defs << parent_def
@@ -341,14 +490,14 @@ module Herringbone
341
490
  buf.defs << d
342
491
  buf.reps&.<< rep
343
492
  begin
344
- buf.values << field.column.encoder.call(value)
493
+ buf.values << @encoders[field.column.index].call(value)
345
494
  rescue ArgumentError, TypeError, NoMethodError, RangeError => e
346
- raise EncodeError, "Cannot write #{value.inspect} to #{field.column.dotted_path}: #{e.message}"
495
+ raise EncodeError.new("Cannot write #{value.inspect} to #{field.column.dotted_path}: #{e.message}", column: field.column.dotted_path, value: value)
347
496
  end
348
497
  when :struct
349
498
  field.children.each { |ch| shred(ch, lookup(value, ch.name), d, rep) }
350
499
  when :list
351
- raise EncodeError, "Expected an Array for #{field.node.path.join(".")}, got #{value.class}" unless value.respond_to?(:each_with_index)
500
+ 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)
352
501
  if value.empty?
353
502
  field.leaves.each do |col|
354
503
  buf = @buffers[col.index]
@@ -361,7 +510,7 @@ module Herringbone
361
510
  end
362
511
  end
363
512
  when :map
364
- raise EncodeError, "Expected a Hash for #{field.node.path.join(".")}, got #{value.class}" unless value.respond_to?(:each_pair) || value.is_a?(Array)
513
+ 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)
365
514
  if value.empty?
366
515
  field.leaves.each do |col|
367
516
  buf = @buffers[col.index]
@@ -379,6 +528,13 @@ module Herringbone
379
528
  end
380
529
  end
381
530
 
531
+ # Writes one column chunk of the current row group: an optional dictionary page, then data pages.
532
+ # Bloom filters and page indexes are queued, to be written after the row group and before the
533
+ # footer.
534
+ #
535
+ # @param col [Schema::Column] column being written
536
+ # @param buffer [ColumnBuffer] the column's buffered levels and values
537
+ # @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
382
538
  def write_column_chunk(col, buffer)
383
539
  type = col.type
384
540
  path = col.dotted_path
@@ -399,11 +555,7 @@ module Herringbone
399
555
  value_encoding = if dict_values
400
556
  E::RLE_DICTIONARY
401
557
  else
402
- enc = @encodings[path] || E::PLAIN
403
- unless VALID_ENCODINGS.fetch(enc).include?(type)
404
- raise ArgumentError, "Encoding #{E::NAMES[enc]} is not valid for #{T::NAMES[type]} column #{path}"
405
- end
406
- enc
558
+ @encodings[path] || E::PLAIN
407
559
  end
408
560
 
409
561
  chunk_start = @pos
@@ -482,8 +634,24 @@ module Herringbone
482
634
  chunk
483
635
  end
484
636
 
637
+ # What the page indexes need to know about a written data page
638
+ #
639
+ # @!attribute offset
640
+ # @return [Integer] file offset of the page header
641
+ # @!attribute size
642
+ # @return [Integer] page size in the file, header included
643
+ # @!attribute first_row
644
+ # @return [Integer] index of the page's first row within the row group
645
+ # @!attribute nulls
646
+ # @return [Integer] null entries in the page
647
+ # @!attribute non_null
648
+ # @return [Integer] non-null values in the page
649
+ # @!attribute range
650
+ # @return [Array(Object, Object), nil] [min, max] physical values, nil when there are none
485
651
  PageInfo = Struct.new(:offset, :size, :first_row, :nulls, :non_null, :range)
486
652
 
653
+ # @param pages [Array<PageInfo>] pages of a column chunk
654
+ # @return [Format::OffsetIndex]
487
655
  def offset_index_for(pages)
488
656
  Format::OffsetIndex.new(page_locations: pages.map do |p|
489
657
  Format::PageLocation.new(offset: p.offset, compressed_page_size: p.size, first_row_index: p.first_row)
@@ -491,6 +659,11 @@ module Herringbone
491
659
  end
492
660
 
493
661
  # nil when the column has no defined sort order, or a page's values have no min/max (all NaN)
662
+ #
663
+ # @param col [Schema::Column] column the pages belong to
664
+ # @param pages [Array<PageInfo>] pages of the column chunk
665
+ # @param order [Proc, Method, nil] sort key from #sort_key
666
+ # @return [Format::ColumnIndex, nil]
494
667
  def column_index_for(col, pages, order)
495
668
  return nil unless order
496
669
  return nil if pages.any? { |p| p.non_null.positive? && p.range.nil? }
@@ -503,13 +676,17 @@ module Herringbone
503
676
  )
504
677
  end
505
678
 
679
+ # @param ranges [Array<Array(Object, Object)>] [min, max] of each non-null page, in page order
680
+ # @param order [Proc, Method] sort key from #sort_key
681
+ # @return [Integer] Format::BoundaryOrder: ASCENDING when both mins and maxes never decrease
682
+ # (or there are fewer than two pages), DESCENDING when they never increase, else UNORDERED
506
683
  def boundary_order(ranges, order)
507
684
  return Format::BoundaryOrder::ASCENDING if ranges.size < 2
508
685
  cmp = ->(a, b) { order.equal?(IDENTITY) ? a <=> b : order.call(a) <=> order.call(b) }
509
686
  pairs = ranges.each_cons(2)
510
- if pairs.all? { |(a_min, a_max), (b_min, b_max)| cmp.(a_min, b_min) <= 0 && cmp.(a_max, b_max) <= 0 }
687
+ if pairs.all? { |(a_min, a_max), (b_min, b_max)| cmp.call(a_min, b_min) <= 0 && cmp.call(a_max, b_max) <= 0 }
511
688
  Format::BoundaryOrder::ASCENDING
512
- elsif pairs.all? { |(a_min, a_max), (b_min, b_max)| cmp.(a_min, b_min) >= 0 && cmp.(a_max, b_max) >= 0 }
689
+ elsif pairs.all? { |(a_min, a_max), (b_min, b_max)| cmp.call(a_min, b_min) >= 0 && cmp.call(a_max, b_max) >= 0 }
513
690
  Format::BoundaryOrder::DESCENDING
514
691
  else
515
692
  Format::BoundaryOrder::UNORDERED
@@ -517,6 +694,8 @@ module Herringbone
517
694
  end
518
695
 
519
696
  # Page indexes go after the last row group: all column indexes, then all offset indexes
697
+ #
698
+ # @return [void]
520
699
  def write_page_indexes
521
700
  @page_indexes.each do |chunk, column_index, _|
522
701
  next unless column_index
@@ -534,17 +713,25 @@ module Herringbone
534
713
  @page_indexes.clear
535
714
  end
536
715
 
716
+ # Per-column settings accepted in the +bloom_filters:+ option
537
717
  BLOOM_FILTER_OPTIONS = %i[ndv fpp max_bytes].freeze
538
718
 
539
719
  # { dotted_path => { ndv:, fpp:, max_bytes: } } from the bloom_filters: option
540
- def bloom_filter_config(option)
541
- return {} if option.nil? || option == false
720
+ #
721
+ # @param requested [Boolean, Array<String>, Hash{String => Boolean, Hash, nil}, nil] true for every
722
+ # column of a supported type, column paths, or column path => true / false / settings Hash.
723
+ # A path may also be given as an Array of names. Settings are +ndv+, +fpp+ and +max_bytes+.
724
+ # @return [Hash{String => Hash{Symbol => Numeric, nil}}] settings by dotted path; empty when disabled
725
+ # @raise [ArgumentError] for an unknown column, a column type without bloom filter support, or
726
+ # invalid settings
727
+ def bloom_filter_config(requested)
728
+ return {} if requested.nil? || requested == false
542
729
  columns = @schema.columns.to_h { |c| [c.dotted_path, c] }
543
- if option == true
730
+ if requested == true
544
731
  return columns.select { |_, c| BloomFilter::TYPES.include?(c.type) }.transform_values { bloom_filter_settings({}) }
545
732
  end
546
- option = Array(option).to_h { |path| [path, true] } unless option.is_a?(Hash)
547
- option.each_with_object({}) do |(path, settings), config|
733
+ requested = Array(requested).to_h { |path| [path, true] } unless requested.is_a?(Hash)
734
+ requested.each_with_object({}) do |(path, settings), config|
548
735
  path = path.is_a?(Array) ? path.join(".") : path.to_s
549
736
  col = columns[path] or raise ArgumentError, "bloom_filters: no such column #{path}"
550
737
  next if settings.nil? || settings == false
@@ -557,6 +744,11 @@ module Herringbone
557
744
  end
558
745
  end
559
746
 
747
+ # @param settings [Hash{Symbol => Object}] +ndv+, +fpp+ and +max_bytes+, all optional
748
+ # @param path [String, nil] column path, for error messages
749
+ # @return [Hash{Symbol => Numeric, nil}] +ndv+ (nil to count distinct values), +fpp+ and
750
+ # +max_bytes+ with defaults filled in
751
+ # @raise [ArgumentError] for unknown keys, a non-positive ndv, or an fpp outside (0, 1)
560
752
  def bloom_filter_settings(settings, path = nil)
561
753
  unknown = settings.keys - BLOOM_FILTER_OPTIONS
562
754
  raise ArgumentError, "bloom_filters: unknown option #{unknown.join(", ")} for #{path}" unless unknown.empty?
@@ -564,12 +756,18 @@ module Herringbone
564
756
  raise ArgumentError, "bloom_filters: ndv must be positive for #{path}" if ndv && !ndv.positive?
565
757
  fpp = Float(settings.fetch(:fpp, BloomFilter::DEFAULT_FPP))
566
758
  raise ArgumentError, "bloom_filters: fpp must be between 0 and 1 for #{path}" unless fpp > 0 && fpp < 1
567
- { ndv: ndv, fpp: fpp, max_bytes: Integer(settings.fetch(:max_bytes, BloomFilter::DEFAULT_MAX_BYTES)) }
759
+ {ndv: ndv, fpp: fpp, max_bytes: Integer(settings.fetch(:max_bytes, BloomFilter::DEFAULT_MAX_BYTES))}
568
760
  end
569
761
 
570
762
  # A filter holding the chunk's values: +dict_values+ (already distinct) for dictionary-encoded
571
763
  # chunks, +values+ otherwise. Each distinct value is hashed once, and the filter is sized from
572
764
  # the configured ndv or from the number of distinct values.
765
+ #
766
+ # @param col [Schema::Column] column the filter is for
767
+ # @param settings [Hash{Symbol => Numeric, nil}] from #bloom_filter_settings
768
+ # @param dict_values [Array, nil] dictionary of the chunk, when dictionary-encoded
769
+ # @param values [Array, nil] the chunk's non-null physical values, used without a dictionary
770
+ # @return [BloomFilter]
573
771
  def build_bloom_filter(col, settings, dict_values, values)
574
772
  hashes = if dict_values
575
773
  BloomFilter.hash_physical_all(dict_values, col.type)
@@ -583,6 +781,8 @@ module Herringbone
583
781
  end
584
782
 
585
783
  # Bloom filters go right after the row group's column chunks, in column order
784
+ #
785
+ # @return [void]
586
786
  def write_bloom_filters
587
787
  @pending_bloom_filters.each do |meta, filter|
588
788
  bytes = filter.encode
@@ -593,6 +793,9 @@ module Herringbone
593
793
  @pending_bloom_filters.clear
594
794
  end
595
795
 
796
+ # @param col [Schema::Column] column to check
797
+ # @return [Boolean] whether the +dictionary:+ option asks for this column to be dictionary-encoded
798
+ # (never for BOOLEAN)
596
799
  def use_dictionary?(col)
597
800
  return false if col.type == T::BOOLEAN
598
801
  case @dictionary
@@ -602,13 +805,20 @@ module Herringbone
602
805
  end
603
806
  end
604
807
 
605
- # Returns [dictionary_values, indices] or nil when a dictionary is not worthwhile
808
+ # Returns [dictionary_values, indices] or nil when a dictionary is not worthwhile: more than about
809
+ # half the values are distinct, or the dictionary exceeds MAX_DICTIONARY_BYTES
810
+ #
811
+ # @param values [Array] non-null physical values of the chunk
812
+ # @param type [Integer] physical type
813
+ # @param type_length [Integer, nil] FIXED_LEN_BYTE_ARRAY width
814
+ # @return [Array(Array, Array<Integer>), nil]
606
815
  def build_dictionary(values, type, type_length)
607
816
  # Floats are keyed by bit pattern so that -0.0 and 0.0 (and NaNs) stay distinct
608
817
  if type == T::FLOAT || type == T::DOUBLE
609
818
  keys = values.pack("G*").unpack("Q>*")
610
819
  uniq = keys.uniq
611
820
  return nil if uniq.size > values.size / 2 + 1 && values.size > 16
821
+ return nil if uniq.size * 8 > MAX_DICTIONARY_BYTES
612
822
  index = uniq.each_with_index.to_h
613
823
  return [uniq.pack("Q>*").unpack("G*"), keys.map(&index)]
614
824
  end
@@ -626,11 +836,15 @@ module Herringbone
626
836
 
627
837
  # Splits a column buffer into pages of roughly @page_bytes bytes. Repeated columns
628
838
  # are only cut where a new row starts.
839
+ #
840
+ # @param buf [ColumnBuffer] column buffer with levels as Arrays
841
+ # @param value_bytes [Integer] estimated encoded size of all the chunk's values
842
+ # @return [Array<Array(Integer, Integer)>] [from, to) ranges of level entries, one per page
629
843
  def page_ranges(buf, value_bytes)
630
844
  n = buf.defs.size
631
845
  bytes = n + value_bytes
632
846
  pages = (bytes + @page_bytes - 1) / @page_bytes
633
- per = pages <= 1 ? n : (n + pages - 1) / pages
847
+ per = (pages <= 1) ? n : (n + pages - 1) / pages
634
848
  per = @page_rows if per > @page_rows
635
849
  return [[0, n]] if per >= n
636
850
  reps = buf.reps
@@ -646,6 +860,8 @@ module Herringbone
646
860
  ranges
647
861
  end
648
862
 
863
+ # @param col [Schema::Column] column to check
864
+ # @return [Integer, nil] PLAIN-encoded bytes per value, nil for BYTE_ARRAY (variable width)
649
865
  def value_width(col)
650
866
  case col.type
651
867
  when T::BOOLEAN then 1
@@ -656,6 +872,15 @@ module Herringbone
656
872
  end
657
873
  end
658
874
 
875
+ # Encodes a page's values. RLE_DICTIONARY values are the dictionary indices, prefixed with the
876
+ # bit width byte; RLE is only used for BOOLEAN and carries the 4-byte length prefix.
877
+ #
878
+ # @param values [Array] physical values, or dictionary indices
879
+ # @param encoding [Integer] encoding id
880
+ # @param type [Integer] physical type
881
+ # @param type_length [Integer, nil] FIXED_LEN_BYTE_ARRAY width
882
+ # @param dict_size [Integer, nil] number of dictionary entries, for RLE_DICTIONARY
883
+ # @return [String] encoded binary page values
659
884
  def encode_values(values, encoding, type, type_length, dict_size)
660
885
  case encoding
661
886
  when E::PLAIN then Encodings::Plain.encode(values, type, type_length)
@@ -666,27 +891,41 @@ module Herringbone
666
891
  body = Encodings::RLE.encode_hybrid(values.map { |v| v ? 1 : 0 }, 1)
667
892
  [body.bytesize].pack("V") << body
668
893
  when E::DELTA_BINARY_PACKED
669
- Encodings::Delta.encode_binary_packed(values, type == T::INT32 ? 32 : 64)
894
+ Encodings::Delta.encode_binary_packed(values, (type == T::INT32) ? 32 : 64)
670
895
  when E::DELTA_LENGTH_BYTE_ARRAY then Encodings::Delta.encode_length_byte_array(values)
671
896
  when E::DELTA_BYTE_ARRAY then Encodings::Delta.encode_byte_array(values)
672
897
  when E::BYTE_STREAM_SPLIT
673
- width = { T::INT32 => 4, T::FLOAT => 4, T::INT64 => 8, T::DOUBLE => 8 }[type] || type_length
898
+ width = {T::INT32 => 4, T::FLOAT => 4, T::INT64 => 8, T::DOUBLE => 8}[type] || type_length
674
899
  Encodings::ByteStreamSplit.encode(Encodings::Plain.encode(values, type, type_length), width)
675
900
  end
676
901
  end
677
902
 
678
903
  # Writes a page, returning the uncompressed size including the header
904
+ #
905
+ # @param header [Format::PageHeader] header; sizes and CRC32 are filled in here
906
+ # @param body [String] uncompressed page body
907
+ # @param compressed [String, nil] bytes to write as the page body, when already prepared (v2 data
908
+ # pages, whose levels stay uncompressed); +body+ is compressed otherwise
909
+ # @return [Integer]
679
910
  def write_page(header, body, compressed = nil)
680
911
  compressed ||= Compression.compress(@codec, body)
681
912
  header.uncompressed_page_size ||= body.bytesize
682
913
  header.compressed_page_size = compressed.bytesize
683
- header.crc = Zlib.crc32(compressed).then { |c| c >= 0x8000_0000 ? c - 0x1_0000_0000 : c }
914
+ header.crc = Zlib.crc32(compressed).then { |c| (c >= 0x8000_0000) ? c - 0x1_0000_0000 : c }
684
915
  encoded = header.encode
685
916
  write_raw(encoded)
686
917
  write_raw(compressed)
687
918
  encoded.bytesize + header.uncompressed_page_size
688
919
  end
689
920
 
921
+ # A DATA_PAGE: length-prefixed repetition and definition levels, then the values, all compressed
922
+ #
923
+ # @param n [Integer] number of level entries (values including nulls)
924
+ # @param rep_bytes [String] RLE-encoded repetition levels, empty when the column has none
925
+ # @param def_bytes [String] RLE-encoded definition levels, empty when the column has none
926
+ # @param encoded [String] encoded values
927
+ # @param encoding [Integer] encoding id of the values
928
+ # @return [Integer] uncompressed size including the header
690
929
  def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding)
691
930
  body = String.new(encoding: Encoding::BINARY)
692
931
  body << [rep_bytes.bytesize].pack("V") << rep_bytes unless rep_bytes.empty?
@@ -702,6 +941,16 @@ module Herringbone
702
941
  write_page(header, body)
703
942
  end
704
943
 
944
+ # A DATA_PAGE_V2: levels without length prefixes and uncompressed, then the compressed values
945
+ #
946
+ # @param n [Integer] number of level entries (values including nulls)
947
+ # @param nulls [Integer] null entries
948
+ # @param rows [Integer] rows in the page
949
+ # @param rep_bytes [String] RLE-encoded repetition levels, empty when the column has none
950
+ # @param def_bytes [String] RLE-encoded definition levels, empty when the column has none
951
+ # @param encoded [String] encoded values
952
+ # @param encoding [Integer] encoding id of the values
953
+ # @return [Integer] uncompressed size including the header
705
954
  def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding)
706
955
  compressed = Compression.compress(@codec, encoded)
707
956
  header = Format::PageHeader.new(
@@ -717,9 +966,20 @@ module Herringbone
717
966
  write_page(header, "".b, rep_bytes + def_bytes + compressed)
718
967
  end
719
968
 
969
+ # Statistics and column index bounds longer than this are truncated, see #truncate_min
720
970
  STAT_TRUNCATE_BYTES = 64
721
- IDENTITY = ->(v) { v }
971
+ # Sort key for types whose Ruby ordering already matches Parquet's; compared by identity to
972
+ # skip calling it
973
+ IDENTITY = Ractor.make_shareable(->(v) { v })
722
974
 
975
+ # Chunk statistics: null count, plus min/max (flagged exact unless truncated) when the column
976
+ # has a sort order and non-NaN values
977
+ #
978
+ # @param col [Schema::Column] column the chunk belongs to
979
+ # @param defs [Array<Integer>] definition levels of the chunk
980
+ # @param values [Array] distinct or all non-null physical values of the chunk
981
+ # @param order [Proc, Method, nil] sort key from #sort_key
982
+ # @return [Format::Statistics]
723
983
  def statistics_for(col, defs, values, order)
724
984
  max_def = col.max_definition_level
725
985
  nulls = max_def.zero? ? 0 : defs.size - defs.count(max_def)
@@ -738,13 +998,16 @@ module Herringbone
738
998
 
739
999
  # A key giving the Parquet sort order of the column's physical values (IDENTITY when Ruby's own
740
1000
  # comparison already matches), or nil when the order is undefined (INT96)
1001
+ #
1002
+ # @param col [Schema::Column] column to order
1003
+ # @return [Proc, Method, nil]
741
1004
  def sort_key(col)
742
1005
  kind, _, signed = Types.logical_of(col.node)
743
1006
  case col.type
744
1007
  when T::BOOLEAN then ->(v) { v ? 1 : 0 }
745
1008
  when T::INT32, T::INT64
746
1009
  return IDENTITY unless kind == :integer && !signed
747
- mask = col.type == T::INT32 ? 0xFFFF_FFFF : 0xFFFF_FFFF_FFFF_FFFF
1010
+ mask = (col.type == T::INT32) ? 0xFFFF_FFFF : 0xFFFF_FFFF_FFFF_FFFF
748
1011
  ->(v) { v & mask }
749
1012
  when T::FLOAT, T::DOUBLE then IDENTITY
750
1013
  when T::BYTE_ARRAY, T::FIXED_LEN_BYTE_ARRAY
@@ -756,7 +1019,13 @@ module Herringbone
756
1019
  end
757
1020
  end
758
1021
 
759
- # [min, max] of +values+ in column order, ignoring NaNs; nil if there is nothing to compare
1022
+ # [min, max] of +values+ in column order, ignoring NaNs; nil if there is nothing to compare.
1023
+ # A zero float bound is normalized to -0.0 (min) / 0.0 (max), as the spec asks.
1024
+ #
1025
+ # @param col [Schema::Column] column the values belong to
1026
+ # @param values [Array] physical values
1027
+ # @param order [Proc, Method] sort key from #sort_key
1028
+ # @return [Array(Object, Object), nil]
760
1029
  def value_range(col, values, order)
761
1030
  floats = col.type == T::FLOAT || col.type == T::DOUBLE
762
1031
  if floats
@@ -773,6 +1042,9 @@ module Herringbone
773
1042
  [min, max]
774
1043
  end
775
1044
 
1045
+ # @param col [Schema::Column] column the value belongs to
1046
+ # @param value [Object] physical value
1047
+ # @return [String] the value PLAIN-encoded, as statistics store it (byte arrays without length)
776
1048
  def stat_bytes(col, value)
777
1049
  case col.type
778
1050
  when T::BOOLEAN then value ? "\x01".b : "\x00".b
@@ -786,10 +1058,17 @@ module Herringbone
786
1058
 
787
1059
  # Long byte-array bounds are truncated: a prefix is still a lower bound for the minimum, and
788
1060
  # a prefix with its last byte incremented is an upper bound for the maximum
1061
+ #
1062
+ # @param bytes [String] encoded minimum
1063
+ # @return [String] at most STAT_TRUNCATE_BYTES bytes
789
1064
  def truncate_min(bytes)
790
- bytes.bytesize > STAT_TRUNCATE_BYTES ? bytes.byteslice(0, STAT_TRUNCATE_BYTES) : bytes
1065
+ (bytes.bytesize > STAT_TRUNCATE_BYTES) ? bytes.byteslice(0, STAT_TRUNCATE_BYTES) : bytes
791
1066
  end
792
1067
 
1068
+ # @param bytes [String] encoded maximum
1069
+ # @return [String] at most STAT_TRUNCATE_BYTES bytes, with the last byte that is not 0xFF
1070
+ # incremented (trailing 0xFF bytes dropped); +bytes+ unchanged when it fits, or when the
1071
+ # whole prefix is 0xFF
793
1072
  def truncate_max(bytes)
794
1073
  return bytes if bytes.bytesize <= STAT_TRUNCATE_BYTES
795
1074
  prefix = bytes.byteslice(0, STAT_TRUNCATE_BYTES).bytes