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,414 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")/../.."
4
+
5
+ DEFAULT_MYSQL_IMAGE="mysql:5.7.44@sha256:dab0a802b44617303694fb17d166501de279c3031ddeb28c56ecf7fcab5ef0da"
6
+ IMAGE="${MCDB_MYSQL_IMAGE:-$DEFAULT_MYSQL_IMAGE}"
7
+ PLATFORM="${MCDB_MYSQL_PLATFORM:-linux/amd64}"
8
+ CONTAINER="${MCDB_MYSQL_CONTAINER:-mcdb-e2e}"
9
+ WAIT_SECONDS="${MCDB_MYSQL_WAIT_SECONDS:-90}"
10
+ WORK_ROOT="${MCDB_MYSQL_WORK_ROOT:-$(mktemp -d -t multi-compress-mysql.XXXXXX)}"
11
+ REMOVE_WORK_ROOT=0
12
+ ARCHIVE="$WORK_ROOT/multi_compress-mysql.tar.gz"
13
+ GEM_UNPACK_DIR="$WORK_ROOT/unpacked"
14
+ GEM_FILE="$WORK_ROOT/multi_compress.gem"
15
+ PACKAGED_GEM_ROOT=""
16
+ BUNDLE_PARENT="/opt"
17
+
18
+ command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 2; }
19
+
20
+ cleanup() {
21
+ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
22
+ [ "$REMOVE_WORK_ROOT" -eq 1 ] && rm -rf "$WORK_ROOT"
23
+ }
24
+ trap cleanup EXIT
25
+
26
+ if [ -z "${MCDB_MYSQL_WORK_ROOT:-}" ]; then
27
+ REMOVE_WORK_ROOT=1
28
+ fi
29
+
30
+ ensure_ruby_extension() {
31
+ if ruby -Ilib -e 'require "multi_compress"; require "multi_compress/database"' >/dev/null 2>&1; then
32
+ return
33
+ fi
34
+
35
+ echo "building Ruby extension..."
36
+ if command -v bundle >/dev/null 2>&1; then
37
+ bundle exec rake compile
38
+ else
39
+ rake compile
40
+ fi
41
+ ruby -Ilib -e 'require "multi_compress"; require "multi_compress/database"' >/dev/null
42
+ }
43
+
44
+ require_fixture() {
45
+ local path="$1"
46
+ [ -f "$path" ] || { echo "missing committed fixture: $path" >&2; exit 2; }
47
+ }
48
+
49
+ fixture_hex() {
50
+ ruby -e 'print File.binread(ARGV.fetch(0)).unpack1("H*")' "$1"
51
+ }
52
+
53
+ prepare_packaged_gem() {
54
+ gem build multi_compress.gemspec --output "$GEM_FILE" >/dev/null
55
+ mkdir -p "$GEM_UNPACK_DIR"
56
+ gem unpack "$GEM_FILE" --target "$GEM_UNPACK_DIR" >/dev/null
57
+ shopt -s nullglob
58
+ roots=("$GEM_UNPACK_DIR"/*)
59
+ shopt -u nullglob
60
+ [ "${#roots[@]}" -eq 1 ] && [ -d "${roots[0]}" ] || { echo "could not unpack exactly one multi_compress gem root" >&2; exit 2; }
61
+ PACKAGED_GEM_ROOT="${roots[0]}"
62
+ [ -f "$PACKAGED_GEM_ROOT/multi_compress.gemspec" ] || { echo "packaged gem lacks gemspec" >&2; exit 2; }
63
+ [ -d "$PACKAGED_GEM_ROOT/lib" ] || { echo "packaged gem lacks lib" >&2; exit 2; }
64
+ [ -d "$PACKAGED_GEM_ROOT/exe" ] || { echo "packaged gem lacks exe" >&2; exit 2; }
65
+ [ -f "$PACKAGED_GEM_ROOT/lib/multi_compress/db_deployment.rb" ] || { echo "packaged gem lacks db deployment CLI" >&2; exit 2; }
66
+ }
67
+
68
+ db_cli() {
69
+ ruby -I "$PACKAGED_GEM_ROOT/lib" "$PACKAGED_GEM_ROOT/exe/multi_compress" db "$@"
70
+ }
71
+
72
+ compress_hex() {
73
+ MCDB_TEXT="$1" ruby -Ilib -r multi_compress -r multi_compress/database \
74
+ -e 'print MultiCompress::Database.compress(ENV.fetch("MCDB_TEXT")).unpack1("H*")'
75
+ }
76
+
77
+ mcdb2_fixture() {
78
+ MCDB_TEXT="$1" ruby -Ilib -r multi_compress -r multi_compress/database <<'RUBY'
79
+ samples = 256.times.map do |i|
80
+ %({"kind":"event","tenant":#{i % 8},"metadata":{"source":"worker","version":1,"name":"same-shape-#{i % 16}"},"payload":"#{"x" * (48 + i % 64)}"})
81
+ end
82
+
83
+ dictionary = MultiCompress::Database::Dictionary.train(samples, id: 42, size: 4096)
84
+ blob = MultiCompress::Database.compress(ENV.fetch("MCDB_TEXT"), dictionary: dictionary)
85
+
86
+ puts dictionary.id
87
+ puts dictionary.zstd_id
88
+ puts dictionary.sha256.unpack1("H*")
89
+ puts dictionary.bytes.unpack1("H*")
90
+ puts blob.unpack1("H*")
91
+ RUBY
92
+ }
93
+
94
+ bundle_name() {
95
+ ruby -I "$PACKAGED_GEM_ROOT/lib" -r multi_compress/version \
96
+ -e 'print "multi_compress-mysql-#{MultiCompress::VERSION}"'
97
+ }
98
+
99
+ build_deployment_bundle() {
100
+ db_cli package mysql --output "$ARCHIVE" --force
101
+ test -s "$ARCHIVE"
102
+ }
103
+
104
+ install_mysql_build_tools() {
105
+ docker exec -u 0 "$CONTAINER" sh -ceu '
106
+ if command -v yum >/dev/null 2>&1; then
107
+ yum install -y gcc make binutils tar gzip
108
+ elif command -v microdnf >/dev/null 2>&1; then
109
+ microdnf install -y gcc make binutils tar gzip
110
+ elif command -v apt-get >/dev/null 2>&1; then
111
+ apt-get update
112
+ DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential tar gzip
113
+ else
114
+ echo "no supported package manager in MySQL container" >&2
115
+ exit 2
116
+ fi
117
+
118
+ command -v cc
119
+ command -v make
120
+ command -v ar
121
+ '
122
+ }
123
+
124
+ install_bundle_on_db_host() {
125
+ local bundle_name="$1"
126
+ local bundle_root="${BUNDLE_PARENT}/${bundle_name}"
127
+
128
+ tar -tzf "$ARCHIVE" | grep -Fx "${bundle_name}/THIRD_PARTY_NOTICES.md" >/dev/null
129
+ if tar -tzf "$ARCHIVE" | grep -E '/\.DS_Store$' >/dev/null; then
130
+ echo 'deployment bundle must not contain .DS_Store' >&2
131
+ exit 2
132
+ fi
133
+ docker cp "$ARCHIVE" "$CONTAINER:/tmp/${bundle_name}.tar.gz"
134
+ docker exec -u 0 -e "MCDB_BUNDLE_ROOT=$bundle_root" "$CONTAINER" sh -ceu '
135
+ rm -rf "$MCDB_BUNDLE_ROOT"
136
+ mkdir -p "$(dirname "$MCDB_BUNDLE_ROOT")"
137
+ tar -xzf "/tmp/$(basename "$MCDB_BUNDLE_ROOT").tar.gz" -C "$(dirname "$MCDB_BUNDLE_ROOT")"
138
+ cd "$MCDB_BUNDLE_ROOT"
139
+ make verify
140
+ make doctor
141
+ make install
142
+ make enable
143
+ make status
144
+ '
145
+ }
146
+
147
+ apply_readable_view() {
148
+ local path="$1"
149
+ db_cli view mysql \
150
+ --table app.events \
151
+ --column payload_compressed \
152
+ --view admin.events_readable \
153
+ --columns id \
154
+ --output "$path"
155
+ docker exec -i "$CONTAINER" mysql --default-character-set=utf8mb4 < "$path"
156
+ }
157
+
158
+ apply_dictionary_readable_view() {
159
+ local path="$1"
160
+ local warnings
161
+ db_cli view mysql \
162
+ --table app.dictionary_events \
163
+ --column payload_compressed \
164
+ --dictionary-table app.mcdb_dictionary_versions \
165
+ --dictionary-id-column payload_dictionary_id \
166
+ --view admin.dictionary_events_readable \
167
+ --columns id \
168
+ --output "$path"
169
+ warnings=$( { cat "$path"; printf '\nSHOW WARNINGS;\n'; } | docker exec -i "$CONTAINER" mysql \
170
+ --default-character-set=utf8mb4 --batch --skip-column-names --raw)
171
+ if printf '%s\n' "$warnings" | grep -Eqi '(undefined|merge)'; then
172
+ echo "MySQL refused the required MERGE view algorithm:" >&2
173
+ printf '%s\n' "$warnings" >&2
174
+ exit 1
175
+ fi
176
+ }
177
+
178
+ ensure_ruby_extension
179
+ prepare_packaged_gem
180
+ build_deployment_bundle
181
+
182
+ for name in corrupt_magic corrupt_payload corrupt_crc invalid_utf8 nul_text trailing_skippable trailing_frame; do
183
+ require_fixture "test/fixtures/database_v1/${name}.mcdb"
184
+ done
185
+
186
+ TEXT='Привет, DBeaver! Сәлем! 🌍'
187
+ BIG=$(ruby -e 'print "a" * 300')
188
+ VALID_HEX=$(compress_hex "$TEXT")
189
+ BIG_HEX=$(compress_hex "$BIG")
190
+ CORRUPT_HEX=$(fixture_hex test/fixtures/database_v1/corrupt_crc.mcdb)
191
+ MCDB2_FIXTURE_OUTPUT="$(mcdb2_fixture "$TEXT")"
192
+ readarray -t MCDB2_FIXTURE <<< "$MCDB2_FIXTURE_OUTPUT"
193
+ if [ "${#MCDB2_FIXTURE[@]}" -ne 5 ] || [ -z "${MCDB2_FIXTURE[0]}" ] || [ -z "${MCDB2_FIXTURE[4]}" ]; then
194
+ echo "MCDB2 fixture generator returned an invalid payload" >&2
195
+ exit 2
196
+ fi
197
+ DICT_REF="${MCDB2_FIXTURE[0]}"
198
+ DICT_ZSTD_ID="${MCDB2_FIXTURE[1]}"
199
+ DICT_SHA_HEX="${MCDB2_FIXTURE[2]}"
200
+ DICT_HEX="${MCDB2_FIXTURE[3]}"
201
+ DICT_BLOB_HEX="${MCDB2_FIXTURE[4]}"
202
+ TEXT_BYTES=$(MCDB_TEXT="$TEXT" ruby -e 'print ENV.fetch("MCDB_TEXT").bytesize')
203
+ BUNDLE_NAME="$(bundle_name)"
204
+ VIEW_SQL="$(mktemp -t multi-compress-mysql-view.XXXXXX.sql)"
205
+ DICT_VIEW_SQL="$(mktemp -t multi-compress-mysql-dictionary-view.XXXXXX.sql)"
206
+ REGISTRY_SQL="$(mktemp -t multi-compress-mysql-registry.XXXXXX.sql)"
207
+ trap 'rm -f "$VIEW_SQL" "$DICT_VIEW_SQL" "$REGISTRY_SQL"; cleanup' EXIT
208
+
209
+ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
210
+ docker run --platform "$PLATFORM" -d --name "$CONTAINER" \
211
+ -e MYSQL_ALLOW_EMPTY_PASSWORD=1 "$IMAGE" \
212
+ --character-set-server=utf8mb4 \
213
+ --collation-server=utf8mb4_unicode_ci >/dev/null
214
+
215
+ echo "waiting for mysqld in $IMAGE..."
216
+ deadline=$(( $(date +%s) + WAIT_SECONDS ))
217
+ until docker exec "$CONTAINER" mysqladmin ping --silent 2>/dev/null; do
218
+ if [ "$(date +%s)" -ge "$deadline" ]; then
219
+ echo "mysqld did not become ready within ${WAIT_SECONDS}s" >&2
220
+ docker logs "$CONTAINER" >&2 || true
221
+ exit 1
222
+ fi
223
+ sleep 2
224
+ done
225
+
226
+ q() {
227
+ docker exec "$CONTAINER" mysql \
228
+ --default-character-set=utf8mb4 \
229
+ --batch \
230
+ --skip-column-names \
231
+ --raw \
232
+ -e "$1"
233
+ }
234
+ qfail() {
235
+ if q "$1" >/dev/null 2>&1; then return 1; else return 0; fi
236
+ }
237
+
238
+ install_mysql_build_tools
239
+ install_bundle_on_db_host "$BUNDLE_NAME"
240
+
241
+ q "DROP DATABASE IF EXISTS app;
242
+ DROP DATABASE IF EXISTS admin;
243
+ CREATE DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
244
+ CREATE DATABASE admin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
245
+ USE app;
246
+ CREATE TABLE events (id INT PRIMARY KEY, payload_compressed LONGBLOB NULL);
247
+ INSERT INTO events VALUES
248
+ (1, UNHEX('$VALID_HEX')),
249
+ (2, NULL),
250
+ (3, UNHEX('$BIG_HEX')),
251
+ (4, UNHEX('$VALID_HEX')),
252
+ (5, UNHEX('$CORRUPT_HEX')),
253
+ (6, UNHEX('$VALID_HEX'));"
254
+ apply_readable_view "$VIEW_SQL"
255
+
256
+ # MCDB2: registry DDL lives in app and the generated view supplies dictionary bytes through an INNER JOIN.
257
+ q "USE app;
258
+ CREATE TABLE dictionary_events (
259
+ id INT PRIMARY KEY,
260
+ payload_compressed LONGBLOB NOT NULL,
261
+ payload_dictionary_id BIGINT UNSIGNED NOT NULL
262
+ ) ENGINE=InnoDB;"
263
+ db_cli registry mysql --database app \
264
+ --payload-table dictionary_events --payload-column payload_compressed \
265
+ --payload-dictionary-id-column payload_dictionary_id \
266
+ --output "$REGISTRY_SQL"
267
+ docker exec -i "$CONTAINER" mysql --default-character-set=utf8mb4 < "$REGISTRY_SQL"
268
+ q "USE app;
269
+ INSERT INTO mcdb_dictionary_versions (id, family, zstd_dict_id, sha256, bytes)
270
+ VALUES ($DICT_REF, 'events_payload_v1', $DICT_ZSTD_ID, UNHEX('$DICT_SHA_HEX'), UNHEX('$DICT_HEX'));
271
+ INSERT INTO dictionary_events (id, payload_compressed, payload_dictionary_id)
272
+ SELECT seq.id, UNHEX('$DICT_BLOB_HEX'), $DICT_REF
273
+ FROM (
274
+ SELECT ones.n + tens.n * 10 + hundreds.n * 100 + 1 AS id
275
+ FROM (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4
276
+ UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) AS ones
277
+ CROSS JOIN (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4
278
+ UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) AS tens
279
+ CROSS JOIN (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4
280
+ UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) AS hundreds
281
+ ) AS seq
282
+ WHERE seq.id <= 1000;"
283
+ apply_dictionary_readable_view "$DICT_VIEW_SQL"
284
+
285
+ fails=0
286
+ check() {
287
+ if [ "$2" = "$3" ]; then echo " PASS $1"; else echo " FAIL $1 (got [$2] want [$3])"; fails=$((fails + 1)); fi
288
+ }
289
+ check_err() {
290
+ if qfail "$2"; then echo " PASS $1 (rejected)"; else echo " FAIL $1 (should have errored)"; fails=$((fails + 1)); fi
291
+ }
292
+
293
+ check "deployment bundle version() smoke" "$(q 'SELECT LOCATE(CHAR(77,67,68,66,49), multi_compress_db_version()) > 0;')" "1"
294
+ check "MySQL connection uses utf8mb4" \
295
+ "$(q "SELECT CONCAT(@@character_set_client, '/', @@character_set_connection, '/', @@character_set_results);")" \
296
+ "utf8mb4/utf8mb4/utf8mb4"
297
+ check "decode roundtrip" \
298
+ "$(q 'USE app; SELECT CONVERT(multi_compress_db_decompress(payload_compressed) USING utf8mb4) FROM events WHERE id=1;')" \
299
+ "$TEXT"
300
+ check "NULL -> NULL" "$(q 'USE app; SELECT multi_compress_db_decompress(payload_compressed) IS NULL FROM events WHERE id=2;')" "1"
301
+ check "big (>255, >input)" "$(q 'USE app; SELECT OCTET_LENGTH(multi_compress_db_decompress(payload_compressed)) FROM events WHERE id=3;')" "300"
302
+ check "generated readable view" "$(q 'SELECT COUNT(*) FROM admin.events_readable WHERE id IN (1, 4);')" "2"
303
+ check "generated readable view returns Unicode" \
304
+ "$(q 'SELECT payload FROM admin.events_readable WHERE id=1;')" \
305
+ "$TEXT"
306
+ check "valid-corrupt-valid scan preserves later valid row" \
307
+ "$(q "USE app; SELECT GROUP_CONCAT(CASE WHEN multi_compress_db_decompress(payload_compressed) IS NULL THEN '<NULL>' ELSE CONVERT(multi_compress_db_decompress(payload_compressed) USING utf8mb4) END ORDER BY id SEPARATOR '|') FROM events WHERE id IN (1, 5, 6);")" \
308
+ "$TEXT|<NULL>|$TEXT"
309
+ check "is_valid true" "$(q 'USE app; SELECT multi_compress_db_is_valid(payload_compressed) FROM events WHERE id=1;')" "1"
310
+ check "is_valid on NULL" "$(q 'USE app; SELECT multi_compress_db_is_valid(payload_compressed) IS NULL FROM events WHERE id=2;')" "1"
311
+
312
+ for name in corrupt_magic corrupt_payload corrupt_crc invalid_utf8 nul_text trailing_skippable trailing_frame; do
313
+ hex=$(fixture_hex "test/fixtures/database_v1/${name}.mcdb")
314
+ check "$name -> NULL" \
315
+ "$(q "USE app; SELECT multi_compress_db_decompress(UNHEX('$hex')) IS NULL;")" \
316
+ "1"
317
+ done
318
+ check "is_valid false (crc)" \
319
+ "$(q "SELECT multi_compress_db_is_valid(UNHEX('$(fixture_hex test/fixtures/database_v1/corrupt_crc.mcdb)'));")" \
320
+ "0"
321
+ check "MCDB2 dictionary reference" "$(q 'USE app; SELECT multi_compress_db_dictionary_ref(payload_compressed) FROM dictionary_events WHERE id=1;')" "$DICT_REF"
322
+ check "MCDB2 original size" "$(q 'USE app; SELECT multi_compress_db_original_size(payload_compressed) FROM dictionary_events WHERE id=1;')" "$TEXT_BYTES"
323
+ check "MCDB2 generated readable view" "$(q 'SELECT payload FROM admin.dictionary_events_readable WHERE id=1;')" "$TEXT"
324
+ check "MCDB2 dictionary is valid" \
325
+ "$(q 'USE app; SELECT multi_compress_db_is_valid_dict(e.payload_compressed, e.payload_dictionary_id, d.sha256, d.bytes) FROM dictionary_events e JOIN mcdb_dictionary_versions d ON d.id = e.payload_dictionary_id WHERE e.id=1;')" \
326
+ "1"
327
+ check_err "MCDB2 payload/FK mismatch rejected" "USE app; INSERT INTO dictionary_events VALUES (1001, UNHEX('$DICT_BLOB_HEX'), $((DICT_REF + 1)));"
328
+ check "MCDB2 rejects wrong dictionary digest" \
329
+ "$(q 'USE app; SELECT multi_compress_db_is_valid_dict(e.payload_compressed, e.payload_dictionary_id, UNHEX(REPEAT("00", 32)), d.bytes) FROM dictionary_events e JOIN mcdb_dictionary_versions d ON d.id = e.payload_dictionary_id WHERE e.id=1;')" \
330
+ "0"
331
+ MYSQL_DICT_PLAN="$(q 'EXPLAIN SELECT id, payload FROM admin.dictionary_events_readable WHERE id BETWEEN 100 AND 900 ORDER BY id LIMIT 100;')"
332
+ if printf '%s\n' "$MYSQL_DICT_PLAN" | grep -Fq '<derived'; then
333
+ echo " FAIL MCDB2 view materialized as a derived table"
334
+ printf '%s\n' "$MYSQL_DICT_PLAN" >&2
335
+ fails=$((fails + 1))
336
+ elif ! printf '%s\n' "$MYSQL_DICT_PLAN" | awk -F '\t' '$3 == "source" && $5 == "range" && $7 == "PRIMARY" { found = 1 } END { exit(found ? 0 : 1) }'; then
337
+ echo " FAIL MCDB2 view did not use the source PRIMARY range scan"
338
+ printf '%s\n' "$MYSQL_DICT_PLAN" >&2
339
+ fails=$((fails + 1))
340
+ else
341
+ # `Using temporary` / `Using filesort` here belongs to the outer ORDER BY
342
+ # over a joined projection. It is not evidence that the view was materialized:
343
+ # MySQL reports materialized views as <derived...>. The important contract is
344
+ # that the source predicate is merged and reaches the PRIMARY range scan.
345
+ echo " PASS MCDB2 view is MERGE-able and keeps the source PRIMARY range scan"
346
+ fi
347
+ check "max_allowed_packet is reported" "$(q 'SELECT @@GLOBAL.max_allowed_packet > 0;')" "1"
348
+
349
+ MYSQL_SOCKET_PATH="$(q 'SELECT @@socket;' | tr -d '\r\n')"
350
+ # Simulate an installed 0.5.x server: only the three MCDB1 UDF registrations
351
+ # remain. 0.6 must recognise this as upgradeable, not partial, then use the
352
+ # documented DROP -> replace library -> CREATE lifecycle to register all nine.
353
+ docker exec -u 0 \
354
+ -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" \
355
+ -e "MCDB_MYSQL_SOCKET=$MYSQL_SOCKET_PATH" \
356
+ "$CONTAINER" sh -ceu '
357
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "
358
+ DROP FUNCTION multi_compress_db_decompress_dict;
359
+ DROP FUNCTION multi_compress_db_is_valid_dict;
360
+ DROP FUNCTION multi_compress_db_dictionary_sha256;
361
+ DROP FUNCTION multi_compress_db_dictionary_zstd_id;
362
+ DROP FUNCTION multi_compress_db_dictionary_ref;
363
+ DROP FUNCTION multi_compress_db_original_size;"
364
+ cd "$MCDB_BUNDLE_ROOT"
365
+ make status MYSQL_SOCKET="$MCDB_MYSQL_SOCKET" | grep -F "state: MCDB1-only"
366
+ make doctor MYSQL_SOCKET="$MCDB_MYSQL_SOCKET" | grep -F "server socket: $MCDB_MYSQL_SOCKET"
367
+ make upgrade CONFIRM=UPGRADE_MULTI_COMPRESS MYSQL_SOCKET="$MCDB_MYSQL_SOCKET"
368
+ make status MYSQL_SOCKET="$MCDB_MYSQL_SOCKET" | grep -F "state: enabled"
369
+ '
370
+ check "upgrade from MCDB1-only restores MCDB2 functions" "$(q 'SELECT multi_compress_db_original_size(payload_compressed) FROM app.dictionary_events WHERE id=1;')" "$TEXT_BYTES"
371
+ check "upgrade keeps version() available" "$(q 'SELECT LOCATE(CHAR(77,67,68,66,49), multi_compress_db_version()) > 0;')" "1"
372
+
373
+ # Register one expected name to a different SONAME and prove enable refuses without
374
+ # dropping it. The copied library makes this a real MySQL registration, not a mock.
375
+ docker exec -u 0 \
376
+ -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" \
377
+ -e "MCDB_MYSQL_SOCKET=$MYSQL_SOCKET_PATH" \
378
+ "$CONTAINER" sh -ceu '
379
+ plugin_dir="$(mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" --batch --skip-column-names -e "SELECT @@plugin_dir;")"
380
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "DROP FUNCTION multi_compress_db_version;"
381
+ cp "$plugin_dir/multi_compress_mysql.so" "$plugin_dir/foreign_multi_compress.so"
382
+ foreign_soname="$(printf "\\047foreign_multi_compress.so\\047")"
383
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "CREATE FUNCTION multi_compress_db_version RETURNS STRING SONAME $foreign_soname;"
384
+ cd "$MCDB_BUNDLE_ROOT"
385
+ if make enable MYSQL_SOCKET="$MCDB_MYSQL_SOCKET" >/tmp/mcdb-foreign-enable.out 2>&1; then
386
+ echo "enable unexpectedly accepted a foreign registration" >&2
387
+ exit 1
388
+ fi
389
+ grep -F "foreign SONAME" /tmp/mcdb-foreign-enable.out
390
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" --batch --skip-column-names \
391
+ -e "SELECT dl FROM mysql.func WHERE name = $(printf "\\047multi_compress_db_version\\047");" | grep -Fx "foreign_multi_compress.so"
392
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "DROP FUNCTION multi_compress_db_version;"
393
+ owned_soname="$(printf "\\047multi_compress_mysql.so\\047")"
394
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "CREATE FUNCTION multi_compress_db_version RETURNS STRING SONAME $owned_soname;"
395
+ rm -f "$plugin_dir/foreign_multi_compress.so"
396
+ '
397
+
398
+ docker exec -u 0 -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" "$CONTAINER" sh -ceu '
399
+ cd "$MCDB_BUNDLE_ROOT"
400
+ if make disable >/dev/null 2>&1; then
401
+ echo "disable succeeded without CONFIRM" >&2
402
+ exit 1
403
+ fi
404
+ if make uninstall >/dev/null 2>&1; then
405
+ echo "uninstall succeeded without CONFIRM" >&2
406
+ exit 1
407
+ fi
408
+ make enable | grep -F "already enabled; no changes made."
409
+ make uninstall CONFIRM=REMOVE_MULTI_COMPRESS
410
+ '
411
+ check_err "uninstall removes UDF" "SELECT multi_compress_db_version();"
412
+
413
+ if [ "$fails" -ne 0 ]; then echo "E2E FAILED ($fails)"; exit 1; fi
414
+ echo "E2E OK"
@@ -0,0 +1,74 @@
1
+ EXTENSION = multi_compress
2
+ MODULE_big = multi_compress_pg
3
+ OBJS = src/multi_compress_pg.o src/mcdb_format.o
4
+ DATA = sql/multi_compress--0.5.0.sql sql/multi_compress--0.6.0.sql sql/multi_compress--0.5.0--0.6.0.sql
5
+
6
+ PG_CONFIG ?= pg_config
7
+ ZSTD_DIR ?= ../ext/multi_compress/vendor/zstd/lib
8
+ CORE_DIR ?= ../db_core
9
+ BUILD ?= build
10
+ CC ?= cc
11
+ AR ?= ar
12
+
13
+ WARNFLAGS ?= -Wall -Wextra -Werror
14
+ PG_CPPFLAGS += -I$(CORE_DIR)/include -I$(ZSTD_DIR)
15
+ PG_CFLAGS += -O2 -fPIC -std=c99 $(WARNFLAGS)
16
+ EXPORT_MAP := $(CURDIR)/multi_compress_pg.exports
17
+ SHLIB_LINK += -Wl,--version-script=$(EXPORT_MAP) $(ZSTD_LIB)
18
+ EXTRA_CLEAN += $(BUILD)
19
+
20
+ ZSTD_CFLAGS ?= -O2 -fPIC -std=c99 -I$(ZSTD_DIR) -I$(ZSTD_DIR)/common
21
+ ZSTD_OBJ_DIR := $(BUILD)/zstd
22
+ ZSTD_C_SRCS := $(wildcard $(ZSTD_DIR)/common/*.c) $(wildcard $(ZSTD_DIR)/decompress/*.c)
23
+ ZSTD_C_OBJS := $(patsubst $(ZSTD_DIR)/%.c,$(ZSTD_OBJ_DIR)/%.o,$(ZSTD_C_SRCS))
24
+
25
+ ARCH := $(shell uname -m)
26
+ ifneq (,$(filter x86_64 amd64,$(ARCH)))
27
+ ZSTD_ASM_SRC := $(ZSTD_DIR)/decompress/huf_decompress_amd64.S
28
+ ifneq (,$(wildcard $(ZSTD_ASM_SRC)))
29
+ ZSTD_ASM_OBJ := $(ZSTD_OBJ_DIR)/decompress/huf_decompress_amd64.o
30
+ endif
31
+ endif
32
+
33
+ ZSTD_OBJS := $(ZSTD_C_OBJS) $(ZSTD_ASM_OBJ)
34
+ ZSTD_LIB := $(BUILD)/libzstd_dec.a
35
+
36
+ PGXS := $(shell $(PG_CONFIG) --pgxs)
37
+ include $(PGXS)
38
+
39
+ .PHONY: all test parity e2e fixtures
40
+
41
+ all: $(ZSTD_LIB)
42
+
43
+ $(MODULE_big)$(DLSUFFIX): $(ZSTD_LIB)
44
+
45
+ $(BUILD):
46
+ mkdir -p $@
47
+
48
+ $(ZSTD_OBJ_DIR)/%.o: $(ZSTD_DIR)/%.c
49
+ @mkdir -p $(dir $@)
50
+ $(CC) $(ZSTD_CFLAGS) -MMD -MP -c $< -o $@
51
+
52
+ $(ZSTD_OBJ_DIR)/%.o: $(ZSTD_DIR)/%.S
53
+ @mkdir -p $(dir $@)
54
+ $(CC) -fPIC -I$(ZSTD_DIR) -c $< -o $@
55
+
56
+ $(ZSTD_LIB): $(ZSTD_OBJS) | $(BUILD)
57
+ $(AR) rcs $@ $^
58
+
59
+ # The source wrapper includes the shared MCDB1/MCDB2 decoder; it is not a fork.
60
+ src/mcdb_format.o: src/mcdb_format.c $(CORE_DIR)/src/mcdb_format.c $(CORE_DIR)/include/mcdb_format.h
61
+
62
+ # Golden fixtures are committed. Regeneration is a development action only.
63
+ fixtures:
64
+ RUBYLIB=../lib ruby ../test/fixtures/database_v1/generate.rb
65
+
66
+ parity:
67
+ $(MAKE) -C ../mysql_udf test
68
+
69
+ test: all parity
70
+
71
+ e2e:
72
+ ./test/run_e2e.sh
73
+
74
+ -include $(ZSTD_C_OBJS:.o=.d)
@@ -0,0 +1,160 @@
1
+ # MultiCompress PostgreSQL extension (`MCDB1` reader)
2
+
3
+ This PostgreSQL extension reads the same frozen `MCDB1` envelopes as the Ruby
4
+ API and the MySQL UDF. Rails stores `MultiCompress::Database.compress(text)` in
5
+ `bytea`; PostgreSQL exposes decoded UTF-8 through SQL and a view for DBeaver.
6
+
7
+ It is a separate server-native artifact, not part of `gem install`. The shared
8
+ format contract is [`docs/database-envelope-v1.md`](../docs/database-envelope-v1.md).
9
+
10
+ ## SQL API
11
+
12
+ | Function | Returns | Purpose |
13
+ |---|---:|---|
14
+ | `multi_compress_db_version()` | `text` | reader build, MCDB1 and zstd version smoke test |
15
+ | `multi_compress_db_is_valid(bytea)` | `boolean` | validates an MCDB1 blob without raising |
16
+ | `multi_compress_db_decompress(bytea)` | `text` | returns UTF-8 text or raises on corruption |
17
+
18
+ `multi_compress_db_decompress` requires a UTF-8 PostgreSQL database. MCDB1 does
19
+ not permit NUL bytes, so decoded values are valid PostgreSQL `text` values.
20
+
21
+ ## Production deployment: generated DBA bundle
22
+
23
+ Do **not** ask a DBA to clone this repository, find a release tag, manually copy
24
+ `multi_compress_pg.so`, or guess PostgreSQL install paths. The application
25
+ developer generates a source bundle from the exact installed gem version:
26
+
27
+ ```bash
28
+ bundle exec multi_compress db package postgres --output tmp/multi-compress-postgres.tar.gz
29
+ ```
30
+
31
+ The DBA receives that one archive and runs this on the PostgreSQL host:
32
+
33
+ ```bash
34
+ tar -xzf multi-compress-postgres.tar.gz
35
+ cd multi_compress-postgres-0.5.0
36
+ make verify
37
+ make doctor
38
+ sudo make install
39
+ sudo -u postgres make enable DB=app_production \
40
+ MIGRATION_ROLE=app_migrations READ_ROLE=dbeaver_readonly
41
+ ```
42
+
43
+ `install` compiles the extension against the selected host `pg_config` and puts
44
+ its library/control/SQL files in the directories reported by that exact server.
45
+ Install the matching `postgresql-server-dev-<major>` package and a C toolchain
46
+ beforehand; `make doctor` verifies both.
47
+ For a non-default PostgreSQL install use:
48
+
49
+ ```bash
50
+ sudo make install PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config
51
+ ```
52
+
53
+ `enable` rejects non-UTF8 databases and an extension already installed in a
54
+ different schema. It runs `CREATE EXTENSION` once per application database,
55
+ revokes public access, grants the migration role `USAGE` on the extension schema,
56
+ and grants both named roles `EXECUTE` on the decoder functions. The application
57
+ migration must grant the read role `SELECT` on each generated view. Repeat the
58
+ native install on every reporting, failover and restore target before enabling
59
+ or querying MCDB1 there.
60
+
61
+ ## DBeaver: generated readable view
62
+
63
+ Generate the view in the application repository and put the output in a Rails
64
+ migration or versioned SQL. It is application schema, not a hand-typed DBA task:
65
+
66
+ ```bash
67
+ bundle exec multi_compress db view postgres \
68
+ --table app.events \
69
+ --column payload_compressed \
70
+ --view admin.events_readable \
71
+ --columns id,created_at,status \
72
+ --as payload \
73
+ --output db/views/events_readable.sql
74
+ ```
75
+
76
+ DBeaver users only query `admin.events_readable`. Their role needs `USAGE` on
77
+ `admin`, `SELECT` on this view, and `EXECUTE` on the decoder functions granted by
78
+ `make enable`; it does not need `USAGE` on `multi_compress`. If `make enable`
79
+ used a different extension schema, pass it to the generator with
80
+ `--extension-schema that_schema`:
81
+
82
+ ```sql
83
+ SELECT id, created_at, status, payload
84
+ FROM admin.events_readable
85
+ WHERE id = 123;
86
+ ```
87
+
88
+ The view decompresses selected rows. Filter with indexed ordinary columns before
89
+ reading it; do not scan decoded payloads with an unbounded `LIKE '%text%'`.
90
+
91
+ ## Migration and constraints
92
+
93
+ Do not reinterpret an existing plaintext column as MCDB1. Add a new nullable
94
+ `bytea` column, dual-write it from the application, backfill in batches, verify
95
+ `multi_compress.multi_compress_db_is_valid(blob)`, switch reads, then keep the
96
+ old column for a rollback window. MCDB1 v1 is zstd-only, accepts valid UTF-8
97
+ text without NUL bytes, and has a 16 MiB decompressed limit. The target database
98
+ must use UTF-8 encoding for the text-returning function.
99
+
100
+ ## Development source build
101
+
102
+ The source target remains useful for contributors and CI:
103
+
104
+ ```bash
105
+ cd postgres_extension
106
+ make PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config
107
+ make test PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config
108
+ make e2e
109
+ ```
110
+
111
+ `test/run_e2e.sh` builds against a pinned PostgreSQL Docker target and is the
112
+ server-side acceptance gate. It is not part of the end-user installation flow.
113
+
114
+ ## MCDB2 dictionary-backed columns
115
+
116
+ MCDB2 is an opt-in companion for one homogeneous column containing many similar
117
+ small JSON/text values. It does not replace MCDB1. First install the 0.6 reader
118
+ on every host, then run `make enable` once again to execute `ALTER EXTENSION
119
+ multi_compress UPDATE` and refresh function grants.
120
+
121
+ The dictionary is application data, not an extension file. Create the append-only
122
+ registry under a dedicated NOLOGIN owner and retain every version referenced by a
123
+ payload:
124
+
125
+ ```bash
126
+ bundle exec multi_compress db registry postgres \
127
+ --schema app \
128
+ --owner mcdb_dictionary_owner \
129
+ --migration-role app_migrations \
130
+ --output db/mcdb_dictionary_registry.sql
131
+ ```
132
+
133
+ For a new MCDB2 column, add a `payload_dictionary_id bigint NOT NULL` FK to
134
+ `app.mcdb_dictionary_versions`, register one strict
135
+ `MultiCompress::Database::Dictionary`, then generate the readable view:
136
+
137
+ ```bash
138
+ bundle exec multi_compress db view postgres \
139
+ --table app.events \
140
+ --column payload_compressed \
141
+ --dictionary-table app.mcdb_dictionary_versions \
142
+ --dictionary-id-column payload_dictionary_id \
143
+ --view admin.events_readable \
144
+ --columns id,created_at,status \
145
+ --as payload \
146
+ --output db/views/events_readable.sql
147
+ ```
148
+
149
+ The generated view uses an `INNER JOIN` and calls the four-argument native
150
+ function with payload, registry id, dictionary SHA-256 and bytes. It keeps raw
151
+ payloads/dictionary bytes out of the DBeaver role. The view owner needs source
152
+ and registry access; DBeaver needs `SELECT` on the view and the decoder
153
+ `EXECUTE` grant made by `make enable`.
154
+
155
+ Check production query shape with `EXPLAIN (ANALYZE, BUFFERS)` on an indexed
156
+ filter plus `LIMIT`. Do not search/order/group by decoded payload text. See
157
+ [`docs/database-envelope-v2.md`](../docs/database-envelope-v2.md) for the full
158
+ format and rollback contract.
159
+
160
+ 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.
@@ -0,0 +1,4 @@
1
+ comment = 'MultiCompress MCDB1/MCDB2 zstd reader'
2
+ default_version = '0.6.0'
3
+ module_pathname = '$libdir/multi_compress_pg'
4
+ relocatable = true
@@ -0,0 +1,24 @@
1
+ {
2
+ global:
3
+ Pg_magic_func;
4
+ pg_finfo_multi_compress_db_version;
5
+ pg_finfo_multi_compress_db_is_valid;
6
+ pg_finfo_multi_compress_db_decompress;
7
+ pg_finfo_multi_compress_db_original_size;
8
+ pg_finfo_multi_compress_db_dictionary_ref;
9
+ pg_finfo_multi_compress_db_dictionary_zstd_id;
10
+ pg_finfo_multi_compress_db_dictionary_sha256;
11
+ pg_finfo_multi_compress_db_is_valid_dict;
12
+ pg_finfo_multi_compress_db_decompress_dict;
13
+ multi_compress_db_version;
14
+ multi_compress_db_is_valid;
15
+ multi_compress_db_decompress;
16
+ multi_compress_db_original_size;
17
+ multi_compress_db_dictionary_ref;
18
+ multi_compress_db_dictionary_zstd_id;
19
+ multi_compress_db_dictionary_sha256;
20
+ multi_compress_db_is_valid_dict;
21
+ multi_compress_db_decompress_dict;
22
+ local:
23
+ *;
24
+ };