补齐任务幂等并发保护
新增三数据库任务幂等登记迁移,在任务创建事务中防止重复幂等键,并补充并发回归测试与进度清单。
This commit is contained in:
parent
163b3e9c5d
commit
7e14bbf42f
@ -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"
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
|
||||
|
||||
本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。
|
||||
|
||||
@ -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 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
|
||||
|
||||
本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。
|
||||
|
||||
@ -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 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
|
||||
|
||||
本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。
|
||||
|
||||
@ -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 完成,待复审**,本轮没有新增 `[✓]` 复审通过项。
|
||||
|
||||
本轮下一步继续审查任务中心剩余入口和备份恢复;开发阶段收尾后,再统一执行生产构建、实机部署和验收。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user