multi_compress 0.3.5 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/GET_STARTED.md +207 -0
  4. data/README.md +69 -0
  5. data/db_core/include/mcdb_format.h +113 -0
  6. data/db_core/src/mcdb_format.c +594 -0
  7. data/db_deployment/mysql/Makefile +48 -0
  8. data/db_deployment/mysql/README.md +61 -0
  9. data/db_deployment/mysql/bin/common +212 -0
  10. data/db_deployment/mysql/bin/disable +40 -0
  11. data/db_deployment/mysql/bin/doctor +64 -0
  12. data/db_deployment/mysql/bin/enable +61 -0
  13. data/db_deployment/mysql/bin/install +64 -0
  14. data/db_deployment/mysql/bin/status +67 -0
  15. data/db_deployment/mysql/bin/uninstall +47 -0
  16. data/db_deployment/mysql/bin/upgrade +127 -0
  17. data/db_deployment/mysql/bin/verify +14 -0
  18. data/db_deployment/postgres/Makefile +45 -0
  19. data/db_deployment/postgres/README.md +84 -0
  20. data/db_deployment/postgres/bin/doctor +61 -0
  21. data/db_deployment/postgres/bin/enable +138 -0
  22. data/db_deployment/postgres/bin/install +106 -0
  23. data/db_deployment/postgres/bin/uninstall +54 -0
  24. data/db_deployment/postgres/bin/verify +14 -0
  25. data/docs/database-envelope-v1.md +159 -0
  26. data/docs/database-envelope-v2.md +147 -0
  27. data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
  28. data/exe/multi_compress +228 -0
  29. data/ext/multi_compress/extconf.rb +4 -0
  30. data/ext/multi_compress/multi_compress.c +62 -0
  31. data/lib/multi_compress/active_record.rb +61 -0
  32. data/lib/multi_compress/codec.rb +164 -0
  33. data/lib/multi_compress/database.rb +312 -0
  34. data/lib/multi_compress/db_deployment.rb +868 -0
  35. data/lib/multi_compress/version.rb +1 -1
  36. data/multi_compress.gemspec +55 -0
  37. data/mysql_udf/Makefile +96 -0
  38. data/mysql_udf/README.md +140 -0
  39. data/mysql_udf/sql/examples.sql +16 -0
  40. data/mysql_udf/sql/install.sql +39 -0
  41. data/mysql_udf/sql/uninstall.sql +10 -0
  42. data/mysql_udf/sql/views.sql +20 -0
  43. data/mysql_udf/src/multi_compress_mysql.c +472 -0
  44. data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
  45. data/mysql_udf/test/mcdb_cli.c +60 -0
  46. data/mysql_udf/test/run_e2e.sh +414 -0
  47. data/postgres_extension/Makefile +74 -0
  48. data/postgres_extension/README.md +160 -0
  49. data/postgres_extension/multi_compress.control +4 -0
  50. data/postgres_extension/multi_compress_pg.exports +24 -0
  51. data/postgres_extension/sql/examples.sql +23 -0
  52. data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
  53. data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
  54. data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
  55. data/postgres_extension/sql/uninstall.sql +2 -0
  56. data/postgres_extension/sql/views.sql +7 -0
  57. data/postgres_extension/src/mcdb_format.c +2 -0
  58. data/postgres_extension/src/multi_compress_pg.c +381 -0
  59. data/postgres_extension/test/run_e2e.sh +411 -0
  60. metadata +56 -3
