@@ -17,6 +17,7 @@ import (
1717
1818	"github.com/cashapp/spirit/pkg/table" 
1919	"github.com/stretchr/testify/assert" 
20+ 	"github.com/stretchr/testify/require" 
2021)
2122
2223func  TestMain (m  * testing.M ) {
@@ -547,6 +548,116 @@ func TestSetDDLNotificationChannel(t *testing.T) {
547548	})
548549}
549550
551+ // TestCompositePKUpdate tests that we correctly handle 
552+ // the case when a PRIMARY KEY is moved. 
553+ // See: https://github.com/block/spirit/issues/417 
554+ func  TestCompositePKUpdate (t  * testing.T ) {
555+ 	db , err  :=  dbconn .New (testutils .DSN (), dbconn .NewDBConfig ())
556+ 	assert .NoError (t , err )
557+ 	defer  db .Close ()
558+ 
559+ 	// Drop tables if they exist 
560+ 	testutils .RunSQL (t , "DROP TABLE IF EXISTS composite_pk_src, composite_pk_dst" )
561+ 
562+ 	// Create a table with composite primary key similar to customer's message_groups table 
563+ 	testutils .RunSQL (t , `CREATE TABLE composite_pk_src ( 
564+ 		organization_id BIGINT NOT NULL, 
565+ 		from_id BIGINT NOT NULL DEFAULT 0, 
566+ 		id BIGINT NOT NULL, 
567+ 		message VARCHAR(255) NOT NULL, 
568+ 		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 
569+ 		PRIMARY KEY (organization_id, from_id, id), 
570+ 		UNIQUE KEY idx_id (id) 
571+ 	)` )
572+ 
573+ 	testutils .RunSQL (t , `CREATE TABLE composite_pk_dst ( 
574+ 		organization_id BIGINT NOT NULL, 
575+ 		from_id BIGINT NOT NULL DEFAULT 0, 
576+ 		id BIGINT NOT NULL, 
577+ 		message VARCHAR(255) NOT NULL, 
578+ 		created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 
579+ 		PRIMARY KEY (organization_id, from_id, id), 
580+ 		UNIQUE KEY idx_id (id) 
581+ 	)` )
582+ 
583+ 	// Insert initial test data in *both* source and destination tables 
584+ 	testutils .RunSQL (t , `INSERT INTO composite_pk_src (organization_id, from_id, id, message) VALUES  
585+ 		(1, 100, 1, 'message 1'), 
586+ 		(1, 200, 2, 'message 2'), 
587+ 		(1, 300, 3, 'message 3'), 
588+ 		(2, 100, 4, 'message 4'), 
589+ 		(2, 200, 5, 'message 5')` )
590+ 	testutils .RunSQL (t , `INSERT INTO composite_pk_dst SELECT * FROM composite_pk_src` )
591+ 
592+ 	// Set up table info 
593+ 	t1  :=  table .NewTableInfo (db , "test" , "composite_pk_src" )
594+ 	assert .NoError (t , t1 .SetInfo (t .Context ()))
595+ 	t2  :=  table .NewTableInfo (db , "test" , "composite_pk_dst" )
596+ 	assert .NoError (t , t2 .SetInfo (t .Context ()))
597+ 
598+ 	// Create replication client 
599+ 	logger  :=  logrus .New ()
600+ 	cfg , err  :=  mysql2 .ParseDSN (testutils .DSN ())
601+ 	assert .NoError (t , err )
602+ 	client  :=  NewClient (db , cfg .Addr , cfg .User , cfg .Passwd , & ClientConfig {
603+ 		Logger :          logger ,
604+ 		Concurrency :     4 ,
605+ 		TargetBatchTime : time .Second ,
606+ 		ServerID :        NewServerID (),
607+ 	})
608+ 
609+ 	// Add subscription - note that keyAboveWatermark is disabled for composite PKs 
610+ 	assert .NoError (t , client .AddSubscription (t1 , t2 , nil ))
611+ 	assert .NoError (t , client .Run (t .Context ()))
612+ 	defer  client .Close ()
613+ 
614+ 	// Update the from_id (part of the primary key) 
615+ 	testutils .RunSQL (t , `UPDATE composite_pk_src SET from_id = 999 WHERE id IN (1, 3)` )
616+ 	assert .NoError (t , client .BlockWait (t .Context ()))
617+ 
618+ 	// The update should result in changes being tracked 
619+ 	// With binlog_row_image=minimal and PK updates, we expect 4 changes (2 deletes + 2 inserts) 
620+ 	deltaLen  :=  client .GetDeltaLen ()
621+ 	require .Equal (t , 4 , deltaLen , "Should have tracked 4 changes for PK update (2 deletes + 2 inserts)" )
622+ 
623+ 	// Flush the changes 
624+ 	// This should update the destination table correctly 
625+ 	assert .NoError (t , client .Flush (t .Context ()))
626+ 
627+ 	// Verify the data was replicated correctly 
628+ 	var  count  int 
629+ 
630+ 	// Check that rows with new from_id exist in destination 
631+ 	err  =  db .QueryRow (`SELECT COUNT(*) FROM composite_pk_dst 
632+ 		WHERE organization_id = 1 AND from_id = 999 AND id IN (1, 3)` ).Scan (& count )
633+ 	assert .NoError (t , err )
634+ 	assert .Equal (t , 2 , count , "Rows with updated from_id should exist in destination" )
635+ 
636+ 	// Check that rows with old from_id don't exist in destination 
637+ 	err  =  db .QueryRow (`SELECT COUNT(*) FROM composite_pk_dst 
638+ 		WHERE (organization_id = 1 AND from_id = 100 AND id = 1) 
639+ 		   OR (organization_id = 1 AND from_id = 300 AND id = 3)` ).Scan (& count )
640+ 	assert .NoError (t , err )
641+ 	assert .Equal (t , 0 , count , "Rows with old from_id should not exist in destination" )
642+ 
643+ 	// Verify total row count 
644+ 	err  =  db .QueryRow ("SELECT COUNT(*) FROM composite_pk_dst" ).Scan (& count )
645+ 	assert .NoError (t , err )
646+ 	assert .Equal (t , 5 , count , "Should have all 5 rows in destination" )
647+ 
648+ 	// Now test another PK update 
649+ 	testutils .RunSQL (t , `UPDATE composite_pk_src SET from_id = 888 WHERE id = 5` )
650+ 	assert .NoError (t , client .BlockWait (t .Context ()))
651+ 	assert .Positive (t , client .GetDeltaLen (), "Should have tracked changes for second PK update" )
652+ 	assert .NoError (t , client .Flush (t .Context ()))
653+ 
654+ 	// Verify the second update 
655+ 	err  =  db .QueryRow (`SELECT COUNT(*) FROM composite_pk_dst 
656+ 		WHERE organization_id = 2 AND from_id = 888 AND id = 5` ).Scan (& count )
657+ 	assert .NoError (t , err )
658+ 	assert .Equal (t , 1 , count , "Row with updated from_id=888 should exist in destination" )
659+ }
660+ 
550661func  TestAllChangesFlushed (t  * testing.T ) {
551662	srcTable , dstTable  :=  setupTestTables (t )
552663
0 commit comments