herringbone 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.4.0"
6
6
  end
@@ -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: {},
@@ -189,20 +189,20 @@ module Herringbone
189
189
  if buf.nil?
190
190
  shred(field, value, 0, 0)
191
191
  elsif value.nil?
192
- 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
193
193
  buf.defs << 0
194
194
  else
195
195
  begin
196
196
  buf.values << encoder.call(value)
197
197
  rescue ArgumentError, TypeError, NoMethodError, RangeError => e
198
- 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)
199
199
  end
200
200
  buf.defs << field.def_level
201
201
  end
202
202
  end
203
203
  rescue EncodeError => e
204
204
  rollback_row(marks)
205
- raise EncodeError, "Row #{@total_rows + @buffered_rows}: #{e.message}"
205
+ raise EncodeError.new("Row #{@total_rows + @buffered_rows}: #{e.message}", row: @total_rows + @buffered_rows, column: e.column, value: e.value)
206
206
  rescue
207
207
  rollback_row(marks)
208
208
  raise
@@ -314,10 +314,12 @@ module Herringbone
314
314
  col.max_repetition_level.positive? ? String.new(encoding: Encoding::BINARY) : nil,
315
315
  new_values_store(col))
316
316
  end
317
+ # Column#encoder builds its Proc on every call
318
+ @encoders = @schema.columns.map(&:encoder)
317
319
  # Top-level, non-repeated leaves are written directly, everything else is shredded
318
320
  @plan = @schema.fields.map do |field|
319
321
  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]
322
+ [field, field.name, field.name.to_sym, flat ? @buffers[field.column.index] : nil, flat ? @encoders[field.column.index] : nil]
321
323
  end
322
324
  @nested_buffers = @plan.reject { |p| p[3] }.flat_map { |p| p[0].leaves.map { |c| @buffers[c.index] } }
323
325
  @buffered_rows = 0
@@ -472,7 +474,7 @@ module Herringbone
472
474
  # map key is nil, or a leaf value cannot be encoded
473
475
  def shred(field, value, parent_def, rep)
474
476
  if value.nil?
475
- 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
476
478
  field.leaves.each do |col|
477
479
  buf = @buffers[col.index]
478
480
  buf.defs << parent_def
@@ -488,14 +490,14 @@ module Herringbone
488
490
  buf.defs << d
489
491
  buf.reps&.<< rep
490
492
  begin
491
- buf.values << field.column.encoder.call(value)
493
+ buf.values << @encoders[field.column.index].call(value)
492
494
  rescue ArgumentError, TypeError, NoMethodError, RangeError => e
493
- 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)
494
496
  end
495
497
  when :struct
496
498
  field.children.each { |ch| shred(ch, lookup(value, ch.name), d, rep) }
497
499
  when :list
498
- 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)
499
501
  if value.empty?
500
502
  field.leaves.each do |col|
501
503
  buf = @buffers[col.index]
@@ -508,7 +510,7 @@ module Herringbone
508
510
  end
509
511
  end
510
512
  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)
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)
512
514
  if value.empty?
513
515
  field.leaves.each do |col|
514
516
  buf = @buffers[col.index]
@@ -968,7 +970,7 @@ module Herringbone
968
970
  STAT_TRUNCATE_BYTES = 64
969
971
  # Sort key for types whose Ruby ordering already matches Parquet's; compared by identity to
970
972
  # skip calling it
971
- IDENTITY = ->(v) { v }
973
+ IDENTITY = Ractor.make_shareable(->(v) { v })
972
974
 
973
975
  # Chunk statistics: null count, plus min/max (flagged exact unless truncated) when the column
974
976
  # has a sort order and non-NaN values
@@ -3,9 +3,9 @@
3
3
  module Herringbone
4
4
  # XXH64 (seed 0), the hash Parquet bloom filters use.
5
5
  #
6
- # When the optional "xxhash" gem (a C extension) can be loaded it is used, which is 20-40x
7
- # faster; otherwise hashing is pure Ruby. The gem is only a speedup, so nothing fails without
8
- # it. +XXHash.backend = :ruby+ forces pure Ruby (for tests and benchmarks).
6
+ # When the optional "xxhash" gem (a C extension) is loaded (+require "xxhash"+) it is used,
7
+ # which is 20-40x faster; otherwise hashing is pure Ruby. The gem is only a speedup, so nothing
8
+ # fails without it. +XXHash.backend = :ruby+ forces pure Ruby (for tests and benchmarks).
9
9
  #
10
10
  # The pure-Ruby version keeps every 64-bit value as two 32-bit halves, and multiplies by the
11
11
  # XXH64 primes split into 16-bit pieces, so that no intermediate result leaves the Fixnum range.
