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.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ module Codecs
5
+ # Pure-Ruby LZO1X decompressor, for reading Parquet files written by parquet-mr with
6
+ # hadoop-lzo. There is no compressor: nothing but hadoop-lzo writes LZO, and nothing
7
+ # outside the JVM reads it.
8
+ #
9
+ # Written from the bitstream description in the Linux kernel's
10
+ # Documentation/staging/lzo.rst, not from the (GPL) LZO sources.
11
+ module LZO
12
+ # Raised for corrupt or truncated LZO input.
13
+ class Error < StandardError; end
14
+
15
+ # Hadoop block header: big-endian 32-bit uncompressed size of the block.
16
+ HADOOP_BLOCK_PREFIX = 4
17
+ # Hadoop chunk header: big-endian 32-bit compressed size of the chunk.
18
+ HADOOP_CHUNK_PREFIX = 4
19
+ # Shorthand for Encoding::BINARY.
20
+ BINARY = Encoding::BINARY
21
+
22
+ module_function
23
+
24
+ # Parquet LZO (codec 3). hadoop-lzo writes Hadoop block framing; data that does not fit
25
+ # the framing is decoded as one bare LZO1X stream.
26
+ # @param input [String] compressed page data
27
+ # @param uncompressed_size [Integer] exact decompressed size, from the page header
28
+ # @return [String] decompressed bytes in ASCII-8BIT
29
+ # @raise [Error] if neither layout decodes
30
+ def decompress_hadoop(input, uncompressed_size)
31
+ src = binary(input)
32
+ try_hadoop(src, uncompressed_size) || decompress_block(src, uncompressed_size)
33
+ end
34
+
35
+ # Decompress one bare LZO1X stream that must expand to exactly uncompressed_size bytes.
36
+ # @param input [String] LZO1X stream, ending with the end-of-stream marker
37
+ # @param uncompressed_size [Integer] exact decompressed size
38
+ # @return [String] decompressed bytes in ASCII-8BIT
39
+ # @raise [Error] if the stream is corrupt or does not decode to +uncompressed_size+ bytes
40
+ def decompress_block(input, uncompressed_size)
41
+ src = binary(input)
42
+ out = String.new(capacity: uncompressed_size, encoding: BINARY)
43
+ ip = decode_block(src, 0, src.bytesize, out, uncompressed_size)
44
+ raise Error, "#{src.bytesize - ip} bytes after the end of the LZO stream" unless ip == src.bytesize
45
+ unless out.bytesize == uncompressed_size
46
+ raise Error, "LZO stream decoded to #{out.bytesize} bytes, expected #{uncompressed_size}"
47
+ end
48
+ out
49
+ end
50
+
51
+ # @param str [String] input in any encoding
52
+ # @return [String] +str+ itself if already binary, otherwise a binary copy
53
+ def binary(str)
54
+ (str.encoding == BINARY) ? str : str.b
55
+ end
56
+
57
+ # Hadoop's BlockCompressorStream layout: blocks of (uncompressed size, then chunks of
58
+ # (compressed size, LZO stream) until the block is filled). Each chunk is a separate stream.
59
+ # @param src [String] binary input
60
+ # @param uncompressed_size [Integer] exact decompressed size expected over all blocks
61
+ # @return [String, nil] decompressed bytes, or nil if +src+ is not valid Hadoop-framed LZO
62
+ def try_hadoop(src, uncompressed_size)
63
+ n = src.bytesize
64
+ out = String.new(capacity: uncompressed_size, encoding: BINARY)
65
+ ip = 0
66
+ while ip < n
67
+ return nil if n - ip < HADOOP_BLOCK_PREFIX
68
+ block_end = out.bytesize + src.byteslice(ip, HADOOP_BLOCK_PREFIX).unpack1("N")
69
+ ip += HADOOP_BLOCK_PREFIX
70
+ return nil if block_end > uncompressed_size
71
+
72
+ while out.bytesize < block_end
73
+ return nil if n - ip < HADOOP_CHUNK_PREFIX
74
+ csize = src.byteslice(ip, HADOOP_CHUNK_PREFIX).unpack1("N")
75
+ ip += HADOOP_CHUNK_PREFIX
76
+ return nil if csize > n - ip
77
+ begin
78
+ return nil unless decode_block(src, ip, ip + csize, out, block_end) == ip + csize
79
+ rescue Error
80
+ return nil
81
+ end
82
+ ip += csize
83
+ end
84
+ end
85
+ (out.bytesize == uncompressed_size) ? out : nil
86
+ end
87
+
88
+ # Decode one LZO1X stream from src[ip...iend], appending to out. Matches may only reach
89
+ # back to where this stream's output starts. out may not grow beyond limit.
90
+ #
91
+ # Every instruction is a match optionally followed by 0-3 literals (its low two bits, the
92
+ # "state"), or a run of 4+ literals. What an instruction byte below 16 means depends on
93
+ # the state the previous instruction left behind.
94
+ # @param src [String] binary input
95
+ # @param ip [Integer] offset of the stream in +src+
96
+ # @param iend [Integer] offset just past the stream
97
+ # @param out [String] binary output buffer, appended to
98
+ # @param limit [Integer] maximum total byte size of +out+
99
+ # @return [Integer] input offset just past the end-of-stream marker
100
+ # @raise [Error] if the stream is truncated, has an invalid distance or overflows +limit+
101
+ def decode_block(src, ip, iend, out, limit)
102
+ base = out.bytesize
103
+ raise Error, "Empty LZO stream" if ip >= iend
104
+
105
+ state = 0
106
+ t = src.getbyte(ip)
107
+ if t > 17 # the first byte may open with a literal run of its own
108
+ ip += 1
109
+ state = t - 17
110
+ ip = copy_literals(src, ip, iend, out, limit, state)
111
+ state = 4 if state > 4
112
+ end
113
+
114
+ while true
115
+ raise Error, "Truncated LZO stream" if ip >= iend
116
+ t = src.getbyte(ip)
117
+ ip += 1
118
+
119
+ if t < 16
120
+ if state == 0
121
+ len = t + 3
122
+ len, ip = extended_length(src, ip, iend, 18) if t == 0
123
+ ip = copy_literals(src, ip, iend, out, limit, len)
124
+ state = 4
125
+ next
126
+ end
127
+ raise Error, "Truncated LZO stream" if ip >= iend
128
+ dist = (src.getbyte(ip) << 2) + (t >> 2) + 1
129
+ ip += 1
130
+ if state == 4
131
+ dist += 2048
132
+ len = 3
133
+ else
134
+ len = 2
135
+ end
136
+ s = t & 3
137
+ elsif t < 32
138
+ len = (t & 7) + 2
139
+ len, ip = extended_length(src, ip, iend, 9) if t & 7 == 0
140
+ raise Error, "Truncated LZO stream" if ip + 2 > iend
141
+ le = src.getbyte(ip) | (src.getbyte(ip + 1) << 8)
142
+ ip += 2
143
+ dist = ((t & 8) << 11) + (le >> 2)
144
+ return ip if dist == 0 # end of stream
145
+ dist += 16_384
146
+ s = le & 3
147
+ elsif t < 64
148
+ len = (t & 31) + 2
149
+ len, ip = extended_length(src, ip, iend, 33) if t & 31 == 0
150
+ raise Error, "Truncated LZO stream" if ip + 2 > iend
151
+ le = src.getbyte(ip) | (src.getbyte(ip + 1) << 8)
152
+ ip += 2
153
+ dist = (le >> 2) + 1
154
+ s = le & 3
155
+ else
156
+ raise Error, "Truncated LZO stream" if ip >= iend
157
+ len = (t >> 5) + 1 # 3-4 bytes for 01LDDDSS, 5-8 for 1LLDDDSS
158
+ dist = (src.getbyte(ip) << 3) + ((t >> 2) & 7) + 1
159
+ ip += 1
160
+ s = t & 3
161
+ end
162
+
163
+ copy_match(out, base, dist, len, limit)
164
+ ip = copy_literals(src, ip, iend, out, limit, s) if s > 0
165
+ state = s
166
+ end
167
+ end
168
+
169
+ # Reads a length extension: a byte of 0 for each 255, then the nonzero remainder.
170
+ # @param src [String] binary input
171
+ # @param ip [Integer] offset of the first extension byte
172
+ # @param iend [Integer] offset just past the stream
173
+ # @param bias [Integer] added to the extension
174
+ # @return [Array(Integer, Integer)] the length, and the offset just past the extension
175
+ # @raise [Error] if the extension runs past +iend+
176
+ def extended_length(src, ip, iend, bias)
177
+ len = bias
178
+ while true
179
+ raise Error, "Truncated LZO length" if ip >= iend
180
+ b = src.getbyte(ip)
181
+ ip += 1
182
+ return [len + b, ip] unless b == 0
183
+ len += 255
184
+ end
185
+ end
186
+
187
+ # @param src [String] binary input
188
+ # @param ip [Integer] offset of the literals in +src+
189
+ # @param iend [Integer] offset just past the stream
190
+ # @param out [String] binary output buffer, appended to
191
+ # @param limit [Integer] maximum total byte size of +out+
192
+ # @param len [Integer] number of literal bytes
193
+ # @return [Integer] offset just past the literals
194
+ # @raise [Error] if the literals run past +iend+ or +out+ would exceed +limit+
195
+ def copy_literals(src, ip, iend, out, limit, len)
196
+ raise Error, "LZO literals run past end of input" if ip + len > iend
197
+ raise Error, "LZO output exceeds expected size" if out.bytesize + len > limit
198
+ out << src.byteslice(ip, len)
199
+ ip + len
200
+ end
201
+
202
+ # @param out [String] binary output buffer, appended to
203
+ # @param base [Integer] size of +out+ when the current stream started
204
+ # @param dist [Integer] how far back the match starts
205
+ # @param len [Integer] match length, which may exceed +dist+ (a repeating pattern)
206
+ # @param limit [Integer] maximum total byte size of +out+
207
+ # @return [String] +out+
208
+ # @raise [Error] if the match reaches before the stream's output or +out+ would exceed +limit+
209
+ def copy_match(out, base, dist, len, limit)
210
+ pos = out.bytesize
211
+ raise Error, "LZO match distance #{dist} reaches before start of output" if dist > pos - base
212
+ raise Error, "LZO output exceeds expected size" if pos + len > limit
213
+ if dist >= len
214
+ out << out.byteslice(pos - dist, len)
215
+ else
216
+ pattern = out.byteslice(pos - dist, dist)
217
+ out << (pattern * (len / dist)) << pattern.byteslice(0, len % dist)
218
+ end
219
+ end
220
+
221
+ private_class_method :binary, :try_hadoop, :decode_block, :extended_length, :copy_literals, :copy_match
222
+ end
223
+ end
224
+ end
@@ -7,7 +7,7 @@ module Herringbone
7
7
  # Pure-Ruby implementation of the raw Snappy block format (as used by Parquet),
