补齐任务幂等并发保护
新增三数据库任务幂等登记迁移,在任务创建事务中防止重复幂等键,并补充并发回归测试与进度清单。
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/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/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/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
|
var migrationFiles embed.FS
|
||||||
|
|
||||||
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestMigrationVersion = 14
|
const latestMigrationVersion = 15
|
||||||
|
|
||||||
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
||||||
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
|
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
|
||||||
@ -220,6 +221,8 @@ func migrationName(version int) string {
|
|||||||
return "history_scope"
|
return "history_scope"
|
||||||
case 14:
|
case 14:
|
||||||
return "project_members"
|
return "project_members"
|
||||||
|
case 15:
|
||||||
|
return "task_idempotency"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
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,
|
Message: "等待执行证书申请", IdempotencyKey: idempotencyKey, CreatedAt: now, UpdatedAt: now,
|
||||||
}
|
}
|
||||||
if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil {
|
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.issueTaskMu.Unlock()
|
||||||
s.writeError(c, http.StatusInternalServerError, "创建证书任务失败", err)
|
s.writeError(c, http.StatusInternalServerError, "创建证书任务失败", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -127,6 +127,17 @@ func (r *SQLTaskRepository) CreateTask(ctx context.Context, item Task) error {
|
|||||||
if err := validateTaskInsertResult(result); err != nil {
|
if err := validateTaskInsertResult(result); err != nil {
|
||||||
return err
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("提交任务创建事务失败:%w", err)
|
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)
|
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-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
|
||||||
- [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。
|
- [!] 总体验收不得升级:仍处于 **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-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
|
||||||
- [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。
|
- [!] 总体验收不得升级:仍处于 **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-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
|
||||||
- [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。
|
- [!] 总体验收不得升级:仍处于 **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-12 备份恢复、三数据库生产矩阵、浏览器清缓存、密码 SSH、远端部署、HTTPS/DNS/ACME、Linux/Windows 服务和最终生产门禁。
|
||||||
- [!] 总体验收不得升级:仍处于 **P0-03 完成,待复审**,当前没有新增 `[✓]` 复审通过项。
|
- [!] 总体验收不得升级:仍处于 **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