herringbone 0.6.1 → 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/MANUAL.md +196 -17
- data/README.md +22 -5
- data/bin/herringbone +56 -1
- data/lib/herringbone/active_record.rb +3 -1
- data/lib/herringbone/bloom_filter.rb +2 -0
- data/lib/herringbone/byte_values.rb +2 -0
- data/lib/herringbone/codecs/lz4.rb +9 -4
- data/lib/herringbone/codecs/lzo.rb +2 -0
- data/lib/herringbone/codecs/snappy.rb +48 -6
- data/lib/herringbone/combiner.rb +426 -0
- data/lib/herringbone/compression.rb +76 -13
- data/lib/herringbone/encodings/delta.rb +4 -0
- data/lib/herringbone/encodings/plain.rb +2 -0
- data/lib/herringbone/encodings/rle.rb +2 -0
- data/lib/herringbone/encryption.rb +2 -0
- data/lib/herringbone/inferring_writer.rb +1 -1
- data/lib/herringbone/inspector.rb +29 -5
- data/lib/herringbone/io_buffer_support.rb +2 -0
- data/lib/herringbone/reader/bloom_filters.rb +1 -0
- data/lib/herringbone/reader/chunk_copier.rb +177 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +6 -2
- data/lib/herringbone/reader/column_cursor.rb +2 -0
- data/lib/herringbone/reader/numo.rb +4 -0
- data/lib/herringbone/reader/page_stream.rb +2 -0
- data/lib/herringbone/reader/scan.rb +2 -0
- data/lib/herringbone/reader.rb +19 -6
- data/lib/herringbone/redaction/rewriter.rb +51 -157
- data/lib/herringbone/redaction.rb +25 -15
- data/lib/herringbone/restricted_readable_io.rb +85 -0
- data/lib/herringbone/schema/merge.rb +355 -0
- data/lib/herringbone/schema.rb +175 -12
- data/lib/herringbone/thrift.rb +49 -10
- data/lib/herringbone/types.rb +22 -12
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/visualizer.rb +2 -0
- data/lib/herringbone/writer.rb +58 -22
- data/lib/herringbone/xxhash.rb +2 -0
- data/lib/herringbone.rb +96 -9
- metadata +6 -2
data/lib/herringbone/thrift.rb
CHANGED
|
@@ -4,6 +4,8 @@ module Herringbone
|
|
|
4
4
|
# Minimal Thrift Compact Protocol implementation, just enough for Parquet metadata.
|
|
5
5
|
# Structs are described declaratively (see Herringbone::Thrift::Struct) so that
|
|
6
6
|
# both the reader and the writer are driven by the same field tables.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
7
9
|
module Thrift
|
|
8
10
|
# Raised on malformed or truncated Thrift data, and on values the writer cannot encode
|
|
9
11
|
class Error < StandardError; end
|
|
@@ -47,7 +49,7 @@ module Herringbone
|
|
|
47
49
|
|
|
48
50
|
# Values each declared integer type can hold, whatever width it came in on the wire
|
|
49
51
|
INT_RANGES = {
|
|
50
|
-
i16: -2**15...2**15, i32: -2**31...2**31, i64: -2**63...2**63
|
|
52
|
+
byte: -2**7...2**7, i16: -2**15...2**15, i32: -2**31...2**31, i64: -2**63...2**63
|
|
51
53
|
}.freeze
|
|
52
54
|
|
|
53
55
|
# Deepest nesting of structs, lists, sets and maps read or skipped, as in Apache Thrift
|
|
@@ -94,7 +96,9 @@ module Herringbone
|
|
|
94
96
|
|
|
95
97
|
# @param buf [String] the encoded data (binary)
|
|
96
98
|
# @param pos [Integer] byte offset to start reading at
|
|
99
|
+
# @raise [Error] when +pos+ is negative
|
|
97
100
|
def initialize(buf, pos = 0)
|
|
101
|
+
raise Error, "Negative offset #{pos}" if pos.negative?
|
|
98
102
|
@buf = buf
|
|
99
103
|
@pos = pos
|
|
100
104
|
@depth = 0
|
|
@@ -197,8 +201,8 @@ module Herringbone
|
|
|
197
201
|
# Reads one value of wire type +wire+ as declared +type+
|
|
198
202
|
#
|
|
199
203
|
# @param wire [Integer] wire type from the field or list header
|
|
200
|
-
# @param type [Symbol, Array, Class] declared type; +:string+ makes binary values UTF-8
|
|
201
|
-
# an Array or Struct subclass gives the element type or struct to read
|
|
204
|
+
# @param type [Symbol, Array, Class] declared type; +:string+ makes binary values UTF-8 and
|
|
205
|
+
# +:binary+ makes them BINARY, an Array or Struct subclass gives the element type or struct to read
|
|
202
206
|
# @return [Object] true/false, an Integer (bytes are signed), a Float, a String, an Array
|
|
203
207
|
# (or nil, see #read_list) or a Struct
|
|
204
208
|
# @raise [Error] on an unsupported wire type, truncated data or an integer out of the range
|
|
@@ -217,7 +221,7 @@ module Herringbone
|
|
|
217
221
|
when T_DOUBLE then read_double
|
|
218
222
|
when T_BINARY
|
|
219
223
|
s = read_binary
|
|
220
|
-
(type == :string) ?
|
|
224
|
+
s.force_encoding((type == :string) ? Encoding::UTF_8 : Encoding::BINARY)
|
|
221
225
|
when T_LIST, T_SET then read_list(type)
|
|
222
226
|
when T_STRUCT then read_struct(type)
|
|
223
227
|
else
|
|
@@ -407,12 +411,15 @@ module Herringbone
|
|
|
407
411
|
# @param type [Symbol, Array, Class] declared type (see WIRE_TYPES)
|
|
408
412
|
# @param value [Object] Integer, Float, String, Array or Struct matching +type+
|
|
409
413
|
# @return [void]
|
|
414
|
+
# @raise [Error] when an integer type gets a value that is not an Integer in its range, or a
|
|
415
|
+
# +:string+ gets a value that is not valid UTF-8 and cannot be converted to it
|
|
410
416
|
def write_value(type, value)
|
|
411
417
|
case type
|
|
412
|
-
when :byte then @buf << (value & 0xFF)
|
|
413
|
-
when :i16, :i32, :i64 then write_zigzag(value)
|
|
418
|
+
when :byte then @buf << (checked_int(type, value) & 0xFF)
|
|
419
|
+
when :i16, :i32, :i64 then write_zigzag(checked_int(type, value))
|
|
414
420
|
when :double then @buf << [value].pack("E")
|
|
415
|
-
when :binary
|
|
421
|
+
when :binary then write_binary(value)
|
|
422
|
+
when :string then write_binary(utf8(value))
|
|
416
423
|
when Array then write_list(type[1], value)
|
|
417
424
|
else write_struct(value)
|
|
418
425
|
end
|
|
@@ -440,6 +447,32 @@ module Herringbone
|
|
|
440
447
|
end
|
|
441
448
|
end
|
|
442
449
|
end
|
|
450
|
+
|
|
451
|
+
private
|
|
452
|
+
|
|
453
|
+
# @param type [Symbol] declared integer type, a key of INT_RANGES
|
|
454
|
+
# @param value [Object] value to be written as +type+
|
|
455
|
+
# @return [Integer] +value+
|
|
456
|
+
# @raise [Error] when +value+ is not an Integer that +type+ can hold
|
|
457
|
+
def checked_int(type, value)
|
|
458
|
+
raise Error, "#{value.inspect} is not a valid #{type}" unless value.is_a?(Integer) && INT_RANGES.fetch(type).cover?(value)
|
|
459
|
+
value
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Parquet strings are UTF-8. Binary Strings are taken to hold UTF-8 bytes, Strings in other
|
|
463
|
+
# encodings are converted.
|
|
464
|
+
#
|
|
465
|
+
# @param s [String] value of a +:string+ field
|
|
466
|
+
# @return [String] +s+, or a UTF-8 copy of it
|
|
467
|
+
# @raise [Error] when +s+ is not valid UTF-8 and cannot be converted to it
|
|
468
|
+
def utf8(s)
|
|
469
|
+
return s if s.ascii_only? || (s.encoding == Encoding::UTF_8 && s.valid_encoding?)
|
|
470
|
+
u = (s.encoding == Encoding::BINARY) ? s.dup.force_encoding(Encoding::UTF_8) : s.encode(Encoding::UTF_8)
|
|
471
|
+
raise Error, "String in #{s.encoding} is not valid UTF-8" unless u.valid_encoding?
|
|
472
|
+
u
|
|
473
|
+
rescue EncodingError => e
|
|
474
|
+
raise Error, "String in #{s.encoding} cannot be converted to UTF-8: #{e.message}"
|
|
475
|
+
end
|
|
443
476
|
end
|
|
444
477
|
|
|
445
478
|
# A field declared on a Thrift::Struct subclass
|
|
@@ -458,14 +491,14 @@ module Herringbone
|
|
|
458
491
|
# field 1, :name, :i32
|
|
459
492
|
class Struct
|
|
460
493
|
class << self
|
|
461
|
-
# @return [Array<Field>] declared fields, sorted by id
|
|
494
|
+
# @return [Array<Field>] declared fields, sorted by id, including the superclass's
|
|
462
495
|
def fields
|
|
463
|
-
@fields || []
|
|
496
|
+
@fields || ((superclass < Struct) ? superclass.fields : [])
|
|
464
497
|
end
|
|
465
498
|
|
|
466
499
|
# @return [Hash{Integer => Field}] declared fields by id, for decoding
|
|
467
500
|
def fields_by_id
|
|
468
|
-
@fields_by_id || {}
|
|
501
|
+
@fields_by_id || ((superclass < Struct) ? superclass.fields_by_id : {})
|
|
469
502
|
end
|
|
470
503
|
|
|
471
504
|
# Declares a field and defines its accessor
|
|
@@ -534,6 +567,12 @@ module Herringbone
|
|
|
534
567
|
def ==(other)
|
|
535
568
|
other.class == self.class && other.to_h == to_h
|
|
536
569
|
end
|
|
570
|
+
alias_method :eql?, :==
|
|
571
|
+
|
|
572
|
+
# @return [Integer] hash agreeing with #==, so equal structs work as Hash keys and in +uniq+
|
|
573
|
+
def hash
|
|
574
|
+
[self.class, to_h].hash
|
|
575
|
+
end
|
|
537
576
|
|
|
538
577
|
# @return [String] short class name and the set fields
|
|
539
578
|
def inspect
|
data/lib/herringbone/types.rb
CHANGED
|
@@ -18,6 +18,8 @@ module Herringbone
|
|
|
18
18
|
# DECIMAL <-> BigDecimal
|
|
19
19
|
# UUID <-> String "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
20
20
|
# FLOAT16 <-> Float
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
21
23
|
module Types
|
|
22
24
|
module_function
|
|
23
25
|
|
|
@@ -81,13 +83,15 @@ module Herringbone
|
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
# Physical attributes (type, logical type etc.) for a DSL type name
|
|
86
|
+
#
|
|
87
|
+
# +:time+ and +:timestamp+ hold values converted to UTC and are flagged isAdjustedToUTC;
|
|
88
|
+
# +:local_time+ and +:local_timestamp+ hold wall clock values, are not flagged and get no
|
|
89
|
+
# legacy converted type.
|
|
84
90
|
# @param type [Symbol, String] column type name, e.g. +:string+, +:int64+, +:decimal+, +:timestamp+
|
|
85
91
|
# @param opts [Hash{Symbol => Object}] type-specific options
|
|
86
92
|
# @option opts [Boolean] :parquet_enum for +:enum+, annotate as ENUM instead of STRING
|
|
87
93
|
# @option opts [Integer] :length byte length for +:fixed+ (required)
|
|
88
|
-
# @option opts [Symbol] :unit (:micros) for
|
|
89
|
-
# @option opts [Boolean] :utc (true) for +:time+ and +:timestamp+, sets +is_adjusted_to_utc+;
|
|
90
|
-
# the legacy converted type is only written when true
|
|
94
|
+
# @option opts [Symbol] :unit (:micros) for the time and timestamp types, see {time_unit}
|
|
91
95
|
# @option opts [Integer] :precision number of decimal digits for +:decimal+ (required)
|
|
92
96
|
# @option opts [Integer] :scale (0) digits after the decimal point for +:decimal+
|
|
93
97
|
# @option opts [Symbol] :physical for +:decimal+, force +:int32+, +:int64+, +:binary+ or
|
|
@@ -125,16 +129,18 @@ module Herringbone
|
|
|
125
129
|
when :int96 then {type: T::INT96}
|
|
126
130
|
when :fixed
|
|
127
131
|
{type: T::FIXED_LEN_BYTE_ARRAY, type_length: Integer(opts.fetch(:length))}
|
|
128
|
-
when :time
|
|
132
|
+
when :time, :local_time
|
|
133
|
+
utc = type.to_sym == :time
|
|
129
134
|
unit = time_unit(opts.fetch(:unit, :micros))
|
|
130
135
|
converted = {millis: C::TIME_MILLIS, micros: C::TIME_MICROS}[unit.to_sym]
|
|
131
|
-
{type: unit.millis ? T::INT32 : T::INT64, converted_type:
|
|
132
|
-
logical_type: lt(time: Format::TimeType.new(is_adjusted_to_utc:
|
|
133
|
-
when :timestamp
|
|
136
|
+
{type: unit.millis ? T::INT32 : T::INT64, converted_type: utc ? converted : nil,
|
|
137
|
+
logical_type: lt(time: Format::TimeType.new(is_adjusted_to_utc: utc, unit: unit))}
|
|
138
|
+
when :timestamp, :local_timestamp
|
|
139
|
+
utc = type.to_sym == :timestamp
|
|
134
140
|
unit = time_unit(opts.fetch(:unit, :micros))
|
|
135
141
|
converted = {millis: C::TIMESTAMP_MILLIS, micros: C::TIMESTAMP_MICROS}[unit.to_sym]
|
|
136
|
-
{type: T::INT64, converted_type:
|
|
137
|
-
logical_type: lt(timestamp: Format::TimestampType.new(is_adjusted_to_utc:
|
|
142
|
+
{type: T::INT64, converted_type: utc ? converted : nil,
|
|
143
|
+
logical_type: lt(timestamp: Format::TimestampType.new(is_adjusted_to_utc: utc, unit: unit))}
|
|
138
144
|
when :decimal
|
|
139
145
|
precision = Integer(opts.fetch(:precision))
|
|
140
146
|
scale = Integer(opts.fetch(:scale, 0))
|
|
@@ -335,7 +341,8 @@ module Herringbone
|
|
|
335
341
|
# date: Date, Time/DateTime (its calendar date), ISO-8601 String, Integer days since epoch
|
|
336
342
|
# timestamp: Time, DateTime, ActiveSupport::TimeWithZone, Date (midnight UTC), ISO-8601 String,
|
|
337
343
|
# Integer in the column's unit
|
|
338
|
-
# time: Time (its time of day), "HH:MM[:SS[.fraction]]"
|
|
344
|
+
# time: Time (its time of day, in UTC unless the column is local), "HH:MM[:SS[.fraction]]"
|
|
345
|
+
# String, Integer in the column's unit
|
|
339
346
|
# json: String (used as-is) or any other object (serialized with JSON.generate)
|
|
340
347
|
# string: String, Symbol or anything responding to to_s
|
|
341
348
|
# boolean: true/false, 1/0, "true"/"false", "t"/"f", "1"/"0", "yes"/"no"
|
|
@@ -355,7 +362,7 @@ module Herringbone
|
|
|
355
362
|
when :timestamp
|
|
356
363
|
return timestamp_writer(a, b)
|
|
357
364
|
when :time
|
|
358
|
-
return time_writer(a) if type == T::INT32 || type == T::INT64
|
|
365
|
+
return time_writer(a, b) if type == T::INT32 || type == T::INT64
|
|
359
366
|
when :json
|
|
360
367
|
return ->(v) { v.is_a?(String) ? v : JSON.generate(v) }
|
|
361
368
|
when :string, :enum
|
|
@@ -481,9 +488,11 @@ module Herringbone
|
|
|
481
488
|
# anything responding to +hour+ and +nsec+ (Time, DateTime), and raises ArgumentError or
|
|
482
489
|
# RangeError for anything else or a time past 23:59:59.
|
|
483
490
|
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
491
|
+
# @param utc [Boolean] true to convert a Time to UTC before taking its time of day, false to
|
|
492
|
+
# take the time of day in the Time's own zone
|
|
484
493
|
# @return [Proc] one-argument lambda
|
|
485
494
|
# @raise [KeyError] for an unknown unit
|
|
486
|
-
def time_writer(unit)
|
|
495
|
+
def time_writer(unit, utc)
|
|
487
496
|
mult = UNIT_DIVISORS.fetch(unit)
|
|
488
497
|
lambda do |v|
|
|
489
498
|
return v if v.is_a?(Integer)
|
|
@@ -494,6 +503,7 @@ module Herringbone
|
|
|
494
503
|
secs = m[1].to_i * 3600 + m[2].to_i * 60 + m[3].to_i
|
|
495
504
|
else
|
|
496
505
|
raise ArgumentError, "expected a Time, got #{v.class}" unless v.respond_to?(:hour) && v.respond_to?(:nsec)
|
|
506
|
+
v = v.getutc if utc && v.respond_to?(:getutc)
|
|
497
507
|
nanos = v.nsec
|
|
498
508
|
secs = v.hour * 3600 + v.min * 60 + v.sec
|
|
499
509
|
end
|
data/lib/herringbone/version.rb
CHANGED
|
@@ -15,6 +15,8 @@ module Herringbone
|
|
|
15
15
|
# Used through Inspector#to_html:
|
|
16
16
|
#
|
|
17
17
|
# File.open("data.parquet", "rb") { |io| Herringbone::Inspector.new(io).to_html }
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
18
20
|
class Visualizer
|
|
19
21
|
# highlight.js build loaded by the page to colour the footer JSON; optional.
|
|
20
22
|
HIGHLIGHT_JS = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
|
data/lib/herringbone/writer.rb
CHANGED
|
@@ -76,6 +76,8 @@ module Herringbone
|
|
|
76
76
|
MAX_DICTIONARY_BYTES = 1024 * 1024
|
|
77
77
|
# The row group byte size is first estimated after this many rows, then after every row group
|
|
78
78
|
ESTIMATE_AFTER_ROWS = 1000
|
|
79
|
+
# Options .open_for_copies refuses, since the row groups it writes keep their boundaries
|
|
80
|
+
ROW_GROUP_OPTIONS = %i[row_group_bytes row_group_rows].freeze
|
|
79
81
|
|
|
80
82
|
# @return [Schema] schema the rows are written with
|
|
81
83
|
attr_reader :schema
|
|
@@ -121,6 +123,29 @@ module Herringbone
|
|
|
121
123
|
result
|
|
122
124
|
end
|
|
123
125
|
|
|
126
|
+
# Internal (used by Redaction and Combiner): .open for a file whose row groups are all written
|
|
127
|
+
# with #write_row_group, one for each row group of another file, so they keep their boundaries
|
|
128
|
+
#
|
|
129
|
+
# @param io [IO, #write] destination
|
|
130
|
+
# @param schema [Schema] schema of the rows
|
|
131
|
+
# @param refusal [String] why +row_group_bytes:+ and +row_group_rows:+ cannot be given, e.g.
|
|
132
|
+
# "a redaction keeps the row groups of the input"
|
|
133
|
+
# @param options [Hash{Symbol => Object}] see .open, without the row group options
|
|
134
|
+
# @option options [Symbol] :compression (:snappy) codec of the chunks encoded again
|
|
135
|
+
# @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
|
|
136
|
+
# @option options [EncryptionConfiguration, Hash{Symbol => Object}, nil] :encryption (nil) see #initialize
|
|
137
|
+
# @yield [writer] the open writer
|
|
138
|
+
# @yieldparam writer [Writer] writer to write row groups to
|
|
139
|
+
# @yieldreturn [Object] returned by open_for_copies
|
|
140
|
+
# @return [Object] the block's value
|
|
141
|
+
# @raise [ArgumentError] for +row_group_bytes:+ / +row_group_rows:+, or an invalid option
|
|
142
|
+
# @api private
|
|
143
|
+
def self.open_for_copies(io, schema, refusal, **options, &block)
|
|
144
|
+
bad = options.keys & ROW_GROUP_OPTIONS
|
|
145
|
+
raise ArgumentError, "#{bad.join(", ")}: #{refusal}" unless bad.empty?
|
|
146
|
+
self.open(io, schema, row_group_bytes: 1 << 62, **options, &block)
|
|
147
|
+
end
|
|
148
|
+
|
|
124
149
|
# Validates the options and writes the leading magic bytes to +io+.
|
|
125
150
|
#
|
|
126
151
|
# @param io [IO, #write] destination; switched to binmode when it supports that
|
|
@@ -269,11 +294,12 @@ module Herringbone
|
|
|
269
294
|
# @return [Format::OffsetIndex, nil] the chunk's OffsetIndex, with source file offsets
|
|
270
295
|
# @!attribute bloom_filter
|
|
271
296
|
# @return [String, nil] the chunk's encoded bloom filter, header included
|
|
297
|
+
# @api private
|
|
272
298
|
CopiedChunk = Struct.new(:chunk, :start, :bytes, :column_index, :offset_index, :bloom_filter)
|
|
273
299
|
|
|
274
|
-
# Internal (used by Redaction): writes a row group of +num_rows+ rows from the
|
|
275
|
-
# except for the columns in +copies+, whose chunks are copied byte for byte
|
|
276
|
-
# with their offsets rebased. Starts a new row group afterwards.
|
|
300
|
+
# Internal (used by Redaction and Combiner): writes a row group of +num_rows+ rows from the
|
|
301
|
+
# buffered values, except for the columns in +copies+, whose chunks are copied byte for byte
|
|
302
|
+
# from another file with their offsets rebased. Starts a new row group afterwards.
|
|
277
303
|
#
|
|
278
304
|
# @param num_rows [Integer] rows in the row group
|
|
279
305
|
# @param copies [Hash{Integer => CopiedChunk}] column index => chunk to copy instead of encoding
|
|
@@ -285,6 +311,7 @@ module Herringbone
|
|
|
285
311
|
# @param sorting_columns [Array<Format::SortingColumn>, nil] stored on the RowGroup as given
|
|
286
312
|
# @return [void]
|
|
287
313
|
# @raise [Error] when the writer is closed
|
|
314
|
+
# @api private
|
|
288
315
|
def write_row_group(num_rows, copies: {}, codecs: {}, bloom_filters: {}, sorting_columns: nil)
|
|
289
316
|
raise Error, "Writer is closed" if @closed
|
|
290
317
|
if @encryption && (encrypted = copies.keys.find { |i| @encryption.encrypted?(i) })
|
|
@@ -293,7 +320,7 @@ module Herringbone
|
|
|
293
320
|
start = @pos
|
|
294
321
|
chunks = @schema.columns.map do |col|
|
|
295
322
|
if (copy = copies[col.index])
|
|
296
|
-
copy_column_chunk(copy)
|
|
323
|
+
copy_column_chunk(col, copy)
|
|
297
324
|
else
|
|
298
325
|
bloom = @bloom_filters[col.dotted_path]
|
|
299
326
|
bloom ||= bloom_filter_settings({}) if bloom_filters[col.index] && BloomFilter::TYPES.include?(col.type)
|
|
@@ -314,21 +341,23 @@ module Herringbone
|
|
|
314
341
|
reset_buffers
|
|
315
342
|
end
|
|
316
343
|
|
|
317
|
-
# Internal (used by Redaction): whether the column is encrypted in this file
|
|
344
|
+
# Internal (used by Redaction and Combiner): whether the column is encrypted in this file
|
|
318
345
|
#
|
|
319
346
|
# @param index [Integer] leaf column index
|
|
320
347
|
# @return [Boolean]
|
|
348
|
+
# @api private
|
|
321
349
|
def encrypted_column?(index) = @encryption&.encrypted?(index) || false
|
|
322
350
|
|
|
323
|
-
# Internal (used by Redaction): shreds one value per row of a top-level field into
|
|
324
|
-
# for #write_row_group. Other fields are left as they are, so the caller decides
|
|
325
|
-
# are encoded and which are copied.
|
|
351
|
+
# Internal (used by Redaction and Combiner): shreds one value per row of a top-level field into
|
|
352
|
+
# the buffers, for #write_row_group. Other fields are left as they are, so the caller decides
|
|
353
|
+
# which columns are encoded and which are copied.
|
|
326
354
|
#
|
|
327
355
|
# @param name [String] top-level field name
|
|
328
356
|
# @param values [Array] the field's value for each row
|
|
329
357
|
# @return [void]
|
|
330
358
|
# @raise [ArgumentError] when the schema has no such field
|
|
331
359
|
# @raise [EncodeError] when a value cannot be written to the field
|
|
360
|
+
# @api private
|
|
332
361
|
def buffer_field(name, values)
|
|
333
362
|
field = @schema.field(name) or raise ArgumentError, "No such field #{name.inspect}"
|
|
334
363
|
values.each_with_index do |value, i|
|
|
@@ -414,6 +443,7 @@ module Herringbone
|
|
|
414
443
|
# @return [String, Array<Integer>, nil] repetition levels, like +defs+; nil for non-repeated columns
|
|
415
444
|
# @!attribute values
|
|
416
445
|
# @return [Array, ByteValues] non-null physical values
|
|
446
|
+
# @api private
|
|
417
447
|
ColumnBuffer = Struct.new(:defs, :reps, :values)
|
|
418
448
|
|
|
419
449
|
# Starts a new row group: fresh buffers per column, and the per-field write plan
|
|
@@ -769,6 +799,7 @@ module Herringbone
|
|
|
769
799
|
# @return [Integer] non-null values in the page
|
|
770
800
|
# @!attribute range
|
|
771
801
|
# @return [Array(Object, Object), nil] [min, max] physical values, nil when there are none
|
|
802
|
+
# @api private
|
|
772
803
|
PageInfo = Struct.new(:offset, :size, :first_row, :nulls, :non_null, :range)
|
|
773
804
|
|
|
774
805
|
# @param pages [Array<PageInfo>] pages of a column chunk
|
|
@@ -781,14 +812,17 @@ module Herringbone
|
|
|
781
812
|
|
|
782
813
|
# Writes a chunk copied from another file. The pages, the ColumnIndex and the bloom filter
|
|
783
814
|
# hold no file offsets and go out as they are; the ColumnMetaData and the OffsetIndex do, so
|
|
784
|
-
# those are rebased onto where the chunk lands in this file.
|
|
815
|
+
# those are rebased onto where the chunk lands in this file. The path is this file's, which
|
|
816
|
+
# differs when the source file names list elements another way ("list.item", "array").
|
|
785
817
|
#
|
|
818
|
+
# @param col [Schema::Column] the column the chunk is copied into
|
|
786
819
|
# @param copy [CopiedChunk] the chunk to copy
|
|
787
820
|
# @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
|
|
788
|
-
def copy_column_chunk(copy)
|
|
821
|
+
def copy_column_chunk(col, copy)
|
|
789
822
|
start = @pos
|
|
790
823
|
shift = start - copy.start
|
|
791
824
|
meta = Format::ColumnMetaData.decode(copy.chunk.meta_data.encode).first
|
|
825
|
+
meta.path_in_schema = col.path
|
|
792
826
|
meta.data_page_offset += shift
|
|
793
827
|
dict = meta.dictionary_page_offset
|
|
794
828
|
# Some writers store 0 when there is no dictionary page
|
|
@@ -1070,7 +1104,7 @@ module Herringbone
|
|
|
1070
1104
|
# keeps in their source codec use that codec's default
|
|
1071
1105
|
#
|
|
1072
1106
|
# @param codec [Integer] codec id
|
|
1073
|
-
# @param data [String] bytes to compress
|
|
1107
|
+
# @param data [String, Array<String>] bytes to compress, or the parts of them
|
|
1074
1108
|
# @return [String] compressed bytes
|
|
1075
1109
|
def compress(codec, data)
|
|
1076
1110
|
Compression.compress(codec, data, (codec == @codec) ? @compression_level : nil)
|
|
@@ -1080,28 +1114,30 @@ module Herringbone
|
|
|
1080
1114
|
# stored, encrypted.
|
|
1081
1115
|
#
|
|
1082
1116
|
# @param header [Format::PageHeader] header; sizes and CRC32 are filled in here
|
|
1083
|
-
# @param body [String] uncompressed page body
|
|
1084
|
-
# @param compressed [String, nil] bytes to write as the page body,
|
|
1085
|
-
# pages, whose levels stay uncompressed); +body+ is
|
|
1117
|
+
# @param body [String, Array<String>] uncompressed page body, or its parts
|
|
1118
|
+
# @param compressed [String, Array<String>, nil] bytes to write as the page body, or their
|
|
1119
|
+
# parts, when already prepared (v2 data pages, whose levels stay uncompressed); +body+ is
|
|
1120
|
+
# compressed otherwise
|
|
1086
1121
|
# @param codec [Integer] codec id to compress +body+ with
|
|
1087
1122
|
# @param crypto [Encryption::ModuleCrypto, nil] encryption of the column's modules
|
|
1088
1123
|
# @param ordinal [Integer, nil] data page ordinal within the chunk; nil for a dictionary page
|
|
1089
1124
|
# @return [Integer]
|
|
1090
1125
|
def write_page(header, body, compressed = nil, codec: @codec, crypto: nil, ordinal: nil)
|
|
1091
|
-
compressed
|
|
1092
|
-
header.uncompressed_page_size ||= body.bytesize
|
|
1126
|
+
parts = Array(compressed || compress(codec, body))
|
|
1127
|
+
header.uncompressed_page_size ||= Array(body).sum(&:bytesize)
|
|
1093
1128
|
dictionary = header.type == Format::PageType::DICTIONARY_PAGE
|
|
1094
1129
|
if crypto
|
|
1095
|
-
|
|
1130
|
+
parts = [crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE : Encryption::DATA_PAGE, parts.join, ordinal)]
|
|
1096
1131
|
end
|
|
1097
|
-
header.compressed_page_size =
|
|
1098
|
-
|
|
1132
|
+
header.compressed_page_size = parts.sum(&:bytesize)
|
|
1133
|
+
crc = parts.inject(0) { |c, part| Zlib.crc32(part, c) }
|
|
1134
|
+
header.crc = (crc >= 0x8000_0000) ? crc - 0x1_0000_0000 : crc
|
|
1099
1135
|
encoded = header.encode
|
|
1100
1136
|
if crypto
|
|
1101
1137
|
encoded = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE_HEADER : Encryption::DATA_PAGE_HEADER, encoded, ordinal)
|
|
1102
1138
|
end
|
|
1103
1139
|
write_raw(encoded)
|
|
1104
|
-
write_raw(
|
|
1140
|
+
parts.each { |part| write_raw(part) unless part.empty? }
|
|
1105
1141
|
encoded.bytesize + header.uncompressed_page_size
|
|
1106
1142
|
end
|
|
1107
1143
|
|
|
@@ -1117,7 +1153,7 @@ module Herringbone
|
|
|
1117
1153
|
# modules and the page's ordinal, for an encrypted column
|
|
1118
1154
|
# @return [Integer] uncompressed size including the header
|
|
1119
1155
|
def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
|
|
1120
|
-
body =
|
|
1156
|
+
body = []
|
|
1121
1157
|
body << [rep_bytes.bytesize].pack("V") << rep_bytes unless rep_bytes.empty?
|
|
1122
1158
|
body << [def_bytes.bytesize].pack("V") << def_bytes unless def_bytes.empty?
|
|
1123
1159
|
body << encoded
|
|
@@ -1156,7 +1192,7 @@ module Herringbone
|
|
|
1156
1192
|
is_compressed: codec != Format::Codec::UNCOMPRESSED
|
|
1157
1193
|
)
|
|
1158
1194
|
)
|
|
1159
|
-
write_page(header, "".b, rep_bytes
|
|
1195
|
+
write_page(header, "".b, [rep_bytes, def_bytes, compressed], codec: codec, crypto: page_crypto&.first,
|
|
1160
1196
|
ordinal: page_crypto&.last)
|
|
1161
1197
|
end
|
|
1162
1198
|
|
data/lib/herringbone/xxhash.rb
CHANGED
|
@@ -14,6 +14,8 @@ module Herringbone
|
|
|
14
14
|
# allocations trigger so many garbage collections that hashing becomes up to 10x slower. The
|
|
15
15
|
# arithmetic is written once with the small code generators below, whose output is inlined into
|
|
16
16
|
# the hashing methods: no method calls or allocations in the hot paths.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
17
19
|
module XXHash
|
|
18
20
|
# 64-bit mask
|
|
19
21
|
M = 0xFFFF_FFFF_FFFF_FFFF
|
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 +
|
|
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(
|
|
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
|
|
188
|
-
#
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julik Tarkhanov
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-17 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
|