@@ -286,8 +286,7 @@ module Herringbone
286
286
 
287
287
  module_eval(Generator.source, __FILE__, __LINE__)
288
288
 
289
- @native = nil # nil: not resolved yet, false: pure Ruby, else the native module
290
- @native_lib = nil
289
+ @native = nil # nil: native when loaded, false: pure Ruby, else the native module
291
290
 
292
291
  class << self
293
292
  # XXH64 of a String's bytes, as an unsigned 64-bit Integer
@@ -296,7 +295,7 @@ module Herringbone
296
295
  # @return [Integer] the hash, 0...2^64
297
296
  def xxh64(bytes)
298
297
  native = @native
299
- native = resolve_backend if native.nil?
298
+ native = native_library if native.nil?
300
299
  native ? native.xxh64(bytes, 0) : ruby_xxh64(bytes)
301
300
  end
302
301
 
@@ -307,7 +306,7 @@ module Herringbone
307
306
  # @return [Integer] the hash, 0...2^64
308
307
  def xxh64_u64(lane)
309
308
  native = @native
310
- native = resolve_backend if native.nil?
309
+ native = native_library if native.nil?
311
310
  return native.xxh64([lane].pack("Q<"), 0) if native
312
311
  ruby_xxh64_lane((lane >> 32) & M32, lane & M32)
313
312
  end
@@ -318,7 +317,7 @@ module Herringbone
318
317
  # @return [Integer] the hash, 0...2^64
319
318
  def xxh64_u32(word)
320
319
  native = @native
321
- native = resolve_backend if native.nil?
320
+ native = native_library if native.nil?
322
321
  return native.xxh64([word].pack("L<"), 0) if native
323
322
  ruby_xxh64_u32(word & M32)
324
323
  end
@@ -329,7 +328,7 @@ module Herringbone
329
328
  # @return [Array<Integer>] the hashes, in the same order
330
329
  def xxh64_u64_all(lanes)
331
330
  native = @native
332
- native = resolve_backend if native.nil?
331
+ native = native_library if native.nil?
333
332
  if native
334
333
  packed = lanes.pack("Q<*")
335
334
  Array.new(lanes.size) { |i| native.xxh64(packed.byteslice(i << 3, 8), 0) }
@@ -344,7 +343,7 @@ module Herringbone
344
343
  # @return [Array<Integer>] the hashes, in the same order
345
344
  def xxh64_u32_all(words)
346
345
  native = @native
347
- native = resolve_backend if native.nil?
346
+ native = native_library if native.nil?
348
347
  if native
349
348
  packed = words.pack("L<*")
350
349
  Array.new(words.size) { |i| native.xxh64(packed.byteslice(i << 2, 4), 0) }
@@ -359,7 +358,7 @@ module Herringbone
359
358
  # @return [Array<Integer>] the hashes, in the same order
360
359
  def xxh64_all(strings)
361
360
  native = @native
362
- native = resolve_backend if native.nil?
361
+ native = native_library if native.nil?
363
362
  native ? strings.map { |s| native.xxh64(s, 0) } : strings.map { |s| ruby_xxh64(s) }
364
363
  end
365
364
 
@@ -368,57 +367,43 @@ module Herringbone
368
367
  # @return [Symbol] +:native+ or +:ruby+
369
368
  def backend
370
369
  native = @native
371
- native = resolve_backend if native.nil?
370
+ native = native_library if native.nil?
372
371
  native ? :native : :ruby
373
372
  end
374
373
 
375
- # :ruby forces pure Ruby, :native requires the xxhash gem (UnsupportedError if it cannot be
376
- # loaded), nil goes back to the default: native when available
374
+ # :ruby forces pure Ruby, :native the xxhash gem (UnsupportedError if it is not loaded), nil
375
+ # goes back to the default: native when loaded
377
376
  #
378
377
  # @param name [Symbol, nil] +:ruby+, +:native+ or nil
379
378
  # @return [void]
380
- # @raise [UnsupportedError] for +:native+ when the gem cannot be loaded
379
+ # @raise [UnsupportedError] for +:native+ when the gem is not loaded
381
380
  # @raise [ArgumentError] for any other name
382
381
  def backend=(name)
383
382
  @native = case name
384
383
  when :ruby then false
385
384
  when :native
386
- native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem could not be loaded")
385
+ native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem is not loaded (require \"#{NATIVE_GEM}\")")
387
386
  when nil then nil
388
387
  else raise ArgumentError, "Unknown XXHash backend #{name.inspect} (expected :ruby, :native or nil)"
389
388
  end
390
389
  end
391
390
 
