herringbone 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08770d40f03a2320d9aa60e6fe63fbfe40ea5ddeddc627bff3be2bf46f431795'
4
- data.tar.gz: b18fef0b86fd290576d254f338da456ad80dc2600c68eb0c903e6085a024ebe2
3
+ metadata.gz: 826898400d423a36ccd89740d408da3d367726cf84c6cf33c4067a3c54b8861d
4
+ data.tar.gz: 0667170a3ede8376299aff04b4081259533c8877f9823ad8fcc9aee916a86650
5
5
  SHA512:
6
- metadata.gz: 5c941e4b5f31f278ff819d25d464dab518c46e50d10435c360faf2da5994a9cff891754d00a73d8834aa08f2cd00be0387d9f6c8536459389797ba7daa1a82d0
7
- data.tar.gz: 4532d47095f0832bf4cebb86ef370882dd7287625d80b9b94f6bc49fffb1ad3317075ecf8c45a1fef3fdb2fa19c3a641c0a6c2c4664bf24f5aa664e5b686e03e
6
+ metadata.gz: 21984e5695bf75e086fa091a4c69456edd4fc7ef21ea2d73c89e0ad3de1316238d2f66cf9d8c86183c5e962a1ce965aaeac8036dc1a5c1a43aa6a3b85c983405
7
+ data.tar.gz: 9377178bd66028014087cc55ed255c4abc2ed1c0217cdfbd86d5f9fe647a4ac0d741a973415f393c31fc7af83d653ce14df33d7a7629dee69aaed554e1fa5719
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.4.0
6
+
7
+ - herringbone is now Ractor-enabled! Reading and writing work from any Ractor on Ruby 3.1+, and schemas can be passed to Ractors with
8
+ - **Breaking:** the optional gems (`zstd-ruby`, `brotli`, `snappy`, `xxhash`, `numo-narray-alt`)
9
+ are used only when loaded: require them (`Bundler.require` does) instead of relying on
10
+ Herringbone to require them on first use.
11
+ - Files with LZO-compressed pages (written by parquet-mr with hadoop-lzo) can be read. Writing
12
+ LZO is not supported.
13
+ - `Herringbone::SimpleWriter`: CSV-style writing with `headers!` and Array rows, types inferred.
14
+ - `Herringbone.write` without `schema:` reads the rows once, holding back only the first 1000 for
15
+ inference, so cursors and one-shot Enumerators no longer lose rows.
16
+ - `Herringbone.write(io, rows) { json :payload }` declares fields that replace inferred ones.
17
+ - `Herringbone::SchemaMismatch` (an `EncodeError`) explains a row that doesn't fit an inferred
18
+ schema and stops the write; `EncodeError` has `row`, `column` and `value`.
19
+ - `herringbone inspect --format=html` opens the page in the browser when run in a terminal.
20
+ `Ractor.make_shareable(schema)`.
21
+ - **Breaking:** `herringbone inspect` takes `--format=text|json|html` instead of `--json`/`--html`.
22
+
23
+ ## 0.3.0
24
+
25
+ - `read(as: :numo)` and `each_batch(as: :numo)` return Numo arrays (optional `numo-narray-alt`).
26
+ - The `snappy` gem is used for Snappy when installed: 27x faster compression, 12x faster decompression.
27
+ - Writes are forward-only, so files can be streamed into S3 multipart uploads.
28
+ - YARD documentation for the whole library.
29
+
30
+ ## 0.2.0
31
+
32
+ - Streaming reads: pages are decoded incrementally and rows assembled in batches.
33
+ - `each_batch(as: :columns)` and `read(as: :columns)` for column-order reads.
34
+ - `where:`, `from:` and `limit:` skip row groups and pages using statistics, bloom filters and the
35
+ page index.
36
+ - Page indexes (ColumnIndex and OffsetIndex) are written for every column chunk.
37
+ - Bloom filters are written and read, using the `xxhash` gem when it is installed.
38
+ - `Herringbone::Inspector`, an HTML visualizer and `herringbone inspect`: page headers, page
39
+ indexes, CRC verification and the decoded ARROW:schema, without decompressing any data.
40
+ - `Herringbone.write` takes ActiveRecord models and relations.
41
+ - `zstd-ruby` and `brotli` are optional; a missing codec raises `MissingCodecError`.
42
+ - **Breaking:** the public API was simplified: `Reader.new(io)` and `Writer.open(io, schema)`
43
+ only take IOs, Hash schemas and `Herringbone.export`/`open`/`read` are gone, and `DecodeError`
44
+ is now `FormatError`.
45
+
46
+ ## 0.1.0
47
+
48
+ - Pure-Ruby Parquet reader and writer, with no Thrift gem or native extensions.
49
+ - Full nesting support (structs, lists, maps) via Dremel shredding and assembly.
50
+ - All standard encodings, v1 and v2 data pages, Snappy, LZ4 and GZIP in Ruby, ZSTD and Brotli via gems.
51
+ - Schemas from a block DSL, inferred from rows, or built from ActiveRecord models.
52
+ - Writer coercions for Rails values, byte-based row groups and compact buffers.
data/README.md CHANGED
@@ -3,34 +3,71 @@
3
3
  A pure-Ruby reader and writer for [Apache Parquet](https://parquet.apache.org/) files.
4
4
 
5
5
  - No Thrift gem and no native extensions of its own: the Thrift compact protocol, all encodings
6
- and the Snappy and LZ4 codecs are implemented in Ruby (ZSTD and Brotli use optional gems)
6
+ and the Snappy and LZ4 codecs are implemented in Ruby (ZSTD and Brotli use optional gems).
7
+ LZO, from Hadoop-era files, can be read but not written
7
8
  - Full nesting support (structs, lists, maps, any depth) via Dremel record shredding/assembly
8
9
  - Reads files from parquet-mr, Arrow, Spark, Impala, DuckDB, Rust writers etc.
10
+ - Optimized reads with batches and pages
9
11
  - Ruby 3.0+
10
12
 
13
+ ## Diving in: dumping records in Rails
14
+
15
+ Stream a relation straight into S3, no temp file needed:
16
+
17
+ ```ruby
18
+ s3 = Aws::S3::TransferManager.new
19
+ s3.upload_stream(bucket: "exports", key: "payments.parquet") do |io|
20
+ # Schema will be auto-inferred, find_each will be used automatically
21
+ Herringbone.write(io, Payment.where(status: "settled", created_at: 1.month.ago..))
22
+ end
23
+ ```
24
+
11
25
  ## Installation
12
26
 
13
27
  ```ruby
14
28
  gem "herringbone"
15
- gem "snappy" # optional: native Snappy, 2-3x faster reads and writes of typical files
16
- gem "zstd-ruby" # optional: ZSTD (faster writes and smaller files than the default Snappy)
17
- gem "brotli" # optional: Brotli
18
- gem "xxhash" # optional: faster bloom filters
19
- gem "numo-narray-alt" # optional: read(as: :numo)
20
29
  ```
21
30
 
22
- The only dependency is `bigdecimal`. Snappy, LZ4 and GZIP always work. Snappy, Parquet's most
23
- common codec, is pure Ruby unless the `snappy` gem is installed (it needs libsnappy or cmake to
24
- build); Herringbone then uses it automatically. `Herringbone.codecs` lists
25
- the codecs this process can use, e.g. `[:none, :snappy, :gzip, :lz4, :lz4_hadoop, :zstd]`. Using
26
- a missing one raises `Herringbone::MissingCodecError` naming the gem to add: a writer raises it
27
- before writing anything, a reader when it reaches the first such page (the schema and metadata
28
- are still readable). LZO is not supported.
31
+ Add these libraries to speed up and enable certain functionality:
32
+
33
+ ```ruby
34
+ gem "snappy" # native Snappy, 2-3x faster reads and writes of typical files
35
+ gem "zstd-ruby" # ZSTD
36
+ gem "brotli" # Brotli
37
+ gem "xxhash" # faster bloom filters
38
+ gem "numo-narray-alt" # read(as: :numo)
39
+ ```
40
+
41
+ Herringbone never requires them itself: it uses whichever are loaded. Bundler.require (as in Rails)
42
+ loads them, otherwise require them yourself:
43
+
44
+ ```ruby
45
+ require "herringbone"
46
+ require "zstd-ruby"
47
+ require "numo/narray"
48
+ ```
49
+
50
+ On Ruby 3.1 and later, Herringbone works from any Ractor. Pass a schema to a Ractor with
51
+ `Ractor.make_shareable(schema)`. Rows made shareable the same way reach a writing Ractor by
52
+ reference, without being copied:
53
+
54
+ ```ruby
55
+ writer = Ractor.new("out.parquet", Ractor.make_shareable(schema)) do |path, schema|
56
+ File.open(path, "wb") do |f|
57
+ Herringbone::Writer.open(f, schema) do |w|
58
+ while (row = Ractor.receive) != :done
59
+ w << row
60
+ end
61
+ end
62
+ end
63
+ end
64
+ rows.each { |row| writer.send(Ractor.make_shareable(row)) }
65
+ writer.send(:done)
66
+ ```
29
67
 
30
68
  ## Reading
31
69
 
32
- Herringbone never opens files by path: readers take a random-access IO (a `File` opened with
33
- `"rb"`, `StringIO`, `Tempfile`...), which stays open and belongs to the caller.
70
+ Herringbone can read from any IO-ish object with random access (the IO should be seekable).
34
71
 
35
72
  ```ruby
36
73
  require "herringbone"
@@ -84,8 +121,8 @@ shows which row groups and row ranges a read would touch, without reading them.
84
121
 
85
122
  `as: :numo` returns (or yields, with `each_batch`) a Hash of column name => Numo array, and takes
86
123
  the same `columns:`, `where:`, `from:` and `limit:`. Add `gem "numo-narray-alt"` (or
87
- `numo-narray`) to your Gemfile: Herringbone requires it on first use and raises
88
- `Herringbone::UnsupportedError` naming the gem when it is missing.
124
+ `numo-narray`) to your Gemfile and `require "numo/narray"`: without it, `as: :numo` raises
125
+ `Herringbone::UnsupportedError` naming the gem.
89
126
 
90
127
  ```ruby
91
128
  cols = reader.read(as: :numo, columns: %w[id amount]) # { "id" => Numo::Int64, "amount" => Numo::DFloat }
@@ -140,7 +177,10 @@ end
140
177
 
141
178
  `Herringbone.write(io, rows)` writes an Enumerable of rows in one go, inferring the schema from the
142
179
  first 1000 rows unless `schema:` is given; fields declared in a block replace inferred ones:
143
- `Herringbone.write(io, rows, schema: Herringbone::Schema.infer(rows) { json :payload })`.
180
+ `Herringbone.write(io, rows) { json :payload }`. The rows are iterated once, holding back only
181
+ those first 1000, so lazy Enumerators and cursors that can't be rewound work. A later row that
182
+ doesn't fit the inferred types raises `Herringbone::SchemaMismatch`, which explains what was
183
+ inferred and how to declare the column, and leaves the file unfinished.
144
184
 
145
185
  The writer writes to any IO that responds to `#write` (a `File`, `StringIO`, `Tempfile`, socket or
146
186
  pipe), sequentially, and never seeks, rewinds or closes it (it does switch it to binary mode). If
@@ -192,6 +232,24 @@ Writer options:
192
232
  | `metadata` | `{}` | footer key/value metadata, read back with `reader.metadata` |
193
233
  | `bloom_filters` | none | `true`, an Array of column paths, or `{ "path" => { ndv:, fpp:, max_bytes: } }` |
194
234
 
235
+ ### Coming from CSV
236
+
237
+ `SimpleWriter` writes like the CSV gem: name the columns, then append Arrays. The types are
238
+ inferred from the first 1000 rows, as above.
239
+
240
+ ```ruby
241
+ File.open("people.parquet", "wb") do |file|
242
+ Herringbone::SimpleWriter.open(file) do |sw|
243
+ sw.headers!(:id, :name, :age)
244
+ sw << [123, "John", 12]
245
+ sw << { id: 124, name: "Jane" } # Hashes work too
246
+ end
247
+ end
248
+ ```
249
+
250
+ Unlike CSV, headers are required. A column that holds more than one type can be declared up front:
251
+ `Herringbone::SimpleWriter.new(io) { string :code }` (then call `close` when done).
252
+
195
253
  ### Statistics, page indexes and bloom filters
196
254
 
197
255
  Every column chunk gets min/max/null-count statistics and the Parquet page index (per-page min/max
@@ -204,7 +262,7 @@ emails or UUIDs; a bloom filter can. They are off by default. `bloom_filters: ["
204
262
  (or `true` for every non-boolean column) writes Parquet's split block bloom filters, sized from
205
263
  each row group's distinct values at a false positive probability of 1% (`fpp:`, up to `max_bytes:`,
206
264
  default 1MB) unless `ndv:` gives the number of distinct values. Nested leaves are named by their
207
- dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is installed:
265
+ dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is loaded:
208
266
  a million rows take about 1.2 s longer to write with a filter on an INT64 column and 2.8 s longer
209
267
  with one on a ~22-byte string column, and about 0.5 s longer with `xxhash`.
210
268
 
@@ -281,7 +339,7 @@ follows `Model.columns`.
281
339
 
282
340
  > The inspector and its HTML view are modelled on
283
341
  > **[Parquet X-ray](https://huggingface.co/spaces/cfahlgren1/parquet-xray) by cfahlgren1** —
284
- > the design and the idea are theirs. Go check it out.
342
+ > the design and the idea are theirs. Go check it out. It is amazing!
285
343
 
286
344
  `Herringbone::Inspector` examines a file using only its footer, page headers, page indexes and
287
345
  bloom filter headers. Nothing is decompressed, so it is fast on big files and works for ZSTD and
@@ -304,14 +362,20 @@ CRCs; the results then appear in `summary`, `report`, `to_h` and `to_html`.
304
362
  ## Command line
305
363
 
306
364
  ```
307
- bin/herringbone cat FILE [N] # rows as JSON lines
308
- bin/herringbone inspect FILE [--pages] # text report (--pages lists every page header)
309
- bin/herringbone inspect FILE --json # everything as JSON
310
- bin/herringbone inspect FILE --html > out.html # the HTML page
365
+ bin/herringbone cat FILE [N] # rows as JSON lines
366
+ bin/herringbone inspect FILE [--pages] # text report (--pages lists every page header)
367
+ bin/herringbone inspect FILE --format=json # everything as JSON
368
+ bin/herringbone inspect FILE --format=html # the HTML page, opened in your browser
369
+ bin/herringbone inspect FILE --format=html > out.html # the HTML page, saved
311
370
  ```
312
371
 
313
372
  Add `--verify-checksums` to any `inspect` form to check page CRCs.
314
373
 
374
+ `--format` is `text` (the default), `json` or `html`; `--pages` only goes with `text`.
375
+ In a terminal, `--format=html` writes the page to a temp file, prints its path and opens it with `open` on
376
+ macOS, `start` on Windows or `xdg-open` elsewhere. When stdout is redirected or piped it prints the
377
+ page instead.
378
+
315
379
  ## Supported format features
316
380
 
317
381
  - Encodings (read and write): PLAIN, PLAIN_DICTIONARY/RLE_DICTIONARY, RLE, DELTA_BINARY_PACKED,
data/bin/herringbone CHANGED
@@ -3,14 +3,21 @@
3
3
 
4
4
  # Usage:
5
5
  # herringbone cat FILE [N] print rows as JSON lines (optionally only the first N)
6
- # herringbone inspect FILE [--pages] print the schema, row groups, column chunks (and every page header)
7
- # herringbone inspect FILE --json print all of it as JSON
8
- # herringbone inspect FILE --html print a self-contained HTML page showing the file's layout
6
+ # herringbone inspect FILE [--pages] print the schema, row groups, column chunks (and every
7
+ # page header); same as --format=text
8
+ # herringbone inspect FILE --format=json print all of it as JSON
9
+ # herringbone inspect FILE --format=html write a self-contained HTML page showing the file's layout
10
+ # and open it in the browser (printed when stdout is redirected)
9
11
  # add --verify-checksums to any inspect form to read page bodies and check their CRCs
10
12
  # `inspect` reads only the footer, page headers and page indexes: no values are decompressed.
11
13
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
12
14
  require "herringbone"
13
15
  require "json"
16
+ %w[zstd-ruby brotli snappy xxhash].each do |lib|
17
+ require lib
18
+ rescue LoadError
19
+ end
20
+ require "tmpdir"
14
21
 
15
22
  def jsonable(v)
16
23
  case v
@@ -25,8 +32,20 @@ def jsonable(v)
25
32
  end
26
33
  end
27
34
 
35
+ # Opens a file with the platform's default application
36
+ # @return [Boolean] whether the opener could be started
37
+ def open_with_default_app(path)
38
+ command = case RbConfig::CONFIG["host_os"]
39
+ when /darwin/ then ["open", path]
40
+ when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", "", path]
41
+ else ["xdg-open", path]
42
+ end
43
+ !!system(*command, out: File::NULL, err: File::NULL, exception: false)
44
+ end
45
+
28
46
  USAGE = "usage: herringbone cat FILE [N]\n" \
29
- " herringbone inspect FILE [--pages | --json | --html] [--verify-checksums]"
47
+ " herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums]\n" \
48
+ " (--pages only with --format=text)"
30
49
 
31
50
  command, *rest = ARGV
32
51
  flags, args = rest.partition { |a| a.start_with?("--") }
@@ -34,8 +53,11 @@ path, limit = args
34
53
  valid = case command
35
54
  when "cat" then flags.empty? && path && (limit.nil? || limit.match?(/\A\d+\z/))
36
55
  when "inspect"
37
- formats = flags & %w[--pages --json --html]
38
- path && limit.nil? && (flags - %w[--pages --json --html --verify-checksums]).empty? && formats.size <= 1
56
+ format_flags, other_flags = flags.partition { |f| f.start_with?("--format=") }
57
+ format = format_flags.empty? ? "text" : format_flags.first.delete_prefix("--format=")
58
+ path && limit.nil? && format_flags.size <= 1 && %w[text json html].include?(format) &&
59
+ (other_flags - %w[--pages --verify-checksums]).empty? &&
60
+ (format == "text" || !other_flags.include?("--pages"))
39
61
  end
40
62
  unless valid
41
63
  warn USAGE
@@ -51,11 +73,19 @@ begin
51
73
  else
52
74
  inspector = Herringbone::Inspector.new(io)
53
75
  inspector.verify_checksums if flags.include?("--verify-checksums")
54
- if flags.include?("--json")
76
+ case format
77
+ when "json"
55
78
  puts JSON.pretty_generate(inspector.to_h)
56
- elsif flags.include?("--html")
57
- $stdout.write(inspector.to_html)
58
- else
79
+ when "html"
80
+ if $stdout.tty?
81
+ html_path = File.join(Dir.tmpdir, "#{File.basename(path, ".*")}-#{Process.pid}.html")
82
+ File.binwrite(html_path, inspector.to_html)
83
+ puts html_path
84
+ warn "herringbone: could not open the page, open #{html_path} in a browser" unless open_with_default_app(html_path)
85
+ else
86
+ $stdout.write(inspector.to_html)
87
+ end
88
+ when "text"
59
89
  puts inspector.report(pages: flags.include?("--pages"))
60
90
  end
61
91
  end
@@ -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) can be loaded, it is used instead:
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, passed to +require+.
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 requires the snappy gem
61
- # (UnsupportedError if it cannot be loaded), nil goes back to the default
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 cannot be loaded
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 could not be loaded")
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, resolved (and memoized) on first call unless forced by backend=.
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
- @native = native_library || false if @native.nil?
80
- @native || nil
79
+ native = @native
80
+ native = native_library if native.nil?
81
+ native || nil
81
82
  end
82
83
 
83
- # Tries to load the snappy gem once and memoizes the result.
84
- # @return [Module, false] the +::Snappy+ module, or false if it is missing or lacks inflate/deflate
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
- if @native_lib.nil?
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.