multi_compress 0.3.5 → 0.5.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 +76 -0
- data/GET_STARTED.md +188 -0
- data/README.md +56 -0
- data/db_core/include/mcdb_format.h +55 -0
- data/db_core/src/mcdb_format.c +341 -0
- data/db_deployment/mysql/Makefile +48 -0
- data/db_deployment/mysql/README.md +42 -0
- data/db_deployment/mysql/bin/common +160 -0
- data/db_deployment/mysql/bin/disable +40 -0
- data/db_deployment/mysql/bin/doctor +64 -0
- data/db_deployment/mysql/bin/enable +57 -0
- data/db_deployment/mysql/bin/install +64 -0
- data/db_deployment/mysql/bin/status +65 -0
- data/db_deployment/mysql/bin/uninstall +47 -0
- data/db_deployment/mysql/bin/upgrade +115 -0
- data/db_deployment/mysql/bin/verify +14 -0
- data/db_deployment/postgres/Makefile +45 -0
- data/db_deployment/postgres/README.md +60 -0
- data/db_deployment/postgres/bin/doctor +61 -0
- data/db_deployment/postgres/bin/enable +136 -0
- data/db_deployment/postgres/bin/install +104 -0
- data/db_deployment/postgres/bin/uninstall +54 -0
- data/db_deployment/postgres/bin/verify +14 -0
- data/docs/database-envelope-v1.md +159 -0
- data/exe/multi_compress +228 -0
- data/ext/multi_compress/extconf.rb +4 -0
- data/ext/multi_compress/multi_compress.c +30 -0
- data/lib/multi_compress/active_record.rb +61 -0
- data/lib/multi_compress/codec.rb +164 -0
- data/lib/multi_compress/database.rb +106 -0
- data/lib/multi_compress/db_deployment.rb +456 -0
- data/lib/multi_compress/version.rb +1 -1
- data/multi_compress.gemspec +55 -0
- data/mysql_udf/Makefile +96 -0
- data/mysql_udf/README.md +95 -0
- data/mysql_udf/sql/examples.sql +11 -0
- data/mysql_udf/sql/install.sql +15 -0
- data/mysql_udf/sql/uninstall.sql +4 -0
- data/mysql_udf/sql/views.sql +8 -0
- data/mysql_udf/src/multi_compress_mysql.c +133 -0
- data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
- data/mysql_udf/test/mcdb_cli.c +60 -0
- data/mysql_udf/test/run_e2e.sh +296 -0
- data/postgres_extension/Makefile +74 -0
- data/postgres_extension/README.md +112 -0
- data/postgres_extension/multi_compress.control +4 -0
- data/postgres_extension/multi_compress_pg.exports +12 -0
- data/postgres_extension/sql/examples.sql +12 -0
- data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
- data/postgres_extension/sql/uninstall.sql +2 -0
- data/postgres_extension/sql/views.sql +7 -0
- data/postgres_extension/src/mcdb_format.c +2 -0
- data/postgres_extension/src/multi_compress_pg.c +96 -0
- data/postgres_extension/test/run_e2e.sh +323 -0
- metadata +52 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
DATABASE="${PGDATABASE:-}"
|
|
5
|
+
SCHEMA="multi_compress"
|
|
6
|
+
MIGRATION_ROLE=""
|
|
7
|
+
READ_ROLE=""
|
|
8
|
+
PSQL="${PSQL:-psql}"
|
|
9
|
+
HOST=""
|
|
10
|
+
PORT=""
|
|
11
|
+
USERNAME=""
|
|
12
|
+
|
|
13
|
+
usage() {
|
|
14
|
+
cat <<'TEXT'
|
|
15
|
+
Usage: ./bin/enable --database DATABASE --migration-role ROLE --read-role ROLE [OPTIONS]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--schema NAME extension schema (default: multi_compress)
|
|
19
|
+
--migration-role ROLE role that creates the generated view migration
|
|
20
|
+
--read-role ROLE role that reads the generated view
|
|
21
|
+
--host HOST PostgreSQL host passed to psql
|
|
22
|
+
--port PORT PostgreSQL port passed to psql
|
|
23
|
+
--username USER PostgreSQL user passed to psql
|
|
24
|
+
--psql PATH psql binary (default: psql)
|
|
25
|
+
TEXT
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
die() {
|
|
29
|
+
echo "multi_compress postgres enable: $*" >&2
|
|
30
|
+
exit 2
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
validate_identifier() {
|
|
34
|
+
local value="$1"
|
|
35
|
+
local option="$2"
|
|
36
|
+
printf '%s' "$value" | grep -Eq '^[A-Za-z_][A-Za-z0-9_]*$' || die "$option must be a simple SQL identifier"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
while [ "$#" -gt 0 ]; do
|
|
40
|
+
case "$1" in
|
|
41
|
+
--database)
|
|
42
|
+
[ "$#" -ge 2 ] || die "--database requires a value"
|
|
43
|
+
DATABASE="$2"
|
|
44
|
+
shift 2
|
|
45
|
+
;;
|
|
46
|
+
--schema)
|
|
47
|
+
[ "$#" -ge 2 ] || die "--schema requires a value"
|
|
48
|
+
SCHEMA="$2"
|
|
49
|
+
shift 2
|
|
50
|
+
;;
|
|
51
|
+
--migration-role)
|
|
52
|
+
[ "$#" -ge 2 ] || die "--migration-role requires a value"
|
|
53
|
+
MIGRATION_ROLE="$2"
|
|
54
|
+
shift 2
|
|
55
|
+
;;
|
|
56
|
+
--read-role)
|
|
57
|
+
[ "$#" -ge 2 ] || die "--read-role requires a value"
|
|
58
|
+
READ_ROLE="$2"
|
|
59
|
+
shift 2
|
|
60
|
+
;;
|
|
61
|
+
--host)
|
|
62
|
+
[ "$#" -ge 2 ] || die "--host requires a value"
|
|
63
|
+
HOST="$2"
|
|
64
|
+
shift 2
|
|
65
|
+
;;
|
|
66
|
+
--port)
|
|
67
|
+
[ "$#" -ge 2 ] || die "--port requires a value"
|
|
68
|
+
PORT="$2"
|
|
69
|
+
shift 2
|
|
70
|
+
;;
|
|
71
|
+
--username)
|
|
72
|
+
[ "$#" -ge 2 ] || die "--username requires a value"
|
|
73
|
+
USERNAME="$2"
|
|
74
|
+
shift 2
|
|
75
|
+
;;
|
|
76
|
+
--psql)
|
|
77
|
+
[ "$#" -ge 2 ] || die "--psql requires a path"
|
|
78
|
+
PSQL="$2"
|
|
79
|
+
shift 2
|
|
80
|
+
;;
|
|
81
|
+
--help|-h)
|
|
82
|
+
usage
|
|
83
|
+
exit 0
|
|
84
|
+
;;
|
|
85
|
+
*)
|
|
86
|
+
die "unknown option: $1"
|
|
87
|
+
;;
|
|
88
|
+
esac
|
|
89
|
+
done
|
|
90
|
+
|
|
91
|
+
[ -n "$DATABASE" ] || die "--database is required"
|
|
92
|
+
[ -n "$MIGRATION_ROLE" ] || die "--migration-role is required"
|
|
93
|
+
[ -n "$READ_ROLE" ] || die "--read-role is required"
|
|
94
|
+
validate_identifier "$SCHEMA" '--schema'
|
|
95
|
+
validate_identifier "$MIGRATION_ROLE" '--migration-role'
|
|
96
|
+
validate_identifier "$READ_ROLE" '--read-role'
|
|
97
|
+
|
|
98
|
+
if [[ "$PSQL" == */* ]]; then
|
|
99
|
+
[ -x "$PSQL" ] || die "psql is not executable: $PSQL"
|
|
100
|
+
else
|
|
101
|
+
PSQL="$(command -v "$PSQL" || true)"
|
|
102
|
+
[ -n "$PSQL" ] || die "psql was not found"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
ARGS=(-X -v ON_ERROR_STOP=1 -d "$DATABASE")
|
|
106
|
+
[ -n "$HOST" ] && ARGS+=(-h "$HOST")
|
|
107
|
+
[ -n "$PORT" ] && ARGS+=(-p "$PORT")
|
|
108
|
+
[ -n "$USERNAME" ] && ARGS+=(-U "$USERNAME")
|
|
109
|
+
|
|
110
|
+
SERVER_ENCODING="$("$PSQL" "${ARGS[@]}" -Atq -c 'SHOW server_encoding;' | tr -d '\r\n')"
|
|
111
|
+
[ "$SERVER_ENCODING" = 'UTF8' ] || die "database $DATABASE uses $SERVER_ENCODING; multi_compress requires UTF8"
|
|
112
|
+
|
|
113
|
+
EXISTING_SCHEMA="$("$PSQL" "${ARGS[@]}" -Atq -c "SELECT n.nspname FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = 'multi_compress';" | tr -d '\r\n')"
|
|
114
|
+
if [ -n "$EXISTING_SCHEMA" ] && [ "$EXISTING_SCHEMA" != "$SCHEMA" ]; then
|
|
115
|
+
die "multi_compress is already installed in schema $EXISTING_SCHEMA; requested schema is $SCHEMA"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
if [ -z "$EXISTING_SCHEMA" ]; then
|
|
119
|
+
"$PSQL" "${ARGS[@]}" <<SQL
|
|
120
|
+
CREATE SCHEMA IF NOT EXISTS "$SCHEMA";
|
|
121
|
+
CREATE EXTENSION multi_compress WITH SCHEMA "$SCHEMA";
|
|
122
|
+
SQL
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
"$PSQL" "${ARGS[@]}" <<SQL
|
|
126
|
+
REVOKE ALL ON SCHEMA "$SCHEMA" FROM PUBLIC;
|
|
127
|
+
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA "$SCHEMA" FROM PUBLIC;
|
|
128
|
+
GRANT USAGE ON SCHEMA "$SCHEMA" TO "$MIGRATION_ROLE";
|
|
129
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA "$SCHEMA" TO "$MIGRATION_ROLE", "$READ_ROLE";
|
|
130
|
+
SELECT "$SCHEMA".multi_compress_db_version() AS multi_compress_version;
|
|
131
|
+
SQL
|
|
132
|
+
|
|
133
|
+
printf '%s\n' \
|
|
134
|
+
"MultiCompress is enabled in $DATABASE.$SCHEMA." \
|
|
135
|
+
"Grant SELECT on each generated readable view to $READ_ROLE in the application migration." \
|
|
136
|
+
'Apply the generated readable-view SQL in the application migration, then query that view from DBeaver.'
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
DEFAULT_PG_CONFIG="${PG_CONFIG:-pg_config}"
|
|
6
|
+
BUILD_PG_CONFIG="$DEFAULT_PG_CONFIG"
|
|
7
|
+
RUNTIME_PG_CONFIG="$DEFAULT_PG_CONFIG"
|
|
8
|
+
|
|
9
|
+
usage() {
|
|
10
|
+
cat <<'TEXT'
|
|
11
|
+
Usage: sudo ./bin/install [--pg-config PATH]
|
|
12
|
+
|
|
13
|
+
Builds the extension on this PostgreSQL host and installs its library, control
|
|
14
|
+
file and extension SQL into the directories reported by the selected pg_config.
|
|
15
|
+
|
|
16
|
+
Advanced: --build-pg-config and --runtime-pg-config let a container build with
|
|
17
|
+
separate development headers while installing into the running server's paths.
|
|
18
|
+
Ordinary installations should use only --pg-config, or no option at all.
|
|
19
|
+
TEXT
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
die() {
|
|
23
|
+
echo "multi_compress postgres install: $*" >&2
|
|
24
|
+
exit 2
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
resolve_pg_config() {
|
|
28
|
+
local candidate="$1"
|
|
29
|
+
|
|
30
|
+
if [[ "$candidate" == */* ]]; then
|
|
31
|
+
[ -x "$candidate" ] || die "pg_config is not executable: $candidate"
|
|
32
|
+
printf '%s\n' "$candidate"
|
|
33
|
+
else
|
|
34
|
+
candidate="$(command -v "$candidate" || true)"
|
|
35
|
+
[ -n "$candidate" ] || die "pg_config was not found"
|
|
36
|
+
printf '%s\n' "$candidate"
|
|
37
|
+
fi
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pg_major() {
|
|
41
|
+
"$1" --version | awk '{print $2}' | cut -d. -f1
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
while [ "$#" -gt 0 ]; do
|
|
45
|
+
case "$1" in
|
|
46
|
+
--pg-config)
|
|
47
|
+
[ "$#" -ge 2 ] || die "--pg-config requires a path"
|
|
48
|
+
BUILD_PG_CONFIG="$2"
|
|
49
|
+
RUNTIME_PG_CONFIG="$2"
|
|
50
|
+
shift 2
|
|
51
|
+
;;
|
|
52
|
+
--build-pg-config)
|
|
53
|
+
[ "$#" -ge 2 ] || die "--build-pg-config requires a path"
|
|
54
|
+
BUILD_PG_CONFIG="$2"
|
|
55
|
+
shift 2
|
|
56
|
+
;;
|
|
57
|
+
--runtime-pg-config)
|
|
58
|
+
[ "$#" -ge 2 ] || die "--runtime-pg-config requires a path"
|
|
59
|
+
RUNTIME_PG_CONFIG="$2"
|
|
60
|
+
shift 2
|
|
61
|
+
;;
|
|
62
|
+
--help|-h)
|
|
63
|
+
usage
|
|
64
|
+
exit 0
|
|
65
|
+
;;
|
|
66
|
+
*)
|
|
67
|
+
die "unknown option: $1"
|
|
68
|
+
;;
|
|
69
|
+
esac
|
|
70
|
+
done
|
|
71
|
+
|
|
72
|
+
[ "${EUID:-$(id -u)}" -eq 0 ] || die "run as root: sudo ./bin/install"
|
|
73
|
+
BUILD_PG_CONFIG="$(resolve_pg_config "$BUILD_PG_CONFIG")"
|
|
74
|
+
RUNTIME_PG_CONFIG="$(resolve_pg_config "$RUNTIME_PG_CONFIG")"
|
|
75
|
+
|
|
76
|
+
BUILD_MAJOR="$(pg_major "$BUILD_PG_CONFIG")"
|
|
77
|
+
RUNTIME_MAJOR="$(pg_major "$RUNTIME_PG_CONFIG")"
|
|
78
|
+
case "$BUILD_MAJOR" in
|
|
79
|
+
""|*[!0-9]*) die "could not determine PostgreSQL build major version" ;;
|
|
80
|
+
esac
|
|
81
|
+
case "$RUNTIME_MAJOR" in
|
|
82
|
+
""|*[!0-9]*) die "could not determine PostgreSQL runtime major version" ;;
|
|
83
|
+
esac
|
|
84
|
+
[ "$BUILD_MAJOR" = "$RUNTIME_MAJOR" ] || die "build PostgreSQL major $BUILD_MAJOR does not match runtime major $RUNTIME_MAJOR"
|
|
85
|
+
|
|
86
|
+
"$ROOT/bin/doctor" --pg-config "$BUILD_PG_CONFIG"
|
|
87
|
+
|
|
88
|
+
make -C "$ROOT/postgres_extension" clean all PG_CONFIG="$BUILD_PG_CONFIG"
|
|
89
|
+
|
|
90
|
+
PKGLIBDIR="$($RUNTIME_PG_CONFIG --pkglibdir)"
|
|
91
|
+
EXTENSION_DIR="$($RUNTIME_PG_CONFIG --sharedir)/extension"
|
|
92
|
+
install -d -m 0755 "$PKGLIBDIR" "$EXTENSION_DIR"
|
|
93
|
+
install -m 0755 "$ROOT/postgres_extension/multi_compress_pg.so" "$PKGLIBDIR/multi_compress_pg.so"
|
|
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"
|
|
96
|
+
|
|
97
|
+
printf '%s\n' \
|
|
98
|
+
'MultiCompress PostgreSQL files installed.' \
|
|
99
|
+
" library: $PKGLIBDIR/multi_compress_pg.so" \
|
|
100
|
+
" control: $EXTENSION_DIR/multi_compress.control" \
|
|
101
|
+
" SQL: $EXTENSION_DIR/multi_compress--0.5.0.sql" \
|
|
102
|
+
'' \
|
|
103
|
+
'Activate once per application database:' \
|
|
104
|
+
' sudo -u postgres make enable DB=app_production'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
PG_CONFIG="${PG_CONFIG:-pg_config}"
|
|
6
|
+
|
|
7
|
+
usage() {
|
|
8
|
+
cat <<'TEXT'
|
|
9
|
+
Usage: sudo ./bin/uninstall [--pg-config PATH]
|
|
10
|
+
|
|
11
|
+
Removes installed PostgreSQL extension files from this host. Drop the extension
|
|
12
|
+
from every database first; this command cannot discover all databases/views. The
|
|
13
|
+
Makefile requires CONFIRM=REMOVE_MULTI_COMPRESS before this script can run.
|
|
14
|
+
TEXT
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
die() {
|
|
18
|
+
echo "multi_compress postgres uninstall: $*" >&2
|
|
19
|
+
exit 2
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
while [ "$#" -gt 0 ]; do
|
|
23
|
+
case "$1" in
|
|
24
|
+
--pg-config)
|
|
25
|
+
[ "$#" -ge 2 ] || die "--pg-config requires a path"
|
|
26
|
+
PG_CONFIG="$2"
|
|
27
|
+
shift 2
|
|
28
|
+
;;
|
|
29
|
+
--help|-h)
|
|
30
|
+
usage
|
|
31
|
+
exit 0
|
|
32
|
+
;;
|
|
33
|
+
*)
|
|
34
|
+
die "unknown option: $1"
|
|
35
|
+
;;
|
|
36
|
+
esac
|
|
37
|
+
done
|
|
38
|
+
|
|
39
|
+
[ "${EUID:-$(id -u)}" -eq 0 ] || die "run as root: sudo ./bin/uninstall"
|
|
40
|
+
|
|
41
|
+
if [[ "$PG_CONFIG" == */* ]]; then
|
|
42
|
+
[ -x "$PG_CONFIG" ] || die "pg_config is not executable: $PG_CONFIG"
|
|
43
|
+
else
|
|
44
|
+
PG_CONFIG="$(command -v "$PG_CONFIG" || true)"
|
|
45
|
+
[ -n "$PG_CONFIG" ] || die "pg_config was not found"
|
|
46
|
+
fi
|
|
47
|
+
PKGLIBDIR="$($PG_CONFIG --pkglibdir)"
|
|
48
|
+
EXTENSION_DIR="$($PG_CONFIG --sharedir)/extension"
|
|
49
|
+
|
|
50
|
+
rm -f "$PKGLIBDIR/multi_compress_pg.so" \
|
|
51
|
+
"$EXTENSION_DIR/multi_compress.control" \
|
|
52
|
+
"$EXTENSION_DIR/multi_compress--0.5.0.sql"
|
|
53
|
+
|
|
54
|
+
echo "MultiCompress PostgreSQL files removed from this host."
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
cd "$ROOT"
|
|
6
|
+
|
|
7
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
8
|
+
sha256sum -c SHA256SUMS
|
|
9
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
10
|
+
shasum -a 256 -c SHA256SUMS
|
|
11
|
+
else
|
|
12
|
+
echo "multi_compress postgres verify: sha256sum or shasum is required" >&2
|
|
13
|
+
exit 2
|
|
14
|
+
fi
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# MultiCompress Database Envelope — Format v1 (`MCDB1`)
|
|
2
|
+
|
|
3
|
+
Status: **frozen**. This is a long-term storage contract. Once data is written
|
|
4
|
+
to a database column, these bytes must remain readable forever, so v1 will not
|
|
5
|
+
change. New capabilities go into a future `v2` with a new version byte.
|
|
6
|
+
|
|
7
|
+
Two independent implementations MUST agree on this document byte-for-byte:
|
|
8
|
+
|
|
9
|
+
* the Ruby writer/reader — `MultiCompress::Database` (`lib/multi_compress/database.rb`)
|
|
10
|
+
* the MySQL 5.7 reader — `multi_compress_db_decompress()` UDF (`mysql_udf/`)
|
|
11
|
+
* the PostgreSQL reader — `multi_compress_db_decompress(bytea)` extension function
|
|
12
|
+
(`postgres_extension/`)
|
|
13
|
+
|
|
14
|
+
## Scope of v1 (deliberately narrow)
|
|
15
|
+
|
|
16
|
+
| Aspect | v1 rule |
|
|
17
|
+
|---------------------|------------------------------------------------------|
|
|
18
|
+
| Algorithm | **zstd only** (no lz4, brotli) |
|
|
19
|
+
| Dictionaries | **not allowed** |
|
|
20
|
+
| Transport encoding | **raw bytes** (no base64) |
|
|
21
|
+
| Payload text | **UTF-8 without U+0000/NUL** (validated on write) |
|
|
22
|
+
| Column type (MySQL) | **`LONGBLOB`** recommended; `MEDIUMBLOB` only if the stored envelope stays < 16 MiB (see note) |
|
|
23
|
+
| Column type (PostgreSQL) | **`bytea`** |
|
|
24
|
+
| Max decompressed | **16 MiB** (16777216 bytes), enforced on read AND write |
|
|
25
|
+
|
|
26
|
+
Anything outside this table is a v2 concern and is out of scope for v1.
|
|
27
|
+
|
|
28
|
+
**Column sizing.** The 16 MiB cap is on the *decompressed* text. What the column
|
|
29
|
+
must hold is the *stored envelope* (`19 bytes + zstd frame`), which is normally
|
|
30
|
+
far smaller than the text. But `MEDIUMBLOB` tops out at `2^24 - 1 = 16,777,215`
|
|
31
|
+
bytes, one byte below the plaintext cap, and incompressible input can make the
|
|
32
|
+
envelope exceed the plaintext size. So v1 recommends **`LONGBLOB`**. Use
|
|
33
|
+
`MEDIUMBLOB` only if the application also checks the final `blob.bytesize` before
|
|
34
|
+
insert.
|
|
35
|
+
|
|
36
|
+
## Byte layout
|
|
37
|
+
|
|
38
|
+
All multi-byte integers are **little-endian**. The envelope is a fixed 19-byte
|
|
39
|
+
header followed by a standard zstd frame:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
offset size field value / meaning
|
|
43
|
+
------ ---- -------------- -----------------------------------------------
|
|
44
|
+
0 4 magic ASCII "MCDB" = 0x4D 0x43 0x44 0x42
|
|
45
|
+
4 1 format_version 0x01
|
|
46
|
+
5 1 codec 0x01 = zstd (only value allowed in v1)
|
|
47
|
+
6 1 flags 0x00 (reserved; MUST be 0 in v1)
|
|
48
|
+
7 8 original_size uint64 LE: byte length of the original UTF-8 text
|
|
49
|
+
15 4 crc32 uint32 LE: CRC-32 (zlib/IEEE) of the original text
|
|
50
|
+
19 N zstd_frame a standard zstd frame produced from the text
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Total stored size = `19 + N` bytes.
|
|
54
|
+
|
|
55
|
+
`original_size` is the length of the **decompressed** UTF-8 bytes, stored
|
|
56
|
+
explicitly so a reader can pre-allocate exactly. `crc32` is the standard
|
|
57
|
+
zlib/IEEE CRC-32 of those same original bytes.
|
|
58
|
+
|
|
59
|
+
**Why an explicit CRC-32.** A raw zstd frame carries no content checksum by
|
|
60
|
+
default, so a single flipped byte in the payload can decode to a value of the
|
|
61
|
+
correct length with corrupted contents that zstd cannot detect. v1 therefore
|
|
62
|
+
stores a CRC-32 of the plaintext and every reader verifies it after
|
|
63
|
+
decompression. This matches Ruby's `MultiCompress.crc32`.
|
|
64
|
+
|
|
65
|
+
CRC-32 detects accidental corruption (bit flips, truncation, wrong bytes) with a
|
|
66
|
+
small residual collision probability; it is **not** a cryptographic integrity or
|
|
67
|
+
authenticity mechanism. For trusted database storage that is the intended use.
|
|
68
|
+
If envelopes could be altered by an untrusted party, a keyed cryptographic
|
|
69
|
+
digest would be required — that is a different, future format, not v1.
|
|
70
|
+
|
|
71
|
+
### Empty text
|
|
72
|
+
|
|
73
|
+
An empty string is valid: `original_size = 0`, and `zstd_frame` is the zstd
|
|
74
|
+
frame of a zero-length input. The reader returns an empty string.
|
|
75
|
+
|
|
76
|
+
## Writer algorithm (compress)
|
|
77
|
+
|
|
78
|
+
1. Require input is a `String` whose bytes are valid UTF-8 and contain no NUL byte; else error.
|
|
79
|
+
2. Let `n = bytesize`. If `n > 16777216`, error (too large for v1).
|
|
80
|
+
3. `crc = crc32(bytes)` (zlib/IEEE).
|
|
81
|
+
4. `frame = zstd_compress(bytes)`.
|
|
82
|
+
5. Emit `"MCDB" + 0x01 + 0x01 + 0x00 + le_uint64(n) + le_uint32(crc) + frame`.
|
|
83
|
+
|
|
84
|
+
## Reader algorithm (decompress / validate)
|
|
85
|
+
|
|
86
|
+
A reader MUST reject any input that fails any check below. Ruby raises
|
|
87
|
+
`MultiCompress::DataError`, PostgreSQL raises a SQL error, and the MySQL UDF
|
|
88
|
+
returns SQL `NULL` while `multi_compress_db_is_valid(blob)` returns `0`.
|
|
89
|
+
|
|
90
|
+
1. `19 <= length <= 16842771` (a complete, bounded envelope).
|
|
91
|
+
2. `magic == "MCDB"`.
|
|
92
|
+
3. `format_version == 0x01`.
|
|
93
|
+
4. `codec == 0x01`.
|
|
94
|
+
5. `flags == 0x00`.
|
|
95
|
+
6. `original_size <= 16777216`.
|
|
96
|
+
7. The payload is **exactly one** standard zstd frame: no skippable frame,
|
|
97
|
+
concatenated frame, or trailing byte is allowed.
|
|
98
|
+
8. The zstd frame declares a content size and that size equals `original_size`.
|
|
99
|
+
9. `zstd_decompress(frame)` succeeds with a bounded output of at most
|
|
100
|
+
`original_size` bytes.
|
|
101
|
+
10. The produced byte length equals `original_size` exactly.
|
|
102
|
+
11. `crc32(produced) == crc32` from the header.
|
|
103
|
+
12. The produced bytes are valid UTF-8 and contain no NUL byte.
|
|
104
|
+
|
|
105
|
+
On success the reader returns a UTF-8 string. MySQL callers can expose it with
|
|
106
|
+
`CONVERT(... USING utf8mb4)`. PostgreSQL callers use the `text`-returning
|
|
107
|
+
extension function and therefore require a UTF-8 database encoding.
|
|
108
|
+
|
|
109
|
+
`multi_compress_db_is_valid(blob)` performs checks 1–12 and returns 1/0 without
|
|
110
|
+
raising, so it can be used in `WHERE`/`CASE`.
|
|
111
|
+
|
|
112
|
+
## Error taxonomy
|
|
113
|
+
|
|
114
|
+
| Condition | Ruby | PostgreSQL extension | MySQL UDF |
|
|
115
|
+
|-----------|------|----------------------|-----------|
|
|
116
|
+
| truncated / oversized envelope / bad magic / version | `MultiCompress::DataError` | SQL error | `NULL`, `is_valid = 0` |
|
|
117
|
+
| codec/flags not v1 | `MultiCompress::DataError` | SQL error | `NULL`, `is_valid = 0` |
|
|
118
|
+
| `original_size` over the cap | `MultiCompress::DataError` | SQL error | `NULL`, `is_valid = 0` |
|
|
119
|
+
| invalid, trailing, or concatenated zstd frame / size mismatch | `MultiCompress::DataError` | SQL error | `NULL`, `is_valid = 0` |
|
|
120
|
+
| crc32 mismatch (payload corruption) | `MultiCompress::DataError` | SQL error | `NULL`, `is_valid = 0` |
|
|
121
|
+
| non-UTF-8 or NUL text on write or read | `ArgumentError` / `MultiCompress::DataError` | SQL error | `NULL`, `is_valid = 0` |
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
**MySQL NULL diagnostics.** In a MySQL readable view, `payload IS NULL` cannot
|
|
125
|
+
distinguish an original SQL `NULL` from a corrupt MCDB1 envelope. Diagnose a
|
|
126
|
+
specific stored value with `multi_compress_db_is_valid(payload_compressed)`.
|
|
127
|
+
|
|
128
|
+
## Versioning policy
|
|
129
|
+
|
|
130
|
+
* The 4-byte magic `MCDB` is a reserved namespace.
|
|
131
|
+
* `format_version` gates the whole layout. A reader that does not recognise the
|
|
132
|
+
version MUST error, not guess.
|
|
133
|
+
* v1 is immutable. Format changes ship as v2 (`format_version = 0x02`) with its
|
|
134
|
+
own spec document; readers dispatch on the version byte.
|
|
135
|
+
|
|
136
|
+
## Migration note
|
|
137
|
+
|
|
138
|
+
v1 has **no legacy/plaintext auto-detection**. To migrate an existing text
|
|
139
|
+
column, add a new `LONGBLOB` column and write the envelope there; never
|
|
140
|
+
reinterpret old plaintext bytes as an envelope. See the rollout steps in
|
|
141
|
+
`mysql_udf/README.md` or `postgres_extension/README.md`.
|
|
142
|
+
|
|
143
|
+
## Deployment and integrity notes
|
|
144
|
+
|
|
145
|
+
`make verify` detects accidental corruption after extraction. It does not
|
|
146
|
+
authenticate an archive because the archive's own Makefile is executed during
|
|
147
|
+
installation. Use an out-of-band SHA-256, a minisign/GPG signature, or signed
|
|
148
|
+
release provenance for a real trust chain.
|
|
149
|
+
|
|
150
|
+
For MySQL, `@@GLOBAL.max_allowed_packet`, the Ruby driver, and the DBeaver/JDBC
|
|
151
|
+
connection must all allow MCDB1 payloads up to the 16 MiB limit; the deployment
|
|
152
|
+
bundle's mysql client uses `--max_allowed_packet=32M` but cannot configure Rails
|
|
153
|
+
or DBeaver.
|
|
154
|
+
|
|
155
|
+
Install the native library on every host that will read through a generated
|
|
156
|
+
view—primary, replicas, reporting, failover, and restore targets—before
|
|
157
|
+
registering UDFs, creating the PostgreSQL extension, or applying a view
|
|
158
|
+
migration. A read-only view does not itself enter MySQL replication; replication
|
|
159
|
+
requires the UDF on a replica only when a replicated statement invokes it.
|