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

Ethan-Xingyue 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 6bd31c77 fix: align RM undo log delete codec with Java wire format 
(#1135)
6bd31c77 is described below

commit 6bd31c77a01de46421f6d4369108b8661f3960ee
Author: Cocoyu <[email protected]>
AuthorDate: Sat Aug 29 21:05:16 2026 -0700

    fix: align RM undo log delete codec with Java wire format (#1135)
    
    * fix: support RM delete undo log request
    
    * fix ci
    
    * feat: realize undo_log deletion logic
    
    * add license header
    
    * feat: add DeleteBatchSize to undo log Config
    
    * fix: align RM undo log delete codec with Java wire format
    
    * fix: surface real undo log delete failures instead of swallowing
    
    * test: cover end-to-end undo log delete path and broadcast-skip branch
    
    * test: fix data race in TestDayValueYAML by not mutating time.Local
    
    * fix(undo): Fix security vulnerability during undo log deletion process
    
    * refactor(protocol): Simplify the decoding logic of UndoLogDeleteRequest
    
    * fix(remoting): Fixed the RowsAffected error handling issue when deleting 
undo logs
    
    * fix (rm): Fix parameter validation issue in undo log deletion handler
    
    * fix (rm): Fix parameter validation issue in undo log deletion handler
    
    * ci: rerun integration tests
    
    * fix: gate undo log delete to MySQL, cap batch rounds per request, and pin 
Java wire format with golden bytes
    
    ---------
    
    Co-authored-by: everfid-ever 
<[email protected]>
    Co-authored-by: ThunGuo <[email protected]>
    Co-authored-by: EVERFID <[email protected]>
    Co-authored-by: Ethan <[email protected]>
---
 pkg/datasource/sql/undo/config.go                  |   2 +
 pkg/protocol/codec/codec.go                        |   1 +
 pkg/protocol/codec/undolog_delete_req_codec.go     |  63 ++++
 .../codec/undolog_delete_req_codec_test.go         | 334 +++++++++++++++++++
 pkg/protocol/message/request_message.go            |   2 +-
 pkg/remoting/processor/client/init.go              |   1 +
 .../client/rm_delete_undolog_processor.go          | 177 ++++++++++
 .../client/rm_delete_undolog_processor_test.go     | 368 +++++++++++++++++++++
 8 files changed, 947 insertions(+), 1 deletion(-)

diff --git a/pkg/datasource/sql/undo/config.go 
b/pkg/datasource/sql/undo/config.go
index 3a3b1118..850feed3 100644
--- a/pkg/datasource/sql/undo/config.go
+++ b/pkg/datasource/sql/undo/config.go
@@ -43,6 +43,7 @@ type Config struct {
        LogTable              string         `yaml:"log-table" 
json:"log-table,omitempty" koanf:"log-table"`
        OnlyCareUpdateColumns bool           `yaml:"only-care-update-columns" 
json:"only-care-update-columns,omitempty" koanf:"only-care-update-columns"`
        CompressConfig        CompressConfig `yaml:"compress" 
json:"compress,omitempty" koanf:"compress"`
+       DeleteBatchSize       int            `yaml:"delete-batch-size" 
json:"delete-batch-size,omitempty" koanf:"delete-batch-size"`
 }
 
 func (u *Config) RegisterFlagsWithPrefix(prefix string, f *flag.FlagSet) {
@@ -50,6 +51,7 @@ func (u *Config) RegisterFlagsWithPrefix(prefix string, f 
*flag.FlagSet) {
        f.StringVar(&u.LogSerialization, prefix+".log-serialization", "json", 
"Serialization method.")
        f.StringVar(&u.LogTable, prefix+".log-table", "undo_log", "undo log 
table name.")
        f.BoolVar(&u.OnlyCareUpdateColumns, prefix+".only-care-update-columns", 
true, "The switch for degrade check.")
+       f.IntVar(&u.DeleteBatchSize, prefix+".delete-batch-size", 1000, "The 
batch size when deleting expired undo log.")
        u.CompressConfig.RegisterFlagsWithPrefix(prefix+".compress", f)
 }
 
diff --git a/pkg/protocol/codec/codec.go b/pkg/protocol/codec/codec.go
index 2f9b3564..16ae6aa5 100644
--- a/pkg/protocol/codec/codec.go
+++ b/pkg/protocol/codec/codec.go
@@ -144,6 +144,7 @@ func Init() {
        // RM
        GetCodecManager().RegisterCodec(CodecTypeSeata, 
&RegisterRMRequestCodec{})
        GetCodecManager().RegisterCodec(CodecTypeSeata, 
&RegisterRMResponseCodec{})
+       GetCodecManager().RegisterCodec(CodecTypeSeata, 
&UndoLogDeleteRequestCodec{})
 
        // TM
        GetCodecManager().RegisterCodec(CodecTypeSeata, 
&RegisterTMRequestCodec{})
diff --git a/pkg/protocol/codec/undolog_delete_req_codec.go 
b/pkg/protocol/codec/undolog_delete_req_codec.go
new file mode 100644
index 00000000..21dbc242
--- /dev/null
+++ b/pkg/protocol/codec/undolog_delete_req_codec.go
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package codec
+
+import (
+       "seata.apache.org/seata-go/v2/pkg/protocol/branch"
+       "seata.apache.org/seata-go/v2/pkg/protocol/message"
+       "seata.apache.org/seata-go/v2/pkg/util/bytes"
+       "seata.apache.org/seata-go/v2/pkg/util/log"
+)
+
+type UndoLogDeleteRequestCodec struct{}
+
+func (u *UndoLogDeleteRequestCodec) Decode(in []byte) interface{} {
+       data := message.UndoLogDeleteRequest{}
+       buf := bytes.NewByteBuffer(in)
+
+       branchType, err := buf.ReadByte()
+       if err != nil {
+               log.Errorf("failed to decode UndoLogDeleteRequest branchType: 
%v", err)
+               return nil
+       }
+       data.BranchType = branch.BranchType(branchType)
+       data.ResourceId = bytes.ReadString16Length(buf)
+       saveDays, err := buf.ReadUint16()
+       if err != nil {
+               log.Errorf("failed to decode UndoLogDeleteRequest saveDays: 
%v", err)
+               return nil
+       }
+       data.SaveDays = int16(saveDays)
+
+       return data
+}
+
+func (u *UndoLogDeleteRequestCodec) Encode(in interface{}) []byte {
+       data := in.(message.UndoLogDeleteRequest)
+       buf := bytes.NewByteBuffer([]byte{})
+
+       buf.WriteByte(byte(data.BranchType))
+       bytes.WriteString16Length(data.ResourceId, buf)
+       buf.WriteUint16(uint16(data.SaveDays))
+
+       return buf.Bytes()
+}
+
+func (u *UndoLogDeleteRequestCodec) GetMessageType() message.MessageType {
+       return message.MessageTypeRmDeleteUndolog
+}
diff --git a/pkg/protocol/codec/undolog_delete_req_codec_test.go 
b/pkg/protocol/codec/undolog_delete_req_codec_test.go
new file mode 100644
index 00000000..d483e870
--- /dev/null
+++ b/pkg/protocol/codec/undolog_delete_req_codec_test.go
@@ -0,0 +1,334 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package codec
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       "seata.apache.org/seata-go/v2/pkg/protocol/branch"
+       "seata.apache.org/seata-go/v2/pkg/protocol/message"
+)
+
+func TestUndoLogDeleteRequestCodec_Encode(t *testing.T) {
+       c := &UndoLogDeleteRequestCodec{}
+
+       tests := []struct {
+               name string
+               req  message.UndoLogDeleteRequest
+       }{
+               {
+                       name: "normal case with resource id",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "jdbc:mysql://localhost:3306/seata",
+                               SaveDays:   7,
+                               BranchType: branch.BranchTypeAT,
+                       },
+               },
+               {
+                       name: "empty resource id",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "",
+                               SaveDays:   30,
+                               BranchType: branch.BranchTypeAT,
+                       },
+               },
+               {
+                       name: "max save days",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "test-resource",
+                               SaveDays:   32767, // max int16
+                               BranchType: branch.BranchTypeAT,
+                       },
+               },
+               {
+                       name: "min save days",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "test-resource",
+                               SaveDays:   -32768, // min int16
+                               BranchType: branch.BranchTypeAT,
+                       },
+               },
+               {
+                       name: "zero save days",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "test-resource",
+                               SaveDays:   0,
+                               BranchType: branch.BranchTypeAT,
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       encoded := c.Encode(tt.req)
+                       assert.NotNil(t, encoded)
+                       assert.Greater(t, len(encoded), 0, "encoded data should 
not be empty")
+               })
+       }
+}
+
+func TestUndoLogDeleteRequestCodec_Decode(t *testing.T) {
+       c := &UndoLogDeleteRequestCodec{}
+
+       tests := []struct {
+               name     string
+               req      message.UndoLogDeleteRequest
+               validate func(t *testing.T, decoded 
message.UndoLogDeleteRequest)
+       }{
+               {
+                       name: "decode normal request",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "jdbc:mysql://localhost:3306/seata",
+                               SaveDays:   7,
+                               BranchType: branch.BranchTypeAT,
+                       },
+                       validate: func(t *testing.T, decoded 
message.UndoLogDeleteRequest) {
+                               assert.Equal(t, 
"jdbc:mysql://localhost:3306/seata", decoded.ResourceId)
+                               assert.Equal(t, int16(7), decoded.SaveDays)
+                               assert.Equal(t, branch.BranchTypeAT, 
decoded.BranchType)
+                       },
+               },
+               {
+                       name: "decode request with empty resource id",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "",
+                               SaveDays:   15,
+                               BranchType: branch.BranchTypeAT,
+                       },
+                       validate: func(t *testing.T, decoded 
message.UndoLogDeleteRequest) {
+                               assert.Equal(t, "", decoded.ResourceId)
+                               assert.Equal(t, int16(15), decoded.SaveDays)
+                               assert.Equal(t, branch.BranchTypeAT, 
decoded.BranchType)
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       encoded := c.Encode(tt.req)
+                       assert.NotNil(t, encoded)
+
+                       decoded := c.Decode(encoded)
+                       assert.NotNil(t, decoded)
+
+                       decodedReq, ok := decoded.(message.UndoLogDeleteRequest)
+                       assert.True(t, ok, "decoded result should be 
UndoLogDeleteRequest type")
+
+                       tt.validate(t, decodedReq)
+               })
+       }
+}
+
+func TestUndoLogDeleteRequestCodec_EncodeDecode(t *testing.T) {
+       c := &UndoLogDeleteRequestCodec{}
+
+       tests := []struct {
+               name string
+               req  message.UndoLogDeleteRequest
+       }{
+               {
+                       name: "round trip - normal case",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: 
"jdbc:mysql://127.0.0.1:3306/test_db",
+                               SaveDays:   10,
+                               BranchType: branch.BranchTypeAT,
+                       },
+               },
+               {
+                       name: "round trip - empty resource id",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "",
+                               SaveDays:   5,
+                               BranchType: branch.BranchTypeAT,
+                       },
+               },
+               {
+                       name: "round trip - negative save days",
+                       req: message.UndoLogDeleteRequest{
+                               ResourceId: "test-resource",
+                               SaveDays:   -1,
+                               BranchType: branch.BranchTypeAT,
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       encoded := c.Encode(tt.req)
+                       assert.NotNil(t, encoded)
+
+                       decoded := c.Decode(encoded)
+                       assert.NotNil(t, decoded)
+
+                       decodedReq, ok := decoded.(message.UndoLogDeleteRequest)
+                       assert.True(t, ok)
+
+                       assert.Equal(t, tt.req.ResourceId, 
decodedReq.ResourceId, "ResourceId should match")
+                       assert.Equal(t, tt.req.SaveDays, decodedReq.SaveDays, 
"SaveDays should match")
+                       assert.Equal(t, tt.req.BranchType, 
decodedReq.BranchType, "BranchType should match")
+               })
+       }
+}
+
+func TestUndoLogDeleteRequestCodec_GetMessageType(t *testing.T) {
+       c := &UndoLogDeleteRequestCodec{}
+       assert.Equal(t, message.MessageTypeRmDeleteUndolog, c.GetMessageType(),
+               "message type should be MessageTypeRmDeleteUndolog (111)")
+}
+
+// Byte vectors hand-derived from Java UndoLogDeleteRequestCodec#encode:
+// branchType ordinal (1 byte) + resourceId (uint16-BE length + UTF-8) + 
saveDays (int16-BE).
+func TestUndoLogDeleteRequestCodec_JavaWireFormat(t *testing.T) {
+       c := &UndoLogDeleteRequestCodec{}
+
+       tests := []struct {
+               name string
+               in   []byte
+               want message.UndoLogDeleteRequest
+       }{
+               {
+                       name: "AT mysql resource 7 days",
+                       in: []byte{
+                               0x00,
+                               0x00, 0x21,
+                               'j', 'd', 'b', 'c', ':', 'm', 'y', 's', 'q', 
'l', ':', '/', '/',
+                               '1', '2', '7', '.', '0', '.', '0', '.', '1', 
':', '3', '3', '0', '6',
+                               '/', 's', 'e', 'a', 't', 'a',
+                               0x00, 0x07,
+                       },
+                       want: message.UndoLogDeleteRequest{
+                               BranchType: branch.BranchTypeAT,
+                               ResourceId: "jdbc:mysql://127.0.0.1:3306/seata",
+                               SaveDays:   7,
+                       },
+               },
+               {
+                       name: "AT empty resource id 30 days",
+                       in:   []byte{0x00, 0x00, 0x00, 0x00, 0x1e},
+                       want: message.UndoLogDeleteRequest{
+                               BranchType: branch.BranchTypeAT,
+                               ResourceId: "",
+                               SaveDays:   30,
+                       },
+               },
+               {
+                       name: "AT negative save days two complement",
+                       in:   []byte{0x00, 0x00, 0x02, 'p', 'g', 0xff, 0xff},
+                       want: message.UndoLogDeleteRequest{
+                               BranchType: branch.BranchTypeAT,
+                               ResourceId: "pg",
+                               SaveDays:   -1,
+                       },
+               },
+               {
+                       name: "XA ordinal 3 14 days",
+                       in:   []byte{0x03, 0x00, 0x02, 'x', 'a', 0x00, 0x0e},
+                       want: message.UndoLogDeleteRequest{
+                               BranchType: branch.BranchTypeXA,
+                               ResourceId: "xa",
+                               SaveDays:   14,
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       req, ok := 
c.Decode(tt.in).(message.UndoLogDeleteRequest)
+                       assert.True(t, ok, "decoded result should be 
UndoLogDeleteRequest type")
+                       assert.Equal(t, tt.want, req)
+                       assert.Equal(t, tt.in, c.Encode(tt.want), "encode must 
reproduce the Java bytes exactly")
+               })
+       }
+}
+
+func TestUndoLogDeleteRequestCodec_DecodeMalformed(t *testing.T) {
+       c := &UndoLogDeleteRequestCodec{}
+
+       tests := []struct {
+               name string
+               in   []byte
+       }{
+               {name: "empty input", in: []byte{}},
+               {name: "branch type only", in: []byte{0x00}},
+               {name: "missing save days", in: []byte{0x00, 0x00, 0x01, 'a'}},
+               {name: "truncated resourceId (declares 10 bytes, only 3 
provided)", in: []byte{0x00, 0x00, 0x0A, 'a', 'b', 'c'}},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       assert.Nil(t, c.Decode(tt.in), "malformed input should 
be rejected with nil")
+               })
+       }
+}
+
+func TestUndoLogDeleteRequestCodec_Integration(t *testing.T) {
+       Init()
+
+       cm := GetCodecManager()
+       c := cm.GetCodec(CodecTypeSeata, message.MessageTypeRmDeleteUndolog)
+       assert.NotNil(t, c, "UndoLogDeleteRequest codec should be registered")
+
+       req := message.UndoLogDeleteRequest{
+               ResourceId: "integration-test-resource",
+               SaveDays:   20,
+               BranchType: branch.BranchTypeAT,
+       }
+
+       encoded := cm.Encode(CodecTypeSeata, req)
+       assert.NotNil(t, encoded)
+       assert.Greater(t, len(encoded), 2, "encoded data should include type 
code (2 bytes) + body")
+
+       decoded := cm.Decode(CodecTypeSeata, encoded)
+       assert.NotNil(t, decoded)
+
+       decodedReq, ok := decoded.(message.UndoLogDeleteRequest)
+       assert.True(t, ok)
+       assert.Equal(t, req.ResourceId, decodedReq.ResourceId)
+       assert.Equal(t, req.SaveDays, decodedReq.SaveDays)
+       assert.Equal(t, req.BranchType, decodedReq.BranchType)
+}
+
+func BenchmarkUndoLogDeleteRequestCodec_Encode(b *testing.B) {
+       c := &UndoLogDeleteRequestCodec{}
+       req := message.UndoLogDeleteRequest{
+               ResourceId: "jdbc:mysql://localhost:3306/seata",
+               SaveDays:   7,
+               BranchType: branch.BranchTypeAT,
+       }
+
+       b.ResetTimer()
+       for i := 0; i < b.N; i++ {
+               _ = c.Encode(req)
+       }
+}
+
+func BenchmarkUndoLogDeleteRequestCodec_Decode(b *testing.B) {
+       c := &UndoLogDeleteRequestCodec{}
+       req := message.UndoLogDeleteRequest{
+               ResourceId: "jdbc:mysql://localhost:3306/seata",
+               SaveDays:   7,
+               BranchType: branch.BranchTypeAT,
+       }
+       encoded := c.Encode(req)
+
+       b.ResetTimer()
+       for i := 0; i < b.N; i++ {
+               _ = c.Decode(encoded)
+       }
+}
diff --git a/pkg/protocol/message/request_message.go 
b/pkg/protocol/message/request_message.go
index c0d4a135..733207e5 100644
--- a/pkg/protocol/message/request_message.go
+++ b/pkg/protocol/message/request_message.go
@@ -131,7 +131,7 @@ func (req GlobalRollbackRequest) GetTypeCode() MessageType {
 
 type UndoLogDeleteRequest struct {
        ResourceId string
-       SaveDays   MessageType
+       SaveDays   int16
        BranchType model2.BranchType
 }
 
diff --git a/pkg/remoting/processor/client/init.go 
b/pkg/remoting/processor/client/init.go
index 60461ab2..ca6c55fb 100644
--- a/pkg/remoting/processor/client/init.go
+++ b/pkg/remoting/processor/client/init.go
@@ -23,4 +23,5 @@ func RegisterProcessor() {
        initOnResponse()
        initBranchCommit()
        initBranchRollback()
+       initDeleteUndoLog()
 }
diff --git a/pkg/remoting/processor/client/rm_delete_undolog_processor.go 
b/pkg/remoting/processor/client/rm_delete_undolog_processor.go
new file mode 100644
index 00000000..7becc209
--- /dev/null
+++ b/pkg/remoting/processor/client/rm_delete_undolog_processor.go
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package client
+
+import (
+       "context"
+       "database/sql"
+       "fmt"
+       "time"
+
+       "seata.apache.org/seata-go/v2/pkg/datasource/sql/types"
+       "seata.apache.org/seata-go/v2/pkg/datasource/sql/undo"
+       "seata.apache.org/seata-go/v2/pkg/protocol/branch"
+       "seata.apache.org/seata-go/v2/pkg/protocol/message"
+       "seata.apache.org/seata-go/v2/pkg/remoting/getty"
+       "seata.apache.org/seata-go/v2/pkg/rm"
+       "seata.apache.org/seata-go/v2/pkg/util/log"
+)
+
+const (
+       defaultDeleteBatchSize = 1000
+       maxDeleteBatchRounds   = 100
+)
+
+func initDeleteUndoLog() {
+       getty.GetGettyClientHandlerInstance().RegisterProcessor(
+               message.MessageTypeRmDeleteUndolog, &rmDeleteUndoLogProcessor{})
+}
+
+type rmDeleteUndoLogProcessor struct{}
+
+func (r *rmDeleteUndoLogProcessor) Process(ctx context.Context, rpcMessage 
message.RpcMessage) error {
+       req, ok := rpcMessage.Body.(message.UndoLogDeleteRequest)
+       if !ok {
+               err := fmt.Errorf("invalid message body type: %T, expected 
message.UndoLogDeleteRequest", rpcMessage.Body)
+               log.Errorf("rmDeleteUndoLogProcessor: %v", err)
+               return err
+       }
+
+       if req.BranchType != branch.BranchTypeAT {
+               log.Infof("skip undo log delete for non-AT branch type: %v", 
req.BranchType)
+               return nil
+       }
+
+       if req.SaveDays <= 0 {
+               log.Errorf("skip undo log delete, invalid saveDays: 
resourceId=%s, saveDays=%d", req.ResourceId, req.SaveDays)
+               return fmt.Errorf("invalid saveDays %d for resourceId %s", 
req.SaveDays, req.ResourceId)
+       }
+
+       log.Infof("received undo log delete request: resourceId=%s, 
saveDays=%d",
+               req.ResourceId, req.SaveDays)
+
+       if err := r.deleteExpiredUndoLog(ctx, req); err != nil {
+               log.Errorf("failed to delete expired undo log: resourceId=%s, 
saveDays=%d, err=%v",
+                       req.ResourceId, req.SaveDays, err)
+               return err
+       }
+
+       return nil
+}
+
+type dbResource interface {
+       GetDB() *sql.DB
+       GetDbType() types.DBType
+}
+
+func (r *rmDeleteUndoLogProcessor) deleteExpiredUndoLog(ctx context.Context, 
req message.UndoLogDeleteRequest) error {
+       resMgr, err := safeGetResourceManager(req.BranchType)
+       if err != nil {
+               log.Infof("skip undo log delete, no AT resource manager: %v", 
err)
+               return nil
+       }
+
+       val, ok := resMgr.GetCachedResources().Load(req.ResourceId)
+       if !ok {
+               log.Infof("skip undo log delete, resource not managed by this 
client: %s", req.ResourceId)
+               return nil
+       }
+
+       res, ok := val.(dbResource)
+       if !ok {
+               log.Warnf("skip undo log delete, resource does not implement 
dbResource: %s", req.ResourceId)
+               return nil
+       }
+
+       if res.GetDbType() != types.DBTypeMySQL {
+               log.Warnf("skip undo log delete, unsupported dbType %v, only 
MySQL supported: resourceId=%s",
+                       res.GetDbType(), req.ResourceId)
+               return nil
+       }
+
+       conn, err := res.GetDB().Conn(ctx)
+       if err != nil {
+               return fmt.Errorf("get conn: %w", err)
+       }
+       defer conn.Close()
+
+       undoMgr, err := undo.GetUndoLogManager(res.GetDbType())
+       if err != nil {
+               return fmt.Errorf("get undo log manager for dbType %v: %w", 
res.GetDbType(), err)
+       }
+
+       exists, err := undoMgr.HasUndoLogTable(ctx, conn)
+       if err != nil {
+               return fmt.Errorf("check undo log table: %w", err)
+       }
+       if !exists {
+               log.Infof("undo_log table not exist, skip: resourceId=%s", 
req.ResourceId)
+               return nil
+       }
+
+       before := time.Now().AddDate(0, 0, -int(req.SaveDays))
+       return r.batchDeleteByLogCreated(ctx, conn, before)
+}
+
+func (r *rmDeleteUndoLogProcessor) batchDeleteByLogCreated(ctx 
context.Context, conn *sql.Conn, before time.Time) error {
+       undoLogTable := undo.UndoConfig.LogTable
+       if undoLogTable == "" {
+               undoLogTable = "undo_log"
+       }
+
+       batchSize := undo.UndoConfig.DeleteBatchSize
+       if batchSize <= 0 {
+               batchSize = defaultDeleteBatchSize
+       }
+
+       deleteSQL := fmt.Sprintf("DELETE FROM %s WHERE log_created <= ? LIMIT 
%d", undoLogTable, batchSize)
+
+       totalAffected := int64(0)
+       for round := 1; ; round++ {
+               result, err := conn.ExecContext(ctx, deleteSQL, before)
+               if err != nil {
+                       return fmt.Errorf("exec delete: %w", err)
+               }
+               affected, err := result.RowsAffected()
+               if err != nil {
+                       return fmt.Errorf("get rows affected: %w", err)
+               }
+               totalAffected += affected
+               if affected < int64(batchSize) {
+                       break
+               }
+               if round >= maxDeleteBatchRounds {
+                       log.Warnf("undo log delete stopped at round limit %d, 
leftover rows wait for the next request: before=%v, totalAffected=%d",
+                               maxDeleteBatchRounds, before, totalAffected)
+                       return nil
+               }
+       }
+
+       log.Infof("deleted expired undo log: before=%v, totalAffected=%d", 
before, totalAffected)
+       return nil
+}
+
+func safeGetResourceManager(bt branch.BranchType) (mgr rm.ResourceManager, err 
error) {
+       defer func() {
+               if r := recover(); r != nil {
+                       err = fmt.Errorf("resource manager not registered for 
branch type %v: %v", bt, r)
+               }
+       }()
+       mgr = rm.GetRmCacheInstance().GetResourceManager(bt)
+       return
+}
diff --git a/pkg/remoting/processor/client/rm_delete_undolog_processor_test.go 
b/pkg/remoting/processor/client/rm_delete_undolog_processor_test.go
new file mode 100644
index 00000000..bfffacdf
--- /dev/null
+++ b/pkg/remoting/processor/client/rm_delete_undolog_processor_test.go
@@ -0,0 +1,368 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package client
+
+import (
+       "context"
+       "database/sql"
+       "database/sql/driver"
+       "errors"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/DATA-DOG/go-sqlmock"
+       "github.com/stretchr/testify/assert"
+       "seata.apache.org/seata-go/v2/pkg/datasource/sql/types"
+       "seata.apache.org/seata-go/v2/pkg/datasource/sql/undo"
+       mysqlundo "seata.apache.org/seata-go/v2/pkg/datasource/sql/undo/mysql"
+       "seata.apache.org/seata-go/v2/pkg/protocol/branch"
+       "seata.apache.org/seata-go/v2/pkg/protocol/message"
+       "seata.apache.org/seata-go/v2/pkg/rm"
+)
+
+func TestProcess_InvalidBodyType(t *testing.T) {
+       p := &rmDeleteUndoLogProcessor{}
+       err := p.Process(context.Background(), message.RpcMessage{
+               Body: "not a UndoLogDeleteRequest",
+       })
+       assert.Error(t, err)
+}
+
+func TestProcess_NonATBranchType_Skipped(t *testing.T) {
+       p := &rmDeleteUndoLogProcessor{}
+       for _, bt := range []branch.BranchType{branch.BranchTypeXA, 
branch.BranchTypeTCC, branch.BranchTypeSAGA} {
+               err := p.Process(context.Background(), message.RpcMessage{
+                       Body: message.UndoLogDeleteRequest{
+                               ResourceId: "any-resource",
+                               SaveDays:   7,
+                               BranchType: bt,
+                       },
+               })
+               assert.NoError(t, err, "branch type %v should be skipped 
silently", bt)
+       }
+}
+
+func TestProcess_ResourceNotFound_ReturnsNil(t *testing.T) {
+       p := &rmDeleteUndoLogProcessor{}
+       err := p.Process(context.Background(), message.RpcMessage{
+               Body: message.UndoLogDeleteRequest{
+                       ResourceId: "jdbc:mysql://not-registered:3306/db",
+                       SaveDays:   7,
+                       BranchType: branch.BranchTypeAT,
+               },
+       })
+       // no AT resource manager / resource not managed by this client is a 
normal
+       // skip (the request is broadcast), so Process returns nil rather than 
an error
+       assert.NoError(t, err, "unmanaged resource should be skipped, not 
treated as an error")
+}
+
+type mockDBResource struct {
+       db     *sql.DB
+       dbType types.DBType
+}
+
+func (m *mockDBResource) GetDB() *sql.DB          { return m.db }
+func (m *mockDBResource) GetDbType() types.DBType { return m.dbType }
+
+// fakeATResourceManager is a minimal AT resource manager that only exposes a 
cached
+// resource, so the processor can reach the delete path in tests.
+type fakeATResourceManager struct {
+       rm.ResourceManager
+       cache *sync.Map
+}
+
+func (f *fakeATResourceManager) GetBranchType() branch.BranchType { return 
branch.BranchTypeAT }
+func (f *fakeATResourceManager) GetCachedResources() *sync.Map    { return 
f.cache }
+
+// TestProcess_RealError_Propagates verifies that a real internal failure while
+// deleting (here: no undo log manager for the resource's db type) is surfaced 
by
+// Process instead of being swallowed, while unmanaged resources are still 
skipped.
+func TestProcess_RealError_Propagates(t *testing.T) {
+       undo.RegisterUndoLogManager(mysqlundo.NewUndoLogManager())
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       cache := &sync.Map{}
+       cache.Store("res-real-err", &mockDBResource{db: db, dbType: 
types.DBTypeMySQL})
+       
rm.GetRmCacheInstance().RegisterResourceManager(&fakeATResourceManager{cache: 
cache})
+
+       mock.ExpectQuery("SELECT 1 FROM undo_log LIMIT 1").
+               WillReturnError(errors.New("check table failed"))
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.Process(context.Background(), message.RpcMessage{
+               Body: message.UndoLogDeleteRequest{
+                       ResourceId: "res-real-err",
+                       SaveDays:   7,
+                       BranchType: branch.BranchTypeAT,
+               },
+       })
+       assert.Error(t, err, "a real internal failure must propagate through 
Process")
+       assert.Contains(t, err.Error(), "check undo log table")
+       assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestProcess_EndToEnd_DeletesExpiredUndoLog(t *testing.T) {
+       undo.RegisterUndoLogManager(mysqlundo.NewUndoLogManager())
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       const resourceID = "jdbc:mysql://127.0.0.1:3306/seata"
+       cache := &sync.Map{}
+       cache.Store(resourceID, &mockDBResource{db: db, dbType: 
types.DBTypeMySQL})
+       
rm.GetRmCacheInstance().RegisterResourceManager(&fakeATResourceManager{cache: 
cache})
+
+       mock.ExpectQuery("SELECT 1 FROM undo_log LIMIT 1").
+               WillReturnRows(sqlmock.NewRows([]string{"1"}).AddRow(1))
+       mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnResult(sqlmock.NewResult(0, defaultDeleteBatchSize))
+       mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnResult(sqlmock.NewResult(0, 10))
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.Process(context.Background(), message.RpcMessage{
+               Body: message.UndoLogDeleteRequest{
+                       ResourceId: resourceID,
+                       SaveDays:   7,
+                       BranchType: branch.BranchTypeAT,
+               },
+       })
+       assert.NoError(t, err, "end-to-end delete should succeed")
+       assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestProcess_NonPositiveSaveDays_RejectedAtEntry(t *testing.T) {
+       p := &rmDeleteUndoLogProcessor{}
+       for _, saveDays := range []int16{0, -1, -32768} {
+               err := p.Process(context.Background(), message.RpcMessage{
+                       Body: message.UndoLogDeleteRequest{
+                               ResourceId: 
"jdbc:mysql://127.0.0.1:3306/seata-savedays-guard",
+                               SaveDays:   saveDays,
+                               BranchType: branch.BranchTypeAT,
+                       },
+               })
+               assert.Error(t, err, "saveDays=%d must be rejected", saveDays)
+       }
+}
+
+func TestProcess_ResourceNotInCache_Skipped(t *testing.T) {
+       
rm.GetRmCacheInstance().RegisterResourceManager(&fakeATResourceManager{cache: 
&sync.Map{}})
+
+       p := &rmDeleteUndoLogProcessor{}
+       err := p.Process(context.Background(), message.RpcMessage{
+               Body: message.UndoLogDeleteRequest{
+                       ResourceId: "jdbc:mysql://another-client:3306/seata",
+                       SaveDays:   7,
+                       BranchType: branch.BranchTypeAT,
+               },
+       })
+       assert.NoError(t, err, "unmanaged resource on a broadcast request 
should be skipped, not error")
+}
+
+// TestProcess_UnsupportedDbType_Skipped verifies that a non-MySQL AT resource 
is
+// skipped before any SQL is issued: the DELETE ... LIMIT cleanup is a MySQL
+// dialect extension, and Postgres (which has a registered undo log manager, so
+// the earlier manager lookup would not stop it) rejects the statement on every
+// TC cleanup cycle. The processor must warn and skip instead.
+func TestProcess_UnsupportedDbType_Skipped(t *testing.T) {
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       const resourceID = "jdbc:postgresql://127.0.0.1:5432/seata"
+       cache := &sync.Map{}
+       cache.Store(resourceID, &mockDBResource{db: db, dbType: 
types.DBTypePostgreSQL})
+       
rm.GetRmCacheInstance().RegisterResourceManager(&fakeATResourceManager{cache: 
cache})
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.Process(context.Background(), message.RpcMessage{
+               Body: message.UndoLogDeleteRequest{
+                       ResourceId: resourceID,
+                       SaveDays:   7,
+                       BranchType: branch.BranchTypeAT,
+               },
+       })
+       assert.NoError(t, err, "non-MySQL resource should be skipped, not 
error")
+       assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestBatchDeleteByLogCreated_DeletesRows(t *testing.T) {
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       before := time.Now().AddDate(0, 0, -7)
+
+       mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnResult(sqlmock.NewResult(0, 1000))
+       mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnResult(sqlmock.NewResult(0, 50))
+
+       conn, err := db.Conn(context.Background())
+       assert.NoError(t, err)
+       defer conn.Close()
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.batchDeleteByLogCreated(context.Background(), conn, before)
+       assert.NoError(t, err)
+       assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestBatchDeleteByLogCreated_CustomTableName(t *testing.T) {
+       original := undo.UndoConfig.LogTable
+       undo.UndoConfig.LogTable = "my_undo_log"
+       defer func() { undo.UndoConfig.LogTable = original }()
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       mock.ExpectExec("DELETE FROM my_undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnResult(sqlmock.NewResult(0, 0))
+
+       conn, err := db.Conn(context.Background())
+       assert.NoError(t, err)
+       defer conn.Close()
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.batchDeleteByLogCreated(context.Background(), conn, time.Now())
+       assert.NoError(t, err)
+       assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestBatchDeleteByLogCreated_DBError(t *testing.T) {
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnError(driver.ErrBadConn)
+
+       conn, err := db.Conn(context.Background())
+       assert.NoError(t, err)
+       defer conn.Close()
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.batchDeleteByLogCreated(context.Background(), conn, time.Now())
+       assert.Error(t, err)
+}
+
+// TestBatchDeleteByLogCreated_RowsAffectedError verifies that an error from
+// RowsAffected() (e.g. driver returns -1 affected on a successful exec) is
+// surfaced instead of being swallowed. Before the fix, affected=-1 would
+// trigger an early `break` (since -1 < batchSize), silently leaving old
+// undo_log rows un-deleted while reporting success.
+func TestBatchDeleteByLogCreated_RowsAffectedError(t *testing.T) {
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnResult(sqlmock.NewErrorResult(errors.New("driver does 
not support RowsAffected")))
+
+       conn, err := db.Conn(context.Background())
+       assert.NoError(t, err)
+       defer conn.Close()
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.batchDeleteByLogCreated(context.Background(), conn, time.Now())
+       assert.Error(t, err, "RowsAffected error must propagate, not be 
swallowed")
+       assert.Contains(t, err.Error(), "rows affected")
+       assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func BenchmarkProcess_NonAT(b *testing.B) {
+       p := &rmDeleteUndoLogProcessor{}
+       msg := message.RpcMessage{
+               Body: message.UndoLogDeleteRequest{
+                       ResourceId: "any",
+                       SaveDays:   7,
+                       BranchType: branch.BranchTypeXA,
+               },
+       }
+
+       b.ResetTimer()
+       for i := 0; i < b.N; i++ {
+               _ = p.Process(context.Background(), msg)
+       }
+}
+
+func TestBatchDeleteByLogCreated_CustomBatchSize(t *testing.T) {
+       original := undo.UndoConfig.DeleteBatchSize
+       undo.UndoConfig.DeleteBatchSize = 2 // 小批次,方便验证循环
+       defer func() { undo.UndoConfig.DeleteBatchSize = original }()
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       // 第一批删了 2 行(等于 batchSize),继续
+       mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnResult(sqlmock.NewResult(0, 2))
+       // 第二批删了 1 行(小于 batchSize),退出
+       mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+               WithArgs(sqlmock.AnyArg()).
+               WillReturnResult(sqlmock.NewResult(0, 1))
+
+       conn, err := db.Conn(context.Background())
+       assert.NoError(t, err)
+       defer conn.Close()
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.batchDeleteByLogCreated(context.Background(), conn, time.Now())
+       assert.NoError(t, err)
+       assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestBatchDeleteByLogCreated_RoundLimitStopsDraining(t *testing.T) {
+       original := undo.UndoConfig.DeleteBatchSize
+       undo.UndoConfig.DeleteBatchSize = 2
+       defer func() { undo.UndoConfig.DeleteBatchSize = original }()
+
+       db, mock, err := sqlmock.New()
+       assert.NoError(t, err)
+       defer db.Close()
+
+       for i := 0; i < maxDeleteBatchRounds; i++ {
+               mock.ExpectExec("DELETE FROM undo_log WHERE log_created <= ?").
+                       WithArgs(sqlmock.AnyArg()).
+                       WillReturnResult(sqlmock.NewResult(0, 2))
+       }
+
+       conn, err := db.Conn(context.Background())
+       assert.NoError(t, err)
+       defer conn.Close()
+
+       p := &rmDeleteUndoLogProcessor{}
+       err = p.batchDeleteByLogCreated(context.Background(), conn, time.Now())
+       assert.NoError(t, err)
+       assert.NoError(t, mock.ExpectationsWereMet())
+}


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

Reply via email to