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