补齐任务幂等并发保护

新增三数据库任务幂等登记迁移,在任务创建事务中防止重复幂等键,并补充并发回归测试与进度清单。
This commit is contained in:
xuans 2026-09-30 02:00:36 +08:00
parent 163b3e9c5d
commit 7e14bbf42f
11 changed files with 168 additions and 5 deletions

View File

@ -16,6 +16,7 @@ import (
//go:embed migrations/sqlite/0012_resource_replacement.sql migrations/mysql/0012_resource_replacement.sql migrations/postgres/0012_resource_replacement.sql
//go:embed migrations/sqlite/0013_history_scope.sql migrations/mysql/0013_history_scope.sql migrations/postgres/0013_history_scope.sql
//go:embed migrations/sqlite/0014_project_members.sql migrations/mysql/0014_project_members.sql migrations/postgres/0014_project_members.sql
//go:embed migrations/sqlite/0015_task_idempotency.sql migrations/mysql/0015_task_idempotency.sql migrations/postgres/0015_task_idempotency.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
@ -40,7 +41,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil
}
const latestMigrationVersion = 14
const latestMigrationVersion = 15
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
@ -220,6 +221,8 @@ func migrationName(version int) string {
return "history_scope"
case 14:
return "project_members"
case 15:
return "task_idempotency"
default:
return "unknown"
}

View File

@ -0,0 +1,14 @@
CREATE TABLE task_idempotency (
domain_id VARCHAR(36) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
task_id VARCHAR(36) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (domain_id, idempotency_key),
UNIQUE KEY uq_task_idempotency_task (task_id)
);
INSERT INTO task_idempotency (domain_id, idempotency_key, task_id, created_at)
SELECT domain_id, idempotency_key, MIN(id), MIN(created_at)
FROM tasks
WHERE idempotency_key <> ''
GROUP BY domain_id, idempotency_key;

View File

@ -0,0 +1,14 @@
CREATE TABLE task_idempotency (
domain_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
task_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (domain_id, idempotency_key),
UNIQUE (task_id)
);
INSERT INTO task_idempotency (domain_id, idempotency_key, task_id, created_at)
SELECT domain_id, idempotency_key, MIN(id), MIN(created_at)
FROM tasks
WHERE idempotency_key <> ''
GROUP BY domain_id, idempotency_key;

View File

@ -0,0 +1,14 @@
CREATE TABLE task_idempotency (
domain_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
task_id TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (domain_id, idempotency_key),
UNIQUE (task_id)
);
INSERT INTO task_idempotency (domain_id, idempotency_key, task_id, created_at)
SELECT domain_id, idempotency_key, MIN(id), MIN(created_at)
FROM tasks
WHERE idempotency_key <> ''
GROUP BY domain_id, idempotency_key;

View File

