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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/GET_STARTED.md +19 -0
  4. data/README.md +14 -1
  5. data/db_core/include/mcdb_format.h +67 -9
  6. data/db_core/src/mcdb_format.c +392 -139
  7. data/db_deployment/mysql/README.md +19 -0
  8. data/db_deployment/mysql/bin/common +70 -18
  9. data/db_deployment/mysql/bin/enable +5 -1
  10. data/db_deployment/mysql/bin/status +2 -0
  11. data/db_deployment/mysql/bin/upgrade +17 -5
  12. data/db_deployment/postgres/README.md +24 -0
  13. data/db_deployment/postgres/bin/enable +2 -0
  14. data/db_deployment/postgres/bin/install +4 -2
  15. data/docs/database-envelope-v2.md +147 -0
  16. data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
  17. data/ext/multi_compress/multi_compress.c +32 -0
  18. data/lib/multi_compress/database.rb +255 -49
  19. data/lib/multi_compress/db_deployment.rb +423 -11
  20. data/lib/multi_compress/version.rb +1 -1
  21. data/mysql_udf/README.md +45 -0
  22. data/mysql_udf/sql/examples.sql +5 -0
  23. data/mysql_udf/sql/install.sql +25 -1
  24. data/mysql_udf/sql/uninstall.sql +7 -1
  25. data/mysql_udf/sql/views.sql +16 -4
  26. data/mysql_udf/src/multi_compress_mysql.c +383 -44
  27. data/mysql_udf/test/run_e2e.sh +119 -1
  28. data/postgres_extension/Makefile +2 -2
  29. data/postgres_extension/README.md +48 -0
  30. data/postgres_extension/multi_compress.control +2 -2
  31. data/postgres_extension/multi_compress_pg.exports +12 -0
  32. data/postgres_extension/sql/examples.sql +11 -0
  33. data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
  34. data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
  35. data/postgres_extension/src/multi_compress_pg.c +301 -16
  36. data/postgres_extension/test/run_e2e.sh +90 -2
  37. 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
- rows="$(mcdb_query "SELECT name, dl FROM mysql.func WHERE dl = '${MCDB_UDF_LIBRARY}' OR name IN ('multi_compress_db_version', 'multi_compress_db_is_valid', 'multi_compress_db_decompress') ORDER BY name;")"
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
- case "$name" in
91
- multi_compress_db_version|multi_compress_db_is_valid|multi_compress_db_decompress)
92
- if [ "$dl" = "$MCDB_UDF_LIBRARY" ]; then
93
- MCDB_EXPECTED_OWNED=$((MCDB_EXPECTED_OWNED + 1))
94
- else
95
- MCDB_FOREIGN_EXPECTED=$((MCDB_FOREIGN_EXPECTED + 1))
96
- fi
97
- ;;
98
- *)
99
- if [ "$dl" = "$MCDB_UDF_LIBRARY" ]; then
100
- MCDB_UNEXPECTED_LIBRARY=$((MCDB_UNEXPECTED_LIBRARY + 1))
101
- fi
102
- ;;
103
- esac
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 3 ] && [ "$MCDB_FOREIGN_EXPECTED" -eq 0 ] && [ "$MCDB_UNEXPECTED_LIBRARY" -eq 0 ]
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
- mcdb_smoke_check() {
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 three MultiCompress UDFs once. Existing healthy registrations are
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
- mcdb_state_is_clean_enabled || mcdb_die 'upgrade requires all three MultiCompress UDFs to be enabled'
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
- mcdb_smoke_check
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
- [ "${#CAPTURED_REGISTRATIONS[@]}" -eq 3 ] || mcdb_die 'captured registration state is incomplete'
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
- install -m 0644 "$ROOT/postgres_extension/sql/multi_compress--0.5.0.sql" "$EXTENSION_DIR/multi_compress--0.5.0.sql"
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.5.0.sql" \
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);