@@ -22,7 +22,10 @@ import (
2222 "time"
2323
2424 "go.uber.org/zap"
25+ "google.golang.org/grpc/codes"
26+ "google.golang.org/grpc/status"
2527
28+ "github.com/bucketeer-io/bucketeer/v2/pkg/api/api"
2629 domainevent "github.com/bucketeer-io/bucketeer/v2/pkg/domainevent/domain"
2730 "github.com/bucketeer-io/bucketeer/v2/pkg/feature/domain"
2831 "github.com/bucketeer-io/bucketeer/v2/pkg/feature/scheduled"
@@ -645,6 +648,13 @@ func (s *FeatureService) ExecuteScheduledFlagChange(
645648
646649 var sfc * domain.ScheduledFlagChange
647650 var event * eventproto.Event
651+ // Failure reason for permanent (non-retryable) failures. It must be
652+ // persisted OUTSIDE the transaction: returning an error from the
653+ // transaction callback rolls back everything, including any status
654+ // update written inside it. Persisting FAILED after the rollback
655+ // prevents the batch executor from retrying the same broken schedule
656+ // forever.
657+ var failureReason string
648658
649659 err = s .dbClient .RunInTransactionV2 (ctx , func (ctxWithTx context.Context ) error {
650660 var err error
@@ -673,17 +683,26 @@ func (s *FeatureService) ExecuteScheduledFlagChange(
673683 feature , err := s .featureStorage .GetFeature (ctxWithTx , sfc .FeatureId , req .EnvironmentId )
674684 if err != nil {
675685 if errors .Is (err , v2fs .ErrFeatureNotFound ) {
676- sfc .MarkFailed ("Feature not found" )
677- _ = s .scheduledFlagChangeStorage .UpdateScheduledFlagChange (ctxWithTx , sfc )
686+ failureReason = "Feature not found"
678687 return statusFeatureNotFound .Err ()
679688 }
680- return err
689+ s .logger .Error (
690+ "Failed to get feature for scheduled change execution" ,
691+ log .FieldsFromIncomingContext (ctx ).AddFields (
692+ zap .Error (err ),
693+ zap .String ("id" , req .Id ),
694+ zap .String ("featureId" , sfc .FeatureId ),
695+ zap .String ("environmentId" , req .EnvironmentId ),
696+ )... ,
697+ )
698+ return statusInternal .Err ()
681699 }
682700
683701 // Validate references still exist
684702 if err := s .validateScheduledChangePayload (ctxWithTx , sfc .Payload , feature .Feature , req .EnvironmentId ); err != nil {
685- sfc .MarkFailed (err .Error ())
686- _ = s .scheduledFlagChangeStorage .UpdateScheduledFlagChange (ctxWithTx , sfc )
703+ if isPermanentScheduledChangeError (err ) {
704+ failureReason = err .Error ()
705+ }
687706 return err
688707 }
689708
@@ -693,8 +712,9 @@ func (s *FeatureService) ExecuteScheduledFlagChange(
693712
694713 event , _ , err = s .updateFeatureWithinTransaction (ctxWithTx , editor , updateReq )
695714 if err != nil {
696- sfc .MarkFailed (err .Error ())
697- _ = s .scheduledFlagChangeStorage .UpdateScheduledFlagChange (ctxWithTx , sfc )
715+ if isPermanentScheduledChangeError (err ) {
716+ failureReason = err .Error ()
717+ }
698718 return err
699719 }
700720
@@ -705,6 +725,9 @@ func (s *FeatureService) ExecuteScheduledFlagChange(
705725 })
706726
707727 if err != nil {
728+ if failureReason != "" && sfc != nil {
729+ s .markScheduledFlagChangeFailed (ctx , sfc , failureReason , req .EnvironmentId )
730+ }
708731 s .logger .Error (
709732 "Failed to execute scheduled flag change" ,
710733 log .FieldsFromIncomingContext (ctx ).AddFields (
@@ -734,6 +757,64 @@ func (s *FeatureService) ExecuteScheduledFlagChange(
734757 }, nil
735758}
736759
760+ // isPermanentScheduledChangeError reports whether an execution error is
761+ // permanent (caused by the schedule's payload or the flag's current state)
762+ // as opposed to transient (storage/infrastructure). Only permanent errors
763+ // should mark the schedule FAILED; transient errors leave it PENDING so the
764+ // batch executor retries it.
765+ func isPermanentScheduledChangeError (err error ) bool {
766+ st , ok := status .FromError (err )
767+ if ! ok {
768+ // Not a gRPC status error: domain validation errors (pkg/error
769+ // BktError) are mapped to their equivalent gRPC code; raw storage
770+ // errors map to Unknown and are treated as transient.
771+ st = api .NewGRPCStatus (err )
772+ }
773+ switch st .Code () {
774+ case codes .InvalidArgument ,
775+ codes .NotFound ,
776+ codes .AlreadyExists ,
777+ codes .FailedPrecondition ,
778+ codes .OutOfRange :
779+ return true
780+ default :
781+ return false
782+ }
783+ }
784+
785+ // markScheduledFlagChangeFailed persists the FAILED status in its own write,
786+ // outside any (rolled back) transaction, so the schedule is not retried by
787+ // the batch executor. It uses a detached context so the write still succeeds
788+ // when the caller's context is already cancelled or past its deadline
789+ // (e.g. the batch executor's timeout).
790+ func (s * FeatureService ) markScheduledFlagChangeFailed (
791+ ctx context.Context ,
792+ sfc * domain.ScheduledFlagChange ,
793+ reason , environmentID string ,
794+ ) {
795+ // WithoutCancel detaches cancellation/deadline (so the write succeeds
796+ // even if the caller's context is already cancelled) while preserving
797+ // request-scoped values such as trace IDs. The caller's context never
798+ // carries a transaction: RunInTransactionV2 only injects it into the
799+ // callback's context, so this write always goes to the pool.
800+ writeCtx , cancel := context .WithTimeout (context .WithoutCancel (ctx ), 5 * time .Second )
801+ defer cancel ()
802+
803+ sfc .MarkFailed (reason )
804+ if err := s .scheduledFlagChangeStorage .UpdateScheduledFlagChange (writeCtx , sfc ); err != nil {
805+ s .logger .Error (
806+ "Failed to mark scheduled flag change as failed" ,
807+ log .FieldsFromIncomingContext (ctx ).AddFields (
808+ zap .Error (err ),
809+ zap .String ("id" , sfc .Id ),
810+ zap .String ("featureId" , sfc .FeatureId ),
811+ zap .String ("environmentId" , environmentID ),
812+ zap .String ("failureReason" , reason ),
813+ )... ,
814+ )
815+ }
816+ }
817+
737818func (s * FeatureService ) GetScheduledFlagChangeSummary (
738819 ctx context.Context ,
739820 req * ftproto.GetScheduledFlagChangeSummaryRequest ,
@@ -928,7 +1009,13 @@ func (s *FeatureService) validateScheduledChangePayload(
9281009 }
9291010 prereqFeature , err := s .featureStorage .GetFeature (ctx , pc .Prerequisite .FeatureId , environmentID )
9301011 if err != nil {
931- return statusInvalidPrerequisiteReference .Err ()
1012+ if errors .Is (err , v2fs .ErrFeatureNotFound ) {
1013+ return statusInvalidPrerequisiteReference .Err ()
1014+ }
1015+ // Transient storage error: don't misreport it as an invalid
1016+ // reference, or execution would permanently mark the schedule
1017+ // FAILED instead of retrying.
1018+ return statusInternal .Err ()
9321019 }
9331020 if ! domain .VariationExists (prereqFeature .Feature , pc .Prerequisite .VariationId ) {
9341021 return statusInvalidPrerequisiteReference .Err ()
0 commit comments