multi_compress 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/GET_STARTED.md +19 -0
  4. data/README.md +14 -1
  5. data/db_core/include/mcdb_format.h +67 -9
  6. data/db_core/src/mcdb_format.c +392 -139
  7. data/db_deployment/mysql/README.md +19 -0
  8. data/db_deployment/mysql/bin/common +70 -18
  9. data/db_deployment/mysql/bin/enable +5 -1
  10. data/db_deployment/mysql/bin/status +2 -0
  11. data/db_deployment/mysql/bin/upgrade +17 -5
  12. data/db_deployment/postgres/README.md +24 -0
  13. data/db_deployment/postgres/bin/enable +2 -0
  14. data/db_deployment/postgres/bin/install +4 -2
  15. data/docs/database-envelope-v2.md +147 -0
  16. data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
  17. data/ext/multi_compress/multi_compress.c +32 -0
  18. data/lib/multi_compress/database.rb +255 -49
  19. data/lib/multi_compress/db_deployment.rb +423 -11
  20. data/lib/multi_compress/version.rb +1 -1
  21. data/mysql_udf/README.md +45 -0
  22. data/mysql_udf/sql/examples.sql +5 -0
  23. data/mysql_udf/sql/install.sql +25 -1
  24. data/mysql_udf/sql/uninstall.sql +7 -1
  25. data/mysql_udf/sql/views.sql +16 -4
  26. data/mysql_udf/src/multi_compress_mysql.c +383 -44
  27. data/mysql_udf/test/run_e2e.sh +119 -1
  28. data/postgres_extension/Makefile +2 -2
  29. data/postgres_extension/README.md +48 -0
  30. data/postgres_extension/multi_compress.control +2 -2
  31. data/postgres_extension/multi_compress_pg.exports +12 -0
  32. data/postgres_extension/sql/examples.sql +11 -0
  33. data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
  34. data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
  35. data/postgres_extension/src/multi_compress_pg.c +301 -16
  36. data/postgres_extension/test/run_e2e.sh +90 -2
  37. metadata +6 -2
@@ -1,81 +1,255 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "multi_compress"
4
5
 
5
6
  module MultiCompress
7
+ # Long-term, SQL-readable envelopes. MCDB1 is dictionary-free. MCDB2 is an
8
+ # opt-in, dictionary-required format for one homogeneous database column.
6
9
  module Database
7
10
  MAGIC = "MCDB".b
8
- VERSION = 1
11
+ VERSION_V1 = 1
12
+ VERSION_V2 = 2
13
+ VERSION = VERSION_V1 # MCDB1 compatibility alias
9
14
  CODEC_ZSTD = 1
10
- FLAGS_V1 = 0
11
- HEADER_SIZE = 19 # 4 magic +1 ver +1 codec +1 flags +8 size +4 crc32
12
- MAX_OUTPUT = 16 * 1024 * 1024 # 16 MiB decompressed, enforced on write and read
13
- MAX_ENVELOPE = 16_842_771
15
+ FLAGS_NONE = 0
16
+ FLAGS_V1 = FLAGS_NONE
17
+ V1_HEADER_SIZE = 19 # 4 magic + 1 version + 1 codec + 1 flags + 8 size + 4 crc32
18
+ V2_HEADER_SIZE = 27 # MCDB1 header + 8 byte application dictionary_ref
19
+ HEADER_SIZE = V1_HEADER_SIZE
20
+ ORIGINAL_SIZE_OFFSET = 7
21
+ CRC_OFFSET = 15
22
+ DICTIONARY_REF_OFFSET = 19
23
+ MAX_OUTPUT = 16 * 1024 * 1024
24
+ MAX_DICTIONARY_BYTES = 256 * 1024
25
+ MAX_DICTIONARY_REF = (2**63) - 1
26
+ MAX_ENVELOPE_V1 = 16_842_771
27
+ MAX_ENVELOPE_V2 = 16_842_779
28
+ MAX_ENVELOPE = MAX_ENVELOPE_V2
14
29
  ZSTD_LEVEL = 6
15
30
 
