From ecad474785852aa28c94939d1fbc4e35603bcfbd Mon Sep 17 00:00:00 2001 From: Hungerdream <1710233908@qq.com> Date: Wed, 29 Jul 2026 13:50:03 +0800 Subject: [PATCH] feat(delivery): enforce instance name uniqueness at task creation Wrap task creation in a transaction that locks the business line row (SELECT ... FOR UPDATE) so concurrent requests cannot both pass the availability check. A name is considered occupied by in-flight or retained tasks (pending through registering, finished, register_failed, canceling, rollback_pending/rolling_back/rollback_failed) and by active deployment results; it becomes reusable only after rollback or explicit cleanup releases it. --- server/internal/service/delivery.go | 52 ++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/server/internal/service/delivery.go b/server/internal/service/delivery.go index 50f34af..2415f0c 100644 --- a/server/internal/service/delivery.go +++ b/server/internal/service/delivery.go @@ -552,7 +552,18 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin PayloadHash: hex.EncodeToString(digest[:]), IdempotencyKey: idempotencyKey, } - if err := s.db.WithContext(ctx).Create(&task).Error; err != nil { + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Serialize name allocation per business line so concurrent requests cannot + // pass the availability check and create the same instance identity. + var businessLine model.BusinessLine + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&businessLine, input.BusinessLineID).Error; err != nil { + return err + } + if err := ensureInstanceNameAvailable(tx, input.BusinessLineID, task.Component, input.InstanceName); err != nil { + return err + } + return tx.Create(&task).Error + }); err != nil { if lookupErr := s.db.WithContext(ctx).Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; lookupErr == nil { return &existing, true, nil } @@ -567,6 +578,45 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin return &task, false, nil } +// ensureInstanceNameAvailable treats in-flight tasks, retained failed deployments, +// and active results as name reservations. A name is reusable only after rollback +// or explicit cleanup has released the corresponding task/result. +func ensureInstanceNameAvailable(tx *gorm.DB, businessLineID uint64, component, instanceName string) error { + occupiedTaskStatuses := []string{ + model.TaskPending, + model.TaskValidating, + model.TaskDispatching, + model.TaskRunning, + model.TaskRegistering, + model.TaskFinished, + model.TaskRegisterFailed, + model.TaskCanceling, + model.TaskRollbackPending, + model.TaskRollingBack, + model.TaskRollbackFailed, + } + var taskCount int64 + if err := tx.Model(&model.DeliveryTask{}). + Where("business_line_id = ? AND component = ? AND instance_name = ? AND status IN ?", businessLineID, component, instanceName, occupiedTaskStatuses). + Count(&taskCount).Error; err != nil { + return err + } + if taskCount > 0 { + return fmt.Errorf("instance_name %q is already in use by an active or pending delivery", instanceName) + } + + var resultCount int64 + if err := tx.Model(&model.DeploymentResult{}). + Where("business_line_id = ? AND component = ? AND instance_name = ? AND status = ?", businessLineID, component, instanceName, "active"). + Count(&resultCount).Error; err != nil { + return err + } + if resultCount > 0 { + return fmt.Errorf("instance_name %q is already in use by a delivered instance", instanceName) + } + return nil +} + // 版本白名单与 playbook 的 mysql_package_map 保持同步: // 8.0 走 Ubuntu 自带源,8.4 走 MySQL 官方 APT 源;5.6/5.7 已 EOL 且无 noble 包,不支持。 var supportedMySQLVersions = map[string]bool{"8.0": true, "8.4": true}