@@ -0,0 +1,147 @@
1
+ # MCDB2 database envelope (dictionary-backed, version 2)
2
+
3
+ MCDB2 is an opt-in, dictionary-backed companion to [MCDB1](database-envelope-v1.md).
4
+ It exists for a **single homogeneous database column** whose values share a stable
5
+ text/JSON shape. Every MCDB2 value requires the exact immutable dictionary version
6
+ recorded with it. MCDB1 remains the default for dictionary-free, heterogeneous, or
7
+ large values.
8
+
9
+ MCDB2 is compression, not encryption. Its CRC-32 detects accidental corruption; it
10
+ does not authenticate data.
11
+
12
+ ## Wire format
13
+
14
+ All integer fields are little-endian.
15
+
16
+ | Offset | Size | Field | Required value |
17
+ |---:|---:|---|---|
18
+ | 0 | 4 | magic | ASCII `MCDB` |
19
+ | 4 | 1 | format version | `0x02` |
20
+ | 5 | 1 | codec | `0x01` (zstd) |
21
+ | 6 | 1 | reserved flags | `0x00` |
22
+ | 7 | 8 | original UTF-8 byte size | `0..16 MiB` |
23
+ | 15 | 4 | CRC-32 | IEEE/zlib CRC of plaintext |
24
+ | 19 | 8 | `dictionary_ref` | application registry id, `1..INT64_MAX` |
25
+ | 27 | ... | zstd frame | exactly one frame with a non-zero zstd DictID |
26
+
27
+ The maximum stored envelope is 16,842,779 bytes (`27 + ZSTD_compressBound(16 MiB)`).
28
+
29
+ `dictionary_ref` is **not** the zstd DictID. It is a signed-bigint-compatible,
30
+ application-level immutable primary key. The zstd frame contains the zstd DictID;
31
+ readers require that it equals `ZSTD_getDictID_fromDict(dictionary_bytes)`.
32
+
33
+ ## Writer requirements
34
+
35
+ A writer MUST:
36
+
37
+ 1. accept only valid UTF-8 text without NUL bytes and at most 16 MiB;
38
+ 2. use a conformant zstd dictionary no larger than 256 KiB with non-zero DictID;
39
+ 3. use a positive registry id at most `INT64_MAX`;
40
+ 4. emit a zstd frame through a prepared CDict so its DictID and content size are set;
41
+ 5. emit the 27-byte MCDB2 header followed by exactly that frame.
42
+
43
+ Ruby API:
44
+
45
+ ```ruby
46
+ samples = historical_payloads
47
+
48
+ dictionary = MultiCompress::Database::Dictionary.train(
49
+ samples,
50
+ id: 42,
51
+ size: 32 * 1024
52
+ )
53
+
54
+ blob = MultiCompress::Database.compress(payload_json, dictionary: dictionary)
55
+ ```
56
+
57
+ ## Reader requirements
58
+
59
+ The supported SQL ABI has four arguments:
60
+
61
+ ```sql
62
+ multi_compress_db_decompress_dict(
63
+ blob,
64
+ dictionary_id,
65
+ dictionary_sha256,
66
+ dictionary_bytes
67
+ )
68
+ ```
69
+
70
+ The reader rejects any value unless all checks hold:
71
+
72
+ 1. the MCDB2 header is valid and `header.dictionary_ref == dictionary_id`;
73
+ 2. `dictionary_sha256` is 32 bytes and equals SHA-256 of `dictionary_bytes`;
74
+ 3. dictionary bytes are a conformant zstd dictionary with a non-zero DictID;
75
+ 4. the frame has exactly one zstd frame and its DictID equals the supplied dictionary's zstd DictID;
76
+ 5. the frame content size equals the header size; decompression, CRC-32 and UTF-8/NUL checks pass.
77
+
78
+ `multi_compress_db_is_valid_dict(...)` returns false/0 rather than raising for an
79
+ invalid non-NULL input. PostgreSQL `decompress_dict` raises a SQL error; MySQL 5.7
80
+ returns SQL `NULL`, matching the MCDB1 UDF policy.
81
+
82
+ ## Dictionary registry
83
+
84
+ Dictionary bytes are **application data**, not part of the DBA reader bundle.
85
+ Store them in an append-only registry table in the application database. A rotation
86
+ creates a new version row and changes only a movable `dictionary_heads` pointer;
87
+ old rows and old payloads continue to reference their original version.
88
+
89
+ Generate starter DDL:
90
+
91
+ ```bash
92
+ multi_compress db registry postgres \
93
+ --schema app --owner mcdb_dictionary_owner \
94
+ --migration-role app_migrations \
95
+ --payload-table events --payload-column payload_compressed \
96
+ --payload-dictionary-id-column payload_dictionary_id \
97
+ > db/mcdb_dictionary_registry.sql
98
+
99
+ multi_compress db registry mysql --database app \
100
+ --payload-table events --payload-column payload_compressed \
101
+ --payload-dictionary-id-column payload_dictionary_id \
102
+ > db/mcdb_dictionary_registry.sql
103
+ ```
104
+
105
+ The registry DDL validates id/hash/zstd-ID/size at insert time and rejects updates
106
+ and deletes. When the three `--payload-*` options are supplied, it also creates the
107
+ payload FK and a trigger requiring `header.dictionary_ref == payload_dictionary_id`.
108
+ For PostgreSQL it grants `USAGE` on the application schema to the dedicated NOLOGIN
109
+ registry owner: internal FK checks run with relation-owner privileges and require
110
+ that schema privilege. Backups, replication and restores therefore carry dictionaries
111
+ with the compressed rows that require them.
112
+
113
+ ## Readable views
114
+
115
+ Generate an MCDB2 view with an INNER JOIN. MySQL 5.7 output uses a source-first
116
+ `STRAIGHT_JOIN` internally so an indexed source predicate does not begin from the
117
+ registry. A MySQL 5.7 `EXPLAIN` may still report `Using temporary; Using filesort`
118
+ for an outer `ORDER BY` over the joined projection; this is sorting, not view
119
+ materialization. The view must expose no raw compressed column or dictionary bytes
120
+ to DBeaver users:
121
+
122
+ ```bash
123
+ multi_compress db view postgres \
124
+ --table app.events --column payload_compressed \
125
+ --dictionary-table app.mcdb_dictionary_versions \
126
+ --dictionary-id-column payload_dictionary_id \
127
+ --view admin.events_readable --columns id,created_at,status --as payload
128
+ ```
129
+
130
+ For MySQL 5.7 the generator emits `ALGORITHM=MERGE SQL SECURITY DEFINER`. Do not
131
+ add `DISTINCT`, aggregates, `GROUP BY`, `UNION`, subqueries, or an inner `LIMIT` to
132
+ the generated view; they can force materialization and make decoding happen before
133
+ an outer filter/limit.
134
+
135
+ Filter and order only by ordinary indexed columns. Keep decoding in the SELECT list.
136
+ Use `EXPLAIN` for production query shapes such as `WHERE id >= ... ORDER BY id LIMIT 100`.
137
+
138
+ ## Rollout and rollback
139
+
140
+ 1. Install 0.6 readers on every DB reader host before writing an MCDB2 row.
141
+ 2. PostgreSQL: run `ALTER EXTENSION multi_compress UPDATE`; MySQL: use the bundle's
142
+ controlled UDF upgrade flow, which preserves both MCDB1 and MCDB2 functions.
143
+ 3. Create registry, registry validation/freeze triggers, source FK, and readable view.
144
+ 4. Train/register a dictionary and enable the MCDB2 writer for its new column.
145
+
146
+ After the first MCDB2 write, a reader at 0.5.x cannot read that row. Roll back the
147
+ application writer first; retain 0.6 readers until all MCDB2 values are retired.
@@ -0,0 +1,75 @@
1
+ # RFC 0002: MCDB2 dictionary-backed SQL-readable database envelopes
2
+
3
+ **Status:** accepted for MultiCompress 0.6.0
4
+
5
+ ## Decision
6
+
7
+ Introduce MCDB2 as an opt-in storage format for columns containing many small,
8
+ homogeneous UTF-8 documents. MCDB2 uses a zstd dictionary and remains readable from
9
+ Ruby, PostgreSQL and MySQL 5.7. MCDB1 is unchanged and remains dictionary-free.
10
+
11
+ ## Non-goals
12
+
13
+ - No adaptive mixing of MCDB1 and MCDB2 inside one newly designed column.
14
+ - No dictionary files in `db package` archives.
15
+ - No session registration, global process registry, filesystem lookup, SPI lookup,
16
+ or implicit dictionary lookup inside the native reader.
17
+ - No content search on decoded payloads.
18
+
19
+ ## Design
20
+
21
+ MCDB2 uses the 27-byte format specified in `database-envelope-v2.md`. Its registry
22
+ is application-owned, append-only data. Payload rows hold a NOT NULL FK to one
23
+ registry version, and a generated trigger rejects a row unless its MCDB2 header
24
+ `dictionary_ref` equals that FK. Generated views JOIN that version and pass all four
25
+ dictionary arguments to a pure native decoder.
26
+
27
+ ```sql
28
+ SELECT e.id,
29
+ multi_compress_db_decompress_dict(
30
+ e.payload_compressed, e.payload_dictionary_id, d.sha256, d.bytes
31
+ ) AS payload
32
+ FROM app.events e
33
+ JOIN app.mcdb_dictionary_versions d ON d.id = e.payload_dictionary_id;
34
+ ```
35
+
36
+ The function cache is a bounded LRU implementation detail, not a source of data.
37
+ Its key is `dictionary_id + dictionary_sha256`; cache hits also compare canonical
38
+ stored bytes before reuse. This preserves result correctness for direct calls with
39
+ arbitrary SQL arguments.
40
+
41
+ ## Access model
42
+
43
+ The view owner has access to the source table and dictionary registry. DBeaver users
44
+ receive only `SELECT` on the view, never source-table or registry-table access.
45
+
46
+ PostgreSQL needs `EXECUTE` on decoder functions for a role selecting the view; grant
47
+ that deliberately to the read role, revoke from PUBLIC, and do not treat the SQL ABI
48
+ as a public arbitrary-dictionary API. MySQL views use a stable `SQL SECURITY DEFINER`
49
+ account; include that account in backup/restore checks.
50
+
51
+ ## Registry invariants
52
+
53
+ - version rows are INSERT-only under a dedicated NOLOGIN owner in PostgreSQL;
54
+ - `id > 0`, `id <= INT64_MAX` for cross-DB compatibility;
55
+ - SHA-256 matches bytes on INSERT;
56
+ - zstd DictID is non-zero and matches bytes on INSERT;
57
+ - dictionary bytes are at most 256 KiB;
58
+ - payload FK deletion is restricted; dictionary bytes are never updated in place.
59
+
60
+ ## Performance contract
61
+
62
+ The decoder is intentionally in the projection, after a selective indexed predicate.
63
+ CI validates PostgreSQL and MySQL query plans for `WHERE id >= ... ORDER BY id LIMIT
64
+ 10/100/500`. MySQL's generated MCDB2 view uses a source-first `STRAIGHT_JOIN`; the
65
+ plan must remain MERGE-able and read the source through a selective range/ref access
66
+ before dictionary lookup, with no warning or `<derived>` row. MySQL 5.7 may use a
67
+ temporary/filesort implementation for the outer `ORDER BY` over a joined projection;
68
+ that does not mean the view itself was materialized. Benchmark acceptance is separate
69
+ from timing-sensitive CI: it uses a time-split corpus, warm/cold runs, and concurrent
70
+ sessions.
71
+
72
+ An optional `multi_compress_db_original_size(blob)` helper can support size-aware
73
+ administrative queries. PostgreSQL may use it in a stored generated column; MySQL
74
+ 5.7 must use an ordinary maintained column because loadable UDFs are not supported
75
+ in generated columns.
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "securerandom"
6
+
7
+ if ARGV.first == "db"
8
+ require "multi_compress/db_deployment"
9
+ exit MultiCompress::DBDeployment::CLI.run(ARGV.drop(1))
10
+ end
11
+
12
+ require "multi_compress"
13
+
14
+ module MultiCompress
15
+ class CLI
16
+ EXT = { zstd: ".zst", lz4: ".mclz4", brotli: ".br" }.freeze
17
+ KNOWN_EXT = { ".zst" => :zstd, ".zstd" => :zstd, ".mclz4" => :lz4, ".br" => :brotli }.freeze
18
+ NAMED_LEVELS = %w[fastest default best].freeze
19
+ READ_CHUNK = 256 * 1024
20
+ MAX_OUTPUT_LIMIT = (2**(1.size * 8 - 1)) - 1
21
+
22
+ def self.run(argv)
23
+ new.run(argv)
24
+ end
25
+
26
+ def run(argv)
27
+ opts = parse(argv)
28
+ files = opts.delete(:files)
29
+
30
+ validate_invocation!(files, opts)
31
+
32
+ if files.empty?
33
+ process_stream($stdin, $stdout, opts)
34
+ else
35
+ files.each { |path| process_file(path, opts) }
36
+ end
37
+ 0
38
+ rescue OptionParser::ParseError => e
39
+ warn "multi_compress: #{e.message}"
40
+ 2
41
+ rescue MultiCompress::Error, SystemCallError, IOError, ArgumentError, RangeError => e
42
+ warn "multi_compress: #{e.message}"
43
+ 1
44
+ end
45
+
46
+ private
47
+
48
+ def parse(argv)
49
+ o = { decompress: false, keep: false, force: false, stdout: false, quiet: false }
50
+
51
+ @parser = OptionParser.new do |p|
52
+ p.banner = "Usage: multi_compress [OPTIONS] [FILE...]"
53
+ p.separator ""
54
+ p.on("-a", "--algo NAME", %w[zstd lz4 brotli], "zstd | lz4 | brotli (default: by extension, else zstd)") { |v| o[:algo] = v.to_sym }
55
+ p.on("-l", "--level LEVEL", "number, or: fastest | default | best") { |v| o[:level] = parse_level(v) }
56
+ p.on("-d", "--decompress", "decompress instead of compress") { o[:decompress] = true }
57
+ p.on("-o", "--output PATH", "write result to PATH (single input only)") { |v| o[:output] = v }
58
+ p.on("-c", "--stdout", "write to stdout, keep input") { o[:stdout] = true }
59
+ p.on("-k", "--keep", "keep input file (default: remove on success)") { o[:keep] = true }
60
+ p.on("-f", "--force", "overwrite existing output") { o[:force] = true }
61
+ p.on("-q", "--quiet", "no progress line") { o[:quiet] = true }
62
+ p.on("--max-output SIZE", "cap decompressed size, e.g. 64MB") { |v| o[:max_output_size] = parse_size(v) }
63
+ p.on("--version", "print gem + library versions") do
64
+ puts "multi_compress #{MultiCompress::VERSION} (zstd #{MultiCompress.version(:zstd)}, " \
65
+ "lz4 #{MultiCompress.version(:lz4)}, brotli #{MultiCompress.version(:brotli)})"
66
+ exit 0
67
+ end
68
+ p.on("-h", "--help") { puts p; exit 0 }
69
+ end
70
+
71
+ o[:files] = @parser.parse(argv)
72
+ o
73
+ end
74
+
75
+ def parse_level(v)
76
+ return v.to_sym if NAMED_LEVELS.include?(v)
77
+ Integer(v)
78
+ rescue ArgumentError
79
+ raise OptionParser::InvalidArgument, "invalid level: #{v}"
80
+ end
81
+
82
+ def parse_size(v)
83
+ m = v.strip.match(/\A(\d+)\s*([kmg]?)b?\z/i) or
84
+ raise OptionParser::InvalidArgument, "invalid size: #{v}"
85
+ mult = { "" => 1, "k" => 1024, "m" => 1024**2, "g" => 1024**3 }
86
+ bytes = m[1].to_i * mult[m[2].downcase]
87
+ if bytes <= 0
88
+ raise OptionParser::InvalidArgument, "max-output must be greater than zero: #{v}"
89
+ end
90
+ if bytes > MAX_OUTPUT_LIMIT
91
+ raise OptionParser::InvalidArgument, "max-output too large (limit #{MAX_OUTPUT_LIMIT} bytes): #{v}"
92
+ end
93
+ bytes
94
+ end
95
+
96
+ def validate_invocation!(files, opts)
97
+ return if opts[:output].nil?
98
+
99
+ if opts[:stdout]
100
+ raise OptionParser::InvalidOption, "-o and -c are mutually exclusive"
101
+ end
102
+ if files.size > 1
103
+ raise ArgumentError, "-o cannot be used with multiple input files (would overwrite the same output)"
104
+ end
105
+ end
106
+
107
+ def process_file(path, opts)
108
+ raise Errno::ENOENT, path unless File.exist?(path)
109
+
110
+ if opts[:stdout]
111
+ File.open(path, "rb") { |input| run_pipeline(input, $stdout, resolve_algo(path, opts), opts) }
112
+ return
113
+ end
114
+
115
+ out_path = opts[:output] || default_out_path(path, opts)
116
+ guard_paths!(path, out_path, opts)
117
+
118
+ in_size = File.size(path)
119
+ started = Time.now
120
+ write_atomically(out_path) do |tmp|
121
+ File.open(path, "rb") { |input| run_pipeline(input, tmp, resolve_algo(path, opts), opts) }
122
+ end
123
+
124
+ report(path, out_path, in_size, File.size(out_path), started, opts)
125
+ File.delete(path) unless opts[:keep]
126
+ end
127
+
128
+ def process_stream(input, output, opts)
129
+ run_pipeline(input, output, opts[:algo] || :zstd, opts)
130
+ end
131
+
132
+ def run_pipeline(input, output, algo, opts)
133
+ if opts[:decompress]
134
+ reader_opts = {}
135
+ reader_opts[:max_output_size] = opts[:max_output_size] if opts[:max_output_size]
136
+ reader = MultiCompress::Reader.new(input, algo: algo, **reader_opts)
137
+ while (chunk = reader.read(READ_CHUNK))
138
+ output.write(chunk)
139
+ end
140
+ reader.close
141
+ else
142
+ writer = MultiCompress::Writer.new(output, algo: algo, level: opts[:level])
143
+ while (chunk = input.read(READ_CHUNK))
144
+ writer.write(chunk)
145
+ end
146
+ writer.close
147
+ end
148
+ end
149
+
150
+ def write_atomically(dst)
151
+ dir = File.dirname(dst)
152
+ tmp = unique_temp(dir)
153
+ file = File.open(tmp, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o644)
154
+ begin
155
+ yield file
156
+ file.flush
157
+ file.fsync
158
+ file.close
159
+ File.rename(tmp, dst)
160
+ fsync_dir(dir)
161
+ rescue StandardError
162
+ file.close unless file.closed?
163
+ File.delete(tmp) if File.exist?(tmp)
164
+ raise
165
+ end
166
+ end
167
+
168
+ def unique_temp(dir)
169
+ loop do
170
+ candidate = File.join(dir, ".multi_compress.#{Process.pid}.#{SecureRandom.hex(8)}.tmp")
171
+ return candidate unless File.exist?(candidate)
172
+ end
173
+ end
174
+
175
+ def fsync_dir(dir)
176
+ d = File.open(dir)
177
+ begin
178
+ d.fsync
179
+ rescue NotImplementedError, Errno::EINVAL, Errno::EACCES
180
+ # some platforms/filesystems can't fsync a directory; best-effort
181
+ ensure
182
+ d.close
183
+ end
184
+ end
185
+
186
+ def guard_paths!(src, dst, opts)
187
+ if same_file?(src, dst)
188
+ raise ArgumentError, "input and output are the same file: #{src}"
189
+ end
190
+ if File.exist?(dst) && !opts[:force]
191
+ raise ArgumentError, "#{dst} already exists (use -f to overwrite)"
192
+ end
193
+ end
194
+
195
+ def same_file?(a, b)
196
+ return true if File.expand_path(a) == File.expand_path(b)
197
+ File.exist?(a) && File.exist?(b) && File.identical?(a, b)
198
+ end
199
+
200
+ def resolve_algo(path, opts)
201
+ opts[:algo] || KNOWN_EXT[File.extname(path).downcase] || :zstd
202
+ end
203
+
204
+ def default_out_path(path, opts)
205
+ if opts[:decompress]
206
+ stripped = strip_known_ext(path)
207
+ stripped == path ? "#{path}.out" : stripped
208
+ else
209
+ "#{path}#{EXT.fetch(resolve_algo(path, opts))}"
210
+ end
211
+ end
212
+
213
+ def strip_known_ext(path)
214
+ ext = File.extname(path).downcase
215
+ KNOWN_EXT.key?(ext) ? path[0...-ext.length] : path
216
+ end
217
+
218
+ def report(src, dst, in_size, out_size, started, opts)
219
+ return if opts[:quiet]
220
+ pct = in_size.zero? ? 0 : (100.0 * out_size / in_size)
221
+ dir = opts[:decompress] ? "expanded" : "reduced"
222
+ warn format("%s -> %s %d -> %d bytes (%s to %.1f%%, %.2fs)",
223
+ src, dst, in_size, out_size, dir, pct, Time.now - started)
224
+ end
225
+ end
226
+ end
227
+
228
+ exit MultiCompress::CLI.run(ARGV)
@@ -253,6 +253,10 @@ end
253
253
  $CFLAGS += " -O3"