31
+ # A strict, immutable MCDB2 dictionary. It is intentionally distinct from
32
+ # MultiCompress::Dictionary: MCDB2 accepts only conformant zstd dictionaries
33
+ # with a non-zero zstd DictID and an application-level registry id.
34
+ class Dictionary
35
+ attr_reader :id, :bytes, :sha256, :zstd_id, :native
36
+
37
+ def self.train(samples, id:, size: 32 * 1024)
38
+ new(id: id, native: MultiCompress::Zstd.train_dictionary(samples, size: size))
39
+ end
40
+
41
+ def self.wrap(native, id:)
42
+ new(id: id, native: native)
43
+ end
44
+
45
+ def initialize(id:, native: nil, bytes: nil)
46
+ @id = normalize_id(id)
47
+ raise ArgumentError, "provide exactly one of native: or bytes:" if (!!native) == (!!bytes)
48
+
49
+ @native = native || MultiCompress::Dictionary.new(bytes, algo: :zstd)
50
+ unless @native.is_a?(MultiCompress::Dictionary) && @native.algo == :zstd
51
+ raise TypeError, "MCDB2 dictionary must be a MultiCompress::Dictionary with algo: :zstd"
52
+ end
53
+
54
+ @bytes = @native.bytes.b.freeze
55
+ if @bytes.empty? || @bytes.bytesize > MAX_DICTIONARY_BYTES
56
+ raise ArgumentError,
57
+ "MCDB2 dictionary is #{@bytes.bytesize} bytes; allowed range is 1..#{MAX_DICTIONARY_BYTES}"
58
+ end
59
+
60
+ @zstd_id = @native.zstd_id
61
+ raise ArgumentError, "MCDB2 dictionary must be a conformant zstd dictionary with a non-zero DictID" if @zstd_id.zero?
62
+
63
+ @sha256 = Digest::SHA256.digest(@bytes).freeze
64
+ freeze
65
+ end
66
+
67
+ def sha256_hex
68
+ @sha256.unpack1("H*")
69
+ end
70
+
71
+ def registry_attributes
72
+ { id: @id, zstd_dict_id: @zstd_id, sha256: @sha256, bytes: @bytes }
73
+ end
74
+
75
+ private
76
+
77
+ def normalize_id(value)
78
+ raise TypeError, "MCDB2 dictionary id must be an Integer" unless value.respond_to?(:to_int)
79
+
80
+ integer = value.to_int
81
+ unless integer.between?(1, MAX_DICTIONARY_REF)
82
+ raise ArgumentError, "MCDB2 dictionary id must be in 1..#{MAX_DICTIONARY_REF}"
83
+ end
84
+ integer
85
+ end
86
+ end
87
+
16
88
  module_function
17
89
 