8
8
  # see https://github.com/google/snappy/blob/main/format_description.txt
9
9
  #
10
- # When the `snappy` gem (a binding to Google's libsnappy) can be loaded, it is used instead:
10
+ # When the `snappy` gem (a binding to Google's libsnappy) is loaded, it is used instead:
11
11
  # 12x faster decompression and 27x faster compression. It is optional and only a speedup: if
12
12
  # it is missing, the pure-Ruby code below is used silently. Both produce raw Snappy blocks the
13
13
  # other reads.
@@ -30,7 +30,7 @@ module Herringbone
30
30
  # The length preamble is a 32-bit varint, so no block may decompress to more than this.
31
31
  MAX_UNCOMPRESSED = (1 << 32) - 1
32
32
 
33
- # Name of the optional native gem, passed to +require+.
33
+ # Name of the optional native gem.
34
34
  NATIVE_GEM = "snappy"
35
35
 
36
36
  module_function
@@ -57,41 +57,34 @@ module Herringbone
57
57
  native ? :native : :ruby
58
58
  end
59
59
 
60
- # For tests and benchmarks: :ruby forces pure Ruby, :native requires the snappy gem
61
- # (UnsupportedError if it cannot be loaded), nil goes back to the default
60
+ # For tests and benchmarks: :ruby forces pure Ruby, :native the snappy gem
61
+ # (UnsupportedError if it is not loaded), nil goes back to the default
62
62
  # @param name [Symbol, nil] +:ruby+, +:native+ or nil
