herringbone 0.6.1 → 0.8.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 +45 -0
- data/MANUAL.md +196 -17
- data/README.md +22 -5
- data/bin/herringbone +56 -1
- data/lib/herringbone/active_record.rb +3 -1
- data/lib/herringbone/bloom_filter.rb +2 -0
- data/lib/herringbone/byte_values.rb +2 -0
- data/lib/herringbone/codecs/lz4.rb +9 -4
- data/lib/herringbone/codecs/lzo.rb +2 -0
- data/lib/herringbone/codecs/snappy.rb +48 -6
- data/lib/herringbone/combiner.rb +426 -0
- data/lib/herringbone/compression.rb +76 -13
- data/lib/herringbone/encodings/delta.rb +4 -0
- data/lib/herringbone/encodings/plain.rb +2 -0
- data/lib/herringbone/encodings/rle.rb +2 -0
- data/lib/herringbone/encryption.rb +2 -0
- data/lib/herringbone/inferring_writer.rb +1 -1
- data/lib/herringbone/inspector.rb +29 -5
- data/lib/herringbone/io_buffer_support.rb +2 -0
- data/lib/herringbone/reader/bloom_filters.rb +1 -0
- data/lib/herringbone/reader/chunk_copier.rb +177 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +6 -2
- data/lib/herringbone/reader/column_cursor.rb +2 -0
- data/lib/herringbone/reader/numo.rb +4 -0
- data/lib/herringbone/reader/page_stream.rb +2 -0
- data/lib/herringbone/reader/scan.rb +2 -0
- data/lib/herringbone/reader.rb +19 -6
- data/lib/herringbone/redaction/rewriter.rb +51 -157
- data/lib/herringbone/redaction.rb +25 -15
- data/lib/herringbone/restricted_readable_io.rb +85 -0
- data/lib/herringbone/schema/merge.rb +355 -0
- data/lib/herringbone/schema.rb +175 -12
- data/lib/herringbone/thrift.rb +49 -10
- data/lib/herringbone/types.rb +22 -12
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/visualizer.rb +2 -0
- data/lib/herringbone/writer.rb +58 -22
- data/lib/herringbone/xxhash.rb +2 -0
- data/lib/herringbone.rb +96 -9
- 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: b0226448164e89104b937f6d2009b6b8de9893fbabf06b20ab7379cdfd306ccd
|
|
4
|
+
data.tar.gz: a90c4223cf2f849fc839e3389cf40bba2d17145525bc99e57412f53cbb83f866
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe6316662f7ed600969dfd0ed7c297faf26c9694e9143b277bffca342b5593eb346989a99cce41e52ca27b3b655d069e53f8e279863b869bdb3703fcb0e7bb20
|
|
7
|
+
data.tar.gz: 9c441b9927dbd48f589bf42309e3468e55e47f056c363fc2edce0692e9be745de1db164bb313c04181e64b88b0262770843a983bff4a7b235a437deddd5e7b9e
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.0
|
|
6
|
+
|
|
7
|
+
- `Herringbone::MissingCodecError` explains over several lines which gem to install and that it has
|
|
8
|
+
to be required before the read or write, and names the column it could not decompress up front.
|
|
9
|
+
- `Herringbone::Schema#only(:id, :name)` and `#except(:ssn)` make a schema with some of the
|
|
10
|
+
top-level fields.
|
|
11
|
+
- `time` columns convert a `Time` to UTC when writing, as `timestamp` columns already did, instead
|
|
12
|
+
of storing its time of day in its own zone.
|
|
13
|
+
- **Breaking:** `local_timestamp` and `local_time` (`s.local_timestamp :opens_at`) write the wall
|
|
14
|
+
clock time without converting it to UTC, replacing the `utc: false` option, which now raises.
|
|
15
|
+
|
|
16
|
+
## 0.7.0
|
|
17
|
+
|
|
18
|
+
- `Herringbone.redact` and `Herringbone::Redaction#apply` take a `Herringbone::Reader` as well as an
|
|
19
|
+
IO, using the Reader's decryption.
|
|
20
|
+
- `Herringbone::Schema#union` (`+`) and `#intersect` (`&`) match fields by name at every level of
|
|
21
|
+
nesting and widen their types without loss (int32 + int64 -> int64, uint32 + int8 -> int64,
|
|
22
|
+
int32 + float -> double, millis + micros -> micros, string + binary -> binary).
|
|
23
|
+
- `Herringbone::IncompatibleSchema` lists every field two schemas cannot agree on, at once.
|
|
24
|
+
- `Herringbone::Schema#==` compares structure: field names, order, nullability, types, field ids
|
|
25
|
+
and nesting.
|
|
26
|
+
- `Herringbone.combine(ios_or_readers, output_io)` concatenates Parquet files, copying their column
|
|
27
|
+
chunks byte for byte wherever the stored values stay the same. `schema: :union` fills fields an
|
|
28
|
+
input lacks with nulls and widens the others, `schema: :intersect` keeps the fields all inputs
|
|
29
|
+
have.
|
|
30
|
+
- `Herringbone.combine` checks every input before writing and raises one
|
|
31
|
+
`Herringbone::IncompatibleSchema` naming every input and field that does not fit.
|
|
32
|
+
- `Herringbone.combine` encrypts the output like the encrypted inputs unless `encryption:` says
|
|
33
|
+
otherwise.
|
|
34
|
+
- `Herringbone::Combiner::Report#inputs` says which fields of each input were filled with nulls,
|
|
35
|
+
widened or dropped.
|
|
36
|
+
- `herringbone combine INPUT... --output=FILE` does the same from the command line, with
|
|
37
|
+
`--schema=union|intersect`, and `--encrypt-key=KEY` or `--plaintext` to encrypt the output
|
|
38
|
+
otherwise.
|
|
39
|
+
- `Herringbone.redact` raises `Herringbone::FormatError` for a corrupt page header in a row group it
|
|
40
|
+
copies, instead of retrying forever.
|
|
41
|
+
- Writing allocates less: compressed pages are no longer copied before they reach the IO, and GZIP,
|
|
42
|
+
ZSTD and pure-Ruby Snappy compress a page's levels and values without joining them first.
|
|
43
|
+
- Writing metadata whose integers don't fit their Thrift field raises `Herringbone::Thrift::Error`
|
|
44
|
+
instead of producing a file Herringbone itself cannot read back.
|
|
45
|
+
- Column names and key-value metadata in other encodings are converted to UTF-8 on write, and
|
|
46
|
+
ones that aren't valid UTF-8 raise `Herringbone::Thrift::Error`.
|
|
47
|
+
- **Breaking:** `Herringbone::Reader#io` returns a `Herringbone::RestrictedReadableIO` wrapping the
|
|
48
|
+
given IO, which answers only `#read`, `#seek`, `#pos`, `#size` and `#path`.
|
|
49
|
+
|
|
5
50
|
## 0.6.1
|
|
6
51
|
|
|
7
52
|
- `Herringbone::Reader` raises `Herringbone::FormatError` for forged metadata or page headers that
|
data/MANUAL.md
CHANGED
|
@@ -19,6 +19,7 @@ The full reference for Herringbone. For a quick start, see the [README](README.m
|
|
|
19
19
|
- [SimpleWriter: coming from CSV](#simplewriter-coming-from-csv)
|
|
20
20
|
- [Statistics, page indexes and bloom filters](#statistics-page-indexes-and-bloom-filters)
|
|
21
21
|
- [Writing to S3](#writing-to-s3)
|
|
22
|
+
- [Writing to Google Cloud Storage](#writing-to-google-cloud-storage)
|
|
22
23
|
- [Encryption](#encryption)
|
|
23
24
|
- [Writing encrypted files](#writing-encrypted-files)
|
|
24
25
|
- [Which settings other tools read](#which-settings-other-tools-read)
|
|
@@ -33,6 +34,9 @@ The full reference for Herringbone. For a quick start, see the [README](README.m
|
|
|
33
34
|
- [What gets erased, and what doesn't](#what-gets-erased-and-what-doesnt)
|
|
34
35
|
- [Encrypted files](#encrypted-files)
|
|
35
36
|
- [Recipes](#recipes)
|
|
37
|
+
- [Combining files](#combining-files)
|
|
38
|
+
- [Comparing and uniting schemas](#comparing-and-uniting-schemas)
|
|
39
|
+
- [Combining encrypted files](#combining-encrypted-files)
|
|
36
40
|
- [Type mapping](#type-mapping)
|
|
37
41
|
- [Inspecting files](#inspecting-files)
|
|
38
42
|
- [Command line](#command-line)
|
|
@@ -209,6 +213,17 @@ The block receives the schema builder, and the blocks of `struct`, `list` and `m
|
|
|
209
213
|
builder of their own, so the block can still call your methods and read your instance variables.
|
|
210
214
|
A block that takes no parameter raises `ArgumentError`.
|
|
211
215
|
|
|
216
|
+
`only` and `except` make a schema with some of the top-level fields, keeping their order, for
|
|
217
|
+
instance to write part of a file you are reading:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
schema.only(:id, :name) # id and name
|
|
221
|
+
schema.except(:ssn) # every field but ssn
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A name the schema has no top-level field for raises `ArgumentError`, and so does leaving out
|
|
225
|
+
every field.
|
|
226
|
+
|
|
212
227
|
### Writing with an inferred schema
|
|
213
228
|
|
|
214
229
|
`Herringbone.write(io, rows)` writes an Enumerable of rows in one go, inferring the schema from the
|
|
@@ -229,11 +244,17 @@ file and rename it.
|
|
|
229
244
|
### Column types
|
|
230
245
|
|
|
231
246
|
`boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16 string binary
|
|
232
|
-
json bson enum uuid date int96 time timestamp decimal fixed`, plus
|
|
247
|
+
json bson enum uuid date int96 time local_time timestamp local_timestamp decimal fixed`, plus
|
|
248
|
+
`struct`, `list` and `map`;
|
|
233
249
|
`s.column :name, :int32` declares one by name. Fields are nullable unless `null: false` is given;
|
|
234
250
|
list elements unless `element_null: false`, map values unless `value_null: false`. Nested lists:
|
|
235
|
-
`s.list :matrix do |matrix| matrix.list :element, :double end`.
|
|
236
|
-
(`:millis`, `:micros`, `:nanos`)
|
|
251
|
+
`s.list :matrix do |matrix| matrix.list :element, :double end`. The time and timestamp types take `unit:`
|
|
252
|
+
(`:millis`, `:micros`, `:nanos`).
|
|
253
|
+
|
|
254
|
+
`timestamp` stores instants: a `Time` in any zone is converted to UTC, so the file means the same
|
|
255
|
+
thing wherever it was written. `local_timestamp` stores the wall clock date and time as given, with
|
|
256
|
+
no zone, for values like "the shop opens at 09:00" that are not instants. `time` and `local_time`
|
|
257
|
+
do the same for a time of day.
|
|
237
258
|
|
|
238
259
|
`enum` is a string column. `values:` restricts what may be written, and also takes a Rails-style
|
|
239
260
|
Hash (`values: Order.statuses`), in which case both labels and stored integers are accepted and
|
|
@@ -247,8 +268,8 @@ Columns accept the values Ruby and Rails code usually has at hand:
|
|
|
247
268
|
| column | accepts |
|
|
248
269
|
|---|---|
|
|
249
270
|
| `date` | `Date`, `Time`/`DateTime` (their date), `"2024-05-01"` |
|
|
250
|
-
| `timestamp` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date` (midnight UTC), ISO-8601 strings, Integers in the column's unit |
|
|
251
|
-
| `time` | `Time` (its time of day, as Rails returns for `time` columns), `"13:45:30.25"`, Integers |
|
|
271
|
+
| `timestamp`, `local_timestamp` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date` (midnight UTC), ISO-8601 strings, Integers in the column's unit |
|
|
272
|
+
| `time`, `local_time` | `Time` (its time of day, in UTC for `time`, as Rails returns for `time` columns), `"13:45:30.25"`, Integers |
|
|
252
273
|
| `json` | Strings as-is, anything else through `JSON.generate` |
|
|
253
274
|
| `string`, `enum` | Strings, Symbols, anything with `to_s` |
|
|
254
275
|
| `boolean` | `true`/`false`, `1`/`0`, `"t"`/`"f"`, `"true"`/`"false"`, `"yes"`/`"no"` |
|
|
@@ -339,6 +360,18 @@ If the block raises, the SDK aborts the multipart upload and raises `Aws::S3::Mu
|
|
|
339
360
|
so no partial object is left. S3 allows at most 10,000 parts, which with the default 5MB parts caps
|
|
340
361
|
the file at about 48GB; raise `part_size:` for bigger files.
|
|
341
362
|
|
|
363
|
+
### Writing to Google Cloud Storage
|
|
364
|
+
|
|
365
|
+
`google-cloud-storage` needs the size of an upload up front, so it can't take a stream. Use
|
|
366
|
+
[gcs_put](https://github.com/julik/gcs_put), which streams into a resumable upload of unknown size:
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
gcs_file = Google::Cloud::Storage.new.bucket("exports").file("orders.parquet", skip_lookup: true)
|
|
370
|
+
GCSPut.open_gcs_file(gcs_file) { |io| Herringbone.write(io, Order.all) }
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
If the block raises, the upload is never finalized and no object appears.
|
|
374
|
+
|
|
342
375
|
## Encryption
|
|
343
376
|
|
|
344
377
|
Herringbone reads and writes files encrypted with
|
|
@@ -520,22 +553,22 @@ nothing touches are copied byte for byte.
|
|
|
520
553
|
|
|
521
554
|
```ruby
|
|
522
555
|
# Forget me: remove the rows
|
|
523
|
-
Herringbone.redact(
|
|
556
|
+
Herringbone.redact(io, output_io) do |r|
|
|
524
557
|
r.where(user_id: 42).delete
|
|
525
558
|
end
|
|
526
559
|
|
|
527
560
|
# Forget me, but keep the row for accounting: blank the personal columns
|
|
528
|
-
Herringbone.redact(
|
|
561
|
+
Herringbone.redact(io, output_io) do |r|
|
|
529
562
|
r.where(user_id: 42).replace(email: nil, name: nil, address: nil)
|
|
530
563
|
end
|
|
531
564
|
|
|
532
565
|
# Forget me, but keep the row linkable: pseudonymize just that user's email
|
|
533
|
-
Herringbone.redact(
|
|
566
|
+
Herringbone.redact(io, output_io) do |r|
|
|
534
567
|
r.where(user_id: 42).replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email) }
|
|
535
568
|
end
|
|
536
569
|
|
|
537
570
|
# Pseudonymize a column across the whole file, mask another, drop a third
|
|
538
|
-
Herringbone.redact(
|
|
571
|
+
Herringbone.redact(io, output_io) do |r|
|
|
539
572
|
r.replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
|
|
540
573
|
r.replace(:phone) { |phone| phone && "***#{phone[-3..]}" }
|
|
541
574
|
r.drop :ssn, :ip_address
|
|
@@ -575,8 +608,8 @@ forget = Herringbone::Redaction.new do |r|
|
|
|
575
608
|
r.where(created_at: ..2.years.ago).delete # retention works the same way
|
|
576
609
|
end
|
|
577
610
|
|
|
578
|
-
if forget.affects?(
|
|
579
|
-
report = forget.apply(
|
|
611
|
+
if forget.affects?(io) # statistics and bloom filters first, then the where columns only
|
|
612
|
+
report = forget.apply(io, output_io)
|
|
580
613
|
report.rows_deleted # => 3
|
|
581
614
|
report.row_groups # => { copied: 61, rewritten: 1 }
|
|
582
615
|
end
|
|
@@ -588,7 +621,16 @@ The `Redaction::Report` that `apply` returns has `rows_read`, `rows_deleted`, `r
|
|
|
588
621
|
### Input and output
|
|
589
622
|
|
|
590
623
|
`apply` and `Herringbone.redact` take IOs like `Reader` and `Writer` do: the input must be
|
|
591
|
-
seekable, the output only needs `#write`, and neither is closed.
|
|
624
|
+
seekable, the output only needs `#write`, and neither is closed. Herringbone doesn't open files by
|
|
625
|
+
path, so open them yourself. The input can also be a `Herringbone::Reader` you already have, and
|
|
626
|
+
then its IO and its decryption are used. Whatever `keys:` or `time_zone:` it was built with, the
|
|
627
|
+
redaction reads values the same way (and blocks get rows with String keys):
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
reader = Herringbone::Reader.new(io, keys: :symbol, decryption: {keys: kms_lookup})
|
|
631
|
+
Herringbone.redact(reader, output_io) { |r| r.where(user_id: 42).delete }
|
|
632
|
+
```
|
|
633
|
+
To redact in place, write to a
|
|
592
634
|
temporary file and rename it over the original; on S3, read the object and write the new one
|
|
593
635
|
through `upload_stream`.
|
|
594
636
|
|
|
@@ -619,10 +661,11 @@ there), and other files with the same person in them need their own pass.
|
|
|
619
661
|
Pass the input's keys in `decryption:`. The output is then encrypted the same way: same algorithm,
|
|
620
662
|
footer mode, AAD prefix, keys and key metadata, for the columns that are kept. `encryption:` (as
|
|
621
663
|
for `Writer`) writes it with other settings, and `encryption: false` writes a plaintext file.
|
|
622
|
-
`Redaction#affects?` takes `decryption:` too.
|
|
664
|
+
`Redaction#affects?` takes `decryption:` too. A `Herringbone::Reader` brings its own keys, so
|
|
665
|
+
`decryption:` is not given with one.
|
|
623
666
|
|
|
624
667
|
```ruby
|
|
625
|
-
Herringbone.redact(
|
|
668
|
+
Herringbone.redact(io, output_io, decryption: {keys: kms_lookup}) do |r|
|
|
626
669
|
r.where(user_id: 42).delete
|
|
627
670
|
end
|
|
628
671
|
```
|
|
@@ -639,7 +682,7 @@ rotate or destroy it to cut the link:
|
|
|
639
682
|
```ruby
|
|
640
683
|
require "openssl"
|
|
641
684
|
KEY = ENV.fetch("PSEUDONYM_KEY")
|
|
642
|
-
Herringbone.redact(
|
|
685
|
+
Herringbone.redact(io, output_io) do |r|
|
|
643
686
|
r.replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
|
|
644
687
|
end
|
|
645
688
|
```
|
|
@@ -647,7 +690,7 @@ end
|
|
|
647
690
|
Masking keeps enough to be recognizable to the person but not to anyone else:
|
|
648
691
|
|
|
649
692
|
```ruby
|
|
650
|
-
Herringbone.redact(
|
|
693
|
+
Herringbone.redact(io, output_io) do |r|
|
|
651
694
|
r.replace(:card_number) { |number| number && number[-4..].rjust(number.size, "*") }
|
|
652
695
|
r.replace(:email) { |email| email&.sub(/\A(.).*@/, '\1***@') }
|
|
653
696
|
r.replace(:birth_date) { |date| date && Date.new(date.year, 1, 1) }
|
|
@@ -660,7 +703,7 @@ original value to get the same fake for the same person in every file:
|
|
|
660
703
|
```ruby
|
|
661
704
|
require "faker"
|
|
662
705
|
require "zlib"
|
|
663
|
-
Herringbone.redact(
|
|
706
|
+
Herringbone.redact(io, output_io) do |r|
|
|
664
707
|
r.replace(:name) do |name|
|
|
665
708
|
next nil unless name
|
|
666
709
|
Faker::Config.random = Random.new(Zlib.crc32(name))
|
|
@@ -672,6 +715,132 @@ Herringbone.redact(input, output) do |r|
|
|
|
672
715
|
end
|
|
673
716
|
```
|
|
674
717
|
|
|
718
|
+
## Combining files
|
|
719
|
+
|
|
720
|
+
`Herringbone.combine` concatenates Parquet files into one: every row group of every input, in
|
|
721
|
+
order. The inputs are an Array (or any Enumerable) of seekable IOs or `Herringbone::Reader`s, and
|
|
722
|
+
the output any IO with `#write`, like for `Reader` and `Writer`. Herringbone never opens files by
|
|
723
|
+
path, and closes nothing it was given.
|
|
724
|
+
|
|
725
|
+
```ruby
|
|
726
|
+
paths = %w[2026-01.parquet 2026-02.parquet 2026-03.parquet]
|
|
727
|
+
ios = paths.map { |path| File.open(path, "rb") }
|
|
728
|
+
File.open("2026-q1.parquet", "wb") { |output_io| Herringbone.combine(ios, output_io) }
|
|
729
|
+
ios.each(&:close)
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
Every input is read twice: its footer first, while `combine` works out the output schema and
|
|
733
|
+
checks every input against it, then its column chunks. Keep the IOs open until `combine` returns,
|
|
734
|
+
and mind the limit on open files (256 by default on macOS) when combining very many of them.
|
|
735
|
+
|
|
736
|
+
When the inputs have the same schema, nothing is decoded: the column chunks are copied byte for
|
|
737
|
+
byte, with their statistics, page indexes and bloom filters, and only the offsets pointing at them
|
|
738
|
+
are rebased. Row groups keep their boundaries, so a hundred small files make a file of a hundred
|
|
739
|
+
small row groups; to merge row groups, read the files and write the rows with `Writer` instead.
|
|
740
|
+
|
|
741
|
+
The footer's key/value metadata is the first input's (without `ARROW:schema` and `pandas` when the
|
|
742
|
+
output schema is another than the first input's), unless `metadata:` is given. Writer options
|
|
743
|
+
(`compression:`, `page_rows:`...) apply to the column chunks that have to be encoded again;
|
|
744
|
+
`row_group_bytes:` and `row_group_rows:` are refused. Those chunks keep the codec of the original
|
|
745
|
+
chunk, and get a bloom filter if they had one.
|
|
746
|
+
|
|
747
|
+
### Inputs with different schemas
|
|
748
|
+
|
|
749
|
+
`schema:` says what the output holds:
|
|
750
|
+
|
|
751
|
+
| `schema:` | Output schema | Inputs |
|
|
752
|
+
|----------------|------------------------------------------------------------------|----------------------------------------------|
|
|
753
|
+
| not given | the schema of the first input | must all have it (`Schema#==`) |
|
|
754
|
+
| `:union` | every field of every input (`Schema#union`) | fields an input lacks are written as nulls |
|
|
755
|
+
| `:intersect` | the fields all inputs have (`Schema#intersect`) | the other fields are dropped |
|
|
756
|
+
| a `Schema` | as given | must each fit it: the same fields or fewer, the same types or narrower |
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
759
|
+
Herringbone.combine([old, new], output_io, schema: :union)
|
|
760
|
+
Herringbone.combine([old, new], output_io, schema: :intersect)
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
Fields are matched by name at every level: struct members, list elements, map keys and values. A
|
|
764
|
+
field one input lacks, or has nullable, is nullable in a union. The types of fields both have are
|
|
765
|
+
widened to one that holds the values of both without loss:
|
|
766
|
+
|
|
767
|
+
| Types | Widened to |
|
|
768
|
+
|------------------------------------------|---------------------------------------------|
|
|
769
|
+
| two signed or two unsigned integers | the wider one |
|
|
770
|
+
| unsigned and signed integer | signed, twice as wide: `uint32 + int8` -> `int64` |
|
|
771
|
+
| two floats | the wider one |
|
|
772
|
+
| integer and float | the narrowest float holding every integer exactly: `int16 + float` -> `float`, `int32 + float` -> `double` |
|
|
773
|
+
| timestamps (or times) of different units | the finer unit: `millis + micros` -> `micros` |
|
|
774
|
+
| string, enum, json | string |
|
|
775
|
+
| any of those and binary | binary |
|
|
776
|
+
|
|
777
|
+
Anything else does not fit: `uint64` with a signed integer, `int64` with any float, decimals of
|
|
778
|
+
another precision or scale, UTC with local timestamps, dates with timestamps, a struct with a
|
|
779
|
+
list, fields with different field ids, a bare `repeated` field only some inputs have (it cannot be
|
|
780
|
+
null), and inputs with no fields in common.
|
|
781
|
+
|
|
782
|
+
Column chunks are copied wherever the stored bytes stay the same, which includes widenings of the
|
|
783
|
+
annotation alone (`int8` into `int32`, `uint16` into `int32`, `string` into `binary`) and list
|
|
784
|
+
elements named another way (pyarrow's `list.item`). Columns whose physical type, time unit or
|
|
785
|
+
nullability changes (`int32` into `int64`, `millis` into `micros`, required into nullable) are
|
|
786
|
+
encoded again from their values, and so are struct members that lose their siblings to an
|
|
787
|
+
intersection.
|
|
788
|
+
|
|
789
|
+
`combine` returns a `Combiner::Report`: `rows`, `row_groups` (`{ copied:, rewritten: }`) and
|
|
790
|
+
`inputs`, which says per input which fields were `filled` with nulls, `widened` or `dropped`:
|
|
791
|
+
|
|
792
|
+
```ruby
|
|
793
|
+
report = Herringbone.combine(ios, output_io, schema: :union)
|
|
794
|
+
report.inputs.each { |input| puts "#{input.name}: nulls in #{input.filled.join(", ")}" if input.filled.any? }
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
### When inputs do not fit
|
|
798
|
+
|
|
799
|
+
Every input is checked before anything is written, and `Herringbone::IncompatibleSchema` names
|
|
800
|
+
every input and every field that does not fit at once. Inputs are named by their path when the
|
|
801
|
+
IO has one:
|
|
802
|
+
|
|
803
|
+
```
|
|
804
|
+
2 of 4 inputs have another schema than input 0 (2026-01.parquet):
|
|
805
|
+
|
|
806
|
+
input 2 (2026-03.parquet)
|
|
807
|
+
address.zip int32 in input 0, string here
|
|
808
|
+
|
|
809
|
+
input 3 (2026-04.parquet)
|
|
810
|
+
id required in input 0, nullable here
|
|
811
|
+
email missing here, input 0 has it
|
|
812
|
+
mail only here, input 0 lacks it
|
|
813
|
+
|
|
814
|
+
Combine files with the same schema, or pass schema: :union to fill the fields an input lacks
|
|
815
|
+
with nulls and widen the others, or schema: :intersect to keep only the fields all inputs have.
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
`error.conflicts` has the same as `Conflict`s (`path`, `left`, `right`, `reason`, and `input`, the
|
|
819
|
+
position of the input). `Schema#union` and `Schema#intersect` raise the same error class, with
|
|
820
|
+
`input` nil.
|
|
821
|
+
|
|
822
|
+
### Combining encrypted files
|
|
823
|
+
|
|
824
|
+
Pass the keys of encrypted inputs in `decryption:`, as for `Reader` (`[key, older_key]` lets each
|
|
825
|
+
file find its own), or pass Readers, which bring their own keys. An encrypted column chunk can't
|
|
826
|
+
be copied, since its encryption is tied to the file and to its position in it, so it is decrypted
|
|
827
|
+
and encoded again.
|
|
828
|
+
|
|
829
|
+
The output is encrypted like the encrypted inputs: the same algorithm, footer mode, footer key and
|
|
830
|
+
key metadata, and the same key for each column. Plaintext inputs in the same call get that
|
|
831
|
+
encryption too, so nothing gets decrypted into a plaintext file by accident. The AAD prefix the
|
|
832
|
+
inputs share is kept.
|
|
833
|
+
|
|
834
|
+
```ruby
|
|
835
|
+
Herringbone.combine(ios, output_io, decryption: key) # encrypted with key, like the inputs
|
|
836
|
+
Herringbone.combine(ios, output_io, decryption: key, encryption: new_key) # re-keyed
|
|
837
|
+
Herringbone.combine(ios, output_io, decryption: key, encryption: false) # plaintext, on purpose
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
Inputs encrypted differently (another footer key, algorithm, footer mode or AAD prefix, a column
|
|
841
|
+
encrypted with different keys) have no single "like the inputs", so `combine` raises `ArgumentError` listing
|
|
842
|
+
how they differ, and `encryption:` decides.
|
|
843
|
+
|
|
675
844
|
## Type mapping
|
|
676
845
|
|
|
677
846
|
| Parquet | Ruby |
|
|
@@ -725,10 +894,18 @@ bin/herringbone inspect FILE [--pages] # text report (--pages li
|
|
|
725
894
|
bin/herringbone inspect FILE --format=json # everything as JSON
|
|
726
895
|
bin/herringbone inspect FILE --format=html # the HTML page, opened in your browser
|
|
727
896
|
bin/herringbone inspect FILE --format=html > out.html # the HTML page, saved
|
|
897
|
+
bin/herringbone combine A B C --output=all.parquet # concatenate files with the same schema
|
|
898
|
+
bin/herringbone combine A B --schema=union > all.parquet # fill fields an input lacks with nulls
|
|
899
|
+
bin/herringbone combine A B --schema=intersect > all.parquet # keep the fields all inputs have
|
|
728
900
|
```
|
|
729
901
|
|
|
730
902
|
Add `--verify-checksums` to any `inspect` form to check page CRCs.
|
|
731
903
|
|
|
904
|
+
`combine` does what [`Herringbone.combine`](#combining-files) does and prints how many row groups
|
|
905
|
+
it copied and encoded again. Without `--output` it writes to stdout when that is redirected.
|
|
906
|
+
`--compression=CODEC` sets the codec of the chunks it encodes again. If it fails, the output file
|
|
907
|
+
is removed rather than left without a footer, and an output that is also an input is refused.
|
|
908
|
+
|
|
732
909
|
Encrypted files take keys on the command line: as they are (picked by their fingerprint id, like
|
|
733
910
|
a keyring), or for the footer, a column path or the key id the file stores (which `inspect`
|
|
734
911
|
shows):
|
|
@@ -743,6 +920,8 @@ bin/herringbone inspect FILE --aad-prefix=orders/part-0 --no-prompt
|
|
|
743
920
|
Keys are hex, `base64:...` or `raw:...` (a 16, 24 or 32-character key that isn't valid hex is
|
|
744
921
|
taken as typed too). A key the file needs but wasn't given is asked for on stdin, without echo in
|
|
745
922
|
a terminal; an empty answer leaves that column unreadable. `--no-prompt` asks for nothing.
|
|
923
|
+
`combine` reads encrypted inputs with the same flags, and encrypts the output like them;
|
|
924
|
+
`--encrypt-key=KEY` encrypts it with one key instead, and `--plaintext` writes it decrypted.
|
|
746
925
|
|
|
747
926
|
`--format` is `text` (the default), `json` or `html`; `--pages` only goes with `text`.
|
|
748
927
|
In a terminal, `--format=html` writes the page to a temp file, prints its path and opens it with
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A pure-Ruby reader and writer for [Apache Parquet](https://parquet.apache.org/)
|
|
|
13
13
|
|
|
14
14
|
This README covers the common cases. The [MANUAL](MANUAL.md) has everything else: reader and
|
|
15
15
|
writer options, schemas and column types, bloom filters, Numo arrays, encryption, the redaction
|
|
16
|
-
rules, type mapping and the inspector.
|
|
16
|
+
rules, combining files, type mapping and the inspector.
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
@@ -46,7 +46,9 @@ end
|
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Any Enumerable of Hashes, Structs or Arrays works too; the schema is inferred from the first 1000
|
|
49
|
-
rows. See [ActiveRecord](MANUAL.md#activerecord) and [Writing to S3](MANUAL.md#writing-to-s3).
|
|
49
|
+
rows. See [ActiveRecord](MANUAL.md#activerecord) and [Writing to S3](MANUAL.md#writing-to-s3). For
|
|
50
|
+
Google Cloud Storage, use [gcs_put](https://github.com/julik/gcs_put), see
|
|
51
|
+
[Writing to Google Cloud Storage](MANUAL.md#writing-to-google-cloud-storage).
|
|
50
52
|
|
|
51
53
|
## Reading
|
|
52
54
|
|
|
@@ -125,20 +127,22 @@ footers and which tools read what.
|
|
|
125
127
|
|
|
126
128
|
`Herringbone.redact` rewrites a file with rows removed or values replaced, for GDPR "forget me"
|
|
127
129
|
requests and pseudonymization. The parts of the file nothing touches are copied byte for byte.
|
|
130
|
+
It reads from a seekable IO or from a `Herringbone::Reader`, which brings its own decryption, and
|
|
131
|
+
writes to any IO with `#write`.
|
|
128
132
|
|
|
129
133
|
```ruby
|
|
130
134
|
# Forget me: remove the rows
|
|
131
|
-
Herringbone.redact(
|
|
135
|
+
Herringbone.redact(io, output_io) do |r|
|
|
132
136
|
r.where(user_id: 42).delete
|
|
133
137
|
end
|
|
134
138
|
|
|
135
139
|
# Forget me, but keep the row for accounting: blank the personal columns
|
|
136
|
-
Herringbone.redact(
|
|
140
|
+
Herringbone.redact(io, output_io) do |r|
|
|
137
141
|
r.where(user_id: 42).replace(email: nil, name: nil, address: nil)
|
|
138
142
|
end
|
|
139
143
|
|
|
140
144
|
# Pseudonymize a column across the whole file, mask another, drop a third
|
|
141
|
-
Herringbone.redact(
|
|
145
|
+
Herringbone.redact(io, output_io) do |r|
|
|
142
146
|
r.replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
|
|
143
147
|
r.replace(:phone) { |phone| phone && "***#{phone[-3..]}" }
|
|
144
148
|
r.drop :ssn, :ip_address
|
|
@@ -147,6 +151,19 @@ end
|
|
|
147
151
|
|
|
148
152
|
See [Redaction](MANUAL.md#redaction) for reusable redactions, reports and recipes.
|
|
149
153
|
|
|
154
|
+
## Combining files
|
|
155
|
+
|
|
156
|
+
`Herringbone.combine` concatenates files with the same schema by copying their row groups byte for
|
|
157
|
+
byte, without decoding anything:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
Herringbone.combine([january, february, march], output_io)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Files with different columns combine with `schema: :union`, which fills the gaps with nulls and
|
|
164
|
+
widens types (`int32` and `int64` to `int64`), or `schema: :intersect`, which keeps the fields all
|
|
165
|
+
of them have. See [Combining files](MANUAL.md#combining-files).
|
|
166
|
+
|
|
150
167
|
## Looking inside a file
|
|
151
168
|
|
|
152
169
|
```
|
data/bin/herringbone
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
# herringbone inspect FILE --format=html write a self-contained HTML page showing the file's layout
|
|
10
10
|
# and open it in the browser (printed when stdout is redirected)
|
|
11
11
|
# add --verify-checksums to any inspect form to read page bodies and check their CRCs
|
|
12
|
+
# herringbone combine INPUT... --output=FILE concatenate files with the same schema, copying their
|
|
13
|
+
# row groups (to stdout when it is redirected and
|
|
14
|
+
# --output is not given); --schema=union fills fields an
|
|
15
|
+
# input lacks with nulls, --schema=intersect keeps only
|
|
16
|
+
# the fields all inputs have, --compression=CODEC sets
|
|
17
|
+
# the codec of the chunks that are encoded again
|
|
12
18
|
# `inspect` reads only the footer, page headers and page indexes: no values are decompressed.
|
|
13
19
|
#
|
|
14
20
|
# Encrypted files: both commands take --key=KEY (a key matched by its fingerprint id, or used for
|
|
@@ -17,6 +23,8 @@
|
|
|
17
23
|
# base64:..., or raw:... for the bytes as typed (a 16, 24 or 32-character String that is not
|
|
18
24
|
# valid hex is taken as is too). Keys that are needed but not given are asked for on stdin
|
|
19
25
|
# (without echo in a terminal; an empty answer skips the key), unless --no-prompt is given.
|
|
26
|
+
# `combine` takes the same flags for its inputs, and encrypts the output like the encrypted inputs;
|
|
27
|
+
# --encrypt-key=KEY encrypts it with that key instead, and --plaintext not at all.
|
|
20
28
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
21
29
|
require "herringbone"
|
|
22
30
|
require "json"
|
|
@@ -110,13 +118,16 @@ def decryption_from(key_flags, prompt)
|
|
|
110
118
|
end
|
|
111
119
|
end
|
|
112
120
|
decryption[:footer_key] ||= plain.first.bytes if plain.size == 1
|
|
113
|
-
|
|
121
|
+
asked = Hash.new { |h, (metadata, owner)| h[[metadata, owner]] = ask_key(metadata, owner) } # combine's inputs may share keys
|
|
122
|
+
decryption[:keys] = ->(metadata, owner) { by_metadata.fetch(metadata) { asked[[metadata, owner]] if prompt } }
|
|
114
123
|
decryption
|
|
115
124
|
end
|
|
116
125
|
|
|
117
126
|
USAGE = "usage: herringbone cat FILE [N] [KEY OPTIONS]\n" \
|
|
118
127
|
" herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums] [KEY OPTIONS]\n" \
|
|
119
128
|
" (--pages only with --format=text)\n" \
|
|
129
|
+
" herringbone combine INPUT... [--output=FILE] [--schema=union|intersect] [--compression=CODEC]\n" \
|
|
130
|
+
" [--encrypt-key=KEY | --plaintext] [KEY OPTIONS]\n" \
|
|
120
131
|
"key options, for encrypted files: --key=KEY --key=KEY_ID=KEY --footer-key=KEY --column-key=PATH=KEY\n" \
|
|
121
132
|
" --aad-prefix=PREFIX --no-prompt (KEY is hex, base64:... or raw:...)"
|
|
122
133
|
|
|
@@ -135,15 +146,54 @@ when "inspect"
|
|
|
135
146
|
path && limit.nil? && format_flags.size <= 1 && %w[text json html].include?(format) &&
|
|
136
147
|
(other_flags - %w[--pages --verify-checksums]).empty? &&
|
|
137
148
|
(format == "text" || !other_flags.include?("--pages"))
|
|
149
|
+
when "combine"
|
|
150
|
+
valued, other_flags = flags.partition { |f| f.start_with?("--output=", "--compression=", "--encrypt-key=", "--schema=") }
|
|
151
|
+
options = valued.to_h { |f| f.delete_prefix("--").split("=", 2) }
|
|
152
|
+
plaintext = other_flags.delete("--plaintext")
|
|
153
|
+
!args.empty? && options.size == valued.size && other_flags.empty? && !(plaintext && options["encrypt-key"]) &&
|
|
154
|
+
[nil, "union", "intersect"].include?(options["schema"]) &&
|
|
155
|
+
(options.key?("output") ? !options["output"].empty? : !$stdout.tty?)
|
|
138
156
|
end
|
|
139
157
|
unless valid
|
|
140
158
|
warn USAGE
|
|
141
159
|
exit 1
|
|
142
160
|
end
|
|
143
161
|
|
|
162
|
+
# Writes the inputs into one file, removing the output again when that fails
|
|
163
|
+
# @return [Herringbone::Combiner::Report]
|
|
164
|
+
def combine(paths, options, plaintext, decryption)
|
|
165
|
+
output_path = options["output"]
|
|
166
|
+
if output_path && paths.any? { |p| File.expand_path(p) == File.expand_path(output_path) || (File.exist?(output_path) && File.identical?(p, output_path)) }
|
|
167
|
+
raise ArgumentError, "#{output_path} is one of the inputs"
|
|
168
|
+
end
|
|
169
|
+
writer_options = {}
|
|
170
|
+
writer_options[:compression] = options["compression"].to_sym if options["compression"]
|
|
171
|
+
writer_options[:encryption] = Herringbone::Key.new(parse_key(options["encrypt-key"], "--encrypt-key")) if options["encrypt-key"]
|
|
172
|
+
writer_options[:encryption] = false if plaintext
|
|
173
|
+
inputs = paths.map { |p| File.open(p, "rb") }
|
|
174
|
+
output = output_path ? File.open(output_path, "wb") : $stdout.binmode
|
|
175
|
+
Herringbone.combine(inputs, output, schema: options["schema"]&.to_sym, decryption: decryption, **writer_options)
|
|
176
|
+
rescue Exception # standard:disable Lint/RescueException
|
|
177
|
+
# A file without its footer is of no use to anyone
|
|
178
|
+
if output_path && output
|
|
179
|
+
output.close
|
|
180
|
+
File.delete(output_path)
|
|
181
|
+
end
|
|
182
|
+
raise
|
|
183
|
+
ensure
|
|
184
|
+
output.close if output_path && output && !output.closed?
|
|
185
|
+
inputs&.each(&:close)
|
|
186
|
+
end
|
|
187
|
+
|
|
144
188
|
# Parquet, file and key errors are reported in one line, without a backtrace
|
|
145
189
|
begin
|
|
146
190
|
decryption = decryption_from(key_flags, prompt)
|
|
191
|
+
if command == "combine"
|
|
192
|
+
report = combine(args, options, plaintext, decryption)
|
|
193
|
+
copied, rewritten = report.row_groups.values_at(:copied, :rewritten)
|
|
194
|
+
warn "herringbone: #{report.rows} rows, #{copied} row groups copied, #{rewritten} encoded again"
|
|
195
|
+
exit
|
|
196
|
+
end
|
|
147
197
|
File.open(path, "rb") do |io|
|
|
148
198
|
if command == "cat"
|
|
149
199
|
n = limit&.to_i
|
|
@@ -171,5 +221,10 @@ begin
|
|
|
171
221
|
rescue Herringbone::Error, SystemCallError, ArgumentError => e
|
|
172
222
|
warn "herringbone: #{e.message}"
|
|
173
223
|
warn "herringbone: give keys with --footer-key=KEY, --column-key=PATH=KEY or --key=KEY_METADATA=KEY" if e.is_a?(Herringbone::DecryptionError)
|
|
224
|
+
warn "herringbone: give --encrypt-key=KEY to encrypt the output, or --plaintext" if e.message.include?("Pass encryption: for the output")
|
|
225
|
+
if e.is_a?(Herringbone::IncompatibleSchema) && command == "combine"
|
|
226
|
+
warn "herringbone: give --schema=union to fill the fields an input lacks with nulls, or --schema=intersect to keep " \
|
|
227
|
+
"only the fields all inputs have"
|
|
228
|
+
end
|
|
174
229
|
exit 1
|
|
175
230
|
end
|
|
@@ -56,6 +56,8 @@ module Herringbone
|
|
|
56
56
|
# hstore map<string, string>
|
|
57
57
|
# anything else (string, text, citext, inet, cidr, macaddr, ...) -> string
|
|
58
58
|
# Postgres arrays (column.array, or a sql_type ending in "[]") -> list of the element type
|
|
59
|
+
#
|
|
60
|
+
# @api private
|
|
59
61
|
module ActiveRecordMapping
|
|
60
62
|
module_function
|
|
61
63
|
|
|
@@ -126,7 +128,7 @@ module Herringbone
|
|
|
126
128
|
when :boolean then [:boolean, {}]
|
|
127
129
|
when :binary then [:binary, {}]
|
|
128
130
|
when :date then [:date, {}]
|
|
129
|
-
when :datetime, :timestamp, :timestamptz then [:timestamp, {unit: :micros
|
|
131
|
+
when :datetime, :timestamp, :timestamptz then [:timestamp, {unit: :micros}]
|
|
130
132
|
when :time then [:time, {unit: :micros}]
|
|
131
133
|
when :json, :jsonb then [:json, {}]
|
|
132
134
|
when :uuid then [:uuid, {}]
|
|
@@ -12,6 +12,8 @@ module Herringbone
|
|
|
12
12
|
# like the writer converts them (the column's encoder), so a Date, Time, BigDecimal or UUID
|
|
13
13
|
# String hashes the same bytes as the stored value. Nulls are never in a bloom filter.
|
|
14
14
|
# The writer builds them (bloom_filters: option) and reads with where: consult them.
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
15
17
|
class BloomFilter
|
|
16
18
|
# The spec's eight salt constants, one per word of a block
|
|
17
19
|
SALT = [0x47b6137b, 0x44974d91, 0x8824ad5b, 0xa2b7289d, 0x705495c7, 0x2df1424b, 0x9efc4947, 0x5c6bfb31].freeze
|
|
@@ -11,6 +11,8 @@ module Herringbone
|
|
|
11
11
|
# It starts out in dictionary mode (when allowed) and switches to raw bytes once the dictionary
|
|
12
12
|
# grows too large or too many values turn out to be distinct. Strings are only rebuilt when the
|
|
13
13
|
# row group is flushed, one column at a time.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
14
16
|
class ByteValues
|
|
15
17
|
# Values in the dictionary may take this many bytes before switching to raw bytes
|
|
16
18
|
MAX_DICTIONARY_BYTES = 1024 * 1024
|
|
@@ -4,6 +4,8 @@ module Herringbone
|
|
|
4
4
|
module Codecs
|
|
5
5
|
# Pure-Ruby LZ4: raw block format (Parquet LZ4_RAW), Hadoop-framed blocks
|
|
6
6
|
# (Parquet's deprecated LZ4) and a decoder for the LZ4 frame format.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
7
9
|
module LZ4
|
|
8
10
|
# Raised for corrupt, truncated or unsupported LZ4 input.
|
|
9
11
|
class Error < StandardError; end
|
|
@@ -101,11 +103,13 @@ module Herringbone
|
|
|
101
103
|
|
|
102
104
|
# Compress into a single raw LZ4 block.
|
|
103
105
|
# @param input [String] bytes to compress
|
|
106
|
+
# @param skip [Integer] zero bytes to put before the block, for a header filled in later
|
|
104
107
|
# @return [String] raw LZ4 block in ASCII-8BIT
|
|
105
|
-
def compress_block(input)
|
|
108
|
+
def compress_block(input, skip: 0)
|
|
106
109
|
src = binary(input)
|
|
107
110
|
n = src.bytesize
|
|
108
|
-
out = String.new(capacity: n + (n / 255) + 16, encoding: BINARY)
|
|
111
|
+
out = String.new(capacity: skip + n + (n / 255) + 16, encoding: BINARY)
|
|
112
|
+
out << ("\0" * skip) if skip > 0
|
|
109
113
|
anchor = 0
|
|
110
114
|
|
|
111
115
|
if n > MFLIMIT
|
|
@@ -171,8 +175,9 @@ module Herringbone
|
|
|
171
175
|
# @return [String] Hadoop-framed LZ4 data in ASCII-8BIT
|
|
172
176
|
def compress_hadoop(input)
|
|
173
177
|
src = binary(input)
|
|
174
|
-
|
|
175
|
-
[src.bytesize,
|
|
178
|
+
out = compress_block(src, skip: HADOOP_PREFIX)
|
|
179
|
+
out[0, HADOOP_PREFIX] = [src.bytesize, out.bytesize - HADOOP_PREFIX].pack("NN")
|
|
180
|
+
out
|
|
176
181
|
end
|
|
177
182
|
|
|
178
183
|
# -- internals --
|
|
@@ -8,6 +8,8 @@ module Herringbone
|
|
|
8
8
|
#
|
|
9
9
|
# Written from the bitstream description in the Linux kernel's
|
|
10
10
|
# Documentation/staging/lzo.rst, not from the (GPL) LZO sources.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
11
13
|
module LZO
|
|
12
14
|
# Raised for corrupt or truncated LZO input.
|
|
13
15
|
class Error < StandardError; end
|