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
|
@@ -13,19 +13,27 @@ module Herringbone
|
|
|
13
13
|
# String hashes the same bytes as the stored value. Nulls are never in a bloom filter.
|
|
14
14
|
# The writer builds them (bloom_filters: option) and reads with where: consult them.
|
|
15
15
|
class BloomFilter
|
|
16
|
+
# The spec's eight salt constants, one per word of a block
|
|
16
17
|
SALT = [0x47b6137b, 0x44974d91, 0x8824ad5b, 0xa2b7289d, 0x705495c7, 0x2df1424b, 0x9efc4947, 0x5c6bfb31].freeze
|
|
17
18
|
S0, S1, S2, S3, S4, S5, S6, S7 = SALT
|
|
18
19
|
# Low 16 bits of the salts: (lo * salt) mod 2**32 is computed as
|
|
19
20
|
# (lo & 0xFFFF) * salt + ((lo >> 16) * (salt & 0xFFFF) << 16), which stays a Fixnum
|
|
20
21
|
L0, L1, L2, L3, L4, L5, L6, L7 = SALT.map { |s| s & 0xFFFF }
|
|
22
|
+
# Size of a block: eight 32-bit words
|
|
21
23
|
BLOCK_BYTES = 32
|
|
24
|
+
# Smallest bitset: a single block
|
|
22
25
|
MIN_BYTES = 32
|
|
26
|
+
# Largest bitset accepted when reading or building (128 MiB, the cap parquet-mr uses)
|
|
23
27
|
MAX_BYTES = 128 * 1024 * 1024
|
|
24
28
|
# Default cap for filters sized by the writer (as in parquet-mr)
|
|
25
29
|
DEFAULT_MAX_BYTES = 1024 * 1024
|
|
30
|
+
# Default false positive probability for filters sized by the writer
|
|
26
31
|
DEFAULT_FPP = 0.01
|
|
32
|
+
# 32-bit mask
|
|
27
33
|
M32 = 0xFFFF_FFFF
|
|
34
|
+
# 64-bit mask
|
|
28
35
|
M64 = 0xFFFF_FFFF_FFFF_FFFF
|
|
36
|
+
# Shorthand for the physical type constants
|
|
29
37
|
T = Format::Type
|
|
30
38
|
# Physical types a bloom filter can be built for (the spec does not define BOOLEAN hashing)
|
|
31
39
|
TYPES = [T::INT32, T::INT64, T::INT96, T::FLOAT, T::DOUBLE, T::BYTE_ARRAY, T::FIXED_LEN_BYTE_ARRAY].freeze
|
|
@@ -33,10 +41,17 @@ module Herringbone
|
|
|
33
41
|
# Bitset size in bytes for +ndv+ distinct values at false positive probability +fpp+, per the
|
|
34
42
|
# spec's formula (m = -8 * ndv / ln(1 - fpp ** (1/8)) bits), rounded up to a power of two
|
|
35
43
|
# and clamped to MIN_BYTES..max_bytes
|
|
44
|
+
#
|
|
45
|
+
# @param ndv [Integer] expected number of distinct values (values below 1 count as 1)
|
|
46
|
+
# @param fpp [Float] false positive probability, strictly between 0 and 1
|
|
47
|
+
# @param max_bytes [Integer] upper bound, clamped to MIN_BYTES..MAX_BYTES and rounded down to a
|
|
48
|
+
# power of two
|
|
49
|
+
# @return [Integer] bitset size in bytes, a power of two
|
|
50
|
+
# @raise [ArgumentError] when +fpp+ is not between 0 and 1
|
|
36
51
|
def self.optimal_num_bytes(ndv, fpp = DEFAULT_FPP, max_bytes: DEFAULT_MAX_BYTES)
|
|
37
52
|
fpp = Float(fpp)
|
|
38
53
|
raise ArgumentError, "fpp must be between 0 and 1, got #{fpp}" unless fpp > 0 && fpp < 1
|
|
39
|
-
max_bytes =
|
|
54
|
+
max_bytes = Integer(max_bytes).clamp(MIN_BYTES, MAX_BYTES)
|
|
40
55
|
max_bytes = 1 << (max_bytes.bit_length - 1) # a power of two
|
|
41
56
|
ndv = [Integer(ndv), 1].max
|
|
42
57
|
bits = -8.0 * ndv / Math.log(1 - fpp**(1.0 / 8))
|
|
@@ -47,6 +62,12 @@ module Herringbone
|
|
|
47
62
|
end
|
|
48
63
|
|
|
49
64
|
# XXH64 of the PLAIN encoding of a physical value of +type+ (as the column encoders produce it)
|
|
65
|
+
#
|
|
66
|
+
# @param value [Integer, Float, String, Array<Integer>] the physical value; for INT96 the
|
|
67
|
+
# +[nanos_of_day, julian_day]+ pair the encoder produces
|
|
68
|
+
# @param type [Integer] Format::Type physical type
|
|
69
|
+
# @return [Integer] the unsigned 64-bit hash
|
|
70
|
+
# @raise [UnsupportedError] for BOOLEAN (or any type not in TYPES)
|
|
50
71
|
def self.hash_physical(value, type)
|
|
51
72
|
case type
|
|
52
73
|
when T::INT32 then XXHash.xxh64_u32(value)
|
|
@@ -62,6 +83,12 @@ module Herringbone
|
|
|
62
83
|
# Hashes of many physical values of +type+, converting them in bulk. With +distinct+, each
|
|
63
84
|
# distinct physical value is hashed once (floats are compared by their bytes, so -0.0 and 0.0,
|
|
64
85
|
# or NaNs with different payloads, stay apart as they hash differently).
|
|
86
|
+
#
|
|
87
|
+
# @param values [Array] physical values, as for .hash_physical
|
|
88
|
+
# @param type [Integer] Format::Type physical type
|
|
89
|
+
# @param distinct [Boolean] hash each distinct value once (the result is then shorter)
|
|
90
|
+
# @return [Array<Integer>] the unsigned 64-bit hashes
|
|
91
|
+
# @raise [UnsupportedError] for BOOLEAN (or any type not in TYPES)
|
|
65
92
|
def self.hash_physical_all(values, type, distinct: false)
|
|
66
93
|
case type
|
|
67
94
|
when T::INT32 then XXHash.xxh64_u32_all(distinct ? values.uniq : values)
|
|
@@ -80,6 +107,13 @@ module Herringbone
|
|
|
80
107
|
|
|
81
108
|
# Reads a filter (header and bitset) from +buf+ at +pos+. Returns nil for algorithms, hashes
|
|
82
109
|
# or compressions this implementation does not know.
|
|
110
|
+
#
|
|
111
|
+
# @param buf [String] bytes holding the BloomFilterHeader followed by the bitset
|
|
112
|
+
# @param pos [Integer] byte offset of the header in +buf+
|
|
113
|
+
# @param column [Schema::Column, nil] column the filter belongs to, for converting values
|
|
114
|
+
# @return [BloomFilter, nil] the filter, or nil when it is of an unsupported kind
|
|
115
|
+
# @raise [FormatError] when the bitset is shorter than the header announces
|
|
116
|
+
# @raise [Thrift::Error] when the header cannot be decoded
|
|
83
117
|
def self.decode(buf, pos = 0, column: nil)
|
|
84
118
|
reader = Thrift::Reader.new(buf, pos)
|
|
85
119
|
header = reader.read_struct(Format::BloomFilterHeader)
|
|
@@ -89,48 +123,78 @@ module Herringbone
|
|
|
89
123
|
new(bitset: bitset, column: column)
|
|
90
124
|
end
|
|
91
125
|
|
|
126
|
+
# Whether a header describes a filter this implementation can read: split block algorithm,
|
|
127
|
+
# XXH64, uncompressed, and a size that is a multiple of 32 bytes up to MAX_BYTES
|
|
128
|
+
#
|
|
129
|
+
# @param header [Format::BloomFilterHeader] decoded header
|
|
130
|
+
# @return [Boolean] true when supported
|
|
92
131
|
def self.supported_header?(header)
|
|
93
132
|
n = header.num_bytes
|
|
94
133
|
n.is_a?(Integer) && n >= BLOCK_BYTES && (n % BLOCK_BYTES).zero? && n <= MAX_BYTES &&
|
|
95
|
-
header.algorithm
|
|
96
|
-
(header.
|
|
134
|
+
!(header.algorithm && header.algorithm.block).nil? &&
|
|
135
|
+
!(header.hash_function && header.hash_function.xxhash).nil? &&
|
|
136
|
+
(header.compression.nil? || !header.compression.uncompressed.nil?)
|
|
97
137
|
end
|
|
98
138
|
|
|
139
|
+
# @return [Schema::Column, nil] column whose encoder converts values, nil for raw Strings
|
|
99
140
|
attr_reader :column
|
|
100
141
|
|
|
101
142
|
# A filter of +num_bytes+ (a multiple of 32, normally a power of two), or one using an existing
|
|
102
143
|
# +bitset+ String. With a +column+ (a Schema::Column), values are Ruby values converted with
|
|
103
144
|
# the column's encoder; without one, values must be Strings and their bytes are hashed.
|
|
145
|
+
#
|
|
146
|
+
# @param num_bytes [Integer, nil] bitset size for an empty filter (MIN_BYTES when nil); ignored
|
|
147
|
+
# with +bitset+
|
|
148
|
+
# @param bitset [String, nil] existing bitset (little-endian 32-bit words)
|
|
149
|
+
# @param column [Schema::Column, nil] column the filter is for
|
|
150
|
+
# @raise [ArgumentError] when the size (or +bitset+'s size) is not a multiple of 32 bytes or
|
|
151
|
+
# exceeds MAX_BYTES
|
|
152
|
+
# @raise [UnsupportedError] when the column's physical type cannot have a bloom filter
|
|
104
153
|
def initialize(num_bytes = nil, bitset: nil, column: nil)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
num_bytes = Integer(num_bytes || MIN_BYTES)
|
|
109
|
-
unless num_bytes >= BLOCK_BYTES && (num_bytes % BLOCK_BYTES).zero? && num_bytes <= MAX_BYTES
|
|
110
|
-
raise ArgumentError, "Bloom filter size must be a multiple of 32 bytes up to 128MB, got #{num_bytes}"
|
|
111
|
-
end
|
|
112
|
-
@words = Array.new(num_bytes / 4, 0)
|
|
154
|
+
num_bytes = bitset ? bitset.bytesize : Integer(num_bytes || MIN_BYTES)
|
|
155
|
+
unless num_bytes >= BLOCK_BYTES && (num_bytes % BLOCK_BYTES).zero? && num_bytes <= MAX_BYTES
|
|
156
|
+
raise ArgumentError, "Bloom filter size must be a multiple of 32 bytes up to 128MB, got #{num_bytes}"
|
|
113
157
|
end
|
|
158
|
+
@words = bitset ? bitset.unpack("V*") : Array.new(num_bytes / 4, 0)
|
|
114
159
|
@num_blocks = @words.size / 8
|
|
115
|
-
raise ArgumentError, "Bloom filter bitset must be a multiple of 32 bytes" if @num_blocks.zero? || @words.size % 8 != 0
|
|
116
160
|
@column = column
|
|
117
161
|
if column && !TYPES.include?(column.type)
|
|
118
162
|
raise UnsupportedError, "Bloom filters are not supported for #{T::NAMES[column.type]} column #{column.dotted_path}"
|
|
119
163
|
end
|
|
120
164
|
end
|
|
121
165
|
|
|
166
|
+
# @return [Integer] bitset size in bytes
|
|
122
167
|
def num_bytes = @words.size * 4
|
|
123
168
|
|
|
169
|
+
# Adds a value to the filter
|
|
170
|
+
#
|
|
171
|
+
# @param value [Object] Ruby value, converted with the column's encoder (a String without a
|
|
172
|
+
# column)
|
|
173
|
+
# @return [BloomFilter] self
|
|
174
|
+
# @raise [ArgumentError] for nil or a value the column cannot encode
|
|
124
175
|
def insert(value)
|
|
125
176
|
insert_hash(hash_of(value))
|
|
126
177
|
self
|
|
127
178
|
end
|
|
128
179
|
|
|
180
|
+
# Whether the value may be in the filter. Never false for an inserted value; true for a
|
|
181
|
+
# value that was not inserted with about the false positive probability the filter was sized for.
|
|
182
|
+
#
|
|
183
|
+
# @param value [Object] Ruby value, converted with the column's encoder (a String without a
|
|
184
|
+
# column)
|
|
185
|
+
# @return [Boolean] false when the value is definitely absent
|
|
186
|
+
# @raise [ArgumentError] for nil or a value the column cannot encode
|
|
129
187
|
def might_contain?(value)
|
|
130
188
|
might_contain_hash?(hash_of(value))
|
|
131
189
|
end
|
|
132
190
|
|
|
133
191
|
# XXH64 hash the filter uses for a Ruby +value+
|
|
192
|
+
#
|
|
193
|
+
# @param value [Object] Ruby value, converted with the column's encoder (a String without a
|
|
194
|
+
# column)
|
|
195
|
+
# @return [Integer] the unsigned 64-bit hash
|
|
196
|
+
# @raise [ArgumentError] for nil, a value the column cannot encode, or (without a column) a
|
|
197
|
+
# non-String
|
|
134
198
|
def hash_of(value)
|
|
135
199
|
raise ArgumentError, "Nulls are not recorded in bloom filters" if value.nil?
|
|
136
200
|
if @column
|
|
@@ -146,6 +210,11 @@ module Herringbone
|
|
|
146
210
|
end
|
|
147
211
|
end
|
|
148
212
|
|
|
213
|
+
# Sets the bits for a hash: the upper 32 bits pick the block, the lower 32 bits (multiplied
|
|
214
|
+
# by each salt) one bit in each of its eight words
|
|
215
|
+
#
|
|
216
|
+
# @param h [Integer] unsigned 64-bit XXH64 hash
|
|
217
|
+
# @return [BloomFilter] self
|
|
149
218
|
def insert_hash(h)
|
|
150
219
|
i = (((h >> 32) * @num_blocks) >> 32) << 3
|
|
151
220
|
x0 = h & 0xFFFF
|
|
@@ -162,10 +231,14 @@ module Herringbone
|
|
|
162
231
|
self
|
|
163
232
|
end
|
|
164
233
|
|
|
234
|
+
# Single-bit masks, +BITS[i] == 1 << i+
|
|
165
235
|
BITS = Array.new(32) { |i| 1 << i }.freeze
|
|
166
236
|
|
|
167
237
|
# Inserts many hashes at once: faster than insert_hash, as the hashes (mostly Bignums) are
|
|
168
238
|
# split into 32-bit halves in bulk and the rest is Fixnum arithmetic
|
|
239
|
+
#
|
|
240
|
+
# @param hashes [Array<Integer>] unsigned 64-bit XXH64 hashes
|
|
241
|
+
# @return [BloomFilter] self
|
|
169
242
|
def insert_hashes(hashes)
|
|
170
243
|
halves = hashes.pack("Q<*").unpack("V*")
|
|
171
244
|
w = @words
|
|
@@ -191,6 +264,10 @@ module Herringbone
|
|
|
191
264
|
self
|
|
192
265
|
end
|
|
193
266
|
|
|
267
|
+
# Whether all the bits for a hash are set (see #insert_hash)
|
|
268
|
+
#
|
|
269
|
+
# @param h [Integer] unsigned 64-bit XXH64 hash
|
|
270
|
+
# @return [Boolean] false when the hashed value is definitely absent
|
|
194
271
|
def might_contain_hash?(h)
|
|
195
272
|
i = (((h >> 32) * @num_blocks) >> 32) << 3
|
|
196
273
|
x0 = h & 0xFFFF
|
|
@@ -207,19 +284,27 @@ module Herringbone
|
|
|
207
284
|
end
|
|
208
285
|
|
|
209
286
|
# The raw bitset (little-endian 32-bit words)
|
|
287
|
+
#
|
|
288
|
+
# @return [String] binary String of #num_bytes bytes
|
|
210
289
|
def bitset = @words.pack("V*")
|
|
211
290
|
|
|
212
291
|
# Header and bitset, as stored in a Parquet file
|
|
292
|
+
#
|
|
293
|
+
# @return [String] Thrift-encoded BloomFilterHeader followed by the bitset (binary)
|
|
213
294
|
def encode
|
|
214
295
|
header.encode << bitset
|
|
215
296
|
end
|
|
216
297
|
|
|
298
|
+
# @return [String] size and, when set, the column's dotted path
|
|
217
299
|
def inspect
|
|
218
300
|
"#<#{self.class.name} #{num_bytes} bytes#{" for #{@column.dotted_path}" if @column}>"
|
|
219
301
|
end
|
|
220
302
|
|
|
221
303
|
private
|
|
222
304
|
|
|
305
|
+
# Header for this filter: split block algorithm, XXH64 hash, uncompressed
|
|
306
|
+
#
|
|
307
|
+
# @return [Format::BloomFilterHeader] the header
|
|
223
308
|
def header
|
|
224
309
|
Format::BloomFilterHeader.new(
|
|
225
310
|
num_bytes: num_bytes,
|
|
@@ -234,6 +319,13 @@ module Herringbone
|
|
|
234
319
|
# Internal (used by reads with where:): the bloom filter of a column chunk. +column+ is a
|
|
235
320
|
# dotted path ("a.b"), an Array path or a Schema::Column. Returns a BloomFilter, or nil when
|
|
236
321
|
# the chunk has none (or one of an unknown kind).
|
|
322
|
+
#
|
|
323
|
+
# @param row_group_index [Integer] index of the row group
|
|
324
|
+
# @param column [String, Array<String, Symbol>, Symbol, Schema::Column] the leaf column
|
|
325
|
+
# @return [BloomFilter, nil] the filter, or nil when there is none or it is unsupported
|
|
326
|
+
# @raise [IndexError] when there is no such row group
|
|
327
|
+
# @raise [ArgumentError] when there is no such column
|
|
328
|
+
# @raise [FormatError] when the filter is truncated or its header cannot be decoded
|
|
237
329
|
def bloom_filter(row_group_index, column)
|
|
238
330
|
col = bloom_filter_column(column)
|
|
239
331
|
rg = row_groups.fetch(row_group_index) { raise IndexError, "No row group #{row_group_index}" }
|
|
@@ -256,10 +348,18 @@ module Herringbone
|
|
|
256
348
|
bitset = @io.read(header.num_bytes)
|
|
257
349
|
raise FormatError, "Truncated bloom filter" if bitset.nil? || bitset.bytesize != header.num_bytes
|
|
258
350
|
BloomFilter.new(bitset: bitset, column: col)
|
|
351
|
+
rescue Thrift::Error => e
|
|
352
|
+
raise FormatError, "Corrupt bloom filter header for #{col.dotted_path}: #{e.message}"
|
|
259
353
|
end
|
|
260
354
|
|
|
261
355
|
private
|
|
262
356
|
|
|
357
|
+
# Resolves the +column+ argument of #bloom_filter to a leaf column
|
|
358
|
+
#
|
|
359
|
+
# @param column [String, Array<String, Symbol>, Symbol, Schema::Column] dotted path, path
|
|
360
|
+
# Array or column
|
|
361
|
+
# @return [Schema::Column] the column
|
|
362
|
+
# @raise [ArgumentError] when the schema has no such column
|
|
263
363
|
def bloom_filter_column(column)
|
|
264
364
|
return column if column.is_a?(Schema::Column)
|
|
265
365
|
col = schema.column(column.is_a?(Array) ? column.map(&:to_s) : column.to_s)
|
|
@@ -17,8 +17,11 @@ module Herringbone
|
|
|
17
17
|
# After this many values, give up on the dictionary if more than half of them are distinct
|
|
18
18
|
CARDINALITY_CHECK_AT = 4096
|
|
19
19
|
|
|
20
|
+
# Whether String#append_as_bytes (Ruby 3.4+) is available for appending without re-encoding
|
|
20
21
|
APPEND_AS_BYTES = "".respond_to?(:append_as_bytes)
|
|
21
22
|
|
|
23
|
+
# @param width [Integer, nil] byte length of each value for FIXED_LEN_BYTE_ARRAY, nil for BYTE_ARRAY
|
|
24
|
+
# @param dictionary [Boolean] start in dictionary mode; false appends raw bytes from the start
|
|
22
25
|
def initialize(width: nil, dictionary: true)
|
|
23
26
|
@width = width
|
|
24
27
|
@bytes = String.new(encoding: Encoding::BINARY)
|
|
@@ -31,14 +34,21 @@ module Herringbone
|
|
|
31
34
|
end
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
# @return [Integer] number of values held
|
|
34
38
|
def size
|
|
35
39
|
@indices ? @indices.size : @count
|
|
36
40
|
end
|
|
37
41
|
|
|
42
|
+
# @return [Boolean] true when no values are held
|
|
38
43
|
def empty? = size.zero?
|
|
39
44
|
|
|
45
|
+
# @return [Boolean] true while still dictionary-encoding (not yet switched to raw bytes)
|
|
40
46
|
def dictionary? = !@indices.nil?
|
|
41
47
|
|
|
48
|
+
# Appends a value, switching to raw bytes when the dictionary limits are exceeded.
|
|
49
|
+
# @param value [String] bytes of the value; for FIXED_LEN_BYTE_ARRAY it must be +width+ bytes
|
|
50
|
+
# long (not checked here)
|
|
51
|
+
# @return [ByteValues] self
|
|
42
52
|
def <<(value)
|
|
43
53
|
if @indices
|
|
44
54
|
index = @dictionary[value]
|
|
@@ -61,16 +71,20 @@ module Herringbone
|
|
|
61
71
|
end
|
|
62
72
|
|
|
63
73
|
# Removes the last value
|
|
74
|
+
# @return [void]
|
|
64
75
|
def pop
|
|
65
76
|
truncate(size - 1) unless size.zero?
|
|
66
77
|
end
|
|
67
78
|
|
|
68
79
|
# Supports the `slice!(n..)` form used to roll back a failed row
|
|
80
|
+
# @param range [Range] endless range; values from +range.begin+ on are dropped
|
|
81
|
+
# @return [void]
|
|
69
82
|
def slice!(range)
|
|
70
83
|
truncate(range.begin)
|
|
71
84
|
end
|
|
72
85
|
|
|
73
86
|
# Approximate memory held, used to size row groups
|
|
87
|
+
# @return [Integer] estimated bytes
|
|
74
88
|
def memory_bytes
|
|
75
89
|
if @indices
|
|
76
90
|
# Each distinct value is a String object plus a Hash entry
|
|
@@ -82,6 +96,11 @@ module Herringbone
|
|
|
82
96
|
|
|
83
97
|
# Returns [:dictionary, values, indices] when the column is worth dictionary-encoding,
|
|
84
98
|
# otherwise [:plain, values]
|
|
99
|
+
#
|
|
100
|
+
# Dictionary encoding is kept unless there are more than 16 values and more than about half
|
|
101
|
+
# of them are distinct.
|
|
102
|
+
# @return [Array(Symbol, Array<String>, Array<Integer>), Array(Symbol, Array<String>)]
|
|
103
|
+
# distinct values and an index per value, or all values in order
|
|
85
104
|
def materialize
|
|
86
105
|
if @indices
|
|
87
106
|
keys = @dictionary.keys
|
|
@@ -93,6 +112,9 @@ module Herringbone
|
|
|
93
112
|
|
|
94
113
|
private
|
|
95
114
|
|
|
115
|
+
# Appends +value+ in raw-bytes mode, recording its length for BYTE_ARRAY.
|
|
116
|
+
# @param value [String] bytes of the value, in any encoding
|
|
117
|
+
# @return [ByteValues] self
|
|
96
118
|
def append_bytes(value)
|
|
97
119
|
if value.encoding == Encoding::BINARY || value.ascii_only?
|
|
98
120
|
@bytes << value
|
|
@@ -106,6 +128,8 @@ module Herringbone
|
|
|
106
128
|
self
|
|
107
129
|
end
|
|
108
130
|
|
|
131
|
+
# Leaves dictionary mode, re-appending every value held so far as raw bytes.
|
|
132
|
+
# @return [void]
|
|
109
133
|
def switch_to_bytes
|
|
110
134
|
keys = @dictionary.keys
|
|
111
135
|
indices = @indices
|
|
@@ -113,6 +137,9 @@ module Herringbone
|
|
|
113
137
|
indices.each { |i| append_bytes(keys[i]) }
|
|
114
138
|
end
|
|
115
139
|
|
|
140
|
+
# Drops all values after the first +n+.
|
|
141
|
+
# @param n [Integer] number of values to keep
|
|
142
|
+
# @return [void]
|
|
116
143
|
def truncate(n)
|
|
117
144
|
if @indices
|
|
118
145
|
@indices.slice!(n..)
|
|
@@ -128,6 +155,8 @@ module Herringbone
|
|
|
128
155
|
end
|
|
129
156
|
end
|
|
130
157
|
|
|
158
|
+
# Splits the raw bytes back into one binary String per value.
|
|
159
|
+
# @return [Array<String>]
|
|
131
160
|
def strings
|
|
132
161
|
if @lengths
|
|
133
162
|
pos = 0
|
|
@@ -5,17 +5,26 @@ module Herringbone
|
|
|
5
5
|
# Pure-Ruby LZ4: raw block format (Parquet LZ4_RAW), Hadoop-framed blocks
|
|
6
6
|
# (Parquet's deprecated LZ4) and a decoder for the LZ4 frame format.
|
|
7
7
|
module LZ4
|
|
8
|
+
# Raised for corrupt, truncated or unsupported LZ4 input.
|
|
8
9
|
class Error < StandardError; end
|
|
9
10
|
|
|
11
|
+
# Shortest match a sequence can encode; the token's match length is stored minus this.
|
|
10
12
|
MIN_MATCH = 4
|
|
11
13
|
LAST_LITERALS = 5 # the last 5 bytes of a block are always literals
|
|
12
14
|
MFLIMIT = 12 # the last match must start at least 12 bytes before the end
|
|
15
|
+
# Largest backward distance a 2-byte match offset can express.
|
|
13
16
|
MAX_OFFSET = 65_535
|
|
17
|
+
# Size of the compressor's hash table, in bits (16384 entries).
|
|
14
18
|
HASH_LOG = 14
|
|
19
|
+
# Right shift that keeps the top HASH_LOG bits of the 32-bit multiplicative hash.
|
|
15
20
|
HASH_SHIFT = 32 - HASH_LOG
|
|
21
|
+
# Controls how fast the compressor skips ahead through input without matches (as in the reference).
|
|
16
22
|
SKIP_STRENGTH = 6
|
|
23
|
+
# Magic number at the start of an LZ4 frame (little-endian).
|
|
17
24
|
FRAME_MAGIC = 0x184D2204
|
|
25
|
+
# Hadoop block header: big-endian 32-bit uncompressed size and compressed size.
|
|
18
26
|
HADOOP_PREFIX = 8
|
|
27
|
+
# Shorthand for Encoding::BINARY.
|
|
19
28
|
BINARY = Encoding::BINARY
|
|
20
29
|
# String#unpack1 accepts offset: since Ruby 3.1
|
|
21
30
|
UNPACK_OFFSET = begin
|
|
@@ -28,6 +37,10 @@ module Herringbone
|
|
|
28
37
|
module_function
|
|
29
38
|
|
|
30
39
|
# Decompress a raw LZ4 block that must expand to exactly uncompressed_size bytes.
|
|
40
|
+
# @param input [String] raw LZ4 block
|
|
41
|
+
# @param uncompressed_size [Integer] exact decompressed size, from the page header
|
|
42
|
+
# @return [String] decompressed bytes in ASCII-8BIT
|
|
43
|
+
# @raise [Error] if the block is corrupt or does not decode to +uncompressed_size+ bytes
|
|
31
44
|
def decompress_block(input, uncompressed_size)
|
|
32
45
|
src = binary(input)
|
|
33
46
|
out = String.new(capacity: uncompressed_size, encoding: BINARY)
|
|
@@ -40,6 +53,10 @@ module Herringbone
|
|
|
40
53
|
|
|
41
54
|
# Parquet LZ4 (codec 5). Tries Hadoop framing, then the LZ4 frame format,
|
|
42
55
|
# then a bare raw block (Arrow falls back hadoop -> raw; some writers emitted frames).
|
|
56
|
+
# @param input [String] compressed page data
|
|
57
|
+
# @param uncompressed_size [Integer] exact decompressed size, from the page header
|
|
58
|
+
# @return [String] decompressed bytes in ASCII-8BIT
|
|
59
|
+
# @raise [Error] if none of the three layouts decodes
|
|
43
60
|
def decompress_hadoop(input, uncompressed_size)
|
|
44
61
|
src = binary(input)
|
|
45
62
|
result = try_hadoop(src, uncompressed_size)
|
|
@@ -58,6 +75,10 @@ module Herringbone
|
|
|
58
75
|
|
|
59
76
|
# Decode LZ4 frame format data (one or more frames, skippable frames ignored).
|
|
60
77
|
# Checksums are skipped, not verified.
|
|
78
|
+
# @param input [String] one or more concatenated LZ4 frames
|
|
79
|
+
# @param max_size [Integer] upper bound on the decompressed size
|
|
80
|
+
# @return [String] decompressed bytes in ASCII-8BIT, possibly shorter than +max_size+
|
|
81
|
+
# @raise [Error] on bad magic, truncation, unsupported frame features or output over +max_size+
|
|
61
82
|
def decompress_frame(input, max_size)
|
|
62
83
|
src = binary(input)
|
|
63
84
|
n = src.bytesize
|
|
@@ -79,6 +100,8 @@ module Herringbone
|
|
|
79
100
|
end
|
|
80
101
|
|
|
81
102
|
# Compress into a single raw LZ4 block.
|
|
103
|
+
# @param input [String] bytes to compress
|
|
104
|
+
# @return [String] raw LZ4 block in ASCII-8BIT
|
|
82
105
|
def compress_block(input)
|
|
83
106
|
src = binary(input)
|
|
84
107
|
n = src.bytesize
|
|
@@ -121,7 +144,7 @@ module Herringbone
|
|
|
121
144
|
# Emit the sequence: token, literal run, offset, extended match length
|
|
122
145
|
lit_len = ip - anchor
|
|
123
146
|
ml = len - MIN_MATCH
|
|
124
|
-
out << (((lit_len < 15 ? lit_len : 15) << 4) | (ml < 15 ? ml : 15))
|
|
147
|
+
out << ((((lit_len < 15) ? lit_len : 15) << 4) | ((ml < 15) ? ml : 15))
|
|
125
148
|
write_length(out, lit_len - 15) if lit_len >= 15
|
|
126
149
|
out << src.byteslice(anchor, lit_len) if lit_len > 0
|
|
127
150
|
offset = ip - ref
|
|
@@ -144,6 +167,8 @@ module Herringbone
|
|
|
144
167
|
end
|
|
145
168
|
|
|
146
169
|
# Single Hadoop-framed block: [BE uncompressed size][BE compressed size][raw block]
|
|
170
|
+
# @param input [String] bytes to compress
|
|
171
|
+
# @return [String] Hadoop-framed LZ4 data in ASCII-8BIT
|
|
147
172
|
def compress_hadoop(input)
|
|
148
173
|
src = binary(input)
|
|
149
174
|
block = compress_block(src)
|
|
@@ -152,18 +177,31 @@ module Herringbone
|
|
|
152
177
|
|
|
153
178
|
# -- internals --
|
|
154
179
|
|
|
180
|
+
# @param str [String] input in any encoding
|
|
181
|
+
# @return [String] +str+ itself if already binary, otherwise a binary copy
|
|
155
182
|
def binary(str)
|
|
156
|
-
str.encoding == BINARY ? str : str.b
|
|
183
|
+
(str.encoding == BINARY) ? str : str.b
|
|
157
184
|
end
|
|
158
185
|
|
|
186
|
+
# @param src [String] binary input
|
|
187
|
+
# @param i [Integer] byte offset
|
|
188
|
+
# @return [Integer] unsigned little-endian 32-bit value at +i+
|
|
159
189
|
def le32(src, i)
|
|
160
190
|
src.byteslice(i, 4).unpack1("V")
|
|
161
191
|
end
|
|
162
192
|
|
|
193
|
+
# Same as le32 but via getbyte, for Rubies without unpack1(offset:).
|
|
194
|
+
# @param src [String] binary input
|
|
195
|
+
# @param i [Integer] byte offset
|
|
196
|
+
# @return [Integer] unsigned little-endian 32-bit value at +i+
|
|
163
197
|
def u32(src, i)
|
|
164
198
|
src.getbyte(i) | (src.getbyte(i + 1) << 8) | (src.getbyte(i + 2) << 16) | (src.getbyte(i + 3) << 24)
|
|
165
199
|
end
|
|
166
200
|
|
|
201
|
+
# Appends the extension of a literal or match length: 255-bytes followed by the remainder.
|
|
202
|
+
# @param out [String] binary output buffer, appended to
|
|
203
|
+
# @param len [Integer] length minus the 15 already stored in the token
|
|
204
|
+
# @return [String] +out+
|
|
167
205
|
def write_length(out, len)
|
|
168
206
|
if len >= 255
|
|
169
207
|
out << ("\xFF".b * (len / 255))
|
|
@@ -172,14 +210,27 @@ module Herringbone
|
|
|
172
210
|
out << len
|
|
173
211
|
end
|
|
174
212
|
|
|
213
|
+
# Appends the final, literals-only sequence that terminates every block.
|
|
214
|
+
# @param out [String] binary output buffer, appended to
|
|
215
|
+
# @param src [String] binary input
|
|
216
|
+
# @param anchor [Integer] offset of the first pending literal in +src+
|
|
217
|
+
# @param lit_len [Integer] number of trailing literal bytes (may be zero)
|
|
218
|
+
# @return [String, nil] +out+, or nil when there are no literals
|
|
175
219
|
def emit_last_literals(out, src, anchor, lit_len)
|
|
176
|
-
out << ((lit_len < 15 ? lit_len : 15) << 4)
|
|
220
|
+
out << (((lit_len < 15) ? lit_len : 15) << 4)
|
|
177
221
|
write_length(out, lit_len - 15) if lit_len >= 15
|
|
178
222
|
out << src.byteslice(anchor, lit_len) if lit_len > 0
|
|
179
223
|
end
|
|
180
224
|
|
|
181
225
|
# Decode one raw block from src[ip...iend], appending to out (which may already
|
|
182
226
|
# hold earlier data that matches can reference). out may not grow beyond limit.
|
|
227
|
+
# @param src [String] binary input
|
|
228
|
+
# @param ip [Integer] offset of the block in +src+
|
|
229
|
+
# @param iend [Integer] offset just past the block
|
|
230
|
+
# @param out [String] binary output buffer, appended to
|
|
231
|
+
# @param limit [Integer] maximum total byte size of +out+
|
|
232
|
+
# @return [Integer] input offset where decoding stopped
|
|
233
|
+
# @raise [Error] if the block is truncated, has an invalid offset or overflows +limit+
|
|
183
234
|
def decode_block(src, ip, iend, out, limit)
|
|
184
235
|
while ip < iend
|
|
185
236
|
token = src.getbyte(ip)
|
|
@@ -237,6 +288,9 @@ module Herringbone
|
|
|
237
288
|
end
|
|
238
289
|
|
|
239
290
|
# Arrow-compatible Hadoop frame parsing; returns nil if the data does not fit the framing.
|
|
291
|
+
# @param src [String] binary input
|
|
292
|
+
# @param uncompressed_size [Integer] exact decompressed size expected over all blocks
|
|
293
|
+
# @return [String, nil] decompressed bytes, or nil if +src+ is not valid Hadoop-framed LZ4
|
|
240
294
|
def try_hadoop(src, uncompressed_size)
|
|
241
295
|
n = src.bytesize
|
|
242
296
|
return nil if n < HADOOP_PREFIX
|
|
@@ -262,6 +316,15 @@ module Herringbone
|
|
|
262
316
|
out
|
|
263
317
|
end
|
|
264
318
|
|
|
319
|
+
# Decodes the descriptor and data blocks of one LZ4 frame (after its magic number),
|
|
320
|
+
# skipping content size and checksums.
|
|
321
|
+
# @param src [String] binary input
|
|
322
|
+
# @param ip [Integer] offset of the frame descriptor (just past the magic)
|
|
323
|
+
# @param n [Integer] byte size of +src+
|
|
324
|
+
# @param out [String] binary output buffer, appended to
|
|
325
|
+
# @param limit [Integer] maximum total byte size of +out+
|
|
326
|
+
# @return [Integer] offset just past the frame
|
|
327
|
+
# @raise [Error] on truncation, an unsupported version or dictionary, or output over +limit+
|
|
265
328
|
def decode_frame(src, ip, n, out, limit)
|
|
266
329
|
raise Error, "Truncated LZ4 frame descriptor" if ip + 3 > n
|
|
267
330
|
flg = src.getbyte(ip)
|