multi_compress 0.3.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/GET_STARTED.md +188 -0
- data/README.md +56 -0
- data/db_core/include/mcdb_format.h +55 -0
- data/db_core/src/mcdb_format.c +341 -0
- data/db_deployment/mysql/Makefile +48 -0
- data/db_deployment/mysql/README.md +42 -0
- data/db_deployment/mysql/bin/common +160 -0
- data/db_deployment/mysql/bin/disable +40 -0
- data/db_deployment/mysql/bin/doctor +64 -0
- data/db_deployment/mysql/bin/enable +57 -0
- data/db_deployment/mysql/bin/install +64 -0
- data/db_deployment/mysql/bin/status +65 -0
- data/db_deployment/mysql/bin/uninstall +47 -0
- data/db_deployment/mysql/bin/upgrade +115 -0
- data/db_deployment/mysql/bin/verify +14 -0
- data/db_deployment/postgres/Makefile +45 -0
- data/db_deployment/postgres/README.md +60 -0
- data/db_deployment/postgres/bin/doctor +61 -0
- data/db_deployment/postgres/bin/enable +136 -0
- data/db_deployment/postgres/bin/install +104 -0
- data/db_deployment/postgres/bin/uninstall +54 -0
- data/db_deployment/postgres/bin/verify +14 -0
- data/docs/database-envelope-v1.md +159 -0
- data/exe/multi_compress +228 -0
- data/ext/multi_compress/extconf.rb +4 -0
- data/ext/multi_compress/multi_compress.c +30 -0
- data/lib/multi_compress/active_record.rb +61 -0
- data/lib/multi_compress/codec.rb +164 -0
- data/lib/multi_compress/database.rb +106 -0
- data/lib/multi_compress/db_deployment.rb +456 -0
- data/lib/multi_compress/version.rb +1 -1
- data/multi_compress.gemspec +55 -0
- data/mysql_udf/Makefile +96 -0
- data/mysql_udf/README.md +95 -0
- data/mysql_udf/sql/examples.sql +11 -0
- data/mysql_udf/sql/install.sql +15 -0
- data/mysql_udf/sql/uninstall.sql +4 -0
- data/mysql_udf/sql/views.sql +8 -0
- data/mysql_udf/src/multi_compress_mysql.c +133 -0
- data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
- data/mysql_udf/test/mcdb_cli.c +60 -0
- data/mysql_udf/test/run_e2e.sh +296 -0
- data/postgres_extension/Makefile +74 -0
- data/postgres_extension/README.md +112 -0
- data/postgres_extension/multi_compress.control +4 -0
- data/postgres_extension/multi_compress_pg.exports +12 -0
- data/postgres_extension/sql/examples.sql +12 -0
- data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
- data/postgres_extension/sql/uninstall.sql +2 -0
- data/postgres_extension/sql/views.sql +7 -0
- data/postgres_extension/src/mcdb_format.c +2 -0
- data/postgres_extension/src/multi_compress_pg.c +96 -0
- data/postgres_extension/test/run_e2e.sh +323 -0
- metadata +52 -3
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "multi_compress/version"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "pathname"
|
|
9
|
+
require "rubygems/package"
|
|
10
|
+
require "securerandom"
|
|
11
|
+
require "tmpdir"
|
|
12
|
+
require "zlib"
|
|
13
|
+
|
|
14
|
+
module MultiCompress
|
|
15
|
+
module DBDeployment
|
|
16
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
17
|
+
FORMAT = "MCDB1"
|
|
18
|
+
BUNDLE_FORMAT_VERSION = 1
|
|
19
|
+
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
|
|
22
|
+
class CLI
|
|
23
|
+
def self.run(argv)
|
|
24
|
+
new.run(argv)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(argv)
|
|
28
|
+
command = argv.shift
|
|
29
|
+
|
|
30
|
+
case command
|
|
31
|
+
when "package"
|
|
32
|
+
PackageCommand.new.run(argv)
|
|
33
|
+
when "view"
|
|
34
|
+
ViewCommand.new.run(argv)
|
|
35
|
+
when "help", "--help", "-h", nil
|
|
36
|
+
puts help
|
|
37
|
+
0
|
|
38
|
+
else
|
|
39
|
+
raise OptionParser::InvalidOption, "unknown db command: #{command.inspect}"
|
|
40
|
+
end
|
|
41
|
+
rescue OptionParser::ParseError, Error => e
|
|
42
|
+
warn "multi_compress db: #{e.message}"
|
|
43
|
+
2
|
|
44
|
+
rescue SystemCallError, IOError => e
|
|
45
|
+
warn "multi_compress db: #{e.message}"
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def help
|
|
52
|
+
<<~TEXT
|
|
53
|
+
Usage:
|
|
54
|
+
multi_compress db package postgres [--output PATH] [--force]
|
|
55
|
+
multi_compress db package mysql [--output PATH] [--force]
|
|
56
|
+
|
|
57
|
+
multi_compress db view postgres --table SCHEMA.TABLE --column COLUMN \\
|
|
58
|
+
--view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] \\
|
|
59
|
+
[--extension-schema multi_compress] [--output PATH]
|
|
60
|
+
|
|
61
|
+
multi_compress db view mysql --table SCHEMA.TABLE --column COLUMN \\
|
|
62
|
+
--view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] [--output PATH]
|
|
63
|
+
|
|
64
|
+
`package` creates a self-contained source bundle for the DBA. The DBA
|
|
65
|
+
extracts it on the database host and runs `make doctor`, `sudo make
|
|
66
|
+
install`, then `make enable DB=...`.
|
|
67
|
+
|
|
68
|
+
`view` emits a safe readable-view definition so DBeaver users query
|
|
69
|
+
decoded text instead of the compressed bytea/BLOB column.
|
|
70
|
+
TEXT
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class PackageCommand
|
|
75
|
+
TARGETS = %w[postgres mysql].freeze
|
|
76
|
+
|
|
77
|
+
def run(argv)
|
|
78
|
+
target = argv.shift
|
|
79
|
+
raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
|
|
80
|
+
raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
|
|
81
|
+
|
|
82
|
+
options = { output: nil, force: false }
|
|
83
|
+
parser = OptionParser.new do |opts|
|
|
84
|
+
opts.banner = "Usage: multi_compress db package #{target} [OPTIONS]"
|
|
85
|
+
opts.on("-o", "--output PATH", "write the deployment archive to PATH") { |value| options[:output] = value }
|
|
86
|
+
opts.on("-f", "--force", "overwrite an existing archive") { options[:force] = true }
|
|
87
|
+
opts.on("-h", "--help", "show this help") { puts opts; return 0 }
|
|
88
|
+
end
|
|
89
|
+
parser.parse!(argv)
|
|
90
|
+
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
91
|
+
|
|
92
|
+
output = options[:output] || "multi_compress-#{target}-#{MultiCompress::VERSION}.tar.gz"
|
|
93
|
+
DeploymentBundle.new(target, output, force: options[:force]).write!
|
|
94
|
+
puts File.expand_path(output)
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class ViewCommand
|
|
100
|
+
TARGETS = %w[postgres mysql].freeze
|
|
101
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
102
|
+
|
|
103
|
+
def run(argv)
|
|
104
|
+
target = argv.shift
|
|
105
|
+
raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
|
|
106
|
+
raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
|
|
107
|
+
|
|
108
|
+
options = { columns: [], as: "payload", output: nil, extension_schema: "multi_compress" }
|
|
109
|
+
parser = OptionParser.new do |opts|
|
|
110
|
+
opts.banner = "Usage: multi_compress db view #{target} --table SCHEMA.TABLE --column COLUMN --view SCHEMA.VIEW --columns ID,CREATED_AT [OPTIONS]"
|
|
111
|
+
opts.on("--table NAME", "source table, optionally schema-qualified") { |value| options[:table] = value }
|
|
112
|
+
opts.on("--column NAME", "compressed bytea/BLOB column") { |value| options[:column] = value }
|
|
113
|
+
opts.on("--view NAME", "destination view, optionally schema-qualified") { |value| options[:view] = value }
|
|
114
|
+
opts.on("--columns LIST", "comma-separated plain columns to expose") { |value| options[:columns] = value.split(",").map(&:strip) }
|
|
115
|
+
opts.on("--as NAME", "decoded column name (default: payload)") { |value| options[:as] = value }
|
|
116
|
+
if target == "postgres"
|
|
117
|
+
opts.on("--extension-schema NAME", "extension schema (default: multi_compress)") { |value| options[:extension_schema] = value }
|
|
118
|
+
end
|
|
119
|
+
opts.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
|
|
120
|
+
opts.on("-h", "--help", "show this help") { puts opts; return 0 }
|
|
121
|
+
end
|
|
122
|
+
parser.parse!(argv)
|
|
123
|
+
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
124
|
+
|
|
125
|
+
sql = ReadableView.new(target, options).to_sql
|
|
126
|
+
if options[:output]
|
|
127
|
+
write_text_atomically(options[:output], sql)
|
|
128
|
+
puts File.expand_path(options[:output])
|
|
129
|
+
else
|
|
130
|
+
$stdout.write(sql)
|
|
131
|
+
end
|
|
132
|
+
0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def write_text_atomically(path, contents)
|
|
138
|
+
directory = File.dirname(File.expand_path(path))
|
|
139
|
+
FileUtils.mkdir_p(directory)
|
|
140
|
+
temporary = File.join(directory, ".multi-compress-view-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
141
|
+
File.open(temporary, "wb", 0o644) { |file| file.write(contents) }
|
|
142
|
+
File.rename(temporary, path)
|
|
143
|
+
ensure
|
|
144
|
+
File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
class ReadableView
|
|
149
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
150
|
+
|
|
151
|
+
def initialize(target, options)
|
|
152
|
+
@target = target
|
|
153
|
+
@table = qualified_identifier!(options.fetch(:table), "--table")
|
|
154
|
+
@view = qualified_identifier!(options.fetch(:view), "--view")
|
|
155
|
+
@column = identifier!(options.fetch(:column), "--column")
|
|
156
|
+
@columns = Array(options.fetch(:columns)).reject(&:empty?).map { |value| identifier!(value, "--columns") }
|
|
157
|
+
@decoded_name = identifier!(options.fetch(:as), "--as")
|
|
158
|
+
@extension_schema = identifier!(options.fetch(:extension_schema, "multi_compress"), "--extension-schema")
|
|
159
|
+
raise Error, "--columns must contain at least one plain column" if @columns.empty?
|
|
160
|
+
raise Error, "--columns must not contain duplicate names" unless @columns.uniq.length == @columns.length
|
|
161
|
+
raise Error, "--as must not duplicate a selected column" if @columns.include?(@decoded_name)
|
|
162
|
+
rescue KeyError
|
|
163
|
+
raise Error, "--table, --column, --view and --columns are required"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def to_sql
|
|
167
|
+
case @target
|
|
168
|
+
when "postgres" then postgres_sql
|
|
169
|
+
when "mysql" then mysql_sql
|
|
170
|
+
else raise Error, "unsupported database target: #{@target.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def postgres_sql
|
|
177
|
+
selections = @columns.map { |column| " source.#{pg_quote(column)}" }
|
|
178
|
+
selections << " #{pg_quote(@extension_schema)}.multi_compress_db_decompress(source.#{pg_quote(@column)}) AS #{pg_quote(@decoded_name)}"
|
|
179
|
+
|
|
180
|
+
<<~SQL
|
|
181
|
+
-- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
|
|
182
|
+
-- Query it in DBeaver; do not expose the compressed column for ad-hoc reading.
|
|
183
|
+
CREATE OR REPLACE VIEW #{pg_qualified(@view)} AS
|
|
184
|
+
SELECT
|
|
185
|
+
#{selections.join(",\n")}
|
|
186
|
+
FROM #{pg_qualified(@table)} AS source;
|
|
187
|
+
SQL
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def mysql_sql
|
|
191
|
+
selections = @columns.map { |column| " source.#{mysql_quote(column)}" }
|
|
192
|
+
selections << " CONVERT(multi_compress_db_decompress(source.#{mysql_quote(@column)}) USING utf8mb4) AS #{mysql_quote(@decoded_name)}"
|
|
193
|
+
|
|
194
|
+
<<~SQL
|
|
195
|
+
-- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
|
|
196
|
+
-- Query it in DBeaver; do not expose the compressed column for ad-hoc reading.
|
|
197
|
+
CREATE OR REPLACE VIEW #{mysql_qualified(@view)} AS
|
|
198
|
+
SELECT
|
|
199
|
+
#{selections.join(",\n")}
|
|
200
|
+
FROM #{mysql_qualified(@table)} AS source;
|
|
201
|
+
SQL
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def qualified_identifier!(value, option)
|
|
205
|
+
pieces = value.to_s.split(".", -1)
|
|
206
|
+
unless pieces.length.between?(1, 2) && pieces.all? { |piece| IDENTIFIER.match?(piece) }
|
|
207
|
+
raise Error, "#{option} must be NAME or SCHEMA.NAME using letters, digits and underscores"
|
|
208
|
+
end
|
|
209
|
+
pieces
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def identifier!(value, option)
|
|
213
|
+
string = value.to_s
|
|
214
|
+
raise Error, "#{option} must use letters, digits and underscores and cannot start with a digit" unless IDENTIFIER.match?(string)
|
|
215
|
+
|
|
216
|
+
string
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def pg_quote(identifier)
|
|
220
|
+
%Q("#{identifier}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def pg_qualified(pieces)
|
|
224
|
+
pieces.map { |piece| pg_quote(piece) }.join(".")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def mysql_quote(identifier)
|
|
228
|
+
"`#{identifier}`"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def mysql_qualified(pieces)
|
|
232
|
+
pieces.map { |piece| mysql_quote(piece) }.join(".")
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
class DeploymentBundle
|
|
237
|
+
DEFAULT_ARCHIVE_EPOCH = 315_619_200
|
|
238
|
+
|
|
239
|
+
class DeterministicTarWriter < Gem::Package::TarWriter
|
|
240
|
+
def self.new(io, epoch)
|
|
241
|
+
writer = allocate
|
|
242
|
+
writer.send(:initialize, io, epoch)
|
|
243
|
+
return writer unless block_given?
|
|
244
|
+
|
|
245
|
+
begin
|
|
246
|
+
yield writer
|
|
247
|
+
ensure
|
|
248
|
+
writer.close
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def initialize(io, epoch)
|
|
255
|
+
@archive_epoch = epoch
|
|
256
|
+
super(io)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def add_file_simple(name, mode, size)
|
|
260
|
+
check_closed
|
|
261
|
+
|
|
262
|
+
name, prefix = split_name(name)
|
|
263
|
+
header = Gem::Package::TarHeader.new(
|
|
264
|
+
name: name,
|
|
265
|
+
mode: mode,
|
|
266
|
+
size: size,
|
|
267
|
+
prefix: prefix,
|
|
268
|
+
mtime: @archive_epoch
|
|
269
|
+
).to_s
|
|
270
|
+
@io.write(header)
|
|
271
|
+
output = BoundedStream.new(@io, size)
|
|
272
|
+
yield output if block_given?
|
|
273
|
+
@io.write("\0" * (size - output.written))
|
|
274
|
+
@io.write("\0" * ((512 - (size % 512)) % 512))
|
|
275
|
+
self
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def mkdir(name, mode)
|
|
279
|
+
check_closed
|
|
280
|
+
|
|
281
|
+
name, prefix = split_name(name)
|
|
282
|
+
header = Gem::Package::TarHeader.new(
|
|
283
|
+
name: name,
|
|
284
|
+
mode: mode,
|
|
285
|
+
typeflag: "5",
|
|
286
|
+
size: 0,
|
|
287
|
+
prefix: prefix,
|
|
288
|
+
mtime: @archive_epoch
|
|
289
|
+
)
|
|
290
|
+
@io.write(header)
|
|
291
|
+
self
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
TARGETS = {
|
|
296
|
+
"postgres" => {
|
|
297
|
+
source_paths: %w[
|
|
298
|
+
db_core
|
|
299
|
+
postgres_extension
|
|
300
|
+
ext/multi_compress/vendor/zstd
|
|
301
|
+
docs/database-envelope-v1.md
|
|
302
|
+
LICENSE.txt
|
|
303
|
+
THIRD_PARTY_NOTICES.md
|
|
304
|
+
],
|
|
305
|
+
template_path: "db_deployment/postgres"
|
|
306
|
+
},
|
|
307
|
+
"mysql" => {
|
|
308
|
+
source_paths: %w[
|
|
309
|
+
db_core
|
|
310
|
+
mysql_udf
|
|
311
|
+
ext/multi_compress/vendor/zstd
|
|
312
|
+
docs/database-envelope-v1.md
|
|
313
|
+
LICENSE.txt
|
|
314
|
+
THIRD_PARTY_NOTICES.md
|
|
315
|
+
],
|
|
316
|
+
template_path: "db_deployment/mysql"
|
|
317
|
+
}
|
|
318
|
+
}.freeze
|
|
319
|
+
|
|
320
|
+
def initialize(target, output, force:)
|
|
321
|
+
@target = target
|
|
322
|
+
@output = File.expand_path(output)
|
|
323
|
+
@force = force
|
|
324
|
+
@root_name = "multi_compress-#{target}-#{MultiCompress::VERSION}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def write!
|
|
328
|
+
raise Error, "deployment sources are unavailable from #{ROOT}" unless File.directory?(ROOT)
|
|
329
|
+
raise Error, "#{@output} already exists (use --force to overwrite)" if File.exist?(@output) && !@force
|
|
330
|
+
|
|
331
|
+
Dir.mktmpdir("multi-compress-db-package") do |temporary_directory|
|
|
332
|
+
bundle_root = File.join(temporary_directory, @root_name)
|
|
333
|
+
FileUtils.mkdir_p(bundle_root)
|
|
334
|
+
stage_payload!(bundle_root)
|
|
335
|
+
write_metadata!(bundle_root)
|
|
336
|
+
write_tarball!(temporary_directory)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private
|
|
341
|
+
|
|
342
|
+
def stage_payload!(bundle_root)
|
|
343
|
+
target = TARGETS.fetch(@target)
|
|
344
|
+
target.fetch(:source_paths).each do |relative|
|
|
345
|
+
copy_path!(relative, bundle_root)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
template_root = File.join(ROOT, target.fetch(:template_path))
|
|
349
|
+
Dir.children(template_root).sort.each do |entry|
|
|
350
|
+
next if ignored?(entry)
|
|
351
|
+
|
|
352
|
+
copy_path!(File.join(target.fetch(:template_path), entry), bundle_root, destination: entry)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def copy_path!(relative, bundle_root, destination: relative)
|
|
357
|
+
source = File.join(ROOT, relative)
|
|
358
|
+
raise Error, "package source is missing: #{relative}" unless File.exist?(source)
|
|
359
|
+
|
|
360
|
+
destination_path = File.join(bundle_root, destination)
|
|
361
|
+
if File.directory?(source)
|
|
362
|
+
FileUtils.mkdir_p(destination_path)
|
|
363
|
+
Dir.children(source).sort.each do |entry|
|
|
364
|
+
next if ignored?(entry)
|
|
365
|
+
|
|
366
|
+
copy_path!(File.join(relative, entry), bundle_root, destination: File.join(destination, entry))
|
|
367
|
+
end
|
|
368
|
+
else
|
|
369
|
+
FileUtils.mkdir_p(File.dirname(destination_path))
|
|
370
|
+
FileUtils.copy_file(source, destination_path)
|
|
371
|
+
File.chmod(File.stat(source).mode & 0o777, destination_path)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def ignored?(entry)
|
|
376
|
+
entry == ".DS_Store" || entry == ".git" || entry == "build" || entry == "test"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def write_metadata!(bundle_root)
|
|
380
|
+
payload = regular_files(bundle_root).reject do |path|
|
|
381
|
+
%w[manifest.json SHA256SUMS].include?(File.basename(path))
|
|
382
|
+
end
|
|
383
|
+
files = payload.sort.map do |path|
|
|
384
|
+
relative = relative_to(bundle_root, path)
|
|
385
|
+
{ "path" => relative, "sha256" => Digest::SHA256.file(path).hexdigest }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
manifest = {
|
|
389
|
+
"bundle_format_version" => BUNDLE_FORMAT_VERSION,
|
|
390
|
+
"gem" => { "name" => "multi_compress", "version" => MultiCompress::VERSION },
|
|
391
|
+
"database_target" => @target,
|
|
392
|
+
"database_envelope" => FORMAT,
|
|
393
|
+
"files" => files
|
|
394
|
+
}
|
|
395
|
+
File.write(File.join(bundle_root, "manifest.json"), JSON.pretty_generate(manifest) + "\n")
|
|
396
|
+
|
|
397
|
+
checksum_paths = regular_files(bundle_root).reject { |path| File.basename(path) == "SHA256SUMS" }
|
|
398
|
+
checksums = checksum_paths.sort.map do |path|
|
|
399
|
+
"#{Digest::SHA256.file(path).hexdigest} #{relative_to(bundle_root, path)}"
|
|
400
|
+
end
|
|
401
|
+
File.write(File.join(bundle_root, "SHA256SUMS"), checksums.join("\n") + "\n")
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def write_tarball!(temporary_directory)
|
|
405
|
+
FileUtils.mkdir_p(File.dirname(@output))
|
|
406
|
+
temporary_output = File.join(
|
|
407
|
+
File.dirname(@output),
|
|
408
|
+
".#{File.basename(@output)}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
File.open(temporary_output, "wb", 0o644) do |file|
|
|
412
|
+
Zlib::GzipWriter.wrap(file) do |gzip|
|
|
413
|
+
gzip.mtime = archive_epoch
|
|
414
|
+
DeterministicTarWriter.new(gzip, archive_epoch) do |tar|
|
|
415
|
+
add_tree_to_tar!(tar, File.join(temporary_directory, @root_name), @root_name)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
File.rename(temporary_output, @output)
|
|
420
|
+
ensure
|
|
421
|
+
File.delete(temporary_output) if defined?(temporary_output) && File.exist?(temporary_output)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def archive_epoch
|
|
425
|
+
value = ENV["SOURCE_DATE_EPOCH"]
|
|
426
|
+
return DEFAULT_ARCHIVE_EPOCH unless value&.match?(/\A\d+\z/)
|
|
427
|
+
|
|
428
|
+
value.to_i
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def add_tree_to_tar!(tar, directory, archive_path)
|
|
432
|
+
tar.mkdir(archive_path, File.stat(directory).mode & 0o777)
|
|
433
|
+
Dir.children(directory).sort.each do |entry|
|
|
434
|
+
source = File.join(directory, entry)
|
|
435
|
+
destination = File.join(archive_path, entry)
|
|
436
|
+
if File.directory?(source)
|
|
437
|
+
add_tree_to_tar!(tar, source, destination)
|
|
438
|
+
else
|
|
439
|
+
mode = File.stat(source).mode & 0o777
|
|
440
|
+
tar.add_file_simple(destination, mode, File.size(source)) do |destination_io|
|
|
441
|
+
File.open(source, "rb") { |source_io| IO.copy_stream(source_io, destination_io) }
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def regular_files(root)
|
|
448
|
+
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def relative_to(root, path)
|
|
452
|
+
Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require_relative "lib/multi_compress/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "multi_compress"
|
|
5
|
+
spec.version = MultiCompress::VERSION
|
|
6
|
+
spec.authors = ["Roman Haydarov"]
|
|
7
|
+
spec.email = ["romnhajdarov@gmail.com"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "Modern fiber-friendly compression for Ruby: zstd, lz4, brotli in one gem"
|
|
10
|
+
spec.description = "Unified C-extension gem for zstd, lz4, and brotli compression. " \
|
|
11
|
+
"One-shot, streaming, IO wrappers, dictionary support. " \
|
|
12
|
+
"Fiber-friendly: cooperates with Fiber::Scheduler (async, falcon) " \
|
|
13
|
+
"so CPU-heavy compression never blocks the event loop. " \
|
|
14
|
+
"Ships vendored sources — no system libraries required."
|
|
15
|
+
spec.homepage = "https://github.com/roman-haidarov/multi_compress"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 2.7.1"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
22
|
+
|
|
23
|
+
spec.files = Dir[
|
|
24
|
+
"lib/**/*.rb",
|
|
25
|
+
"exe/*",
|
|
26
|
+
"docs/**/*.md",
|
|
27
|
+
"ext/**/*.{c,h,rb}",
|
|
28
|
+
"ext/multi_compress/vendor/**/*",
|
|
29
|
+
"ext/multi_compress/vendor/.vendored",
|
|
30
|
+
"db_core/**/*.{c,h}",
|
|
31
|
+
"postgres_extension/**/*",
|
|
32
|
+
"mysql_udf/**/*",
|
|
33
|
+
"db_deployment/**/*",
|
|
34
|
+
"multi_compress.gemspec",
|
|
35
|
+
"README.md",
|
|
36
|
+
"GET_STARTED.md",
|
|
37
|
+
"LICENSE.txt",
|
|
38
|
+
"THIRD_PARTY_NOTICES.md",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
].reject do |path|
|
|
41
|
+
File.basename(path) == ".DS_Store" ||
|
|
42
|
+
path.split(File::SEPARATOR).include?("build") ||
|
|
43
|
+
%w[.o .so .bundle .dylib .bc .d].include?(File.extname(path))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
spec.bindir = "exe"
|
|
47
|
+
spec.executables = ["multi_compress"]
|
|
48
|
+
spec.require_paths = ["lib"]
|
|
49
|
+
spec.extensions = ["ext/multi_compress/extconf.rb"]
|
|
50
|
+
|
|
51
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
|
52
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
53
|
+
spec.add_development_dependency "rake-compiler", "~> 1.2"
|
|
54
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
55
|
+
end
|
data/mysql_udf/Makefile
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
ZSTD_DIR ?= ../ext/multi_compress/vendor/zstd/lib
|
|
2
|
+
CORE_DIR ?= ../db_core
|
|
3
|
+
BUILD ?= build
|
|
4
|
+
CC ?= gcc
|
|
5
|
+
AR ?= ar
|
|
6
|
+
MYSQL_UDF_ABI ?= mysql57
|
|
7
|
+
MYSQL_INCLUDE ?= /usr/include/mysql
|
|
8
|
+
FIXTURES ?= ../test/fixtures/database_v1
|
|
9
|
+
|
|
10
|
+
ifeq ($(MYSQL_UDF_ABI),mysql57)
|
|
11
|
+
MYSQL_UDF_CPPFLAGS := -DMCDB_MYSQL_UDF_ABI_57
|
|
12
|
+
MYSQL_UDF_HEADER_CHECK :=
|
|
13
|
+
else ifeq ($(MYSQL_UDF_ABI),system)
|
|
14
|
+
MYSQL_UDF_CPPFLAGS := -I$(MYSQL_INCLUDE)
|
|
15
|
+
MYSQL_UDF_HEADER_CHECK := test -f "$(MYSQL_INCLUDE)/mysql.h" || { echo "missing mysql.h: set MYSQL_INCLUDE=/path/to/mysql/include" >&2; exit 2; }
|
|
16
|
+
else
|
|
17
|
+
$(error MYSQL_UDF_ABI must be mysql57 or system)
|
|
18
|
+
endif
|
|
19
|
+
|
|
20
|
+
WARNFLAGS ?= -Wall -Wextra -Werror
|
|
21
|
+
CFLAGS ?= -O2 -fPIC -std=c99 $(WARNFLAGS) -I$(ZSTD_DIR) -I$(CORE_DIR)/include
|
|
22
|
+
ZSTD_CFLAGS ?= -O2 -fPIC -std=c99 -I$(ZSTD_DIR) -I$(ZSTD_DIR)/common
|
|
23
|
+
|
|
24
|
+
ZSTD_OBJ_DIR := $(BUILD)/zstd
|
|
25
|
+
ZSTD_C_SRCS := $(wildcard $(ZSTD_DIR)/common/*.c) $(wildcard $(ZSTD_DIR)/decompress/*.c)
|
|
26
|
+
ZSTD_C_OBJS := $(patsubst $(ZSTD_DIR)/%.c,$(ZSTD_OBJ_DIR)/%.o,$(ZSTD_C_SRCS))
|
|
27
|
+
|
|
28
|
+
ARCH := $(shell uname -m)
|
|
29
|
+
ifneq (,$(filter x86_64 amd64,$(ARCH)))
|
|
30
|
+
ZSTD_ASM_SRC := $(ZSTD_DIR)/decompress/huf_decompress_amd64.S
|
|
31
|
+
ifneq (,$(wildcard $(ZSTD_ASM_SRC)))
|
|
32
|
+
ZSTD_ASM_OBJ := $(ZSTD_OBJ_DIR)/decompress/huf_decompress_amd64.o
|
|
33
|
+
endif
|
|
34
|
+
endif
|
|
35
|
+
|
|
36
|
+
ZSTD_OBJS := $(ZSTD_C_OBJS) $(ZSTD_ASM_OBJ)
|
|
37
|
+
ZSTD_LIB := $(BUILD)/libzstd_dec.a
|
|
38
|
+
UDF_SO := $(BUILD)/multi_compress_mysql.so
|
|
39
|
+
CLI := $(BUILD)/mcdb_cli
|
|
40
|
+
VALID_FIXTURES := utf8_text empty_text large_text
|
|
41
|
+
CORRUPT_FIXTURES := corrupt_magic wrong_version corrupt_payload corrupt_crc invalid_utf8 nul_text trailing_skippable trailing_frame
|
|
42
|
+
|
|
43
|
+
.PHONY: all udf test fixtures clean
|
|
44
|
+
all: udf
|
|
45
|
+
|
|
46
|
+
$(BUILD):
|
|
47
|
+
mkdir -p $@
|
|
48
|
+
|
|
49
|
+
$(ZSTD_OBJ_DIR)/%.o: $(ZSTD_DIR)/%.c
|
|
50
|
+
@mkdir -p $(dir $@)
|
|
51
|
+
$(CC) $(ZSTD_CFLAGS) -MMD -MP -c $< -o $@
|
|
52
|
+
|
|
53
|
+
$(ZSTD_OBJ_DIR)/%.o: $(ZSTD_DIR)/%.S
|
|
54
|
+
@mkdir -p $(dir $@)
|
|
55
|
+
$(CC) -fPIC -I$(ZSTD_DIR) -c $< -o $@
|
|
56
|
+
|
|
57
|
+
$(ZSTD_LIB): $(ZSTD_OBJS) | $(BUILD)
|
|
58
|
+
$(AR) rcs $@ $^
|
|
59
|
+
|
|
60
|
+
$(UDF_SO): src/multi_compress_mysql.c $(CORE_DIR)/src/mcdb_format.c $(CORE_DIR)/include/mcdb_format.h $(ZSTD_LIB) | $(BUILD)
|
|
61
|
+
@$(MYSQL_UDF_HEADER_CHECK)
|
|
62
|
+
$(CC) $(CFLAGS) $(MYSQL_UDF_CPPFLAGS) -shared \
|
|
63
|
+
src/multi_compress_mysql.c $(CORE_DIR)/src/mcdb_format.c $(ZSTD_LIB) \
|
|
64
|
+
-o $@
|
|
65
|
+
@echo "built $@"
|
|
66
|
+
|
|
67
|
+
udf: $(UDF_SO)
|
|
68
|
+
|
|
69
|
+
$(CLI): $(CORE_DIR)/src/mcdb_format.c $(CORE_DIR)/include/mcdb_format.h test/mcdb_cli.c $(ZSTD_LIB) | $(BUILD)
|
|
70
|
+
$(CC) $(CFLAGS) $(CORE_DIR)/src/mcdb_format.c test/mcdb_cli.c $(ZSTD_LIB) -o $@
|
|
71
|
+
|
|
72
|
+
fixtures:
|
|
73
|
+
RUBYLIB=../lib ruby $(FIXTURES)/generate.rb
|
|
74
|
+
|
|
75
|
+
test: $(CLI)
|
|
76
|
+
@echo "== MCDB1 Ruby<->C parity =="
|
|
77
|
+
@fail=0; \
|
|
78
|
+
for name in $(VALID_FIXTURES); do \
|
|
79
|
+
f=$(FIXTURES)/$$name.mcdb; exp=$(FIXTURES)/$$name.expected; \
|
|
80
|
+
if [ ! -f $$f ] || [ ! -f $$exp ]; then echo " MISSING $$name (run: make fixtures)"; fail=1; continue; fi; \
|
|
81
|
+
if $(CLI) decode $$f | cmp -s - $$exp; then echo " valid $$name OK"; \
|
|
82
|
+
else echo " valid $$name FAIL"; fail=1; fi; \
|
|
83
|
+
done; \
|
|
84
|
+
for name in $(CORRUPT_FIXTURES); do \
|
|
85
|
+
f=$(FIXTURES)/$$name.mcdb; \
|
|
86
|
+
if [ ! -f $$f ]; then echo " MISSING $$name (run: make fixtures)"; fail=1; continue; fi; \
|
|
87
|
+
if $(CLI) decode $$f >/dev/null 2>&1; then echo " corrupt $$name UNEXPECTEDLY VALID"; fail=1; \
|
|
88
|
+
else echo " corrupt $$name correctly rejected"; fi; \
|
|
89
|
+
done; \
|
|
90
|
+
if [ $$fail -ne 0 ]; then echo "PARITY FAILED"; exit 1; fi; \
|
|
91
|
+
echo "PARITY OK"
|
|
92
|
+
|
|
93
|
+
clean:
|
|
94
|
+
rm -rf $(BUILD)
|
|
95
|
+
|
|
96
|
+
-include $(ZSTD_C_OBJS:.o=.d)
|