18
- def compress(text)
90
+ # Without a dictionary this emits MCDB1. With Database::Dictionary it emits
91
+ # MCDB2. Choose one format per database column; mixed use is deliberately
92
+ # not what the generated dictionary view is optimized for.
93
+ def compress(text, dictionary: nil)
94
+ return compress_v1(text) if dictionary.nil?
95
+
96
+ compress_v2(text, dictionary: dictionary)
97
+ end
98
+
99
+ def compress_v1(text)
100
+ bytes = input_bytes!(text, format: "v1")
101
+ frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL)
102
+ validate_canonical_zstd_frame!(frame, bytes.bytesize, expected_dictionary_id: 0)
103
+ envelope = header_v1(bytes.bytesize, MultiCompress.crc32(bytes)) << frame
104
+ check_envelope_size!(envelope, MAX_ENVELOPE_V1, "v1")
105
+ envelope
106
+ end
107
+
108
+ def compress_v2(text, dictionary:)
109
+ dictionary = database_dictionary!(dictionary)
110
+ bytes = input_bytes!(text, format: "v2")
111
+ frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL, dictionary: dictionary.native)
112
+ validate_canonical_zstd_frame!(frame, bytes.bytesize, expected_dictionary_id: dictionary.zstd_id)
113
+ envelope = header_v2(bytes.bytesize, MultiCompress.crc32(bytes), dictionary.id) << frame
114
+ check_envelope_size!(envelope, MAX_ENVELOPE_V2, "v2")
115
+ envelope
116
+ end
117
+
118
+ def decompress(blob, dictionary: nil)
119
+ header = parse_header!(blob)
120
+ case header.fetch(:version)
121
+ when VERSION_V1
122
+ raise ArgumentError, "MCDB1 does not accept a dictionary" unless dictionary.nil?
123
+
124
+ decode_v1(blob.b, header)
125
+ when VERSION_V2
126
+ decode_v2(blob.b, header, dictionary)
127
+ else
128
+ err("unsupported version #{header.fetch(:version)}")
129
+ end
130
+ end
131
+
132
+ def valid?(blob, dictionary: nil)
133
+ decompress(blob, dictionary: dictionary)
134
+ true
135
+ rescue StandardError
136
+ false
137
+ end
138
+
139
+ # Parses only the envelope header. It is safe for registry/FK consistency
140
+ # checks and does not decompress the payload.
141
+ def dictionary_ref(blob)
142
+ header = parse_header!(blob)
143
+ return nil if header.fetch(:version) == VERSION_V1
144
+
145
+ header.fetch(:dictionary_ref)
146
+ end
147
+
148
+ def original_size(blob)
149
+ parse_header!(blob).fetch(:original_size)
150
+ end
151
+
152
+ def version_string
153
+ "MCDB1 + MCDB2 (zstd #{MultiCompress.version(:zstd)})"
154
+ end
155
+
156
+ def input_bytes!(text, format:)
19
157
  raise TypeError, "MultiCompress::Database.compress expects a String, got #{text.class}" unless text.is_a?(String)
20
158
 
21
159
  bytes = utf8_bytes(text)
22
160
  n = bytes.bytesize
23
161
  if n > MAX_OUTPUT
24
- raise ArgumentError, "MultiCompress::Database: input is #{n} bytes, over the #{MAX_OUTPUT}-byte v1 limit"
162
+ raise ArgumentError, "MultiCompress::Database: input is #{n} bytes, over the #{MAX_OUTPUT}-byte #{format} limit"
25
163
  end
164
+ bytes
165
+ end
166
+ private_class_method :input_bytes!
26
167
 
27
- frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL)
28
- validate_canonical_zstd_frame!(frame, n)
168
+ def decode_v1(blob, header)
169
+ frame = blob.byteslice(V1_HEADER_SIZE, blob.bytesize - V1_HEADER_SIZE) || +"".b
170
+ validate_canonical_zstd_frame!(frame, header.fetch(:original_size), expected_dictionary_id: 0)
171
+ out = MultiCompress.decompress(frame, algo: :zstd, max_output_size: MAX_OUTPUT)
172
+ validate_decoded!(blob, header, out)
173
+ end
174
+ private_class_method :decode_v1
29
175
 
30
- envelope = header(n, MultiCompress.crc32(bytes)) << frame
31
- if envelope.bytesize > MAX_ENVELOPE
32
- raise MultiCompress::DataError,
33
- "MultiCompress::Database: stored envelope is #{envelope.bytesize} bytes, over the #{MAX_ENVELOPE}-byte v1 limit"
176
+ def decode_v2(blob, header, dictionary)
177
+ err("MCDB2 requires a dictionary") if dictionary.nil?
178
+ dictionary = database_dictionary!(dictionary)
179
+ if dictionary.id != header.fetch(:dictionary_ref)
180
+ err("dictionary reference #{header.fetch(:dictionary_ref)} does not match supplied dictionary #{dictionary.id}")
34
181
  end
35
182
 
36
- envelope
183
+ frame = blob.byteslice(V2_HEADER_SIZE, blob.bytesize - V2_HEADER_SIZE) || +"".b
184
+ validate_canonical_zstd_frame!(frame, header.fetch(:original_size), expected_dictionary_id: dictionary.zstd_id)
185
+ out = MultiCompress.decompress(
186
+ frame,
187
+ algo: :zstd,
188
+ dictionary: dictionary.native,
189
+ max_output_size: MAX_OUTPUT
190
+ )
191
+ validate_decoded!(blob, header, out)
37
192
  end
193
+ private_class_method :decode_v2
38
194
 
