herringbone 0.6.0 → 0.7.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/MANUAL.md +161 -12
  4. data/README.md +19 -4
  5. data/bin/herringbone +56 -1
  6. data/lib/herringbone/active_record.rb +2 -0
  7. data/lib/herringbone/bloom_filter.rb +2 -0
  8. data/lib/herringbone/byte_values.rb +2 -0
  9. data/lib/herringbone/codecs/lz4.rb +9 -4
  10. data/lib/herringbone/codecs/lzo.rb +2 -0
  11. data/lib/herringbone/codecs/snappy.rb +48 -6
  12. data/lib/herringbone/combiner.rb +426 -0
  13. data/lib/herringbone/compression.rb +53 -9
  14. data/lib/herringbone/encodings/delta.rb +4 -0
  15. data/lib/herringbone/encodings/plain.rb +2 -0
  16. data/lib/herringbone/encodings/rle.rb +2 -0
  17. data/lib/herringbone/encryption.rb +2 -0
  18. data/lib/herringbone/inspector.rb +29 -5
  19. data/lib/herringbone/io_buffer_support.rb +2 -0
  20. data/lib/herringbone/reader/bloom_filters.rb +1 -0
  21. data/lib/herringbone/reader/chunk_copier.rb +177 -0
  22. data/lib/herringbone/reader/column_chunk_reader.rb +4 -2
  23. data/lib/herringbone/reader/column_cursor.rb +2 -0
  24. data/lib/herringbone/reader/numo.rb +4 -0
  25. data/lib/herringbone/reader/page_stream.rb +2 -0
  26. data/lib/herringbone/reader/scan.rb +2 -0
  27. data/lib/herringbone/reader.rb +19 -6
  28. data/lib/herringbone/redaction/rewriter.rb +51 -157
  29. data/lib/herringbone/redaction.rb +25 -15
  30. data/lib/herringbone/restricted_readable_io.rb +85 -0
  31. data/lib/herringbone/schema/merge.rb +355 -0
  32. data/lib/herringbone/schema.rb +80 -0
  33. data/lib/herringbone/thrift.rb +166 -68
  34. data/lib/herringbone/types.rb +2 -0
  35. data/lib/herringbone/version.rb +1 -1
  36. data/lib/herringbone/visualizer.rb +2 -0
  37. data/lib/herringbone/writer.rb +58 -22
  38. data/lib/herringbone/xxhash.rb +2 -0
  39. data/lib/herringbone.rb +96 -9
  40. metadata +6 -2
data/lib/herringbone.rb CHANGED
@@ -40,6 +40,34 @@ module Herringbone
40
40
  # such as a codec whose library is unavailable
41
41
  class UnsupportedError < Error; end
42
42
 