63
63
  # @return [void]
64
- # @raise [UnsupportedError] if +:native+ is requested and the gem cannot be loaded
64
+ # @raise [UnsupportedError] if +:native+ is requested and the gem is not loaded
65
65
  # @raise [ArgumentError] for any other backend name
66
66
  def backend=(name)
67
67
  @native = case name
68
68
  when :ruby then false
69
69
  when :native
70
- native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem could not be loaded")
70
+ native_library || raise(UnsupportedError, "The \"#{NATIVE_GEM}\" gem is not loaded (require \"#{NATIVE_GEM}\")")
71
71
  when nil then nil
72
72
  else raise ArgumentError, "Unknown Snappy backend #{name.inspect} (expected :ruby, :native or nil)"
73
73
  end
74
74
  end
75
75
 
76
- # The native library to use, resolved (and memoized) on first call unless forced by backend=.
76
+ # The native library to use: the snappy gem when it is loaded, unless backend= forced one.
77
77
  # @return [Module, nil] the +::Snappy+ module, or nil when the pure-Ruby code should run
78
78
  def native
79
- @native = native_library || false if @native.nil?
80
- @native || nil
79
+ native = @native
80
+ native = native_library if native.nil?
81
+ native || nil
81
82
  end
82
83
 
83
- # Tries to load the snappy gem once and memoizes the result.
84
- # @return [Module, false] the +::Snappy+ module, or false if it is missing or lacks inflate/deflate
84
+ # Not memoized, so that it works the same in every Ractor and once the gem is required later.
85
+ # @return [Module, false] the +::Snappy+ module, or false if it is not loaded or lacks inflate/deflate
85
86
  def native_library
