@@ -397,6 +397,228 @@ func TestReplicateService_SkipMessageTypes(t *testing.T) {
397397 })
398398}
399399
400+ func TestReplicateService_AlterConfigPChannelIncreasing (t * testing.T ) {
401+ // New config adds a 3rd channel (dml_2)
402+ newConfig := & commonpb.ReplicateConfiguration {
403+ Clusters : []* commonpb.MilvusCluster {
404+ {ClusterId : "primary" , Pchannels : []string {"primary-rootcoord-dml_0" , "primary-rootcoord-dml_1" , "primary-rootcoord-dml_2" }},
405+ {ClusterId : "by-dev" , Pchannels : []string {"by-dev-rootcoord-dml_0" , "by-dev-rootcoord-dml_1" , "by-dev-rootcoord-dml_2" }},
406+ },
407+ CrossClusterTopology : []* commonpb.CrossClusterTopology {
408+ {SourceClusterId : "primary" , TargetClusterId : "by-dev" },
409+ },
410+ }
411+
412+ t .Run ("with_flag_maps_all_channels" , func (t * testing.T ) {
413+ c := mock_client .NewMockClient (t )
414+ as := mock_client .NewMockAssignmentService (t )
415+ c .EXPECT ().Assignment ().Return (as ).Maybe ()
416+
417+ h := mock_handler .NewMockHandlerClient (t )
418+ p := mock_producer .NewMockProducer (t )
419+ p .EXPECT ().Append (mock .Anything , mock .Anything ).RunAndReturn (func (ctx context.Context , mm message.MutableMessage ) (* types.AppendResult , error ) {
420+ msg := message .MustAsMutableAlterReplicateConfigMessageV2 (mm )
421+ // With IsPchannelIncreasing flag, all 3 channels (including new one)
422+ // should be mapped using the new config from the message header.
423+ bh := msg .BroadcastHeader ()
424+ assert .NotNil (t , bh )
425+ assert .Len (t , bh .VChannels , 3 , "all channels including new one should be mapped" )
426+ for _ , vchannel := range bh .VChannels {
427+ assert .True (t , strings .HasPrefix (vchannel , "by-dev" ), "vchannel should be mapped to secondary cluster" )
428+ }
429+ return & types.AppendResult {
430+ MessageID : walimplstest .NewTestMessageID (1 ),
431+ TimeTick : 1 ,
432+ }, nil
433+ }).Maybe ()
434+ p .EXPECT ().IsAvailable ().Return (true ).Maybe ()
435+ p .EXPECT ().Available ().Return (make (chan struct {})).Maybe ()
436+ h .EXPECT ().CreateProducer (mock .Anything , mock .Anything ).Return (p , nil ).Maybe ()
437+
438+ // Current (old) config has 2 channels
439+ as .EXPECT ().GetReplicateConfiguration (mock .Anything ).Return (replicateutil .MustNewConfigHelper (
440+ "by-dev" ,
441+ & commonpb.ReplicateConfiguration {
442+ Clusters : []* commonpb.MilvusCluster {
443+ {ClusterId : "primary" , Pchannels : []string {"primary-rootcoord-dml_0" , "primary-rootcoord-dml_1" }},
444+ {ClusterId : "by-dev" , Pchannels : []string {"by-dev-rootcoord-dml_0" , "by-dev-rootcoord-dml_1" }},
445+ },
446+ CrossClusterTopology : []* commonpb.CrossClusterTopology {
447+ {SourceClusterId : "primary" , TargetClusterId : "by-dev" },
448+ },
449+ },
450+ ), nil )
451+ as .EXPECT ().GetLatestAssignments (mock .Anything ).Return (nil , errors .New ("not needed" )).Maybe ()
452+
453+ rs := & replicateService {
454+ walAccesserImpl : & walAccesserImpl {
455+ lifetime : typeutil .NewLifetime (),
456+ clusterID : "by-dev" ,
457+ streamingCoordClient : c ,
458+ handlerClient : h ,
459+ producers : make (map [string ]* producer.ResumableProducer ),
460+ },
461+ }
462+
463+ // Build AlterReplicateConfig broadcast with 3 channels and IsPchannelIncreasing flag
464+ replicateMsgs := createReplicateAlterConfigMessages (newConfig ,
465+ []string {"primary-rootcoord-dml_0" , "primary-rootcoord-dml_1" , "primary-rootcoord-dml_2" },
466+ true )
467+
468+ for _ , msg := range replicateMsgs {
469+ _ , err := rs .Append (context .Background (), msg )
470+ assert .NoError (t , err )
471+ }
472+ })
473+
474+ t .Run ("without_flag_fails_for_unknown_channel" , func (t * testing.T ) {
475+ c := mock_client .NewMockClient (t )
476+ as := mock_client .NewMockAssignmentService (t )
477+ c .EXPECT ().Assignment ().Return (as ).Maybe ()
478+
479+ h := mock_handler .NewMockHandlerClient (t )
480+
481+ // Current (old) config has 2 channels
482+ as .EXPECT ().GetReplicateConfiguration (mock .Anything ).Return (replicateutil .MustNewConfigHelper (
483+ "by-dev" ,
484+ & commonpb.ReplicateConfiguration {
485+ Clusters : []* commonpb.MilvusCluster {
486+ {ClusterId : "primary" , Pchannels : []string {"primary-rootcoord-dml_0" , "primary-rootcoord-dml_1" }},
487+ {ClusterId : "by-dev" , Pchannels : []string {"by-dev-rootcoord-dml_0" , "by-dev-rootcoord-dml_1" }},
488+ },
489+ CrossClusterTopology : []* commonpb.CrossClusterTopology {
490+ {SourceClusterId : "primary" , TargetClusterId : "by-dev" },
491+ },
492+ },
493+ ), nil )
494+
495+ rs := & replicateService {
496+ walAccesserImpl : & walAccesserImpl {
497+ lifetime : typeutil .NewLifetime (),
498+ clusterID : "by-dev" ,
499+ streamingCoordClient : c ,
500+ handlerClient : h ,
501+ producers : make (map [string ]* producer.ResumableProducer ),
502+ },
503+ }
504+
505+ // Build AlterReplicateConfig broadcast with 3 channels but NO IsPchannelIncreasing flag
506+ replicateMsgs := createReplicateAlterConfigMessages (newConfig ,
507+ []string {"primary-rootcoord-dml_0" , "primary-rootcoord-dml_1" , "primary-rootcoord-dml_2" },
508+ false )
509+
510+ // Should fail because the old config doesn't know about primary-rootcoord-dml_2
511+ for _ , msg := range replicateMsgs {
512+ _ , err := rs .Append (context .Background (), msg )
513+ assert .Error (t , err )
514+ assert .Contains (t , err .Error (), "failed to get target channel" )
515+ }
516+ })
517+
518+ t .Run ("with_flag_invalid_config_in_header" , func (t * testing.T ) {
519+ c := mock_client .NewMockClient (t )
520+ as := mock_client .NewMockAssignmentService (t )
521+ c .EXPECT ().Assignment ().Return (as ).Maybe ()
522+
523+ h := mock_handler .NewMockHandlerClient (t )
524+
525+ // Current config has 2 channels
526+ as .EXPECT ().GetReplicateConfiguration (mock .Anything ).Return (replicateutil .MustNewConfigHelper (
527+ "by-dev" ,
528+ & commonpb.ReplicateConfiguration {
529+ Clusters : []* commonpb.MilvusCluster {
530+ {ClusterId : "primary" , Pchannels : []string {"primary-rootcoord-dml_0" , "primary-rootcoord-dml_1" }},
531+ {ClusterId : "by-dev" , Pchannels : []string {"by-dev-rootcoord-dml_0" , "by-dev-rootcoord-dml_1" }},
532+ },
533+ CrossClusterTopology : []* commonpb.CrossClusterTopology {
534+ {SourceClusterId : "primary" , TargetClusterId : "by-dev" },
535+ },
536+ },
537+ ), nil )
538+
539+ rs := & replicateService {
540+ walAccesserImpl : & walAccesserImpl {
541+ lifetime : typeutil .NewLifetime (),
542+ clusterID : "by-dev" ,
543+ streamingCoordClient : c ,
544+ handlerClient : h ,
545+ producers : make (map [string ]* producer.ResumableProducer ),
546+ },
547+ }
548+
549+ // Build with IsPchannelIncreasing flag but an invalid config (no topology, multiple primary => error)
550+ invalidConfig := & commonpb.ReplicateConfiguration {
551+ Clusters : []* commonpb.MilvusCluster {
552+ {ClusterId : "primary" , Pchannels : []string {"primary-rootcoord-dml_0" }},
553+ {ClusterId : "by-dev" , Pchannels : []string {"by-dev-rootcoord-dml_0" }},
554+ },
555+ // Missing CrossClusterTopology => both clusters are "primary" => primaryCount != 1
556+ }
557+ replicateMsgs := createReplicateAlterConfigMessages (invalidConfig ,
558+ []string {"primary-rootcoord-dml_0" },
559+ true )
560+
561+ for _ , msg := range replicateMsgs {
562+ _ , err := rs .Append (context .Background (), msg )
563+ assert .Error (t , err )
564+ assert .Contains (t , err .Error (), "failed to parse new replicate config" )
565+ }
566+ })
567+
568+ t .Run ("with_flag_source_cluster_missing_in_new_config" , func (t * testing.T ) {
569+ c := mock_client .NewMockClient (t )
570+ as := mock_client .NewMockAssignmentService (t )
571+ c .EXPECT ().Assignment ().Return (as ).Maybe ()
572+
573+ h := mock_handler .NewMockHandlerClient (t )
574+
575+ // Current config has 2 channels
576+ as .EXPECT ().GetReplicateConfiguration (mock .Anything ).Return (replicateutil .MustNewConfigHelper (
577+ "by-dev" ,
578+ & commonpb.ReplicateConfiguration {
579+ Clusters : []* commonpb.MilvusCluster {
580+ {ClusterId : "primary" , Pchannels : []string {"primary-rootcoord-dml_0" , "primary-rootcoord-dml_1" }},
581+ {ClusterId : "by-dev" , Pchannels : []string {"by-dev-rootcoord-dml_0" , "by-dev-rootcoord-dml_1" }},
582+ },
583+ CrossClusterTopology : []* commonpb.CrossClusterTopology {
584+ {SourceClusterId : "primary" , TargetClusterId : "by-dev" },
585+ },
586+ },
587+ ), nil )
588+
589+ rs := & replicateService {
590+ walAccesserImpl : & walAccesserImpl {
591+ lifetime : typeutil .NewLifetime (),
592+ clusterID : "by-dev" ,
593+ streamingCoordClient : c ,
594+ handlerClient : h ,
595+ producers : make (map [string ]* producer.ResumableProducer ),
596+ },
597+ }
598+
599+ // Build with IsPchannelIncreasing flag but the new config uses "other-cluster" instead of "primary"
600+ // The replicate header has ClusterID="primary", but the new config doesn't contain "primary"
601+ missingSourceConfig := & commonpb.ReplicateConfiguration {
602+ Clusters : []* commonpb.MilvusCluster {
603+ {ClusterId : "other-cluster" , Pchannels : []string {"other-rootcoord-dml_0" }},
604+ {ClusterId : "by-dev" , Pchannels : []string {"by-dev-rootcoord-dml_0" }},
605+ },
606+ CrossClusterTopology : []* commonpb.CrossClusterTopology {
607+ {SourceClusterId : "other-cluster" , TargetClusterId : "by-dev" },
608+ },
609+ }
610+ replicateMsgs := createReplicateAlterConfigMessages (missingSourceConfig ,
611+ []string {"primary-rootcoord-dml_0" },
612+ true )
613+
614+ for _ , msg := range replicateMsgs {
615+ _ , err := rs .Append (context .Background (), msg )
616+ assert .Error (t , err )
617+ assert .Contains (t , err .Error (), "source cluster primary not found in new replicate configuration" )
618+ }
619+ })
620+ }
621+
400622func TestBuildSkipMessageTypes (t * testing.T ) {
401623 t .Run ("normal" , func (t * testing.T ) {
402624 m := buildSkipMessageTypes ([]string {"AlterResourceGroup" , "DropResourceGroup" })
@@ -457,6 +679,26 @@ func broadcastMsgToReplicateMsgs(broadcastMsg message.BroadcastMutableMessage) [
457679 return replicateMsgs
458680}
459681
682+ func createReplicateAlterConfigMessages (newConfig * commonpb.ReplicateConfiguration , broadcastChannels []string , isPchannelIncreasing bool ) []message.ReplicateMutableMessage {
683+ alterMsg := message .NewAlterReplicateConfigMessageBuilderV2 ().
684+ WithHeader (& message.AlterReplicateConfigMessageHeader {
685+ ReplicateConfiguration : newConfig ,
686+ IsPchannelIncreasing : isPchannelIncreasing ,
687+ }).
688+ WithBody (& message.AlterReplicateConfigMessageBody {}).
689+ WithBroadcast (broadcastChannels ).
690+ MustBuildBroadcast ()
691+ msgs := alterMsg .WithBroadcastID (200 ).SplitIntoMutableMessage ()
692+ replicateMsgs := make ([]message.ReplicateMutableMessage , 0 , len (msgs ))
693+ for _ , msg := range msgs {
694+ immutableMsg := msg .WithLastConfirmedUseMessageID ().WithTimeTick (1 ).IntoImmutableMessage (pulsar2 .NewPulsarID (
695+ pulsar .NewMessageID (1 , 2 , 3 , 4 ),
696+ ))
697+ replicateMsgs = append (replicateMsgs , message .MustNewReplicateMessage ("primary" , immutableMsg .IntoImmutableMessageProto ()))
698+ }
699+ return replicateMsgs
700+ }
701+
460702func createReplicateCreateCollectionMessages () []message.ReplicateMutableMessage {
461703 schema := & schemapb.CollectionSchema {
462704 Fields : []* schemapb.FieldSchema {
0 commit comments