Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions backend/core/plugin/plugin_blueprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package plugin
import (
"encoding/json"

"github.com/apache/incubator-devlake/core/dal"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/models"
)
Expand Down Expand Up @@ -92,6 +93,23 @@ type ProjectMapper interface {
MapProject(projectName string, scopes []Scope) (models.PipelinePlan, errors.Error)
}

// ProjectDeleteHook is implemented by plugins that persist state whose
// lifecycle is tied to a DevLake project.
//
// BeforeDeleteProject is called inside the core deletion transaction before
// the project's core records are deleted. Returning an error aborts deletion.
//
// Contract:
// - Database mutations must use only the supplied transaction.
// - The plugin must not commit or roll back the transaction.
// - External side effects cannot be rolled back by the database transaction.
// - Plugins must not depend on hook execution order across plugins.
// Plugins that do not maintain project-scoped state do not need to implement
// this interface.
type ProjectDeleteHook interface {
BeforeDeleteProject(tx dal.Transaction, projectName string) errors.Error
}

// CompositeDataSourcePluginBlueprintV200 is for unit test
type CompositeDataSourcePluginBlueprintV200 interface {
PluginMeta
Expand Down
25 changes: 21 additions & 4 deletions backend/helpers/pluginhelper/services/blueprint_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,18 @@ func (b *BlueprintManager) DeleteBlueprint(id uint64) errors.Error {
}
}
}()
err = tx.Delete(&models.BlueprintLabel{}, dal.Where("blueprint_id = ?", id))
err = b.DeleteBlueprintInTransaction(tx, id)
if err != nil {
return err
}
err = tx.Commit()
return err
}

// DeleteBlueprintInTransaction removes a blueprint and its dependent records using
// the caller's transaction. The caller owns commit and rollback.
func (b *BlueprintManager) DeleteBlueprintInTransaction(tx dal.Transaction, id uint64) errors.Error {
err := tx.Delete(&models.BlueprintLabel{}, dal.Where("blueprint_id = ?", id))
if err != nil {
return err
}
Expand All @@ -274,9 +285,15 @@ func (b *BlueprintManager) DeleteBlueprint(id uint64) errors.Error {
if err != nil {
return err
}
errors.Must(tx.Delete(&models.BlueprintConnection{}, dal.Where("blueprint_id = ?", id)))
errors.Must(tx.Delete(&models.BlueprintScope{}, dal.Where("blueprint_id = ?", id)))
return tx.Commit()
err = tx.Delete(&models.BlueprintConnection{}, dal.Where("blueprint_id = ?", id))
if err != nil {
return err
}
err = tx.Delete(&models.BlueprintScope{}, dal.Where("blueprint_id = ?", id))
if err != nil {
return err
}
return nil
}

func (b *BlueprintManager) fillBlueprintDetail(blueprint *models.Blueprint) errors.Error {
Expand Down
51 changes: 51 additions & 0 deletions backend/helpers/pluginhelper/services/blueprint_helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package services

import (
"testing"

"github.com/apache/incubator-devlake/core/errors"
dalmocks "github.com/apache/incubator-devlake/mocks/core/dal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

func TestDeleteBlueprintInTransaction(t *testing.T) {
t.Run("uses the caller transaction for every delete", func(t *testing.T) {
tx := dalmocks.NewTransaction(t)
tx.On("Delete", mock.Anything, mock.Anything).Return(nil).Times(4)

manager := &BlueprintManager{}
err := manager.DeleteBlueprintInTransaction(tx, 42)

assert.NoError(t, err)
})

t.Run("returns dependent deletion failures", func(t *testing.T) {
tx := dalmocks.NewTransaction(t)
expected := errors.Default.New("unable to delete blueprint")
tx.On("Delete", mock.Anything, mock.Anything).Return(nil).Once()
tx.On("Delete", mock.Anything, mock.Anything).Return(expected).Once()

manager := &BlueprintManager{}
err := manager.DeleteBlueprintInTransaction(tx, 42)

assert.ErrorIs(t, err, expected)
})
}
46 changes: 33 additions & 13 deletions backend/server/services/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/models"
"github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain"
"github.com/apache/incubator-devlake/core/plugin"
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
)

Expand Down Expand Up @@ -327,6 +328,9 @@ func thereAreUnfinishedPipelinesUnderProject(projectName string) (bool, errors.E
if err != nil {
return false, err
}
if blueprint == nil {
return false, nil
}
return thereAreUnfinishedPipelinesUnderBlueprint(blueprint.ID)
}

Expand Down Expand Up @@ -366,10 +370,6 @@ func DeleteProject(name string) errors.Error {
if pipelinesAreUnfinished {
return errors.Default.New("There are unfinished pipelines in the current project. It cannot be deleted at this time.")
}
err = deleteProjectBlueprint(name)
if err != nil {
return err
}
tx := db.Begin()
defer func() {
if r := recover(); r != nil || err != nil {
Expand All @@ -379,6 +379,13 @@ func DeleteProject(name string) errors.Error {
}
}
}()
if err = runProjectDeleteHooks(tx, name); err != nil {
return err
}
err = deleteProjectBlueprint(tx, name)
if err != nil {
return err
}
err = tx.Delete(&models.Project{}, dal.Where("name = ?", name))
if err != nil {
return errors.Default.Wrap(err, "error deleting project")
Expand All @@ -399,20 +406,33 @@ func DeleteProject(name string) errors.Error {
if err != nil {
return errors.Default.Wrap(err, "error deleting project Issue metric")
}
return tx.Commit()
err = tx.Commit()
return err
}

func runProjectDeleteHooks(tx dal.Transaction, projectName string) errors.Error {
return plugin.TraversalPlugin(func(name string, pluginInst plugin.PluginMeta) errors.Error {
if hook, ok := pluginInst.(plugin.ProjectDeleteHook); ok {
if err := hook.BeforeDeleteProject(tx, projectName); err != nil {
return errors.Default.Wrap(err, fmt.Sprintf("error executing delete hook for plugin %s", name))
}
}
return nil
})
}

func deleteProjectBlueprint(projectName string) errors.Error {
bp, err := bpManager.GetDbBlueprintByProjectName(projectName)
func deleteProjectBlueprint(tx dal.Transaction, projectName string) errors.Error {
bp := &models.Blueprint{}
err := tx.First(bp, dal.Where("project_name = ?", projectName))
if err != nil {
if !db.IsErrorNotFound(err) {
if !tx.IsErrorNotFound(err) {
return errors.Default.Wrap(err, fmt.Sprintf("error finding blueprint associated with project %s", projectName))
}
} else {
err = bpManager.DeleteBlueprint(bp.ID)
if err != nil {
return errors.Default.Wrap(err, fmt.Sprintf("error deleting blueprint associated with project %s", projectName))
}
return nil
}
err = bpManager.DeleteBlueprintInTransaction(tx, bp.ID)
if err != nil {
return errors.Default.Wrap(err, fmt.Sprintf("error deleting blueprint associated with project %s", projectName))
}
return nil
}
Expand Down
Loading