herringbone 0.6.0 → 0.7.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 +40 -0
- data/MANUAL.md +161 -12
- data/README.md +19 -4
- data/bin/herringbone +56 -1
- data/lib/herringbone/active_record.rb +2 -0
- 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 +53 -9
- 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/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 +4 -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 +80 -0
- data/lib/herringbone/thrift.rb +166 -68
- data/lib/herringbone/types.rb +2 -0
- 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: e9b95c84373ccf0215789628229613c8899227be711dc204c31be6778352f2d6
|
|
4
|
+
data.tar.gz: 9314fc6d6d55660e210db0fe62369d9732e33dc3910e306f500b3ebc5c7090e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63292b6fde84386d41068903db5267fe7fa94b15c8c191d12c229f888cd94bfd1e6c78e3d01ed5f562643565e8126e9ee2f372e80ab96e08609293afeff1c3bf
|
|
7
|
+
data.tar.gz: e9946d5f2ac156222b3bca852f5fc26194ec304fa2b150fd9520311b7e3a323af3ba28e1662bc3b47e593508b22b216e148cf6af67d55318830272f1d87578e5
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.0
|
|
6
|
+
|
|
7
|
+
- `Herringbone.redact` and `Herringbone::Redaction#apply` take a `Herringbone::Reader` as well as an
|
|
8
|
+
IO, using the Reader's decryption.
|
|
9
|
+
- `Herringbone::Schema#union` (`+`) and `#intersect` (`&`) match fields by name at every level of
|
|
10
|
+
nesting and widen their types without loss (int32 + int64 -> int64, uint32 + int8 -> int64,
|
|
11
|
+
int32 + float -> double, millis + micros -> micros, string + binary -> binary).
|
|
12
|
+
- `Herringbone::IncompatibleSchema` lists every field two schemas cannot agree on, at once.
|
|
13
|
+
- `Herringbone::Schema#==` compares structure: field names, order, nullability, types, field ids
|
|
14
|
+
and nesting.
|
|
15
|
+
- `Herringbone.combine(ios_or_readers, output_io)` concatenates Parquet files, copying their column
|
|
16
|
+
chunks byte for byte wherever the stored values stay the same. `schema: :union` fills fields an
|
|
17
|
+
input lacks with nulls and widens the others, `schema: :intersect` keeps the fields all inputs
|
|
18
|
+
have.
|
|
19
|
+
- `Herringbone.combine` checks every input before writing and raises one
|
|
20
|
+
`Herringbone::IncompatibleSchema` naming every input and field that does not fit.
|
|
21
|
+
- `Herringbone.combine` encrypts the output like the encrypted inputs unless `encryption:` says
|
|
22
|
+
otherwise.
|
|
23
|
+
- `Herringbone::Combiner::Report#inputs` says which fields of each input were filled with nulls,
|
|
24
|
+
widened or dropped.
|
|
25
|
+
- `herringbone combine INPUT... --output=FILE` does the same from the command line, with
|
|
26
|
+
`--schema=union|intersect`, and `--encrypt-key=KEY` or `--plaintext` to encrypt the output
|
|
27
|
+
otherwise.
|
|
28
|
+
- `Herringbone.redact` raises `Herringbone::FormatError` for a corrupt page header in a row group it
|
|
29
|
+
copies, instead of retrying forever.
|
|
30
|
+
- Writing allocates less: compressed pages are no longer copied before they reach the IO, and GZIP,
|
|
31
|
+
ZSTD and pure-Ruby Snappy compress a page's levels and values without joining them first.
|
|
32
|
+
- Writing metadata whose integers don't fit their Thrift field raises `Herringbone::Thrift::Error`
|
|
33
|
+
instead of producing a file Herringbone itself cannot read back.
|
|
34
|
+
- Column names and key-value metadata in other encodings are converted to UTF-8 on write, and
|
|
35
|
+
ones that aren't valid UTF-8 raise `Herringbone::Thrift::Error`.
|
|
36
|
+
- **Breaking:** `Herringbone::Reader#io` returns a `Herringbone::RestrictedReadableIO` wrapping the
|
|
37
|
+
given IO, which answers only `#read`, `#seek`, `#pos`, `#size` and `#path`.
|
|
38
|
+
|
|
39
|
+
## 0.6.1
|
|
40
|
+
|
|
41
|
+
- `Herringbone::Reader` raises `Herringbone::FormatError` for forged metadata or page headers that
|
|
42
|
+
made it loop endlessly, overflow the stack or raise `ArgumentError`, `RangeError` or
|
|
43
|
+
`NoMemoryError`.
|
|
44
|
+
|
|
5
45
|
## 0.6.0
|
|
6
46
|
|
|
7
47
|
- `require "herringbone"` loads the rest of the gem on first use: under 1 ms instead of about
|
data/MANUAL.md
CHANGED
|
@@ -33,6 +33,9 @@ The full reference for Herringbone. For a quick start, see the [README](README.m
|
|
|
33
33
|
- [What gets erased, and what doesn't](#what-gets-erased-and-what-doesnt)
|
|
34
34
|
- [Encrypted files](#encrypted-files)
|
|
35
35
|
- [Recipes](#recipes)
|
|
36
|
+
- [Combining files](#combining-files)
|
|
37
|
+
- [Comparing and uniting schemas](#comparing-and-uniting-schemas)
|
|
38
|
+
- [Combining encrypted files](#combining-encrypted-files)
|
|
36
39
|
- [Type mapping](#type-mapping)
|
|
37
40
|
- [Inspecting files](#inspecting-files)
|
|
38
41
|
- [Command line](#command-line)
|
|
@@ -520,22 +523,22 @@ nothing touches are copied byte for byte.
|
|
|
520
523
|
|
|
521
524
|
```ruby
|
|
522
525
|
# Forget me: remove the rows
|
|
523
|
-
Herringbone.redact(
|
|
526
|
+
Herringbone.redact(io, output_io) do |r|
|
|
524
527
|
r.where(user_id: 42).delete
|
|
525
528
|
end
|
|
526
529
|
|
|
527
530
|
# Forget me, but keep the row for accounting: blank the personal columns
|
|
528
|
-
Herringbone.redact(
|
|
531
|
+
Herringbone.redact(io, output_io) do |r|
|
|
529
532
|
r.where(user_id: 42).replace(email: nil, name: nil, address: nil)
|
|
530
533
|
end
|
|
531
534
|
|
|
532
535
|
# Forget me, but keep the row linkable: pseudonymize just that user's email
|
|
533
|
-
Herringbone.redact(
|
|
536
|
+
Herringbone.redact(io, output_io) do |r|
|
|
534
537
|
r.where(user_id: 42).replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email) }
|
|
535
538
|
end
|
|
536
539
|
|
|
537
540
|
# Pseudonymize a column across the whole file, mask another, drop a third
|
|
538
|
-
Herringbone.redact(
|
|
541
|
+
Herringbone.redact(io, output_io) do |r|
|
|
539
542
|
r.replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
|
|
540
543
|
r.replace(:phone) { |phone| phone && "***#{phone[-3..]}" }
|
|
541
544
|
r.drop :ssn, :ip_address
|
|
@@ -575,8 +578,8 @@ forget = Herringbone::Redaction.new do |r|
|
|
|
575
578
|
r.where(created_at: ..2.years.ago).delete # retention works the same way
|
|
576
579
|
end
|
|
577
580
|
|
|
578
|
-
if forget.affects?(
|
|
579
|
-
report = forget.apply(
|
|
581
|
+
if forget.affects?(io) # statistics and bloom filters first, then the where columns only
|
|
582
|
+
report = forget.apply(io, output_io)
|
|
580
583
|
report.rows_deleted # => 3
|
|
581
584
|
report.row_groups # => { copied: 61, rewritten: 1 }
|
|
582
585
|
end
|
|
@@ -588,7 +591,16 @@ The `Redaction::Report` that `apply` returns has `rows_read`, `rows_deleted`, `r
|
|
|
588
591
|
### Input and output
|
|
589
592
|
|
|
590
593
|
`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.
|
|
594
|
+
seekable, the output only needs `#write`, and neither is closed. Herringbone doesn't open files by
|
|
595
|
+
path, so open them yourself. The input can also be a `Herringbone::Reader` you already have, and
|
|
596
|
+
then its IO and its decryption are used. Whatever `keys:` or `time_zone:` it was built with, the
|
|
597
|
+
redaction reads values the same way (and blocks get rows with String keys):
|
|
598
|
+
|
|
599
|
+
```ruby
|
|
600
|
+
reader = Herringbone::Reader.new(io, keys: :symbol, decryption: {keys: kms_lookup})
|
|
601
|
+
Herringbone.redact(reader, output_io) { |r| r.where(user_id: 42).delete }
|
|
602
|
+
```
|
|
603
|
+
To redact in place, write to a
|
|
592
604
|
temporary file and rename it over the original; on S3, read the object and write the new one
|
|
593
605
|
through `upload_stream`.
|
|
594
606
|
|
|
@@ -619,10 +631,11 @@ there), and other files with the same person in them need their own pass.
|
|
|
619
631
|
Pass the input's keys in `decryption:`. The output is then encrypted the same way: same algorithm,
|
|
620
632
|
footer mode, AAD prefix, keys and key metadata, for the columns that are kept. `encryption:` (as
|
|
621
633
|
for `Writer`) writes it with other settings, and `encryption: false` writes a plaintext file.
|
|
622
|
-
`Redaction#affects?` takes `decryption:` too.
|
|
634
|
+
`Redaction#affects?` takes `decryption:` too. A `Herringbone::Reader` brings its own keys, so
|
|
635
|
+
`decryption:` is not given with one.
|
|
623
636
|
|
|
624
637
|
```ruby
|
|
625
|
-
Herringbone.redact(
|
|
638
|
+
Herringbone.redact(io, output_io, decryption: {keys: kms_lookup}) do |r|
|
|
626
639
|
r.where(user_id: 42).delete
|
|
627
640
|
end
|
|
628
641
|
```
|
|
@@ -639,7 +652,7 @@ rotate or destroy it to cut the link:
|
|
|
639
652
|
```ruby
|
|
640
653
|
require "openssl"
|
|
641
654
|
KEY = ENV.fetch("PSEUDONYM_KEY")
|
|
642
|
-
Herringbone.redact(
|
|
655
|
+
Herringbone.redact(io, output_io) do |r|
|
|
643
656
|
r.replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
|
|
644
657
|
end
|
|
645
658
|
```
|
|
@@ -647,7 +660,7 @@ end
|
|
|
647
660
|
Masking keeps enough to be recognizable to the person but not to anyone else:
|
|
648
661
|
|
|
649
662
|
```ruby
|
|
650
|
-
Herringbone.redact(
|
|
663
|
+
Herringbone.redact(io, output_io) do |r|
|
|
651
664
|
r.replace(:card_number) { |number| number && number[-4..].rjust(number.size, "*") }
|
|
652
665
|
r.replace(:email) { |email| email&.sub(/\A(.).*@/, '\1***@') }
|
|
653
666
|
r.replace(:birth_date) { |date| date && Date.new(date.year, 1, 1) }
|
|
@@ -660,7 +673,7 @@ original value to get the same fake for the same person in every file:
|
|
|
660
673
|
```ruby
|
|
661
674
|
require "faker"
|
|
662
675
|
require "zlib"
|
|
663
|
-
Herringbone.redact(
|
|
676
|
+
Herringbone.redact(io, output_io) do |r|
|
|
664
677
|
r.replace(:name) do |name|
|
|
665
678
|
next nil unless name
|
|
666
679
|
Faker::Config.random = Random.new(Zlib.crc32(name))
|
|
@@ -672,6 +685,132 @@ Herringbone.redact(input, output) do |r|
|
|
|
672
685
|
end
|
|
673
686
|
```
|
|
674
687
|
|
|
688
|
+
## Combining files
|
|
689
|
+
|
|
690
|
+
`Herringbone.combine` concatenates Parquet files into one: every row group of every input, in
|
|
691
|
+
order. The inputs are an Array (or any Enumerable) of seekable IOs or `Herringbone::Reader`s, and
|
|
692
|
+
the output any IO with `#write`, like for `Reader` and `Writer`. Herringbone never opens files by
|
|
693
|
+
path, and closes nothing it was given.
|
|
694
|
+
|
|
695
|
+
```ruby
|
|
696
|
+
paths = %w[2026-01.parquet 2026-02.parquet 2026-03.parquet]
|
|
697
|
+
ios = paths.map { |path| File.open(path, "rb") }
|
|
698
|
+
File.open("2026-q1.parquet", "wb") { |output_io| Herringbone.combine(ios, output_io) }
|
|
699
|
+
ios.each(&:close)
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
Every input is read twice: its footer first, while `combine` works out the output schema and
|
|
703
|
+
checks every input against it, then its column chunks. Keep the IOs open until `combine` returns,
|
|
704
|
+
and mind the limit on open files (256 by default on macOS) when combining very many of them.
|
|
705
|
+
|
|
706
|
+
When the inputs have the same schema, nothing is decoded: the column chunks are copied byte for
|
|
707
|
+
byte, with their statistics, page indexes and bloom filters, and only the offsets pointing at them
|
|
708
|
+
are rebased. Row groups keep their boundaries, so a hundred small files make a file of a hundred
|
|
709
|
+
small row groups; to merge row groups, read the files and write the rows with `Writer` instead.
|
|
710
|
+
|
|
711
|
+
The footer's key/value metadata is the first input's (without `ARROW:schema` and `pandas` when the
|
|
712
|
+
output schema is another than the first input's), unless `metadata:` is given. Writer options
|
|
713
|
+
(`compression:`, `page_rows:`...) apply to the column chunks that have to be encoded again;
|
|
714
|
+
`row_group_bytes:` and `row_group_rows:` are refused. Those chunks keep the codec of the original
|
|
715
|
+
chunk, and get a bloom filter if they had one.
|
|
716
|
+
|
|
717
|
+
### Inputs with different schemas
|
|
718
|
+
|
|
719
|
+
`schema:` says what the output holds:
|
|
720
|
+
|
|
721
|
+
| `schema:` | Output schema | Inputs |
|
|
722
|
+
|----------------|------------------------------------------------------------------|----------------------------------------------|
|
|
723
|
+
| not given | the schema of the first input | must all have it (`Schema#==`) |
|
|
724
|
+
| `:union` | every field of every input (`Schema#union`) | fields an input lacks are written as nulls |
|
|
725
|
+
| `:intersect` | the fields all inputs have (`Schema#intersect`) | the other fields are dropped |
|
|
726
|
+
| a `Schema` | as given | must each fit it: the same fields or fewer, the same types or narrower |
|
|
727
|
+
|
|
728
|
+
```ruby
|
|
729
|
+
Herringbone.combine([old, new], output_io, schema: :union)
|
|
730
|
+
Herringbone.combine([old, new], output_io, schema: :intersect)
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
Fields are matched by name at every level: struct members, list elements, map keys and values. A
|
|
734
|
+
field one input lacks, or has nullable, is nullable in a union. The types of fields both have are
|
|
735
|
+
widened to one that holds the values of both without loss:
|
|
736
|
+
|
|
737
|
+
| Types | Widened to |
|
|
738
|
+
|------------------------------------------|---------------------------------------------|
|
|
739
|
+
| two signed or two unsigned integers | the wider one |
|
|
740
|
+
| unsigned and signed integer | signed, twice as wide: `uint32 + int8` -> `int64` |
|
|
741
|
+
| two floats | the wider one |
|
|
742
|
+
| integer and float | the narrowest float holding every integer exactly: `int16 + float` -> `float`, `int32 + float` -> `double` |
|
|
743
|
+
| timestamps (or times) of different units | the finer unit: `millis + micros` -> `micros` |
|
|
744
|
+
| string, enum, json | string |
|
|
745
|
+
| any of those and binary | binary |
|
|
746
|
+
|
|
747
|
+
Anything else does not fit: `uint64` with a signed integer, `int64` with any float, decimals of
|
|
748
|
+
another precision or scale, UTC with local timestamps, dates with timestamps, a struct with a
|
|
749
|
+
list, fields with different field ids, a bare `repeated` field only some inputs have (it cannot be
|
|
750
|
+
null), and inputs with no fields in common.
|
|
751
|
+
|
|
752
|
+
Column chunks are copied wherever the stored bytes stay the same, which includes widenings of the
|
|
753
|
+
annotation alone (`int8` into `int32`, `uint16` into `int32`, `string` into `binary`) and list
|
|
754
|
+
elements named another way (pyarrow's `list.item`). Columns whose physical type, time unit or
|
|
755
|
+
nullability changes (`int32` into `int64`, `millis` into `micros`, required into nullable) are
|
|
756
|
+
encoded again from their values, and so are struct members that lose their siblings to an
|
|
757
|
+
intersection.
|
|
758
|
+
|
|
759
|
+
`combine` returns a `Combiner::Report`: `rows`, `row_groups` (`{ copied:, rewritten: }`) and
|
|
760
|
+
`inputs`, which says per input which fields were `filled` with nulls, `widened` or `dropped`:
|
|
761
|
+
|
|
762
|
+
```ruby
|
|
763
|
+
report = Herringbone.combine(ios, output_io, schema: :union)
|
|
764
|
+
report.inputs.each { |input| puts "#{input.name}: nulls in #{input.filled.join(", ")}" if input.filled.any? }
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
### When inputs do not fit
|
|
768
|
+
|
|
769
|
+
Every input is checked before anything is written, and `Herringbone::IncompatibleSchema` names
|
|
770
|
+
every input and every field that does not fit at once. Inputs are named by their path when the
|
|
771
|
+
IO has one:
|
|
772
|
+
|
|
773
|
+
```
|
|
774
|
+
2 of 4 inputs have another schema than input 0 (2026-01.parquet):
|
|
775
|
+
|
|
776
|
+
input 2 (2026-03.parquet)
|
|
777
|
+
address.zip int32 in input 0, string here
|
|
778
|
+
|
|
779
|
+
input 3 (2026-04.parquet)
|
|
780
|
+
id required in input 0, nullable here
|
|
781
|
+
email missing here, input 0 has it
|
|
782
|
+
mail only here, input 0 lacks it
|
|
783
|
+
|
|
784
|
+
Combine files with the same schema, or pass schema: :union to fill the fields an input lacks
|
|
785
|
+
with nulls and widen the others, or schema: :intersect to keep only the fields all inputs have.
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
`error.conflicts` has the same as `Conflict`s (`path`, `left`, `right`, `reason`, and `input`, the
|
|
789
|
+
position of the input). `Schema#union` and `Schema#intersect` raise the same error class, with
|
|
790
|
+
`input` nil.
|
|
791
|
+
|
|
792
|
+
### Combining encrypted files
|
|
793
|
+
|
|
794
|
+
Pass the keys of encrypted inputs in `decryption:`, as for `Reader` (`[key, older_key]` lets each
|
|
795
|
+
file find its own), or pass Readers, which bring their own keys. An encrypted column chunk can't
|
|
796
|
+
be copied, since its encryption is tied to the file and to its position in it, so it is decrypted
|
|
797
|
+
and encoded again.
|
|
798
|
+
|
|
799
|
+
The output is encrypted like the encrypted inputs: the same algorithm, footer mode, footer key and
|
|
800
|
+
key metadata, and the same key for each column. Plaintext inputs in the same call get that
|
|
801
|
+
encryption too, so nothing gets decrypted into a plaintext file by accident. The AAD prefix the
|
|
802
|
+
inputs share is kept.
|
|
803
|
+
|
|
804
|
+
```ruby
|
|
805
|
+
Herringbone.combine(ios, output_io, decryption: key) # encrypted with key, like the inputs
|
|
806
|
+
Herringbone.combine(ios, output_io, decryption: key, encryption: new_key) # re-keyed
|
|
807
|
+
Herringbone.combine(ios, output_io, decryption: key, encryption: false) # plaintext, on purpose
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
Inputs encrypted differently (another footer key, algorithm, footer mode or AAD prefix, a column
|
|
811
|
+
encrypted with different keys) have no single "like the inputs", so `combine` raises `ArgumentError` listing
|
|
812
|
+
how they differ, and `encryption:` decides.
|
|
813
|
+
|
|
675
814
|
## Type mapping
|
|
676
815
|
|
|
677
816
|
| Parquet | Ruby |
|
|
@@ -725,10 +864,18 @@ bin/herringbone inspect FILE [--pages] # text report (--pages li
|
|
|
725
864
|
bin/herringbone inspect FILE --format=json # everything as JSON
|
|
726
865
|
bin/herringbone inspect FILE --format=html # the HTML page, opened in your browser
|
|
727
866
|
bin/herringbone inspect FILE --format=html > out.html # the HTML page, saved
|
|
867
|
+
bin/herringbone combine A B C --output=all.parquet # concatenate files with the same schema
|
|
868
|
+
bin/herringbone combine A B --schema=union > all.parquet # fill fields an input lacks with nulls
|
|
869
|
+
bin/herringbone combine A B --schema=intersect > all.parquet # keep the fields all inputs have
|
|
728
870
|
```
|
|
729
871
|
|
|
730
872
|
Add `--verify-checksums` to any `inspect` form to check page CRCs.
|
|
731
873
|
|
|
874
|
+
`combine` does what [`Herringbone.combine`](#combining-files) does and prints how many row groups
|
|
875
|
+
it copied and encoded again. Without `--output` it writes to stdout when that is redirected.
|
|
876
|
+
`--compression=CODEC` sets the codec of the chunks it encodes again. If it fails, the output file
|
|
877
|
+
is removed rather than left without a footer, and an output that is also an input is refused.
|
|
878
|
+
|
|
732
879
|
Encrypted files take keys on the command line: as they are (picked by their fingerprint id, like
|
|
733
880
|
a keyring), or for the footer, a column path or the key id the file stores (which `inspect`
|
|
734
881
|
shows):
|
|
@@ -743,6 +890,8 @@ bin/herringbone inspect FILE --aad-prefix=orders/part-0 --no-prompt
|
|
|
743
890
|
Keys are hex, `base64:...` or `raw:...` (a 16, 24 or 32-character key that isn't valid hex is
|
|
744
891
|
taken as typed too). A key the file needs but wasn't given is asked for on stdin, without echo in
|
|
745
892
|
a terminal; an empty answer leaves that column unreadable. `--no-prompt` asks for nothing.
|
|
893
|
+
`combine` reads encrypted inputs with the same flags, and encrypts the output like them;
|
|
894
|
+
`--encrypt-key=KEY` encrypts it with one key instead, and `--plaintext` writes it decrypted.
|
|
746
895
|
|
|
747
896
|
`--format` is `text` (the default), `json` or `html`; `--pages` only goes with `text`.
|
|
748
897
|
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
|
|
|
@@ -125,20 +125,22 @@ footers and which tools read what.
|
|
|
125
125
|
|
|
126
126
|
`Herringbone.redact` rewrites a file with rows removed or values replaced, for GDPR "forget me"
|
|
127
127
|
requests and pseudonymization. The parts of the file nothing touches are copied byte for byte.
|
|
128
|
+
It reads from a seekable IO or from a `Herringbone::Reader`, which brings its own decryption, and
|
|
129
|
+
writes to any IO with `#write`.
|
|
128
130
|
|
|
129
131
|
```ruby
|
|
130
132
|
# Forget me: remove the rows
|
|
131
|
-
Herringbone.redact(
|
|
133
|
+
Herringbone.redact(io, output_io) do |r|
|
|
132
134
|
r.where(user_id: 42).delete
|
|
133
135
|
end
|
|
134
136
|
|
|
135
137
|
# Forget me, but keep the row for accounting: blank the personal columns
|
|
136
|
-
Herringbone.redact(
|
|
138
|
+
Herringbone.redact(io, output_io) do |r|
|
|
137
139
|
r.where(user_id: 42).replace(email: nil, name: nil, address: nil)
|
|
138
140
|
end
|
|
139
141
|
|
|
140
142
|
# Pseudonymize a column across the whole file, mask another, drop a third
|
|
141
|
-
Herringbone.redact(
|
|
143
|
+
Herringbone.redact(io, output_io) do |r|
|
|
142
144
|
r.replace(:email) { |email| OpenSSL::HMAC.hexdigest("SHA256", KEY, email.downcase) }
|
|
143
145
|
r.replace(:phone) { |phone| phone && "***#{phone[-3..]}" }
|
|
144
146
|
r.drop :ssn, :ip_address
|
|
@@ -147,6 +149,19 @@ end
|
|
|
147
149
|
|
|
148
150
|
See [Redaction](MANUAL.md#redaction) for reusable redactions, reports and recipes.
|
|
149
151
|
|
|
152
|
+
## Combining files
|
|
153
|
+
|
|
154
|
+
`Herringbone.combine` concatenates files with the same schema by copying their row groups byte for
|
|
155
|
+
byte, without decoding anything:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
Herringbone.combine([january, february, march], output_io)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Files with different columns combine with `schema: :union`, which fills the gaps with nulls and
|
|
162
|
+
widens types (`int32` and `int64` to `int64`), or `schema: :intersect`, which keeps the fields all
|
|
163
|
+
of them have. See [Combining files](MANUAL.md#combining-files).
|
|
164
|
+
|
|
150
165
|
## Looking inside a file
|
|
151
166
|
|
|
152
167
|
```
|
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
|
|
|
@@ -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
|
|
@@ -14,6 +14,8 @@ module Herringbone
|
|
|
14
14
|
#
|
|
15
15
|
# 32-bit loads are done with four getbyte calls rather than unpack1(offset:) to stay
|
|
16
16
|
# compatible with Ruby 3.0 - the speed difference on MRI is marginal.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
17
19
|
module Snappy
|
|
18
20
|
# Raised for corrupt or truncated Snappy input, and for input too large to compress.
|
|
19
21
|
class Error < StandardError; end
|
|
@@ -227,32 +229,72 @@ module Herringbone
|
|
|
227
229
|
out
|
|
228
230
|
end
|
|
229
231
|
|
|
230
|
-
#
|
|
232
|
+
# Several Strings are compressed as their concatenation without joining them: fragments are
|
|
233
|
+
# compressed straight out of the part holding them, and only a fragment straddling two parts
|
|
234
|
+
# is copied out. The output is the same as for the joined input.
|
|
235
|
+
#
|
|
236
|
+
# @param input [String, Array<String>] bytes to compress, or the parts of them
|
|
231
237
|
# @return [String] raw snappy block in ASCII-8BIT
|
|
232
238
|
# @raise [Error] if the input is larger than MAX_UNCOMPRESSED
|
|
233
239
|
def compress(input)
|
|
234
|
-
|
|
235
|
-
n =
|
|
240
|
+
parts = input.is_a?(Array) ? input.map { |part| binary(part) } : [binary(input)]
|
|
241
|
+
n = parts.sum(&:bytesize)
|
|
236
242
|
# Checked for libsnappy too: it would truncate the 32-bit length preamble without complaint
|
|
237
243
|
raise Error, "input too large for snappy" if n > MAX_UNCOMPRESSED
|
|
238
244
|
if (lib = native)
|
|
239
|
-
return lib.deflate(
|
|
245
|
+
return lib.deflate((parts.size == 1) ? parts.first : parts.join)
|
|
240
246
|
end
|
|
241
247
|
|
|
242
248
|
out = String.new(capacity: 32 + n + n / 6, encoding: Encoding::BINARY)
|
|
243
249
|
write_varint(out, n)
|
|
244
250
|
table = Array.new(1 << HASH_BITS, 0)
|
|
251
|
+
part_index = 0
|
|
252
|
+
offset = 0
|
|
245
253
|
start = 0
|
|
246
254
|
while start < n
|
|
247
255
|
len = n - start
|
|
248
256
|
len = BLOCK_SIZE if len > BLOCK_SIZE
|
|
249
257
|
table.fill(0)
|
|
250
|
-
|
|
258
|
+
part_index, offset = next_part(parts, part_index, offset)
|
|
259
|
+
part = parts[part_index]
|
|
260
|
+
if offset + len <= part.bytesize
|
|
261
|
+
compress_block(part, offset, len, out, table)
|
|
262
|
+
offset += len
|
|
263
|
+
else
|
|
264
|
+
fragment = String.new(capacity: len, encoding: Encoding::BINARY)
|
|
265
|
+
while fragment.bytesize < len
|
|
266
|
+
part_index, offset = next_part(parts, part_index, offset)
|
|
267
|
+
take = [len - fragment.bytesize, parts[part_index].bytesize - offset].min
|
|
268
|
+
fragment << parts[part_index].byteslice(offset, take)
|
|
269
|
+
offset += take
|
|
270
|
+
end
|
|
271
|
+
compress_block(fragment, 0, len, out, table)
|
|
272
|
+
end
|
|
251
273
|
start += len
|
|
252
274
|
end
|
|
253
275
|
out
|
|
254
276
|
end
|
|
255
277
|
|
|
278
|
+
# @param str [String] bytes in any encoding
|
|
279
|
+
# @return [String] +str+, or a binary copy when it is in another encoding
|
|
280
|
+
def binary(str)
|
|
281
|
+
(str.encoding == Encoding::BINARY) ? str : str.b
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Skips past parts that are used up
|
|
285
|
+
#
|
|
286
|
+
# @param parts [Array<String>] the input parts
|
|
287
|
+
# @param index [Integer] index of the current part
|
|
288
|
+
# @param offset [Integer] offset in the current part
|
|
289
|
+
# @return [Array(Integer, Integer)] index of a part with bytes left at the offset, and that offset
|
|
290
|
+
def next_part(parts, index, offset)
|
|
291
|
+
while offset >= parts[index].bytesize
|
|
292
|
+
index += 1
|
|
293
|
+
offset = 0
|
|
294
|
+
end
|
|
295
|
+
[index, offset]
|
|
296
|
+
end
|
|
297
|
+
|
|
256
298
|
# Reads the uncompressed-length preamble (a little-endian base-128 varint) at the start of +src+.
|
|
257
299
|
# @param src [String] raw snappy block
|
|
258
300
|
# @param n [Integer] byte size of +src+
|
|
@@ -490,7 +532,7 @@ module Herringbone
|
|
|
490
532
|
end
|
|
491
533
|
end
|
|
492
534
|
|
|
493
|
-
private_class_method :native, :native_library, :decompress_io_buffer, :decompress_string, :read_varint, :write_varint, :compress_block, :block_words, :match_length_words,
|
|
535
|
+
private_class_method :native, :native_library, :binary, :next_part, :decompress_io_buffer, :decompress_string, :read_varint, :write_varint, :compress_block, :block_words, :match_length_words,
|
|
494
536
|
:match_length, :emit_literal, :emit_copy, :emit_copy_upto64
|
|
495
537
|
end
|
|
496
538
|
end
|