herringbone 0.5.0 → 0.6.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.
@@ -24,6 +24,8 @@ module Herringbone
24
24
  # Options:
25
25
  # compression: :snappy (default), :zstd, :gzip, :lz4 (LZ4_RAW), :lz4_hadoop, :brotli, :none
26
26
  # (:zstd and :brotli need the zstd-ruby / brotli gems)
27
+ # compression_level: nil (the codec's default), or a level for :zstd (up to 22), :gzip (0-9)
28
+ # or :brotli (0-11)
27
29
  # row_group_bytes: flush a row group once the buffered values take roughly this much memory
28
30
  # (default 16MB). This bounds memory use while writing.
29
31
  # row_group_rows: also flush after this many rows (default: no row limit)
@@ -38,6 +40,9 @@ module Herringbone
38
40
  # an Array of column paths, or { "path" => true | { ndv:, fpp:, max_bytes: } }.
39
41
  # Without ndv: the distinct values of each row group are counted. fpp defaults
40
42
  # to 0.01 and max_bytes to 1MB. Filters are written after each row group.
43
+ # encryption: Parquet modular encryption: { footer_key: "...", columns: { "ssn" => "..." } }.
44
+ # Without columns: every column is encrypted with the footer key. See
45
+ # #initialize for the settings.
41
46
  #
42
47
  # Statistics and page indexes (ColumnIndex/OffsetIndex) are always written.
43
48
  class Writer
@@ -83,6 +88,7 @@ module Herringbone
83
88
  # @param schema [Schema] schema of the rows
84
89
  # @param options [Hash{Symbol => Object}] see the class description and #initialize
85
90
  # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
91
+ # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
86
92
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
87
93
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
88
94
  # @option options [Integer] :page_bytes (1MB) approximate uncompressed data page size
@@ -95,6 +101,8 @@ module Herringbone
95
101
  # @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
96
102
  # @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
97
103
  # columns to write split block bloom filters for
104
+ # @option options [EncryptionConfiguration, Hash{Symbol => Object}, nil] :encryption (nil) modular
105
+ # encryption settings, see #initialize
98
106
  # @yield [writer] the open writer
99
107
  # @yieldparam writer [Writer] writer to append rows to
100
108
  # @yieldreturn [Object] returned by open
@@ -118,6 +126,8 @@ module Herringbone
118
126
  # @param io [IO, #write] destination; switched to binmode when it supports that
119
127
  # @param schema [Schema] schema of the rows
120
128
  # @param compression [Symbol, Integer] codec name (see Herringbone.codecs) or Format::Codec id
129
+ # @param compression_level [Integer, nil] level for :zstd, :gzip or :brotli (see
130
+ # Compression::LEVELS), nil for the codec's default
121
131
  # @param row_group_bytes [Integer] approximate buffered size that triggers a row group
122
132
  # @param row_group_rows [Integer, nil] also flush a row group after this many rows
123
133
  # @param page_bytes [Integer] approximate uncompressed data page size
@@ -131,17 +141,35 @@ module Herringbone
131
141
  # @param metadata [Hash{#to_s => #to_s}] key/value metadata for the footer
132
142
  # @param bloom_filters [Boolean, Array<String>, Hash{String => Boolean, Hash}, nil] see the class
133
143
  # description