86
- if @native_lib.nil?
87
- @native_lib = begin
88
- require NATIVE_GEM
89
- (::Snappy.respond_to?(:inflate) && ::Snappy.respond_to?(:deflate)) ? ::Snappy : false
90
- rescue LoadError
91
- false
92
- end
93
- end
94
- @native_lib
87
+ (defined?(::Snappy.inflate) && defined?(::Snappy.deflate)) ? ::Snappy : false
95
88
  end
96
89
 
97
90
  # Decompresses into a preallocated IO::Buffer: copies do not allocate intermediate Strings.
@@ -4,80 +4,67 @@ require "zlib"
4
4
  require "stringio"
5
5
 
6
6
  module Herringbone
7
- # Raised when a file uses (or a writer asks for) a codec whose library is not installed
7
+ # Raised when a file uses (or a writer asks for) a codec whose library is not loaded
8
8
  class MissingCodecError < UnsupportedError
9
9
  # @return [String] codec name (+"ZSTD"+) and name of the gem providing it (+"zstd-ruby"+)
10
10
  attr_reader :codec, :gem_name
11
11
 
12
12
  # @param codec [String] codec name, as shown in the message
13
13
  # @param gem_name [String] gem to add to the Gemfile
14
- # @param load_error [LoadError] the error from requiring the gem, whose message is included
15
- def initialize(codec, gem_name, load_error)
14
+ # @param path [String] what to require to load the gem
15
+ def initialize(codec, gem_name, path)
16
16
  @codec = codec
17
17
  @gem_name = gem_name
18
- super("#{codec} compression needs the \"#{gem_name}\" gem, which could not be loaded " \
19
- "(#{load_error.message}). Add `gem \"#{gem_name}\"` to your Gemfile to use #{codec}.")
18
+ super("#{codec} compression needs the \"#{gem_name}\" gem, which is not loaded. Add " \
19
+ "`gem \"#{gem_name}\"` to your Gemfile and `require \"#{path}\"` to use #{codec}.")
20
20
  end
21
21
  end
22
22
 
23
23
  # Dispatches page (de)compression by Parquet codec id. Snappy and LZ4 are pure Ruby and GZIP