@ -102,6 +102,17 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
Message: "等待执行证书申请", IdempotencyKey: idempotencyKey, CreatedAt: now, UpdatedAt: now,
}
if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil {
if idempotencyKey != "" {
if existing, lookupErr := s.tasks.FindTaskByIdempotencyKey(c.Request.Context(), domainID, idempotencyKey); lookupErr == nil {
status := http.StatusOK
if existing.Status == "queued" || existing.Status == "running" {
status = http.StatusConflict
}
c.JSON(status, gin.H{"task": existing, "replayed": true})
s.issueTaskMu.Unlock()
return
}
}
s.issueTaskMu.Unlock()
s.writeError(c, http.StatusInternalServerError, "创建证书任务失败", err)
return

View File

@ -127,6 +127,17 @@ func (r *SQLTaskRepository) CreateTask(ctx context.Context, item Task) error {
if err := validateTaskInsertResult(result); err != nil {
return err
}
if item.IdempotencyKey != "" {
idempotencyResult, idempotencyErr := tx.ExecContext(ctx, r.query(`INSERT INTO task_idempotency
(domain_id, idempotency_key, task_id, created_at) VALUES (?, ?, ?, ?)`),
item.DomainID, item.IdempotencyKey, item.ID, now)
if idempotencyErr != nil {
return fmt.Errorf("创建任务幂等记录失败:%w", idempotencyErr)
}
if err := validateTaskInsertResult(idempotencyResult); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交任务创建事务失败:%w", err)
}

View File

@ -209,3 +209,47 @@ func TestSQLTaskRepositoryRecoversInterruptedTasksAndAttempts(t *testing.T) {
t.Fatalf("\u5df2\u5b8c\u6210\u4efb\u52a1\u5c1d\u8bd5\u4e0d\u5e94\u88ab\u6062\u590d\u903b\u8f91\u4fee\u6539\uff1a%+v %v", completedAttempts, err)
}
}
func TestSQLTaskRepositoryRejectsConcurrentDuplicateIdempotencyKeys(t *testing.T) {
repository, db := newTaskRepositoryTest(t)
defer db.Close()
ctx := context.Background()
start := make(chan struct{})
results := make(chan error, 2)
for _, taskID := range []string{"task-idempotency-a", "task-idempotency-b"} {
go func(taskID string) {
<-start
results <- repository.CreateTask(ctx, Task{
ID: taskID, DomainID: "domain-1", TaskType: "certificate_issue",
Status: "queued", Stage: "queued", Message: "test",
IdempotencyKey: "duplicate-key-20260929",
})
}(taskID)
}
close(start)
var succeeded, failed int
for range 2 {
if err := <-results; err == nil {
succeeded++
} else {
failed++
}
}
if succeeded != 1 || failed != 1 {
t.Fatalf("unexpected concurrent idempotency result: success=%d failure=%d", succeeded, failed)
}
items, err := repository.ListTasks(ctx, "domain-1", 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].IdempotencyKey != "duplicate-key-20260929" {
t.Fatalf("duplicate idempotency key created multiple tasks: %+v", items)
}
stored, err := repository.FindTaskByIdempotencyKey(ctx, "domain-1", "duplicate-key-20260929")
if err != nil || stored.ID != items[0].ID {
t.Fatalf("idempotency lookup mismatch: %+v %v", stored, err)
}
}

View File

@ -3558,4 +3558,17 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无
- [ ] 未开始:P0-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
- [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。
下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。
下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。
## 本轮开发进度(2026-09-29)
总体验收位置继续保持 **P0-03:完成,待复审**;本轮只推进 P0-10 任务幂等并发保护,不代表整项复审或生产部署验收通过。
- [x] 已完成,等待复审:新增 `task_idempotency` 数据表及 SQLite、PostgreSQL、MySQL 三套 0015 迁移;历史非空幂等键在迁移时建立登记。
- [x] 已完成,等待复审:任务创建在同一事务登记幂等键,数据库层拒绝同一域名同一幂等键的并发重复任务,避免仅依赖单进程互斥锁。
- [x] 已完成,等待复审:接口在并发创建冲突后回读已存在任务并返回重放结果,不再把可恢复的幂等冲突直接返回为普通创建失败。
- [x] 已完成,等待复审:新增并发重复幂等键回归测试;`internal/repository`、`internal/database`、`internal/httpapi` 及后端全量 `go test ./...` 均通过,`git diff --check` 通过。
- [~] 进行中:P0-10 其余长任务取消、重试、任务尝试记录、崩溃恢复,以及证书校验、导入、部署、恢复入口是否全部纳入任务中心仍需继续审查。
- [ ] 未开始:P0-12 备份恢复、三数据库实库矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
- [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。

View File

@ -864,4 +864,17 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全
- [ ] 未开始:P0-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
- [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。
下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。
下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。
## 本轮开发进度(2026-09-29)
总体验收位置继续保持 **P0-03:完成,待复审**;本轮只推进 P0-10 任务幂等并发保护,不代表整项复审或生产部署验收通过。
- [x] 已完成,等待复审:新增 `task_idempotency` 数据表及 SQLite、PostgreSQL、MySQL 三套 0015 迁移;历史非空幂等键在迁移时建立登记。
- [x] 已完成,等待复审:任务创建在同一事务登记幂等键,数据库层拒绝同一域名同一幂等键的并发重复任务,避免仅依赖单进程互斥锁。
- [x] 已完成,等待复审:接口在并发创建冲突后回读已存在任务并返回重放结果,不再把可恢复的幂等冲突直接返回为普通创建失败。
- [x] 已完成,等待复审:新增并发重复幂等键回归测试;`internal/repository`、`internal/database`、`internal/httpapi` 及后端全量 `go test ./...` 均通过,`git diff --check` 通过。
- [~] 进行中:P0-10 其余长任务取消、重试、任务尝试记录、崩溃恢复,以及证书校验、导入、部署、恢复入口是否全部纳入任务中心仍需继续审查。
- [ ] 未开始:P0-12 备份恢复、三数据库实库矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
- [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。

View File

@ -1502,4 +1502,17 @@ P0-11 生产验收门禁
- [ ] 未开始:P0-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
- [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。
下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。
下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。
## 本轮开发进度(2026-09-29)
总体验收位置继续保持 **P0-03:完成,待复审**;本轮只推进 P0-10 任务幂等并发保护,不代表整项复审或生产部署验收通过。
- [x] 已完成,等待复审:新增 `task_idempotency` 数据表及 SQLite、PostgreSQL、MySQL 三套 0015 迁移;历史非空幂等键在迁移时建立登记。
- [x] 已完成,等待复审:任务创建在同一事务登记幂等键,数据库层拒绝同一域名同一幂等键的并发重复任务,避免仅依赖单进程互斥锁。
- [x] 已完成,等待复审:接口在并发创建冲突后回读已存在任务并返回重放结果,不再把可恢复的幂等冲突直接返回为普通创建失败。
- [x] 已完成,等待复审:新增并发重复幂等键回归测试;`internal/repository`、`internal/database`、`internal/httpapi` 及后端全量 `go test ./...` 均通过,`git diff --check` 通过。
- [~] 进行中:P0-10 其余长任务取消、重试、任务尝试记录、崩溃恢复,以及证书校验、导入、部署、恢复入口是否全部纳入任务中心仍需继续审查。
- [ ] 未开始:P0-12 备份恢复、三数据库实库矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
- [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。

View File

@ -443,4 +443,17 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收
- [ ] 未开始:P0-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
- [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。
下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。
下一步继续审查 P0-10 剩余任务执行链和 P0-12 备份恢复;开发阶段完成后,再统一执行生产构建、部署和实机验收。
## 本轮开发进度(2026-09-29)
总体验收位置继续保持 **P0-03:完成,待复审**;本轮只推进 P0-10 任务幂等并发保护,不代表整项复审或生产部署验收通过。
- [x] 已完成,等待复审:新增 `task_idempotency` 数据表及 SQLite、PostgreSQL、MySQL 三套 0015 迁移;历史非空幂等键在迁移时建立登记。
- [x] 已完成,等待复审:任务创建在同一事务登记幂等键,数据库层拒绝同一域名同一幂等键的并发重复任务,避免仅依赖单进程互斥锁。
- [x] 已完成,等待复审:接口在并发创建冲突后回读已存在任务并返回重放结果,不再把可恢复的幂等冲突直接返回为普通创建失败。
- [x] 已完成,等待复审:新增并发重复幂等键回归测试;`internal/repository`、`internal/database`、`internal/httpapi` 及后端全量 `go test ./...` 均通过,`git diff --check` 通过。
- [~] 进行中:P0-10 其余长任务取消、重试、任务尝试记录、崩溃恢复,以及证书校验、导入、部署、恢复入口是否全部纳入任务中心仍需继续审查。
- [ ] 未开始:P0-12 备份恢复、三数据库实库矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
- [!] 总体验收不得升级:当前仍为 **P0-03 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。