134
- # @raise [ArgumentError] for an invalid IO, schema or option
144
+ # @param encryption [EncryptionConfiguration, Hash{Symbol => Object}, false, nil] encrypts the file
145
+ # (Parquet modular encryption); a Hash holds the keywords of EncryptionConfiguration.new.
146
+ # Keys are 16, 24 or 32-byte Strings; key metadata is stored as given, for readers to find
147
+ # the keys by.
148
+ # @option encryption [String] :footer_key key of the footer, and of columns without their own
149
+ # @option encryption [String, nil] :footer_key_metadata stored for the footer key
150
+ # @option encryption [Hash{String => String, Hash, Symbol}, nil] :columns column path or field
151
+ # name => key, +{key:, key_metadata:}+ or +:footer+. Columns not listed are not encrypted;
152
+ # without this option every column is, with the footer key.
153
+ # @option encryption [Boolean] :plaintext_footer (false) store the footer in the clear (signed),
154
+ # so readers without keys can read the plaintext columns
155
+ # @option encryption [Symbol] :algorithm (:aes_gcm) or :aes_gcm_ctr, which encrypts pages with
156
+ # AES-CTR (faster, but page contents are not authenticated)
157
+ # @option encryption [String, nil] :aad_prefix identity of the file, which readers can check
158
+ # @option encryption [Boolean] :store_aad_prefix (true) false leaves it out of the file, so
159
+ # readers must supply it
160
+ # @raise [ArgumentError] for an invalid IO, schema or option, or a compression level the codec
161
+ # does not take
135
162
  # @raise [MissingCodecError] when the codec's optional gem is not loaded
136
163
  # @raise [UnsupportedError] when the codec is not supported
137
- def initialize(io, schema, compression: :snappy, row_group_bytes: 16 * 1024 * 1024, row_group_rows: nil,
164
+ def initialize(io, schema, compression: :snappy, compression_level: nil, row_group_bytes: 16 * 1024 * 1024, row_group_rows: nil,
138
165
  page_bytes: 1024 * 1024, page_rows: 20_000, data_page_version: 1, dictionary: true, encodings: {},
139
- metadata: {}, bloom_filters: nil)
166
+ metadata: {}, bloom_filters: nil, encryption: nil)
140
167
  raise ArgumentError, "Expected a Herringbone::Schema, got #{schema.class}" unless schema.is_a?(Schema)
141
168
  @schema = schema
142
169
  @codec = Compression.codec_id(compression)
143
170
  # Fail before creating any file if the codec's library is missing
144
171
  Compression.ensure_available!(@codec)
172
+ @compression_level = Compression.check_level!(@codec, compression_level)
145
173
  @row_group_bytes = Integer(row_group_bytes)
146
174
  @row_group_rows = row_group_rows && Integer(row_group_rows)
147
175
  @row_limit = @row_group_rows || ESTIMATE_AFTER_ROWS
@@ -159,7 +187,8 @@ module Herringbone
159
187
  @encodings.each { |path, enc| check_encoding!(columns[path], enc) }
160
188
  @metadata = metadata
161
189
  @bloom_filters = bloom_filter_config(bloom_filters)
162
- @pending_bloom_filters = [] # [ColumnMetaData, BloomFilter] for the row group being written
190
+ @pending_bloom_filters = [] # [ColumnMetaData, BloomFilter, ModuleCrypto] for the row group being written
191
+ @encryption = encryption ? Encryption::FileEncryptor.new(EncryptionConfiguration.from(encryption), schema) : nil
163
192
  @row_groups = []
164
193
  @total_rows = 0
165
194
  @pos = 0
@@ -167,7 +196,7 @@ module Herringbone
167
196
  @aborted = false
168
197
  @bytes_per_row = nil
169
198
  @io = check_io!(io)
170
- write_raw(MAGIC)
199
+ write_raw(@encryption ? @encryption.magic : MAGIC)
171
200
  reset_buffers
172
201
  end
173
202
 
@@ -248,6 +277,7 @@ module Herringbone
248
277
  #
249
278
  # @param num_rows [Integer] rows in the row group
250
279
  # @param copies [Hash{Integer => CopiedChunk}] column index => chunk to copy instead of encoding
280
+ # (only plaintext chunks, into columns this writer does not encrypt)
251
281
  # @param codecs [Hash{Integer => Integer}] column index => codec id for an encoded chunk, instead
252
282
  # of the +compression:+ option
253
283
  # @param bloom_filters [Hash{Integer => Boolean}] column index => true to give an encoded chunk a
@@ -257,6 +287,9 @@ module Herringbone
257
287
  # @raise [Error] when the writer is closed
258
288
  def write_row_group(num_rows, copies: {}, codecs: {}, bloom_filters: {}, sorting_columns: nil)
