herringbone 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/MANUAL.md +773 -0
- data/README.md +81 -316
- data/bin/herringbone +86 -7
- data/lib/herringbone/active_record.rb +1 -1
- data/lib/herringbone/bloom_filter.rb +0 -55
- data/lib/herringbone/byte_values.rb +9 -0
- data/lib/herringbone/compression.rb +27 -4
- data/lib/herringbone/encryption.rb +716 -0
- data/lib/herringbone/encryption_configuration.rb +304 -0
- data/lib/herringbone/format.rb +55 -3
- data/lib/herringbone/inferring_writer.rb +24 -2
- data/lib/herringbone/inspector.rb +158 -28
- data/lib/herringbone/key.rb +107 -0
- data/lib/herringbone/reader/bloom_filters.rb +91 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +55 -1
- data/lib/herringbone/reader.rb +100 -15
- data/lib/herringbone/redaction/rewriter.rb +585 -0
- data/lib/herringbone/redaction.rb +302 -0
- data/lib/herringbone/schema.rb +123 -57
- data/lib/herringbone/simple_writer.rb +42 -4
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +231 -37
- data/lib/herringbone.rb +115 -29
- metadata +9 -2
|
@@ -23,7 +23,15 @@ module Herringbone
|
|
|
23
23
|
# Columns declared in a block (Schema::Builder DSL) replace inferred ones, e.g. for a column that
|
|
24
24
|
# holds both numbers and text:
|
|
25
25
|
#
|
|
26
|
-
# Herringbone::SimpleWriter.new(io) { string :code }
|
|
26
|
+
# Herringbone::SimpleWriter.new(io) { |s| s.string :code }
|
|
27
|
+
#
|
|
28
|
+
# #encrypt! encrypts the file with one key (see Key):
|
|
29
|
+
#
|
|
30
|
+
# Herringbone::SimpleWriter.open(file) do |sw|
|
|
31
|
+
# sw.encrypt!(key: ENV["PARQUET_KEY"]) # the key's hex, or a Herringbone::Key
|
|
32
|
+
# sw.headers!(:id, :name)
|
|
33
|
+
# sw << [1, "John"]
|
|
34
|
+
# end
|
|
27
35
|
class SimpleWriter
|
|
28
36
|
# Opens a writer, yields it and closes it, finishing the file. If the block raises, the file is
|
|
29
37
|
# left unfinished (no footer), as with Writer.open. To declare columns, use #initialize and #close.
|
|
@@ -31,6 +39,7 @@ module Herringbone
|
|
|
31
39
|
# @param io [IO, #write] destination; written sequentially, never closed
|
|
32
40
|
# @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
|
|
33
41
|
# @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
|
|
42
|
+
# @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
|
|
34
43
|
# @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
|
|
35
44
|
# @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
|
|
36
45
|
# (other Writer options are passed on as well)
|
|
@@ -57,16 +66,21 @@ module Herringbone
|
|
|
57
66
|
# @param io [IO, #write] destination; nothing is written to it until the column types are known
|
|
58
67
|
# @param options [Hash{Symbol => Object}] Writer options (compression:, row_group_bytes:...)
|
|
59
68
|
# @option options [Symbol] :compression (:snappy) codec, see Herringbone.codecs
|
|
69
|
+
# @option options [Integer, nil] :compression_level (nil) level for :zstd, :gzip or :brotli
|
|
60
70
|
# @option options [Integer] :row_group_bytes (16MB) approximate buffered size that triggers a row group
|
|
61
71
|
# @option options [Integer, nil] :row_group_rows (nil) also flush a row group after this many rows
|
|
62
72
|
# (other Writer options are passed on as well)
|
|
63
|
-
# @yield optional
|
|
64
|
-
#
|
|
73
|
+
# @yield [s] optional, declares columns (named in #headers!) that replace inferred ones
|
|
74
|
+
# @yieldparam s [Schema::Builder] the builder to declare columns on
|
|
75
|
+
# @yieldreturn [void]
|
|
65
76
|
# @raise [MissingCodecError] when the codec's optional gem is not loaded
|
|
77
|
+
# @raise [ArgumentError] when the block takes no parameter
|
|
66
78
|
def initialize(io, **options, &overrides)
|
|
79
|
+
Schema::Builder.check_block!(overrides, "Herringbone::SimpleWriter.new(io) { |s| s.string :code }")
|
|
67
80
|
@headers = nil
|
|
81
|
+
@encrypted = !!options[:encryption]
|
|
68
82
|
@overrides = overrides
|
|
69
|
-
fix = "Herringbone::SimpleWriter.new(io) {
|
|
83
|
+
fix = "Herringbone::SimpleWriter.new(io) { |s| s.%s }"
|
|
70
84
|
@writer = InferringWriter.new(io, fix: fix, **options) { |sample| schema_for(sample) }
|
|
71
85
|
end
|
|
72
86
|
|
|
@@ -86,6 +100,23 @@ module Herringbone
|
|
|
86
100
|
self
|
|
87
101
|
end
|
|
88
102
|
|
|
103
|
+
# Encrypts the file with one key, the way most Parquet readers can decrypt with that key (see
|
|
104
|
+
# EncryptionConfiguration.simple). Must be called before the first row. For a new key, pass
|
|
105
|
+
# +key: Herringbone::Key.generate+ and keep it (+key.hex+): the file can't be read without it.
|
|
106
|
+
#
|
|
107
|
+
# @param key [Key, String] a key, or its hex (32 or 64 digits; raw bytes go through Key.new)
|
|
108
|
+
# @return [Key] the key the file is encrypted with
|
|
109
|
+
# @raise [ArgumentError] after the first row, when called twice or after +encryption:+ was
|
|
110
|
+
# given, for a String that is not the hex of a key, or a 192-bit key
|
|
111
|
+
def encrypt!(key:)
|
|
112
|
+
raise ArgumentError, "encrypt! must be called before the first row" if rows_written.positive?
|
|
113
|
+
raise ArgumentError, "The file is already encrypted (encrypt! or encryption:)" if @encrypted
|
|
114
|
+
key = Key.from(key)
|
|
115
|
+
@writer.encryption = EncryptionConfiguration.simple(key)
|
|
116
|
+
@encrypted = true
|
|
117
|
+
key
|
|
118
|
+
end
|
|
119
|
+
|
|
89
120
|
# Appends a row: an Array with one value per header, in header order, or a Hash keyed by
|
|
90
121
|
# header (String or Symbol keys; missing columns are nulls).
|
|
91
122
|
#
|
|
@@ -117,6 +148,13 @@ module Herringbone
|
|
|
117
148
|
# @return [void]
|
|
118
149
|
def abort = @writer.abort
|
|
119
150
|
|
|
151
|
+
# Short summary for the console, without the rows
|
|
152
|
+
#
|
|
153
|
+
# @return [String] the number of columns and the underlying writer's summary
|
|
154
|
+
def inspect
|
|
155
|
+
"#<#{self.class.name} columns=#{@headers&.size.inspect} writer=#{@writer.inspect}>"
|
|
156
|
+
end
|
|
157
|
+
|
|
120
158
|
private
|
|
121
159
|
|
|
122
160
|
# @param row [Array, Hash] row given to #<<
|
data/lib/herringbone/version.rb
CHANGED
data/lib/herringbone/writer.rb
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module Herringbone
|
|
4
4
|
# Writes Parquet files.
|
|
5
5
|
#
|
|
6
|
-
# schema = Herringbone::Schema.define do
|
|
7
|
-
# int64 :id, null: false
|
|
8
|
-
# string :name
|
|
9
|
-
# list :tags, :string
|
|
6
|
+
# schema = Herringbone::Schema.define do |s|
|
|
7
|
+
# s.int64 :id, null: false
|
|
8
|
+
# s.string :name
|
|
9
|
+
# s.list :tags, :string
|
|
10
10
|
# end
|
|
11
11
|
# File.open("out.parquet", "wb") do |file|
|
|
12
12
|
# Herringbone::Writer.open(file, schema) do |w|
|
|
@@ -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
|
-
# @
|
|
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,14 +187,16 @@ 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
|
|
166
195
|
@closed = false
|
|
196
|
+
@aborted = false
|
|
167
197
|
@bytes_per_row = nil
|
|
168
198
|
@io = check_io!(io)
|
|
169
|
-
write_raw(MAGIC)
|
|
199
|
+
write_raw(@encryption ? @encryption.magic : MAGIC)
|
|
170
200
|
reset_buffers
|
|
171
201
|
end
|
|
172
202
|
|
|
@@ -222,21 +252,92 @@ module Herringbone
|
|
|
222
252
|
# @return [void]
|
|
223
253
|
def flush_row_group
|
|
224
254
|
return if @buffered_rows.zero?
|
|
255
|
+
write_row_group(@buffered_rows)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# A column chunk taken as it is from another file, for #write_row_group
|
|
259
|
+
#
|
|
260
|
+
# @!attribute chunk
|
|
261
|
+
# @return [Format::ColumnChunk] the chunk's footer entry in the source file
|
|
262
|
+
# @!attribute start
|
|
263
|
+
# @return [Integer] source file offset of the chunk's first page
|
|
264
|
+
# @!attribute bytes
|
|
265
|
+
# @return [String] the chunk's pages, headers included
|
|
266
|
+
# @!attribute column_index
|
|
267
|
+
# @return [String, nil] the chunk's encoded ColumnIndex
|
|
268
|
+
# @!attribute offset_index
|
|
269
|
+
# @return [Format::OffsetIndex, nil] the chunk's OffsetIndex, with source file offsets
|
|
270
|
+
# @!attribute bloom_filter
|
|
271
|
+
# @return [String, nil] the chunk's encoded bloom filter, header included
|
|
272
|
+
CopiedChunk = Struct.new(:chunk, :start, :bytes, :column_index, :offset_index, :bloom_filter)
|
|
273
|
+
|
|
274
|
+
# Internal (used by Redaction): writes a row group of +num_rows+ rows from the buffered values,
|
|
275
|
+
# except for the columns in +copies+, whose chunks are copied byte for byte from another file
|
|
276
|
+
# with their offsets rebased. Starts a new row group afterwards.
|
|
277
|
+
#
|
|
278
|
+
# @param num_rows [Integer] rows in the row group
|
|
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)
|
|
281
|
+
# @param codecs [Hash{Integer => Integer}] column index => codec id for an encoded chunk, instead
|
|
282
|
+
# of the +compression:+ option
|
|
283
|
+
# @param bloom_filters [Hash{Integer => Boolean}] column index => true to give an encoded chunk a
|
|
284
|
+
# bloom filter (default settings) when the +bloom_filters:+ option does not ask for one
|
|
285
|
+
# @param sorting_columns [Array<Format::SortingColumn>, nil] stored on the RowGroup as given
|
|
286
|
+
# @return [void]
|
|
287
|
+
# @raise [Error] when the writer is closed
|
|
288
|
+
def write_row_group(num_rows, copies: {}, codecs: {}, bloom_filters: {}, sorting_columns: nil)
|
|
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
|
|
225
293
|
start = @pos
|
|
226
|
-
chunks = @schema.columns.map
|
|
294
|
+
chunks = @schema.columns.map do |col|
|
|
295
|
+
if (copy = copies[col.index])
|
|
296
|
+
copy_column_chunk(copy)
|
|
297
|
+
else
|
|
298
|
+
bloom = @bloom_filters[col.dotted_path]
|
|
299
|
+
bloom ||= bloom_filter_settings({}) if bloom_filters[col.index] && BloomFilter::TYPES.include?(col.type)
|
|
300
|
+
write_column_chunk(col, @buffers[col.index], codec: codecs.fetch(col.index, @codec), bloom: bloom)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
227
303
|
@row_groups << Format::RowGroup.new(
|
|
228
304
|
columns: chunks,
|
|
229
305
|
total_byte_size: chunks.sum { |c| c.meta_data.total_uncompressed_size },
|
|
230
|
-
num_rows:
|
|
306
|
+
num_rows: num_rows,
|
|
307
|
+
sorting_columns: sorting_columns,
|
|
231
308
|
file_offset: start,
|
|
232
309
|
total_compressed_size: @pos - start,
|
|
233
310
|
ordinal: @row_groups.size
|
|
234
311
|
)
|
|
235
312
|
write_bloom_filters
|
|
236
|
-
@total_rows +=
|
|
313
|
+
@total_rows += num_rows
|
|
237
314
|
reset_buffers
|
|
238
315
|
end
|
|
239
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
|
+
|
|
323
|
+
# Internal (used by Redaction): shreds one value per row of a top-level field into the buffers,
|
|
324
|
+
# for #write_row_group. Other fields are left as they are, so the caller decides which columns
|
|
325
|
+
# are encoded and which are copied.
|
|
326
|
+
#
|
|
327
|
+
# @param name [String] top-level field name
|
|
328
|
+
# @param values [Array] the field's value for each row
|
|
329
|
+
# @return [void]
|
|
330
|
+
# @raise [ArgumentError] when the schema has no such field
|
|
331
|
+
# @raise [EncodeError] when a value cannot be written to the field
|
|
332
|
+
def buffer_field(name, values)
|
|
333
|
+
field = @schema.field(name) or raise ArgumentError, "No such field #{name.inspect}"
|
|
334
|
+
values.each_with_index do |value, i|
|
|
335
|
+
shred(field, value, 0, 0)
|
|
336
|
+
rescue EncodeError => e
|
|
337
|
+
raise EncodeError.new("Row #{@total_rows + i}: #{e.message}", row: @total_rows + i, column: e.column, value: e.value)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
240
341
|
# Flushes buffered rows, then writes the page indexes and the footer and flushes the IO.
|
|
241
342
|
# Does nothing when already closed or aborted. The IO itself is not closed.
|
|
242
343
|
#
|
|
@@ -245,6 +346,7 @@ module Herringbone
|
|
|
245
346
|
return if @closed
|
|
246
347
|
flush_row_group
|
|
247
348
|
write_page_indexes
|
|
349
|
+
@encryption&.finish(@row_groups)
|
|
248
350
|
meta = Format::FileMetaData.new(
|
|
249
351
|
version: 2,
|
|
250
352
|
schema: @schema.to_elements,
|
|
@@ -254,10 +356,10 @@ module Herringbone
|
|
|
254
356
|
created_by: "herringbone-ruby #{VERSION}",
|
|
255
357
|
column_orders: @schema.columns.map { Format::ColumnOrder.new(type_order: Format::TypeDefinedOrder.new) }
|
|
256
358
|
)
|
|
257
|
-
footer = meta.encode
|
|
359
|
+
footer = @encryption ? @encryption.footer(meta) : meta.encode
|
|
258
360
|
write_raw(footer)
|
|
259
361
|
write_raw([footer.bytesize].pack("V"))
|
|
260
|
-
write_raw(MAGIC)
|
|
362
|
+
write_raw(@encryption ? @encryption.magic : MAGIC)
|
|
261
363
|
@io.flush if @io.respond_to?(:flush)
|
|
262
364
|
@closed = true
|
|
263
365
|
end
|
|
@@ -267,9 +369,23 @@ module Herringbone
|
|
|
267
369
|
#
|
|
268
370
|
# @return [void]
|
|
269
371
|
def abort
|
|
372
|
+
@aborted = true unless @closed
|
|
270
373
|
@closed = true
|
|
271
374
|
end
|
|
272
375
|
|
|
376
|
+
# Short summary for the console, without the buffered values
|
|
377
|
+
#
|
|
378
|
+
# @return [String] state (open, closed or aborted), rows written including buffered ones,
|
|
379
|
+
# row groups flushed and the codec
|
|
380
|
+
def inspect
|
|
381
|
+
state = if @aborted then "aborted"
|
|
382
|
+
elsif @closed then "closed"
|
|
383
|
+
else "open"
|
|
384
|
+
end
|
|
385
|
+
"#<#{self.class.name} #{state} rows_written=#{rows_written} row_groups=#{@row_groups.size} " \
|
|
386
|
+
"compression=#{Compression::NAMES.fetch(@codec, @codec).inspect}>"
|
|
387
|
+
end
|
|
388
|
+
|
|
273
389
|
private
|
|
274
390
|
|
|
275
391
|
# Pathname responds to #write too (writing a whole file by path), so it is rejected explicitly.
|
|
@@ -534,8 +650,11 @@ module Herringbone
|
|
|
534
650
|
#
|
|
535
651
|
# @param col [Schema::Column] column being written
|
|
536
652
|
# @param buffer [ColumnBuffer] the column's buffered levels and values
|
|
653
|
+
# @param codec [Integer] codec id to compress the pages with
|
|
654
|
+
# @param bloom [Hash{Symbol => Numeric, nil}, nil] bloom filter settings (see
|
|
655
|
+
# #bloom_filter_settings), nil for no bloom filter
|
|
537
656
|
# @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
|
|
538
|
-
def write_column_chunk(col, buffer)
|
|
657
|
+
def write_column_chunk(col, buffer, codec: @codec, bloom: @bloom_filters[col.dotted_path])
|
|
539
658
|
type = col.type
|
|
540
659
|
path = col.dotted_path
|
|
541
660
|
dict_values = nil
|
|
@@ -558,6 +677,7 @@ module Herringbone
|
|
|
558
677
|
@encodings[path] || E::PLAIN
|
|
559
678
|
end
|
|
560
679
|
|
|
680
|
+
crypto = @encryption&.chunk(@row_groups.size, col.index)
|
|
561
681
|
chunk_start = @pos
|
|
562
682
|
uncompressed_total = 0
|
|
563
683
|
dictionary_offset = nil
|
|
@@ -568,7 +688,7 @@ module Herringbone
|
|
|
568
688
|
type: Format::PageType::DICTIONARY_PAGE,
|
|
569
689
|
dictionary_page_header: Format::DictionaryPageHeader.new(num_values: dict_values.size, encoding: E::PLAIN)
|
|
570
690
|
)
|
|
571
|
-
uncompressed_total += write_page(header, plain)
|
|
691
|
+
uncompressed_total += write_page(header, plain, codec: codec, crypto: crypto)
|
|
572
692
|
end
|
|
573
693
|
|
|
574
694
|
data_offset = @pos
|
|
@@ -601,11 +721,12 @@ module Herringbone
|
|
|
601
721
|
encoded = encode_values(page_values, value_encoding, type, col.type_length, dict_values&.size)
|
|
602
722
|
rep_bytes = max_rep.positive? ? Encodings::RLE.encode_hybrid(reps, max_rep.bit_length) : "".b
|
|
603
723
|
def_bytes = max_def.positive? ? Encodings::RLE.encode_hybrid(defs, max_def.bit_length) : "".b
|
|
724
|
+
page_crypto = crypto && [crypto, pages.size]
|
|
604
725
|
uncompressed_total += if @data_page_version == 1
|
|
605
|
-
write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding)
|
|
726
|
+
write_data_page_v1(n, rep_bytes, def_bytes, encoded, value_encoding, codec, page_crypto)
|
|
606
727
|
else
|
|
607
728
|
num_rows = max_rep.zero? ? n : reps.count(0)
|
|
608
|
-
write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding)
|
|
729
|
+
write_data_page_v2(n, n - non_null, num_rows, rep_bytes, def_bytes, encoded, value_encoding, codec, page_crypto)
|
|
609
730
|
end
|
|
610
731
|
pages << PageInfo.new(page_offset, @pos - page_offset, first_row, n - non_null, non_null, range)
|
|
611
732
|
first_row += max_rep.zero? ? n : reps.count(0)
|
|
@@ -618,7 +739,7 @@ module Herringbone
|
|
|
618
739
|
type: type,
|
|
619
740
|
encodings: encodings.uniq,
|
|
620
741
|
path_in_schema: col.path,
|
|
621
|
-
codec:
|
|
742
|
+
codec: codec,
|
|
622
743
|
num_values: buf.defs.size,
|
|
623
744
|
total_uncompressed_size: uncompressed_total,
|
|
624
745
|
total_compressed_size: @pos - chunk_start,
|
|
@@ -627,10 +748,10 @@ module Herringbone
|
|
|
627
748
|
statistics: statistics_for(col, buf.defs, values || indices.uniq.map { |i| dict_values[i] }, order)
|
|
628
749
|
)
|
|
629
750
|
chunk = Format::ColumnChunk.new(file_offset: chunk_start, meta_data: meta)
|
|
630
|
-
if
|
|
631
|
-
@pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values)]
|
|
751
|
+
if bloom
|
|
752
|
+
@pending_bloom_filters << [meta, build_bloom_filter(col, bloom, dict_values, values), crypto]
|
|
632
753
|
end
|
|
633
|
-
@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]
|
|
634
755
|
chunk
|
|
635
756
|
end
|
|
636
757
|
|
|
@@ -658,6 +779,37 @@ module Herringbone
|
|
|
658
779
|
end)
|
|
659
780
|
end
|
|
660
781
|
|
|
782
|
+
# Writes a chunk copied from another file. The pages, the ColumnIndex and the bloom filter
|
|
783
|
+
# hold no file offsets and go out as they are; the ColumnMetaData and the OffsetIndex do, so
|
|
784
|
+
# those are rebased onto where the chunk lands in this file.
|
|
785
|
+
#
|
|
786
|
+
# @param copy [CopiedChunk] the chunk to copy
|
|
787
|
+
# @return [Format::ColumnChunk] chunk with its ColumnMetaData, for the row group
|
|
788
|
+
def copy_column_chunk(copy)
|
|
789
|
+
start = @pos
|
|
790
|
+
shift = start - copy.start
|
|
791
|
+
meta = Format::ColumnMetaData.decode(copy.chunk.meta_data.encode).first
|
|
792
|
+
meta.data_page_offset += shift
|
|
793
|
+
dict = meta.dictionary_page_offset
|
|
794
|
+
# Some writers store 0 when there is no dictionary page
|
|
795
|
+
meta.dictionary_page_offset = dict&.positive? ? dict + shift : nil
|
|
796
|
+
meta.index_page_offset += shift if meta.index_page_offset
|
|
797
|
+
meta.total_compressed_size = copy.bytes.bytesize
|
|
798
|
+
meta.bloom_filter_offset = meta.bloom_filter_length = nil
|
|
799
|
+
write_raw(copy.bytes)
|
|
800
|
+
chunk = Format::ColumnChunk.new(file_offset: start, meta_data: meta)
|
|
801
|
+
@pending_bloom_filters << [meta, copy.bloom_filter] if copy.bloom_filter
|
|
802
|
+
offset_index = copy.offset_index && Format::OffsetIndex.new(
|
|
803
|
+
page_locations: copy.offset_index.page_locations.map do |loc|
|
|
804
|
+
Format::PageLocation.new(offset: loc.offset + shift, compressed_page_size: loc.compressed_page_size,
|
|
805
|
+
first_row_index: loc.first_row_index)
|
|
806
|
+
end,
|
|
807
|
+
unencoded_byte_array_data_bytes: copy.offset_index.unencoded_byte_array_data_bytes
|
|
808
|
+
)
|
|
809
|
+
@page_indexes << [chunk, copy.column_index, offset_index]
|
|
810
|
+
chunk
|
|
811
|
+
end
|
|
812
|
+
|
|
661
813
|
# nil when the column has no defined sort order, or a page's values have no min/max (all NaN)
|
|
662
814
|
#
|
|
663
815
|
# @param col [Schema::Column] column the pages belong to
|
|
@@ -693,19 +845,24 @@ module Herringbone
|
|
|
693
845
|
end
|
|
694
846
|
end
|
|
695
847
|
|
|
696
|
-
# Page indexes go after the last row group: all column indexes, then all offset indexes
|
|
848
|
+
# Page indexes go after the last row group: all column indexes, then all offset indexes.
|
|
849
|
+
# A copied chunk brings its ColumnIndex already encoded, and may come without either index.
|
|
850
|
+
# Those of encrypted columns are encrypted.
|
|
697
851
|
#
|
|
698
852
|
# @return [void]
|
|
699
853
|
def write_page_indexes
|
|
700
|
-
@page_indexes.each do |chunk, column_index, _|
|
|
854
|
+
@page_indexes.each do |chunk, column_index, _, crypto|
|
|
701
855
|
next unless column_index
|
|
702
|
-
bytes = column_index.encode
|
|
856
|
+
bytes = column_index.is_a?(String) ? column_index : column_index.encode
|
|
857
|
+
bytes = crypto.encrypt(Encryption::COLUMN_INDEX, bytes) if crypto
|
|
703
858
|
chunk.column_index_offset = @pos
|
|
704
859
|
chunk.column_index_length = bytes.bytesize
|
|
705
860
|
write_raw(bytes)
|
|
706
861
|
end
|
|
707
|
-
@page_indexes.each do |chunk, _, offset_index|
|
|
862
|
+
@page_indexes.each do |chunk, _, offset_index, crypto|
|
|
863
|
+
next unless offset_index
|
|
708
864
|
bytes = offset_index.encode
|
|
865
|
+
bytes = crypto.encrypt(Encryption::OFFSET_INDEX, bytes) if crypto
|
|
709
866
|
chunk.offset_index_offset = @pos
|
|
710
867
|
chunk.offset_index_length = bytes.bytesize
|
|
711
868
|
write_raw(bytes)
|
|
@@ -780,12 +937,19 @@ module Herringbone
|
|
|
780
937
|
filter
|
|
781
938
|
end
|
|
782
939
|
|
|
783
|
-
# Bloom filters go right after the row group's column chunks, in column order
|
|
940
|
+
# Bloom filters go right after the row group's column chunks, in column order. A copied
|
|
941
|
+
# chunk brings its filter already encoded. In an encrypted column the header and the bitset
|
|
942
|
+
# are encrypted separately.
|
|
784
943
|
#
|
|
785
944
|
# @return [void]
|
|
786
945
|
def write_bloom_filters
|
|
787
|
-
@pending_bloom_filters.each do |meta, filter|
|
|
788
|
-
bytes =
|
|
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
|
|
789
953
|
meta.bloom_filter_offset = @pos
|
|
790
954
|
meta.bloom_filter_length = bytes.bytesize
|
|
791
955
|
write_raw(bytes)
|
|
@@ -902,17 +1066,40 @@ module Herringbone
|
|
|
902
1066
|
|
|
903
1067
|
# Writes a page, returning the uncompressed size including the header
|
|
904
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
|
+
#
|
|
905
1082
|
# @param header [Format::PageHeader] header; sizes and CRC32 are filled in here
|
|
906
1083
|
# @param body [String] uncompressed page body
|
|
907
1084
|
# @param compressed [String, nil] bytes to write as the page body, when already prepared (v2 data
|
|
908
1085
|
# pages, whose levels stay uncompressed); +body+ is compressed otherwise
|
|
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
|
|
909
1089
|
# @return [Integer]
|
|
910
|
-
def write_page(header, body, compressed = nil)
|
|
911
|
-
compressed ||=
|
|
1090
|
+
def write_page(header, body, compressed = nil, codec: @codec, crypto: nil, ordinal: nil)
|
|
1091
|
+
compressed ||= compress(codec, body)
|
|
912
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
|
|
913
1097
|
header.compressed_page_size = compressed.bytesize
|
|
914
1098
|
header.crc = Zlib.crc32(compressed).then { |c| (c >= 0x8000_0000) ? c - 0x1_0000_0000 : c }
|
|
915
1099
|
encoded = header.encode
|
|
1100
|
+
if crypto
|
|
1101
|
+
encoded = crypto.encrypt(dictionary ? Encryption::DICTIONARY_PAGE_HEADER : Encryption::DATA_PAGE_HEADER, encoded, ordinal)
|
|
1102
|
+
end
|
|
916
1103
|
write_raw(encoded)
|
|
917
1104
|
write_raw(compressed)
|
|
918
1105
|
encoded.bytesize + header.uncompressed_page_size
|
|
@@ -925,8 +1112,11 @@ module Herringbone
|
|
|
925
1112
|
# @param def_bytes [String] RLE-encoded definition levels, empty when the column has none
|
|
926
1113
|
# @param encoded [String] encoded values
|
|
927
1114
|
# @param encoding [Integer] encoding id of the values
|
|
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
|
|
928
1118
|
# @return [Integer] uncompressed size including the header
|
|
929
|
-
def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding)
|
|
1119
|
+
def write_data_page_v1(n, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
|
|
930
1120
|
body = String.new(encoding: Encoding::BINARY)
|
|
931
1121
|
body << [rep_bytes.bytesize].pack("V") << rep_bytes unless rep_bytes.empty?
|
|
932
1122
|
body << [def_bytes.bytesize].pack("V") << def_bytes unless def_bytes.empty?
|
|
@@ -938,7 +1128,7 @@ module Herringbone
|
|
|
938
1128
|
definition_level_encoding: E::RLE, repetition_level_encoding: E::RLE
|
|
939
1129
|
)
|
|
940
1130
|
)
|
|
941
|
-
write_page(header, body)
|
|
1131
|
+
write_page(header, body, codec: codec, crypto: page_crypto&.first, ordinal: page_crypto&.last)
|
|
942
1132
|
end
|
|
943
1133
|
|
|
944
1134
|
# A DATA_PAGE_V2: levels without length prefixes and uncompressed, then the compressed values
|
|
@@ -950,9 +1140,12 @@ module Herringbone
|
|
|
950
1140
|
# @param def_bytes [String] RLE-encoded definition levels, empty when the column has none
|
|
951
1141
|
# @param encoded [String] encoded values
|
|
952
1142
|
# @param encoding [Integer] encoding id of the values
|
|
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
|
|
953
1146
|
# @return [Integer] uncompressed size including the header
|
|
954
|
-
def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding)
|
|
955
|
-
compressed =
|
|
1147
|
+
def write_data_page_v2(n, nulls, rows, rep_bytes, def_bytes, encoded, encoding, codec, page_crypto)
|
|
1148
|
+
compressed = compress(codec, encoded)
|
|
956
1149
|
header = Format::PageHeader.new(
|
|
957
1150
|
type: Format::PageType::DATA_PAGE_V2,
|
|
958
1151
|
uncompressed_page_size: rep_bytes.bytesize + def_bytes.bytesize + encoded.bytesize,
|
|
@@ -960,10 +1153,11 @@ module Herringbone
|
|
|
960
1153
|
num_values: n, num_nulls: nulls, num_rows: rows, encoding: encoding,
|
|
961
1154
|
definition_levels_byte_length: def_bytes.bytesize,
|
|
962
1155
|
repetition_levels_byte_length: rep_bytes.bytesize,
|
|
963
|
-
is_compressed:
|
|
1156
|
+
is_compressed: codec != Format::Codec::UNCOMPRESSED
|
|
964
1157
|
)
|
|
965
1158
|
)
|
|
966
|
-
write_page(header, "".b, rep_bytes + def_bytes + compressed
|
|
1159
|
+
write_page(header, "".b, rep_bytes + def_bytes + compressed, codec: codec, crypto: page_crypto&.first,
|
|
1160
|
+
ordinal: page_crypto&.last)
|
|
967
1161
|
end
|
|
968
1162
|
|
|
969
1163
|
# Statistics and column index bounds longer than this are truncated, see #truncate_min
|