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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/GET_STARTED.md +207 -0
- data/README.md +69 -0
- data/db_core/include/mcdb_format.h +113 -0
- data/db_core/src/mcdb_format.c +594 -0
- data/db_deployment/mysql/Makefile +48 -0
- data/db_deployment/mysql/README.md +61 -0
- data/db_deployment/mysql/bin/common +212 -0
- data/db_deployment/mysql/bin/disable +40 -0
- data/db_deployment/mysql/bin/doctor +64 -0
- data/db_deployment/mysql/bin/enable +61 -0
- data/db_deployment/mysql/bin/install +64 -0
- data/db_deployment/mysql/bin/status +67 -0
- data/db_deployment/mysql/bin/uninstall +47 -0
- data/db_deployment/mysql/bin/upgrade +127 -0
- data/db_deployment/mysql/bin/verify +14 -0
- data/db_deployment/postgres/Makefile +45 -0
- data/db_deployment/postgres/README.md +84 -0
- data/db_deployment/postgres/bin/doctor +61 -0
- data/db_deployment/postgres/bin/enable +138 -0
- data/db_deployment/postgres/bin/install +106 -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/docs/database-envelope-v2.md +147 -0
- data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
- data/exe/multi_compress +228 -0
- data/ext/multi_compress/extconf.rb +4 -0
- data/ext/multi_compress/multi_compress.c +62 -0
- data/lib/multi_compress/active_record.rb +61 -0
- data/lib/multi_compress/codec.rb +164 -0
- data/lib/multi_compress/database.rb +312 -0
- data/lib/multi_compress/db_deployment.rb +868 -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 +140 -0
- data/mysql_udf/sql/examples.sql +16 -0
- data/mysql_udf/sql/install.sql +39 -0
- data/mysql_udf/sql/uninstall.sql +10 -0
- data/mysql_udf/sql/views.sql +20 -0
- data/mysql_udf/src/multi_compress_mysql.c +472 -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 +414 -0
- data/postgres_extension/Makefile +74 -0
- data/postgres_extension/README.md +160 -0
- data/postgres_extension/multi_compress.control +4 -0
- data/postgres_extension/multi_compress_pg.exports +24 -0
- data/postgres_extension/sql/examples.sql +23 -0
- data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
- data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
- data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -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 +381 -0
- data/postgres_extension/test/run_e2e.sh +411 -0
- 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.
|
data/exe/multi_compress
ADDED
|
@@ -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
|