259
289
  raise Error, "Writer is closed" if @closed
290
+ if @encryption && (encrypted = copies.keys.find { |i| @encryption.encrypted?(i) })
291
+ raise ArgumentError, "Cannot copy a chunk into the encrypted column #{@schema.columns[encrypted].dotted_path}"
292
+ end
260
293
  start = @pos
261
294
  chunks = @schema.columns.map do |col|
262
295
  if (copy = copies[col.index])
@@ -281,6 +314,12 @@ module Herringbone
281
314
  reset_buffers
282
315
  end
283
316
 
317
+ # Internal (used by Redaction): whether the column is encrypted in this file
318
+ #
319
+ # @param index [Integer] leaf column index
320
+ # @return [Boolean]
321
+ def encrypted_column?(index) = @encryption&.encrypted?(index) || false
322
+
284
323
  # Internal (used by Redaction): shreds one value per row of a top-level field into the buffers,
285
324
  # for #write_row_group. Other fields are left as they are, so the caller decides which columns
286
325
  # are encoded and which are copied.
@@ -307,6 +346,7 @@ module Herringbone
307
346
  return if @closed
308
347
  flush_row_group
309
348
  write_page_indexes
349
+ @encryption&.finish(@row_groups)
310
350
  meta = Format::FileMetaData.new(
311
351
  version: 2,
312
352
  schema: @schema.to_elements,
@@ -316,10 +356,10 @@ module Herringbone
316
356
  created_by: "herringbone-ruby #{VERSION}",
317
357
  column_orders: @schema.columns.map { Format::ColumnOrder.new(type_order: Format::TypeDefinedOrder.new) }
318
358
  )
319
- footer = meta.encode
359
+ footer = @encryption ? @encryption.footer(meta) : meta.encode
320
360
  write_raw(footer)
321
361
  write_raw([footer.bytesize].pack("V"))
322
- write_raw(MAGIC)
362
+ write_raw(@encryption ? @encryption.magic : MAGIC)
323
363
  @io.flush if @io.respond_to?(:flush)
324
364
  @closed = true
325
365
  end
@@ -637,6 +677,7 @@ module Herringbone
637
677
  @encodings[path] || E::PLAIN
638
678
  end
639
679
 
680
+ crypto = @encryption&.chunk(@row_groups.size, col.index)
640
681
  chunk_start = @pos
641
682
  uncompressed_total = 0
642
683
  dictionary_offset = nil
@@ -647,7 +688,7 @@ module Herringbone
647
688
  type: Format::PageType::DICTIONARY_PAGE,
648
689
  dictionary_page_header: Format::DictionaryPageHeader.new(num_values: dict_values.size, encoding: E::PLAIN)
649
690
  )
650
- uncompressed_total += write_page(header, plain, codec: codec)
691
+ uncompressed_total += write_page(header, plain, codec: codec, crypto: crypto)
651
692
  end
652
693
 
653
694
  data_offset = @pos
@@ -680,11 +721,12 @@ module Herringbone
680
721
  encoded = encode_values(page_values, value_encoding, type, col.type_length, dict_values&.size)
681
722
  rep_bytes = max_rep.positive? ? Encodings::RLE.encode_hybrid(reps, max_rep.bit_length) : "".b
682
723
  def_bytes = max_def.positive? ? Encodings::RLE.encode_hybrid(defs, max_def.bit_length) : "".b
724
+ page_crypto = crypto && [crypto, pages.size]
683
725
  uncompressed_total += if @data_page_version == 1
684
- write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding, codec)
726
+ write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding, codec, page_crypto)
685
727
  else
686
728
  num_rows = max_rep.zero? ? n : reps.count(0)
687
- write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding, codec)
729
+ write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding, codec, page_crypto)
688
730
  end
689
731
  pages << PageInfo.new(page_offset, @pos - page_offset, first_row, n - non_null, non_null, range)
690
732
  first_row += max_rep.zero? ? n : reps.count(0)
@@ -707,9 +749,9 @@ module Herringbone
707
749
  )
