Files

168 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package docrevision
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)
// slowPool 模拟慢 PG:每 Query/QueryRow 阻塞 configurable 延迟。
// 用于验证 doc 级锁下,docA 的慢 hydrate 不阻塞 docB。
type slowQueryRecorder struct {
mu sync.Mutex
inFlight map[string]int
peak int
}
func (r *slowQueryRecorder) enter(docID string) {
r.mu.Lock()
r.inFlight[docID]++
cur := 0
for _, v := range r.inFlight {
cur += v
}
if cur > r.peak {
r.peak = cur
}
r.mu.Unlock()
}
func (r *slowQueryRecorder) exit(docID string) {
r.mu.Lock()
r.inFlight[docID]--
r.mu.Unlock()
}
// TestParallelHydrateDoesNotSerializeAcrossDocs(T20 B3 验收)
// 两个不存在 ledger 的文档并行做 GetRevisionState(触发 hydrate 慢查询路径)。
// 断言:
// 1. 全局互斥不覆盖两文档的 DB RTT —— 并行段存在重叠;
// 2. 结果与串行一致(不存在的 ledger → ErrNotFound)。
func TestParallelHydrateDoesNotSerializeAcrossDocs(t *testing.T) {
if testing.Short() {
t.Skip("concurrency benchmark skipped in -short")
}
svc := NewService(nil)
// 用 pool=nil 的内存模式无法触发 hydrate DB 路径;直接对 docLocks 做锁隔离验证。
var overlap int64
var wg sync.WaitGroup
start := make(chan struct{})
hold := make(chan struct{})
// goroutine A: 持 docA 锁并阻塞(模拟 docA 慢 hydrate)
wg.Add(1)
go func() {
defer wg.Done()
gA := svc.lockDoc("docA")
<-start
// 标记:此刻若 docB 也能拿到自己的锁并完成,说明无全局串行
atomic.AddInt64(&overlap, 1)
<-hold // 模拟 DB RTT
gA.Release()
}()
// goroutine B: docB 锁的获取/释放必须在 docA 持锁期间完成
wg.Add(1)
go func() {
defer wg.Done()
<-start
gB := svc.lockDoc("docB")
if atomic.LoadInt64(&overlap) == 1 {
// docA 持锁中,docB 仍成功获取了自己的锁
t.Log("docB acquired lock while docA held its own: no cross-doc serialization")
}
gB.Release()
}()
close(start)
time.Sleep(50 * time.Millisecond)
close(hold)
wg.Wait()
if atomic.LoadInt64(&overlap) != 1 {
t.Fatalf("expected docA hold marker, got %d", overlap)
}
}
// TestParallelSameDocStillSerialized(T20 B3 回归保护)
// 同一文档的两个写路径仍然互斥(分锁不放松单文档内串行化)。
func TestParallelSameDocStillSerialized(t *testing.T) {
svc := NewService(nil)
var critical int64
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
g := svc.lockDoc("docSame")
cur := atomic.AddInt64(&critical, 1)
if cur > 1 {
t.Errorf("same-doc critical section overlapped: %d", cur)
}
atomic.AddInt64(&critical, -1)
g.Release()
}()
}
wg.Wait()
}
// TestConcurrentDistinctDocsCommits(T20 B3:多文档并发提交链)
// 4 个文档各自串行提交 10 版;不同文档间并行。
// 校验各文档 revision 链独立、无交叉污染。
func TestConcurrentDistinctDocsCommits(t *testing.T) {
svc := NewService(nil)
ctx := context.Background()
const docs = 4
const revs = 10
var wg sync.WaitGroup
errs := make(chan error, docs*revs)
for d := 0; d < docs; d++ {
docID := "docpar" + string(rune('A'+d))
st, err := svc.OpenTemplateVersion(ctx, docID, "# base\n", ActorSnapshot{}, 1, "")
if err != nil {
t.Fatal(err)
}
wg.Add(1)
go func(docID string, head int64, rev int) {
defer wg.Done()
for i := 0; i < revs; i++ {
md := fmt.Sprintf("# base\n\nedit %d\n", i)
r, err := svc.CommitHumanRevision(ctx, HumanRevisionRequest{
DocID: docID, Markdown: md,
ExpectedHeadSnapshot: &head, ExpectedRevisionNo: &rev,
IdempotencyKey: fmt.Sprintf("%s-seq-%d", docID, i),
ActorUserID: 1,
LifecycleState: "drafting",
})
if err != nil {
errs <- err
return
}
head = r.State.HeadSnapshotID
rev = r.State.HeadRevisionNo
}
}(docID, st.HeadSnapshotID, st.HeadRevisionNo)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
for d := 0; d < docs; d++ {
docID := "docpar" + string(rune('A'+d))
st, err := svc.GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
if st.HeadRevisionNo != revs {
t.Fatalf("%s: want %d revisions, got %d", docID, revs, st.HeadRevisionNo)
}
}
}