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]