Skip to content

Commit 997cac9

Browse files
committed
feat: add exponential backoff retry to SyncQueue
- SyncHandlerFunc 신규 타입 추가 (error 반환) — 핸들러 실패를 queue가 인지 - RetryConfig 구조체 추가 (MaxAttempts=3, BaseDelay=1s, MaxDelay=30s) - NewSyncQueueWithOptions 추가 — retry 설정 주입 가능 - panic을 error로 변환하여 retry 대상에 포함 - context 취소 시 대기 중인 retry 즉시 중단 - 테스트 3개 추가: retry 성공, MaxAttempts 초과 포기, context 취소 중단 - guide/webhook.md, guide/architecture.md 문서 업데이트
1 parent d1b2169 commit 997cac9

7 files changed

Lines changed: 212 additions & 45 deletions

File tree

cmd/ccg/main.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,14 @@ func serveStreamableHTTP(deps *cli.Deps, srv *server.MCPServer, cfg cli.ServeCon
277277
}
278278
filter := webhook.NewRepoFilterFromRules(rules)
279279
secret := []byte(cfg.WebhookSecret)
280-
syncHandler := func(ctx context.Context, repoFullName, cloneURL string) {
280+
syncHandler := func(ctx context.Context, repoFullName, cloneURL string) error {
281281
ns := webhook.ExtractNamespace(repoFullName)
282282
deps.Logger.Info("webhook sync started", "repo", repoFullName, "namespace", ns)
283283
cloneCtx, cloneCancel := context.WithTimeout(ctx, 10*time.Minute)
284284
defer cloneCancel()
285285
if err := webhook.CloneOrPull(cloneCtx, cloneURL, cfg.RepoRoot, ns, nil); err != nil {
286286
deps.Logger.Error("webhook clone/pull failed", "repo", repoFullName, "error", err)
287-
return
287+
return err
288288
}
289289

290290
repoDir := webhook.RepoDir(cfg.RepoRoot, ns)
@@ -304,10 +304,11 @@ func serveStreamableHTTP(deps *cli.Deps, srv *server.MCPServer, cfg cli.ServeCon
304304
})
305305
if err != nil {
306306
deps.Logger.Error("webhook build failed", "repo", repoFullName, "error", err)
307-
return
307+
return err
308308
}
309309
deps.Logger.Info("webhook sync completed", "repo", repoFullName, "namespace", ns,
310310
"files", stats.TotalFiles, "nodes", stats.TotalNodes, "edges", stats.TotalEdges)
311+
return nil
311312
}
312313
syncQueue = webhook.NewSyncQueueWithContext(syncCtx, 4, syncHandler)
313314
mux.Handle("/webhook", webhook.NewWebhookHandler(secret, filter, syncQueue.Add))

guide/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Golden corpus 기반 파서 정확도 및 검색 품질 평가 프레임워크.
8686
GitHub/Gitea push 이벤트 수신 → 자동 clone/build 파이프라인.
8787

8888
- **RepoFilter**: 레포별 브랜치 필터링 (`IsAllowedRef`)
89-
- **SyncQueue**: 중복 제거 + 동시성 제어 워커 큐
89+
- **SyncQueue**: 중복 제거 + 동시성 제어 워커 큐. 핸들러 실패 시 exponential backoff retry (기본 3회, 1s→2s→4s, 최대 30s)
9090
- **CloneOrPull**: go-git 기반 clone/pull (SSH key, app token 지원)
9191

9292
## Database Schema

guide/webhook.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ Push Event → HMAC Verify → RepoFilter.IsAllowedRef()
9999
- 서로 다른 레포는 병렬 처리
100100
- 같은 레포는 순차 처리 (dirty requeue)
101101

102+
### Retry / Backoff
103+
104+
클론 또는 빌드 실패 시 exponential backoff로 자동 재시도합니다:
105+
106+
| 설정 | 기본값 | 설명 |
107+
|------|--------|------|
108+
| MaxAttempts | 3 | 최대 시도 횟수 (첫 시도 포함) |
109+
| BaseDelay | 1s | 첫 번째 재시도 대기 시간 |
110+
| MaxDelay | 30s | 재시도 대기 시간 상한 |
111+
112+
- 지수 증가: 1s → 2s → 4s → ... (MaxDelay 초과 시 MaxDelay로 고정)
113+
- context 취소(서버 종료) 시 대기 중인 retry 즉시 중단
114+
- panic도 에러로 처리되어 retry 대상이 됨
115+
- MaxAttempts 초과 시 `ERROR` 로그 기록 후 해당 sync 포기 (다음 push 이벤트 시 재시도 가능)
116+
102117
## `.ccg.yaml` include_paths 자동 반영
103118

