Skip to content

Commit 16e1ff5

Browse files
authored
Merge pull request #218 from Team-StackUp/test/realtime-race-detector
RealTime 레지스트리 동시성 테스트 + CI race detector
2 parents 8ac5d7c + 581d9ae commit 16e1ff5

3 files changed

Lines changed: 76 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ jobs:
125125
working-directory: ./realtime
126126
run: go vet ./...
127127

128+
# -race 로 돌린다. RealTime 은 본업이 동시 fan-out(AMQP 컨슈머가 Dispatch 하는 동안
129+
# HTTP 핸들러들이 Subscribe/Unsubscribe)이라, 경합은 여기서 못 잡으면 운영에서
130+
# 간헐적 패닉·유실로만 드러난다. realtime/CLAUDE.md §11 도 -race 를 규정한다.
128131
- name: Test
129132
working-directory: ./realtime
130-
run: go test ./...
133+
run: go test -race ./...

realtime/Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build run test fmt lint tidy clean
1+
.PHONY: build run test test-race fmt lint tidy clean
22

33
GO ?= go
44
PKG ?= ./...
@@ -12,6 +12,10 @@ run:
1212
test:
1313
$(GO) test $(PKG)
1414

15+
# CI 가 도는 것과 같은 형태. 동시성 변경을 했다면 push 전에 이걸로 확인한다.
16+
test-race:
17+
$(GO) test -race $(PKG)
18+
1519
fmt:
1620
$(GO) fmt $(PKG)
1721

realtime/internal/session/registry_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package session
22

33
import (
4+
"sync"
45
"testing"
56
"time"
67
)
@@ -44,3 +45,69 @@ func TestUnsubscribeRemovesChannelEntry(t *testing.T) {
4445
t.Errorf("delivered = %d, want 0 after unsubscribe", n)
4546
}
4647
}
48+
49+
// Registry 는 RealTime 서버 전체의 공유 가변 상태다. 운영에서는 AMQP 컨슈머 고루틴이
50+
// Dispatch 하는 동안 HTTP 핸들러들이 Subscribe/Unsubscribe 한다 — 그런데 이 조합을
51+
// 검증하는 테스트가 없었다.
52+
//
53+
// Dispatch 는 의도적으로 락을 놓은 뒤 채널에 쓴다(느린 구독자가 락을 잡고 있으면 다른
54+
// 구독자까지 막히므로). 그 설계 때문에 "복사한 구독자 목록"과 "지금 살아있는 구독자"가
55+
// 어긋나는 창이 생기고, 여기서 어긋남이 자료 경합이 되지 않는지 확인한다.
56+
//
57+
// `-race` 와 함께 돌 때 의미가 있다 (CI 의 `go test -race ./...`).
58+
func TestRegistryConcurrentSubscribeDispatchUnsubscribe(t *testing.T) {
59+
r := NewRegistry()
60+
target := ch(ChannelSession, 1)
61+
62+
const (
63+
churnGoroutines = 8
64+
churnIterations = 200
65+
dispatchers = 4
66+
)
67+
68+
stop := make(chan struct{})
69+
var dispatchWG, churnWG sync.WaitGroup
70+
71+
for i := 0; i < dispatchers; i++ {
72+
dispatchWG.Add(1)
73+
go func() {
74+
defer dispatchWG.Done()
75+
ev := Event{ID: "1", Type: "SESSION_MESSAGE", Data: []byte(`{"a":1}`)}
76+
for {
77+
select {
78+
case <-stop:
79+
return
80+
default:
81+
}
82+
// 느린 구독자 타임아웃은 짧게 — 버퍼가 찬 구독자 때문에 테스트가 늘어지지 않게.
83+
r.Dispatch(target, ev, time.Millisecond)
84+
}
85+
}()
86+
}
87+
88+
for i := 0; i < churnGoroutines; i++ {
89+
churnWG.Add(1)
90+
go func() {
91+
defer churnWG.Done()
92+
for j := 0; j < churnIterations; j++ {
93+
sub := r.Subscribe(target, 2)
94+
// 한 건 정도 읽어 Dispatch 가 항상 타임아웃으로만 끝나지 않게 한다.
95+
select {
96+
case <-sub.Ch:
97+
default:
98+
}
99+
r.Unsubscribe(target, sub)
100+
}
101+
}()
102+
}
103+
104+
churnWG.Wait()
105+
close(stop)
106+
dispatchWG.Wait()
107+
108+
// 모든 구독이 해제됐으면 전달 대상이 남아 있으면 안 된다. id 기반 제거가 churn 중
109+
// 어긋나면 해제된 구독자가 목록에 남아 여기서 0 이 아니게 된다.
110+
if delivered := r.Dispatch(target, Event{ID: "x"}, time.Millisecond); delivered != 0 {
111+
t.Fatalf("expected no subscribers after full unsubscribe, delivered=%d", delivered)
112+
}
113+
}

0 commit comments

Comments
 (0)