708
750
  chunk = Format::ColumnChunk.new(file_offset: chunk_start, meta_data: meta)
709
751
  if bloom
710
- @pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values)]
752
+ @pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values), crypto]
711
753
  end
712
- @page_indexes << [chunk, column_index_for(col, pages, order), offset_index_for(pages)]
754
+ @page_indexes << [chunk, column_index_for(col, pages, order), offset_index_for(pages), crypto]
713
755
  chunk
714
756
  end
715
757
 
@@ -805,19 +847,22 @@ module Herringbone
805
847
 
806
848
  # Page indexes go after the last row group: all column indexes, then all offset indexes.
807
849
  # A copied chunk brings its ColumnIndex already encoded, and may come without either index.
850
+ # Those of encrypted columns are encrypted.
808
851
  #
809
852
  # @return [void]
810
853
  def write_page_indexes
811
- @page_indexes.each do |chunk, column_index, _|
854
+ @page_indexes.each do |chunk, column_index, _, crypto|
812
855
  next unless column_index
813
856
  bytes = column_index.is_a?(String) ? column_index : column_index.encode
857
+ bytes = crypto.encrypt(Encryption::COLUMN_INDEX, bytes) if crypto
814
858
  chunk.column_index_offset = @pos
815
859
  chunk.column_index_length = bytes.bytesize
816
860
  write_raw(bytes)
817
861
  end
818
- @page_indexes.each do |chunk, _, offset_index|
862
+ @page_indexes.each do |chunk, _, offset_index, crypto|
819
863
  next unless offset_index
820
864
  bytes = offset_index.encode
865
+ bytes = crypto.encrypt(Encryption::OFFSET_INDEX, bytes) if crypto
821
866
  chunk.offset_index_offset = @pos
822
867
  chunk.offset_index_length = bytes.bytesize
823
868
  write_raw(bytes)
@@ -893,12 +938,18 @@ module Herringbone
893
938
  end
894
939
 
895
940
  # Bloom filters go right after the row group's column chunks, in column order. A copied
896
- # chunk brings its filter already encoded.
941
+ # chunk brings its filter already encoded. In an encrypted column the header and the bitset
942
+ # are encrypted separately.
897
943
  #
898
944
  # @return [void]
899
945
  def write_bloom_filters
900
- @pending_bloom_filters.each do |meta, filter|
901
- bytes = filter.is_a?(String) ? filter : filter.encode
946
+ @pending_bloom_filters.each do |meta, filter, crypto|
947
+ bytes = if crypto
948
+ crypto.encrypt(Encryption::BLOOM_FILTER_HEADER, filter.header.encode) <<
949
+ crypto.encrypt(Encryption::BLOOM_FILTER_BITSET, filter.bitset)
950
+ else
951
+ filter.is_a?(String) ? filter : filter.encode
952
+ end
902
953
  meta.bloom_filter_offset = @pos
903
954
  meta.bloom_filter_length = bytes.bytesize
904
955
  write_raw(bytes)
@@ -1015,18 +1066,40 @@ module Herringbone
1015
1066
 
1016
1067
  # Writes a page, returning the uncompressed size including the header
1017
1068
  #
1069
+ # Compresses with the configured level when +codec+ is the writer's own; chunks a redaction
1070
+ # keeps in their source codec use that codec's default
1071
+ #
1072
+ # @param codec [Integer] codec id
1073
+ # @param data [String] bytes to compress
1074
+ # @return [String] compressed bytes
1075
+ def compress(codec, data)
1076
+ Compression.compress(codec, data, (codec == @codec) ? @compression_level : nil)
1077
+ end
1078
+
1079
+ # In an encrypted column the body and the header are encrypted; the CRC covers the body as
1080
+ # stored, encrypted.
1081
+ #
1018
1082
  # @param header [Format::PageHeader] header; sizes and CRC32 are filled in here
1019
1083
  # @param body [String] uncompressed page body
1020
1084
  # @param compressed [String, nil] bytes to write as the page body, when already prepared (v2 data
