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.
@@ -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 installed
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
- def initialize(codec, gem_name, load_error)
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 could not be loaded " \
15
- "(#{load_error.message}). Add `gem \"#{gem_name}\"` to your Gemfile to use #{codec}.")
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. ZSTD and Brotli come from optional gems (zstd-ruby, brotli),
21
- # which are required on first use; if they are missing, MissingCodecError says what to add.
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
- }.freeze
30
-
31
- @libraries = {}
32
- @library_mutex = Mutex.new
35
+ })
33
36
 
34
- # The library module for a codec backed by an optional gem, requiring it on first use
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
- @libraries.fetch(codec) do
37
- @library_mutex.synchronize do
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
- def require_library(path)
52
- require path
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)