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