@@ -10,6 +10,7 @@ import (
1010 "testing"
1111 "time"
1212
13+ "github.com/go-mysql-org/go-mysql/mysql"
1314 "github.com/google/uuid"
1415 "github.com/stretchr/testify/require"
1516 "github.com/testcontainers/testcontainers-go"
@@ -235,6 +236,117 @@ func (s ClickHouseSuite) Test_MySQL_Blobs() {
235236 RequireEnvCanceled (s .t , env )
236237}
237238
239+ func (s ClickHouseSuite ) Test_MySQL_TransactionPayloadCompression () {
240+ mySource , ok := s .source .(* MySqlSource )
241+ if ! ok {
242+ s .t .Skip ("only applies to mysql" )
243+ }
244+ if mySource .Config .Flavor == protos .MySqlFlavor_MYSQL_MARIA {
245+ s .t .Skip ("binlog_transaction_compression is not supported by MariaDB" )
246+ }
247+ cmp , err := mySource .CompareServerVersion (s .t .Context (), mysql_validation .MySQLMinVersionForBinlogTransactionCompression )
248+ require .NoError (s .t , err )
249+ if cmp < 0 {
250+ s .t .Skip ("only applies to mysql versions with binlog_transaction_compression" )
251+ }
252+
253+ srcTableName := "test_txn_payload"
254+ srcFullName := s .attachSchemaSuffix (srcTableName )
255+ dstTableName := "test_txn_payload_dst"
256+
257+ require .NoError (s .t , s .source .Exec (s .t .Context (), fmt .Sprintf (`
258+ CREATE TABLE IF NOT EXISTS %s (
259+ id BIGINT PRIMARY KEY,
260+ val TEXT NOT NULL
261+ )
262+ ` , srcFullName )))
263+
264+ connectionGen := FlowConnectionGenerationConfig {
265+ FlowJobName : s .attachSuffix (srcTableName ),
266+ TableNameMapping : map [string ]string {srcFullName : dstTableName },
267+ Destination : s .Peer ().Name ,
268+ }
269+ flowConnConfig := connectionGen .GenerateFlowConnectionConfigs (s )
270+
271+ tc := NewTemporalClient (s .t )
272+ env := ExecutePeerflow (s .t , tc , flowConnConfig )
273+ SetupCDCFlowStatusQuery (s .t , env , flowConnConfig )
274+
275+ // Compress this session's transactions so the upcoming DML lands in the binlog as a single
276+ // TRANSACTION_PAYLOAD_EVENT, exercising the compressed-payload decode path in CDC.
277+ require .NoError (s .t , s .source .Exec (s .t .Context (), "SET SESSION binlog_transaction_compression = ON" ))
278+
279+ // Capture the binlog position, then write a large, highly compressible transaction as a single
280+ // multi-row INSERT so MySQL compresses it (compressed < uncompressed) into one payload event.
281+ posBefore , err := mySource .GetMasterPos (s .t .Context ())
282+ require .NoError (s .t , err )
283+
284+ const rowCount = 200
285+ rowVal := strings .Repeat ("peerdb-compressible-" , 100 ) // ~2KB highly repetitive per row
286+ var insert strings.Builder
287+ fmt .Fprintf (& insert , "INSERT INTO %s (id, val) VALUES " , srcFullName )
288+ for i := 1 ; i <= rowCount ; i ++ {
289+ if i > 1 {
290+ insert .WriteByte (',' )
291+ }
292+ fmt .Fprintf (& insert , "(%d, '%s')" , i , rowVal )
293+ }
294+ require .NoError (s .t , s .source .Exec (s .t .Context (), insert .String ()))
295+
296+ findPayloadEndPos := func (ctx context.Context , from mysql.Position ) (bool , uint32 , error ) {
297+ rs , err := mySource .Execute (ctx , fmt .Sprintf ("SHOW BINLOG EVENTS IN '%s' FROM %d" , from .Name , from .Pos ))
298+ if err != nil {
299+ return false , 0 , err
300+ }
301+ for i := range rs .Values {
302+ // SHOW BINLOG EVENTS columns: Log_name, Pos, Event_type, Server_id, End_log_pos, Info
303+ eventType , _ := rs .GetString (i , 2 )
304+ if strings .Contains (strings .ToLower (eventType ), "payload" ) {
305+ pos , _ := rs .GetInt (i , 4 )
306+ return true , uint32 (pos ), nil
307+ }
308+ }
309+ return false , 0 , nil
310+ }
311+ hasPayload , payloadEndPos , err := findPayloadEndPos (s .t .Context (), posBefore )
312+ require .NoError (s .t , err )
313+ require .True (s .t , hasPayload , "expected a TRANSACTION_PAYLOAD_EVENT in the binlog" )
314+
315+ // for GTID "SHOW BINLOG EVENTS" returns really complex format and it's different
316+ // across versions/flavors.
317+ // Therefore, we just get the latest GTID from a server instead of reading it from a binglog.
318+ gtidAfter , err := mySource .GetMasterGTIDSet (s .t .Context ())
319+ require .NoError (s .t , err )
320+
321+ EnvWaitForEqualTablesWithNames (env , s , "waiting on cdc" , srcTableName , dstTableName , "id,val" )
322+
323+ // Assert the payload transaction was checkpointed
324+ pool , err := catalogTestAccessPool ()
325+ require .NoError (s .t , err )
326+ var lastText string
327+ require .NoError (s .t , pool .QueryRow (s .t .Context (),
328+ "SELECT last_text FROM metadata_last_sync_state WHERE job_name = $1" ,
329+ flowConnConfig .FlowJobName ,
330+ ).Scan (& lastText ))
331+
332+ if rest , isFilePos := strings .CutPrefix (lastText , "!f:" ); isFilePos {
333+ comma := strings .LastIndexByte (rest , ',' )
334+ require .NotEqual (s .t , - 1 , comma , "malformed file/pos offset %q" , lastText )
335+ storedPos , parseErr := strconv .ParseUint (rest [comma + 1 :], 16 , 32 )
336+ require .NoError (s .t , parseErr )
337+ require .GreaterOrEqual (s .t , uint32 (storedPos ), payloadEndPos ,
338+ "checkpoint did not advance past the TRANSACTION_PAYLOAD_EVENT" )
339+ } else {
340+ storedSet , parseErr := mysql .ParseGTIDSet (mySource .Flavor (), lastText )
341+ require .NoError (s .t , parseErr )
342+ require .True (s .t , storedSet .Contain (gtidAfter ),
343+ "checkpoint GTID set %s does not cover the committed payload transaction %s" , storedSet , gtidAfter )
344+ }
345+
346+ env .Cancel (s .t .Context ())
347+ RequireEnvCanceled (s .t , env )
348+ }
349+
238350// Test_MySQL_Binary_Trailing_Zeros reproduces trailing-0x00 truncation of fixed-length
239351// BINARY(N) columns over CDC.
240352//
0 commit comments