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 +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +88 -24
- data/bin/herringbone +40 -10
- 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 +161 -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/schema.rb +7 -12
- data/lib/herringbone/simple_writer.rb +152 -0
- data/lib/herringbone/thrift.rb +5 -5
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +15 -13
- data/lib/herringbone/xxhash.rb +21 -36
- data/lib/herringbone.rb +52 -8
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 826898400d423a36ccd89740d408da3d367726cf84c6cf33c4067a3c54b8861d
|
|
4
|
+
data.tar.gz: 0667170a3ede8376299aff04b4081259533c8877f9823ad8fcc9aee916a86650
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
88
|
-
`Herringbone::UnsupportedError` naming the gem
|
|
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
|
|
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
|
|
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]
|
|
308
|
-
bin/herringbone inspect FILE [--pages]
|
|
309
|
-
bin/herringbone inspect FILE --json # everything as JSON
|
|
310
|
-
bin/herringbone inspect FILE --html
|
|
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]
|
|
7
|
-
#
|
|
8
|
-
# herringbone inspect FILE --
|
|
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 [--
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
76
|
+
case format
|
|
77
|
+
when "json"
|
|
55
78
|
puts JSON.pretty_generate(inspector.to_h)
|
|
56
|
-
|
|
57
|
-
$stdout.
|
|
58
|
-
|
|
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)
|
|
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.
|