104119
Webhook 빌드 시 clone된 레포 내 `.ccg.yaml` 파일의 `include_paths` 설정을 자동으로 읽어 빌드 범위를 제한합니다.

internal/webhook/e2e_test.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func TestE2E_WebhookToRAG(t *testing.T) {
2525
done := make(chan struct{})
2626

2727
onSync := func(_ context.Context, repoFullName, cloneURL string) {
28-
defer close(done)
28+
defer close(done)
2929
ns := ExtractNamespace(repoFullName)
3030
mu.Lock()
3131
clonedNS = ns
@@ -35,7 +35,7 @@ func TestE2E_WebhookToRAG(t *testing.T) {
3535
mu.Lock()
3636
cloneErr = err
3737
mu.Unlock()
38-
}
38+
}
3939

4040
handler := NewWebhookHandler(secret, allowlist, onSync)
4141

@@ -93,7 +93,7 @@ func TestE2E_MultiRepoIsolation(t *testing.T) {
9393
var wg sync.WaitGroup
9494

9595
onSync := func(_ context.Context, repoFullName, cloneURL string) {
96-
defer wg.Done()
96+
defer wg.Done()
9797
ns := ExtractNamespace(repoFullName)
9898
err := CloneOrPull(context.Background(), cloneURL, repoRoot, ns, nil)
9999
mu.Lock()
@@ -102,7 +102,7 @@ func TestE2E_MultiRepoIsolation(t *testing.T) {
102102
err error
103103
}{ns: ns, err: err})
104104
mu.Unlock()
105-
}
105+
}
106106

107107
handler := NewWebhookHandler(secret, allowlist, onSync)
108108

@@ -186,8 +186,8 @@ func TestE2E_SyncQueueDedup(t *testing.T) {
186186
var mu sync.Mutex
187187
done := make(chan struct{}, 1)
188188

189-
syncHandler := func(_ context.Context, repoFullName, cloneURL string) {
190-
ns := ExtractNamespace(repoFullName)
189+
syncHandler := func(_ context.Context, repoFullName, cloneURL string) error {
190+
ns := ExtractNamespace(repoFullName)
191191
_ = CloneOrPull(context.Background(), cloneURL, repoRoot, ns, nil)
192192
mu.Lock()
193193
callCount++
@@ -199,7 +199,8 @@ func TestE2E_SyncQueueDedup(t *testing.T) {
199199
default:
200200
}
201201
}
202-
}
202+
return nil
203+
}
203204

204205
q := NewSyncQueue(2, syncHandler)
205206
defer q.Shutdown()
@@ -254,8 +255,8 @@ func TestE2E_SyncQueueMultiRepoParallel(t *testing.T) {
254255
synced := make(map[string]bool)
255256
allDone := make(chan struct{})
256257

257-
syncHandler := func(_ context.Context, repoFullName, cloneURL string) {
258-
ns := ExtractNamespace(repoFullName)
258+
syncHandler := func(_ context.Context, repoFullName, cloneURL string) error {
259+
ns := ExtractNamespace(repoFullName)
259260
_ = CloneOrPull(context.Background(), cloneURL, repoRoot, ns, nil)
260261
mu.Lock()
261262
synced[ns] = true
@@ -266,7 +267,8 @@ func TestE2E_SyncQueueMultiRepoParallel(t *testing.T) {
266267
}
267268
}
268269
mu.Unlock()
269-
}
270+
return nil
271+
}
270272

271273
q := NewSyncQueue(2, syncHandler)
272274
defer q.Shutdown()

internal/webhook/handler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414

1515
type SyncFunc func(ctx context.Context, repoFullName, cloneURL string)
1616