24
- # uses zlib, so those always work. ZSTD and Brotli come from optional gems (zstd-ruby, brotli),
25
- # which are required on first use; if they are missing, MissingCodecError says what to add.
26
- # (Snappy also uses the optional snappy gem when it is installed, see Codecs::Snappy.)
24
+ # uses zlib, so those always work. LZO is pure Ruby too, but can only be read. ZSTD and Brotli
25
+ # come from optional gems (zstd-ruby, brotli), which the application requires; if they are
26
+ # not loaded, MissingCodecError says what to add.
27
+ # (Snappy also uses the optional snappy gem when it is loaded, see Codecs::Snappy.)
27
28
  module Compression
28
29
  module_function
29
30
 
30
31
  # Codecs backed by optional native gems: codec id => [name, gem, require path, constant]
31
- LIBRARIES = {
32
+ LIBRARIES = Ractor.make_shareable({
32
33
  Format::Codec::ZSTD => ["ZSTD", "zstd-ruby", "zstd-ruby", :Zstd],
33
34
  Format::Codec::BROTLI => ["Brotli", "brotli", "brotli", :Brotli]
34
- }.freeze
35
-
36
- @libraries = {}
37
- @library_mutex = Mutex.new
35
+ })
38
36
 
39
- # The library module for a codec backed by an optional gem, requiring it on first use
37
+ # The library module for a codec backed by an optional gem
40
38
  #
41
39
  # @param codec [Integer] a codec id that is a key of LIBRARIES
42
40
  # @return [Module] the gem's module (+Zstd+ or +Brotli+)
43
- # @raise [MissingCodecError] when the gem cannot be loaded
41
+ # @raise [MissingCodecError] when the gem is not loaded
44
42
  # @raise [KeyError] for a codec id not in LIBRARIES
45
43
  def library(codec)
46
- @libraries.fetch(codec) do
47
- @library_mutex.synchronize do
48
- @libraries.fetch(codec) do
49
- name, gem_name, path, const = LIBRARIES.fetch(codec)
50
- begin
51
- require_library(path)
52
- rescue LoadError => e
53
- raise MissingCodecError.new(name, gem_name, e)
54
- end
55
- @libraries[codec] = Object.const_get(const)
56
- end
57
- end
58
- end
44
+ name, gem_name, path, const = LIBRARIES.fetch(codec)
45
+ loaded_library(const) || raise(MissingCodecError.new(name, gem_name, path))
59
46
  end
60
47
 
61
- # Requires a codec gem; a separate method so tests can stub it to simulate a missing gem
48
+ # A separate method so tests can stub it to simulate a missing gem
62
49
  #
63
- # @param path [String] require path of the gem
64
- # @return [Boolean] whether the library was newly loaded
65
- # @raise [LoadError] when the gem is not installed
66
- def require_library(path)
67
- require path
50
+ # @param const [Symbol] top-level constant the gem defines
51
+ # @return [Module, nil] the gem's module, nil when it is not loaded
52
+ def loaded_library(const)
53
+ Object.const_get(const) if Object.const_defined?(const)
68
54
  end
69
55
 
70
56
  # Raises MissingCodecError (or UnsupportedError) unless +codec+ can be used
71
57
  #
72
58
  # @param codec [Integer, Symbol, String] codec id or name (see NAMES)
73
59
  # @return [Module, nil] the gem's module for a gem-backed codec, nil for a built-in one
74
- # @raise [MissingCodecError] when the codec's gem cannot be loaded
75
- # @raise [UnsupportedError] for a codec herringbone does not implement (LZO)
60
+ # @raise [MissingCodecError] when the codec's gem is not loaded
61
+ # @raise [UnsupportedError] for a codec herringbone cannot write (LZO)
76
62
  # @raise [ArgumentError] for an unknown codec name
77
63
  def ensure_available!(codec)
78
64
  codec = codec_id(codec)
79
65
  return library(codec) if LIBRARIES.key?(codec)
80
66
  return if SUPPORTED.include?(codec)
67
+ raise UnsupportedError, "LZO compression is only supported for reading" if codec == Format::Codec::LZO
81
68
  raise UnsupportedError, "#{Format::Codec::NAMES[codec] || codec} compression is not supported"
82
69
  end
83
70
 
@@ -115,7 +102,7 @@ module Herringbone
115
102
  # @param uncompressed_size [Integer] expected decompressed size in bytes
116
103
  # @return [String] decompressed bytes (binary)
