Skip to content

Commit ee7c670

Browse files
committed
fix(data): re-read version inside transaction before mark-as-latest promotion
The pre-transaction version lookup could be stale if a concurrent request released the version between the lookup and the transaction start. Re-read the version inside the transaction to ensure the prerelease check uses current data. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez <javier@chainloop.dev> Chainloop-Trace-Sessions: c9c4aac1-2015-43c3-bf26-47621b425735
1 parent 0b1e7c3 commit ee7c670

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

app/controlplane/pkg/biz/workflowrun_integration_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"encoding/json"
2222
"os"
2323
"testing"
24+
"time"
2425

2526
schemav1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2627
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
@@ -601,6 +602,52 @@ func (s *workflowRunIntegrationTestSuite) TestCreate() {
601602
s.Contains(err.Error(), "cannot promote a released version")
602603
})
603604

605+
s.T().Run("mark-as-latest true re-reads version inside transaction", func(_ *testing.T) {
606+
// Create a pre-release version
607+
run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{
608+
WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID,
609+
RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-reread-test",
610+
})
611+
s.Require().NoError(err)
612+
s.True(run.ProjectVersion.Prerelease)
613+
614+
// Release the version — simulates a concurrent release between lookup and tx
615+
_, err = s.ProjectVersion.UpdateReleaseStatus(ctx, run.ProjectVersion.ID.String(), true)
616+
s.Require().NoError(err)
617+
618+
// Attempt to promote with mark-as-latest=true — the in-tx re-read should catch the release
619+
markTrue := true
620+
_, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{
621+
WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID,
622+
RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-reread-test", MarkAsLatest: &markTrue,
623+
})
624+
s.Require().Error(err)
625+
s.True(biz.IsErrValidation(err))
626+
s.Contains(err.Error(), "cannot promote a released version")
627+
})
628+
629+
s.T().Run("mark-as-latest true on soft-deleted version returns not found", func(_ *testing.T) {
630+
// Create a version
631+
run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{
632+
WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID,
633+
RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-deleted-test",
634+
})
635+
s.Require().NoError(err)
636+
637+
// Soft-delete the version — simulates concurrent deletion between lookup and tx
638+
_, err = s.Data.DB.ProjectVersion.UpdateOneID(run.ProjectVersion.ID).
639+
SetDeletedAt(time.Now()).Save(ctx)
640+
s.Require().NoError(err)
641+
642+
markTrue := true
643+
_, err = s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{
644+
WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID,
645+
RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "ml-deleted-test", MarkAsLatest: &markTrue,
646+
})
647+
// The pre-tx lookup won't find the soft-deleted version, so it creates a new one — this is fine
648+
s.Require().NoError(err)
649+
})
650+
604651
s.T().Run("mark-as-latest false on existing version — no promotion change", func(_ *testing.T) {
605652
// Create two versions — v2 is latest
606653
_, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{

app/controlplane/pkg/data/workflowrun.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,22 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC
9898
}
9999
versionCreated = true
100100
} else if opts.MarkAsLatest != nil && *opts.MarkAsLatest {
101-
if !version.Prerelease {
101+
// Re-read version inside the transaction to avoid promoting a concurrently released version
102+
fresh, err := tx.ProjectVersion.Query().
103+
Where(projectversion.ID(version.ID), projectversion.ProjectID(wf.ProjectID), projectversion.DeletedAtIsNil()).
104+
Only(ctx)
105+
if err != nil {
106+
if ent.IsNotFound(err) {
107+
return biz.NewErrNotFound("Version")
108+
}
109+
return fmt.Errorf("loading version for promotion: %w", err)
110+
}
111+
112+
if !fresh.Prerelease {
102113
return biz.NewErrValidationStr("cannot promote a released version to latest")
103114
}
104115

105-
if err := promoteVersionToLatestWithTx(ctx, tx, wf.ProjectID, version.ID); err != nil {
116+
if err := promoteVersionToLatestWithTx(ctx, tx, wf.ProjectID, fresh.ID); err != nil {
106117
return fmt.Errorf("promoting version to latest: %w", err)
107118
}
108119
}

0 commit comments

Comments
 (0)