1021
1085
  # pages, whose levels stay uncompressed); +body+ is compressed otherwise
1022
1086
  # @param codec [Integer] codec id to compress +body+ with
1087
+ # @param crypto [Encryption::ModuleCrypto, nil] encryption of the column's modules
1088
+ # @param ordinal [Integer, nil] data page ordinal within the chunk; nil for a dictionary page
1023
1089
  # @return [Integer]
1024
- def write_page(header, body, compressed = nil, codec: @codec)
1025
- compressed ||= Compression.compress(codec, body)
1090
+ def write_page(header, body, compressed = nil, codec: @codec, crypto: nil, ordinal: nil)
1091
+ compressed ||= compress(codec, body)
1026
1092
  header.uncompressed_page_size ||= body.bytesize
1093
+ dictionary = header.type == Format::PageType::DICTIONARY_PAGE
1094
+ if crypto
1095
+ compressed = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE : Encryption::DATA_PAGE, compressed, ordinal)
1096
+ end
1027
1097
  header.compressed_page_size = compressed.bytesize
1028
1098
  header.crc = Zlib.crc32(compressed).then { |c| (c >= 0x8000_0000) ? c - 0x1_0000_0000 : c }
1029
1099
  encoded = header.encode
1100
+ if crypto
1101
+ encoded = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE_HEADER : Encryption::DATA_PAGE_HEADER, encoded, ordinal)
1102
+ end
1030
1103
  write_raw(encoded)
1031
1104
  write_raw(compressed)
1032
1105
  encoded.bytesize + header.uncompressed_page_size
@@ -1040,8 +1113,10 @@ module Herringbone
1040
1113
  # @param encoded [String] encoded values
1041
1114
  # @param encoding [Integer] encoding id of the values
1042
1115
  # @param codec [Integer] codec id to compress the page with
1116
+ # @param page_crypto [Array(Encryption::ModuleCrypto, Integer), nil] encryption of the column's
1117
+ # modules and the page's ordinal, for an encrypted column
1043
1118
  # @return [Integer] uncompressed size including the header
1044
- def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec)
1119
+ def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
1045
1120
  body = String.new(encoding: Encoding::BINARY)
1046
1121
  body << [rep_bytes.bytesize].pack("V") << rep_bytes unless rep_bytes.empty?
1047
1122
  body << [def_bytes.bytesize].pack("V") << def_bytes unless def_bytes.empty?
@@ -1053,7 +1128,7 @@ module Herringbone
1053
1128
  definition_level_encoding: E::RLE, repetition_level_encoding: E::RLE
1054
1129
  )
1055
1130
  )
1056
- write_page(header, body, codec: codec)
1131
+ write_page(header, body, codec: codec, crypto: page_crypto&.first, ordinal: page_crypto&.last)
1057
1132
  end
1058
1133
 
1059
1134
  # A DATA_PAGE_V2: levels without length prefixes and uncompressed, then the compressed values
@@ -1066,9 +1141,11 @@ module Herringbone
1066
1141
  # @param encoded [String] encoded values
1067
1142
  # @param encoding [Integer] encoding id of the values
1068
1143
  # @param codec [Integer] codec id to compress the values with
1144
+ # @param page_crypto [Array(Encryption::ModuleCrypto, Integer), nil] encryption of the column's
1145
+ # modules and the page's ordinal, for an encrypted column
1069
1146
  # @return [Integer] uncompressed size including the header
1070
- def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding, codec)
1071
- compressed = Compression.compress(codec, encoded)
1147
+ def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
1148
+ compressed = compress(codec, encoded)
1072
1149
  header = Format::PageHeader.new(
1073
1150
  type: Format::PageType::DATA_PAGE_V2,
1074
1151
  uncompressed_page_size: rep_bytes.bytesize + def_bytes.bytesize + encoded.bytesize,
@@ -1079,7 +1156,8 @@ module Herringbone
1079
1156
  is_compressed: codec != Format::Codec::UNCOMPRESSED
1080
1157
  )
1081
1158
  )
