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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/GET_STARTED.md +19 -0
- data/README.md +14 -1
- data/db_core/include/mcdb_format.h +67 -9
- data/db_core/src/mcdb_format.c +392 -139
- data/db_deployment/mysql/README.md +19 -0
- data/db_deployment/mysql/bin/common +70 -18
- data/db_deployment/mysql/bin/enable +5 -1
- data/db_deployment/mysql/bin/status +2 -0
- data/db_deployment/mysql/bin/upgrade +17 -5
- data/db_deployment/postgres/README.md +24 -0
- data/db_deployment/postgres/bin/enable +2 -0
- data/db_deployment/postgres/bin/install +4 -2
- data/docs/database-envelope-v2.md +147 -0
- data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
- data/ext/multi_compress/multi_compress.c +32 -0
- data/lib/multi_compress/database.rb +255 -49
- data/lib/multi_compress/db_deployment.rb +423 -11
- data/lib/multi_compress/version.rb +1 -1
- data/mysql_udf/README.md +45 -0
- data/mysql_udf/sql/examples.sql +5 -0
- data/mysql_udf/sql/install.sql +25 -1
- data/mysql_udf/sql/uninstall.sql +7 -1
- data/mysql_udf/sql/views.sql +16 -4
- data/mysql_udf/src/multi_compress_mysql.c +383 -44
- data/mysql_udf/test/run_e2e.sh +119 -1
- data/postgres_extension/Makefile +2 -2
- data/postgres_extension/README.md +48 -0
- data/postgres_extension/multi_compress.control +2 -2
- data/postgres_extension/multi_compress_pg.exports +12 -0
- data/postgres_extension/sql/examples.sql +11 -0
- data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
- data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
- data/postgres_extension/src/multi_compress_pg.c +301 -16
- data/postgres_extension/test/run_e2e.sh +90 -2
- 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
|
-
|
|
11
|
+
VERSION_V1 = 1
|
|
12
|
+
VERSION_V2 = 2
|
|
13
|
+
VERSION = VERSION_V1 # MCDB1 compatibility alias
|
|
9
14
|
CODEC_ZSTD = 1
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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} < #{
|
|
44
|
-
err("
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
96
|
-
(MAGIC + [
|
|
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
|