merge: candidate docgov-shared-map-race-2026-09-28-r2 into dev
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
|
||||
维护规则:[问题记录与开发经验闭环](../ops/2026-09-23-issue-learning-workflow.md)。每个问题一个稳定 ID;主记录是事实与状态来源,本表为可检索投影。新发现及时登记,修复后保留记录;发布 blocker 仍见 [gap-matrix](../remediation/gap-matrix.md)。
|
||||
|
||||
更新:2026-09-28。只上传文档、无文字时对话 400 已在重载后的 dev 浏览器复验(resolved,`bin/agent-server` 10:22 +0800)。历史任务 run 事件流 HTTP/2 与 502 另记,未在本轮修。激活预览前端预检与管理台密钥 form 已在 dev 浏览器复验(resolved,dev `3e534bbb`,项目 6023)。非编制人触发小队账本提交被 `uow: permission denied` 拒绝(fixing,待 dev 重跑)。test 注册 INTERNAL:标准角色 role-guest 缺失导致 FK 23503,已补种并重启 test(goose 77,resolved)。重复用户名 400 在新代码单测通过;当前 rc 二进制仍返回 INTERNAL,随下次含本提交的 test 制品生效。管理台重置密码入口被 F4-A 误删(端点仍在),已恢复 UI(resolved)。2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。
|
||||
更新:2026-09-28。docgov 共享 map 并发读写曾在 06:38 UTC 打崩 dev 进程,登录等 API 在进程缺席期间 500(verifying,单测 `-race` 已过,dev 重建未做)。只上传文档、无文字时对话 400 已在重载后的 dev 浏览器复验(resolved,`bin/agent-server` 10:22 +0800)。历史任务 run 事件流 HTTP/2 与 502 另记,未在本轮修。激活预览前端预检与管理台密钥 form 已在 dev 浏览器复验(resolved,dev `3e534bbb`,项目 6023)。非编制人触发小队账本提交被 `uow: permission denied` 拒绝(fixing,待 dev 重跑)。test 注册 INTERNAL:标准角色 role-guest 缺失导致 FK 23503,已补种并重启 test(goose 77,resolved)。重复用户名 400 在新代码单测通过;当前 rc 二进制仍返回 INTERNAL,随下次含本提交的 test 制品生效。管理台重置密码入口被 F4-A 误删(端点仍在),已恢复 UI(resolved)。2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。
|
||||
|
||||
| issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| ISSUE-20260928-docgov-map-race | docgov / memDocs / concurrent map / GetDocument / 登录 500 | per-doc 锁拆分后共享 map 无互斥,不同文档并发读写 fatal,进程退出期间全部 API 500 | critical | verifying | Cursor | 2026-09-28;`.tmp/launch/server.log` fatal + `web.log` ECONNREFUSED;`TestMemMapsNoRaceAcrossDocs` `-race` PASS(`a90cce73`);dev 重建未做 | [主记录](../ops/short-term/2026-09-28-docgov-shared-map-race.md#issue-20260928-docgov-map-race) | [LESSON-20260928-docgov-shared-map-race](../ops/lessons/2026-09-28-docgov-shared-map-race.md) |
|
||||
| ISSUE-20260928-quality-heading-drift | squad / quality_gate / 缺少必填章节 / State-of-the-Art / H3 | 调研报告章节在,只因 H3 和少空格被判缺章,受控文档不投影 | high | verifying | Cursor | 2026-09-28;dev `research_1790566833205199368` manifest score 85;单测已覆盖,dev 重跑未做 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-quality-heading-drift) | [LESSON-20260928-quality-heading-normalize](../ops/lessons/2026-09-28-quality-heading-normalize.md) |
|
||||
| ISSUE-20260928-data-dir-doubled | workspace / data/dev/data/dev / DataDir | 出现双层 `data/dev/data/dev/workspaces` 且只有孤立 output.md,写入点未证实 | medium | open | — | 2026-09-28;`intake_1790565513115586191` 双路径对照 | [主记录](../ops/short-term/2026-09-28-quality-gate-heading-mismatch.md#issue-20260928-data-dir-doubled) | 本轮不修 |
|
||||
| ISSUE-20260928-progress-query-trigger | intent / matchTriggerTask / QUERY_PROGRESS / 编写进度 | 询问调研报告编写进度时,写动词加任务名词被识别成启动新任务,进度回复不读真实 run | medium | verifying | Cursor | 2026-09-28;「调研报告编写进度怎么样了」单测为 QUERY_PROGRESS;「开始做调研报告」仍为 research。浏览器复验见测试报告 | [主记录](../ops/short-term/2026-09-28-refresh-task-state-recovery.md) | 疑问进度句必须让行查询;显式「开始/重新」仍启动任务 |
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|---|---|---|---|---|---|
|
||||
| Cursor | docgov-shared-map-race-2026-09-28-r2 | fix/docgov-shared-map-race-2026-09-28 | .worktrees/docgov-shared-map-race-2026-09-28 | docgov-shared-map-race | in_progress |
|
||||
| Cursor | promote-rc-beta6-closeout-2026-09-28 | feature/promote-rc-beta6-closeout-2026-09-28 | .worktrees/promote-rc-beta6-closeout-2026-09-28 | git:test+env:test | done |
|
||||
| Cursor | quality-gate-heading-mismatch-2026-09-28-r3 | fix/quality-gate-heading-mismatch-2026-09-28 | .worktrees/quality-gate-heading-mismatch-2026-09-28 | quality-gate-heading | in_progress |
|
||||
| Cursor | agent-reply-llm-narrative-2026-09-28 | feature/agent-reply-llm-narrative-2026-09-28 | .worktrees/agent-reply-llm-narrative-2026-09-28 | chat+coordinator | in_progress |
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
lesson_id: LESSON-20260928-docgov-shared-map-race
|
||||
lesson_state: candidate
|
||||
owner: Cursor
|
||||
reviewer: Cursor(自审)
|
||||
last_reviewed: 2026-09-28
|
||||
reviewed_revision: a90cce73
|
||||
source_issues: [ISSUE-20260928-docgov-map-race]
|
||||
keywords: [docgov, memDocs, memMu, per-doc lock, concurrent map, 登录 500]
|
||||
---
|
||||
|
||||
# 拆细粒度锁后,共享 map 仍要单独互斥
|
||||
|
||||
- 触发条件:把全局锁拆成 per-id 锁,但多个 id 仍读写同一张 Go map(缓存、审计、批注、提案)。
|
||||
- 机制与来源:docgov 的 per-doc 锁不保护 `memDocs` 等 map。不同文档并发时 `GetDocument` 与写入撞上,运行时 fatal,进程退出后所有 HTTP API(含登录)500。证据:2026-09-28 `.tmp/launch/server.log` 与 `web.log` 的 `ECONNREFUSED`。修复见 [任务](../short-term/2026-09-28-docgov-shared-map-race.md)。
|
||||
- 开发检查:map 的读写、遍历、append 都走同一把锁。已持有外层锁的路径不能再锁同一把(Go 互斥锁不可重入)。锁序固定,禁止在 map 锁内再拿文档锁或外层锁。不要用「登录 500」去改登录代码。
|
||||
- 验证方式:不同 docID 并发创建与读取,`go test -race` 覆盖 `TestMemMapsNoRaceAcrossDocs`。进程崩溃类问题还要看服务日志里的 fatal,而不是只看单个 API 状态码。
|
||||
- 适用边界:适用于 docgov 这四张内存 map。文档结构体字段的数据竞争、以及持久化失败后的缓存投毒(`ISSUE-20260926-docgov-memdocs-poison`)不在本条范围内。
|
||||
- 防护落实:`memMu` 与 `TestMemMapsNoRaceAcrossDocs`。没有新增静态检查,漏一个裸访问仍要靠 `-race` 和复查。
|
||||
- 修订与失效:若这四张 map 改为分片或移除,本条对那个存储失效,保留历史。
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
| lesson_id | 触发条件 / 关键词 | 注意事项 | lesson_state | 主记录 / 最近复核 |
|
||||
|---|---|---|---|---|
|
||||
| LESSON-20260928-docgov-shared-map-race | docgov、memDocs、per-doc 锁、concurrent map、登录 500 | 拆锁后共享 map 仍要单独互斥;锁不可重入;全站 500 先看进程是否退出 | candidate | [共享 map 互斥](2026-09-28-docgov-shared-map-race.md);2026-09-28 |
|
||||
| LESSON-20260928-quality-heading-normalize | 缺少必填章节、State-of-the-Art、H3、质量门禁、AutoRepair | 标题比对去空白并跨 H2–H4;层级漂移不扣分;真实缺章仍失败 | candidate | [章节标题归一化](2026-09-28-quality-heading-normalize.md);2026-09-28 |
|
||||
| LESSON-20260928-task-write-verb-visible-refusal | 帮我定、制定、ClarificationQuestion、前置条件、quality_failed | 写动词覆盖制定与帮我定;拒绝和澄清进对话;质量失败不写宿主机路径 | candidate | [写动词与可见拒绝](2026-09-28-task-write-verb-visible-refusal.md);2026-09-28 |
|
||||
| LESSON-20260928-activation-preview-client-gate | activation-preview、NO_GOVERNED_DOCUMENT、clientDutyIssues、400 | 服务端激活门禁要有同等前端预检;live_edit 不套激活门禁 | validated | [激活预检对齐](2026-09-28-activation-preview-client-gate.md);2026-09-28 |
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "docgov 共享 map 并发读写导致进程崩溃"
|
||||
status: in_progress
|
||||
created: 2026-09-28
|
||||
owner: Cursor
|
||||
task_id: docgov-shared-map-race-2026-09-28-r2
|
||||
policy_revision: issue-learning-2026-09-23-r1 @ a3db288a
|
||||
review_state: pending
|
||||
integration_state: pending
|
||||
deployment: pending
|
||||
cleanup: pending
|
||||
remote_sync: not_requested
|
||||
updated: 2026-09-28
|
||||
write_scope: internal/docgov .doc/ops/CURRENT.md .doc/ops/short-term/2026-09-28-docgov-shared-map-race.md .doc/test-reports/2026-09-28-docgov-shared-map-race-test-report.md .doc/audit/ISSUES.md .doc/ops/lessons/2026-09-28-docgov-shared-map-race.md .doc/ops/lessons/INDEX.md
|
||||
mutex: docgov-shared-map-race
|
||||
base_dev_sha: a3db288a7aa055ff5884e3aa4d91511959cff721
|
||||
---
|
||||
|
||||
# docgov 共享 map 并发读写
|
||||
|
||||
## 范围与授权
|
||||
|
||||
用户报告 dev(`frp-mix.com:58880`)登录及多个 API 返回 500,并说明没有改登录代码。授权是查清原因并修复。允许写路径见 front matter。交付终点:共享 map 并发读写有回归测试,合入 local dev 后重建并复验 `/health` 与登录接口不再因进程死亡返回 500。不改登录协议,不晋升 test/main,不 push。
|
||||
|
||||
## 问题登记与经验检索
|
||||
|
||||
- issue_id: `ISSUE-20260928-docgov-map-race`
|
||||
- 检索词:`docgov`、`memDocs`、`concurrent map`、`500`、`登录`
|
||||
- 命中:`ISSUE-20260926-docgov-memdocs-poison`(同模块内存态与库分叉,机制不同,本轮不关)。无「per-doc 锁拆分后共享 map 无互斥」的既有经验。
|
||||
- 采纳:修共用 map 互斥,不用登录特例。
|
||||
|
||||
## 现象
|
||||
|
||||
2026-09-28 06:38 UTC,dev `agent-server` 在 `GetDocument`(`internal/docgov/engine_read.go`)触发 `fatal error: concurrent map read and map write` 后退出。06:38–06:49 Vite 代理 `ECONNREFUSED 127.0.0.1:8080`,登录、`auth/me`、通知、维护状态等全部 500。06:49 进程被重新拉起后接口恢复。登录代码未改。
|
||||
|
||||
## 根因
|
||||
|
||||
`340342fa` 把 docgov 全局锁拆成 per-doc 锁。per-doc 锁只串行同一 `docID`。`memDocs`、`memAudits`、`memComms`、`memProps` 仍被不同文档并发读写。`e.mu` 不是可重入锁,且部分调用方已经持有它,所以新增 `memMu` 专门保护这四张 map。锁序:docLocks → `e.mu` → `memMu`。
|
||||
|
||||
## 修复
|
||||
|
||||
`a90cce73`。回归:`TestMemMapsNoRaceAcrossDocs`(`go test -race`)。
|
||||
|
||||
## 五态
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| review_state | pending |
|
||||
| integration_state | pending |
|
||||
| deployment | pending(合入后需重建 dev `agent-server`) |
|
||||
| cleanup | pending |
|
||||
| remote_sync | not_requested |
|
||||
|
||||
## ISSUE-20260928-docgov-map-race
|
||||
|
||||
- 预期:文档治理并发不杀死 HTTP 进程;登录等无关 API 保持可用。
|
||||
- 实际:2026-09-28 06:38 UTC 进程 fatal,约 11 分钟内全部 API 500。
|
||||
- 状态:verifying(单测已过;dev 重建复验尚未做)。
|
||||
- 同类:所有裸读写上述四张 map 的路径,已收口到 `memMu` 辅助函数。`ISSUE-20260926-docgov-memdocs-poison` 仍是另一机制。
|
||||
@@ -0,0 +1,47 @@
|
||||
# docgov 共享 map 并发读写 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
2026-09-28 06:38 UTC,dev 后端因 `internal/docgov` 共享 map 并发读写 fatal 退出。Vite 代理在后端缺席期间对登录等全部 API 返回 500。本报告记录修复后的工程回归。产品 PI 全链路不适用:变更是治理引擎内存锁,不改对话或文档业务规则。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- 不同 docID 并发创建、读取、列审计/批注、写批注时,`-race` 不报告 data race,进程不 fatal。
|
||||
- 并发结束后每个 docID 仍能读回。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS:Linux;`go version go1.27.1 linux/amd64`
|
||||
- 工作区:`.worktrees/docgov-shared-map-race-2026-09-28`,提交 `a90cce73`
|
||||
- 层级:模块单元测试 + race detector。无数据库。产品 PI:NOT REQUIRED(非产品工作流变更;依据验证契约对纯工程锁修复的范围,以及本报告不把单测写成产品 PASS)。
|
||||
- 用例:`TestMemMapsNoRaceAcrossDocs`
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
`go test -race` 该用例 PASS,且 48 个文档均可 `GetDocument`。
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
- 测试输入 (Input):`go test -race -count=1 -timeout 180s -v ./internal/docgov/ -run TestMemMapsNoRaceAcrossDocs`
|
||||
- 实际输出 (Actual Output):
|
||||
|
||||
```
|
||||
=== RUN TestMemMapsNoRaceAcrossDocs
|
||||
--- PASS: TestMemMapsNoRaceAcrossDocs (0.00s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/docgov 1.012s
|
||||
```
|
||||
|
||||
- 评价判定 (Evaluation / Verdict):PASS
|
||||
- 性能与耗时 (Duration & Latency):包测试约 1.012s;用例本身 0.00s(race 插桩耗时在包级)
|
||||
|
||||
同一次会话里不带 `-run` 的 `go test -race -count=1 -timeout 180s ./internal/docgov/` 亦为 `ok`(1.012s)。dev 进程重建后的 `/health` 与登录复验在合入之后补记,本段不将其记为已完成。
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
- `go test -race ./internal/docgov/`:PASS(含 `TestMemMapsNoRaceAcrossDocs`)
|
||||
- 未跑产品 PI、未跑全仓 `go test`
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
工程回归达到本修复的单测终点。合入 local dev 后需重建 `bin/agent-server` 并确认 `/health` 与空登录请求返回 401 而不是 500。`ISSUE-20260926-docgov-memdocs-poison` 仍开放。
|
||||
@@ -35,11 +35,12 @@ func (e *GovernanceEngine) FindByProjectTaskKey(ctx context.Context, projectID i
|
||||
}
|
||||
}
|
||||
|
||||
for _, doc := range e.memDocs {
|
||||
if doc != nil && doc.ProjectID == projectID && doc.TaskKey == taskKey {
|
||||
cp := *doc
|
||||
return &cp, nil
|
||||
}
|
||||
doc := e.memDocsFind(func(doc *DocumentRecord) bool {
|
||||
return doc.ProjectID == projectID && doc.TaskKey == taskKey
|
||||
})
|
||||
if doc != nil {
|
||||
cp := *doc
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
@@ -94,9 +95,9 @@ func (e *GovernanceEngine) ProjectDocTaskDuties(
|
||||
}
|
||||
}
|
||||
|
||||
for _, doc := range e.memDocs {
|
||||
e.forEachMemDoc(func(doc *DocumentRecord) {
|
||||
if doc == nil || doc.ProjectID != projectID || doc.TaskKey != taskKey {
|
||||
continue
|
||||
return
|
||||
}
|
||||
doc.AuthorID = authorID
|
||||
doc.AuthorName = authorName
|
||||
@@ -106,6 +107,6 @@ func (e *GovernanceEngine) ProjectDocTaskDuties(
|
||||
doc.ApproverName = approverName
|
||||
doc.DutyPolicy = strings.TrimSpace(dutyPolicy)
|
||||
doc.UpdatedAt = now
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ type GovernanceEngine struct {
|
||||
pool *pgxpool.Pool
|
||||
sm *StateMachine
|
||||
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
// memMu 只保护下面四张共享 map 的结构。per-doc 锁串行同一文档的状态机,
|
||||
// 不同 docID 仍会并发读写这些 map;T20 拆锁后漏了这层,会导致进程崩溃。
|
||||
// 锁序:docLocks → e.mu → memMu。持有 memMu 时不得再获取 docLocks 或 e.mu。
|
||||
memMu sync.RWMutex
|
||||
memDocs map[string]*DocumentRecord
|
||||
memComms map[string][]*DocumentComment
|
||||
memProps map[string]*ChangeProposal
|
||||
|
||||
@@ -80,7 +80,7 @@ func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID strin
|
||||
},
|
||||
CreatedAt: now,
|
||||
}
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
|
||||
if e.pool != nil {
|
||||
if _, err := e.pool.Exec(ctx, `
|
||||
@@ -158,7 +158,7 @@ func (e *GovernanceEngine) RecordDocumentEdited(ctx context.Context, docID strin
|
||||
Details: map[string]any{"timestamp": now},
|
||||
CreatedAt: now,
|
||||
}
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
|
||||
if e.pool != nil {
|
||||
if _, err := e.pool.Exec(ctx, `
|
||||
@@ -235,13 +235,9 @@ func (e *GovernanceEngine) CheckModificationPermission(ctx context.Context, docI
|
||||
}
|
||||
|
||||
// 3. 已认领:检查是否有待编制人处理的挂起提案
|
||||
var pendingProp *ChangeProposal
|
||||
for _, p := range e.memProps {
|
||||
if p.DocID == docID && p.Status == ProposalPendingAuthorApproval {
|
||||
pendingProp = p
|
||||
break
|
||||
}
|
||||
}
|
||||
pendingProp := e.memPropsFind(func(p *ChangeProposal) bool {
|
||||
return p.DocID == docID && p.Status == ProposalPendingAuthorApproval
|
||||
})
|
||||
if pendingProp == nil && e.pool != nil {
|
||||
var p ChangeProposal
|
||||
var stat string
|
||||
@@ -254,7 +250,7 @@ func (e *GovernanceEngine) CheckModificationPermission(ctx context.Context, docI
|
||||
if err == nil {
|
||||
p.Status = ProposalStatus(stat)
|
||||
pendingProp = &p
|
||||
e.memProps[p.ProposalID] = &p
|
||||
e.memPropsPut(p.ProposalID, &p)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func (e *GovernanceEngine) CreateOrGetDocument(
|
||||
defer g.Release()
|
||||
|
||||
now := time.Now()
|
||||
if doc, exists := e.memDocs[docID]; exists {
|
||||
if doc, exists := e.memDocsGet(docID); exists {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func (e *GovernanceEngine) CreateOrGetDocument(
|
||||
doc.ApproverClaimed = appClaim
|
||||
}
|
||||
|
||||
e.memDocs[docID] = doc
|
||||
e.memDocsPut(docID, doc)
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func (e *GovernanceEngine) MarkDocumentContentUpdated(ctx context.Context, docID
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) ensureDocumentLocked(ctx context.Context, docID string) (*DocumentRecord, error) {
|
||||
if doc, exists := e.memDocs[docID]; exists {
|
||||
if doc, exists := e.memDocsGet(docID); exists {
|
||||
return doc, nil
|
||||
}
|
||||
if e.pool != nil {
|
||||
@@ -214,7 +214,7 @@ func (e *GovernanceEngine) SetDocumentWorkDir(ctx context.Context, docID string,
|
||||
g := e.acquireDocLock(docID)
|
||||
defer g.Release()
|
||||
|
||||
doc, exists := e.memDocs[docID]
|
||||
doc, exists := e.memDocsGet(docID)
|
||||
if !exists {
|
||||
return ErrDocumentNotFound
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func (e *GovernanceEngine) ProposeModification(
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
e.memProps[proposalID] = proposal
|
||||
e.memPropsPut(proposalID, proposal)
|
||||
|
||||
// 记录审计
|
||||
audit := &AuditLog{
|
||||
@@ -60,7 +60,7 @@ func (e *GovernanceEngine) ProposeModification(
|
||||
},
|
||||
CreatedAt: now,
|
||||
}
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
|
||||
if e.pool != nil {
|
||||
query := `
|
||||
@@ -103,7 +103,7 @@ func (e *GovernanceEngine) DecideModificationProposal(
|
||||
g := e.acquireDocLock("*proposal:" + proposalID)
|
||||
defer g.Release()
|
||||
|
||||
proposal, exists := e.memProps[proposalID]
|
||||
proposal, exists := e.memPropsGet(proposalID)
|
||||
if !exists && e.pool != nil {
|
||||
var p ChangeProposal
|
||||
var stat string
|
||||
@@ -115,7 +115,7 @@ func (e *GovernanceEngine) DecideModificationProposal(
|
||||
if err == nil {
|
||||
p.Status = ProposalStatus(stat)
|
||||
proposal = &p
|
||||
e.memProps[proposalID] = &p
|
||||
e.memPropsPut(proposalID, &p)
|
||||
exists = true
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (e *GovernanceEngine) DecideModificationProposal(
|
||||
return nil, ErrProposalAlreadyDecided
|
||||
}
|
||||
|
||||
doc, docExists := e.memDocs[proposal.DocID]
|
||||
doc, docExists := e.memDocsGet(proposal.DocID)
|
||||
if !docExists && e.pool != nil {
|
||||
var err error
|
||||
doc, err = e.loadDocumentFromDBLocked(ctx, proposal.DocID)
|
||||
@@ -172,7 +172,7 @@ func (e *GovernanceEngine) DecideModificationProposal(
|
||||
},
|
||||
CreatedAt: now,
|
||||
}
|
||||
e.memAudits[proposal.DocID] = append(e.memAudits[proposal.DocID], audit)
|
||||
e.memAuditsAppend(proposal.DocID, audit)
|
||||
|
||||
if e.pool != nil {
|
||||
if _, err := e.pool.Exec(ctx, `
|
||||
@@ -230,7 +230,7 @@ func (e *GovernanceEngine) AddComment(
|
||||
g := e.acquireDocLock(docID)
|
||||
defer g.Release()
|
||||
|
||||
doc, exists := e.memDocs[docID]
|
||||
doc, exists := e.memDocsGet(docID)
|
||||
if !exists {
|
||||
var err error
|
||||
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
||||
@@ -264,20 +264,11 @@ func (e *GovernanceEngine) AddComment(
|
||||
}
|
||||
|
||||
if e.pool == nil {
|
||||
// in-memory: allocate synthetic ids so ResolveComment can address them
|
||||
var maxID int64
|
||||
for _, list := range e.memComms {
|
||||
for _, c := range list {
|
||||
if c != nil && c.ID > maxID {
|
||||
maxID = c.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
comment.ID = maxID + 1
|
||||
e.memCommsAppend(docID, comment, true)
|
||||
} else {
|
||||
e.memCommsAppend(docID, comment, false)
|
||||
}
|
||||
|
||||
e.memComms[docID] = append(e.memComms[docID], comment)
|
||||
|
||||
if e.pool != nil {
|
||||
query := `
|
||||
INSERT INTO document_comments (doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at)
|
||||
@@ -304,20 +295,7 @@ func (e *GovernanceEngine) ResolveComment(ctx context.Context, commentID int64,
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
var found *DocumentComment
|
||||
var docID string
|
||||
for id, list := range e.memComms {
|
||||
for _, c := range list {
|
||||
if c != nil && c.ID == commentID {
|
||||
found = c
|
||||
docID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
found, docID := e.memCommsFind(commentID)
|
||||
if found == nil && e.pool != nil {
|
||||
var c DocumentComment
|
||||
var role, cType string
|
||||
@@ -334,13 +312,13 @@ func (e *GovernanceEngine) ResolveComment(ctx context.Context, commentID int64,
|
||||
c.CommentType = CommentType(cType)
|
||||
found = &c
|
||||
docID = c.DocID
|
||||
e.memComms[docID] = append(e.memComms[docID], found)
|
||||
e.memCommsAppend(docID, found, false)
|
||||
}
|
||||
if found == nil {
|
||||
return nil, fmt.Errorf("docgov: comment not found")
|
||||
}
|
||||
|
||||
doc, exists := e.memDocs[docID]
|
||||
doc, exists := e.memDocsGet(docID)
|
||||
if !exists {
|
||||
var err error
|
||||
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
||||
@@ -387,6 +365,6 @@ func (e *GovernanceEngine) addCommentInternal(docID string, userID int64, userNa
|
||||
Status: "open",
|
||||
CreatedAt: now,
|
||||
}
|
||||
e.memComms[docID] = append(e.memComms[docID], comment)
|
||||
e.memCommsAppend(docID, comment, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ func (e *GovernanceEngine) GetDocument(ctx context.Context, docID string) (*Docu
|
||||
g := e.acquireDocLock(docID)
|
||||
defer g.Release()
|
||||
|
||||
if doc, exists := e.memDocs[docID]; exists {
|
||||
if doc, exists := e.memDocsGet(docID); exists {
|
||||
return doc, nil
|
||||
}
|
||||
if e.pool != nil {
|
||||
@@ -27,7 +27,7 @@ func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]*
|
||||
g := e.acquireDocLock(docID)
|
||||
defer g.Release()
|
||||
|
||||
if len(e.memAudits[docID]) == 0 && e.pool != nil {
|
||||
if e.memAuditsEmpty(docID) && e.pool != nil {
|
||||
rows, err := e.pool.Query(ctx, `
|
||||
SELECT id, doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at
|
||||
FROM document_audit_logs
|
||||
@@ -51,7 +51,7 @@ func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]*
|
||||
if len(detJSON) > 0 {
|
||||
_ = json.Unmarshal(detJSON, &a.Details)
|
||||
}
|
||||
e.memAudits[docID] = append(e.memAudits[docID], &a)
|
||||
e.memAuditsAppend(docID, &a)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -60,10 +60,7 @@ func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]*
|
||||
}
|
||||
}
|
||||
|
||||
logs := e.memAudits[docID]
|
||||
res := make([]*AuditLog, len(logs))
|
||||
copy(res, logs)
|
||||
return res, nil
|
||||
return e.memAuditsCopy(docID), nil
|
||||
}
|
||||
|
||||
// ListComments 列出文档的所有批注
|
||||
@@ -71,7 +68,7 @@ func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*D
|
||||
g := e.acquireDocLock(docID)
|
||||
defer g.Release()
|
||||
|
||||
if len(e.memComms[docID]) == 0 && e.pool != nil {
|
||||
if e.memCommsEmpty(docID) && e.pool != nil {
|
||||
rows, err := e.pool.Query(ctx, `
|
||||
SELECT id, doc_id, user_id, user_name, user_role, target_section, comment_type, content, status, created_at
|
||||
FROM document_comments
|
||||
@@ -90,7 +87,7 @@ func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*D
|
||||
}
|
||||
c.UserRole = UserRole(role)
|
||||
c.CommentType = CommentType(cType)
|
||||
e.memComms[docID] = append(e.memComms[docID], &c)
|
||||
e.memCommsAppend(docID, &c, false)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -99,10 +96,7 @@ func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*D
|
||||
}
|
||||
}
|
||||
|
||||
comms := e.memComms[docID]
|
||||
res := make([]*DocumentComment, len(comms))
|
||||
copy(res, comms)
|
||||
return res, nil
|
||||
return e.memCommsCopy(docID), nil
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID string) (*DocumentRecord, error) {
|
||||
@@ -162,6 +156,6 @@ func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID s
|
||||
if len(cfgJSON) > 0 {
|
||||
_ = json.Unmarshal(cfgJSON, &doc.Config)
|
||||
}
|
||||
e.memDocs[docID] = &doc
|
||||
e.memDocsPut(docID, &doc)
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, ac
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
if err := e.freezeReviewSnapshotLocked(ctx, doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (e *GovernanceEngine) ReviewApprove(ctx context.Context, docID string, acto
|
||||
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentSuggestion, comment)
|
||||
}
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func (e *GovernanceEngine) ReviewReject(ctx context.Context, docID string, actor
|
||||
// 驳回原因自动持久化为批注记录
|
||||
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason)
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, ac
|
||||
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentSuggestion, comment)
|
||||
}
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
if err := e.persistApproveAndFAV(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act
|
||||
|
||||
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason)
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func (e *GovernanceEngine) ReopenForRevision(ctx context.Context, docID string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.memAuditsAppend(docID, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -241,10 +241,11 @@ func (e *GovernanceEngine) FindApprovedProposal(ctx context.Context, docID strin
|
||||
g := e.acquireDocLock(docID)
|
||||
defer g.Release()
|
||||
|
||||
for _, p := range e.memProps {
|
||||
if p.DocID == docID && p.Status == ProposalApprovedByAuthor {
|
||||
return p, nil
|
||||
}
|
||||
p := e.memPropsFind(func(p *ChangeProposal) bool {
|
||||
return p.DocID == docID && p.Status == ProposalApprovedByAuthor
|
||||
})
|
||||
if p != nil {
|
||||
return p, nil
|
||||
}
|
||||
if e.pool != nil {
|
||||
var p ChangeProposal
|
||||
@@ -258,7 +259,7 @@ func (e *GovernanceEngine) FindApprovedProposal(ctx context.Context, docID strin
|
||||
`, docID).Scan(&p.ID, &p.ProposalID, &p.DocID, &p.ProjectID, &p.TriggeredBy, &p.ProposedChanges, &p.ImpactAnalysis, &p.RiskAssessment, &stat, &p.AuthorDecisionReason, &p.DecidedAt, &p.CreatedAt)
|
||||
if err == nil {
|
||||
p.Status = ProposalStatus(stat)
|
||||
e.memProps[p.ProposalID] = &p
|
||||
e.memPropsPut(p.ProposalID, &p)
|
||||
return &p, nil
|
||||
}
|
||||
}
|
||||
@@ -271,7 +272,7 @@ func (e *GovernanceEngine) MarkProposalApplied(ctx context.Context, proposalID s
|
||||
g := e.acquireDocLock("*proposal:" + proposalID)
|
||||
defer g.Release()
|
||||
|
||||
p, ok := e.memProps[proposalID]
|
||||
p, ok := e.memPropsGet(proposalID)
|
||||
if !ok && e.pool != nil {
|
||||
var loaded ChangeProposal
|
||||
var stat string
|
||||
@@ -284,7 +285,7 @@ func (e *GovernanceEngine) MarkProposalApplied(ctx context.Context, proposalID s
|
||||
}
|
||||
loaded.Status = ProposalStatus(stat)
|
||||
p = &loaded
|
||||
e.memProps[proposalID] = p
|
||||
e.memPropsPut(proposalID, p)
|
||||
}
|
||||
if p == nil {
|
||||
return ErrProposalNotFound
|
||||
@@ -468,7 +469,7 @@ func newLeaseFencingToken() string {
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) getDocLocked(ctx context.Context, docID string) (*DocumentRecord, error) {
|
||||
doc, exists := e.memDocs[docID]
|
||||
doc, exists := e.memDocsGet(docID)
|
||||
if !exists {
|
||||
var err error
|
||||
doc, err = e.loadDocumentFromDBLocked(ctx, docID)
|
||||
@@ -573,7 +574,7 @@ func (e *GovernanceEngine) appendAuditLocked(ctx context.Context, doc *DocumentR
|
||||
Details: details,
|
||||
CreatedAt: now,
|
||||
}
|
||||
e.memAudits[doc.DocID] = append(e.memAudits[doc.DocID], audit)
|
||||
e.memAuditsAppend(doc.DocID, audit)
|
||||
if e.pool != nil {
|
||||
detJSON, _ := json.Marshal(details)
|
||||
if _, err := e.pool.Exec(ctx, `
|
||||
|
||||
@@ -57,18 +57,18 @@ func (e *GovernanceEngine) withDocLock(docID string, fn func() error) error {
|
||||
|
||||
// lookupMemDoc 返回内存中的文档记录(需在 doc 锁内调用)。
|
||||
func (e *GovernanceEngine) lookupMemDoc(docID string) (*DocumentRecord, bool) {
|
||||
doc, ok := e.memDocs[docID]
|
||||
return doc, ok
|
||||
return e.memDocsGet(docID)
|
||||
}
|
||||
|
||||
// ensureDocLockedWithDB 在 doc 锁内做内存查找 + DB 兜底加载。
|
||||
// 对应原 ensureDocumentLocked,但锁粒度为单文档。
|
||||
// 对应原 ensureDocumentLocked,但锁粒度为单文档。map 访问走 memMu,不再重入 e.mu
|
||||
// (SaveDocumentContent 等调用方可能已经持有 e.mu)。
|
||||
func (e *GovernanceEngine) ensureDocLockedWithDB(ctx context.Context, docID string) (*DocumentRecord, error) {
|
||||
if doc, ok := e.memDocs[docID]; ok {
|
||||
if doc, ok := e.memDocsGet(docID); ok {
|
||||
return doc, nil
|
||||
}
|
||||
if e.pool != nil {
|
||||
doc, err := e.loadDocumentFromDB(ctx, docID)
|
||||
doc, err := e.loadDocumentFromDBLocked(ctx, docID)
|
||||
if err == nil && doc != nil {
|
||||
return doc, nil
|
||||
}
|
||||
@@ -76,6 +76,131 @@ func (e *GovernanceEngine) ensureDocLockedWithDB(ctx context.Context, docID stri
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memDocsGet(docID string) (*DocumentRecord, bool) {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
doc, ok := e.memDocs[docID]
|
||||
return doc, ok
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memDocsPut(docID string, doc *DocumentRecord) {
|
||||
e.memMu.Lock()
|
||||
defer e.memMu.Unlock()
|
||||
if e.memDocs == nil {
|
||||
e.memDocs = map[string]*DocumentRecord{}
|
||||
}
|
||||
e.memDocs[docID] = doc
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memDocsFind(match func(*DocumentRecord) bool) *DocumentRecord {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
for _, doc := range e.memDocs {
|
||||
if doc != nil && match(doc) {
|
||||
return doc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) forEachMemDoc(fn func(*DocumentRecord)) {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
for _, doc := range e.memDocs {
|
||||
fn(doc)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memAuditsEmpty(docID string) bool {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
return len(e.memAudits[docID]) == 0
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memAuditsAppend(docID string, audit *AuditLog) {
|
||||
e.memMu.Lock()
|
||||
defer e.memMu.Unlock()
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memAuditsCopy(docID string) []*AuditLog {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
logs := e.memAudits[docID]
|
||||
res := make([]*AuditLog, len(logs))
|
||||
copy(res, logs)
|
||||
return res
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memCommsEmpty(docID string) bool {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
return len(e.memComms[docID]) == 0
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memCommsAppend(docID string, comment *DocumentComment, assignSyntheticID bool) {
|
||||
e.memMu.Lock()
|
||||
defer e.memMu.Unlock()
|
||||
if assignSyntheticID {
|
||||
var maxID int64
|
||||
for _, list := range e.memComms {
|
||||
for _, c := range list {
|
||||
if c != nil && c.ID > maxID {
|
||||
maxID = c.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
comment.ID = maxID + 1
|
||||
}
|
||||
e.memComms[docID] = append(e.memComms[docID], comment)
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memCommsCopy(docID string) []*DocumentComment {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
comms := e.memComms[docID]
|
||||
res := make([]*DocumentComment, len(comms))
|
||||
copy(res, comms)
|
||||
return res
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memCommsFind(commentID int64) (*DocumentComment, string) {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
for id, list := range e.memComms {
|
||||
for _, c := range list {
|
||||
if c != nil && c.ID == commentID {
|
||||
return c, id
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memPropsGet(proposalID string) (*ChangeProposal, bool) {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
p, ok := e.memProps[proposalID]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memPropsPut(proposalID string, proposal *ChangeProposal) {
|
||||
e.memMu.Lock()
|
||||
defer e.memMu.Unlock()
|
||||
e.memProps[proposalID] = proposal
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) memPropsFind(match func(*ChangeProposal) bool) *ChangeProposal {
|
||||
e.memMu.RLock()
|
||||
defer e.memMu.RUnlock()
|
||||
for _, p := range e.memProps {
|
||||
if p != nil && match(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadDocumentFromDB 在无全局锁状态下从 PG 装载文档记录到内存。
|
||||
// 这是 T20 B3 的核心:DB RTT 不再进入全局临界区。
|
||||
func (e *GovernanceEngine) loadDocumentFromDB(ctx context.Context, docID string) (*DocumentRecord, error) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package docgov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMemMapsNoRaceAcrossDocs 复现 2026-09-28 dev 崩溃:不同 docID 并发读写共享 map。
|
||||
// 修复前 go test -race 会报告 concurrent map read/write(运行时则 fatal)。
|
||||
func TestMemMapsNoRaceAcrossDocs(t *testing.T) {
|
||||
e := NewGovernanceEngine(nil)
|
||||
ctx := context.Background()
|
||||
const n = 48
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n * 3)
|
||||
for i := 0; i < n; i++ {
|
||||
id := fmt.Sprintf("race-doc-%d", i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := e.CreateOrGetDocument(ctx, id, int64(i+1), "task", "title", "stage", "", 1, "author", 2, "reviewer", 3, "approver"); err != nil {
|
||||
t.Errorf("create %s: %v", id, err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = e.GetDocument(ctx, id)
|
||||
_, _ = e.ListAuditLogs(ctx, id)
|
||||
_, _ = e.ListComments(ctx, id)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = e.addCommentInternal(id, 1, "author", RoleAuthor, "", CommentSuggestion, "note")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
id := fmt.Sprintf("race-doc-%d", i)
|
||||
doc, err := e.GetDocument(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", id, err)
|
||||
}
|
||||
if doc.DocID != id {
|
||||
t.Fatalf("doc id = %s, want %s", doc.DocID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int6
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if existing, ok := e.memDocs[docID]; ok && existing != nil {
|
||||
if existing, ok := e.memDocsGet(docID); ok && existing != nil {
|
||||
if !existing.IsTemplateOnly {
|
||||
return nil
|
||||
}
|
||||
@@ -129,10 +129,7 @@ func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int6
|
||||
existing.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
if e.memDocs == nil {
|
||||
e.memDocs = map[string]*DocumentRecord{}
|
||||
}
|
||||
e.memDocs[docID] = &DocumentRecord{
|
||||
e.memDocsPut(docID, &DocumentRecord{
|
||||
DocID: docID,
|
||||
ProjectID: projectID,
|
||||
TaskKey: taskKey,
|
||||
@@ -152,7 +149,7 @@ func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int6
|
||||
Config: DefaultDocumentConfig(),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -202,15 +199,16 @@ func (e *GovernanceEngine) hasCanonicalSibling(ctx context.Context, projectID in
|
||||
}
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
for _, doc := range e.memDocs {
|
||||
if doc == nil || doc.ProjectID != projectID || doc.DocID == docID {
|
||||
continue
|
||||
found := e.memDocsFind(func(doc *DocumentRecord) bool {
|
||||
if doc.ProjectID != projectID || doc.DocID == docID {
|
||||
return false
|
||||
}
|
||||
for _, key := range siblings {
|
||||
if doc.TaskKey == key {
|
||||
return true, nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
return false
|
||||
})
|
||||
return found != nil, nil
|
||||
}
|
||||
|
||||
@@ -108,8 +108,8 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
out := make([]WorkflowDocumentSummary, 0)
|
||||
for _, doc := range e.memDocs {
|
||||
if doc.ProjectID == projectID && doc.TaskKey != "" {
|
||||
e.forEachMemDoc(func(doc *DocumentRecord) {
|
||||
if doc != nil && doc.ProjectID == projectID && doc.TaskKey != "" {
|
||||
out = append(out, WorkflowDocumentSummary{
|
||||
DocID: doc.DocID,
|
||||
ProjectID: doc.ProjectID,
|
||||
@@ -133,7 +133,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p
|
||||
DutyPolicy: doc.DutyPolicy,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return collapseWorkflowDocuments(out), nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user