254
254
  $CFLAGS += " -DXXH_NAMESPACE=MULTICOMPRESS_"
255
255
 
256
+ if RUBY_PLATFORM.include?("darwin") && try_cflags("-Wno-default-const-init-field-unsafe")
257
+ $CFLAGS += " -Wno-default-const-init-field-unsafe"
258
+ end
259
+
256
260
  case RUBY_PLATFORM
257
261
  when /x86_64|amd64|aarch64|arm64/
258
262
  $CFLAGS += " -DBROTLI_BUILD_LITTLE_ENDIAN"
@@ -1560,6 +1560,49 @@ static VALUE compress_compress(int argc, VALUE *argv, VALUE self) {
1560
1560
  return Qnil;
1561
1561
  }
1562
1562
 
1563
+ static VALUE zstd_frame_dictionary_id(VALUE self, VALUE data) {
1564
+ (void)self;
1565
+ StringValue(data);
1566
+
1567
+ const void *src = RSTRING_PTR(data);
1568
+ size_t src_len = (size_t)RSTRING_LEN(data);
1569
+ size_t frame_size = ZSTD_findFrameCompressedSize(src, src_len);
1570
+ if (ZSTD_isError(frame_size) || frame_size != src_len)
1571
+ rb_raise(eDataError, "zstd: expected exactly one valid frame with no trailing bytes");
1572
+
1573
+ RB_GC_GUARD(data);
1574
+ return UINT2NUM(ZSTD_getDictID_fromFrame(src, src_len));
1575
+ }
1576
+
1577
+ static VALUE zstd_single_frame_info(VALUE self, VALUE data) {
1578
+ (void)self;
1579
+ StringValue(data);
1580
+
1581
+ const void *src = RSTRING_PTR(data);
1582
+ size_t src_len = (size_t)RSTRING_LEN(data);
1583
+ size_t frame_size = ZSTD_findFrameCompressedSize(src, src_len);
1584
+ if (ZSTD_isError(frame_size)) {
1585
+ rb_raise(eDataError, "zstd: invalid frame: %s", ZSTD_getErrorName(frame_size));
1586
+ }
1587
+ if (frame_size != src_len) {
1588
+ rb_raise(eDataError, "zstd: expected exactly one frame with no trailing bytes");
1589
+ }
1590
+
1591
+ unsigned long long content_size = ZSTD_getFrameContentSize(src, src_len);
1592
+ if (content_size == ZSTD_CONTENTSIZE_ERROR) {
1593
+ rb_raise(eDataError, "zstd: invalid frame content size");
1594
+ }
1595
+ if (content_size == ZSTD_CONTENTSIZE_UNKNOWN) {
1596
+ rb_raise(eDataError, "zstd: frame must declare content size");
1597
+ }
1598
+
1599
+ VALUE result = rb_ary_new_capa(2);
1600
+ rb_ary_push(result, SIZET2NUM(frame_size));
1601
+ rb_ary_push(result, ULL2NUM(content_size));
1602
+ RB_GC_GUARD(data);
1603
+ return result;
1604
+ }
1605
+
1563
1606
  static VALUE compress_decompress(int argc, VALUE *argv, VALUE self) {
1564
1607
  VALUE data, opts;
1565
1608
  scan_one_required_keywords(argc, argv, &data, &opts);
@@ -3442,6 +3485,20 @@ static VALUE dict_algo(VALUE self) {
3442
3485
  return algo_to_sym(d->algo);
3443
3486
  }
3444
3487
 
3488
+ static VALUE dict_bytes(VALUE self) {
3489
+ dictionary_t *d;
3490
+ TypedData_Get_Struct(self, dictionary_t, &dictionary_type, d);
3491
+ return rb_binary_str_new((const char *)d->data, (long)d->size);
3492
+ }
3493
+
3494
+ static VALUE dict_zstd_id(VALUE self) {
3495
+ dictionary_t *d;
3496
+ TypedData_Get_Struct(self, dictionary_t, &dictionary_type, d);
3497
+ if (d->algo != ALGO_ZSTD)
3498
+ rb_raise(eUnsupportedError, "zstd_id is available only for zstd dictionaries");
3499
+ return UINT2NUM(ZSTD_getDictID_fromDict(d->data, d->size));
3500
+ }
3501
+
3445
3502
  static VALUE dict_size(VALUE self) {
3446
3503
  dictionary_t *d;
3447
3504
  TypedData_Get_Struct(self, dictionary_t, &dictionary_type, d);
@@ -3478,6 +3535,9 @@ void Init_multi_compress(void) {
3478
3535
 
3479
3536
  rb_define_module_function(mMultiCompress, "compress", compress_compress, -1);
3480
3537
  rb_define_module_function(mMultiCompress, "decompress", compress_decompress, -1);
3538
+ rb_define_module_function(mMultiCompress, "zstd_single_frame_info", zstd_single_frame_info, 1);
3539
+ rb_define_module_function(mMultiCompress, "zstd_frame_dictionary_id", zstd_frame_dictionary_id,
3540
+ 1);
3481
3541
  rb_define_module_function(mMultiCompress, "crc32", compress_crc32, -1);
3482
3542
  rb_define_module_function(mMultiCompress, "adler32", compress_adler32, -1);
3483
3543
  rb_define_module_function(mMultiCompress, "algorithms", compress_algorithms, 0);
@@ -3512,6 +3572,8 @@ void Init_multi_compress(void) {
3512
3572
  rb_define_singleton_method(cDictionary, "load", dict_load, -1);
3513
3573
  rb_define_method(cDictionary, "save", dict_save, 1);
3514
3574
  rb_define_method(cDictionary, "algo", dict_algo, 0);
3575
+ rb_define_method(cDictionary, "bytes", dict_bytes, 0);
3576
+ rb_define_method(cDictionary, "zstd_id", dict_zstd_id, 0);
3515
3577
  rb_define_method(cDictionary, "size", dict_size, 0);
3516
3578
  rb_define_singleton_method(mZstd, "train_dictionary", zstd_train_dictionary, -1);
3517
3579
  rb_define_singleton_method(mBrotli, "train_dictionary", brotli_train_dictionary, -1);
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "multi_compress/codec"
4
+
5
+ begin
6
+ require "active_model/type"
7
+ rescue LoadError => e
8
+ raise LoadError, "multi_compress/active_record requires activemodel (#{e.message}). " \
9
+ "Add activerecord/activemodel to your bundle, or use MultiCompress::Codec directly."
10
+ end
11
+
12
+ module MultiCompress
13
+ module ActiveRecordSupport
14
+ class Type < ActiveModel::Type::Value
15
+ def initialize(mutable: false, **codec_opts)
16
+ @codec = MultiCompress::Codec.new(**codec_opts)
17
+ @mutable = mutable
18
+ super()
19
+ end
20
+
21
+ def type
22
+ :multi_compress
23
+ end
24
+
25
+ # user assignment -> in-memory value (no compression here)
26
+ def cast(value)
27
+ value
28
+ end
29
+
30
+ # DB -> in-memory (unwrap)
31
+ def deserialize(value)
32
+ @codec.load(value)
33
+ end
34
+
35
+ # in-memory -> DB (compress)
36
+ def serialize(value)
37
+ @codec.dump(value)
38
+ end
39
+
40
+ def changed_in_place?(raw_old_value, new_value)
41
+ return false unless @mutable
42
+
43
+ deserialize(raw_old_value) != new_value
44
+ end
45
+ end
46
+
47
+ class Coder
48
+ def initialize(**codec_opts)
49
+ @codec = MultiCompress::Codec.new(**codec_opts)
50
+ end
51
+
52
+ def dump(value)
53
+ @codec.dump(value)
54
+ end
55
+
56
+ def load(value)
57
+ @codec.load(value)
58
+ end
59
+ end
60
+ end
61
+ end