herringbone 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/MANUAL.md +161 -12
- data/README.md +19 -4
- data/bin/herringbone +56 -1
- data/lib/herringbone/active_record.rb +2 -0
- 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 +53 -9
- 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/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 +4 -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 +80 -0
- data/lib/herringbone/thrift.rb +166 -68
- data/lib/herringbone/types.rb +2 -0
- 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
|
@@ -29,6 +29,8 @@ module Herringbone
|
|
|
29
29
|
WINDOW = 64 * 1024
|
|
30
30
|
|
|
31
31
|
# Just enough of parquet.thrift's BloomFilterHeader to learn the filter's size
|
|
32
|
+
#
|
|
33
|
+
# @api private
|
|
32
34
|
class BloomFilterHeader < Thrift::Struct
|
|
33
35
|
field 1, :num_bytes, :i32
|
|
34
36
|
end
|
|
@@ -484,13 +486,13 @@ module Herringbone
|
|
|
484
486
|
# @raise [FormatError] when the file is too small, lacks the magic bytes or has a corrupt footer
|
|
485
487
|
# @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
|
|
486
488
|
def initialize(io, decryption: nil)
|
|
487
|
-
@io = io
|
|
488
489
|
@decryption = decryption.nil? ? nil : DecryptionConfiguration.from(decryption)
|
|
489
|
-
unless
|
|
490
|
+
unless io.respond_to?(:read) && io.respond_to?(:seek)
|
|
490
491
|
raise ArgumentError, "Herringbone::Inspector expects an IO that supports #seek and #read " \
|
|
491
492
|
"(e.g. File.open(path, \"rb\")), got #{io.class}"
|
|
492
493
|
end
|
|
493
|
-
@
|
|
494
|
+
@io = RestrictedReadableIO.wrap(io)
|
|
495
|
+
@name = File.basename(@io.path) unless @io.path.equal?(RestrictedReadableIO::UNTITLED)
|
|
494
496
|
read_footer
|
|
495
497
|
@schema = Schema.from_elements(@metadata.schema)
|
|
496
498
|
end
|
|
@@ -907,6 +909,7 @@ module Herringbone
|
|
|
907
909
|
# Decryption of an encrypted chunk's modules
|
|
908
910
|
# @param chunk [ColumnChunkInfo] an encrypted chunk
|
|
909
911
|
# @return [Encryption::ModuleCrypto, nil] nil when the chunk's key was not given
|
|
912
|
+
# @api private
|
|
910
913
|
def chunk_crypto(chunk)
|
|
911
914
|
@decryptor&.chunk(chunk.row_group.index, chunk.row_group.row_group.ordinal, chunk.column, chunk.chunk)
|
|
912
915
|
rescue DecryptionError
|
|
@@ -918,6 +921,7 @@ module Herringbone
|
|
|
918
921
|
# @param chunk [ColumnChunkInfo] chunk whose pages to walk
|
|
919
922
|
# @return [Array(Array<PageInfo>, String), Array(Array<PageInfo>, nil)] the pages found, and why
|
|
920
923
|
# the walk stopped early (nil when every value was accounted for)
|
|
924
|
+
# @api private
|
|
921
925
|
def walk_pages(chunk)
|
|
922
926
|
return [[], "column chunk stored in external file #{chunk.external_file}"] if chunk.external_file
|
|
923
927
|
if chunk.encrypted?
|
|
@@ -959,6 +963,7 @@ module Herringbone
|
|
|
959
963
|
# Reads and decodes a chunk's ColumnIndex, decoding the per-page min/max with the column type
|
|
960
964
|
# @param chunk [ColumnChunkInfo] chunk whose ColumnIndex to read
|
|
961
965
|
# @return [ColumnIndexInfo, nil] nil when the chunk has none or it fails to decode
|
|
966
|
+
# @api private
|
|
962
967
|
def read_column_index(chunk)
|
|
963
968
|
offset, length = chunk.column_index_range
|
|
964
969
|
return nil unless offset && length.positive?
|
|
@@ -985,6 +990,7 @@ module Herringbone
|
|
|
985
990
|
# Reads and decodes a chunk's OffsetIndex
|
|
986
991
|
# @param chunk [ColumnChunkInfo] chunk whose OffsetIndex to read
|
|
987
992
|
# @return [OffsetIndexInfo, nil] nil when the chunk has none or it fails to decode
|
|
993
|
+
# @api private
|
|
988
994
|
def read_offset_index(chunk)
|
|
989
995
|
offset, length = chunk.offset_index_range
|
|
990
996
|
return nil unless offset && length.positive?
|
|
@@ -1006,6 +1012,7 @@ module Herringbone
|
|
|
1006
1012
|
# @param offset [Integer] file offset of the BloomFilterHeader
|
|
1007
1013
|
# @param encrypted [Boolean] whether the filter is encrypted
|
|
1008
1014
|
# @return [Integer, nil] nil when the header can't be decoded or has no num_bytes
|
|
1015
|
+
# @api private
|
|
1009
1016
|
def bloom_filter_size(offset, encrypted = false)
|
|
1010
1017
|
if encrypted
|
|
1011
1018
|
header = read_at(offset, 4).unpack1("V") or return nil
|
|
@@ -1022,6 +1029,7 @@ module Herringbone
|
|
|
1022
1029
|
# :ok, :mismatch or :absent for one page (reads its body). Sets PageInfo#actual_crc.
|
|
1023
1030
|
# @param page [PageInfo] page to check
|
|
1024
1031
|
# @return [Symbol]
|
|
1032
|
+
# @api private
|
|
1025
1033
|
def page_checksum(page)
|
|
1026
1034
|
return :absent unless page.crc
|
|
1027
1035
|
page.actual_crc = page_crc(page)
|
|
@@ -1031,6 +1039,7 @@ module Herringbone
|
|
|
1031
1039
|
# CRC32 of a page's body as stored
|
|
1032
1040
|
# @param page [PageInfo] page whose compressed body to read
|
|
1033
1041
|
# @return [Integer] unsigned 32-bit CRC
|
|
1042
|
+
# @api private
|
|
1034
1043
|
def page_crc(page)
|
|
1035
1044
|
Zlib.crc32(read_at(page.body_offset, page.compressed_size))
|
|
1036
1045
|
end
|
|
@@ -1039,6 +1048,7 @@ module Herringbone
|
|
|
1039
1048
|
# @param chunk [ColumnChunkInfo] chunk whose page headers to compare with its ColumnIndex
|
|
1040
1049
|
# @return [Array<Hash{Symbol => Object}>] one entry per disagreement; a single :page_count
|
|
1041
1050
|
# entry when the ColumnIndex and the data pages differ in number
|
|
1051
|
+
# @api private
|
|
1042
1052
|
def compare_page_index(chunk)
|
|
1043
1053
|
ci = chunk.column_index or return []
|
|
1044
1054
|
data = chunk.data_pages
|
|
@@ -1072,6 +1082,7 @@ module Herringbone
|
|
|
1072
1082
|
# @param st [Format::Statistics, nil] statistics from column metadata or a page header
|
|
1073
1083
|
# @param column [Schema::Column] column the statistics describe
|
|
1074
1084
|
# @return [Stats, nil] nil when +st+ is nil
|
|
1085
|
+
# @api private
|
|
1075
1086
|
def decode_statistics(st, column)
|
|
1076
1087
|
return nil unless st
|
|
1077
1088
|
order = Inspector.sort_order(column)
|
|
@@ -1111,6 +1122,7 @@ module Herringbone
|
|
|
1111
1122
|
# @param column [Schema::Column] column whose physical type and converter apply
|
|
1112
1123
|
# @return [Object, nil] the converted value, a hex String when it could not be decoded, nil
|
|
1113
1124
|
# for nil (or empty BOOLEAN) input
|
|
1125
|
+
# @api private
|
|
1114
1126
|
def decode_value(bytes, column)
|
|
1115
1127
|
return nil if bytes.nil?
|
|
1116
1128
|
bytes = bytes.b
|
|
@@ -1158,6 +1170,8 @@ module Herringbone
|
|
|
1158
1170
|
|
|
1159
1171
|
# A minimal flatbuffer reader: tables (through their vtables), scalars, strings, vectors of
|
|
1160
1172
|
# scalars and tables, and unions. Every read is bounds-checked; malformed input raises Error.
|
|
1173
|
+
#
|
|
1174
|
+
# @api private
|
|
1161
1175
|
class FlatBuffer
|
|
1162
1176
|
# @param bytes [String] the flatbuffer (copied as binary)
|
|
1163
1177
|
def initialize(bytes)
|
|
@@ -1241,6 +1255,8 @@ module Herringbone
|
|
|
1241
1255
|
end
|
|
1242
1256
|
|
|
1243
1257
|
# One flatbuffer table; fields are addressed by their slot (declaration order in the .fbs)
|
|
1258
|
+
#
|
|
1259
|
+
# @api private
|
|
1244
1260
|
class Table
|
|
1245
1261
|
# @param fb [FlatBuffer] buffer the table lives in
|
|
1246
1262
|
# @param pos [Integer] absolute position of the table (where its vtable offset is stored)
|
|
@@ -1491,11 +1507,13 @@ module Herringbone
|
|
|
1491
1507
|
|
|
1492
1508
|
# @param e [Integer] Parquet Encoding value
|
|
1493
1509
|
# @return [String] its name, e.g. "RLE_DICTIONARY" (the number as a String when unknown)
|
|
1510
|
+
# @api private
|
|
1494
1511
|
def self.encoding_name(e) = Format::Encoding::NAMES[e]&.to_s || e.to_s
|
|
1495
1512
|
|
|
1496
1513
|
# @param column [Schema::Column] leaf column
|
|
1497
1514
|
# @return [String] physical type with its logical annotation, e.g. "BYTE_ARRAY STRING" or
|
|
1498
1515
|
# "FIXED_LEN_BYTE_ARRAY(16) UUID"
|
|
1516
|
+
# @api private
|
|
1499
1517
|
def self.type_name(column)
|
|
1500
1518
|
node = column.node
|
|
1501
1519
|
phys = T::NAMES[node.type].to_s
|
|
@@ -1508,6 +1526,7 @@ module Herringbone
|
|
|
1508
1526
|
# @param node [Schema::Node] schema node (leaf or group)
|
|
1509
1527
|
# @return [String, nil] e.g. "INTEGER(8, unsigned)", "TIMESTAMP(MICROS, UTC)" or "DECIMAL(10, 2)";
|
|
1510
1528
|
# nil when the node has no annotation
|
|
1529
|
+
# @api private
|
|
1511
1530
|
def self.logical_type_name(node)
|
|
1512
1531
|
if (kind = node.logical_type&.kind)
|
|
1513
1532
|
name, payload = kind
|
|
@@ -1528,6 +1547,7 @@ module Herringbone
|
|
|
1528
1547
|
# :signed, :unsigned or :unknown, per the Parquet sort order rules for the column's type
|
|
1529
1548
|
# @param column [Schema::Column] leaf column
|
|
1530
1549
|
# @return [Symbol]
|
|
1550
|
+
# @api private
|
|
1531
1551
|
def self.sort_order(column)
|
|
1532
1552
|
kind, _a, signed = Types.logical_of(column.node)
|
|
1533
1553
|
case kind
|
|
@@ -1547,6 +1567,7 @@ module Herringbone
|
|
|
1547
1567
|
# Recurses into Hashes, Arrays and Structs; Hash keys other than Symbols become Strings.
|
|
1548
1568
|
# @param v [Object] value to convert
|
|
1549
1569
|
# @return [Hash, Array, String, Integer, Float, Boolean, nil]
|
|
1570
|
+
# @api private
|
|
1550
1571
|
def self.jsonable(v)
|
|
1551
1572
|
case v
|
|
1552
1573
|
when Hash then v.each_with_object({}) { |(k, x), h| h[k.is_a?(Symbol) ? k : k.to_s] = jsonable(x) }
|
|
@@ -1567,6 +1588,7 @@ module Herringbone
|
|
|
1567
1588
|
# Strings already tagged as valid UTF-8 are returned as they are, control characters and all.
|
|
1568
1589
|
# @param s [String] string in any encoding
|
|
1569
1590
|
# @return [String]
|
|
1591
|
+
# @api private
|
|
1570
1592
|
def self.text(s)
|
|
1571
1593
|
return s if s.encoding == Encoding::UTF_8 && s.valid_encoding?
|
|
1572
1594
|
u = s.dup.force_encoding(Encoding::UTF_8)
|
|
@@ -1577,6 +1599,7 @@ module Herringbone
|
|
|
1577
1599
|
# @param bytes [String] bytes to show
|
|
1578
1600
|
# @return [String] "0x" and lowercase hex digits; only the first 64 bytes, followed by the
|
|
1579
1601
|
# total size, for longer input
|
|
1602
|
+
# @api private
|
|
1580
1603
|
def self.hex(bytes)
|
|
1581
1604
|
b = bytes.b
|
|
1582
1605
|
(b.bytesize > 64) ? "0x#{b.byteslice(0, 64).unpack1("H*")}… (#{b.bytesize} bytes)" : "0x#{b.unpack1("H*")}"
|
|
@@ -1587,6 +1610,7 @@ module Herringbone
|
|
|
1587
1610
|
# @param v [Object] decoded value
|
|
1588
1611
|
# @param max [Integer] longest result, in characters; longer ones are cut and end in an ellipsis
|
|
1589
1612
|
# @return [String]
|
|
1613
|
+
# @api private
|
|
1590
1614
|
def self.display(v, max: 40)
|
|
1591
1615
|
s = case v
|
|
1592
1616
|
when String then (v.encoding == Encoding::BINARY) ? text(v) : v
|
|
@@ -1599,6 +1623,7 @@ module Herringbone
|
|
|
1599
1623
|
|
|
1600
1624
|
# @param n [Integer, nil] byte count
|
|
1601
1625
|
# @return [String] e.g. "512 B", "1.50 KB" or "12.3 MB" (binary units); "?" for nil
|
|
1626
|
+
# @api private
|
|
1602
1627
|
def self.human_bytes(n)
|
|
1603
1628
|
return "?" unless n
|
|
1604
1629
|
units = %w[B KB MB GB TB]
|
|
@@ -1710,8 +1735,7 @@ module Herringbone
|
|
|
1710
1735
|
# @raise [FormatError] when the file is too small, lacks the magic bytes or has a corrupt footer
|
|
1711
1736
|
# @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
|
|
1712
1737
|
def read_footer
|
|
1713
|
-
@io.
|
|
1714
|
-
@file_size = @io.pos
|
|
1738
|
+
@file_size = @io.size
|
|
1715
1739
|
raise FormatError, "File too small to be Parquet (#{@file_size} bytes)" if @file_size < 12
|
|
1716
1740
|
tail = read_at(@file_size - 8, 8)
|
|
1717
1741
|
magic = tail.byteslice(4, 4)
|
|
@@ -4,6 +4,8 @@ module Herringbone
|
|
|
4
4
|
# IO::Buffer lets the decompressors copy bytes around without allocating a String per copy.
|
|
5
5
|
# It still carries an "experimental" warning, which is silenced once here: the gem only uses
|
|
6
6
|
# new/for/copy/get_string/free, and falls back to String operations where it is missing.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
7
9
|
module IOBufferSupport
|
|
8
10
|
# true when IO::Buffer has the methods the decompressors use and HERRINGBONE_NO_IO_BUFFER is
|
|
9
11
|
# not set in the environment (which forces the String fallback)
|
|
@@ -14,6 +14,7 @@ module Herringbone
|
|
|
14
14
|
# @raise [FormatError] when the filter is truncated or its header cannot be decoded
|
|
15
15
|
# @raise [DecryptionError] when the column is encrypted and its key was not given, or the
|
|
16
16
|
# filter does not decrypt
|
|
17
|
+
# @api private
|
|
17
18
|
def bloom_filter(row_group_index, column)
|
|
18
19
|
col = bloom_filter_column(column)
|
|
19
20
|
rg = row_groups.fetch(row_group_index) { raise IndexError, "No row group #{row_group_index}" }
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herringbone
|
|
4
|
+
class Reader
|
|
5
|
+
# Internal (used by Redaction and Herringbone.combine): takes plaintext column chunks out of a
|
|
6
|
+
# file as they are stored, for Writer#write_row_group to copy into another file, and tells how
|
|
7
|
+
# a chunk that has to be encoded again should be compressed.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class ChunkCopier
|
|
11
|
+
# Bytes read at a time when a page header runs past what was read of a chunk
|
|
12
|
+
READ_MORE = 64 * 1024
|
|
13
|
+
# Footer metadata that describes the columns, and goes stale when the schema changes
|
|
14
|
+
COLUMN_METADATA_KEYS = %w[ARROW:schema pandas].freeze
|
|
15
|
+
|
|
16
|
+
# @param reader [Reader] the file, whose IO the chunks are read from
|
|
17
|
+
def initialize(reader)
|
|
18
|
+
@reader = reader
|
|
19
|
+
@io = reader.io
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param i [Integer] row group index
|
|
23
|
+
# @param col [Schema::Column] the column, in the file's schema
|
|
24
|
+
# @return [Writer::CopiedChunk] the chunk's pages, page index and bloom filter
|
|
25
|
+
# @raise [UnsupportedError] for an encrypted chunk or one stored in another file
|
|
26
|
+
# @raise [FormatError] when a page header is corrupt or the chunk overruns the file
|
|
27
|
+
def copy(i, col)
|
|
28
|
+
chunk = @reader.row_groups[i].columns.fetch(col.index)
|
|
29
|
+
meta = chunk.meta_data or raise UnsupportedError, "Column chunk without metadata (encrypted?)"
|
|
30
|
+
raise UnsupportedError, "Column chunks in external files are not supported" if chunk.file_path
|
|
31
|
+
column_index, offset_index = @reader.page_index(i, col)
|
|
32
|
+
start, bytes = chunk_bytes(col, meta, offset_index)
|
|
33
|
+
column_index &&= read_at(chunk.column_index_offset, chunk.column_index_length)
|
|
34
|
+
Writer::CopiedChunk.new(chunk, start, bytes, column_index, offset_index, bloom_bytes(i, col, meta))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# How a chunk encoded again follows the source chunk: the same codec (LZO cannot be written
|
|
38
|
+
# and becomes Snappy), and a bloom filter when the source chunk had one
|
|
39
|
+
#
|
|
40
|
+
# @param i [Integer] row group index
|
|
41
|
+
# @param col [Schema::Column] the column, in the file's schema
|
|
42
|
+
# @return [Array(Integer, Boolean), nil] codec id and whether to write a bloom filter, nil when
|
|
43
|
+
# the chunk's metadata is not readable
|
|
44
|
+
def recode_settings(i, col)
|
|
45
|
+
meta = @reader.row_groups[i].columns.fetch(col.index).meta_data
|
|
46
|
+
return nil unless meta
|
|
47
|
+
[(meta.codec == Format::Codec::LZO) ? Format::Codec::SNAPPY : meta.codec, !meta.bloom_filter_offset.nil?]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The encrypted columns of a file encrypted like this one, as the +columns:+ of its
|
|
51
|
+
# EncryptionConfiguration: those encrypted with the footer key as +:footer+, the others
|
|
52
|
+
# with their key and key metadata
|
|
53
|
+
#
|
|
54
|
+
# @param sources [Hash{String => Schema::Column}] the new file's column path => the column
|
|
55
|
+
# here that holds its values
|
|
56
|
+
# @return [Hash{String => Symbol, Hash}] the encrypted ones of +sources+
|
|
57
|
+
# @raise [DecryptionError] when the key of an encrypted column was not given
|
|
58
|
+
def encrypted_columns(sources)
|
|
59
|
+
decryptor = @reader.decryptor or return {}
|
|
60
|
+
chunks = @reader.row_groups.first&.columns || []
|
|
61
|
+
sources.filter_map do |path, col|
|
|
62
|
+
chunk = chunks[col.index]
|
|
63
|
+
crypto = chunk&.crypto_metadata or next
|
|
64
|
+
with_column_key = crypto.encryption_with_column_key
|
|
65
|
+
next [path, :footer] unless with_column_key
|
|
66
|
+
key = decryptor.chunk_key(chunk, col.dotted_path)
|
|
67
|
+
key or raise DecryptionError, "The output is encrypted like the input, which needs the key of #{col.dotted_path}: " \
|
|
68
|
+
"pass it in decryption:, or pass encryption: for the output"
|
|
69
|
+
[path, {key: key, key_metadata: with_column_key.key_metadata}]
|
|
70
|
+
end.to_h
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The row group's sorting columns, pointed at the new file's columns. A column missing from
|
|
74
|
+
# +output_index+ ends the list, since the columns after it were only sorted within its runs.
|
|
75
|
+
#
|
|
76
|
+
# @param i [Integer] row group index
|
|
77
|
+
# @param output_index [Hash{Integer => Integer}] column index here => column index in the new
|
|
78
|
+
# file, for the columns whose values the new file holds as they are
|
|
79
|
+
# @return [Array<Format::SortingColumn>, nil]
|
|
80
|
+
def sorting_columns(i, output_index)
|
|
81
|
+
out = (@reader.row_groups[i].sorting_columns || []).each_with_object([]) do |sc, acc|
|
|
82
|
+
j = output_index[sc.column_idx] or break acc
|
|
83
|
+
acc << Format::SortingColumn.new(column_idx: j, descending: sc.descending, nulls_first: sc.nulls_first)
|
|
84
|
+
end
|
|
85
|
+
out.empty? ? nil : out
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param schema [Schema] the new file's schema
|
|
89
|
+
# @return [Hash{String => String, nil}] the file's key/value metadata, without the keys that
|
|
90
|
+
# describe the columns when +schema+ is another
|
|
91
|
+
def metadata(schema)
|
|
92
|
+
(@reader.schema == schema) ? @reader.metadata : @reader.metadata.except(*COLUMN_METADATA_KEYS)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# The chunk's pages. They are walked header by header rather than trusting
|
|
98
|
+
# total_compressed_size, which some writers get wrong: copying too little breaks the
|
|
99
|
+
# chunk, and copying too much could carry over bytes of a neighbouring chunk.
|
|
100
|
+
#
|
|
101
|
+
# @param col [Schema::Column] the column
|
|
102
|
+
# @param meta [Format::ColumnMetaData] the chunk's metadata
|
|
103
|
+
# @param offset_index [Format::OffsetIndex, nil] the chunk's OffsetIndex, whose pages are
|
|
104
|
+
# included even when num_values is reached before them
|
|
105
|
+
# @return [Array(Integer, String)] file offset of the first page, and the pages' bytes
|
|
106
|
+
# @raise [FormatError] when a page header is corrupt or the chunk overruns the file
|
|
107
|
+
def chunk_bytes(col, meta, offset_index)
|
|
108
|
+
start = meta.data_page_offset
|
|
109
|
+
dict = meta.dictionary_page_offset
|
|
110
|
+
start = dict if dict&.positive? && dict < start
|
|
111
|
+
last = offset_index&.page_locations&.last
|
|
112
|
+
min_end = last ? last.offset + last.compressed_page_size - start : 0
|
|
113
|
+
@io.seek(start)
|
|
114
|
+
buf = (@io.read(meta.total_compressed_size) || "").b
|
|
115
|
+
pos = 0
|
|
116
|
+
seen = 0
|
|
117
|
+
while seen < meta.num_values || pos < min_end
|
|
118
|
+
begin
|
|
119
|
+
header, body = Format::PageHeader.decode(buf, pos)
|
|
120
|
+
rescue Thrift::Error
|
|
121
|
+
raise FormatError, "Corrupt page header in #{col.dotted_path}" if read_more(buf, start, READ_MORE).zero?
|
|
122
|
+
retry
|
|
123
|
+
end
|
|
124
|
+
size = header.compressed_page_size
|
|
125
|
+
raise FormatError, "Negative page size in #{col.dotted_path}" if size.nil? || size.negative?
|
|
126
|
+
short = body + size - buf.bytesize
|
|
127
|
+
if short.positive? && read_more(buf, start, short) < short
|
|
128
|
+
raise FormatError, "Column #{col.dotted_path}: page overruns the file"
|
|
129
|
+
end
|
|
130
|
+
pos = body + size
|
|
131
|
+
seen += header.data_page_header&.num_values || header.data_page_header_v2&.num_values || 0
|
|
132
|
+
end
|
|
133
|
+
[start, (pos == buf.bytesize) ? buf : buf.byteslice(0, pos)]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Appends up to +count+ bytes that follow +buf+ in the file
|
|
137
|
+
#
|
|
138
|
+
# @param buf [String] bytes read from +start+ on; appended to
|
|
139
|
+
# @param start [Integer] file offset of +buf+
|
|
140
|
+
# @param count [Integer] bytes wanted
|
|
141
|
+
# @return [Integer] bytes appended, 0 at the end of the file
|
|
142
|
+
def read_more(buf, start, count)
|
|
143
|
+
@io.seek(start + buf.bytesize)
|
|
144
|
+
more = @io.read(count)
|
|
145
|
+
return 0 if more.nil?
|
|
146
|
+
buf << more.b
|
|
147
|
+
more.bytesize
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param offset [Integer, nil] file offset
|
|
151
|
+
# @param length [Integer, nil] byte count
|
|
152
|
+
# @return [String, nil] the bytes, nil when absent or cut off
|
|
153
|
+
def read_at(offset, length)
|
|
154
|
+
return nil unless offset && length&.positive?
|
|
155
|
+
@io.seek(offset)
|
|
156
|
+
bytes = @io.read(length)
|
|
157
|
+
(bytes&.bytesize == length) ? bytes.b : nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The chunk's bloom filter as stored. Filters written without a length (older writers) are
|
|
161
|
+
# decoded and encoded again. A filter that cannot be read is left out, which only costs
|
|
162
|
+
# pruning.
|
|
163
|
+
#
|
|
164
|
+
# @param i [Integer] row group index
|
|
165
|
+
# @param col [Schema::Column] the column
|
|
166
|
+
# @param meta [Format::ColumnMetaData] the chunk's metadata
|
|
167
|
+
# @return [String, nil] header and bitset
|
|
168
|
+
def bloom_bytes(i, col, meta)
|
|
169
|
+
return nil unless meta.bloom_filter_offset
|
|
170
|
+
return read_at(meta.bloom_filter_offset, meta.bloom_filter_length) if meta.bloom_filter_length
|
|
171
|
+
@reader.bloom_filter(i, col)&.encode
|
|
172
|
+
rescue FormatError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -13,6 +13,8 @@ module Herringbone
|
|
|
13
13
|
#
|
|
14
14
|
# The declared total_compressed_size of the chunk is not relied on (some old writers
|
|
15
15
|
# under-report it): pages are read until the chunk's num_values have been seen.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
16
18
|
class ColumnChunkReader
|
|
17
19
|
# Shorthand for the physical type constants
|
|
18
20
|
T = Format::Type
|
|
@@ -37,7 +39,7 @@ module Herringbone
|
|
|
37
39
|
# @return [Proc, nil] converter of the last page returned by #next_page, nil when none
|
|
38
40
|
attr_reader :page_converter
|
|
39
41
|
|
|
40
|
-
# @param io [IO, StringIO] the file, read with #seek and #read
|
|
42
|
+
# @param io [IO, StringIO, RestrictedReadableIO] the file, read with #seek and #read
|
|
41
43
|
# @param chunk [Format::ColumnChunk] the chunk's footer entry
|
|
42
44
|
# @param column [Schema::Column] the leaf column the chunk stores
|
|
43
45
|
# @param converter [Proc, nil] physical value => Ruby value, applied to values and dictionaries
|
|
@@ -46,7 +48,7 @@ module Herringbone
|
|
|
46
48
|
# encrypted column
|
|
47
49
|
# @raise [UnsupportedError] for chunks without metadata (encrypted) or stored in another file
|
|
48
50
|
def initialize(io, chunk, column, converter: column.converter, lazy: false, crypto: nil)
|
|
49
|
-
@io = io
|
|
51
|
+
@io = RestrictedReadableIO.wrap(io)
|
|
50
52
|
@chunk = chunk
|
|
51
53
|
@column = column
|
|
52
54
|
@meta = chunk.meta_data or raise UnsupportedError, "Column chunk without metadata (encrypted?)"
|
|
@@ -7,6 +7,8 @@ module Herringbone
|
|
|
7
7
|
# entries of the requested rows (plus a small lookahead of levels for repeated columns)
|
|
8
8
|
# become Ruby objects. A row starts at an entry with repetition level 0 and may continue
|
|
9
9
|
# over any number of following pages.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
10
12
|
class ColumnCursor
|
|
11
13
|
# Levels decoded ahead at a time when looking for row starts in repeated columns
|
|
12
14
|
LOOKAHEAD = 4096
|
|
@@ -20,6 +20,8 @@ module Herringbone
|
|
|
20
20
|
# list<number>, all rows of the 2-D [rows, length] of the element type
|
|
21
21
|
# same length and no nulls
|
|
22
22
|
# anything else RObject of the values as: :rows returns
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
23
25
|
module NumoColumns
|
|
24
26
|
# Whether the host is little-endian, so PLAIN bytes (always little-endian) can be handed to
|
|
25
27
|
# Numo's from_binary, which reads native byte order.
|
|
@@ -224,6 +226,8 @@ module Herringbone
|
|
|
224
226
|
# Hands out the next +k+ rows of a flat (non-repeated) numeric or boolean column as a
|
|
225
227
|
# [Numo values, validity] pair: values has one slot per row (zero where the row is null),
|
|
226
228
|
# validity is a Numo::Bit or nil when all rows are present.
|
|
229
|
+
#
|
|
230
|
+
# @api private
|
|
227
231
|
class NumoCursor
|
|
228
232
|
# @param chunk_reader [ColumnChunkReader] reader for the column's chunk in one row group
|
|
229
233
|
# @param spec [NumoColumns::Spec] a :fixed spec with a bin_klass
|
|
@@ -5,6 +5,8 @@ module Herringbone
|
|
|
5
5
|
# Incremental decoders for the contents of one data page. The page bytes are decoded as they
|
|
6
6
|
# are asked for, so a caller that takes a few hundred entries at a time never holds a whole
|
|
7
7
|
# page's worth of levels or values as Ruby objects.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
8
10
|
module PageStream
|
|
9
11
|
# Values unpacked from a bit-packed run at a time (a multiple of 8)
|
|
10
12
|
CHUNK = 1024
|
|
@@ -16,6 +16,8 @@ module Herringbone
|
|
|
16
16
|
# bloom filters; within a row group, pages are ruled out with the page index (ColumnIndex +
|
|
17
17
|
# OffsetIndex), leaving ranges of rows to read; and every row that is read is checked, so
|
|
18
18
|
# results are exact.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
19
21
|
class Filter
|
|
20
22
|
# One where: entry, resolved against the schema
|
|
21
23
|
#
|
data/lib/herringbone/reader.rb
CHANGED
|
@@ -44,9 +44,16 @@ module Herringbone
|
|
|
44
44
|
# @return [Format::FileMetaData] the file's FileMetaData (the decoded Thrift footer)
|
|
45
45
|
attr_reader :file_metadata
|
|
46
46
|
|
|
47
|
-
#
|
|
47
|
+
# @return [RestrictedReadableIO] the IO the file is read from, as given to Reader.new, wrapped
|
|
48
|
+
attr_reader :io
|
|
49
|
+
|
|
50
|
+
# @return [DecryptionConfiguration, nil] the keys given as +decryption:+, nil without them
|
|
51
|
+
attr_reader :decryption
|
|
52
|
+
|
|
53
|
+
# +io+ must support #seek and #read (a File opened with "rb", StringIO, Tempfile...). It is
|
|
54
|
+
# read only through a RestrictedReadableIO wrapping it.
|
|
48
55
|
#
|
|
49
|
-
# @param io [IO, StringIO] random-access source of the Parquet bytes; the caller closes it
|
|
56
|
+
# @param io [IO, StringIO, RestrictedReadableIO] random-access source of the Parquet bytes; the caller closes it
|
|
50
57
|
# @param keys [Symbol, String] +:string+ or +:symbol+, the key type of row and struct Hashes
|
|
51
58
|
# @param time_zone [String, Integer, Object, nil] zone timestamps are returned in (see the
|
|
52
59
|
# class docs); nil keeps them in UTC
|
|
@@ -74,7 +81,7 @@ module Herringbone
|
|
|
74
81
|
@symbolize = keys == :symbol
|
|
75
82
|
@zone_converter = zone_converter(time_zone)
|
|
76
83
|
@decryption = decryption.nil? ? nil : DecryptionConfiguration.from(decryption)
|
|
77
|
-
@io = io
|
|
84
|
+
@io = RestrictedReadableIO.wrap(io)
|
|
78
85
|
@file_metadata = read_footer
|
|
79
86
|
@schema = Schema.from_elements(@file_metadata.schema)
|
|
80
87
|
end
|
|
@@ -301,6 +308,7 @@ module Herringbone
|
|
|
301
308
|
# @return [Array(Format::ColumnIndex, Format::OffsetIndex)] either element may be nil
|
|
302
309
|
# @raise [ArgumentError] when +column+ does not name a leaf column
|
|
303
310
|
# @raise [IndexError] when the row group does not exist
|
|
311
|
+
# @api private
|
|
304
312
|
def page_index(row_group_index, column)
|
|
305
313
|
column = @schema.column(column) unless column.is_a?(Schema::Column)
|
|
306
314
|
raise ArgumentError, "No such leaf column" unless column
|
|
@@ -328,9 +336,10 @@ module Herringbone
|
|
|
328
336
|
# checked) and +:columns+
|
|
329
337
|
def encryption = @decryptor&.describe(@schema, row_groups.first&.columns || [])
|
|
330
338
|
|
|
331
|
-
# Internal (used by Redaction): the keys and settings of an encrypted file
|
|
339
|
+
# Internal (used by Redaction and Combiner): the keys and settings of an encrypted file
|
|
332
340
|
#
|
|
333
341
|
# @return [Encryption::FileDecryptor, nil] nil for a file that is not encrypted
|
|
342
|
+
# @api private
|
|
334
343
|
attr_reader :decryptor
|
|
335
344
|
|
|
336
345
|
# Internal: the decryption of one column chunk's modules
|
|
@@ -339,6 +348,7 @@ module Herringbone
|
|
|
339
348
|
# @param column [Schema::Column] leaf column
|
|
340
349
|
# @return [Encryption::ModuleCrypto, nil] nil when the chunk is not encrypted
|
|
341
350
|
# @raise [DecryptionError] when the chunk is encrypted and its key was not given
|
|
351
|
+
# @api private
|
|
342
352
|
def chunk_crypto(row_group_index, column)
|
|
343
353
|
return nil unless @decryptor
|
|
344
354
|
rg = row_groups.fetch(row_group_index)
|
|
@@ -354,6 +364,7 @@ module Herringbone
|
|
|
354
364
|
# @option options [Boolean] :lazy leave non-dictionary values physical
|
|
355
365
|
# @return [ColumnChunkReader]
|
|
356
366
|
# @raise [DecryptionError] when the chunk is encrypted and its key was not given
|
|
367
|
+
# @api private
|
|
357
368
|
def chunk_reader(row_group_index, column, **options)
|
|
358
369
|
chunk = row_groups.fetch(row_group_index).columns.fetch(column.index)
|
|
359
370
|
ColumnChunkReader.new(@io, chunk, column, crypto: chunk_crypto(row_group_index, column), **options)
|
|
@@ -656,8 +667,7 @@ module Herringbone
|
|
|
656
667
|
# @raise [FormatError] when the file is too short, lacks the magic or the footer is corrupt
|
|
657
668
|
# @raise [DecryptionError] when the footer is encrypted and cannot be decrypted
|
|
658
669
|
def read_footer
|
|
659
|
-
@io.
|
|
660
|
-
size = @io.pos
|
|
670
|
+
size = @io.size
|
|
661
671
|
raise FormatError, "File too small to be Parquet (#{size} bytes)" if size < 12
|
|
662
672
|
@io.seek(size - 8)
|
|
663
673
|
tail = @io.read(8)
|
|
@@ -679,6 +689,8 @@ module Herringbone
|
|
|
679
689
|
|
|
680
690
|
# Rebuilds nested values of one top-level field from the levels of its leaf columns
|
|
681
691
|
# (the "record assembly" half of the Dremel algorithm).
|
|
692
|
+
#
|
|
693
|
+
# @api private
|
|
682
694
|
class Assembler
|
|
683
695
|
# @param field [Schema::Field] top-level field to assemble
|
|
684
696
|
# @param symbolize [Boolean] whether struct Hashes are keyed by Symbol instead of String
|
|
@@ -860,6 +872,7 @@ require_relative "reader/bloom_filters"
|
|
|
860
872
|
|
|
861
873
|
module Herringbone
|
|
862
874
|
class Reader
|
|
875
|
+
autoload :ChunkCopier, File.expand_path("reader/chunk_copier", __dir__)
|
|
863
876
|
autoload :Filter, File.expand_path("reader/scan", __dir__)
|
|
864
877
|
autoload :NumoColumns, File.expand_path("reader/numo", __dir__)
|
|
865
878
|
autoload :NumoCursor, File.expand_path("reader/numo", __dir__)
|