merge: candidate docgov-shared-map-race-2026-09-28-r2 into dev

This commit is contained in:
weichen
2026-09-28 09:06:52 +00:00
18 changed files with 384 additions and 109 deletions
+2 -1
View File
@@ -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) | 疑问进度句必须让行查询;显式「开始/重新」仍启动任务 |
+1
View File
@@ -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 改为分片或移除,本条对那个存储失效,保留历史。
+1
View File
@@ -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` 仍开放。
+9 -8
View File
@@ -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
}
+5 -1
View File
@@ -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
+6 -10
View File
@@ -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)
}
}
+4 -4
View File
@@ -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
}
+14 -36
View File
@@ -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
}
+8 -14
View File
@@ -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
}
+6 -6
View File
@@ -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
}
+10 -9
View File
@@ -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, `
+130 -5
View File
@@ -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) {
+49
View File
@@ -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)
}
}
}
+10 -12
View File
@@ -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
}
+3 -3
View File
@@ -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
}