From 0072f2a15f81e920c95d7ea31f3cff9d49a44e60 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Tue, 2 Jun 2026 13:02:40 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20ALTER=20spam=20=D0=B2=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D1=85=20=E2=80=94=20DDL=20=D0=BD=D0=B0=20qt=5Fclie?= =?UTF-8?q?nt=5Fschema=5Fstate=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BD=D1=83=D0=B6=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше ensureClientSchemaStateTable запускался на каждом цикле синка (каждые 5 минут) и пытался ALTER TABLE, даже если все колонки уже были. Для пользователей без DDL-прав это давало WARN-спам в каждом цикле. Два изменения: - schemaOnce (sync.Once) на Service: ensureClientSchemaStateTable вызывается не более одного раза за жизнь процесса - columnExists() проверяет information_schema.COLUMNS перед каждым ALTER — если колонка уже есть, ALTER пропускается без ошибки Если таблица уже мигрирована сервером, клиент молча пропускает все DDL. Co-Authored-By: Claude Sonnet 4.6 --- internal/services/sync/readiness.go | 91 ++++++++++++++++++----------- internal/services/sync/service.go | 1 + 2 files changed, 59 insertions(+), 33 deletions(-) diff --git a/internal/services/sync/readiness.go b/internal/services/sync/readiness.go index f270d41..a0a2879 100644 --- a/internal/services/sync/readiness.go +++ b/internal/services/sync/readiness.go @@ -76,6 +76,11 @@ func (s *Service) GetReadiness() (*SyncReadiness, error) { ) } + s.schemaOnce.Do(func() { + if err := ensureClientSchemaStateTable(mariaDB); err != nil { + slog.Warn("qt_client_schema_state migration skipped (no DDL rights — run server migrate)", "error", err) + } + }) if err := s.reportClientSchemaState(mariaDB, now); err != nil { slog.Warn("failed to report client schema state", "error", err) } @@ -141,39 +146,51 @@ func ensureClientSchemaStateTable(db *gorm.DB) error { } if tableExists(db, "qt_client_schema_state") { - if err := db.Exec(` - ALTER TABLE qt_client_schema_state - ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username - `).Error; err != nil { - return fmt.Errorf("add qt_client_schema_state.hostname: %w", err) + // Each ALTER is guarded by a column existence check so users without DDL + // rights don't get a permission error on every sync cycle — the server + // migration tool is the authoritative path for schema changes. + if !columnExists(db, "qt_client_schema_state", "hostname") { + if err := db.Exec(` + ALTER TABLE qt_client_schema_state + ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username + `).Error; err != nil { + return fmt.Errorf("add qt_client_schema_state.hostname: %w", err) + } + if err := db.Exec(` + ALTER TABLE qt_client_schema_state + DROP PRIMARY KEY, + ADD PRIMARY KEY (username, hostname) + `).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) { + return fmt.Errorf("set qt_client_schema_state primary key: %w", err) + } } - if err := db.Exec(` - ALTER TABLE qt_client_schema_state - DROP PRIMARY KEY, - ADD PRIMARY KEY (username, hostname) - `).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) { - return fmt.Errorf("set qt_client_schema_state primary key: %w", err) + type colMigration struct { + column string + stmt string } - - for _, stmt := range []string{ - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count", - "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count", - } { - if err := db.Exec(stmt).Error; err != nil { + migrations := []colMigration{ + {"last_sync_at", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version"}, + {"last_sync_status", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at"}, + {"pending_changes_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status"}, + {"pending_errors_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count"}, + {"configurations_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count"}, + {"projects_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count"}, + {"estimate_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count"}, + {"warehouse_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version"}, + {"competitor_pricelist_version", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version"}, + {"last_sync_error_code", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version"}, + {"last_sync_error_text", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code"}, + {"local_pricelist_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text"}, + {"pricelist_items_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count"}, + {"components_count", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count"}, + {"db_size_bytes", "ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count"}, + } + for _, m := range migrations { + if columnExists(db, "qt_client_schema_state", m.column) { + continue + } + if err := db.Exec(m.stmt).Error; err != nil { return fmt.Errorf("expand qt_client_schema_state: %w", err) } } @@ -181,6 +198,17 @@ func ensureClientSchemaStateTable(db *gorm.DB) error { return nil } +func columnExists(db *gorm.DB, tableName, columnName string) bool { + var count int64 + if err := db.Raw(` + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? + `, tableName, columnName).Scan(&count).Error; err != nil { + return false + } + return count > 0 +} + func tableExists(db *gorm.DB, tableName string) bool { var count int64 // For MariaDB/MySQL, check information_schema @@ -197,9 +225,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") { return nil } - if err := ensureClientSchemaStateTable(mariaDB); err != nil { - return err - } username := strings.TrimSpace(s.localDB.GetDBUser()) if username == "" { return nil diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 689dbc7..d8b58f1 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -28,6 +28,7 @@ type Service struct { localDB *localdb.LocalDB directDB *gorm.DB pricelistMu sync.Mutex // prevents concurrent pricelist syncs + schemaOnce sync.Once // ensures ensureClientSchemaStateTable runs at most once per process } // NewService creates a new sync service