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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herringbone
|
|
4
|
+
module Codecs
|
|
5
|
+
# Pure-Ruby LZO1X decompressor, for reading Parquet files written by parquet-mr with
|
|
6
|
+
# hadoop-lzo. There is no compressor: nothing but hadoop-lzo writes LZO, and nothing
|
|
7
|
+
# outside the JVM reads it.
|
|
8
|
+
#
|
|
9
|
+
# Written from the bitstream description in the Linux kernel's
|
|
10
|
+
# Documentation/staging/lzo.rst, not from the (GPL) LZO sources.
|
|
11
|
+
module LZO
|
|
12
|
+
# Raised for corrupt or truncated LZO input.
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Hadoop block header: big-endian 32-bit uncompressed size of the block.
|
|
16
|
+
HADOOP_BLOCK_PREFIX = 4
|
|
17
|
+
# Hadoop chunk header: big-endian 32-bit compressed size of the chunk.
|
|
18
|
+
HADOOP_CHUNK_PREFIX = 4
|
|
19
|
+
# Shorthand for Encoding::BINARY.
|
|
20
|
+
BINARY = Encoding::BINARY
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Parquet LZO (codec 3). hadoop-lzo writes Hadoop block framing; data that does not fit
|
|
25
|
+
# the framing is decoded as one bare LZO1X stream.
|
|
26
|
+
# @param input [String] compressed page data
|
|
27
|
+
# @param uncompressed_size [Integer] exact decompressed size, from the page header
|
|
28
|
+
# @return [String] decompressed bytes in ASCII-8BIT
|
|
29
|
+
# @raise [Error] if neither layout decodes
|
|
30
|
+
def decompress_hadoop(input, uncompressed_size)
|
|
31
|
+
src = binary(input)
|
|
32
|
+
try_hadoop(src, uncompressed_size) || decompress_block(src, uncompressed_size)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Decompress one bare LZO1X stream that must expand to exactly uncompressed_size bytes.
|
|
36
|
+
# @param input [String] LZO1X stream, ending with the end-of-stream marker
|
|
37
|
+
# @param uncompressed_size [Integer] exact decompressed size
|
|
38
|
+
# @return [String] decompressed bytes in ASCII-8BIT
|
|
39
|
+
# @raise [Error] if the stream is corrupt or does not decode to +uncompressed_size+ bytes
|
|
40
|
+
def decompress_block(input, uncompressed_size)
|
|
41
|
+
src = binary(input)
|
|
42
|
+
out = String.new(capacity: uncompressed_size, encoding: BINARY)
|
|
43
|
+
ip = decode_block(src, 0, src.bytesize, out, uncompressed_size)
|
|
44
|
+
raise Error, "#{src.bytesize - ip} bytes after the end of the LZO stream" unless ip == src.bytesize
|
|
45
|
+
unless out.bytesize == uncompressed_size
|
|
46
|
+
raise Error, "LZO stream decoded to #{out.bytesize} bytes, expected #{uncompressed_size}"
|
|
47
|
+
end
|
|
48
|
+
out
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param str [String] input in any encoding
|
|
52
|
+
# @return [String] +str+ itself if already binary, otherwise a binary copy
|
|
53
|
+
def binary(str)
|
|
54
|
+
(str.encoding == BINARY) ? str : str.b
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Hadoop's BlockCompressorStream layout: blocks of (uncompressed size, then chunks of
|
|
58
|
+
# (compressed size, LZO stream) until the block is filled). Each chunk is a separate stream.
|
|
59
|
+
# @param src [String] binary input
|
|
60
|
+
# @param uncompressed_size [Integer] exact decompressed size expected over all blocks
|
|
61
|
+
# @return [String, nil] decompressed bytes, or nil if +src+ is not valid Hadoop-framed LZO
|
|
62
|
+
def try_hadoop(src, uncompressed_size)
|
|
63
|
+
n = src.bytesize
|
|
64
|
+
out = String.new(capacity: uncompressed_size, encoding: BINARY)
|
|
65
|
+
ip = 0
|
|
66
|
+
while ip < n
|
|
67
|
+
return nil if n - ip < HADOOP_BLOCK_PREFIX
|
|
68
|
+
block_end = out.bytesize + src.byteslice(ip, HADOOP_BLOCK_PREFIX).unpack1("N")
|
|
69
|
+
ip += HADOOP_BLOCK_PREFIX
|
|
70
|
+
return nil if block_end > uncompressed_size
|
|
71
|
+
|
|
72
|
+
while out.bytesize < block_end
|
|
73
|
+
return nil if n - ip < HADOOP_CHUNK_PREFIX
|
|
74
|
+
csize = src.byteslice(ip, HADOOP_CHUNK_PREFIX).unpack1("N")
|
|
75
|
+
ip += HADOOP_CHUNK_PREFIX
|
|
76
|
+
return nil if csize > n - ip
|
|
77
|
+
begin
|
|
78
|
+
return nil unless decode_block(src, ip, ip + csize, out, block_end) == ip + csize
|
|
79
|
+
rescue Error
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
ip += csize
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
(out.bytesize == uncompressed_size) ? out : nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Decode one LZO1X stream from src[ip...iend], appending to out. Matches may only reach
|
|
89
|
+
# back to where this stream's output starts. out may not grow beyond limit.
|
|
90
|
+
#
|
|
91
|
+
# Every instruction is a match optionally followed by 0-3 literals (its low two bits, the
|
|
92
|
+
# "state"), or a run of 4+ literals. What an instruction byte below 16 means depends on
|
|
93
|
+
# the state the previous instruction left behind.
|
|
94
|
+
# @param src [String] binary input
|
|
95
|
+
# @param ip [Integer] offset of the stream in +src+
|
|
96
|
+
# @param iend [Integer] offset just past the stream
|
|
97
|
+
# @param out [String] binary output buffer, appended to
|
|
98
|
+
# @param limit [Integer] maximum total byte size of +out+
|
|
99
|
+
# @return [Integer] input offset just past the end-of-stream marker
|
|
100
|
+
# @raise [Error] if the stream is truncated, has an invalid distance or overflows +limit+
|
|
101
|
+
def decode_block(src, ip, iend, out, limit)
|
|
102
|
+
base = out.bytesize
|
|
103
|
+
raise Error, "Empty LZO stream" if ip >= iend
|
|
104
|
+
|
|
105
|
+
state = 0
|
|
106
|
+
t = src.getbyte(ip)
|
|
107
|
+
if t > 17 # the first byte may open with a literal run of its own
|
|
108
|
+
ip += 1
|
|
109
|
+
state = t - 17
|
|
110
|
+
ip = copy_literals(src, ip, iend, out, limit, state)
|
|
111
|
+
state = 4 if state > 4
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
while true
|
|
115
|
+
raise Error, "Truncated LZO stream" if ip >= iend
|
|
116
|
+
t = src.getbyte(ip)
|
|
117
|
+
ip += 1
|
|
118
|
+
|
|
119
|
+
if t < 16
|
|
120
|
+
if state == 0
|
|
121
|
+
len = t + 3
|
|
122
|
+
len, ip = extended_length(src, ip, iend, 18) if t == 0
|
|
123
|
+
ip = copy_literals(src, ip, iend, out, limit, len)
|
|
124
|
+
state = 4
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
raise Error, "Truncated LZO stream" if ip >= iend
|
|
128
|
+
dist = (src.getbyte(ip) << 2) + (t >> 2) + 1
|
|
129
|
+
ip += 1
|
|
130
|
+
if state == 4
|
|
131
|
+
dist += 2048
|
|
132
|
+
len = 3
|
|
133
|
+
else
|
|
134
|
+
len = 2
|
|
135
|
+
end
|
|
136
|
+
s = t & 3
|
|
137
|
+
elsif t < 32
|
|
138
|
+
len = (t & 7) + 2
|
|
139
|
+
len, ip = extended_length(src, ip, iend, 9) if t & 7 == 0
|
|
140
|
+
raise Error, "Truncated LZO stream" if ip + 2 > iend
|
|
141
|
+
le = src.getbyte(ip) | (src.getbyte(ip + 1) << 8)
|
|
142
|
+
ip += 2
|
|
143
|
+
dist = ((t & 8) << 11) + (le >> 2)
|
|
144
|
+
return ip if dist == 0 # end of stream
|
|
145
|
+
dist += 16_384
|
|
146
|
+
s = le & 3
|
|
147
|
+
elsif t < 64
|
|
148
|
+
len = (t & 31) + 2
|
|
149
|
+
len, ip = extended_length(src, ip, iend, 33) if t & 31 == 0
|
|
150
|
+
raise Error, "Truncated LZO stream" if ip + 2 > iend
|
|
151
|
+
le = src.getbyte(ip) | (src.getbyte(ip + 1) << 8)
|
|
152
|
+
ip += 2
|
|
153
|
+
dist = (le >> 2) + 1
|
|
154
|
+
s = le & 3
|
|
155
|
+
else
|
|
156
|
+
raise Error, "Truncated LZO stream" if ip >= iend
|
|
157
|
+
len = (t >> 5) + 1 # 3-4 bytes for 01LDDDSS, 5-8 for 1LLDDDSS
|
|
158
|
+
dist = (src.getbyte(ip) << 3) + ((t >> 2) & 7) + 1
|
|
159
|
+
ip += 1
|
|
160
|
+
s = t & 3
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
copy_match(out, base, dist, len, limit)
|
|
164
|
+
ip = copy_literals(src, ip, iend, out, limit, s) if s > 0
|
|
165
|
+
state = s
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Reads a length extension: a byte of 0 for each 255, then the nonzero remainder.
|
|
170
|
+
# @param src [String] binary input
|
|
171
|
+
# @param ip [Integer] offset of the first extension byte
|
|
172
|
+
# @param iend [Integer] offset just past the stream
|
|
173
|
+
# @param bias [Integer] added to the extension
|
|
174
|
+
# @return [Array(Integer, Integer)] the length, and the offset just past the extension
|
|
175
|
+
# @raise [Error] if the extension runs past +iend+
|
|
176
|
+
def extended_length(src, ip, iend, bias)
|
|
177
|
+
len = bias
|
|
178
|
+
while true
|
|
179
|
+
raise Error, "Truncated LZO length" if ip >= iend
|
|
180
|
+
b = src.getbyte(ip)
|
|
181
|
+
ip += 1
|
|
182
|
+
return [len + b, ip] unless b == 0
|
|
183
|
+
len += 255
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @param src [String] binary input
|
|
188
|
+
# @param ip [Integer] offset of the literals in +src+
|
|
189
|
+
# @param iend [Integer] offset just past the stream
|
|
190
|
+
# @param out [String] binary output buffer, appended to
|
|
191
|
+
# @param limit [Integer] maximum total byte size of +out+
|
|
192
|
+
# @param len [Integer] number of literal bytes
|
|
193
|
+
# @return [Integer] offset just past the literals
|
|
194
|
+
# @raise [Error] if the literals run past +iend+ or +out+ would exceed +limit+
|
|
195
|
+
def copy_literals(src, ip, iend, out, limit, len)
|
|
196
|
+
raise Error, "LZO literals run past end of input" if ip + len > iend
|
|
197
|
+
raise Error, "LZO output exceeds expected size" if out.bytesize + len > limit
|
|
198
|
+
out << src.byteslice(ip, len)
|
|
199
|
+
ip + len
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @param out [String] binary output buffer, appended to
|
|
203
|
+
# @param base [Integer] size of +out+ when the current stream started
|
|
204
|
+
# @param dist [Integer] how far back the match starts
|
|
205
|
+
# @param len [Integer] match length, which may exceed +dist+ (a repeating pattern)
|
|
206
|
+
# @param limit [Integer] maximum total byte size of +out+
|
|
207
|
+
# @return [String] +out+
|
|
208
|
+
# @raise [Error] if the match reaches before the stream's output or +out+ would exceed +limit+
|
|
209
|
+
def copy_match(out, base, dist, len, limit)
|
|
210
|
+
pos = out.bytesize
|
|
211
|
+
raise Error, "LZO match distance #{dist} reaches before start of output" if dist > pos - base
|
|
212
|
+
raise Error, "LZO output exceeds expected size" if pos + len > limit
|
|
213
|
+
if dist >= len
|
|
214
|
+
out << out.byteslice(pos - dist, len)
|
|
215
|
+
else
|
|
216
|
+
pattern = out.byteslice(pos - dist, dist)
|
|
217
|
+
out << (pattern * (len / dist)) << pattern.byteslice(0, len % dist)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private_class_method :binary, :try_hadoop, :decode_block, :extended_length, :copy_literals, :copy_match
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -1,33 +1,96 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Herringbone
|
|
4
|
+
# Pure-Ruby compression codecs for Parquet pages: Snappy, and LZ4 (raw blocks and the Hadoop framing).
|
|
5
|
+
# Compression dispatches to these; GZIP uses Zlib and ZSTD/BROTLI use external gems.
|
|
4
6
|
module Codecs
|
|
5
7
|
# Pure-Ruby implementation of the raw Snappy block format (as used by Parquet),
|
|
6
8
|
# see https://github.com/google/snappy/blob/main/format_description.txt
|
|
7
9
|
#
|
|
10
|
+
# When the `snappy` gem (a binding to Google's libsnappy) is loaded, it is used instead:
|
|
11
|
+
# 12x faster decompression and 27x faster compression. It is optional and only a speedup: if
|
|
12
|
+
# it is missing, the pure-Ruby code below is used silently. Both produce raw Snappy blocks the
|
|
13
|
+
# other reads.
|
|
14
|
+
#
|
|
8
15
|
# 32-bit loads are done with four getbyte calls rather than unpack1(offset:) to stay
|
|
9
16
|
# compatible with Ruby 3.0 - the speed difference on MRI is marginal.
|
|
10
17
|
module Snappy
|
|
18
|
+
# Raised for corrupt or truncated Snappy input, and for input too large to compress.
|
|
11
19
|
class Error < StandardError; end
|
|
12
20
|
|
|
21
|
+
# The compressor works on independent 64 KiB fragments, as the reference implementation does.
|
|
13
22
|
BLOCK_SIZE = 1 << 16
|
|
23
|
+
# Size of the match-finder hash table, in bits (16384 entries).
|
|
14
24
|
HASH_BITS = 14
|
|
25
|
+
# Right shift that keeps the top HASH_BITS bits of the 32-bit multiplicative hash.
|
|
15
26
|
HASH_SHIFT = 32 - HASH_BITS
|
|
27
|
+
# Multiplier of the reference implementation's 4-byte hash.
|
|
16
28
|
HASH_MUL = 0x1e35a7bd
|
|
17
29
|
INPUT_MARGIN = 15 # bytes at the block end never searched for matches, as in the reference
|
|
30
|
+
# The length preamble is a 32-bit varint, so no block may decompress to more than this.
|
|
18
31
|
MAX_UNCOMPRESSED = (1 << 32) - 1
|
|
19
32
|
|
|
33
|
+
# Name of the optional native gem.
|
|
34
|
+
NATIVE_GEM = "snappy"
|
|
35
|
+
|
|
20
36
|
module_function
|
|
21
37
|
|
|
22
38
|
# @param input [String] raw snappy block
|
|
23
39
|
# @return [String] decompressed bytes in ASCII-8BIT
|
|
40
|
+
# @raise [Error] if the block is corrupt or truncated
|
|
24
41
|
def decompress(input)
|
|
25
|
-
src = input.encoding == Encoding::BINARY ? input : input.b
|
|
42
|
+
src = (input.encoding == Encoding::BINARY) ? input : input.b
|
|
43
|
+
if (lib = native)
|
|
44
|
+
begin
|
|
45
|
+
return lib.inflate(src)
|
|
46
|
+
rescue lib::Error => e
|
|
47
|
+
raise Error, "corrupt snappy data (#{e.message})"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
26
50
|
return decompress_io_buffer(src) if IOBufferSupport::AVAILABLE
|
|
27
51
|
decompress_string(src)
|
|
28
52
|
end
|
|
29
53
|
|
|
54
|
+
# The backend in use: :native (the snappy gem) or :ruby
|
|
55
|
+
# @return [Symbol] +:native+ or +:ruby+
|
|
56
|
+
def backend
|
|
57
|
+
native ? :native : :ruby
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# For tests and benchmarks: :ruby forces pure Ruby, :native the snappy gem
|
|
61
|
+
# (UnsupportedError if it is not loaded), nil goes back to the default
|
|
62
|
+
# @param name [Symbol, nil] +:ruby+, +:native+ or nil
|
|
63
|
+
# @return [void]
|
|
64
|
+
# @raise [UnsupportedError] if +:native+ is requested and the gem is not loaded
|
|
65
|
+
# @raise [ArgumentError] for any other backend name
|
|
66
|
+
def backend=(name)
|
|
67
|
+
@native = case name
|
|
68
|
+
when :ruby then false
|
|
69
|
+
when :native
|
|
70
|
+
native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem is not loaded (require \"#{NATIVE_GEM}\")")
|
|
71
|
+
when nil then nil
|
|
72
|
+
else raise ArgumentError, "Unknown Snappy backend #{name.inspect} (expected :ruby, :native or nil)"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The native library to use: the snappy gem when it is loaded, unless backend= forced one.
|
|
77
|
+
# @return [Module, nil] the +::Snappy+ module, or nil when the pure-Ruby code should run
|
|
78
|
+
def native
|
|
79
|
+
native = @native
|
|
80
|
+
native = native_library if native.nil?
|
|
81
|
+
native || nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Not memoized, so that it works the same in every Ractor and once the gem is required later.
|
|
85
|
+
# @return [Module, false] the +::Snappy+ module, or false if it is not loaded or lacks inflate/deflate
|
|
86
|
+
def native_library
|
|
87
|
+
(defined?(::Snappy.inflate) && defined?(::Snappy.deflate)) ? ::Snappy : false
|
|
88
|
+
end
|
|
89
|
+
|
|
30
90
|
# Decompresses into a preallocated IO::Buffer: copies do not allocate intermediate Strings.
|
|
91
|
+
# @param src [String] raw snappy block in ASCII-8BIT
|
|
92
|
+
# @return [String] decompressed bytes in ASCII-8BIT
|
|
93
|
+
# @raise [Error] if the block is corrupt or truncated
|
|
31
94
|
def decompress_io_buffer(src)
|
|
32
95
|
n = src.bytesize
|
|
33
96
|
expected, pos = read_varint(src, n)
|
|
@@ -98,6 +161,10 @@ module Herringbone
|
|
|
98
161
|
inbuf&.free
|
|
99
162
|
end
|
|
100
163
|
|
|
164
|
+
# Decompresses by appending to a String; the fallback when IO::Buffer is not available.
|
|
165
|
+
# @param src [String] raw snappy block in ASCII-8BIT
|
|
166
|
+
# @return [String] decompressed bytes in ASCII-8BIT
|
|
167
|
+
# @raise [Error] if the block is corrupt or truncated
|
|
101
168
|
def decompress_string(src)
|
|
102
169
|
n = src.bytesize
|
|
103
170
|
expected, pos = read_varint(src, n)
|
|
@@ -162,10 +229,15 @@ module Herringbone
|
|
|
162
229
|
|
|
163
230
|
# @param input [String] bytes to compress
|
|
164
231
|
# @return [String] raw snappy block in ASCII-8BIT
|
|
232
|
+
# @raise [Error] if the input is larger than MAX_UNCOMPRESSED
|
|
165
233
|
def compress(input)
|
|
166
|
-
src = input.encoding == Encoding::BINARY ? input : input.b
|
|
234
|
+
src = (input.encoding == Encoding::BINARY) ? input : input.b
|
|
167
235
|
n = src.bytesize
|
|
236
|
+
# Checked for libsnappy too: it would truncate the 32-bit length preamble without complaint
|
|
168
237
|
raise Error, "input too large for snappy" if n > MAX_UNCOMPRESSED
|
|
238
|
+
if (lib = native)
|
|
239
|
+
return lib.deflate(src)
|
|
240
|
+
end
|
|
169
241
|
|
|
170
242
|
out = String.new(capacity: 32 + n + n / 6, encoding: Encoding::BINARY)
|
|
171
243
|
write_varint(out, n)
|
|
@@ -181,6 +253,11 @@ module Herringbone
|
|
|
181
253
|
out
|
|
182
254
|
end
|
|
183
255
|
|
|
256
|
+
# Reads the uncompressed-length preamble (a little-endian base-128 varint) at the start of +src+.
|
|
257
|
+
# @param src [String] raw snappy block
|
|
258
|
+
# @param n [Integer] byte size of +src+
|
|
259
|
+
# @return [Array(Integer, Integer)] the declared uncompressed length and the offset just past the varint
|
|
260
|
+
# @raise [Error] if the varint is truncated, longer than 5 bytes or exceeds MAX_UNCOMPRESSED
|
|
184
261
|
def read_varint(src, n)
|
|
185
262
|
value = 0
|
|
186
263
|
shift = 0
|
|
@@ -198,6 +275,10 @@ module Herringbone
|
|
|
198
275
|
[value, pos]
|
|
199
276
|
end
|
|
200
277
|
|
|
278
|
+
# Appends +value+ as a little-endian base-128 varint (the length preamble).
|
|
279
|
+
# @param out [String] binary output buffer, appended to
|
|
280
|
+
# @param value [Integer] non-negative length to encode
|
|
281
|
+
# @return [String] +out+
|
|
201
282
|
def write_varint(out, value)
|
|
202
283
|
while value >= 0x80
|
|
203
284
|
out << ((value & 0x7f) | 0x80)
|
|
@@ -210,6 +291,12 @@ module Herringbone
|
|
|
210
291
|
# positions relative to `base`; matches never cross the block boundary.
|
|
211
292
|
# The 4-byte little-endian word at every position of the block is unpacked up front
|
|
212
293
|
# (in C, via String#unpack) so hashing and match checks are single Array lookups.
|
|
294
|
+
# @param src [String] whole input in ASCII-8BIT
|
|
295
|
+
# @param base [Integer] offset of the fragment in +src+
|
|
296
|
+
# @param len [Integer] fragment length, at most BLOCK_SIZE
|
|
297
|
+
# @param out [String] binary output buffer, appended to
|
|
298
|
+
# @param table [Array<Integer>] zeroed hash table of 1 << HASH_BITS fragment-relative positions
|
|
299
|
+
# @return [void]
|
|
213
300
|
def compress_block(src, base, len, out, table)
|
|
214
301
|
ip_end = base + len
|
|
215
302
|
next_emit = base
|
|
@@ -280,12 +367,24 @@ module Herringbone
|
|
|
280
367
|
|
|
281
368
|
# words[k][j] is the 4-byte little-endian value at base + 4 * j + k, so the word at
|
|
282
369
|
# relative position i is words[i & 3][i >> 2]
|
|
370
|
+
# @param src [String] whole input in ASCII-8BIT
|
|
371
|
+
# @param base [Integer] offset of the fragment in +src+
|
|
372
|
+
# @param len [Integer] fragment length
|
|
373
|
+
# @return [Array<Array<Integer>>] four arrays of 32-bit words, one per byte phase
|
|
283
374
|
def block_words(src, base, len)
|
|
284
375
|
Array.new(4) { |k| src.byteslice(base + k, len - k).unpack("V*") }
|
|
285
376
|
end
|
|
286
377
|
|
|
287
378
|
# Like match_length, but compares 4 bytes at a time using the unpacked block words,
|
|
288
379
|
# which avoids allocating substrings for the (common) short matches.
|
|
380
|
+
# Falls back to match_length once a match reaches 64 bytes.
|
|
381
|
+
# @param words [Array<Array<Integer>>] fragment words from block_words
|
|
382
|
+
# @param src [String] whole input in ASCII-8BIT
|
|
383
|
+
# @param base [Integer] offset of the fragment in +src+
|
|
384
|
+
# @param s1 [Integer] absolute offset of the earlier occurrence
|
|
385
|
+
# @param s2 [Integer] absolute offset of the current position (s1 < s2)
|
|
386
|
+
# @param limit [Integer] absolute offset not to read past (the fragment end)
|
|
387
|
+
# @return [Integer] number of equal bytes starting at s1 and s2
|
|
289
388
|
def match_length_words(words, src, base, s1, s2, limit)
|
|
290
389
|
start = s2
|
|
291
390
|
r1 = s1 - base
|
|
@@ -307,6 +406,11 @@ module Herringbone
|
|
|
307
406
|
|
|
308
407
|
# Number of equal bytes at s1 and s2 (s1 < s2), not reading past limit. Gallops
|
|
309
408
|
# with byteslice comparisons (memcmp) to avoid per-byte loops on long matches.
|
|
409
|
+
# @param src [String] whole input in ASCII-8BIT
|
|
410
|
+
# @param s1 [Integer] absolute offset of the earlier occurrence
|
|
411
|
+
# @param s2 [Integer] absolute offset of the current position
|
|
412
|
+
# @param limit [Integer] absolute offset not to read past
|
|
413
|
+
# @return [Integer] number of equal bytes
|
|
310
414
|
def match_length(src, s1, s2, limit)
|
|
311
415
|
start = s2
|
|
312
416
|
return 0 if s2 >= limit || src.getbyte(s1) != src.getbyte(s2)
|
|
@@ -330,6 +434,13 @@ module Herringbone
|
|
|
330
434
|
s2 - start
|
|
331
435
|
end
|
|
332
436
|
|
|
437
|
+
# Appends a literal element: the tag (with a 1-4 byte length extension for long literals)
|
|
438
|
+
# followed by the bytes themselves. Does nothing for an empty literal.
|
|
439
|
+
# @param out [String] binary output buffer, appended to
|
|
440
|
+
# @param src [String] whole input in ASCII-8BIT
|
|
441
|
+
# @param pos [Integer] offset of the literal bytes in +src+
|
|
442
|
+
# @param len [Integer] number of literal bytes
|
|
443
|
+
# @return [String, nil] +out+, or nil when +len+ is zero
|
|
333
444
|
def emit_literal(out, src, pos, len)
|
|
334
445
|
return if len == 0
|
|
335
446
|
n = len - 1
|
|
@@ -348,6 +459,11 @@ module Herringbone
|
|
|
348
459
|
end
|
|
349
460
|
|
|
350
461
|
# Offsets are always < 64KB (matches stay within a block), so 4-byte offsets are never needed.
|
|
462
|
+
# Long matches are split into copies of at most 64 bytes, never leaving a remainder under 4.
|
|
463
|
+
# @param out [String] binary output buffer, appended to
|
|
464
|
+
# @param offset [Integer] backward distance to the match source, 1...65536
|
|
465
|
+
# @param len [Integer] match length, at least 4
|
|
466
|
+
# @return [String] +out+
|
|
351
467
|
def emit_copy(out, offset, len)
|
|
352
468
|
while len >= 68
|
|
353
469
|
emit_copy_upto64(out, offset, 64)
|
|
@@ -360,6 +476,12 @@ module Herringbone
|
|
|
360
476
|
emit_copy_upto64(out, offset, len)
|
|
361
477
|
end
|
|
362
478
|
|
|
479
|
+
# Appends one copy element: the 2-byte form (1-byte offset) when len is 4..11 and offset < 2048,
|
|
480
|
+
# otherwise the 3-byte form with a 2-byte offset.
|
|
481
|
+
# @param out [String] binary output buffer, appended to
|
|
482
|
+
# @param offset [Integer] backward distance to the match source, 1...65536
|
|
483
|
+
# @param len [Integer] copy length, 4..64
|
|
484
|
+
# @return [String] +out+
|
|
363
485
|
def emit_copy_upto64(out, offset, len)
|
|
364
486
|
if len < 12 && offset < 2048
|
|
365
487
|
out << (1 | ((len - 4) << 2) | ((offset >> 8) << 5)) << (offset & 0xff)
|
|
@@ -368,7 +490,7 @@ module Herringbone
|
|
|
368
490
|
end
|
|
369
491
|
end
|
|
370
492
|
|
|
371
|
-
private_class_method :decompress_io_buffer, :decompress_string, :read_varint, :write_varint, :compress_block, :block_words, :match_length_words,
|
|
493
|
+
private_class_method :native, :native_library, :decompress_io_buffer, :decompress_string, :read_varint, :write_varint, :compress_block, :block_words, :match_length_words,
|
|
372
494
|
:match_length, :emit_literal, :emit_copy, :emit_copy_upto64
|
|
373
495
|
end
|
|
374
496
|
end
|
|
@@ -4,62 +4,71 @@ require "zlib"
|
|
|
4
4
|
require "stringio"
|
|
5
5
|
|
|
6
6
|
module Herringbone
|
|
7
|
-
# Raised when a file uses (or a writer asks for) a codec whose library is not
|
|
7
|
+
# Raised when a file uses (or a writer asks for) a codec whose library is not loaded
|
|
8
8
|
class MissingCodecError < UnsupportedError
|
|
9
|
+
# @return [String] codec name (+"ZSTD"+) and name of the gem providing it (+"zstd-ruby"+)
|
|
9
10
|
attr_reader :codec, :gem_name
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
# @param codec [String] codec name, as shown in the message
|
|
13
|
+
# @param gem_name [String] gem to add to the Gemfile
|
|
14
|
+
# @param path [String] what to require to load the gem
|
|
15
|
+
def initialize(codec, gem_name, path)
|
|
12
16
|
@codec = codec
|
|
13
17
|
@gem_name = gem_name
|
|
14
|
-
super("#{codec} compression needs the \"#{gem_name}\" gem, which
|
|
15
|
-
"
|
|
18
|
+
super("#{codec} compression needs the \"#{gem_name}\" gem, which is not loaded. Add " \
|
|
19
|
+
"`gem \"#{gem_name}\"` to your Gemfile and `require \"#{path}\"` to use #{codec}.")
|
|
16
20
|
end
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
# Dispatches page (de)compression by Parquet codec id. Snappy and LZ4 are pure Ruby and GZIP
|
|
20
|
-
# uses zlib, so those always work.
|
|
21
|
-
#
|
|
24
|
+
# uses zlib, so those always work. LZO is pure Ruby too, but can only be read. ZSTD and Brotli
|
|
25
|
+
# come from optional gems (zstd-ruby, brotli), which the application requires; if they are
|
|
26
|
+
# not loaded, MissingCodecError says what to add.
|
|
27
|
+
# (Snappy also uses the optional snappy gem when it is loaded, see Codecs::Snappy.)
|
|
22
28
|
module Compression
|
|
23
29
|
module_function
|
|
24
30
|
|
|
25
31
|
# Codecs backed by optional native gems: codec id => [name, gem, require path, constant]
|
|
26
|
-
LIBRARIES = {
|
|
32
|
+
LIBRARIES = Ractor.make_shareable({
|
|
27
33
|
Format::Codec::ZSTD => ["ZSTD", "zstd-ruby", "zstd-ruby", :Zstd],
|
|
28
34
|
Format::Codec::BROTLI => ["Brotli", "brotli", "brotli", :Brotli]
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
@libraries = {}
|
|
32
|
-
@library_mutex = Mutex.new
|
|
35
|
+
})
|
|
33
36
|
|
|
34
|
-
# The library module for a codec backed by an optional gem
|
|
37
|
+
# The library module for a codec backed by an optional gem
|
|
38
|
+
#
|
|
39
|
+
# @param codec [Integer] a codec id that is a key of LIBRARIES
|
|
40
|
+
# @return [Module] the gem's module (+Zstd+ or +Brotli+)
|
|
41
|
+
# @raise [MissingCodecError] when the gem is not loaded
|
|
42
|
+
# @raise [KeyError] for a codec id not in LIBRARIES
|
|
35
43
|
def library(codec)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@libraries.fetch(codec) do
|
|
39
|
-
name, gem_name, path, const = LIBRARIES.fetch(codec)
|
|
40
|
-
begin
|
|
41
|
-
require_library(path)
|
|
42
|
-
rescue LoadError => e
|
|
43
|
-
raise MissingCodecError.new(name, gem_name, e)
|
|
44
|
-
end
|
|
45
|
-
@libraries[codec] = Object.const_get(const)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
44
|
+
name, gem_name, path, const = LIBRARIES.fetch(codec)
|
|
45
|
+
loaded_library(const) || raise(MissingCodecError.new(name, gem_name, path))
|
|
49
46
|
end
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
# A separate method so tests can stub it to simulate a missing gem
|
|
49
|
+
#
|
|
50
|
+
# @param const [Symbol] top-level constant the gem defines
|
|
51
|
+
# @return [Module, nil] the gem's module, nil when it is not loaded
|
|
52
|
+
def loaded_library(const)
|
|
53
|
+
Object.const_get(const) if Object.const_defined?(const)
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
# Raises MissingCodecError (or UnsupportedError) unless +codec+ can be used
|
|
57
|
+
#
|
|
58
|
+
# @param codec [Integer, Symbol, String] codec id or name (see NAMES)
|
|
59
|
+
# @return [Module, nil] the gem's module for a gem-backed codec, nil for a built-in one
|
|
60
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
61
|
+
# @raise [UnsupportedError] for a codec herringbone cannot write (LZO)
|
|
62
|
+
# @raise [ArgumentError] for an unknown codec name
|
|
56
63
|
def ensure_available!(codec)
|
|
57
64
|
codec = codec_id(codec)
|
|
58
65
|
return library(codec) if LIBRARIES.key?(codec)
|
|
59
66
|
return if SUPPORTED.include?(codec)
|
|
67
|
+
raise UnsupportedError, "LZO compression is only supported for reading" if codec == Format::Codec::LZO
|
|
60
68
|
raise UnsupportedError, "#{Format::Codec::NAMES[codec] || codec} compression is not supported"
|
|
61
69
|
end
|
|
62
70
|
|
|
71
|
+
# Codec ids that work without optional gems
|
|
63
72
|
SUPPORTED = [
|
|
64
73
|
Format::Codec::UNCOMPRESSED, Format::Codec::SNAPPY, Format::Codec::GZIP,
|
|
65
74
|
Format::Codec::LZ4_RAW, Format::Codec::LZ4
|
|
@@ -71,8 +80,14 @@ module Herringbone
|
|
|
71
80
|
Format::Codec::LZ4_RAW => :lz4, Format::Codec::LZ4 => :lz4_hadoop, Format::Codec::ZSTD => :zstd,
|
|
72
81
|
Format::Codec::BROTLI => :brotli, Format::Codec::LZO => :lzo
|
|
73
82
|
}.freeze
|
|
83
|
+
# Codec name => codec id, the inverse of NAMES
|
|
74
84
|
CODECS_BY_NAME = NAMES.invert.freeze
|
|
75
85
|
|
|
86
|
+
# Codec id for a codec name; Integers are taken to be ids already and returned unchecked
|
|
87
|
+
#
|
|
88
|
+
# @param name [Integer, Symbol, String] codec id, or a name from NAMES (case-insensitive)
|
|
89
|
+
# @return [Integer] the Format::Codec id
|
|
90
|
+
# @raise [ArgumentError] for an unknown name
|
|
76
91
|
def codec_id(name)
|
|
77
92
|
return name if name.is_a?(Integer)
|
|
78
93
|
CODECS_BY_NAME.fetch(name.to_s.downcase.to_sym) do
|
|
@@ -80,6 +95,15 @@ module Herringbone
|
|
|
80
95
|
end
|
|
81
96
|
end
|
|
82
97
|
|
|
98
|
+
# Decompresses a page body, checking the result against the size the page header declares
|
|
99
|
+
#
|
|
100
|
+
# @param codec [Integer] Format::Codec id from the column chunk metadata
|
|
101
|
+
# @param data [String] compressed bytes
|
|
102
|
+
# @param uncompressed_size [Integer] expected decompressed size in bytes
|
|
103
|
+
# @return [String] decompressed bytes (binary)
|
|
104
|
+
# @raise [UnsupportedError] for a codec herringbone does not implement
|
|
105
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
106
|
+
# @raise [FormatError] when the decompressed size does not match +uncompressed_size+
|
|
83
107
|
def decompress(codec, data, uncompressed_size)
|
|
84
108
|
return "".b if uncompressed_size.zero? && data.empty?
|
|
85
109
|
out = case codec
|
|
@@ -88,6 +112,7 @@ module Herringbone
|
|
|
88
112
|
when Format::Codec::GZIP then gunzip(data)
|
|
89
113
|
when Format::Codec::LZ4_RAW then Codecs::LZ4.decompress_block(data, uncompressed_size)
|
|
90
114
|
when Format::Codec::LZ4 then Codecs::LZ4.decompress_hadoop(data, uncompressed_size)
|
|
115
|
+
when Format::Codec::LZO then Codecs::LZO.decompress_hadoop(data, uncompressed_size)
|
|
91
116
|
when Format::Codec::ZSTD then library(codec).decompress(data)
|
|
92
117
|
when Format::Codec::BROTLI then library(codec).inflate(data)
|
|
93
118
|
else
|
|
@@ -100,6 +125,13 @@ module Herringbone
|
|
|
100
125
|
out
|
|
101
126
|
end
|
|
102
127
|
|
|
128
|
+
# Compresses a page body
|
|
129
|
+
#
|
|
130
|
+
# @param codec [Integer] Format::Codec id
|
|
131
|
+
# @param data [String] bytes to compress
|
|
132
|
+
# @return [String] compressed bytes (binary)
|
|
133
|
+
# @raise [UnsupportedError] for a codec herringbone does not implement
|
|
134
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
103
135
|
def compress(codec, data)
|
|
104
136
|
case codec
|
|
105
137
|
when Format::Codec::UNCOMPRESSED then data
|
|
@@ -115,6 +147,10 @@ module Herringbone
|
|
|
115
147
|
end
|
|
116
148
|
|
|
117
149
|
# Handles files whose gzip data consists of several concatenated members
|
|
150
|
+
#
|
|
151
|
+
# @param data [String] gzip data, one or more members
|
|
152
|
+
# @return [String] the members' decompressed bytes concatenated (binary)
|
|
153
|
+
# @raise [Zlib::GzipFile::Error] on corrupt gzip data
|
|
118
154
|
def gunzip(data)
|
|
119
155
|
io = StringIO.new(data)
|
|
120
156
|
out = String.new(encoding: Encoding::BINARY)
|