This is an automated email from the ASF dual-hosted git repository.

flypiggy pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-seata-go.git


The following commit(s) were added to refs/heads/master by this push:
     new 4a684d6f fix: drain cached fence logs for cleanup task (#1085)
4a684d6f is described below

commit 4a684d6fd969a6f8b69adae9e3e2e158b56a5a6b
Author: waterbucket <[email protected]>
AuthorDate: Sat Apr 11 20:49:39 2026 +0800

    fix: drain cached fence logs for cleanup task (#1085)
    
    * fix: drain cached fence logs for cleanup task
    
    * fix: requeue fence logs on drain failure; fix tests and cleanInterval race
    
    * fix: add logCache retry limit and drainCacheTask shutdown test
    
    ---------
    
    Co-authored-by: CocaElbow <[email protected]>
    Co-authored-by: flypiggyNo3 <[email protected]>
    Co-authored-by: ThunGuo <[email protected]>
---
 .../tcc/fence/handler/tcc_fence_wrapper_handler.go | 131 ++++++-
 .../handler/tcc_fence_wrapper_handler_test.go      | 380 ++++++++++++++++++++-
 2 files changed, 499 insertions(+), 12 deletions(-)

diff --git a/pkg/rm/tcc/fence/handler/tcc_fence_wrapper_handler.go 
b/pkg/rm/tcc/fence/handler/tcc_fence_wrapper_handler.go
index 2a656591..5b159fae 100644
--- a/pkg/rm/tcc/fence/handler/tcc_fence_wrapper_handler.go
+++ b/pkg/rm/tcc/fence/handler/tcc_fence_wrapper_handler.go
@@ -24,6 +24,7 @@ import (
        "errors"
        "fmt"
        "sync"
+       "sync/atomic"
        "time"
 
        "github.com/go-sql-driver/mysql"
@@ -40,25 +41,45 @@ type tccFenceWrapperHandler struct {
        tccFenceDao       dao.TCCFenceStore
        logQueue          chan *model.FenceLogIdentity
        logCache          list.List
+       cacheMutex        sync.Mutex
        logQueueOnce      sync.Once
        logQueueCloseOnce sync.Once
+       logCacheOnce      sync.Once
        logTaskOnce       sync.Once
        db                *sql.DB
        dbMutex           sync.RWMutex
+       stopDrainCache    chan struct{}
 }
 
 const (
        maxQueueSize  = 500
        channelDelete = 5
        cleanExpired  = 24 * time.Hour
+       // maxFenceLogCacheRetries is how many times a fence log identity may 
be re-queued to logCache
+       // after a failed drain (Begin/delete/commit). Beyond this, entries are 
dropped to avoid unbounded retry.
+       maxFenceLogCacheRetries = 3
 )
 
+// fenceLogCacheEntry is the value type stored in logCache (queue-full 
overflow path).
+type fenceLogCacheEntry struct {
+       identity   model.FenceLogIdentity
+       retryCount int
+}
+
 var (
-       fenceHandler  *tccFenceWrapperHandler
-       fenceOnce     sync.Once
-       cleanInterval = 5 * time.Minute
+       fenceHandler       *tccFenceWrapperHandler
+       fenceOnce          sync.Once
+       cleanIntervalNanos atomic.Int64
 )
 
+func init() {
+       cleanIntervalNanos.Store(int64(5 * time.Minute))
+}
+
+func currentCleanInterval() time.Duration {
+       return time.Duration(cleanIntervalNanos.Load())
+}
+
 func GetFenceHandler() *tccFenceWrapperHandler {
        if fenceHandler == nil {
                fenceOnce.Do(func() {
@@ -70,8 +91,8 @@ func GetFenceHandler() *tccFenceWrapperHandler {
        return fenceHandler
 }
 
-func (handler *tccFenceWrapperHandler) InitCleanPeriod(time time.Duration) {
-       cleanInterval = time
+func (handler *tccFenceWrapperHandler) InitCleanPeriod(d time.Duration) {
+       cleanIntervalNanos.Store(int64(d))
 }
 
 func (handler *tccFenceWrapperHandler) PrepareFence(ctx context.Context, tx 
*sql.Tx) error {
@@ -188,7 +209,7 @@ func (handler *tccFenceWrapperHandler) 
InitLogCleanChannel(dsn string) {
 
 func (handler *tccFenceWrapperHandler) initLogCleanTask(db *sql.DB) {
 
-       ticker := time.NewTicker(cleanInterval)
+       ticker := time.NewTicker(currentCleanInterval())
        defer ticker.Stop()
 
        for range ticker.C {
@@ -225,6 +246,9 @@ func (handler *tccFenceWrapperHandler) 
enqueueFenceLogIdentities(identityList []
 func (handler *tccFenceWrapperHandler) DestroyLogCleanChannel() {
        handler.logQueueCloseOnce.Do(func() {
                close(handler.logQueue)
+               if handler.stopDrainCache != nil {
+                       close(handler.stopDrainCache)
+               }
                handler.dbMutex.Lock()
                if handler.db != nil {
                        handler.db.Close()
@@ -243,16 +267,21 @@ func (handler *tccFenceWrapperHandler) 
deleteBatchFence(tx *sql.Tx, batch []mode
 }
 
 func (handler *tccFenceWrapperHandler) pushCleanChannel(xid string, branchId 
int64) {
-       // todo implement
        fli := &model.FenceLogIdentity{
                Xid:      xid,
                BranchId: branchId,
        }
        select {
        case handler.logQueue <- fli:
-       // todo add batch delete from log cache.
        default:
-               handler.logCache.PushBack(fli)
+               handler.cacheMutex.Lock()
+               handler.logCache.PushBack(&fenceLogCacheEntry{identity: *fli})
+               handler.cacheMutex.Unlock()
+
+               handler.logCacheOnce.Do(func() {
+                       handler.stopDrainCache = make(chan struct{})
+                       go handler.drainCacheTask()
+               })
        }
        log.Infof("add one log to clean queue: %v ", fli)
 }
@@ -293,3 +322,87 @@ func (handler *tccFenceWrapperHandler) 
traversalCleanChannel(db *sql.DB) {
                }
        }
 }
+
+func (handler *tccFenceWrapperHandler) drainCacheTask() {
+       ticker := time.NewTicker(currentCleanInterval())
+       defer ticker.Stop()
+
+       for {
+               select {
+               case <-ticker.C:
+                       handler.cacheMutex.Lock()
+
+                       if handler.logCache.Len() == 0 {
+                               handler.cacheMutex.Unlock()
+                               continue
+                       }
+
+                       handler.dbMutex.RLock()
+                       db := handler.db
+                       handler.dbMutex.RUnlock()
+                       if db == nil {
+                               handler.cacheMutex.Unlock()
+                               continue
+                       }
+
+                       var drained []fenceLogCacheEntry
+                       for e := handler.logCache.Front(); e != nil; {
+                               next := e.Next()
+
+                               ent := e.Value.(*fenceLogCacheEntry)
+                               drained = append(drained, *ent)
+                               handler.logCache.Remove(e)
+
+                               e = next
+                       }
+
+                       handler.cacheMutex.Unlock()
+
+                       batch := make([]model.FenceLogIdentity, len(drained))
+                       for i := range drained {
+                               batch[i] = drained[i].identity
+                       }
+
+                       if len(batch) == 0 {
+                               continue
+                       }
+                       requeue := func() {
+                               handler.cacheMutex.Lock()
+                               for _, it := range drained {
+                                       if it.retryCount >= 
maxFenceLogCacheRetries {
+                                               log.Errorf("max fence log cache 
retries exceeded, dropping: xid=%s, branchId=%d",
+                                                       it.identity.Xid, 
it.identity.BranchId)
+                                               continue
+                                       }
+                                       
handler.logCache.PushBack(&fenceLogCacheEntry{
+                                               identity:   it.identity,
+                                               retryCount: it.retryCount + 1,
+                                       })
+                               }
+                               handler.cacheMutex.Unlock()
+                       }
+                       tx, err := db.Begin()
+                       if err != nil {
+                               log.Warnf("failed to begin transaction: %v", 
err)
+                               requeue()
+                               continue
+                       }
+                       err = handler.deleteBatchFence(tx, batch)
+                       if err != nil {
+                               _ = tx.Rollback()
+                               log.Errorf("delete batch fence log failed, 
batch: %v, err: %v", batch, err)
+                               requeue()
+                               continue
+                       }
+                       if err = tx.Commit(); err != nil {
+                               _ = tx.Rollback()
+                               log.Errorf("failed to commit transaction: %v", 
err)
+                               requeue()
+                               continue
+                       }
+               case <-handler.stopDrainCache:
+                       return
+               }
+       }
+
+}
diff --git a/pkg/rm/tcc/fence/handler/tcc_fence_wrapper_handler_test.go 
b/pkg/rm/tcc/fence/handler/tcc_fence_wrapper_handler_test.go
index bc03f747..ea93c4ce 100644
--- a/pkg/rm/tcc/fence/handler/tcc_fence_wrapper_handler_test.go
+++ b/pkg/rm/tcc/fence/handler/tcc_fence_wrapper_handler_test.go
@@ -22,6 +22,7 @@ import (
        "database/sql"
        "errors"
        "sync"
+       "sync/atomic"
        "testing"
        "time"
 
@@ -143,10 +144,10 @@ func TestInitCleanPeriod(t *testing.T) {
        testDuration := 10 * time.Minute
 
        handler.InitCleanPeriod(testDuration)
-       assert.Equal(t, testDuration, cleanInterval)
+       assert.Equal(t, testDuration, currentCleanInterval())
 
        // Reset to default for other tests
-       cleanInterval = 5 * time.Minute
+       cleanIntervalNanos.Store(int64(5 * time.Minute))
 }
 
 func TestPrepareFence_Success(t *testing.T) {
@@ -796,6 +797,8 @@ func TestPushCleanChannel_FullQueue(t *testing.T) {
                logQueue: make(chan *model.FenceLogIdentity, 1),
        }
 
+       // Second pushCleanChannel starts drainCacheTask; stop it before the 
next test mutates cleanInterval.
+       defer handler.DestroyLogCleanChannel()
        // Fill the queue
        handler.pushCleanChannel("xid1", 1)
 
@@ -1062,5 +1065,376 @@ func TestConstants(t *testing.T) {
        assert.Equal(t, 500, maxQueueSize)
        assert.Equal(t, 5, channelDelete)
        assert.Equal(t, 24*time.Hour, cleanExpired)
-       assert.Equal(t, 5*time.Minute, cleanInterval)
+       assert.Equal(t, 5*time.Minute, currentCleanInterval())
+}
+
+func TestDrainCacheTask(t *testing.T) {
+       log.Init()
+
+       oldInterval := currentCleanInterval()
+       cleanIntervalNanos.Store(int64(20 * time.Millisecond))
+       defer cleanIntervalNanos.Store(int64(oldInterval))
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       // drainCacheTask should execute one delete batch with Begin + Commit.
+       mock.ExpectBegin()
+       mock.ExpectCommit()
+
+       deleted := make(chan struct{}, 1)
+
+       mockDao := &mockTCCFenceStore{
+               deleteMultipleFunc: func(tx *sql.Tx, identity 
[]model.FenceLogIdentity) error {
+                       for _, it := range identity {
+                               if it.Xid == "xid-cache" && it.BranchId == 2 {
+                                       select {
+                                       case deleted <- struct{}{}:
+                                       default:
+                                       }
+                               }
+                       }
+                       return nil
+               },
+       }
+
+       handler := &tccFenceWrapperHandler{
+               tccFenceDao: mockDao,
+               logQueue:    make(chan *model.FenceLogIdentity, 1), // 
Intentionally small queue to trigger the default branch.
+               db:          db,
+       }
+
+       // Fill the queue first.
+       handler.pushCleanChannel("xid-queue", 1)
+       // Push one more item so it falls back to cache and starts 
drainCacheTask via logCacheOnce.Do(...).
+       handler.pushCleanChannel("xid-cache", 2)
+
+       time.Sleep(100 * time.Millisecond)
+       assert.NotNil(t, handler.stopDrainCache)
+
+       select {
+       case <-deleted:
+               // success
+       case <-time.After(2 * time.Second):
+               t.Fatal("expected drainCacheTask to delete cached identity")
+       }
+
+       assert.NoError(t, mock.ExpectationsWereMet())
+
+       // Cleanup to avoid goroutine leaks.
+       handler.DestroyLogCleanChannel()
+}
+
+func TestDrainCacheTask_DBNil(t *testing.T) {
+       log.Init()
+
+       oldInterval := currentCleanInterval()
+       cleanIntervalNanos.Store(int64(20 * time.Millisecond))
+       defer cleanIntervalNanos.Store(int64(oldInterval))
+
+       handler := &tccFenceWrapperHandler{
+               tccFenceDao:    &mockTCCFenceStore{},
+               logQueue:       make(chan *model.FenceLogIdentity, 1),
+               stopDrainCache: make(chan struct{}),
+               // Keep db as nil intentionally.
+       }
+
+       handler.cacheMutex.Lock()
+       handler.logCache.PushBack(&fenceLogCacheEntry{identity: 
model.FenceLogIdentity{Xid: "xid-nil", BranchId: 9}})
+       handler.cacheMutex.Unlock()
+
+       go handler.drainCacheTask()
+
+       time.Sleep(100 * time.Millisecond)
+
+       handler.cacheMutex.Lock()
+       cacheLen := handler.logCache.Len()
+       handler.cacheMutex.Unlock()
+
+       assert.Equal(t, 1, cacheLen)
+
+       handler.DestroyLogCleanChannel()
+}
+
+func TestDrainCacheTask_DeleteError_RequeuesToCache(t *testing.T) {
+       log.Init()
+
+       oldInterval := currentCleanInterval()
+       cleanIntervalNanos.Store(int64(20 * time.Millisecond))
+       defer cleanIntervalNanos.Store(int64(oldInterval))
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       // Each drain tick: Begin -> deleteBatchFence (error) -> Rollback. 
Allow several ticks before Destroy stops the goroutine.
+       for i := 0; i < 10; i++ {
+               mock.ExpectBegin()
+               mock.ExpectRollback()
+       }
+
+       var deleteCalls atomic.Int32
+       mockDao := &mockTCCFenceStore{
+               deleteMultipleFunc: func(tx *sql.Tx, identity 
[]model.FenceLogIdentity) error {
+                       deleteCalls.Add(1)
+                       return errors.New("simulated delete failure")
+               },
+       }
+
+       handler := &tccFenceWrapperHandler{
+               tccFenceDao: mockDao,
+               logQueue:    make(chan *model.FenceLogIdentity, 1),
+               db:          db,
+       }
+
+       handler.pushCleanChannel("xid-queue", 1)
+       handler.pushCleanChannel("xid-cache", 2)
+
+       // Require both in one poll: (1) Len()>=1 alone is true before the 
first drain (second push left an item).
+       // (2) deleteCalls>=1 alone can race the next ticker tick (cache 
emptied again before we read Len).
+       assert.Eventually(t, func() bool {
+               if deleteCalls.Load() < 1 {
+                       return false
+               }
+               handler.cacheMutex.Lock()
+               n := handler.logCache.Len()
+               handler.cacheMutex.Unlock()
+               return n >= 1
+       }, 2*time.Second, 10*time.Millisecond,
+               "delete must have run and logCache must still hold requeued 
identities (stable window)")
+
+       handler.DestroyLogCleanChannel()
+}
+
+func TestDrainCacheTask_BeginError_RequeuesToCache(t *testing.T) {
+       log.Init()
+
+       oldInterval := currentCleanInterval()
+       cleanIntervalNanos.Store(int64(20 * time.Millisecond))
+       defer cleanIntervalNanos.Store(int64(oldInterval))
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       mock.ExpectBegin().WillReturnError(errors.New("simulated begin 
failure"))
+       for i := 0; i < 15; i++ {
+               mock.ExpectBegin()
+               mock.ExpectCommit()
+       }
+
+       var deleteCalls atomic.Int32
+       mockDao := &mockTCCFenceStore{
+               deleteMultipleFunc: func(tx *sql.Tx, identity 
[]model.FenceLogIdentity) error {
+                       deleteCalls.Add(1)
+                       return nil
+               },
+       }
+
+       handler := &tccFenceWrapperHandler{
+               tccFenceDao: mockDao,
+               logQueue:    make(chan *model.FenceLogIdentity, 1),
+               db:          db,
+       }
+
+       handler.pushCleanChannel("xid-queue", 1)
+       handler.pushCleanChannel("xid-cache", 2)
+
+       // First tick: Begin fails (requeue). deleteCalls stays 0. Later tick: 
delete succeeds and cache drains.
+       assert.Eventually(t, func() bool {
+               if deleteCalls.Load() < 1 {
+                       return false
+               }
+               handler.cacheMutex.Lock()
+               n := handler.logCache.Len()
+               handler.cacheMutex.Unlock()
+               return n == 0
+       }, 2*time.Second, 10*time.Millisecond,
+               "successful delete after begin failure should empty requeued 
cache")
+
+       handler.DestroyLogCleanChannel()
+}
+
+func TestDrainCacheTask_CommitError_RequeuesToCache(t *testing.T) {
+       log.Init()
+
+       oldInterval := currentCleanInterval()
+       cleanIntervalNanos.Store(int64(20 * time.Millisecond))
+       defer cleanIntervalNanos.Store(int64(oldInterval))
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       mock.ExpectBegin()
+       mock.ExpectCommit().WillReturnError(errors.New("simulated commit 
failure"))
+       for i := 0; i < 15; i++ {
+               mock.ExpectBegin()
+               mock.ExpectCommit()
+       }
+
+       var deleteCalls atomic.Int32
+       mockDao := &mockTCCFenceStore{
+               deleteMultipleFunc: func(tx *sql.Tx, identity 
[]model.FenceLogIdentity) error {
+                       deleteCalls.Add(1)
+                       return nil
+               },
+       }
+
+       handler := &tccFenceWrapperHandler{
+               tccFenceDao: mockDao,
+               logQueue:    make(chan *model.FenceLogIdentity, 1),
+               db:          db,
+       }
+
+       handler.pushCleanChannel("xid-queue", 1)
+       handler.pushCleanChannel("xid-cache", 2)
+
+       // First tick: delete ok, Commit fails (requeue), deleteCalls==1 and 
Len==1. Later tick finishes cleanup.
+       assert.Eventually(t, func() bool {
+               if deleteCalls.Load() < 1 {
+                       return false
+               }
+               handler.cacheMutex.Lock()
+               n := handler.logCache.Len()
+               handler.cacheMutex.Unlock()
+               return n == 0
+       }, 2*time.Second, 10*time.Millisecond,
+               "successful commit after prior commit failure should empty 
requeued cache")
+
+       handler.DestroyLogCleanChannel()
+}
+
+func TestDrainCacheTask_MaxRetries_DropsFromCache(t *testing.T) {
+       log.Init()
+
+       oldInterval := currentCleanInterval()
+       cleanIntervalNanos.Store(int64(20 * time.Millisecond))
+       defer cleanIntervalNanos.Store(int64(oldInterval))
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       for i := 0; i < 12; i++ {
+               mock.ExpectBegin()
+               mock.ExpectRollback()
+       }
+
+       var deleteCalls atomic.Int32
+       mockDao := &mockTCCFenceStore{
+               deleteMultipleFunc: func(tx *sql.Tx, identity 
[]model.FenceLogIdentity) error {
+                       deleteCalls.Add(1)
+                       return errors.New("always fail delete")
+               },
+       }
+
+       handler := &tccFenceWrapperHandler{
+               tccFenceDao: mockDao,
+               logQueue:    make(chan *model.FenceLogIdentity, 1),
+               db:          db,
+       }
+
+       handler.pushCleanChannel("xid-queue", 1)
+       handler.pushCleanChannel("xid-cache", 2)
+
+       // maxFenceLogCacheRetries=3: requeue with counts 1,2,3 then drop on 
fourth failure (fourth successful Begin + failed delete).
+       assert.Eventually(t, func() bool {
+               if deleteCalls.Load() < int32(maxFenceLogCacheRetries+1) {
+                       return false
+               }
+               handler.cacheMutex.Lock()
+               n := handler.logCache.Len()
+               handler.cacheMutex.Unlock()
+               return n == 0
+       }, 4*time.Second, 10*time.Millisecond,
+               "after max requeues, failed drain should drop identities from 
logCache")
+
+       handler.DestroyLogCleanChannel()
+}
+
+func TestDrainCacheTask_StopsOnDestroy(t *testing.T) {
+       log.Init()
+
+       oldInterval := currentCleanInterval()
+       cleanIntervalNanos.Store(int64(20 * time.Millisecond))
+       defer cleanIntervalNanos.Store(int64(oldInterval))
+
+       waitDrainGoroutine := func(t *testing.T, wg *sync.WaitGroup) {
+               t.Helper()
+               done := make(chan struct{})
+               go func() {
+                       wg.Wait()
+                       close(done)
+               }()
+               select {
+               case <-done:
+               case <-time.After(3 * time.Second):
+                       t.Fatal("drainCacheTask goroutine did not exit after 
DestroyLogCleanChannel")
+               }
+       }
+
+       t.Run("emptyLogCache", func(t *testing.T) {
+               var wg sync.WaitGroup
+               wg.Add(1)
+
+               handler := &tccFenceWrapperHandler{
+                       tccFenceDao: &mockTCCFenceStore{},
+                       logQueue:    make(chan *model.FenceLogIdentity, 
maxQueueSize),
+               }
+               handler.logCacheOnce.Do(func() {
+                       handler.stopDrainCache = make(chan struct{})
+                       go func() {
+                               defer wg.Done()
+                               handler.drainCacheTask()
+                       }()
+               })
+
+               time.Sleep(30 * time.Millisecond)
+
+               assert.NotPanics(t, func() {
+                       handler.DestroyLogCleanChannel()
+               })
+
+               waitDrainGoroutine(t, &wg)
+       })
+
+       t.Run("withPendingCacheEntries", func(t *testing.T) {
+               var wg sync.WaitGroup
+               wg.Add(1)
+
+               db, mock, err := sqlmock.New()
+               assert.NoError(t, err)
+               defer db.Close()
+               for i := 0; i < 10; i++ {
+                       mock.ExpectBegin()
+                       mock.ExpectCommit()
+               }
+
+               handler := &tccFenceWrapperHandler{
+                       tccFenceDao: &mockTCCFenceStore{},
+                       logQueue:    make(chan *model.FenceLogIdentity, 
maxQueueSize),
+                       db:          db,
+               }
+               handler.logCacheOnce.Do(func() {
+                       handler.stopDrainCache = make(chan struct{})
+                       go func() {
+                               defer wg.Done()
+                               handler.drainCacheTask()
+                       }()
+               })
+
+               handler.cacheMutex.Lock()
+               handler.logCache.PushBack(&fenceLogCacheEntry{identity: 
model.FenceLogIdentity{Xid: "xid-pending", BranchId: 1}})
+               handler.cacheMutex.Unlock()
+
+               time.Sleep(50 * time.Millisecond)
+
+               assert.NotPanics(t, func() {
+                       handler.DestroyLogCleanChannel()
+               })
+
+               waitDrainGoroutine(t, &wg)
+       })
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to