Skip to content

Commit c6e3d7b

Browse files
authored
fix(feature): persist failed status when scheduled flag change execution fails (#2709)
1 parent b2d62d1 commit c6e3d7b

2 files changed

Lines changed: 249 additions & 8 deletions

File tree

pkg/feature/api/scheduled_feature_change.go

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
737818
func (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()

pkg/feature/api/scheduled_feature_change_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package api
1616

1717
import (
1818
"context"
19+
"errors"
1920
"testing"
2021
"time"
2122

@@ -415,6 +416,159 @@ func TestDeleteScheduledFlagChange_Success(t *testing.T) {
415416
assert.NotNil(t, resp)
416417
}
417418

419+
func TestExecuteScheduledFlagChange_ValidationFailureMarksFailedOutsideTransaction(t *testing.T) {
420+
t.Parallel()
421+
ctrl := gomock.NewController(t)
422+
defer ctrl.Finish()
423+
424+
service := createFeatureServiceWithGetAccountByEnvironmentMock(
425+
ctrl,
426+
accountproto.AccountV2_Role_Organization_MEMBER,
427+
accountproto.AccountV2_Role_Environment_EDITOR,
428+
)
429+
430+
dbClient := service.dbClient.(*databasemock.MockClient)
431+
featureStorage := service.featureStorage.(*mock.MockFeatureStorage)
432+
scheduledStorage := service.scheduledFlagChangeStorage.(*mock.MockScheduledFlagChangeStorage)
433+
434+
feature := &domain.Feature{
435+
Feature: &featureproto.Feature{
436+
Id: "feature-id",
437+
Name: "Test Feature",
438+
Version: 1,
439+
Variations: []*featureproto.Variation{
440+
{Id: "var-1", Name: "Variation 1", Value: "true"},
441+
},
442+
// No rules: the scheduled payload references a rule that no longer exists
443+
},
444+
}
445+
446+
sfc := &featureproto.ScheduledFlagChange{
447+
Id: "sfc-id",
448+
FeatureId: "feature-id",
449+
EnvironmentId: "ns0",
450+
ScheduledAt: time.Now().Add(-time.Minute).Unix(),
451+
Status: featureproto.ScheduledFlagChangeStatus_SCHEDULED_FLAG_CHANGE_STATUS_PENDING,
452+
Payload: &featureproto.ScheduledChangePayload{
453+
RuleChanges: []*featureproto.RuleChange{
454+
{
455+
ChangeType: featureproto.ChangeType_UPDATE,
456+
Rule: &featureproto.Rule{Id: "deleted-rule-id"},
457+
},
458+
},
459+
},
460+
}
461+
462+
// Simulate real transaction semantics: the callback error propagates
463+
// and everything written inside the transaction is rolled back.
464+
// A marker is added to the transaction context so we can verify the
465+
// FAILED write does NOT happen on it (i.e. not inside the transaction).
466+
type txCtxKey struct{}
467+
var ctxWithTx context.Context
468+
dbClient.EXPECT().RunInTransactionV2(gomock.Any(), gomock.Any()).DoAndReturn(
469+
func(ctx context.Context, f func(context.Context) error) error {
470+
ctxWithTx = context.WithValue(ctx, txCtxKey{}, true)
471+
return f(ctxWithTx)
472+
},
473+
)
474+
scheduledStorage.EXPECT().GetScheduledFlagChange(gomock.Any(), "sfc-id", "ns0").
475+
Return(&domain.ScheduledFlagChange{ScheduledFlagChange: sfc}, nil)
476+
featureStorage.EXPECT().GetFeature(gomock.Any(), "feature-id", "ns0").Return(feature, nil)
477+
478+
// The FAILED status must be persisted after (outside) the failed transaction,
479+
// otherwise the rollback would discard it and the batch executor would
480+
// retry the same broken schedule forever.
481+
scheduledStorage.EXPECT().UpdateScheduledFlagChange(gomock.Any(), gomock.Any()).
482+
DoAndReturn(func(updateCtx context.Context, updated *domain.ScheduledFlagChange) error {
483+
// The write must not use the transaction context, or it would
484+
// be rolled back together with the failed transaction.
485+
require.NotNil(t, ctxWithTx)
486+
assert.Nil(t, updateCtx.Value(txCtxKey{}))
487+
assert.Equal(
488+
t,
489+
featureproto.ScheduledFlagChangeStatus_SCHEDULED_FLAG_CHANGE_STATUS_FAILED,
490+
updated.Status,
491+
)
492+
assert.NotEmpty(t, updated.FailureReason)
493+
return nil
494+
})
495+
496+
ctx := createContextWithToken()
497+
_, err := service.ExecuteScheduledFlagChange(ctx, &featureproto.ExecuteScheduledFlagChangeRequest{
498+
EnvironmentId: "ns0",
499+
Id: "sfc-id",
500+
})
501+
assert.Equal(t, statusInvalidRuleReference.Err(), err)
502+
}
503+
504+
func TestExecuteScheduledFlagChange_TransientErrorKeepsSchedulePending(t *testing.T) {
505+
t.Parallel()
506+
ctrl := gomock.NewController(t)
507+
defer ctrl.Finish()
508+
509+
service := createFeatureServiceWithGetAccountByEnvironmentMock(
510+
ctrl,
511+
accountproto.AccountV2_Role_Organization_MEMBER,
512+
accountproto.AccountV2_Role_Environment_EDITOR,
513+
)
514+
515+
dbClient := service.dbClient.(*databasemock.MockClient)
516+
featureStorage := service.featureStorage.(*mock.MockFeatureStorage)
517+
scheduledStorage := service.scheduledFlagChangeStorage.(*mock.MockScheduledFlagChangeStorage)
518+
519+
feature := &domain.Feature{
520+
Feature: &featureproto.Feature{
521+
Id: "feature-id",
522+
Name: "Test Feature",
523+
Version: 1,
524+
Variations: []*featureproto.Variation{
525+
{Id: "var-1", Name: "Variation 1", Value: "true"},
526+
},
527+
},
528+
}
529+
530+
sfc := &featureproto.ScheduledFlagChange{
531+
Id: "sfc-id",
532+
FeatureId: "feature-id",
533+
EnvironmentId: "ns0",
534+
ScheduledAt: time.Now().Add(-time.Minute).Unix(),
535+
Status: featureproto.ScheduledFlagChangeStatus_SCHEDULED_FLAG_CHANGE_STATUS_PENDING,
536+
Payload: &featureproto.ScheduledChangePayload{
537+
PrerequisiteChanges: []*featureproto.PrerequisiteChange{
538+
{
539+
ChangeType: featureproto.ChangeType_CREATE,
540+
Prerequisite: &featureproto.Prerequisite{
541+
FeatureId: "prereq-feature-id",
542+
VariationId: "prereq-var-1",
543+
},
544+
},
545+
},
546+
},
547+
}
548+
549+
dbClient.EXPECT().RunInTransactionV2(gomock.Any(), gomock.Any()).DoAndReturn(
550+
func(ctx context.Context, f func(context.Context) error) error {
551+
return f(ctx)
552+
},
553+
)
554+
scheduledStorage.EXPECT().GetScheduledFlagChange(gomock.Any(), "sfc-id", "ns0").
555+
Return(&domain.ScheduledFlagChange{ScheduledFlagChange: sfc}, nil)
556+
featureStorage.EXPECT().GetFeature(gomock.Any(), "feature-id", "ns0").Return(feature, nil)
557+
// The prerequisite lookup fails with a transient storage error (not "not found").
558+
featureStorage.EXPECT().GetFeature(gomock.Any(), "prereq-feature-id", "ns0").
559+
Return(nil, errors.New("db connection lost"))
560+
561+
// No UpdateScheduledFlagChange expectation: a transient error must NOT
562+
// mark the schedule FAILED, so it stays PENDING and is retried later.
563+
564+
ctx := createContextWithToken()
565+
_, err := service.ExecuteScheduledFlagChange(ctx, &featureproto.ExecuteScheduledFlagChangeRequest{
566+
EnvironmentId: "ns0",
567+
Id: "sfc-id",
568+
})
569+
assert.Equal(t, statusInternal.Err(), err)
570+
}
571+
418572
func TestGetScheduledFlagChangeSummary_Success(t *testing.T) {
419573
t.Parallel()
420574
ctrl := gomock.NewController(t)

0 commit comments

Comments
 (0)