39
- def decompress(blob)
195
+ def parse_header!(blob)
40
196
  raise TypeError, "MultiCompress::Database.decompress expects a String, got #{blob.class}" unless blob.is_a?(String)
41
197
 
42
198
  b = blob.b
43
- err("truncated envelope (#{b.bytesize} < #{HEADER_SIZE} bytes)") if b.bytesize < HEADER_SIZE
44
- err("stored envelope too large (#{b.bytesize} > #{MAX_ENVELOPE} bytes)") if b.bytesize > MAX_ENVELOPE
45
- err("bad magic") unless b.byteslice(0, 4) == MAGIC
46
- err("unsupported version #{b.getbyte(4)}") unless b.getbyte(4) == VERSION
47
- err("unsupported codec #{b.getbyte(5)}") unless b.getbyte(5) == CODEC_ZSTD
48
- err("reserved flags must be 0, got #{b.getbyte(6)}") unless b.getbyte(6) == FLAGS_V1
49
-
50
- original_size = b.byteslice(7, 8).unpack1("Q<")
51
- err("declared size #{original_size} over the #{MAX_OUTPUT}-byte v1 limit") if original_size > MAX_OUTPUT
52
- expected_crc = b.byteslice(15, 4).unpack1("L<")
53
-
54
- frame = b.byteslice(HEADER_SIZE, b.bytesize - HEADER_SIZE) || +"".b
55
- validate_canonical_zstd_frame!(frame, original_size)
56
- out = MultiCompress.decompress(frame, algo: :zstd, max_output_size: MAX_OUTPUT)
57
- if out.bytesize != original_size
58
- err("size mismatch: header says #{original_size}, got #{out.bytesize}")
199
+ err("truncated envelope (#{b.bytesize} < #{V1_HEADER_SIZE} bytes)") if b.bytesize < V1_HEADER_SIZE
200
+ err("bad magic") unless b.byteslice(0, 4) == MAGIC
201
+
202
+ version = b.getbyte(4)
203
+ case version
204
+ when VERSION_V1
205
+ header_size = V1_HEADER_SIZE
206
+ max_envelope = MAX_ENVELOPE_V1
207
+ when VERSION_V2
208
+ header_size = V2_HEADER_SIZE
209
+ max_envelope = MAX_ENVELOPE_V2
210
+ else
211
+ err("unsupported version #{version}")
59
212
  end
60
- actual_crc = MultiCompress.crc32(out)
61
- err("crc32 mismatch: header #{expected_crc}, computed #{actual_crc}") if actual_crc != expected_crc
62
213
 
214
+ err("truncated #{format_name(version)} envelope (#{b.bytesize} < #{header_size} bytes)") if b.bytesize < header_size
215
+ err("stored #{format_name(version)} envelope is #{b.bytesize} bytes, over the #{max_envelope}-byte limit") if b.bytesize > max_envelope
216
+ err("unsupported codec #{b.getbyte(5)}") unless b.getbyte(5) == CODEC_ZSTD
217
+ err("reserved flags must be 0, got #{b.getbyte(6)}") unless b.getbyte(6) == FLAGS_NONE
218
+
219
+ original_size = b.byteslice(ORIGINAL_SIZE_OFFSET, 8).unpack1("Q<")
220
+ err("declared size #{original_size} over the #{MAX_OUTPUT}-byte limit") if original_size > MAX_OUTPUT
221
+
222
+ header = {
223
+ version: version,
224
+ header_size: header_size,
225
+ original_size: original_size,
226
+ expected_crc: b.byteslice(CRC_OFFSET, 4).unpack1("L<")
227
+ }
228
+ if version == VERSION_V2
229
+ dictionary_ref = b.byteslice(DICTIONARY_REF_OFFSET, 8).unpack1("Q<")
230
+ unless dictionary_ref.between?(1, MAX_DICTIONARY_REF)
231
+ err("invalid dictionary reference #{dictionary_ref}")
232
+ end
233
+ header[:dictionary_ref] = dictionary_ref
234
+ end
235
+ header
236
+ end
237
+ private_class_method :parse_header!
238
+
239
+ def validate_decoded!(blob, header, out)
240
+ if out.bytesize != header.fetch(:original_size)
241
+ err("size mismatch: header says #{header.fetch(:original_size)}, got #{out.bytesize}")
242
+ end
243
+ actual_crc = MultiCompress.crc32(out)
244
+ if actual_crc != header.fetch(:expected_crc)
245
+ err("crc32 mismatch: header #{header.fetch(:expected_crc)}, computed #{actual_crc}")
246
+ end
63
247
  s = out.force_encoding(Encoding::UTF_8)
