fix: 写队列满时 Close 不再与 enqueue 死锁

This commit is contained in:
Nixevol
2026-09-30 17:28:15 +08:00
parent 0c9b459fb1
commit eb4cb7c655
3 changed files with 166 additions and 9 deletions
+10
View File
@@ -1718,3 +1718,13 @@ issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是
- 原因:原先 12 项备注写�未穷尽�标通过,交付说明写�「通过 23�。
- 备选方案:为�个未测�项补验收用例(本波��,��为�绿放�断言)。
- 影�:汇总改为通过 19�部分通过 4(F03/F08/F21/F22)�失败 0。F19 �引用仓库内 SDK 清��本波��跑。
### ¸´ÉóÐÞ¸´ R3-03
1. **д¶ÓÁÐÂúʱ Close ²»ÔÙÓë enqueue ËÀËø**
- ÈÕÆÚ£º2026-09-30
- Ô­Ìõ¿î£ºissue #67£»Í£»úÐè Drain/Close ÔÚ³¬Ê±ÄÚ·µ»Ø£»ÒÑÈë¶ÓÈÎÎñÈÔÓÉд goroutine ´¦ÀíÍê¡£
- ʵ¼Ê×ö·¨£ºQueue Ôö¼Ó closing Ðźš£enqueue ÔÚ³Ö sendMu.RLock ʱ select ͬʱµÈ´ý q.ch¡¢closing Óë ctx.Done()£¬Í¨µÀÂúʱ²»ÔÙÎÞÏÞ×èÈû¡£Close ÏÈ Swap(closed) ²¢ close(closing) »½ÐÑÔÚ;·¢ËÍ·½ÊͷŶÁËø£¬ÔÙÔÚÐ´ËøÄÚ close(q.ch)£¬×îºóµÈ loop Í˳ö¡£²»ÏòÒÑ¹Ø±Õ channel ·¢ËÍ¡£
- Ô­Òò£ºÍ¨µÀÂúʱ²¢·¢ Do Õ¼×ŶÁËø¶ÂÔÚ·¢ËÍÉÏ£¬Close µÄÐ´ËøÄò»µ½£¬Í£»ú¿¨ËÀ£»Î´Ìá½»µÄÔÚ;дҲ»á¶ª¡£
- ±¸Ñ¡·½°¸£ºÈë¶Ó¸ÄΪ·Ç×èÈû£¬ÂúÔòÁ¢¼´ ErrBusy£¨·ñ¾ö£º¸Ä±ä±³Ñ¹ÓïÒ壬Õý³£¸ß·å»áÎóÉËÌá½»£©¡£
- Ó°Ï죺½ö internal/store/queue.go Óë²âÊÔ£»²»¸ÄÇ¨ÒÆ±àºÅ¡£
+22 -9
View File
@@ -36,10 +36,11 @@ type writeJob struct {
type Queue struct {
db *sql.DB
ch chan writeJob
done chan struct{}
closed atomic.Bool
sendMu sync.RWMutex
ch chan writeJob
done chan struct{}
closing chan struct{} // Close 时关闭,唤醒持读锁阻塞在发送上的 enqueue
closed atomic.Bool
sendMu sync.RWMutex
mu sync.Mutex
ready bool
@@ -52,11 +53,16 @@ type Queue struct {
// NewQueue 创建合并写入队列并启动写 goroutine。
func NewQueue(db *sql.DB) *Queue {
return newQueue(db, queueBuffSize)
}
func newQueue(db *sql.DB, buffSize int) *Queue {
q := &Queue{
db: db,
ch: make(chan writeJob, queueBuffSize),
done: make(chan struct{}),
ready: true,
db: db,
ch: make(chan writeJob, buffSize),
done: make(chan struct{}),
closing: make(chan struct{}),
ready: true,
}
go q.loop()
return q
@@ -115,10 +121,15 @@ func (q *Queue) enqueue(job writeJob) error {
return ErrQueueClosed
}
q.addPending(1)
// 通道满时不得只堵在发送上持有读锁:Close 需要写锁关闭 q.ch。
select {
case q.ch <- job:
q.sendMu.RUnlock()
return nil
case <-q.closing:
q.addPending(-1)
q.sendMu.RUnlock()
return ErrQueueClosed
case <-job.ctx.Done():
q.addPending(-1)
q.sendMu.RUnlock()
@@ -376,11 +387,13 @@ func (q *Queue) Drain(ctx context.Context) error {
}
// Close 关闭队列:不再接受新任务,并等待写 goroutine 处理完已入队任务后退出。
// 在写锁内关闭数据通道,避免并发 Do 向已关闭 channel 发送而 panic。
// 先关闭 closing 唤醒因通道满而阻塞的发送方并释放读锁,再在无发送者时关闭数据通道,
// 避免向已关闭 channel 发送而 panic,也避免与持读锁的 enqueue 死锁。
func (q *Queue) Close() error {
if q.closed.Swap(true) {
return nil
}
close(q.closing)
q.sendMu.Lock()
close(q.ch)
q.sendMu.Unlock()
+134
View File
@@ -289,3 +289,137 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.upd
}
}
}
// openSmallQueue 用小缓冲队列替换默认队列,便于测满通道时的 Close/Drain。
func openSmallQueue(t *testing.T, buf int) *DB {
t.Helper()
dir := t.TempDir()
db, err := Open(dir, "FULL")
if err != nil {
t.Fatal(err)
}
if err := db.Queue.Close(); err != nil {
t.Fatal(err)
}
db.Queue = newQueue(db.Write, buf)
return db
}
func TestQueueCloseUnblocksFullChannel(t *testing.T) {
t.Parallel()
const buf = 4
db := openSmallQueue(t, buf)
defer func() { _ = db.Close() }()
q := db.Queue
hold := make(chan struct{})
blockerStarted := make(chan struct{})
blockerErr := make(chan error, 1)
go func() {
blockerErr <- q.Do(context.Background(), func(tx *sql.Tx) error {
close(blockerStarted)
<-hold
return nil
})
}()
<-blockerStarted
ctx := context.Background()
var fillWG sync.WaitGroup
for i := 0; i < buf; i++ {
fillWG.Add(1)
go func() {
defer fillWG.Done()
_ = q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
}
deadline := time.Now().Add(2 * time.Second)
for q.Len() < buf+1 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
if q.Len() < buf+1 {
t.Fatalf("channel not full: len=%d", q.Len())
}
blockedErr := make(chan error, 1)
go func() {
blockedErr <- q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
// 等额外 Do 堵在 enqueue(pending 超过通道容量+正在执行的一条)。
deadline = time.Now().Add(2 * time.Second)
for q.Len() < buf+2 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
closeDone := make(chan error, 1)
go func() { closeDone <- q.Close() }()
select {
case err := <-blockedErr:
if !errors.Is(err, ErrQueueClosed) {
t.Fatalf("blocked Do: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close did not unblock full-channel enqueue")
}
close(hold)
select {
case err := <-closeDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close hung after writer released")
}
<-blockerErr
fillWG.Wait()
}
func TestQueueDrainTimeoutWhileWriterBlocked(t *testing.T) {
t.Parallel()
const buf = 4
db := openSmallQueue(t, buf)
defer func() { _ = db.Close() }()
q := db.Queue
hold := make(chan struct{})
started := make(chan struct{})
go func() {
_ = q.Do(context.Background(), func(tx *sql.Tx) error {
close(started)
<-hold
return nil
})
}()
<-started
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < buf; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = q.Do(ctx, func(tx *sql.Tx) error { return nil })
}()
}
deadline := time.Now().Add(2 * time.Second)
for q.Len() < buf+1 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
drainCtx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
start := time.Now()
err := q.Drain(drainCtx)
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Drain err=%v want deadline", err)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("Drain took %s, should return on timeout", elapsed)
}
close(hold)
wg.Wait()
}