1082
- write_page(header, "".b, rep_bytes + def_bytes + compressed, codec: codec)
1159
+ write_page(header, "".b, rep_bytes + def_bytes + compressed, codec: codec, crypto: page_crypto&.first,
1160
+ ordinal: page_crypto&.last)
1083
1161
  end
1084
1162
 
1085
1163
  # Statistics and column index bounds longer than this are truncated, see #truncate_min
data/lib/herringbone.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
- require "pathname"
5
4
  require_relative "herringbone/version"
6
5
 
7
6
  # Pure-Ruby reader and writer for Apache Parquet files
@@ -38,33 +37,76 @@ module Herringbone
38
37
  class SchemaMismatch < EncodeError; end
39
38
 
40
39
  # The file (or the writer configuration) uses a Parquet feature Herringbone does not implement,
41
- # such as encryption or a codec whose library is unavailable
40
+ # such as a codec whose library is unavailable
42
41
  class UnsupportedError < Error; end
42
+
43
+ # An encrypted file or column cannot be read: its key was not given, the key or AAD prefix is
44
+ # wrong, or the encrypted bytes were changed
45
+ class DecryptionError < Error; end
43
46
  end
44
47
 
45
- require_relative "herringbone/io_buffer_support"
46
- require_relative "herringbone/codecs/snappy"
47
- require_relative "herringbone/codecs/lz4"
48
- require_relative "herringbone/codecs/lzo"
49
- require_relative "herringbone/thrift"
50
- require_relative "herringbone/format"
51
- require_relative "herringbone/encodings/rle"
52
- require_relative "herringbone/encodings/plain"
53
- require_relative "herringbone/encodings/delta"
54
- require_relative "herringbone/compression"
55
- require_relative "herringbone/types"
56
- require_relative "herringbone/schema"
57
- require_relative "herringbone/active_record"
58
- require_relative "herringbone/reader"
59
- require_relative "herringbone/byte_values"
60
- require_relative "herringbone/writer"
61
- require_relative "herringbone/inferring_writer"
62
- require_relative "herringbone/simple_writer"
63
- require_relative "herringbone/xxhash"
64
- require_relative "herringbone/bloom_filter"
65
- require_relative "herringbone/inspector"
66
- require_relative "herringbone/visualizer"
67
- require_relative "herringbone/redaction"
48
+ module Herringbone
49
+ # Everything else loads on first use, so a process that only reads plain files never loads the
50
+ # writer, the inspector or OpenSSL. Herringbone.eager_load! loads it all (Ractors on Ruby < 3.4
51
+ # need that: they cannot autoload).
52
+
53
+ # Directory holding the files that are autoloaded
54
+ LIB = File.expand_path("herringbone", __dir__)
55
+ private_constant :LIB
56
+
57
+ autoload :BloomFilter, "#{LIB}/bloom_filter"
58
+ autoload :ByteValues, "#{LIB}/byte_values"
59
+ autoload :Compression, "#{LIB}/compression"
60
+ autoload :DecryptionConfiguration, "#{LIB}/encryption_configuration"
61
+ autoload :Encryption, "#{LIB}/encryption"
62
+ autoload :EncryptionConfiguration, "#{LIB}/encryption_configuration"
63
+ autoload :Format, "#{LIB}/format"
64
+ autoload :InferringWriter, "#{LIB}/inferring_writer"
65
+ autoload :Inspector, "#{LIB}/inspector"
66
+ autoload :IOBufferSupport, "#{LIB}/io_buffer_support"
67
+ autoload :Key, "#{LIB}/key"
68
+ autoload :MissingCodecError, "#{LIB}/compression"
69
+ autoload :Reader, "#{LIB}/reader"
70
+ autoload :Redaction, "#{LIB}/redaction"
71
+ autoload :Schema, "#{LIB}/schema"
72
+ autoload :SimpleWriter, "#{LIB}/simple_writer"
73
+ autoload :Thrift, "#{LIB}/thrift"
74
+ autoload :Types, "#{LIB}/types"
75
+ autoload :Visualizer, "#{LIB}/visualizer"
76
+ autoload :Writer, "#{LIB}/writer"
77
+ autoload :XXHash, "#{LIB}/xxhash"
78
+
79
+ module Codecs
80
+ autoload :LZ4, "#{LIB}/codecs/lz4"
81
+ autoload :LZO, "#{LIB}/codecs/lzo"
82
+ autoload :Snappy, "#{LIB}/codecs/snappy"
83
+ end
84
+
85
+ module Encodings
86
+ autoload :ByteStreamSplit, "#{LIB}/encodings/delta"
87
+ autoload :Delta, "#{LIB}/encodings/delta"
88
+ autoload :Plain, "#{LIB}/encodings/plain"
89
+ autoload :RLE, "#{LIB}/encodings/rle"
90
+ end
91
+
92
+ # Loads every part of Herringbone now instead of on first use. Ractors cannot autoload before
93
+ # Ruby 3.4, so call this before starting Ractors that use Herringbone there; it also moves the
94
+ # loading out of the first request in a forking server.
95
+ #
96
+ # @return [void]
97
+ def self.eager_load!
98
+ seen = {}
99
+ walk = lambda do |mod|
100
+ next if seen[mod]
101
+ seen[mod] = true
102
+ mod.constants(false).each do |name|
103
+ value = mod.const_get(name, false)
104
+ walk.call(value) if value.is_a?(Module) && value.name&.start_with?("Herringbone::")
105
+ end
106
+ end
107
+ walk.call(self)
108
+ end
109
+ end
68
110
 
