herringbone 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +236 -38
- data/bin/herringbone +40 -10
- data/lib/herringbone/active_record.rb +1 -1
- data/lib/herringbone/byte_values.rb +9 -0
- data/lib/herringbone/codecs/lzo.rb +224 -0
- data/lib/herringbone/codecs/snappy.rb +13 -20
- data/lib/herringbone/compression.rb +26 -38
- data/lib/herringbone/encodings/plain.rb +2 -2
- data/lib/herringbone/inferring_writer.rb +168 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +5 -8
- data/lib/herringbone/reader/numo.rb +18 -26
- data/lib/herringbone/reader.rb +3 -3
- data/lib/herringbone/redaction/rewriter.rb +533 -0
- data/lib/herringbone/redaction.rb +287 -0
- data/lib/herringbone/schema.rb +128 -69
- data/lib/herringbone/simple_writer.rb +162 -0
- data/lib/herringbone/thrift.rb +5 -5
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +156 -38
- data/lib/herringbone/xxhash.rb +21 -36
- data/lib/herringbone.rb +95 -9
- metadata +8 -2
|
@@ -0,0 +1,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
|
|
@@ -7,7 +7,7 @@ module Herringbone
|
|
|
7
7
|
# Pure-Ruby implementation of the raw Snappy block format (as used by Parquet),
|
|
8
8
|
# see https://github.com/google/snappy/blob/main/format_description.txt
|
|
9
9
|
#
|
|
10
|
-
# When the `snappy` gem (a binding to Google's libsnappy)
|
|
10
|
+
# When the `snappy` gem (a binding to Google's libsnappy) is loaded, it is used instead:
|
|
11
11
|
# 12x faster decompression and 27x faster compression. It is optional and only a speedup: if
|
|
12
12
|
# it is missing, the pure-Ruby code below is used silently. Both produce raw Snappy blocks the
|
|
13
13
|
# other reads.
|
|
@@ -30,7 +30,7 @@ module Herringbone
|
|
|
30
30
|
# The length preamble is a 32-bit varint, so no block may decompress to more than this.
|
|
31
31
|
MAX_UNCOMPRESSED = (1 << 32) - 1
|
|
32
32
|
|
|
33
|
-
# Name of the optional native gem
|
|
33
|
+
# Name of the optional native gem.
|
|
34
34
|
NATIVE_GEM = "snappy"
|
|
35
35
|
|
|
36
36
|
module_function
|
|
@@ -57,41 +57,34 @@ module Herringbone
|
|
|
57
57
|
native ? :native : :ruby
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
# For tests and benchmarks: :ruby forces pure Ruby, :native
|
|
61
|
-
# (UnsupportedError if it
|
|
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
62
|
# @param name [Symbol, nil] +:ruby+, +:native+ or nil
|
|
63
63
|
# @return [void]
|
|
64
|
-
# @raise [UnsupportedError] if +:native+ is requested and the gem
|
|
64
|
+
# @raise [UnsupportedError] if +:native+ is requested and the gem is not loaded
|
|
65
65
|
# @raise [ArgumentError] for any other backend name
|
|
66
66
|
def backend=(name)
|
|
67
67
|
@native = case name
|
|
68
68
|
when :ruby then false
|
|
69
69
|
when :native
|
|
70
|
-
native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem
|
|
70
|
+
native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem is not loaded (require \"#{NATIVE_GEM}\")")
|
|
71
71
|
when nil then nil
|
|
72
72
|
else raise ArgumentError, "Unknown Snappy backend #{name.inspect} (expected :ruby, :native or nil)"
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# The native library to use
|
|
76
|
+
# The native library to use: the snappy gem when it is loaded, unless backend= forced one.
|
|
77
77
|
# @return [Module, nil] the +::Snappy+ module, or nil when the pure-Ruby code should run
|
|
78
78
|
def native
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
native = @native
|
|
80
|
+
native = native_library if native.nil?
|
|
81
|
+
native || nil
|
|
81
82
|
end
|
|
82
83
|
|
|
83
|
-
#
|
|
84
|
-
# @return [Module, false] the +::Snappy+ module, or false if it is
|
|
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
|
|
85
86
|
def native_library
|
|
86
|
-
|
|
87
|
-
@native_lib = begin
|
|
88
|
-
require NATIVE_GEM
|
|
89
|
-
(::Snappy.respond_to?(:inflate) && ::Snappy.respond_to?(:deflate)) ? ::Snappy : false
|
|
90
|
-
rescue LoadError
|
|
91
|
-
false
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
@native_lib
|
|
87
|
+
(defined?(::Snappy.inflate) && defined?(::Snappy.deflate)) ? ::Snappy : false
|
|
95
88
|
end
|
|
96
89
|
|
|
97
90
|
# Decompresses into a preallocated IO::Buffer: copies do not allocate intermediate Strings.
|
|
@@ -4,80 +4,67 @@ 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
9
|
# @return [String] codec name (+"ZSTD"+) and name of the gem providing it (+"zstd-ruby"+)
|
|
10
10
|
attr_reader :codec, :gem_name
|
|
11
11
|
|
|
12
12
|
# @param codec [String] codec name, as shown in the message
|
|
13
13
|
# @param gem_name [String] gem to add to the Gemfile
|
|
14
|
-
# @param
|
|
15
|
-
def initialize(codec, gem_name,
|
|
14
|
+
# @param path [String] what to require to load the gem
|
|
15
|
+
def initialize(codec, gem_name, path)
|
|
16
16
|
@codec = codec
|
|
17
17
|
@gem_name = gem_name
|
|
18
|
-
super("#{codec} compression needs the \"#{gem_name}\" gem, which
|
|
19
|
-
"
|
|
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}.")
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Dispatches page (de)compression by Parquet codec id. Snappy and LZ4 are pure Ruby and GZIP
|
|
24
|
-
# uses zlib, so those always work.
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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.)
|
|
27
28
|
module Compression
|
|
28
29
|
module_function
|
|
29
30
|
|
|
30
31
|
# Codecs backed by optional native gems: codec id => [name, gem, require path, constant]
|
|
31
|
-
LIBRARIES = {
|
|
32
|
+
LIBRARIES = Ractor.make_shareable({
|
|
32
33
|
Format::Codec::ZSTD => ["ZSTD", "zstd-ruby", "zstd-ruby", :Zstd],
|
|
33
34
|
Format::Codec::BROTLI => ["Brotli", "brotli", "brotli", :Brotli]
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
@libraries = {}
|
|
37
|
-
@library_mutex = Mutex.new
|
|
35
|
+
})
|
|
38
36
|
|
|
39
|
-
# The library module for a codec backed by an optional gem
|
|
37
|
+
# The library module for a codec backed by an optional gem
|
|
40
38
|
#
|
|
41
39
|
# @param codec [Integer] a codec id that is a key of LIBRARIES
|
|
42
40
|
# @return [Module] the gem's module (+Zstd+ or +Brotli+)
|
|
43
|
-
# @raise [MissingCodecError] when the gem
|
|
41
|
+
# @raise [MissingCodecError] when the gem is not loaded
|
|
44
42
|
# @raise [KeyError] for a codec id not in LIBRARIES
|
|
45
43
|
def library(codec)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@libraries.fetch(codec) do
|
|
49
|
-
name, gem_name, path, const = LIBRARIES.fetch(codec)
|
|
50
|
-
begin
|
|
51
|
-
require_library(path)
|
|
52
|
-
rescue LoadError => e
|
|
53
|
-
raise MissingCodecError.new(name, gem_name, e)
|
|
54
|
-
end
|
|
55
|
-
@libraries[codec] = Object.const_get(const)
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
44
|
+
name, gem_name, path, const = LIBRARIES.fetch(codec)
|
|
45
|
+
loaded_library(const) || raise(MissingCodecError.new(name, gem_name, path))
|
|
59
46
|
end
|
|
60
47
|
|
|
61
|
-
#
|
|
48
|
+
# A separate method so tests can stub it to simulate a missing gem
|
|
62
49
|
#
|
|
63
|
-
# @param
|
|
64
|
-
# @return [
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
require path
|
|
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)
|
|
68
54
|
end
|
|
69
55
|
|
|
70
56
|
# Raises MissingCodecError (or UnsupportedError) unless +codec+ can be used
|
|
71
57
|
#
|
|
72
58
|
# @param codec [Integer, Symbol, String] codec id or name (see NAMES)
|
|
73
59
|
# @return [Module, nil] the gem's module for a gem-backed codec, nil for a built-in one
|
|
74
|
-
# @raise [MissingCodecError] when the codec's gem
|
|
75
|
-
# @raise [UnsupportedError] for a codec herringbone
|
|
60
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
61
|
+
# @raise [UnsupportedError] for a codec herringbone cannot write (LZO)
|
|
76
62
|
# @raise [ArgumentError] for an unknown codec name
|
|
77
63
|
def ensure_available!(codec)
|
|
78
64
|
codec = codec_id(codec)
|
|
79
65
|
return library(codec) if LIBRARIES.key?(codec)
|
|
80
66
|
return if SUPPORTED.include?(codec)
|
|
67
|
+
raise UnsupportedError, "LZO compression is only supported for reading" if codec == Format::Codec::LZO
|
|
81
68
|
raise UnsupportedError, "#{Format::Codec::NAMES[codec] || codec} compression is not supported"
|
|
82
69
|
end
|
|
83
70
|
|
|
@@ -115,7 +102,7 @@ module Herringbone
|
|
|
115
102
|
# @param uncompressed_size [Integer] expected decompressed size in bytes
|
|
116
103
|
# @return [String] decompressed bytes (binary)
|
|
117
104
|
# @raise [UnsupportedError] for a codec herringbone does not implement
|
|
118
|
-
# @raise [MissingCodecError] when the codec's gem
|
|
105
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
119
106
|
# @raise [FormatError] when the decompressed size does not match +uncompressed_size+
|
|
120
107
|
def decompress(codec, data, uncompressed_size)
|
|
121
108
|
return "".b if uncompressed_size.zero? && data.empty?
|
|
@@ -125,6 +112,7 @@ module Herringbone
|
|
|
125
112
|
when Format::Codec::GZIP then gunzip(data)
|
|
126
113
|
when Format::Codec::LZ4_RAW then Codecs::LZ4.decompress_block(data, uncompressed_size)
|
|
127
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)
|
|
128
116
|
when Format::Codec::ZSTD then library(codec).decompress(data)
|
|
129
117
|
when Format::Codec::BROTLI then library(codec).inflate(data)
|
|
130
118
|
else
|
|
@@ -143,7 +131,7 @@ module Herringbone
|
|
|
143
131
|
# @param data [String] bytes to compress
|
|
144
132
|
# @return [String] compressed bytes (binary)
|
|
145
133
|
# @raise [UnsupportedError] for a codec herringbone does not implement
|
|
146
|
-
# @raise [MissingCodecError] when the codec's gem
|
|
134
|
+
# @raise [MissingCodecError] when the codec's gem is not loaded
|
|
147
135
|
def compress(codec, data)
|
|
148
136
|
case codec
|
|
149
137
|
when Format::Codec::UNCOMPRESSED then data
|
|
@@ -8,12 +8,12 @@ module Herringbone
|
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
# Fixed-width numeric types: pack/unpack directive and byte width (all little-endian).
|
|
11
|
-
FORMATS = {
|
|
11
|
+
FORMATS = Ractor.make_shareable({
|
|
12
12
|
Format::Type::INT32 => ["l<", 4],
|
|
13
13
|
Format::Type::INT64 => ["q<", 8],
|
|
14
14
|
Format::Type::FLOAT => ["e", 4],
|
|
15
15
|
Format::Type::DOUBLE => ["E", 8]
|
|
16
|
-
}
|
|
16
|
+
})
|
|
17
17
|
|
|
18
18
|
# Decodes +count+ values of +type+ from +data+ starting at +pos+.
|
|
19
19
|
# Returns [values, new_pos].
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Herringbone
|
|
4
|
+
# Writes rows whose schema is inferred from the rows themselves, reading them only once: the
|
|
5
|
+
# first Schema::INFER_SAMPLE rows are held back, the schema is built from them, and then they and
|
|
6
|
+
# every later row go straight to a Writer. Memory is bounded by the sample, however many rows
|
|
7
|
+
# follow it. Nothing is written to the IO before the schema is known, so a source that can only
|
|
8
|
+
# be iterated once (a cursor, a queue, a lazy Enumerator over an IO) loses no rows.
|
|
9
|
+
#
|
|
10
|
+
# A row that does not fit the inferred schema raises SchemaMismatch and stops the writer: the
|
|
11
|
+
# file is left unfinished (no footer) rather than written with rows missing.
|
|
12
|
+
#
|
|
13
|
+
# Used by Herringbone.write and SimpleWriter.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
class InferringWriter
|
|
17
|
+
# @param io [IO, #write] destination, passed to Writer.new
|
|
18
|
+
# @param fix [String] how a caller declares a column, for SchemaMismatch messages; +%s+ is
|
|
19
|
+
# replaced by a declaration such as +string :age+
|
|
20
|
+
# @param options [Hash{Symbol => Object}] Writer options
|
|
21
|
+
# @option options [Symbol] :compression (:snappy) codec, checked before any row is read
|
|
22
|
+
# (the other Writer options are passed on as well)
|
|
23
|
+
# @yield [sample] builds the schema once the sample is complete (or at #close)
|
|
24
|
+
# @yieldparam sample [Array] the rows held back, at most Schema::INFER_SAMPLE
|
|
25
|
+
# @yieldreturn [Schema]
|
|
26
|
+
# @raise [MissingCodecError] when the codec's optional gem is not loaded
|
|
27
|
+
def initialize(io, fix:, **options, &schema_for)
|
|
28
|
+
# Fail before reading any rows if the codec's library is missing
|
|
29
|
+
Compression.ensure_available!(Compression.codec_id(options.fetch(:compression, :snappy)))
|
|
30
|
+
@io = io
|
|
31
|
+
@fix = fix
|
|
32
|
+
@options = options
|
|
33
|
+
@schema_for = schema_for
|
|
34
|
+
@sample = []
|
|
35
|
+
@sample_size = nil
|
|
36
|
+
@writer = nil
|
|
37
|
+
@mismatch = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param row [Object] row to write, in any form the Writer accepts
|
|
41
|
+
# @return [self]
|
|
42
|
+
# @raise [SchemaMismatch] when the row does not fit the inferred schema
|
|
43
|
+
# @raise [Error] after an earlier SchemaMismatch
|
|
44
|
+
def <<(row)
|
|
45
|
+
check_usable!
|
|
46
|
+
if @writer
|
|
47
|
+
write(row)
|
|
48
|
+
else
|
|
49
|
+
@sample << row
|
|
50
|
+
start if @sample.size >= Schema::INFER_SAMPLE
|
|
51
|
+
end
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer] rows accepted so far, including the ones held back
|
|
56
|
+
def rows_written = @writer ? @writer.rows_written : @sample.size
|
|
57
|
+
|
|
58
|
+
# Writes the held-back rows if the sample never filled up, then finishes the file
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
# @raise [SchemaMismatch] when a held-back row does not fit the inferred schema
|
|
62
|
+
# @raise [Error] after an earlier SchemaMismatch
|
|
63
|
+
def close
|
|
64
|
+
check_usable!
|
|
65
|
+
start unless @writer
|
|
66
|
+
@writer.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Stops without finishing the file; when the schema was never built nothing was written at all
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
def abort
|
|
73
|
+
@writer&.abort
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Short summary for the console, without the held-back rows
|
|
77
|
+
#
|
|
78
|
+
# @return [String] the number of rows held back, or the Writer's summary once it is open
|
|
79
|
+
def inspect
|
|
80
|
+
"#<#{self.class.name} #{@writer ? "writer=#{@writer.inspect}" : "held_back=#{@sample.size}"}>"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Builds the schema from the held-back rows, opens the Writer and writes them
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [SchemaMismatch] when a held-back row does not fit
|
|
89
|
+
def start
|
|
90
|
+
@sample_size = @sample.size
|
|
91
|
+
@writer = Writer.new(@io, @schema_for.call(@sample), **@options)
|
|
92
|
+
sample, @sample = @sample, nil
|
|
93
|
+
sample.each { |row| write(row) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param row [Object] row for the Writer
|
|
97
|
+
# @return [void]
|
|
98
|
+
# @raise [SchemaMismatch] when the row does not fit; the Writer is aborted
|
|
99
|
+
def write(row)
|
|
100
|
+
@writer << row
|
|
101
|
+
rescue EncodeError => e
|
|
102
|
+
@writer.abort
|
|
103
|
+
@mismatch = SchemaMismatch.new(explain(e), row: e.row, column: e.column, value: e.value)
|
|
104
|
+
raise @mismatch
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [void]
|
|
108
|
+
# @raise [Error] after a SchemaMismatch
|
|
109
|
+
def check_usable!
|
|
110
|
+
return unless @mismatch
|
|
111
|
+
raise Error, "This writer stopped at a schema mismatch and cannot continue " \
|
|
112
|
+
"(the file is unfinished):\n#{@mismatch.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A multi-line explanation of an EncodeError against the inferred schema
|
|
116
|
+
#
|
|
117
|
+
# @param error [EncodeError] error raised by the Writer
|
|
118
|
+
# @return [String]
|
|
119
|
+
def explain(error)
|
|
120
|
+
column = error.column && @writer.schema.column(error.column)
|
|
121
|
+
column ||= error.column && @writer.schema.columns.find { |c| c.dotted_path.start_with?("#{error.column}.") }
|
|
122
|
+
top = error.column&.split(".")&.first
|
|
123
|
+
lines = ["Row #{error.row} does not fit the schema inferred from the first #{@sample_size} rows."]
|
|
124
|
+
lines << " column: #{error.column}" if error.column
|
|
125
|
+
lines << " inferred: #{describe(column)}" if column
|
|
126
|
+
lines << " got: #{error.value.inspect[0, 200]} (#{error.value.class})" unless error.value.nil?
|
|
127
|
+
lines << " error: #{error.message.sub(/\ARow \d+: /, "")}"
|
|
128
|
+
lines << ""
|
|
129
|
+
lines << "Column types are decided from the first #{Schema::INFER_SAMPLE} rows (or all of them, when there are"
|
|
130
|
+
lines << "fewer), so a later value of a different type cannot be written to the same column."
|
|
131
|
+
if top
|
|
132
|
+
lines << "Declare the column with a type that holds every value, for example:"
|
|
133
|
+
lines << " #{format(@fix, declaration_for(top, error.value))}"
|
|
134
|
+
end
|
|
135
|
+
lines << "or pass a complete schema. The file was left unfinished (no footer)."
|
|
136
|
+
lines.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @param column [Schema::Column] leaf column
|
|
140
|
+
# @return [String] its type as the Builder DSL names it, e.g. "int64" or "timestamp (micros)"
|
|
141
|
+
def describe(column)
|
|
142
|
+
kind, *details = Types.logical_of(column.node)
|
|
143
|
+
type = case kind
|
|
144
|
+
when nil then Format::Type::NAMES[column.type].downcase
|
|
145
|
+
when :integer then "#{details[1] ? "int" : "uint"}#{details[0]}"
|
|
146
|
+
when :timestamp, :time then "#{kind} (#{details[0]})"
|
|
147
|
+
when :decimal then "decimal(#{details[1]}, #{details[0]})"
|
|
148
|
+
else kind.to_s
|
|
149
|
+
end
|
|
150
|
+
(column.path.size > 1) ? "#{type} (at #{column.dotted_path})" : type
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A Builder DSL declaration whose type can hold +value+, to suggest in the explanation
|
|
154
|
+
#
|
|
155
|
+
# @param name [String] top-level field name
|
|
156
|
+
# @param value [Object] the value that did not fit
|
|
157
|
+
# @return [String] e.g. "string :age"
|
|
158
|
+
def declaration_for(name, value)
|
|
159
|
+
type = case value
|
|
160
|
+
when Float, BigDecimal, Rational then "double"
|
|
161
|
+
when Time, DateTime then "timestamp"
|
|
162
|
+
when Hash, Array then "json"
|
|
163
|
+
else "string"
|
|
164
|
+
end
|
|
165
|
+
"#{type} :#{name}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -292,8 +292,8 @@ module Herringbone
|
|
|
292
292
|
rep_len = dh.repetition_levels_byte_length
|
|
293
293
|
def_len = dh.definition_levels_byte_length
|
|
294
294
|
reps = defs = nil
|
|
295
|
-
reps = PageStream::HybridDecoder.new(body, 0, rep_len,
|
|
296
|
-
defs = PageStream::HybridDecoder.new(body, rep_len, rep_len + def_len,
|
|
295
|
+
reps = PageStream::HybridDecoder.new(body, 0, rep_len, @max_rep.bit_length) if @max_rep.positive?
|
|
296
|
+
defs = PageStream::HybridDecoder.new(body, rep_len, rep_len + def_len, @max_def.bit_length) if @max_def.positive?
|
|
297
297
|
data = body.byteslice(rep_len + def_len, body.bytesize - rep_len - def_len)
|
|
298
298
|
if dh.is_compressed != false
|
|
299
299
|
data = decompress(data, header.uncompressed_page_size - rep_len - def_len)
|
|
@@ -302,9 +302,6 @@ module Herringbone
|
|
|
302
302
|
PageStream::Page.new(n, defs, reps, values, conv)
|
|
303
303
|
end
|
|
304
304
|
|
|
305
|
-
# Bit width of levels up to a max level (memoized Integer#bit_length)
|
|
306
|
-
RLE_WIDTH = Hash.new { |h, k| h[k] = k.bit_length }
|
|
307
|
-
|
|
308
305
|
# [decoder, position after the levels]
|
|
309
306
|
#
|
|
310
307
|
# @param data [String] the uncompressed page
|
|
@@ -317,7 +314,7 @@ module Herringbone
|
|
|
317
314
|
# @raise [FormatError] when the RLE length prefix is cut off
|
|
318
315
|
# @raise [UnsupportedError] for any other level encoding
|
|
319
316
|
def level_decoder(data, pos, encoding, max, n)
|
|
320
|
-
width =
|
|
317
|
+
width = max.bit_length
|
|
321
318
|
case encoding
|
|
322
319
|
when E::RLE
|
|
323
320
|
len = data.byteslice(pos, 4)&.unpack1("V") or raise FormatError, "Truncated levels"
|
|
@@ -332,9 +329,9 @@ module Herringbone
|
|
|
332
329
|
end
|
|
333
330
|
|
|
334
331
|
# Physical type => [String#unpack directive, byte width] for PLAIN fixed-width values
|
|
335
|
-
FIXED_FORMATS = {
|
|
332
|
+
FIXED_FORMATS = Ractor.make_shareable({
|
|
336
333
|
T::INT32 => ["l<", 4], T::INT64 => ["q<", 8], T::FLOAT => ["e", 4], T::DOUBLE => ["E", 8]
|
|
337
|
-
}
|
|
334
|
+
})
|
|
338
335
|
|
|
339
336
|
# [value decoder, converter still to apply to its values (nil for dictionary pages)]
|
|
340
337
|
#
|