17+
type SyncHandlerFunc func(ctx context.Context, repoFullName, cloneURL string) error
18+
1719
type WebhookHandler struct {
1820
secret []byte
1921
filter *RepoFilter

internal/webhook/syncqueue.go

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,68 @@ package webhook
22

33
import (
44
"context"
5+
"fmt"
56
"log/slog"
67
"sync"
78
"time"
89
)
910

11+
// RetryConfig configures exponential backoff retry for sync handlers.
12+
type RetryConfig struct {
13+
// MaxAttempts is the total number of attempts (1 = no retry). Default: 3.
14+
MaxAttempts int
15+
// BaseDelay is the initial backoff duration. Default: 1s.
16+
BaseDelay time.Duration
17+
// MaxDelay caps the backoff duration. Default: 30s.
18+
MaxDelay time.Duration
19+
}
20+
21+
func defaultRetryConfig() RetryConfig {
22+
return RetryConfig{
23+
MaxAttempts: 3,
24+
BaseDelay: 1 * time.Second,
25+
MaxDelay: 30 * time.Second,
26+
}
27+
}
28+
1029
type syncPayload struct {
1130
repoFullName string
1231
cloneURL string
1332
}
1433

1534
type SyncQueue struct {
16-
ctx context.Context
17-
handler SyncFunc
18-
mu sync.Mutex
19-
queue []string
20-
dirty map[string]bool
21-
processing map[string]bool
22-
payloads map[string]syncPayload
23-
cond *sync.Cond
24-
shutdown bool
25-
wg sync.WaitGroup
35+
ctx context.Context
36+
handler SyncHandlerFunc
37+
retryConfig RetryConfig
38+
mu sync.Mutex
39+
queue []string
40+
dirty map[string]bool
41+
processing map[string]bool
42+
payloads map[string]syncPayload
43+
cond *sync.Cond
44+
shutdown bool
45+
wg sync.WaitGroup
2646
}
2747

28-
func NewSyncQueue(workers int, handler SyncFunc) *SyncQueue {
48+
func NewSyncQueue(workers int, handler SyncHandlerFunc) *SyncQueue {
2949
return NewSyncQueueWithContext(context.Background(), workers, handler)
3050
}
3151

32-
func NewSyncQueueWithContext(ctx context.Context, workers int, handler SyncFunc) *SyncQueue {
52+
func NewSyncQueueWithContext(ctx context.Context, workers int, handler SyncHandlerFunc) *SyncQueue {
53+
return NewSyncQueueWithOptions(ctx, workers, handler, defaultRetryConfig())
54+
}
55+
56+
func NewSyncQueueWithOptions(ctx context.Context, workers int, handler SyncHandlerFunc, retry RetryConfig) *SyncQueue {
57+
if retry.MaxAttempts <= 0 {
58+
retry.MaxAttempts = 1
59+
}
3360
q := &SyncQueue{
34-
ctx: ctx,
35-
handler: handler,
36-
dirty: make(map[string]bool),
37-
processing: make(map[string]bool),
38-
payloads: make(map[string]syncPayload),
61+
ctx: ctx,
62+
handler: handler,
63+
retryConfig: retry,
64+
dirty: make(map[string]bool),
65+
processing: make(map[string]bool),
66+
payloads: make(map[string]syncPayload),
3967
}
4068
q.cond = sync.NewCond(&q.mu)
4169

@@ -109,12 +137,44 @@ func (q *SyncQueue) worker() {
109137
}
110138

111139
func (q *SyncQueue) safeHandle(repo string, payload syncPayload) {
140+
cfg := q.retryConfig
141+
delay := cfg.BaseDelay
142+
143+
for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
144+
err := q.tryHandle(repo, payload)
145+
if err == nil {
146+
return
147+
}
148+
149+
if attempt == cfg.MaxAttempts {
150+
slog.Error("sync handler failed, giving up", "repo", repo, "attempts", attempt, "error", err)
151+
return
152+
}
153+
154+
slog.Warn("sync handler failed, retrying", "repo", repo, "attempt", attempt, "retryIn", delay, "error", err)
155+
156+
select {
157+
case <-q.ctx.Done():
158+
slog.Warn("sync retry cancelled", "repo", repo, "attempt", attempt)
159+
return
160+
case <-time.After(delay):
161+
}
162+
163+
delay *= 2
164+
if delay > cfg.MaxDelay {
165+
delay = cfg.MaxDelay
166+
}
167+
}
168+
}
169+
170+
func (q *SyncQueue) tryHandle(repo string, payload syncPayload) (err error) {
112171
defer func() {
113172
if r := recover(); r != nil {
114173
slog.Error("sync handler panicked", "repo", repo, "panic", r)
174+
err = fmt.Errorf("panic: %v", r)
115175
}
116176
}()
117-
q.handler(q.ctx, payload.repoFullName, payload.cloneURL)
177+
return q.handler(q.ctx, payload.repoFullName, payload.cloneURL)
118178
}
119179

120180
func (q *SyncQueue) get() (string, syncPayload, bool) {

0 commit comments

Comments
 (0)