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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/MANUAL.md +161 -12
  4. data/README.md +19 -4
  5. data/bin/herringbone +56 -1
  6. data/lib/herringbone/active_record.rb +2 -0
  7. data/lib/herringbone/bloom_filter.rb +2 -0
  8. data/lib/herringbone/byte_values.rb +2 -0
  9. data/lib/herringbone/codecs/lz4.rb +9 -4
  10. data/lib/herringbone/codecs/lzo.rb +2 -0
  11. data/lib/herringbone/codecs/snappy.rb +48 -6
  12. data/lib/herringbone/combiner.rb +426 -0
  13. data/lib/herringbone/compression.rb +53 -9
  14. data/lib/herringbone/encodings/delta.rb +4 -0
  15. data/lib/herringbone/encodings/plain.rb +2 -0
  16. data/lib/herringbone/encodings/rle.rb +2 -0
  17. data/lib/herringbone/encryption.rb +2 -0
  18. data/lib/herringbone/inspector.rb +29 -5
  19. data/lib/herringbone/io_buffer_support.rb +2 -0
  20. data/lib/herringbone/reader/bloom_filters.rb +1 -0
  21. data/lib/herringbone/reader/chunk_copier.rb +177 -0
  22. data/lib/herringbone/reader/column_chunk_reader.rb +4 -2
  23. data/lib/herringbone/reader/column_cursor.rb +2 -0
  24. data/lib/herringbone/reader/numo.rb +4 -0
  25. data/lib/herringbone/reader/page_stream.rb +2 -0
  26. data/lib/herringbone/reader/scan.rb +2 -0
  27. data/lib/herringbone/reader.rb +19 -6
  28. data/lib/herringbone/redaction/rewriter.rb +51 -157
  29. data/lib/herringbone/redaction.rb +25 -15
  30. data/lib/herringbone/restricted_readable_io.rb +85 -0
  31. data/lib/herringbone/schema/merge.rb +355 -0
  32. data/lib/herringbone/schema.rb +80 -0
  33. data/lib/herringbone/thrift.rb +166 -68
  34. data/lib/herringbone/types.rb +2 -0
  35. data/lib/herringbone/version.rb +1 -1
  36. data/lib/herringbone/visualizer.rb +2 -0
  37. data/lib/herringbone/writer.rb +58 -22
  38. data/lib/herringbone/xxhash.rb +2 -0
  39. data/lib/herringbone.rb +96 -9
  40. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36e3b2444245fc90b36b577284aab2a81e4991ff04e30dd781f9ba94df43ae90
4
- data.tar.gz: 9e2e4dbe2ab635a03aa743206cd5e6d74d6039cc5e790cefae8b04a7afd8304b
3
+ metadata.gz: e9b95c84373ccf0215789628229613c8899227be711dc204c31be6778352f2d6
4
+ data.tar.gz: 9314fc6d6d55660e210db0fe62369d9732e33dc3910e306f500b3ebc5c7090e9
5
5
  SHA512:
6
- metadata.gz: 162e38811c7e6371ed61a777fddcfa8e1e811de5225842e13dad1cf67da20490c7b7eb2347eaeb6515322a6ace971d453fa4ce1556cc75f4e70acfdcda7a9179
7
- data.tar.gz: 0e5798748be214eb32a7939799ecb7a885283099908c05003065a98569c0572dc17a25fd4e6d017c5635256cefdd3b0db5072e28c1536fe4e26867c1d019eca0
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(input, output) do |r|
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(input, output) do |r|
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(input, output) do |r|
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(input, output) do |r|
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?(input) # statistics and bloom filters first, then the where columns only
579
- report = forget.apply(input, output)
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. To redact in place, write to a
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(input, output, decryption: {keys: kms_lookup}) do |r|
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(input, output) do |r|
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(input, output) do |r|
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(input, output) do |r|
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(input, output) do |r|
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(input, output) do |r|
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(input, output) do |r|
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
- decryption[:keys] = ->(metadata, owner) { by_metadata.fetch(metadata) { prompt ? ask_key(metadata, owner) : nil } }
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
- block = compress_block(src)
175
- [src.bytesize, block.bytesize].pack("NN") << block
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
- # @param input [String] bytes to compress
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
- src = (input.encoding == Encoding::BINARY) ? input : input.b
235
- n = src.bytesize
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(src)
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
- compress_block(src, start, len, out, table)
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