392
- # Whether the native xxhash gem can be loaded (whatever the selected backend)
391
+ # Whether the native xxhash gem is loaded (whatever the selected backend)
393
392
  #
394
- # @return [Boolean] true when the gem is loadable
393
+ # @return [Boolean] true when the gem is loaded
395
394
  def native_available?
396
395
  !!native_library
397
396
  end
398
397
 
399
398
  private
400
399
 
401
- # Picks the default backend on first use: native when the gem loads, pure Ruby otherwise
402
- #
403
- # @return [Module, false] the native module, or false for pure Ruby
404
- def resolve_backend
405
- @native = native_library || false
406
- end
407
-
408
- # Requires the xxhash gem once and memoizes the result (a failed require is not retried)
400
+ # Not memoized, so that it works the same in every Ractor and once the gem is required later
409
401
  #
410
402
  # @return [Module, false] the gem's module to call +xxh64(data, seed)+ on, or false when it
411
- # cannot be loaded
403
+ # is not loaded
412
404
  def native_library
413
- if @native_lib.nil?
414
- @native_lib = begin
415
- require NATIVE_GEM
416
- defined?(::XXhash::XXhashInternal) ? ::XXhash::XXhashInternal : ::XXhash
417
- rescue LoadError
418
- false
419
- end
420
- end
421
- @native_lib
405
+ return ::XXhash::XXhashInternal if defined?(::XXhash::XXhashInternal)
406
+ defined?(::XXhash.xxh64) ? ::XXhash : false
422
407
  end
423
408
  end
424
409
  end
data/lib/herringbone.rb CHANGED
@@ -10,8 +10,33 @@ module Herringbone
10
10
  class Error < StandardError; end
11
11
  # The file is not valid Parquet (bad metadata, corrupt pages...)
12
12
  class FormatError < Error; end
13
+
13
14
  # A value cannot be written to its column
14
- class EncodeError < Error; end
15
+ class EncodeError < Error
16
+ # @return [Integer, nil] index of the row being written (0 for the first), when known
17
+ attr_reader :row
18
+ # @return [String, nil] dotted path of the column the value was meant for, when known
19
+ attr_reader :column
20
+ # @return [Object] the value that could not be written (nil when it was a missing required value)
21
+ attr_reader :value
22
+
23
+ # @param message [String, nil] the error message
24
+ # @param row [Integer, nil] index of the row being written
25
+ # @param column [String, nil] dotted path of the column
26
+ # @param value [Object] the value that could not be written
27
+ def initialize(message = nil, row: nil, column: nil, value: nil)
28
+ super(message)
29
+ @row = row
30
+ @column = column
31
+ @value = value
32
+ end
33
+ end
34
+
35
+ # A row does not fit a schema that was inferred from earlier rows (Herringbone.write without
36
+ # +schema:+, SimpleWriter). The message says what was inferred, from how many rows, and how to
37
+ # declare the column instead.
38
+ class SchemaMismatch < EncodeError; end
39
+
15
40
  # The file (or the writer configuration) uses a Parquet feature Herringbone does not implement,
16
41
  # such as encryption or a codec whose library is unavailable
17
42
  class UnsupportedError < Error; end
@@ -20,6 +45,7 @@ end
20
45
  require_relative "herringbone/io_buffer_support"
21
46
  require_relative "herringbone/codecs/snappy"
22
47
  require_relative "herringbone/codecs/lz4"
48
+ require_relative "herringbone/codecs/lzo"
23
49
  require_relative "herringbone/thrift"
24
50
  require_relative "herringbone/format"
25
51
  require_relative "herringbone/encodings/rle"
@@ -32,6 +58,8 @@ require_relative "herringbone/active_record"
32
58
  require_relative "herringbone/reader"
33
59
  require_relative "herringbone/byte_values"
34
60
  require_relative "herringbone/writer"
61
+ require_relative "herringbone/inferring_writer"
62
+ require_relative "herringbone/simple_writer"
35
63
  require_relative "herringbone/xxhash"
36
64
  require_relative "herringbone/bloom_filter"
37
65
  require_relative "herringbone/inspector"
@@ -43,10 +71,13 @@ module Herringbone
43
71
  # Writes +records+ to +io+ (any IO responding to #write; Herringbone never opens files by path)
44
72
  # and returns the number of rows written. +records+ is an Enumerable of rows, or an ActiveRecord
45
73
  # model or relation, which is read with find_each. Without +schema+, the schema comes from the
46
- # model's columns (Schema.from_active_record) or is inferred from the first rows (Schema.infer).
47
- # Other options go to Writer.
74
+ # model's columns (Schema.from_active_record) or is inferred from the first rows (Schema.infer);
75
+ # fields declared in the block replace inferred ones. +records+ is iterated once, so a source
76
+ # that can only be read once (a cursor, a lazy Enumerator over an IO) works too. Other options go
77
+ # to Writer.
48
78
  #