117
104
  # @raise [UnsupportedError] for a codec herringbone does not implement
118
- # @raise [MissingCodecError] when the codec's gem cannot be loaded
105
+ # @raise [MissingCodecError] when the codec's gem is not loaded
119
106
  # @raise [FormatError] when the decompressed size does not match +uncompressed_size+
120
107
  def decompress(codec, data, uncompressed_size)
121
108
  return "".b if uncompressed_size.zero? && data.empty?
@@ -125,6 +112,7 @@ module Herringbone
125
112
  when Format::Codec::GZIP then gunzip(data)
126
113
  when Format::Codec::LZ4_RAW then Codecs::LZ4.decompress_block(data, uncompressed_size)
127
114
  when Format::Codec::LZ4 then Codecs::LZ4.decompress_hadoop(data, uncompressed_size)
115
+ when Format::Codec::LZO then Codecs::LZO.decompress_hadoop(data, uncompressed_size)
128
116
  when Format::Codec::ZSTD then library(codec).decompress(data)
129
117
  when Format::Codec::BROTLI then library(codec).inflate(data)
130
118
  else
@@ -143,7 +131,7 @@ module Herringbone
143
131
  # @param data [String] bytes to compress
144
132
  # @return [String] compressed bytes (binary)
145
133
  # @raise [UnsupportedError] for a codec herringbone does not implement
146
- # @raise [MissingCodecError] when the codec's gem cannot be loaded
134
+ # @raise [MissingCodecError] when the codec's gem is not loaded
147
135
  def compress(codec, data)
148
136
  case codec
149
137
  when Format::Codec::UNCOMPRESSED then data
@@ -8,12 +8,12 @@ module Herringbone
8
8
  module_function
9
9
 
10
10
  # Fixed-width numeric types: pack/unpack directive and byte width (all little-endian).
11
- FORMATS = {
11
+ FORMATS = Ractor.make_shareable({
12
12
  Format::Type::INT32 => ["l<", 4],
13
13
  Format::Type::INT64 => ["q<", 8],
14
14
  Format::Type::FLOAT => ["e", 4],
15
15
  Format::Type::DOUBLE => ["E", 8]
16
- }.freeze
16
+ })
17
17
 
18
18
  # Decodes +count+ values of +type+ from +data+ starting at +pos+.
