@@ -16,6 +16,7 @@ package api
1616
1717import (
1818 "context"
19+ "errors"
1920 "testing"
2021 "time"
2122
@@ -31,6 +32,8 @@ import (
3132 "github.com/bucketeer-io/bucketeer/v2/pkg/feature/domain"
3233 v2fs "github.com/bucketeer-io/bucketeer/v2/pkg/feature/storage/v2"
3334 storagemock "github.com/bucketeer-io/bucketeer/v2/pkg/feature/storage/v2/mock"
35+ "github.com/bucketeer-io/bucketeer/v2/pkg/pubsub/publisher"
36+ publishermock "github.com/bucketeer-io/bucketeer/v2/pkg/pubsub/publisher/mock"
3437 "github.com/bucketeer-io/bucketeer/v2/pkg/rpc"
3538 databasemock "github.com/bucketeer-io/bucketeer/v2/pkg/storage/v2/database/mock"
3639 "github.com/bucketeer-io/bucketeer/v2/pkg/token"
@@ -732,6 +735,211 @@ func TestListSegmentsMySQL(t *testing.T) {
732735 }
733736}
734737
738+ // TestSegmentDomainEventPublishedAfterCommitMySQL guards the
739+ // publish-after-commit ordering of segment mutations. Publishing inside the
740+ // transaction lets consumers that re-read MySQL on each event (e.g. the
741+ // cache refresher) observe pre-commit state and overwrite the segment users
742+ // cache with stale rules, which broke server SDK diff syncs.
743+ func TestSegmentDomainEventPublishedAfterCommitMySQL (t * testing.T ) {
744+ t .Parallel ()
745+ mockController := gomock .NewController (t )
746+ defer mockController .Finish ()
747+
748+ ctx , cancel := context .WithCancel (context .Background ())
749+ defer cancel ()
750+ ctx = metadata .NewIncomingContext (ctx , metadata.MD {
751+ "accept-language" : []string {"ja" },
752+ })
753+ ctx = setToken (ctx )
754+
755+ segmentRules := & featureproto.RuleListValue {
756+ Values : []* featureproto.Rule {
757+ {
758+ Clauses : []* featureproto.Clause {
759+ {
760+ Attribute : "plan" ,
761+ Operator : featureproto .Clause_EQUALS ,
762+ Values : []string {"premium" },
763+ },
764+ },
765+ },
766+ },
767+ }
768+
769+ testcases := []struct {
770+ desc string
771+ setup func (s * FeatureService , committed * bool )
772+ run func (s * FeatureService ) error
773+ }{
774+ {
775+ desc : "CreateSegment" ,
776+ setup : func (s * FeatureService , committed * bool ) {
777+ s .dbClient .(* databasemock.MockClient ).EXPECT ().RunInTransactionV2 (
778+ gomock .Any (), gomock .Any (),
779+ ).DoAndReturn (func (ctx context.Context , fn func (ctx context.Context ) error ) error {
780+ err := fn (ctx )
781+ require .NoError (t , err )
782+ * committed = true
783+ return err
784+ })
785+ s .segmentStorage .(* storagemock.MockSegmentStorage ).EXPECT ().CreateSegment (
786+ gomock .Any (), gomock .Any (), gomock .Any (),
787+ ).Return (nil )
788+ },
789+ run : func (s * FeatureService ) error {
790+ _ , err := s .CreateSegment (ctx , & featureproto.CreateSegmentRequest {
791+ Name : "name" ,
792+ Description : "description" ,
793+ EnvironmentId : "ns0" ,
794+ })
795+ return err
796+ },
797+ },
798+ {
799+ desc : "UpdateSegment with rules" ,
800+ setup : func (s * FeatureService , committed * bool ) {
801+ s .dbClient .(* databasemock.MockClient ).EXPECT ().RunInTransactionV2 (
802+ gomock .Any (), gomock .Any (),
803+ ).DoAndReturn (func (ctx context.Context , fn func (ctx context.Context ) error ) error {
804+ err := fn (ctx )
805+ require .NoError (t , err )
806+ * committed = true
807+ return err
808+ })
809+ s .segmentStorage .(* storagemock.MockSegmentStorage ).EXPECT ().GetSegment (
810+ gomock .Any (), gomock .Any (), gomock .Any (),
811+ ).Return (& domain.Segment {
812+ Segment : & featureproto.Segment {
813+ Id : "id0" ,
814+ },
815+ }, nil , nil )
816+ s .segmentStorage .(* storagemock.MockSegmentStorage ).EXPECT ().UpdateSegment (
817+ gomock .Any (), gomock .Any (), gomock .Any (),
818+ ).Return (nil )
819+ s .segmentStorage .(* storagemock.MockSegmentStorage ).EXPECT ().ListSegmentUsersBySegment (
820+ gomock .Any (), "id0" , "ns0" ,
821+ ).Return ([]* featureproto.SegmentUser {}, nil )
822+ s .segmentUsersCache .(* cachev3mock.MockSegmentUsersCache ).EXPECT ().Put (
823+ gomock .Any (), "ns0" ,
824+ ).Return (nil )
825+ },
826+ run : func (s * FeatureService ) error {
827+ _ , err := s .UpdateSegment (ctx , & featureproto.UpdateSegmentRequest {
828+ Id : "id0" ,
829+ EnvironmentId : "ns0" ,
830+ Rules : segmentRules ,
831+ })
832+ return err
833+ },
834+ },
835+ {
836+ desc : "DeleteSegment" ,
837+ setup : func (s * FeatureService , committed * bool ) {
838+ s .featureStorage .(* storagemock.MockFeatureStorage ).EXPECT ().ListFeatures (
839+ gomock .Any (), gomock .Any (),
840+ ).Return ([]* featureproto.Feature {}, 0 , int64 (0 ), nil )
841+ s .dbClient .(* databasemock.MockClient ).EXPECT ().RunInTransactionV2 (
842+ gomock .Any (), gomock .Any (),
843+ ).DoAndReturn (func (ctx context.Context , fn func (ctx context.Context ) error ) error {
844+ err := fn (ctx )
845+ require .NoError (t , err )
846+ * committed = true
847+ return err
848+ })
849+ s .segmentStorage .(* storagemock.MockSegmentStorage ).EXPECT ().GetSegment (
850+ gomock .Any (), gomock .Any (), gomock .Any (),
851+ ).Return (& domain.Segment {
852+ Segment : & featureproto.Segment {
853+ Id : "id0" ,
854+ },
855+ }, nil , nil )
856+ s .segmentStorage .(* storagemock.MockSegmentStorage ).EXPECT ().DeleteSegment (
857+ gomock .Any (), gomock .Any (),
858+ ).Return (nil )
859+ },
860+ run : func (s * FeatureService ) error {
861+ _ , err := s .DeleteSegment (ctx , & featureproto.DeleteSegmentRequest {
862+ Id : "id0" ,
863+ EnvironmentId : "ns0" ,
864+ })
865+ return err
866+ },
867+ },
868+ }
869+ for _ , tc := range testcases {
870+ t .Run (tc .desc , func (t * testing.T ) {
871+ service := createFeatureService (mockController )
872+ domainPublisher := publishermock .NewMockPublisher (mockController )
873+ service .domainPublisher = domainPublisher
874+ committed := false
875+ domainPublisher .EXPECT ().Publish (gomock .Any (), gomock .Any ()).DoAndReturn (
876+ func (ctx context.Context , msg publisher.Message ) error {
877+ assert .True (t , committed ,
878+ "domain event must be published after the transaction commits" )
879+ return nil
880+ })
881+ tc .setup (service , & committed )
882+ assert .NoError (t , tc .run (service ))
883+ })
884+ }
885+ }
886+
887+ // TestUpdateSegmentPublishFailureMySQL: when the post-commit publish fails,
888+ // the request must fail and the segment users cache must not be refreshed
889+ // (no ListSegmentUsersBySegment/Put expectations are registered, so the mock
890+ // controller fails the test if they are called).
891+ func TestUpdateSegmentPublishFailureMySQL (t * testing.T ) {
892+ t .Parallel ()
893+ mockController := gomock .NewController (t )
894+ defer mockController .Finish ()
895+
896+ ctx , cancel := context .WithCancel (context .Background ())
897+ defer cancel ()
898+ ctx = metadata .NewIncomingContext (ctx , metadata.MD {
899+ "accept-language" : []string {"ja" },
900+ })
901+ ctx = setToken (ctx )
902+
903+ service := createFeatureService (mockController )
904+ domainPublisher := publishermock .NewMockPublisher (mockController )
905+ service .domainPublisher = domainPublisher
906+ service .dbClient .(* databasemock.MockClient ).EXPECT ().RunInTransactionV2 (
907+ gomock .Any (), gomock .Any (),
908+ ).DoAndReturn (func (ctx context.Context , fn func (ctx context.Context ) error ) error {
909+ return fn (ctx )
910+ })
911+ service .segmentStorage .(* storagemock.MockSegmentStorage ).EXPECT ().GetSegment (
912+ gomock .Any (), gomock .Any (), gomock .Any (),
913+ ).Return (& domain.Segment {
914+ Segment : & featureproto.Segment {
915+ Id : "id0" ,
916+ },
917+ }, nil , nil )
918+ service .segmentStorage .(* storagemock.MockSegmentStorage ).EXPECT ().UpdateSegment (
919+ gomock .Any (), gomock .Any (), gomock .Any (),
920+ ).Return (nil )
921+ domainPublisher .EXPECT ().Publish (gomock .Any (), gomock .Any ()).Return (errors .New ("publish failed" ))
922+
923+ _ , err := service .UpdateSegment (ctx , & featureproto.UpdateSegmentRequest {
924+ Id : "id0" ,
925+ EnvironmentId : "ns0" ,
926+ Rules : & featureproto.RuleListValue {
927+ Values : []* featureproto.Rule {
928+ {
929+ Clauses : []* featureproto.Clause {
930+ {
931+ Attribute : "plan" ,
932+ Operator : featureproto .Clause_EQUALS ,
933+ Values : []string {"premium" },
934+ },
935+ },
936+ },
937+ },
938+ },
939+ })
940+ assert .Error (t , err )
941+ }
942+
735943func setToken (ctx context.Context ) context.Context {
736944 t := & token.AccessToken {
737945 Issuer : "issuer" ,
0 commit comments