64
248
  err("decompressed bytes are not valid UTF-8") unless s.valid_encoding?
65
249
  err("decompressed text contains a NUL byte") if s.include?("\0")
66
250
  s
67
251
  end
68
-
69
- def valid?(blob)
70
- decompress(blob)
71
- true
72
- rescue StandardError
73
- false
74
- end
75
-
76
- def version_string
77
- "MCDB1 (zstd #{MultiCompress.version(:zstd)})"
78
- end
252
+ private_class_method :validate_decoded!
79
253
 
80
254
  def utf8_bytes(text)
81
255
  s = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
@@ -84,23 +258,55 @@ module MultiCompress
84
258
 
85
259
  s.b
86
260
  end
261
+ private_class_method :utf8_bytes
87
262
 
88
- def validate_canonical_zstd_frame!(frame, expected_size)
263
+ def validate_canonical_zstd_frame!(frame, expected_size, expected_dictionary_id:)
89
264
  _frame_size, content_size = MultiCompress.zstd_single_frame_info(frame)
90
265
  err("zstd frame content size #{content_size} does not match header size #{expected_size}") if content_size != expected_size
266
+ actual_dictionary_id = MultiCompress.zstd_frame_dictionary_id(frame)
267
+ if actual_dictionary_id != expected_dictionary_id
268
+ err("zstd frame dictionary id #{actual_dictionary_id} does not match expected #{expected_dictionary_id}")
269
+ end
91
270
  rescue MultiCompress::DataError => e
92
271
  err(e.message.sub(/\AMultiCompress::Database: /, ""))
93
272
  end
273
+ private_class_method :validate_canonical_zstd_frame!
274
+
275
+ def database_dictionary!(value)
276
+ return value if value.is_a?(Dictionary)
277
+
278
+ raise TypeError, "MCDB2 requires MultiCompress::Database::Dictionary, got #{value.class}"
279
+ end
280
+ private_class_method :database_dictionary!
281
+
282
+ def check_envelope_size!(envelope, max, format)
283
+ return if envelope.bytesize <= max
284
+
285
+ raise MultiCompress::DataError,
286
+ "MultiCompress::Database: stored #{format} envelope is #{envelope.bytesize} bytes, over the #{max}-byte limit"
287
+ end
288
+ private_class_method :check_envelope_size!
94
289
 
95
- def header(original_size, crc32)
96
- (MAGIC + [VERSION, CODEC_ZSTD, FLAGS_V1].pack("C3") +
290
+ def header_v1(original_size, crc32)
291
+ (MAGIC + [VERSION_V1, CODEC_ZSTD, FLAGS_NONE].pack("C3") +
97
292
  [original_size].pack("Q<") + [crc32].pack("L<")).b
98
293
  end
294
+ private_class_method :header_v1
295
+
296
+ def header_v2(original_size, crc32, dictionary_ref)
297
+ (MAGIC + [VERSION_V2, CODEC_ZSTD, FLAGS_NONE].pack("C3") +
298
+ [original_size].pack("Q<") + [crc32].pack("L<") + [dictionary_ref].pack("Q<")).b
299
+ end
300
+ private_class_method :header_v2
301
+
302
+ def format_name(version)
303
+ version == VERSION_V2 ? "MCDB2" : "MCDB1"
304
+ end
305
+ private_class_method :format_name
99
306
 
100
307
  def err(message)
101
308
  raise MultiCompress::DataError, "MultiCompress::Database: #{message}"
102
309
  end
103
-
104
- private_class_method :utf8_bytes, :validate_canonical_zstd_frame!, :header, :err
310
+ private_class_method :err
105
311
  end
106
312
  end