49
79
  # File.open("orders.parquet", "wb") { |f| Herringbone.write(f, Order.where(created_at: 1.year.ago..)) }
80
+ # Herringbone.write(io, events.lazy.map(&:to_h)) { json :payload }
50
81
  #
51
82
  # @param io [IO, #write] destination; written sequentially, never closed
52
83
  # @param records [Enumerable<Hash, Array, Object>, Class, #find_each] rows (Hashes, Arrays in schema
@@ -66,27 +97,40 @@ module Herringbone
66
97
  # @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
67
98
  # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
68
99
  # columns to write split block bloom filters for, see Writer
100
+ # @yield optional block for Schema.infer (Builder DSL), declaring fields that replace inferred ones;
101
+ # ignored when the schema is not inferred
69
102
  # @return [Integer] number of rows written
70
103
  # @raise [ArgumentError] when the schema has to be inferred and +records+ is empty or holds Array
71
104
  # rows, or an option is invalid
72
105
  # @raise [EncodeError] when a row does not fit the schema
73
- def write(io, records, schema: nil, **options)
106
+ # @raise [SchemaMismatch] when a row does not fit the inferred schema; the file is left unfinished
107
+ def write(io, records, schema: nil, **options, &overrides)
74
108
  model = if records.respond_to?(:klass) then records.klass
75
109
  elsif records.respond_to?(:columns) && records.respond_to?(:find_each) then records
76
110
  end
77
- schema ||= model ? Schema.from_active_record(model) : Schema.infer(records)
78
- Writer.open(io, schema, **options) do |writer|
111
+ schema ||= Schema.from_active_record(model) if model
112
+ writer = if schema
113
+ Writer.new(io, schema, **options)
114
+ else
115
+ fix = "Herringbone.write(io, rows) { %s }"
116
+ InferringWriter.new(io, fix: fix, **options) { |sample| Schema.infer(sample, &overrides) }
117
+ end
118
+ begin
79
119
  if records.respond_to?(:find_each)
80
120
  records.find_each { |record| writer << record }
81
121
  else
82
122
  records.each { |record| writer << record }
83
123
  end
84
- writer.rows_written
124
+ rescue Exception # rubocop:disable Lint/RescueException -- also abort on Interrupt
125
+ writer.abort
126
+ raise
85
127
  end
128
+ writer.close
129
+ writer.rows_written
86
130
  end
87
131
 
88
132
  # Compression codecs this process can read and write, e.g. [:none, :snappy, :gzip, :lz4, :lz4_hadoop, :zstd].
89
- # :zstd and :brotli are listed when the zstd-ruby / brotli gems can be loaded.
133
+ # :zstd and :brotli are listed when the zstd-ruby / brotli gems are loaded.
90
134
  #
91
135
  # @return [Array<Symbol>] codec names accepted by the writer's +compression:+ option
92
136
  def codecs
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: herringbone
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-01 00:00:00.000000000 Z
10
+ date: 2026-10-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -33,6 +33,7 @@ executables:
33
33
  extensions: []
34
34
  extra_rdoc_files: []
35
35
  files:
36
+ - CHANGELOG.md
36
37
  - LICENSE
37
38
  - README.md
38
39
  - bin/herringbone
@@ -41,12 +42,14 @@ files:
41
42
  - lib/herringbone/bloom_filter.rb
42
43
  - lib/herringbone/byte_values.rb
43
44
  - lib/herringbone/codecs/lz4.rb
45
+ - lib/herringbone/codecs/lzo.rb
44
46
  - lib/herringbone/codecs/snappy.rb
45
47
  - lib/herringbone/compression.rb
46
48
  - lib/herringbone/encodings/delta.rb
47
49
  - lib/herringbone/encodings/plain.rb
48
50
  - lib/herringbone/encodings/rle.rb
49
51
  - lib/herringbone/format.rb
52
+ - lib/herringbone/inferring_writer.rb
50
53
  - lib/herringbone/inspector.rb
51
54
  - lib/herringbone/io_buffer_support.rb
52
55
  - lib/herringbone/reader.rb
@@ -56,6 +59,7 @@ files:
56
59
  - lib/herringbone/reader/page_stream.rb
57
60
  - lib/herringbone/reader/scan.rb
58
61
  - lib/herringbone/schema.rb
62
+ - lib/herringbone/simple_writer.rb
59
63
  - lib/herringbone/thrift.rb
60
64
  - lib/herringbone/types.rb
61
65
  - lib/herringbone/version.rb