herringbone 0.3.0 → 0.5.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 +66 -0
- data/README.md +236 -38
- data/bin/herringbone +40 -10
- data/lib/herringbone/active_record.rb +1 -1
- data/lib/herringbone/byte_values.rb +9 -0
- data/lib/herringbone/codecs/lzo.rb +224 -0
- data/lib/herringbone/codecs/snappy.rb +13 -20
- data/lib/herringbone/compression.rb +26 -38
- data/lib/herringbone/encodings/plain.rb +2 -2
- data/lib/herringbone/inferring_writer.rb +168 -0
- data/lib/herringbone/reader/column_chunk_reader.rb +5 -8
- data/lib/herringbone/reader/numo.rb +18 -26
- data/lib/herringbone/reader.rb +3 -3
- data/lib/herringbone/redaction/rewriter.rb +533 -0
- data/lib/herringbone/redaction.rb +287 -0
- data/lib/herringbone/schema.rb +128 -69
- data/lib/herringbone/simple_writer.rb +162 -0
- data/lib/herringbone/thrift.rb +5 -5
- data/lib/herringbone/version.rb +1 -1
- data/lib/herringbone/writer.rb +156 -38
- data/lib/herringbone/xxhash.rb +21 -36
- data/lib/herringbone.rb +95 -9
- metadata +8 -2
data/lib/herringbone/xxhash.rb
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module Herringbone
|
|
4
4
|
# XXH64 (seed 0), the hash Parquet bloom filters use.
|
|
5
5
|
#
|
|
6
|
-
# When the optional "xxhash" gem (a C extension)
|
|
7
|
-
# faster; otherwise hashing is pure Ruby. The gem is only a speedup, so nothing
|
|
8
|
-
# it. +XXHash.backend = :ruby+ forces pure Ruby (for tests and benchmarks).
|
|
6
|
+
# When the optional "xxhash" gem (a C extension) is loaded (+require "xxhash"+) it is used,
|
|
7
|
+
# which is 20-40x faster; otherwise hashing is pure Ruby. The gem is only a speedup, so nothing
|
|
8
|
+
# fails without it. +XXHash.backend = :ruby+ forces pure Ruby (for tests and benchmarks).
|
|
9
9
|
#
|
|
10
10
|
# The pure-Ruby version keeps every 64-bit value as two 32-bit halves, and multiplies by the
|
|
11
11
|
# XXH64 primes split into 16-bit pieces, so that no intermediate result leaves the Fixnum range.
|
|
@@ -286,8 +286,7 @@ module Herringbone
|
|
|
286
286
|
|
|
287
287
|
module_eval(Generator.source, __FILE__, __LINE__)
|
|
288
288
|
|
|
289
|
-
@native = nil # nil:
|
|
290
|
-
@native_lib = nil
|
|
289
|
+
@native = nil # nil: native when loaded, false: pure Ruby, else the native module
|
|
291
290
|
|
|
292
291
|
class << self
|
|
293
292
|
# XXH64 of a String's bytes, as an unsigned 64-bit Integer
|
|
@@ -296,7 +295,7 @@ module Herringbone
|
|
|
296
295
|
# @return [Integer] the hash, 0...2^64
|
|
297
296
|
def xxh64(bytes)
|
|
298
297
|
native = @native
|
|
299
|
-
native =
|
|
298
|
+
native = native_library if native.nil?
|
|
300
299
|
native ? native.xxh64(bytes, 0) : ruby_xxh64(bytes)
|
|
301
300
|
end
|
|
302
301
|
|
|
@@ -307,7 +306,7 @@ module Herringbone
|
|
|
307
306
|
# @return [Integer] the hash, 0...2^64
|
|
308
307
|
def xxh64_u64(lane)
|
|
309
308
|
native = @native
|
|
310
|
-
native =
|
|
309
|
+
native = native_library if native.nil?
|
|
311
310
|
return native.xxh64([lane].pack("Q<"), 0) if native
|
|
312
311
|
ruby_xxh64_lane((lane >> 32) & M32, lane & M32)
|
|
313
312
|
end
|
|
@@ -318,7 +317,7 @@ module Herringbone
|
|
|
318
317
|
# @return [Integer] the hash, 0...2^64
|
|
319
318
|
def xxh64_u32(word)
|
|
320
319
|
native = @native
|
|
321
|
-
native =
|
|
320
|
+
native = native_library if native.nil?
|
|
322
321
|
return native.xxh64([word].pack("L<"), 0) if native
|
|
323
322
|
ruby_xxh64_u32(word & M32)
|
|
324
323
|
end
|
|
@@ -329,7 +328,7 @@ module Herringbone
|
|
|
329
328
|
# @return [Array<Integer>] the hashes, in the same order
|
|
330
329
|
def xxh64_u64_all(lanes)
|
|
331
330
|
native = @native
|
|
332
|
-
native =
|
|
331
|
+
native = native_library if native.nil?
|
|
333
332
|
if native
|
|
334
333
|
packed = lanes.pack("Q<*")
|
|
335
334
|
Array.new(lanes.size) { |i| native.xxh64(packed.byteslice(i << 3, 8), 0) }
|
|
@@ -344,7 +343,7 @@ module Herringbone
|
|
|
344
343
|
# @return [Array<Integer>] the hashes, in the same order
|
|
345
344
|
def xxh64_u32_all(words)
|
|
346
345
|
native = @native
|
|
347
|
-
native =
|
|
346
|
+
native = native_library if native.nil?
|
|
348
347
|
if native
|
|
349
348
|
packed = words.pack("L<*")
|
|
350
349
|
Array.new(words.size) { |i| native.xxh64(packed.byteslice(i << 2, 4), 0) }
|
|
@@ -359,7 +358,7 @@ module Herringbone
|
|
|
359
358
|
# @return [Array<Integer>] the hashes, in the same order
|
|
360
359
|
def xxh64_all(strings)
|
|
361
360
|
native = @native
|
|
362
|
-
native =
|
|
361
|
+
native = native_library if native.nil?
|
|
363
362
|
native ? strings.map { |s| native.xxh64(s, 0) } : strings.map { |s| ruby_xxh64(s) }
|
|
364
363
|
end
|
|
365
364
|
|
|
@@ -368,57 +367,43 @@ module Herringbone
|
|
|
368
367
|
# @return [Symbol] +:native+ or +:ruby+
|
|
369
368
|
def backend
|
|
370
369
|
native = @native
|
|
371
|
-
native =
|
|
370
|
+
native = native_library if native.nil?
|
|
372
371
|
native ? :native : :ruby
|
|
373
372
|
end
|
|
374
373
|
|
|
375
|
-
# :ruby forces pure Ruby, :native
|
|
376
|
-
#
|
|
374
|
+
# :ruby forces pure Ruby, :native the xxhash gem (UnsupportedError if it is not loaded), nil
|
|
375
|
+
# goes back to the default: native when loaded
|
|
377
376
|
#
|
|
378
377
|
# @param name [Symbol, nil] +:ruby+, +:native+ or nil
|
|
379
378
|
# @return [void]
|
|
380
|
-
# @raise [UnsupportedError] for +:native+ when the gem
|
|
379
|
+
# @raise [UnsupportedError] for +:native+ when the gem is not loaded
|
|
381
380
|
# @raise [ArgumentError] for any other name
|
|
382
381
|
def backend=(name)
|
|
383
382
|
@native = case name
|
|
384
383
|
when :ruby then false
|
|
385
384
|
when :native
|
|
386
|
-
native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem
|
|
385
|
+
native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem is not loaded (require \"#{NATIVE_GEM}\")")
|
|
387
386
|
when nil then nil
|
|
388
387
|
else raise ArgumentError, "Unknown XXHash backend #{name.inspect} (expected :ruby, :native or nil)"
|
|
389
388
|
end
|
|
390
389
|
end
|
|
391
390
|
|
|
392
|
-
# Whether the native xxhash gem
|
|
391
|
+
# Whether the native xxhash gem is loaded (whatever the selected backend)
|
|
393
392
|
#
|
|
394
|
-
# @return [Boolean] true when the gem is
|
|
393
|
+
# @return [Boolean] true when the gem is loaded
|
|
395
394
|
def native_available?
|
|
396
395
|
!!native_library
|
|
397
396
|
end
|
|
398
397
|
|
|
399
398
|
private
|
|
400
399
|
|
|
401
|
-
#
|
|
402
|
-
#
|
|
403
|
-
# @return [Module, false] the native module, or false for pure Ruby
|
|
404
|
-
def resolve_backend
|
|
405
|
-
@native = native_library || false
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
# Requires the xxhash gem once and memoizes the result (a failed require is not retried)
|
|
400
|
+
# Not memoized, so that it works the same in every Ractor and once the gem is required later
|
|
409
401
|
#
|
|
410
402
|
# @return [Module, false] the gem's module to call +xxh64(data, seed)+ on, or false when it
|
|
411
|
-
#
|
|
403
|
+
# is not loaded
|
|
412
404
|
def native_library
|
|
413
|
-
if
|
|
414
|
-
|
|
415
|
-
require NATIVE_GEM
|
|
416
|
-
defined?(::XXhash::XXhashInternal) ? ::XXhash::XXhashInternal : ::XXhash
|
|
417
|
-
rescue LoadError
|
|
418
|
-
false
|
|
419
|
-
end
|
|
420
|
-
end
|
|
421
|
-
@native_lib
|
|
405
|
+
return ::XXhash::XXhashInternal if defined?(::XXhash::XXhashInternal)
|
|
406
|
+
defined?(::XXhash.xxh64) ? ::XXhash : false
|
|
422
407
|
end
|
|
423
408
|
end
|
|
424
409
|
end
|
data/lib/herringbone.rb
CHANGED
|
@@ -10,8 +10,33 @@ module Herringbone
|
|
|
10
10
|
class Error < StandardError; end
|
|
11
11
|
# The file is not valid Parquet (bad metadata, corrupt pages...)
|
|
12
12
|
class FormatError < Error; end
|
|
13
|
+
|
|
13
14
|
# A value cannot be written to its column
|
|
14
|
-
class EncodeError < Error
|
|
15
|
+
class EncodeError < Error
|
|
16
|
+
# @return [Integer, nil] index of the row being written (0 for the first), when known
|
|
17
|
+
attr_reader :row
|
|
18
|
+
# @return [String, nil] dotted path of the column the value was meant for, when known
|
|
19
|
+
attr_reader :column
|
|
20
|
+
# @return [Object] the value that could not be written (nil when it was a missing required value)
|
|
21
|
+
attr_reader :value
|
|
22
|
+
|
|
23
|
+
# @param message [String, nil] the error message
|
|
24
|
+
# @param row [Integer, nil] index of the row being written
|
|
25
|
+
# @param column [String, nil] dotted path of the column
|
|
26
|
+
# @param value [Object] the value that could not be written
|
|
27
|
+
def initialize(message = nil, row: nil, column: nil, value: nil)
|
|
28
|
+
super(message)
|
|
29
|
+
@row = row
|
|
30
|
+
@column = column
|
|
31
|
+
@value = value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A row does not fit a schema that was inferred from earlier rows (Herringbone.write without
|
|
36
|
+
# +schema:+, SimpleWriter). The message says what was inferred, from how many rows, and how to
|
|
37
|
+
# declare the column instead.
|
|
38
|
+
class SchemaMismatch < EncodeError; end
|
|
39
|
+
|
|
15
40
|
# The file (or the writer configuration) uses a Parquet feature Herringbone does not implement,
|
|
16
41
|
# such as encryption or a codec whose library is unavailable
|
|
17
42
|
class UnsupportedError < Error; end
|
|
@@ -20,6 +45,7 @@ end
|
|
|
20
45
|
require_relative "herringbone/io_buffer_support"
|
|
21
46
|
require_relative "herringbone/codecs/snappy"
|
|
22
47
|
require_relative "herringbone/codecs/lz4"
|
|
48
|
+
require_relative "herringbone/codecs/lzo"
|
|
23
49
|
require_relative "herringbone/thrift"
|
|
24
50
|
require_relative "herringbone/format"
|
|
25
51
|
require_relative "herringbone/encodings/rle"
|
|
@@ -32,10 +58,13 @@ require_relative "herringbone/active_record"
|
|
|
32
58
|
require_relative "herringbone/reader"
|
|
33
59
|
require_relative "herringbone/byte_values"
|
|
34
60
|
require_relative "herringbone/writer"
|
|
61
|
+
require_relative "herringbone/inferring_writer"
|
|
62
|
+
require_relative "herringbone/simple_writer"
|
|
35
63
|
require_relative "herringbone/xxhash"
|
|
36
64
|
require_relative "herringbone/bloom_filter"
|
|
37
65
|
require_relative "herringbone/inspector"
|
|
38
66
|
require_relative "herringbone/visualizer"
|
|
67
|
+
require_relative "herringbone/redaction"
|
|
39
68
|
|
|
40
69
|
module Herringbone
|
|
41
70
|
module_function
|
|
@@ -43,10 +72,13 @@ module Herringbone
|
|
|
43
72
|
# Writes +records+ to +io+ (any IO responding to #write; Herringbone never opens files by path)
|
|
44
73
|
# and returns the number of rows written. +records+ is an Enumerable of rows, or an ActiveRecord
|
|
45
74
|
# model or relation, which is read with find_each. Without +schema+, the schema comes from the
|
|
46
|
-
# model's columns (Schema.from_active_record) or is inferred from the first rows (Schema.infer)
|
|
47
|
-
#
|
|
75
|
+
# model's columns (Schema.from_active_record) or is inferred from the first rows (Schema.infer);
|
|
76
|
+
# fields declared in the block replace inferred ones. +records+ is iterated once, so a source
|
|
77
|
+
# that can only be read once (a cursor, a lazy Enumerator over an IO) works too. Other options go
|
|
78
|
+
# to Writer.
|
|
48
79
|
#
|
|
49
80
|
# File.open("orders.parquet", "wb") { |f| Herringbone.write(f, Order.where(created_at: 1.year.ago..)) }
|
|
81
|
+
# Herringbone.write(io, events.lazy.map(&:to_h)) { |s| s.json :payload }
|
|
50
82
|
#
|
|
51
83
|
# @param io [IO, #write] destination; written sequentially, never closed
|
|
52
84
|
# @param records [Enumerable<Hash, Array, Object>, Class, #find_each] rows (Hashes, Arrays in schema
|
|
@@ -66,27 +98,81 @@ module Herringbone
|
|
|
66
98
|
# @option options [Hash{String => String}] :metadata ({}) key/value metadata for the footer
|
|
67
99
|
# @option options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
|
|
68
100
|
# columns to write split block bloom filters for, see Writer
|
|
101
|
+
# @yield [s] optional, declares fields that replace inferred ones (see Schema.infer); ignored when
|
|
102
|
+
# the schema is not inferred
|
|
103
|
+
# @yieldparam s [Schema::Builder] the builder to declare fields on
|
|
104
|
+
# @yieldreturn [void]
|
|
69
105
|
# @return [Integer] number of rows written
|
|
70
106
|
# @raise [ArgumentError] when the schema has to be inferred and +records+ is empty or holds Array
|
|
71
|
-
# rows,
|
|
107
|
+
# rows, an option is invalid, or the block takes no parameter
|
|
72
108
|
# @raise [EncodeError] when a row does not fit the schema
|
|
73
|
-
|
|
109
|
+
# @raise [SchemaMismatch] when a row does not fit the inferred schema; the file is left unfinished
|
|
110
|
+
def write(io, records, schema: nil, **options, &overrides)
|
|
111
|
+
Schema::Builder.check_block!(overrides, "Herringbone.write(io, rows) { |s| s.json :payload }")
|
|
74
112
|
model = if records.respond_to?(:klass) then records.klass
|
|
75
113
|
elsif records.respond_to?(:columns) && records.respond_to?(:find_each) then records
|
|
76
114
|
end
|
|
77
|
-
schema ||=
|
|
78
|
-
|
|
115
|
+
schema ||= Schema.from_active_record(model) if model
|
|
116
|
+
writer = if schema
|
|
117
|
+
Writer.new(io, schema, **options)
|
|
118
|
+
else
|
|
119
|
+
fix = "Herringbone.write(io, rows) { |s| s.%s }"
|
|
120
|
+
InferringWriter.new(io, fix: fix, **options) { |sample| Schema.infer(sample, &overrides) }
|
|
121
|
+
end
|
|
122
|
+
begin
|
|
79
123
|
if records.respond_to?(:find_each)
|
|
80
124
|
records.find_each { |record| writer << record }
|
|
81
125
|
else
|
|
82
126
|
records.each { |record| writer << record }
|
|
83
127
|
end
|
|
84
|
-
|
|
128
|
+
rescue Exception # rubocop:disable Lint/RescueException -- also abort on Interrupt
|
|
129
|
+
writer.abort
|
|
130
|
+
raise
|
|
131
|
+
end
|
|
132
|
+
writer.close
|
|
133
|
+
writer.rows_written
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Rewrites +input+ into +output+ with rows removed or values replaced, building the Redaction from
|
|
137
|
+
# the block (or taking +redaction+). A shortcut for Redaction#apply.
|
|
138
|
+
#
|
|
139
|
+
# Herringbone.redact(input, output) do |r|
|
|
140
|
+
# r.where(user_id: 42).delete
|
|
141
|
+
# r.replace(:email) { |email| email && OpenSSL::HMAC.hexdigest("SHA256", KEY, email) }
|
|
142
|
+
# end
|
|
143
|
+
#
|
|
144
|
+
# @param input [IO, StringIO] the Parquet file, read with #seek and #read; not closed
|
|
145
|
+
# @param output [IO, #write] destination, written sequentially; not closed
|
|
146
|
+
# @param redaction [Redaction, nil] the redaction to apply, instead of a block
|
|
147
|
+
# @param writer_options [Hash{Symbol => Object}] Writer options for re-encoded column chunks, and
|
|
148
|
+
# +metadata:+ to replace the footer key/value metadata, see Redaction#apply
|
|
149
|
+
# @option writer_options [Symbol] :compression (codec of each source chunk) codec for re-encoded chunks
|
|
150
|
+
# @option writer_options [Boolean, Array<String>, Hash{String => Boolean, Hash}] :bloom_filters (nil)
|
|
151
|
+
# columns whose re-encoded chunks get a bloom filter, besides those whose source chunk had one
|
|
152
|
+
# @option writer_options [Hash{String => String}] :metadata (the input's) footer key/value metadata
|
|
153
|
+
# @option writer_options [Integer] :page_bytes (1MB) approximate uncompressed data page size
|
|
154
|
+
# @option writer_options [Integer] :page_rows (20_000) maximum rows per data page
|
|
155
|
+
# @option writer_options [Integer] :data_page_version (1) 1 or 2
|
|
156
|
+
# @option writer_options [Boolean, Array<String>] :dictionary (true) see Writer
|
|
157
|
+
# @option writer_options [Hash{String => Symbol}] :encodings ({}) see Writer
|
|
158
|
+
# @yield [r] declares the statements on a new Redaction (see Redaction.new)
|
|
159
|
+
# @yieldparam r [Redaction] the redaction being built
|
|
160
|
+
# @yieldreturn [void]
|
|
161
|
+
# @return [Redaction::Report] what was done
|
|
162
|
+
# @raise [ArgumentError] when given both or neither of +redaction+ and a block, a block that takes
|
|
163
|
+
# no parameter, or when the redaction does not fit the file's schema
|
|
164
|
+
# @raise [EncodeError] when a replacement value cannot be written to its column
|
|
165
|
+
def redact(input, output, redaction = nil, **writer_options, &block)
|
|
166
|
+
raise ArgumentError, "Herringbone.redact takes a Redaction or a block, not both" if redaction && block
|
|
167
|
+
raise ArgumentError, "Herringbone.redact needs a Redaction or a block" unless redaction || block
|
|
168
|
+
if block&.arity&.zero?
|
|
169
|
+
raise ArgumentError, "The block receives the redaction: Herringbone.redact(input, output) { |r| r.where(user_id: 42).delete }"
|
|
85
170
|
end
|
|
171
|
+
(redaction || Redaction.new(&block)).apply(input, output, **writer_options)
|
|
86
172
|
end
|
|
87
173
|
|
|
88
174
|
# Compression codecs this process can read and write, e.g. [:none, :snappy, :gzip, :lz4, :lz4_hadoop, :zstd].
|
|
89
|
-
# :zstd and :brotli are listed when the zstd-ruby / brotli gems
|
|
175
|
+
# :zstd and :brotli are listed when the zstd-ruby / brotli gems are loaded.
|
|
90
176
|
#
|
|
91
177
|
# @return [Array<Symbol>] codec names accepted by the writer's +compression:+ option
|
|
92
178
|
def codecs
|
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.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julik Tarkhanov
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: bigdecimal
|
|
@@ -33,6 +33,7 @@ executables:
|
|
|
33
33
|
extensions: []
|
|
34
34
|
extra_rdoc_files: []
|
|
35
35
|
files:
|
|
36
|
+
- CHANGELOG.md
|
|
36
37
|
- LICENSE
|
|
37
38
|
- README.md
|
|
38
39
|
- bin/herringbone
|
|
@@ -41,12 +42,14 @@ files:
|
|
|
41
42
|
- lib/herringbone/bloom_filter.rb
|
|
42
43
|
- lib/herringbone/byte_values.rb
|
|
43
44
|
- lib/herringbone/codecs/lz4.rb
|
|
45
|
+
- lib/herringbone/codecs/lzo.rb
|
|
44
46
|
- lib/herringbone/codecs/snappy.rb
|
|
45
47
|
- lib/herringbone/compression.rb
|
|
46
48
|
- lib/herringbone/encodings/delta.rb
|
|
47
49
|
- lib/herringbone/encodings/plain.rb
|
|
48
50
|
- lib/herringbone/encodings/rle.rb
|
|
49
51
|
- lib/herringbone/format.rb
|
|
52
|
+
- lib/herringbone/inferring_writer.rb
|
|
50
53
|
- lib/herringbone/inspector.rb
|
|
51
54
|
- lib/herringbone/io_buffer_support.rb
|
|
52
55
|
- lib/herringbone/reader.rb
|
|
@@ -55,7 +58,10 @@ files:
|
|
|
55
58
|
- lib/herringbone/reader/numo.rb
|
|
56
59
|
- lib/herringbone/reader/page_stream.rb
|
|
57
60
|
- lib/herringbone/reader/scan.rb
|
|
61
|
+
- lib/herringbone/redaction.rb
|
|
62
|
+
- lib/herringbone/redaction/rewriter.rb
|
|
58
63
|
- lib/herringbone/schema.rb
|
|
64
|
+
- lib/herringbone/simple_writer.rb
|
|
59
65
|
- lib/herringbone/thrift.rb
|
|
60
66
|
- lib/herringbone/types.rb
|
|
61
67
|
- lib/herringbone/version.rb
|