69
111
  module Herringbone
70
112
  module_function
@@ -86,6 +128,7 @@ module Herringbone
86
128
  # @param schema [Schema, nil] schema to write with; derived from +records+ when nil
87
129
  # @param options [Hash{Symbol => Object}] passed to Writer.new
88
130
  # @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
131
+ # @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
89
132
  # @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
90
133
  # @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
91
134
  # @option options [Integer] :page_bytes (1MB) approximate uncompressed data page size
@@ -147,6 +190,7 @@ module Herringbone
147
190
  # @param writer_options [Hash{Symbol => Object}] Writer options for re-encoded column chunks, and
148
191
  # +metadata:+ to replace the footer key/value metadata, see Redaction#apply
149
192
  # @option writer_options [Symbol] :compression (codec of each source chunk) codec for re-encoded chunks
193
+ # @option writer_options [Integer, nil] :compression_level (nil) level for that codec, see Writer
150
194
  # @option writer_options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
151
195
  # columns whose re-encoded chunks get a bloom filter, besides those whose source chunk had one
152
196
  # @option writer_options [Hash{String => String}] :metadata (the input's) footer key/value metadata
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: herringbone
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-02 00:00:00.000000000 Z
10
+ date: 2026-10-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -35,6 +35,7 @@ extra_rdoc_files: []
35
35
  files:
36
36
  - CHANGELOG.md
37
37
  - LICENSE
38
+ - MANUAL.md
38
39
  - README.md
39
40
  - bin/herringbone
40
41
  - lib/herringbone.rb
@@ -48,11 +49,15 @@ files:
48
49
  - lib/herringbone/encodings/delta.rb
49
50
  - lib/herringbone/encodings/plain.rb
50
51
  - lib/herringbone/encodings/rle.rb
52
+ - lib/herringbone/encryption.rb
53
+ - lib/herringbone/encryption_configuration.rb
51
54
  - lib/herringbone/format.rb
52
55
  - lib/herringbone/inferring_writer.rb
53
56
  - lib/herringbone/inspector.rb
54
57
  - lib/herringbone/io_buffer_support.rb
58
+ - lib/herringbone/key.rb
55
59
  - lib/herringbone/reader.rb
60
+ - lib/herringbone/reader/bloom_filters.rb
56
61
  - lib/herringbone/reader/column_chunk_reader.rb
57
62
  - lib/herringbone/reader/column_cursor.rb
58
63
  - lib/herringbone/reader/numo.rb