19
19
  # Returns [values, new_pos].
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Herringbone
4
+ # Writes rows whose schema is inferred from the rows themselves, reading them only once: the
5
+ # first Schema::INFER_SAMPLE rows are held back, the schema is built from them, and then they and
6
+ # every later row go straight to a Writer. Memory is bounded by the sample, however many rows
7
+ # follow it. Nothing is written to the IO before the schema is known, so a source that can only
8
+ # be iterated once (a cursor, a queue, a lazy Enumerator over an IO) loses no rows.
9
+ #
10
+ # A row that does not fit the inferred schema raises SchemaMismatch and stops the writer: the
11
+ # file is left unfinished (no footer) rather than written with rows missing.
12
+ #
13
+ # Used by Herringbone.write and SimpleWriter.
14
+ #
15
+ # @api private
16
+ class InferringWriter
17
+ # @param io [IO, #write] destination, passed to Writer.new
18
+ # @param fix [String] how a caller declares a column, for SchemaMismatch messages; +%s+ is
19
+ # replaced by a declaration such as +string :age+
20
+ # @param options [Hash{Symbol => Object}] Writer options
21
+ # @option options [Symbol] :compression (:snappy) codec, checked before any row is read
22
+ # (the other Writer options are passed on as well)
23
+ # @yield [sample] builds the schema once the sample is complete (or at #close)
24
+ # @yieldparam sample [Array] the rows held back, at most Schema::INFER_SAMPLE
25
+ # @yieldreturn [Schema]
26
+ # @raise [MissingCodecError] when the codec's optional gem is not loaded
27
+ def initialize(io, fix:, **options, &schema_for)
28
+ # Fail before reading any rows if the codec's library is missing
29
+ Compression.ensure_available!(Compression.codec_id(options.fetch(:compression, :snappy)))
30
+ @io = io
31
+ @fix = fix
32
+ @options = options
33
+ @schema_for = schema_for
34
+ @sample = []
35
+ @sample_size = nil
36
+ @writer = nil
37
+ @mismatch = nil
38
+ end
39
+
40
+ # @param row [Object] row to write, in any form the Writer accepts
41
+ # @return [self]
42
+ # @raise [SchemaMismatch] when the row does not fit the inferred schema
43
+ # @raise [Error] after an earlier SchemaMismatch
44
+ def <<(row)
45
+ check_usable!
46
+ if @writer
47
+ write(row)
48
+ else
49
+ @sample << row
50
+ start if @sample.size >= Schema::INFER_SAMPLE
51
+ end
52
+ self
53
+ end
54
+
55
+ # @return [Integer] rows accepted so far, including the ones held back
56
+ def rows_written = @writer ? @writer.rows_written : @sample.size
57
+
58
+ # Writes the held-back rows if the sample never filled up, then finishes the file
59
+ #
60
+ # @return [void]
61
+ # @raise [SchemaMismatch] when a held-back row does not fit the inferred schema
62
+ # @raise [Error] after an earlier SchemaMismatch
63
+ def close
64
+ check_usable!
65
+ start unless @writer
66
+ @writer.close
67
+ end
68
+
69
+ # Stops without finishing the file; when the schema was never built nothing was written at all
70
+ #
71
+ # @return [void]
72
+ def abort
73
+ @writer&.abort
74
+ end
75
+
76
+ # Short summary for the console, without the held-back rows
77
+ #
78
+ # @return [String] the number of rows held back, or the Writer's summary once it is open
79
+ def inspect
80
+ "#<#{self.class.name} #{@writer ? "writer=#{@writer.inspect}" : "held_back=#{@sample.size}"}>"
81
+ end
82
+
83
+ private
84
+
85
+ # Builds the schema from the held-back rows, opens the Writer and writes them
86
+ #
87
+ # @return [void]
88
+ # @raise [SchemaMismatch] when a held-back row does not fit
89
+ def start
90
+ @sample_size = @sample.size
91
+ @writer = Writer.new(@io, @schema_for.call(@sample), **@options)
92
+ sample, @sample = @sample, nil
93
+ sample.each { |row| write(row) }
94
+ end
95
+
96
+ # @param row [Object] row for the Writer
97
+ # @return [void]
98
+ # @raise [SchemaMismatch] when the row does not fit; the Writer is aborted
99
+ def write(row)
100
+ @writer << row
101
+ rescue EncodeError => e
102
+ @writer.abort
103
+ @mismatch = SchemaMismatch.new(explain(e), row: e.row, column: e.column, value: e.value)
104
+ raise @mismatch
105
+ end
106
+
107
+ # @return [void]
108
+ # @raise [Error] after a SchemaMismatch
109
+ def check_usable!
110
+ return unless @mismatch
111
+ raise Error, "This writer stopped at a schema mismatch and cannot continue " \
112
+ "(the file is unfinished):\n#{@mismatch.message}"
113
+ end
114
+
115
+ # A multi-line explanation of an EncodeError against the inferred schema
116
+ #
117
+ # @param error [EncodeError] error raised by the Writer
118
+ # @return [String]
119
+ def explain(error)
120
+ column = error.column && @writer.schema.column(error.column)
121
+ column ||= error.column && @writer.schema.columns.find { |c| c.dotted_path.start_with?("#{error.column}.") }
122
+ top = error.column&.split(".")&.first
123
+ lines = ["Row #{error.row} does not fit the schema inferred from the first #{@sample_size} rows."]
124
+ lines << " column: #{error.column}" if error.column
125
+ lines << " inferred: #{describe(column)}" if column
126
+ lines << " got: #{error.value.inspect[0, 200]} (#{error.value.class})" unless error.value.nil?
127
+ lines << " error: #{error.message.sub(/\ARow \d+: /, "")}"
128
+ lines << ""
129
+ lines << "Column types are decided from the first #{Schema::INFER_SAMPLE} rows (or all of them, when there are"
130
+ lines << "fewer), so a later value of a different type cannot be written to the same column."
131
+ if top
132
+ lines << "Declare the column with a type that holds every value, for example:"
133
+ lines << " #{format(@fix, declaration_for(top, error.value))}"
134
+ end
135
+ lines << "or pass a complete schema. The file was left unfinished (no footer)."
136
+ lines.join("\n")
137
+ end
138
+
139
+ # @param column [Schema::Column] leaf column
140
+ # @return [String] its type as the Builder DSL names it, e.g. "int64" or "timestamp (micros)"
141
+ def describe(column)
142
+ kind, *details = Types.logical_of(column.node)
143
+ type = case kind
144
+ when nil then Format::Type::NAMES[column.type].downcase
145
+ when :integer then "#{details[1] ? "int" : "uint"}#{details[0]}"
146
+ when :timestamp, :time then "#{kind} (#{details[0]})"
147
+ when :decimal then "decimal(#{details[1]}, #{details[0]})"
148
+ else kind.to_s
149
+ end
150
+ (column.path.size > 1) ? "#{type} (at #{column.dotted_path})" : type
151
+ end
152
+
153
+ # A Builder DSL declaration whose type can hold +value+, to suggest in the explanation
154
+ #
155
+ # @param name [String] top-level field name
156
+ # @param value [Object] the value that did not fit
157
+ # @return [String] e.g. "string :age"
158
+ def declaration_for(name, value)
159
+ type = case value
160
+ when Float, BigDecimal, Rational then "double"
161
+ when Time, DateTime then "timestamp"
162
+ when Hash, Array then "json"
163
+ else "string"
164
+ end
165
+ "#{type} :#{name}"
166
+ end
167
+ end
168
+ end
@@ -292,8 +292,8 @@ module Herringbone
292
292
  rep_len = dh.repetition_levels_byte_length
