multi_compress 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/GET_STARTED.md +19 -0
- data/README.md +14 -1
- data/db_core/include/mcdb_format.h +67 -9
- data/db_core/src/mcdb_format.c +392 -139
- data/db_deployment/mysql/README.md +19 -0
- data/db_deployment/mysql/bin/common +70 -18
- data/db_deployment/mysql/bin/enable +5 -1
- data/db_deployment/mysql/bin/status +2 -0
- data/db_deployment/mysql/bin/upgrade +17 -5
- data/db_deployment/postgres/README.md +24 -0
- data/db_deployment/postgres/bin/enable +2 -0
- data/db_deployment/postgres/bin/install +4 -2
- data/docs/database-envelope-v2.md +147 -0
- data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
- data/ext/multi_compress/multi_compress.c +32 -0
- data/lib/multi_compress/database.rb +255 -49
- data/lib/multi_compress/db_deployment.rb +423 -11
- data/lib/multi_compress/version.rb +1 -1
- data/mysql_udf/README.md +45 -0
- data/mysql_udf/sql/examples.sql +5 -0
- data/mysql_udf/sql/install.sql +25 -1
- data/mysql_udf/sql/uninstall.sql +7 -1
- data/mysql_udf/sql/views.sql +16 -4
- data/mysql_udf/src/multi_compress_mysql.c +383 -44
- data/mysql_udf/test/run_e2e.sh +119 -1
- data/postgres_extension/Makefile +2 -2
- data/postgres_extension/README.md +48 -0
- data/postgres_extension/multi_compress.control +2 -2
- data/postgres_extension/multi_compress_pg.exports +12 -0
- data/postgres_extension/sql/examples.sql +11 -0
- data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
- data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
- data/postgres_extension/src/multi_compress_pg.c +301 -16
- data/postgres_extension/test/run_e2e.sh +90 -2
- metadata +6 -2
|
@@ -40,3 +40,22 @@ correctly. Filter by indexed, uncompressed columns before selecting through it.
|
|
|
40
40
|
`make verify` catches accidental corruption after extraction; it does not
|
|
41
41
|
authenticate an archive. The mysql client uses `--max_allowed_packet=32M`, but
|
|
42
42
|
mysqld, the Ruby driver and DBeaver/JDBC must each allow the 16 MiB MCDB1 limit.
|
|
43
|
+
|
|
44
|
+
## MCDB2 after reader installation
|
|
45
|
+
|
|
46
|
+
After the 0.6 UDF is installed/enabled, the application owns dictionaries:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
multi_compress db registry mysql --database app
|
|
50
|
+
multi_compress db view mysql \
|
|
51
|
+
--table app.events --column payload_compressed \
|
|
52
|
+
--dictionary-table app.mcdb_dictionary_versions \
|
|
53
|
+
--dictionary-id-column payload_dictionary_id \
|
|
54
|
+
--view admin.events_readable --columns id,created_at,status
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Do not put dictionary files in this bundle. MCDB2 dictionary bytes are immutable
|
|
58
|
+
application records that must travel with backups and replication. Keep generated
|
|
59
|
+
MySQL views MERGE-able and verify `EXPLAIN` before granting DBeaver access.
|
|
60
|
+
|
|
61
|
+
For MCDB2 registry DDL, pass `--payload-table`, `--payload-column`, and `--payload-dictionary-id-column` to `multi_compress db registry`; this creates the payload FK and enforces header dictionary-reference consistency.
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
|
|
3
3
|
MCDB_UDF_LIBRARY='multi_compress_mysql.so'
|
|
4
|
+
MCDB_V1_UDF_NAMES=(
|
|
5
|
+
multi_compress_db_version
|
|
6
|
+
multi_compress_db_is_valid
|
|
7
|
+
multi_compress_db_decompress
|
|
8
|
+
)
|
|
4
9
|
MCDB_EMPTY_ENVELOPE_HEX='4d43444201010000000000000000000000000028b52ffd2000010000'
|
|
5
10
|
MCDB_UDF_NAMES=(
|
|
6
11
|
multi_compress_db_version
|
|
7
12
|
multi_compress_db_is_valid
|
|
8
13
|
multi_compress_db_decompress
|
|
14
|
+
multi_compress_db_original_size
|
|
15
|
+
multi_compress_db_dictionary_ref
|
|
16
|
+
multi_compress_db_dictionary_zstd_id
|
|
17
|
+
multi_compress_db_dictionary_sha256
|
|
18
|
+
multi_compress_db_is_valid_dict
|
|
19
|
+
multi_compress_db_decompress_dict
|
|
9
20
|
)
|
|
10
21
|
|
|
11
22
|
mcdb_die() {
|
|
@@ -83,24 +94,31 @@ mcdb_read_udf_state() {
|
|
|
83
94
|
MCDB_UNEXPECTED_LIBRARY=0
|
|
84
95
|
MCDB_REGISTERED_ROWS=()
|
|
85
96
|
|
|
86
|
-
|
|
97
|
+
local names_sql=""
|
|
98
|
+
local expected=()
|
|
99
|
+
for name in "${MCDB_UDF_NAMES[@]}"; do
|
|
100
|
+
expected+=("'${name}'")
|
|
101
|
+
done
|
|
102
|
+
names_sql="$(IFS=,; printf '%s' "${expected[*]}")"
|
|
103
|
+
|
|
104
|
+
rows="$(mcdb_query "SELECT name, dl FROM mysql.func WHERE dl = '${MCDB_UDF_LIBRARY}' OR name IN (${names_sql}) ORDER BY name;")"
|
|
87
105
|
while IFS=$'\t' read -r name dl; do
|
|
88
106
|
[ -n "$name" ] || continue
|
|
89
107
|
MCDB_REGISTERED_ROWS+=("$name"$'\t'"$dl")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
local expected_name=0
|
|
109
|
+
local candidate
|
|
110
|
+
for candidate in "${MCDB_UDF_NAMES[@]}"; do
|
|
111
|
+
if [ "$candidate" = "$name" ]; then expected_name=1; break; fi
|
|
112
|
+
done
|
|
113
|
+
if [ "$expected_name" -eq 1 ]; then
|
|
114
|
+
if [ "$dl" = "$MCDB_UDF_LIBRARY" ]; then
|
|
115
|
+
MCDB_EXPECTED_OWNED=$((MCDB_EXPECTED_OWNED + 1))
|
|
116
|
+
else
|
|
117
|
+
MCDB_FOREIGN_EXPECTED=$((MCDB_FOREIGN_EXPECTED + 1))
|
|
118
|
+
fi
|
|
119
|
+
elif [ "$dl" = "$MCDB_UDF_LIBRARY" ]; then
|
|
120
|
+
MCDB_UNEXPECTED_LIBRARY=$((MCDB_UNEXPECTED_LIBRARY + 1))
|
|
121
|
+
fi
|
|
104
122
|
done <<< "$rows"
|
|
105
123
|
}
|
|
106
124
|
|
|
@@ -109,7 +127,31 @@ mcdb_state_is_clean_absent() {
|
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
mcdb_state_is_clean_enabled() {
|
|
112
|
-
[ "$MCDB_EXPECTED_OWNED" -eq
|
|
130
|
+
[ "$MCDB_EXPECTED_OWNED" -eq "${#MCDB_UDF_NAMES[@]}" ] && [ "$MCDB_FOREIGN_EXPECTED" -eq 0 ] && [ "$MCDB_UNEXPECTED_LIBRARY" -eq 0 ]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
mcdb_has_owned_registration() {
|
|
134
|
+
local target="$1" registration name dl
|
|
135
|
+
for registration in "${MCDB_REGISTERED_ROWS[@]}"; do
|
|
136
|
+
IFS=$'\t' read -r name dl <<< "$registration"
|
|
137
|
+
[ "$name" = "$target" ] && [ "$dl" = "$MCDB_UDF_LIBRARY" ] && return 0
|
|
138
|
+
done
|
|
139
|
+
return 1
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# A 0.5.x host has only the three MCDB1 UDF registrations. It is a known,
|
|
143
|
+
# upgradeable state, not a partial installation. The 0.6 upgrade replaces the
|
|
144
|
+
# library using MySQL's required DROP -> replace -> CREATE order and registers
|
|
145
|
+
# the full MCDB1/MCDB2 surface.
|
|
146
|
+
mcdb_state_is_clean_v1_enabled() {
|
|
147
|
+
local name
|
|
148
|
+
[ "$MCDB_EXPECTED_OWNED" -eq "${#MCDB_V1_UDF_NAMES[@]}" ] || return 1
|
|
149
|
+
[ "$MCDB_FOREIGN_EXPECTED" -eq 0 ] || return 1
|
|
150
|
+
[ "$MCDB_UNEXPECTED_LIBRARY" -eq 0 ] || return 1
|
|
151
|
+
for name in "${MCDB_V1_UDF_NAMES[@]}"; do
|
|
152
|
+
mcdb_has_owned_registration "$name" || return 1
|
|
153
|
+
done
|
|
154
|
+
return 0
|
|
113
155
|
}
|
|
114
156
|
|
|
115
157
|
mcdb_require_clean_owned_state() {
|
|
@@ -121,17 +163,27 @@ mcdb_require_clean_owned_state() {
|
|
|
121
163
|
fi
|
|
122
164
|
}
|
|
123
165
|
|
|
124
|
-
|
|
166
|
+
mcdb_v1_smoke_check() {
|
|
125
167
|
local version decoded
|
|
126
168
|
version="$(mcdb_query 'SELECT multi_compress_db_version();' | tr -d '\r\n')"
|
|
127
169
|
case "$version" in
|
|
128
170
|
*'MCDB1'*) ;;
|
|
129
|
-
*) mcdb_die "version() smoke check failed: $version" ;;
|
|
171
|
+
*) mcdb_die "MCDB1 version() smoke check failed: $version" ;;
|
|
130
172
|
esac
|
|
131
173
|
decoded="$(mcdb_query "SELECT multi_compress_db_decompress(UNHEX('${MCDB_EMPTY_ENVELOPE_HEX}')) = '';" | tr -d '\r\n')"
|
|
132
174
|
[ "$decoded" = '1' ] || mcdb_die 'decode smoke check failed'
|
|
133
175
|
}
|
|
134
176
|
|
|
177
|
+
mcdb_smoke_check() {
|
|
178
|
+
local version
|
|
179
|
+
mcdb_v1_smoke_check
|
|
180
|
+
version="$(mcdb_query 'SELECT multi_compress_db_version();' | tr -d '\r\n')"
|
|
181
|
+
case "$version" in
|
|
182
|
+
*'MCDB1'*'MCDB2'*) ;;
|
|
183
|
+
*) mcdb_die "MCDB1/MCDB2 version() smoke check failed: $version" ;;
|
|
184
|
+
esac
|
|
185
|
+
}
|
|
186
|
+
|
|
135
187
|
mcdb_drop_owned_udfs() {
|
|
136
188
|
mcdb_read_udf_state
|
|
137
189
|
mcdb_require_clean_owned_state
|
|
@@ -10,7 +10,7 @@ usage() {
|
|
|
10
10
|
cat <<'TEXT'
|
|
11
11
|
Usage: MYSQL_SOCKET=/run/mysqld/mysqld.sock ./bin/enable [--mysql PATH] [--defaults-extra-file PATH]
|
|
12
12
|
|
|
13
|
-
Registers the
|
|
13
|
+
Registers the MultiCompress MCDB1/MCDB2 UDFs once. Existing healthy registrations are
|
|
14
14
|
left untouched; this command never drops functions automatically.
|
|
15
15
|
TEXT
|
|
16
16
|
}
|
|
@@ -53,5 +53,9 @@ if mcdb_state_is_clean_enabled; then
|
|
|
53
53
|
exit 0
|
|
54
54
|
fi
|
|
55
55
|
|
|
56
|
+
if mcdb_state_is_clean_v1_enabled; then
|
|
57
|
+
mcdb_die 'MCDB1-only UDF registrations were found; run make upgrade to install the MCDB2 reader surface'
|
|
58
|
+
fi
|
|
59
|
+
|
|
56
60
|
mcdb_require_clean_owned_state
|
|
57
61
|
mcdb_die 'UDF registrations are partial; run make status and resolve the state before enabling'
|
|
@@ -57,6 +57,8 @@ fi
|
|
|
57
57
|
if mcdb_state_is_clean_enabled; then
|
|
58
58
|
mcdb_smoke_check
|
|
59
59
|
echo 'state: enabled'
|
|
60
|
+
elif mcdb_state_is_clean_v1_enabled; then
|
|
61
|
+
echo 'state: MCDB1-only; run make upgrade before writing MCDB2 values'
|
|
60
62
|
elif mcdb_state_is_clean_absent; then
|
|
61
63
|
echo 'state: absent'
|
|
62
64
|
else
|
|
@@ -44,9 +44,20 @@ mcdb_resolve_mysql
|
|
|
44
44
|
mcdb_mysql_args
|
|
45
45
|
mcdb_read_udf_state
|
|
46
46
|
mcdb_require_clean_owned_state
|
|
47
|
-
|
|
47
|
+
LEGACY_MCDB1_ONLY=0
|
|
48
|
+
if mcdb_state_is_clean_v1_enabled; then
|
|
49
|
+
LEGACY_MCDB1_ONLY=1
|
|
50
|
+
elif ! mcdb_state_is_clean_enabled; then
|
|
51
|
+
mcdb_die 'upgrade requires either a complete MCDB1-only or MCDB1/MCDB2 owned UDF state'
|
|
52
|
+
fi
|
|
48
53
|
CAPTURED_REGISTRATIONS=("${MCDB_REGISTERED_ROWS[@]}")
|
|
49
|
-
|
|
54
|
+
if [ "$LEGACY_MCDB1_ONLY" -eq 1 ]; then
|
|
55
|
+
# A genuine 0.5.x library does not advertise MCDB2 yet. Verify only its
|
|
56
|
+
# MCDB1 surface before the required DROP -> replace -> CREATE transition.
|
|
57
|
+
mcdb_v1_smoke_check
|
|
58
|
+
else
|
|
59
|
+
mcdb_smoke_check
|
|
60
|
+
fi
|
|
50
61
|
|
|
51
62
|
restore_captured_registrations() {
|
|
52
63
|
local registration name dl returns
|
|
@@ -54,8 +65,8 @@ restore_captured_registrations() {
|
|
|
54
65
|
for registration in "${CAPTURED_REGISTRATIONS[@]}"; do
|
|
55
66
|
IFS=$'\t' read -r name dl <<< "$registration"
|
|
56
67
|
case "$name" in
|
|
57
|
-
multi_compress_db_version|multi_compress_db_decompress) returns='STRING' ;;
|
|
58
|
-
multi_compress_db_is_valid) returns='INTEGER' ;;
|
|
68
|
+
multi_compress_db_version|multi_compress_db_decompress|multi_compress_db_dictionary_sha256|multi_compress_db_decompress_dict) returns='STRING' ;;
|
|
69
|
+
multi_compress_db_is_valid|multi_compress_db_original_size|multi_compress_db_dictionary_ref|multi_compress_db_dictionary_zstd_id|multi_compress_db_is_valid_dict) returns='INTEGER' ;;
|
|
59
70
|
*) mcdb_die "captured unexpected UDF registration: $name" ;;
|
|
60
71
|
esac
|
|
61
72
|
mcdb_query "CREATE FUNCTION ${name} RETURNS ${returns} SONAME '${dl}';"
|
|
@@ -89,7 +100,8 @@ fi
|
|
|
89
100
|
|
|
90
101
|
mv -f "$TEMP_LIBRARY" "$LIBRARY"
|
|
91
102
|
if mcdb_register_expected_udfs && mcdb_smoke_check; then
|
|
92
|
-
|
|
103
|
+
mcdb_read_udf_state
|
|
104
|
+
mcdb_state_is_clean_enabled || mcdb_die 'upgrade did not register the complete MCDB1/MCDB2 UDF surface'
|
|
93
105
|
rm -f "$BACKUP"
|
|
94
106
|
RESTORED=1
|
|
95
107
|
echo 'MultiCompress MySQL UDFs upgraded successfully.'
|
|
@@ -58,3 +58,27 @@ columns first; do not use it for unbounded `LIKE '%text%'` searches.
|
|
|
58
58
|
`make verify` catches accidental corruption after extraction; it does not
|
|
59
59
|
authenticate an archive. Use an out-of-band SHA-256, a minisign/GPG signature,
|
|
60
60
|
or signed release provenance for a real trust chain.
|
|
61
|
+
|
|
62
|
+
## MCDB2 after reader installation
|
|
63
|
+
|
|
64
|
+
Reader installation is deliberately separate from application data. Once a 0.6
|
|
65
|
+
reader is installed and `make enable` has run, generate application-owned
|
|
66
|
+
append-only dictionary registry DDL and a dictionary-readable view from the gem:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
multi_compress db registry postgres \
|
|
70
|
+
--schema app --owner mcdb_dictionary_owner --migration-role app_migrations
|
|
71
|
+
|
|
72
|
+
multi_compress db view postgres \
|
|
73
|
+
--table app.events --column payload_compressed \
|
|
74
|
+
--dictionary-table app.mcdb_dictionary_versions \
|
|
75
|
+
--dictionary-id-column payload_dictionary_id \
|
|
76
|
+
--view admin.events_readable --columns id,created_at,status
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Do not place trained dictionaries in this DBA bundle. They belong in the registry
|
|
80
|
+
so backups, replicas and restored databases retain the exact bytes needed by each
|
|
81
|
+
MCDB2 row. The registry generator grants schema `USAGE` to its dedicated NOLOGIN
|
|
82
|
+
owner because PostgreSQL internal FK checks execute with relation-owner privileges.
|
|
83
|
+
|
|
84
|
+
For MCDB2 registry DDL, pass `--payload-table`, `--payload-column`, and `--payload-dictionary-id-column` to `multi_compress db registry`; this creates the payload FK and enforces header dictionary-reference consistency.
|
|
@@ -120,6 +120,8 @@ if [ -z "$EXISTING_SCHEMA" ]; then
|
|
|
120
120
|
CREATE SCHEMA IF NOT EXISTS "$SCHEMA";
|
|
121
121
|
CREATE EXTENSION multi_compress WITH SCHEMA "$SCHEMA";
|
|
122
122
|
SQL
|
|
123
|
+
else
|
|
124
|
+
"$PSQL" "${ARGS[@]}" -c 'ALTER EXTENSION multi_compress UPDATE;'
|
|
123
125
|
fi
|
|
124
126
|
|
|
125
127
|
"$PSQL" "${ARGS[@]}" <<SQL
|
|
@@ -92,13 +92,15 @@ EXTENSION_DIR="$($RUNTIME_PG_CONFIG --sharedir)/extension"
|
|
|
92
92
|
install -d -m 0755 "$PKGLIBDIR" "$EXTENSION_DIR"
|
|
93
93
|
install -m 0755 "$ROOT/postgres_extension/multi_compress_pg.so" "$PKGLIBDIR/multi_compress_pg.so"
|
|
94
94
|
install -m 0644 "$ROOT/postgres_extension/multi_compress.control" "$EXTENSION_DIR/multi_compress.control"
|
|
95
|
-
|
|
95
|
+
for sql in "$ROOT"/postgres_extension/sql/multi_compress--*.sql; do
|
|
96
|
+
install -m 0644 "$sql" "$EXTENSION_DIR/$(basename "$sql")"
|
|
97
|
+
done
|
|
96
98
|
|
|
97
99
|
printf '%s\n' \
|
|
98
100
|
'MultiCompress PostgreSQL files installed.' \
|
|
99
101
|
" library: $PKGLIBDIR/multi_compress_pg.so" \
|
|
100
102
|
" control: $EXTENSION_DIR/multi_compress.control" \
|
|
101
|
-
" SQL: $EXTENSION_DIR/multi_compress--0.
|
|
103
|
+
" SQL: $EXTENSION_DIR/multi_compress--0.6.0.sql (plus upgrade scripts)" \
|
|
102
104
|
'' \
|
|
103
105
|
'Activate once per application database:' \
|
|
104
106
|
' sudo -u postgres make enable DB=app_production'
|
|
@@ -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.
|
|
@@ -1560,6 +1560,20 @@ 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
|
+
|
|
1563
1577
|
static VALUE zstd_single_frame_info(VALUE self, VALUE data) {
|
|
1564
1578
|
(void)self;
|
|
1565
1579
|
StringValue(data);
|
|
@@ -3471,6 +3485,20 @@ static VALUE dict_algo(VALUE self) {
|
|
|
3471
3485
|
return algo_to_sym(d->algo);
|
|
3472
3486
|
}
|
|
3473
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
|
+
|
|
3474
3502
|
static VALUE dict_size(VALUE self) {
|
|
3475
3503
|
dictionary_t *d;
|
|
3476
3504
|
TypedData_Get_Struct(self, dictionary_t, &dictionary_type, d);
|
|
@@ -3508,6 +3536,8 @@ void Init_multi_compress(void) {
|
|
|
3508
3536
|
rb_define_module_function(mMultiCompress, "compress", compress_compress, -1);
|
|
3509
3537
|
rb_define_module_function(mMultiCompress, "decompress", compress_decompress, -1);
|
|
3510
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);
|
|
3511
3541
|
rb_define_module_function(mMultiCompress, "crc32", compress_crc32, -1);
|
|
3512
3542
|
rb_define_module_function(mMultiCompress, "adler32", compress_adler32, -1);
|
|
3513
3543
|
rb_define_module_function(mMultiCompress, "algorithms", compress_algorithms, 0);
|
|
@@ -3542,6 +3572,8 @@ void Init_multi_compress(void) {
|
|
|
3542
3572
|
rb_define_singleton_method(cDictionary, "load", dict_load, -1);
|
|
3543
3573
|
rb_define_method(cDictionary, "save", dict_save, 1);
|
|
3544
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);
|
|
3545
3577
|
rb_define_method(cDictionary, "size", dict_size, 0);
|
|
3546
3578
|
rb_define_singleton_method(mZstd, "train_dictionary", zstd_train_dictionary, -1);
|
|
3547
3579
|
rb_define_singleton_method(mBrotli, "train_dictionary", brotli_train_dictionary, -1);
|