43
+ # Schemas that do not fit together: two schemas that cannot be united or intersected
44
+ # (Schema#union, Schema#intersect), or inputs of Herringbone.combine that do not fit the output
45
+ # schema or each other. The message lists every field that does not fit, one per line, e.g.
46
+ #
47
+ # Cannot unite the schemas, 2 fields do not fit:
48
+ # price: int64 vs double (int64 does not fit a double exactly)
49
+ # address.zip: int32 vs string (no common type)
50
+ class IncompatibleSchema < Error
51
+ # A field that does not fit: its dotted path, how each side declares it, why the two do not
52
+ # fit, and for Herringbone.combine the position of the input it was found in
53
+ Conflict = Struct.new(:path, :left, :right, :reason, :input) do
54
+ # @return [String] e.g. "address.zip: int32 vs string (no common type)"
55
+ def to_s = "#{path.empty? ? "(top level)" : path}: #{left} vs #{right} (#{reason})"
56
+ end
57
+
58
+ # @return [Array<Conflict>] every field that does not fit, in schema (and input) order
59
+ attr_reader :conflicts
60
+
61
+ # @param conflicts [Array<Conflict>] the fields that do not fit
62
+ # @param operation [String, nil] what was attempted, "unite" or "intersect", for the message
63
+ # @param message [String, nil] the message, instead of one built from +operation+
64
+ def initialize(conflicts, operation: nil, message: nil)
65
+ @conflicts = conflicts
66
+ count = (conflicts.size == 1) ? "1 field does" : "#{conflicts.size} fields do"
67
+ super(message || "Cannot #{operation} the schemas, #{count} not fit:\n#{conflicts.map { |c| " #{c}" }.join("\n")}")
68
+ end
69
+ end
70
+
43
71
  # An encrypted file or column cannot be read: its key was not given, the key or AAD prefix is
44
72
  # wrong, or the encrypted bytes were changed
45
73
  class DecryptionError < Error; end
@@ -56,6 +84,7 @@ module Herringbone
56
84
 
57
85
  autoload :BloomFilter, "#{LIB}/bloom_filter"
58
86
  autoload :ByteValues, "#{LIB}/byte_values"
87
+ autoload :Combiner, "#{LIB}/combiner"
59
88
  autoload :Compression, "#{LIB}/compression"
60
89
  autoload :DecryptionConfiguration, "#{LIB}/encryption_configuration"
61
90
  autoload :Encryption, "#{LIB}/encryption"
@@ -68,6 +97,7 @@ module Herringbone
68
97
  autoload :MissingCodecError, "#{LIB}/compression"
69
98
  autoload :Reader, "#{LIB}/reader"
70
99
  autoload :Redaction, "#{LIB}/redaction"
100
+ autoload :RestrictedReadableIO, "#{LIB}/restricted_readable_io"
71
101
  autoload :Schema, "#{LIB}/schema"
72
102
  autoload :SimpleWriter, "#{LIB}/simple_writer"
73
103
  autoload :Thrift, "#{LIB}/thrift"
@@ -176,16 +206,17 @@ module Herringbone
176
206
  writer.rows_written
177
207
  end
178
208
 
179
- # Rewrites +input+ into +output+ with rows removed or values replaced, building the Redaction from
180
- # the block (or taking +redaction+). A shortcut for Redaction#apply.
209
+ # Rewrites +io_or_reader+ into +output_io+ with rows removed or values replaced, building the
210
+ # Redaction from the block (or taking +redaction+). A shortcut for Redaction#apply.
181
211
  #
182
- # Herringbone.redact(input, output) do |r|
212
+ # Herringbone.redact(io, output_io) do |r|
183
213
  # r.where(user_id: 42).delete
184
214
  # r.replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", KEY, email) }
185
215
  # end
186
216
  #
187
- # @param input [IO, StringIO] the Parquet file, read with #seek and #read; not closed
188
- # @param output [IO, #write] destination, written sequentially; not closed
217
+ # @param io_or_reader [IO, StringIO, Reader] the Parquet file, read with #seek and #read, or a
218
+ # Reader of it, whose IO and decryption are used; not closed
219
+ # @param output_io [IO, #write] destination, written sequentially; not closed
189
220
  # @param redaction [Redaction, nil] the redaction to apply, instead of a block
190
221
  # @param writer_options [Hash{Symbol => Object}] Writer options for re-encoded column chunks, and
191
222
  # +metadata:+ to replace the footer key/value metadata, see Redaction#apply
@@ -204,15 +235,71 @@ module Herringbone
204
235
  # @yieldreturn [void]
205
236
  # @return [Redaction::Report] what was done
206
237
  # @raise [ArgumentError] when given both or neither of +redaction+ and a block, a block that takes
207
- # no parameter, or when the redaction does not fit the file's schema
238
+ # no parameter, a Reader with +decryption:+, or when the redaction does not fit the file's schema
208
239
  # @raise [EncodeError] when a replacement value cannot be written to its column
209
- def redact(input, output, redaction = nil, **writer_options, &block)
240
+ def redact(io_or_reader, output_io, redaction = nil, **writer_options, &block)
210
241
  raise ArgumentError, "Herringbone.redact takes a Redaction or a block, not both" if redaction && block
211
242
  raise ArgumentError, "Herringbone.redact needs a Redaction or a block" unless redaction || block
212
243
  if block&.arity&.zero?
213
- raise ArgumentError, "The block receives the redaction: Herringbone.redact(input, output) { |r| r.where(user_id: 42).delete }"
244
+ raise ArgumentError, "The block receives the redaction: Herringbone.redact(io, output_io) { |r| r.where(user_id: 42).delete }"
214
245
  end
215
- (redaction || Redaction.new(&block)).apply(input, output, **writer_options)
246
+ (redaction || Redaction.new(&block)).apply(io_or_reader, output_io, **writer_options)
247
+ end
248
+
249
+ # Concatenates Parquet files into +output_io+: every row group of every input, in order. A
250
+ # shortcut for Combiner. Column chunks are copied byte for byte (only offsets are rebased)
251
+ # wherever the output stores a column's values as the input does, so files with the same schema
252
+ # are combined without decoding anything.
253
+ #
254
+ # Herringbone.combine([jan, feb, mar], output_io) # the same schema
255
+ # Herringbone.combine([old, new], output_io, schema: :union) # all fields, nulls in the gaps
256
+ # Herringbone.combine([a, b], output_io, schema: :intersect) # the fields all inputs have
257
+ #
258
+ # Without +schema:+ every input must have the schema of the first (see Schema#==). With
259
+ # +schema: :union+ the output has the fields of all inputs, widened as Schema#union widens them,
260
+ # and fields an input lacks are written as nulls. With +schema: :intersect+ it has the fields all
261
+ # inputs have, and the others are dropped. A Schema given as +schema:+ must hold every input: each
262
+ # must have the same fields or fewer, in the same or narrower types. Every input is checked
263
+ # before anything is written, and IncompatibleSchema lists all that do not fit.
264
+ #
265
+ # The output is encrypted like the encrypted inputs, if any (plaintext inputs included), unless
266
+ # +encryption:+ says otherwise; +encryption: false+ writes it in plaintext. Inputs encrypted
267
+ # differently need +encryption:+. Herringbone never opens files by path: pass open IOs, which
268
+ # are read twice (footers first, then chunks), so keep them open until +combine+ returns and
269
+ # mind the limit on open files when combining very many of them.
270
+ #
271
+ # @param ios_or_readers [Enumerable<IO, StringIO, Reader>] the Parquet files: IOs read with #seek
272
+ # and #read, or Readers, which bring their own decryption; enumerated once, none is closed
273
+ # @param output_io [IO, #write] destination, written sequentially; not closed
274
+ # @param schema [Schema, Symbol, nil] +:union+, +:intersect+, a Schema every input fits, or nil
275
+ # to require the inputs to have the same schema
276
+ # @param decryption [DecryptionConfiguration, Hash{Symbol => Object}, Array, #call, nil] keys of
277
+ # encrypted inputs given as IOs, see Reader.new
278
+ # @param writer_options [Hash{Symbol => Object}] Writer options for chunks encoded again (and
279
+ # written nulls), +metadata:+ and +encryption:+
280
+ # @option writer_options [Symbol] :compression (codec of each source chunk) codec for chunks
281
+ # encoded again
282
+ # @option writer_options [Integer, nil] :compression_level (nil) level for that codec, see Writer
283
+ # @option writer_options [Hash{String => String}] :metadata (the first input's) footer key/value metadata
284
+ # @option writer_options [EncryptionConfiguration, Hash{Symbol => Object}, Key, String, false] :encryption
285
+ # (encrypted like the encrypted inputs) see Writer; false for a plaintext output
286
+ # @option writer_options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
287
+ # columns whose encoded chunks get a bloom filter, besides those whose source chunk had one
288
+ # @option writer_options [Integer] :page_bytes (1MB) approximate uncompressed data page size
289
+ # @option writer_options [Integer] :page_rows (20_000) maximum rows per data page
290
+ # @option writer_options [Integer] :data_page_version (1) 1 or 2
291
+ # @option writer_options [Boolean, Array<String>] :dictionary (true) see Writer
292
+ # @option writer_options [Hash{String => Symbol}] :encodings ({}) see Writer
293
+ # @return [Combiner::Report] rows written, how many row groups were copied or rewritten, and per
294
+ # input which fields were filled with nulls, widened or dropped
295
+ # @raise [IncompatibleSchema] listing every input (and field) that does not fit the others or
296
+ # +schema:+
297
+ # @raise [ArgumentError] when there are no inputs, an input is neither an IO nor a Reader, the
298
+ # encrypted inputs are encrypted differently and +encryption:+ is not given, or for
299
+ # +row_group_bytes:+ / +row_group_rows:+
300
+ # @raise [DecryptionError] when an input is encrypted and a key it needs was not given
301
+ def combine(ios_or_readers, output_io, schema: nil, decryption: nil, **writer_options)
302
+ Combiner.new(ios_or_readers, schema: schema, decryption: decryption).apply(output_io, **writer_options)
216
303
  end
217
304
 
218
305
  # Compression codecs this process can read and write, e.g. [:none, :snappy, :gzip, :lz4, :lz4_hadoop, :zstd].
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-03 00:00:00.000000000 Z
10
+ date: 2026-10-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -45,6 +45,7 @@ files:
45
45
  - lib/herringbone/codecs/lz4.rb
46
46
  - lib/herringbone/codecs/lzo.rb
47
47
  - lib/herringbone/codecs/snappy.rb
48
+ - lib/herringbone/combiner.rb
48
49
  - lib/herringbone/compression.rb
49
50
  - lib/herringbone/encodings/delta.rb
50
51
  - lib/herringbone/encodings/plain.rb
@@ -58,6 +59,7 @@ files:
58
59
  - lib/herringbone/key.rb
59
60
  - lib/herringbone/reader.rb
60
61
  - lib/herringbone/reader/bloom_filters.rb
62
+ - lib/herringbone/reader/chunk_copier.rb
61
63
  - lib/herringbone/reader/column_chunk_reader.rb
62
64
  - lib/herringbone/reader/column_cursor.rb
63
65
  - lib/herringbone/reader/numo.rb
@@ -65,7 +67,9 @@ files:
65
67
  - lib/herringbone/reader/scan.rb
66
68
  - lib/herringbone/redaction.rb
67
69
  - lib/herringbone/redaction/rewriter.rb
70
+ - lib/herringbone/restricted_readable_io.rb
68
71
  - lib/herringbone/schema.rb
72
+ - lib/herringbone/schema/merge.rb
69
73
  - lib/herringbone/simple_writer.rb
70
74
  - lib/herringbone/thrift.rb
71
75
  - lib/herringbone/types.rb