293
293
  def_len = dh.definition_levels_byte_length
294
294
  reps = defs = nil
295
- reps = PageStream::HybridDecoder.new(body, 0, rep_len, RLE_WIDTH[@max_rep]) if @max_rep.positive?
296
- defs = PageStream::HybridDecoder.new(body, rep_len, rep_len + def_len, RLE_WIDTH[@max_def]) if @max_def.positive?
295
+ reps = PageStream::HybridDecoder.new(body, 0, rep_len, @max_rep.bit_length) if @max_rep.positive?
296
+ defs = PageStream::HybridDecoder.new(body, rep_len, rep_len + def_len, @max_def.bit_length) if @max_def.positive?
297
297
  data = body.byteslice(rep_len + def_len, body.bytesize - rep_len - def_len)
298
298
  if dh.is_compressed != false
299
299
  data = decompress(data, header.uncompressed_page_size - rep_len - def_len)
@@ -302,9 +302,6 @@ module Herringbone
302
302
  PageStream::Page.new(n, defs, reps, values, conv)
303
303
  end
304
304
 
305
- # Bit width of levels up to a max level (memoized Integer#bit_length)
306
- RLE_WIDTH = Hash.new { |h, k| h[k] = k.bit_length }
307
-
308
305
  # [decoder, position after the levels]
309
306
  #
310
307
  # @param data [String] the uncompressed page
@@ -317,7 +314,7 @@ module Herringbone
317
314
  # @raise [FormatError] when the RLE length prefix is cut off
318
315
  # @raise [UnsupportedError] for any other level encoding
319
316
  def level_decoder(data, pos, encoding, max, n)
320
- width = RLE_WIDTH[max]
317
+ width = max.bit_length
321
318
  case encoding
322
319
  when E::RLE
323
320
  len = data.byteslice(pos, 4)&.unpack1("V") or raise FormatError, "Truncated levels"
@@ -332,9 +329,9 @@ module Herringbone
332
329
  end
333
330
 
334
331
  # Physical type => [String#unpack directive, byte width] for PLAIN fixed-width values
335
- FIXED_FORMATS = {
332
+ FIXED_FORMATS = Ractor.make_shareable({
336
333
  T::INT32 => ["l<", 4], T::INT64 => ["q<", 8], T::FLOAT => ["e", 4], T::DOUBLE => ["E", 8]
337
- }.freeze
334
+ })
338
335
 
339
336
  # [value decoder, converter still to apply to its values (nil for dictionary pages)]
340
337
  #