From c96d3db33764e22a97da8b424de0ae458aa1f2ae Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Wed, 11 Mar 2026 12:52:41 +0300 Subject: [PATCH] FIX (mysql): Detect supported compression levels --- .../usecases/mysql/create_backup_uc.go | 27 ++++++++++---- .../databases/databases/mysql/model.go | 35 +++++++++++++++---- .../databases/databases/mysql/model_test.go | 32 +++++++++++++++++ ...1094356_add_is_zstd_supported_to_mysql.sql | 11 ++++++ 4 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/20260311094356_add_is_zstd_supported_to_mysql.sql diff --git a/backend/internal/features/backups/backups/usecases/mysql/create_backup_uc.go b/backend/internal/features/backups/backups/usecases/mysql/create_backup_uc.go index de591a6..f729503 100644 --- a/backend/internal/features/backups/backups/usecases/mysql/create_backup_uc.go +++ b/backend/internal/features/backups/backups/usecases/mysql/create_backup_uc.go @@ -118,7 +118,7 @@ func (uc *CreateMysqlBackupUsecase) buildMysqldumpArgs(my *mysqltypes.MysqlDatab args = append(args, "--events") } - args = append(args, uc.getNetworkCompressionArgs(my.Version)...) + args = append(args, uc.getNetworkCompressionArgs(my)...) if !config.GetEnv().IsCloud { args = append(args, "--max-allowed-packet=1G") @@ -135,15 +135,21 @@ func (uc *CreateMysqlBackupUsecase) buildMysqldumpArgs(my *mysqltypes.MysqlDatab return args } -func (uc *CreateMysqlBackupUsecase) getNetworkCompressionArgs(version tools.MysqlVersion) []string { +func (uc *CreateMysqlBackupUsecase) getNetworkCompressionArgs( + my *mysqltypes.MysqlDatabase, +) []string { const zstdCompressionLevel = 5 - switch version { + switch my.Version { case tools.MysqlVersion80, tools.MysqlVersion84, tools.MysqlVersion9: - return []string{ - "--compression-algorithms=zstd", - fmt.Sprintf("--zstd-compression-level=%d", zstdCompressionLevel), + if my.IsZstdSupported { + return []string{ + "--compression-algorithms=zstd", + fmt.Sprintf("--zstd-compression-level=%d", zstdCompressionLevel), + } } + + return []string{"--compress"} case tools.MysqlVersion57: return []string{"--compress"} default: @@ -589,6 +595,15 @@ func (uc *CreateMysqlBackupUsecase) handleConnectionErrors(stderrStr string) err ) } + if containsIgnoreCase(stderrStr, "compression algorithm") || + containsIgnoreCase(stderrStr, "2066") { + return fmt.Errorf( + "MySQL connection failed due to unsupported compression algorithm. "+ + "Try re-saving the database connection to re-detect compression support. stderr: %s", + stderrStr, + ) + } + if containsIgnoreCase(stderrStr, "unknown database") { return fmt.Errorf( "MySQL database does not exist. stderr: %s", diff --git a/backend/internal/features/databases/databases/mysql/model.go b/backend/internal/features/databases/databases/mysql/model.go index 96c626b..f653443 100644 --- a/backend/internal/features/databases/databases/mysql/model.go +++ b/backend/internal/features/databases/databases/mysql/model.go @@ -25,13 +25,14 @@ type MysqlDatabase struct { Version tools.MysqlVersion `json:"version" gorm:"type:text;not null"` - Host string `json:"host" gorm:"type:text;not null"` - Port int `json:"port" gorm:"type:int;not null"` - Username string `json:"username" gorm:"type:text;not null"` - Password string `json:"password" gorm:"type:text;not null"` - Database *string `json:"database" gorm:"type:text"` - IsHttps bool `json:"isHttps" gorm:"type:boolean;default:false"` - Privileges string `json:"privileges" gorm:"column:privileges;type:text;not null;default:''"` + Host string `json:"host" gorm:"type:text;not null"` + Port int `json:"port" gorm:"type:int;not null"` + Username string `json:"username" gorm:"type:text;not null"` + Password string `json:"password" gorm:"type:text;not null"` + Database *string `json:"database" gorm:"type:text"` + IsHttps bool `json:"isHttps" gorm:"type:boolean;default:false"` + Privileges string `json:"privileges" gorm:"column:privileges;type:text;not null;default:''"` + IsZstdSupported bool `json:"isZstdSupported" gorm:"column:is_zstd_supported;type:boolean;not null;default:true"` } func (m *MysqlDatabase) TableName() string { @@ -102,6 +103,7 @@ func (m *MysqlDatabase) TestConnection( return err } m.Privileges = privileges + m.IsZstdSupported = detectZstdSupport(ctx, db) if err := checkBackupPermissions(m.Privileges); err != nil { return err @@ -125,6 +127,7 @@ func (m *MysqlDatabase) Update(incoming *MysqlDatabase) { m.Database = incoming.Database m.IsHttps = incoming.IsHttps m.Privileges = incoming.Privileges + m.IsZstdSupported = incoming.IsZstdSupported if incoming.Password != "" { m.Password = incoming.Password @@ -185,6 +188,7 @@ func (m *MysqlDatabase) PopulateDbData( return err } m.Privileges = privileges + m.IsZstdSupported = detectZstdSupport(ctx, db) return nil } @@ -223,6 +227,7 @@ func (m *MysqlDatabase) PopulateVersion( return err } m.Version = detectedVersion + m.IsZstdSupported = detectZstdSupport(ctx, db) return nil } @@ -575,6 +580,22 @@ func checkBackupPermissions(privileges string) error { return nil } +// detectZstdSupport checks if the MySQL server supports zstd network compression. +// The protocol_compression_algorithms variable was introduced in MySQL 8.0.18. +// Managed MySQL providers (e.g. PlanetScale) may not support zstd even on 8.0+. +func detectZstdSupport(ctx context.Context, db *sql.DB) bool { + var varName, value string + + err := db.QueryRowContext(ctx, + "SHOW VARIABLES LIKE 'protocol_compression_algorithms'", + ).Scan(&varName, &value) + if err != nil { + return false + } + + return strings.Contains(strings.ToLower(value), "zstd") +} + func decryptPasswordIfNeeded( password string, encryptor encryption.FieldEncryptor, diff --git a/backend/internal/features/databases/databases/mysql/model_test.go b/backend/internal/features/databases/databases/mysql/model_test.go index 5eca837..68127a6 100644 --- a/backend/internal/features/databases/databases/mysql/model_test.go +++ b/backend/internal/features/databases/databases/mysql/model_test.go @@ -177,6 +177,38 @@ func Test_TestConnection_SufficientPermissions_Success(t *testing.T) { } } +func Test_TestConnection_DetectsZstdSupport(t *testing.T) { + env := config.GetEnv() + cases := []struct { + name string + version tools.MysqlVersion + port string + isExpectZstd bool + }{ + {"MySQL 5.7", tools.MysqlVersion57, env.TestMysql57Port, false}, + {"MySQL 8.0", tools.MysqlVersion80, env.TestMysql80Port, true}, + {"MySQL 8.4", tools.MysqlVersion84, env.TestMysql84Port, true}, + {"MySQL 9", tools.MysqlVersion9, env.TestMysql90Port, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + container := connectToMysqlContainer(t, tc.port, tc.version) + defer container.DB.Close() + + mysqlModel := createMysqlModel(container) + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + err := mysqlModel.TestConnection(logger, nil, uuid.New()) + assert.NoError(t, err) + assert.Equal(t, tc.isExpectZstd, mysqlModel.IsZstdSupported, + "IsZstdSupported mismatch for %s", tc.name) + }) + } +} + func Test_IsUserReadOnly_AdminUser_ReturnsFalse(t *testing.T) { env := config.GetEnv() cases := []struct { diff --git a/backend/migrations/20260311094356_add_is_zstd_supported_to_mysql.sql b/backend/migrations/20260311094356_add_is_zstd_supported_to_mysql.sql new file mode 100644 index 0000000..3e4f343 --- /dev/null +++ b/backend/migrations/20260311094356_add_is_zstd_supported_to_mysql.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE mysql_databases + ADD COLUMN is_zstd_supported BOOLEAN NOT NULL DEFAULT TRUE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE mysql_databases + DROP COLUMN is_zstd_supported; +-- +goose StatementEnd