multi_compress 0.5.0 → 0.6.1
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 +45 -0
- data/GET_STARTED.md +20 -1
- data/README.md +15 -2
- data/THIRD_PARTY_NOTICES.md +1 -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 +261 -76
- data/ext/multi_compress/vendor/.vendored +1 -1
- data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
- data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
- data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
- data/lib/multi_compress/active_record.rb +11 -5
- data/lib/multi_compress/codec.rb +164 -94
- data/lib/multi_compress/database/api.rb +42 -0
- data/lib/multi_compress/database/decoding.rb +42 -0
- data/lib/multi_compress/database/dictionary.rb +72 -0
- data/lib/multi_compress/database/encoding.rb +31 -0
- data/lib/multi_compress/database/header.rb +71 -0
- data/lib/multi_compress/database/operations.rb +73 -0
- data/lib/multi_compress/database/validation.rb +95 -0
- data/lib/multi_compress/database.rb +29 -95
- data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
- data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
- data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
- data/lib/multi_compress/db_deployment/commands.rb +309 -0
- data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
- data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
- data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
- data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
- data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
- data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
- data/lib/multi_compress/db_deployment.rb +13 -435
- data/lib/multi_compress/reader/buffer_operations.rb +57 -0
- data/lib/multi_compress/reader/read_operations.rb +67 -0
- data/lib/multi_compress/reader.rb +97 -0
- data/lib/multi_compress/stream_io.rb +13 -0
- data/lib/multi_compress/version.rb +1 -1
- data/lib/multi_compress/writer.rb +85 -0
- data/lib/multi_compress.rb +42 -333
- 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 +29 -6
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def decode_v1(blob, header)
|
|
9
|
+
frame = blob.byteslice(V1_HEADER_SIZE, blob.bytesize - V1_HEADER_SIZE) || +"".b
|
|
10
|
+
validate_canonical_zstd_frame!(frame, header.fetch(:original_size), expected_dictionary_id: 0)
|
|
11
|
+
out = MultiCompress.decompress(frame, algo: :zstd, max_output_size: MAX_OUTPUT)
|
|
12
|
+
validate_decoded!(blob, header, out)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def decode_v2(blob, header, dictionary)
|
|
16
|
+
dictionary = match_dictionary!(dictionary, header)
|
|
17
|
+
frame = blob.byteslice(V2_HEADER_SIZE, blob.bytesize - V2_HEADER_SIZE) || +"".b
|
|
18
|
+
validate_canonical_zstd_frame!(frame, header.fetch(:original_size), expected_dictionary_id: dictionary.zstd_id)
|
|
19
|
+
out = decompress_dictionary_frame(frame, dictionary)
|
|
20
|
+
validate_decoded!(blob, header, out)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def match_dictionary!(dictionary, header)
|
|
24
|
+
err("MCDB2 requires a dictionary") if dictionary.nil?
|
|
25
|
+
dictionary = database_dictionary!(dictionary)
|
|
26
|
+
expected_ref = header.fetch(:dictionary_ref)
|
|
27
|
+
return dictionary if dictionary.id == expected_ref
|
|
28
|
+
|
|
29
|
+
err("dictionary reference #{expected_ref} does not match supplied dictionary #{dictionary.id}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def decompress_dictionary_frame(frame, dictionary)
|
|
33
|
+
MultiCompress.decompress(
|
|
34
|
+
frame,
|
|
35
|
+
algo: :zstd,
|
|
36
|
+
dictionary: dictionary.native,
|
|
37
|
+
max_output_size: MAX_OUTPUT
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
# A strict, immutable MCDB2 dictionary. It is intentionally distinct from
|
|
6
|
+
# MultiCompress::Dictionary: MCDB2 accepts only conformant zstd dictionaries
|
|
7
|
+
# with a non-zero zstd DictID and an application-level registry id.
|
|
8
|
+
class Dictionary
|
|
9
|
+
attr_reader :id, :bytes, :sha256, :zstd_id, :native
|
|
10
|
+
|
|
11
|
+
def self.train(samples, id:, size: 32 * 1024)
|
|
12
|
+
new(id: id, native: MultiCompress::Zstd.train_dictionary(samples, size: size))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.wrap(native, id:)
|
|
16
|
+
new(id: id, native: native)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(id:, native: nil, bytes: nil)
|
|
20
|
+
@id = normalize_id(id)
|
|
21
|
+
@native = build_native(native, bytes)
|
|
22
|
+
@bytes = validate_bytes(@native.bytes.b.freeze)
|
|
23
|
+
@zstd_id = validate_zstd_id(@native.zstd_id)
|
|
24
|
+
@sha256 = Digest::SHA256.digest(@bytes).freeze
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sha256_hex
|
|
29
|
+
sha256.unpack1("H*")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def registry_attributes
|
|
33
|
+
{ id: id, zstd_dict_id: zstd_id, sha256: sha256, bytes: bytes }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_native(native, bytes)
|
|
39
|
+
raise ArgumentError, "provide exactly one of native: or bytes:" if (!!native) == (!!bytes)
|
|
40
|
+
|
|
41
|
+
value = native || MultiCompress::Dictionary.new(bytes, algo: :zstd)
|
|
42
|
+
return value if value.is_a?(MultiCompress::Dictionary) && value.algo == :zstd
|
|
43
|
+
|
|
44
|
+
raise TypeError, "MCDB2 dictionary must be a MultiCompress::Dictionary with algo: :zstd"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_bytes(value)
|
|
48
|
+
return value unless value.empty? || value.bytesize > MAX_DICTIONARY_BYTES
|
|
49
|
+
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"MCDB2 dictionary is #{value.bytesize} bytes; allowed range is 1..#{MAX_DICTIONARY_BYTES}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_zstd_id(value)
|
|
55
|
+
return value unless value.zero?
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "MCDB2 dictionary must be a conformant zstd dictionary with a non-zero DictID"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_id(value)
|
|
61
|
+
raise TypeError, "MCDB2 dictionary id must be an Integer" unless value.respond_to?(:to_int)
|
|
62
|
+
|
|
63
|
+
integer = value.to_int
|
|
64
|
+
unless integer.between?(1, MAX_DICTIONARY_REF)
|
|
65
|
+
raise ArgumentError, "MCDB2 dictionary id must be in 1..#{MAX_DICTIONARY_REF}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
integer
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def input_bytes!(text, format:)
|
|
9
|
+
validate_input_type!(text)
|
|
10
|
+
|
|
11
|
+
bytes = utf8_bytes(text)
|
|
12
|
+
validate_input_size!(bytes, format)
|
|
13
|
+
bytes
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate_input_type!(text)
|
|
17
|
+
return if text.is_a?(String)
|
|
18
|
+
|
|
19
|
+
raise TypeError, "MultiCompress::Database.compress expects a String, got #{text.class}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate_input_size!(bytes, format)
|
|
23
|
+
size = bytes.bytesize
|
|
24
|
+
return unless size > MAX_OUTPUT
|
|
25
|
+
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"MultiCompress::Database: input is #{size} bytes, over the #{MAX_OUTPUT}-byte #{format} limit"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def parse_header!(blob)
|
|
9
|
+
bytes = header_bytes!(blob)
|
|
10
|
+
version = bytes.getbyte(4)
|
|
11
|
+
validate_header_size!(bytes, version)
|
|
12
|
+
header_metadata(bytes, version)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def header_bytes!(blob)
|
|
16
|
+
unless blob.is_a?(String)
|
|
17
|
+
raise TypeError, "MultiCompress::Database.decompress expects a String, got #{blob.class}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
bytes = blob.b
|
|
21
|
+
err("truncated envelope (#{bytes.bytesize} < #{V1_HEADER_SIZE} bytes)") if bytes.bytesize < V1_HEADER_SIZE
|
|
22
|
+
err("bad magic") unless bytes.byteslice(0, 4) == MAGIC
|
|
23
|
+
bytes
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_header_size!(bytes, version)
|
|
27
|
+
header_size, max_envelope = envelope_limits(version)
|
|
28
|
+
if bytes.bytesize < header_size
|
|
29
|
+
err("truncated #{format_name(version)} envelope (#{bytes.bytesize} < #{header_size} bytes)")
|
|
30
|
+
end
|
|
31
|
+
if bytes.bytesize > max_envelope
|
|
32
|
+
err("stored #{format_name(version)} envelope is #{bytes.bytesize} bytes, over the #{max_envelope}-byte limit")
|
|
33
|
+
end
|
|
34
|
+
validate_fixed_header!(bytes)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def envelope_limits(version)
|
|
38
|
+
case version
|
|
39
|
+
when VERSION_V1 then [V1_HEADER_SIZE, MAX_ENVELOPE_V1]
|
|
40
|
+
when VERSION_V2 then [V2_HEADER_SIZE, MAX_ENVELOPE_V2]
|
|
41
|
+
else err("unsupported version #{version}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_fixed_header!(bytes)
|
|
46
|
+
err("unsupported codec #{bytes.getbyte(5)}") unless bytes.getbyte(5) == CODEC_ZSTD
|
|
47
|
+
err("reserved flags must be 0, got #{bytes.getbyte(6)}") unless bytes.getbyte(6) == FLAGS_NONE
|
|
48
|
+
|
|
49
|
+
original_size = bytes.byteslice(ORIGINAL_SIZE_OFFSET, 8).unpack1("Q<")
|
|
50
|
+
err("declared size #{original_size} over the #{MAX_OUTPUT}-byte limit") if original_size > MAX_OUTPUT
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def header_metadata(bytes, version)
|
|
54
|
+
header = {
|
|
55
|
+
version: version,
|
|
56
|
+
header_size: version == VERSION_V2 ? V2_HEADER_SIZE : V1_HEADER_SIZE,
|
|
57
|
+
original_size: bytes.byteslice(ORIGINAL_SIZE_OFFSET, 8).unpack1("Q<"),
|
|
58
|
+
expected_crc: bytes.byteslice(CRC_OFFSET, 4).unpack1("L<")
|
|
59
|
+
}
|
|
60
|
+
header[:dictionary_ref] = dictionary_ref_from_header(bytes) if version == VERSION_V2
|
|
61
|
+
header
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dictionary_ref_from_header(bytes)
|
|
65
|
+
dictionary_ref = bytes.byteslice(DICTIONARY_REF_OFFSET, 8).unpack1("Q<")
|
|
66
|
+
err("invalid dictionary reference #{dictionary_ref}") unless dictionary_ref.between?(1, MAX_DICTIONARY_REF)
|
|
67
|
+
dictionary_ref
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
# Without a dictionary this emits MCDB1. With Database::Dictionary it emits
|
|
7
|
+
# MCDB2. Choose one format per database column; mixed use is deliberately
|
|
8
|
+
# not what the generated dictionary view is optimized for.
|
|
9
|
+
def compress(text, dictionary: nil)
|
|
10
|
+
return compress_v1(text) if dictionary.nil?
|
|
11
|
+
|
|
12
|
+
compress_v2(text, dictionary: dictionary)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def compress_v1(text)
|
|
16
|
+
bytes = input_bytes!(text, format: "v1")
|
|
17
|
+
frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL)
|
|
18
|
+
validate_canonical_zstd_frame!(frame, bytes.bytesize, expected_dictionary_id: 0)
|
|
19
|
+
envelope = header_v1(bytes.bytesize, MultiCompress.crc32(bytes)) << frame
|
|
20
|
+
check_envelope_size!(envelope, MAX_ENVELOPE_V1, "v1")
|
|
21
|
+
envelope
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def compress_v2(text, dictionary:)
|
|
25
|
+
dictionary = database_dictionary!(dictionary)
|
|
26
|
+
bytes = input_bytes!(text, format: "v2")
|
|
27
|
+
frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL, dictionary: dictionary.native)
|
|
28
|
+
validate_canonical_zstd_frame!(frame, bytes.bytesize, expected_dictionary_id: dictionary.zstd_id)
|
|
29
|
+
envelope = header_v2(bytes.bytesize, MultiCompress.crc32(bytes), dictionary.id) << frame
|
|
30
|
+
check_envelope_size!(envelope, MAX_ENVELOPE_V2, "v2")
|
|
31
|
+
envelope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def decompress(blob, dictionary: nil)
|
|
35
|
+
header = parse_header!(blob)
|
|
36
|
+
case header.fetch(:version)
|
|
37
|
+
when VERSION_V1
|
|
38
|
+
raise ArgumentError, "MCDB1 does not accept a dictionary" unless dictionary.nil?
|
|
39
|
+
|
|
40
|
+
decode_v1(blob.b, header)
|
|
41
|
+
when VERSION_V2
|
|
42
|
+
decode_v2(blob.b, header, dictionary)
|
|
43
|
+
else
|
|
44
|
+
err("unsupported version #{header.fetch(:version)}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def valid?(blob, dictionary: nil)
|
|
49
|
+
decompress(blob, dictionary: dictionary)
|
|
50
|
+
true
|
|
51
|
+
rescue StandardError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parses only the envelope header. It is safe for registry/FK consistency
|
|
56
|
+
# checks and does not decompress the payload.
|
|
57
|
+
def dictionary_ref(blob)
|
|
58
|
+
header = parse_header!(blob)
|
|
59
|
+
return nil if header.fetch(:version) == VERSION_V1
|
|
60
|
+
|
|
61
|
+
header.fetch(:dictionary_ref)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def original_size(blob)
|
|
65
|
+
parse_header!(blob).fetch(:original_size)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def version_string
|
|
69
|
+
"MCDB1 + MCDB2 (zstd #{MultiCompress.version(:zstd)})"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module Database
|
|
5
|
+
class Engine
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def validate_decoded!(_blob, header, out)
|
|
9
|
+
validate_decoded_size!(header, out)
|
|
10
|
+
validate_decoded_crc!(header, out)
|
|
11
|
+
validate_decoded_text(out)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate_decoded_size!(header, out)
|
|
15
|
+
expected_size = header.fetch(:original_size)
|
|
16
|
+
return if out.bytesize == expected_size
|
|
17
|
+
|
|
18
|
+
err("size mismatch: header says #{expected_size}, got #{out.bytesize}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate_decoded_crc!(header, out)
|
|
22
|
+
expected_crc = header.fetch(:expected_crc)
|
|
23
|
+
actual_crc = MultiCompress.crc32(out)
|
|
24
|
+
return if actual_crc == expected_crc
|
|
25
|
+
|
|
26
|
+
err("crc32 mismatch: header #{expected_crc}, computed #{actual_crc}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_decoded_text(out)
|
|
30
|
+
text = out.force_encoding(Encoding::UTF_8)
|
|
31
|
+
err("decompressed bytes are not valid UTF-8") unless text.valid_encoding?
|
|
32
|
+
err("decompressed text contains a NUL byte") if text.include?("\0")
|
|
33
|
+
text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def utf8_bytes(text)
|
|
37
|
+
s = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
|
|
38
|
+
raise ArgumentError, "MultiCompress::Database: input is not valid UTF-8" unless s.valid_encoding?
|
|
39
|
+
raise ArgumentError, "MultiCompress::Database: input contains a NUL byte" if s.include?("\0")
|
|
40
|
+
|
|
41
|
+
s.b
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_canonical_zstd_frame!(frame, expected_size, expected_dictionary_id:)
|
|
45
|
+
_frame_size, content_size = MultiCompress.zstd_single_frame_info(frame)
|
|
46
|
+
if content_size != expected_size
|
|
47
|
+
err("zstd frame content size #{content_size} does not match header size #{expected_size}")
|
|
48
|
+
end
|
|
49
|
+
actual_dictionary_id = MultiCompress.zstd_frame_dictionary_id(frame)
|
|
50
|
+
if actual_dictionary_id != expected_dictionary_id
|
|
51
|
+
err("zstd frame dictionary id #{actual_dictionary_id} does not match expected #{expected_dictionary_id}")
|
|
52
|
+
end
|
|
53
|
+
rescue MultiCompress::DataError => e
|
|
54
|
+
err(e.message.sub(/\AMultiCompress::Database: /, ""))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def database_dictionary!(value)
|
|
58
|
+
return value if value.is_a?(Dictionary)
|
|
59
|
+
|
|
60
|
+
raise TypeError, "MCDB2 requires MultiCompress::Database::Dictionary, got #{value.class}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_envelope_size!(envelope, max, format)
|
|
64
|
+
return if envelope.bytesize <= max
|
|
65
|
+
|
|
66
|
+
raise MultiCompress::DataError,
|
|
67
|
+
"MultiCompress::Database: stored #{format} envelope is #{envelope.bytesize} bytes, " \
|
|
68
|
+
"over the #{max}-byte limit"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def header_v1(original_size, crc32)
|
|
72
|
+
encode_header(VERSION_V1, original_size, crc32)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def header_v2(original_size, crc32, dictionary_ref)
|
|
76
|
+
encode_header(VERSION_V2, original_size, crc32, dictionary_ref)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def encode_header(version, original_size, crc32, dictionary_ref = nil)
|
|
80
|
+
header = MAGIC + [version, CODEC_ZSTD, FLAGS_NONE].pack("C3") +
|
|
81
|
+
[original_size].pack("Q<") + [crc32].pack("L<")
|
|
82
|
+
header << [dictionary_ref].pack("Q<") unless dictionary_ref.nil?
|
|
83
|
+
header.b
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def format_name(version)
|
|
87
|
+
version == VERSION_V2 ? "MCDB2" : "MCDB1"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def err(message)
|
|
91
|
+
raise MultiCompress::DataError, "MultiCompress::Database: #{message}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -1,106 +1,40 @@
|
|
|
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
|
-
|
|
16
|
-
module_function
|
|
17
|
-
|
|
18
|
-
def compress(text)
|
|
19
|
-
raise TypeError, "MultiCompress::Database.compress expects a String, got #{text.class}" unless text.is_a?(String)
|
|
20
|
-
|
|
21
|
-
bytes = utf8_bytes(text)
|
|
22
|
-
n = bytes.bytesize
|
|
23
|
-
if n > MAX_OUTPUT
|
|
24
|
-
raise ArgumentError, "MultiCompress::Database: input is #{n} bytes, over the #{MAX_OUTPUT}-byte v1 limit"
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
frame = MultiCompress.compress(bytes, algo: :zstd, level: ZSTD_LEVEL)
|
|
28
|
-
validate_canonical_zstd_frame!(frame, n)
|
|
29
|
-
|
|
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"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
envelope
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def decompress(blob)
|
|
40
|
-
raise TypeError, "MultiCompress::Database.decompress expects a String, got #{blob.class}" unless blob.is_a?(String)
|
|
41
|
-
|
|
42
|
-
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}")
|
|
59
|
-
end
|
|
60
|
-
actual_crc = MultiCompress.crc32(out)
|
|
61
|
-
err("crc32 mismatch: header #{expected_crc}, computed #{actual_crc}") if actual_crc != expected_crc
|
|
62
|
-
|
|
63
|
-
s = out.force_encoding(Encoding::UTF_8)
|
|
64
|
-
err("decompressed bytes are not valid UTF-8") unless s.valid_encoding?
|
|
65
|
-
err("decompressed text contains a NUL byte") if s.include?("\0")
|
|
66
|
-
s
|
|
67
|
-
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
|
|
79
|
-
|
|
80
|
-
def utf8_bytes(text)
|
|
81
|
-
s = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
|
|
82
|
-
raise ArgumentError, "MultiCompress::Database: input is not valid UTF-8" unless s.valid_encoding?
|
|
83
|
-
raise ArgumentError, "MultiCompress::Database: input contains a NUL byte" if s.include?("\0")
|
|
84
|
-
|
|
85
|
-
s.b
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def validate_canonical_zstd_frame!(frame, expected_size)
|
|
89
|
-
_frame_size, content_size = MultiCompress.zstd_single_frame_info(frame)
|
|
90
|
-
err("zstd frame content size #{content_size} does not match header size #{expected_size}") if content_size != expected_size
|
|
91
|
-
rescue MultiCompress::DataError => e
|
|
92
|
-
err(e.message.sub(/\AMultiCompress::Database: /, ""))
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def header(original_size, crc32)
|
|
96
|
-
(MAGIC + [VERSION, CODEC_ZSTD, FLAGS_V1].pack("C3") +
|
|
97
|
-
[original_size].pack("Q<") + [crc32].pack("L<")).b
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def err(message)
|
|
101
|
-
raise MultiCompress::DataError, "MultiCompress::Database: #{message}"
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
private_class_method :utf8_bytes, :validate_canonical_zstd_frame!, :header, :err
|
|
105
30
|
end
|
|
106
31
|
end
|
|
32
|
+
|
|
33
|
+
require "multi_compress/database/dictionary"
|
|
34
|
+
require "multi_compress/database/operations"
|
|
35
|
+
require "multi_compress/database/encoding"
|
|
36
|
+
require "multi_compress/database/decoding"
|
|
37
|
+
require "multi_compress/database/header"
|
|
38
|
+
require "multi_compress/database/validation"
|
|
39
|
+
|
|
40
|
+
require "multi_compress/database/api"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module DBDeployment
|
|
5
|
+
class BundleArchiver
|
|
6
|
+
DEFAULT_EPOCH = 315_619_200
|
|
7
|
+
attr_reader :output, :root_name
|
|
8
|
+
private :output, :root_name
|
|
9
|
+
|
|
10
|
+
def initialize(output, root_name)
|
|
11
|
+
@output = output
|
|
12
|
+
@root_name = root_name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write!(temporary_directory)
|
|
16
|
+
temporary_output = temporary_path
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
18
|
+
write_archive(temporary_output, temporary_directory)
|
|
19
|
+
File.rename(temporary_output, output)
|
|
20
|
+
ensure
|
|
21
|
+
File.delete(temporary_output) if defined?(temporary_output) && File.exist?(temporary_output)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def temporary_path
|
|
27
|
+
File.join(File.dirname(output), ".#{File.basename(output)}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def write_archive(path, temporary_directory)
|
|
31
|
+
File.open(path, "wb", 0o644) do |file|
|
|
32
|
+
Zlib::GzipWriter.wrap(file) do |gzip|
|
|
33
|
+
gzip.mtime = archive_epoch
|
|
34
|
+
DeterministicTarWriter.new(gzip, archive_epoch) do |tar|
|
|
35
|
+
add_tree(tar, File.join(temporary_directory, root_name), root_name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def archive_epoch
|
|
42
|
+
value = ENV.fetch("SOURCE_DATE_EPOCH", nil)
|
|
43
|
+
value&.match?(/\A\d+\z/) ? value.to_i : DEFAULT_EPOCH
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_tree(tar, directory, archive_path)
|
|
47
|
+
tar.mkdir(archive_path, mode(directory))
|
|
48
|
+
Dir.children(directory).sort.each { |entry| add_entry(tar, directory, archive_path, entry) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_entry(tar, directory, archive_path, entry)
|
|
52
|
+
source = File.join(directory, entry)
|
|
53
|
+
destination = File.join(archive_path, entry)
|
|
54
|
+
return add_tree(tar, source, destination) if File.directory?(source)
|
|
55
|
+
|
|
56
|
+
add_file(tar, source, destination)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_file(tar, source, destination)
|
|
60
|
+
tar.add_file_simple(destination, mode(source), File.size(source)) do |destination_io|
|
|
61
|
+
File.open(source, "rb") { |source_io| IO.copy_stream(source_io, destination_io) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def mode(path)
|
|
66
|
+
File.stat(path).mode & 0o777
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MultiCompress
|
|
4
|
+
module DBDeployment
|
|
5
|
+
class BundleMetadata
|
|
6
|
+
attr_reader :target
|
|
7
|
+
private :target
|
|
8
|
+
|
|
9
|
+
def initialize(target)
|
|
10
|
+
@target = target
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def write!(bundle_root)
|
|
14
|
+
write_manifest(bundle_root, manifest_files(bundle_root))
|
|
15
|
+
write_checksums(bundle_root)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def manifest_files(root)
|
|
21
|
+
regular_files(root).reject { |path| %w[manifest.json SHA256SUMS].include?(File.basename(path)) }
|
|
22
|
+
.sort.map { |path| file_record(root, path) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def file_record(root, path)
|
|
26
|
+
{ "path" => relative_to(root, path), "sha256" => Digest::SHA256.file(path).hexdigest }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write_manifest(root, files)
|
|
30
|
+
manifest = {
|
|
31
|
+
"bundle_format_version" => BUNDLE_FORMAT_VERSION,
|
|
32
|
+
"gem" => { "name" => "multi_compress", "version" => MultiCompress::VERSION },
|
|
33
|
+
"database_target" => target,
|
|
34
|
+
"database_envelope" => FORMAT,
|
|
35
|
+
"files" => files
|
|
36
|
+
}
|
|
37
|
+
File.write(File.join(root, "manifest.json"), "#{JSON.pretty_generate(manifest)}\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write_checksums(root)
|
|
41
|
+
checksums = regular_files(root).reject { |path| File.basename(path) == "SHA256SUMS" }.sort.map do |path|
|
|
42
|
+
"#{Digest::SHA256.file(path).hexdigest} #{relative_to(root, path)}"
|
|
43
|
+
end
|
|
44
|
+
File.write(File.join(root, "SHA256SUMS"), "#{checksums.join("\n")}\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def regular_files(root)
|
|
48
|
+
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def relative_to(root, path)
|
|
52
|
+
Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|