This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 4672ecbc7 fix(project): add transactional plugin deletion lifecycle
hook (#9081) (#9091)
4672ecbc7 is described below
commit 4672ecbc7d24671cc35bf1f7bf954d5caef98cd2
Author: Muhammad Muzamil kaleem <[email protected]>
AuthorDate: Thu Sep 17 08:06:52 2026 +0500
fix(project): add transactional plugin deletion lifecycle hook (#9081)
(#9091)
Project deletion deletes core DevLake records directly without exposing
a lifecycle hook for plugins that maintain project-scoped state.
While project renames provide a lifecycle extension through
ProjectService.RenameProject, there was no equivalent hook for project
deletion.
This commit:
- Introduces optional plugin.ProjectDeleteHook in core/plugin
- Traverses registered plugins via plugin.TraversalPlugin during
DeleteProject
- Executes hooks, blueprint cleanup, and project deletion inside a single
transaction
- Provides BlueprintManager.DeleteBlueprintInTransaction to participate in
caller transaction
- Adds comprehensive unit tests covering hook dispatch, veto rollback, and
atomic deletion
Closes #9081
Signed-off-by: Muzamil Kaleem <[email protected]>
---
backend/core/plugin/plugin_blueprint.go | 18 ++
.../pluginhelper/services/blueprint_helper.go | 25 ++-
.../pluginhelper/services/blueprint_helper_test.go | 54 ++++++
backend/server/services/project.go | 46 +++--
backend/server/services/project_test.go | 208 +++++++++++++++++++++
5 files changed, 334 insertions(+), 17 deletions(-)
diff --git a/backend/core/plugin/plugin_blueprint.go
b/backend/core/plugin/plugin_blueprint.go
index 68ea49386..2fa43e4f2 100644
--- a/backend/core/plugin/plugin_blueprint.go
+++ b/backend/core/plugin/plugin_blueprint.go
@@ -20,6 +20,7 @@ package plugin
import (
"encoding/json"
+ "github.com/apache/devlake/core/dal"
"github.com/apache/devlake/core/errors"
"github.com/apache/devlake/core/models"
)
@@ -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
diff --git a/backend/helpers/pluginhelper/services/blueprint_helper.go
b/backend/helpers/pluginhelper/services/blueprint_helper.go
index b25769179..7aa280151 100644
--- a/backend/helpers/pluginhelper/services/blueprint_helper.go
+++ b/backend/helpers/pluginhelper/services/blueprint_helper.go
@@ -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
}
@@ -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 {
diff --git a/backend/helpers/pluginhelper/services/blueprint_helper_test.go
b/backend/helpers/pluginhelper/services/blueprint_helper_test.go
new file mode 100644
index 000000000..2c7be7651
--- /dev/null
+++ b/backend/helpers/pluginhelper/services/blueprint_helper_test.go
@@ -0,0 +1,54 @@
+/*
+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/devlake/core/errors"
+ mockdal "github.com/apache/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 := mockdal.NewTransaction(t)
+ tx.On("Delete", mock.Anything, mock.Anything).Return(nil)
+ tx.On("Delete", mock.Anything).Return(nil)
+
+ manager := &BlueprintManager{}
+ err := manager.DeleteBlueprintInTransaction(tx, 42)
+
+ assert.NoError(t, err)
+ assert.Len(t, tx.Calls, 4)
+ })
+
+ t.Run("returns dependent deletion failures", func(t *testing.T) {
+ expected := errors.Default.New("unable to delete blueprint")
+ tx := mockdal.NewTransaction(t)
+ tx.On("Delete",
mock.AnythingOfType("*models.Blueprint")).Return(expected).Once()
+ tx.On("Delete", mock.Anything, mock.Anything).Return(nil)
+
+ manager := &BlueprintManager{}
+ err := manager.DeleteBlueprintInTransaction(tx, 42)
+
+ assert.ErrorIs(t, err, expected)
+ assert.Len(t, tx.Calls, 2)
+ })
+}
diff --git a/backend/server/services/project.go
b/backend/server/services/project.go
index 3fb8d4f05..acf4abe57 100644
--- a/backend/server/services/project.go
+++ b/backend/server/services/project.go
@@ -28,6 +28,7 @@ import (
"github.com/apache/devlake/core/errors"
"github.com/apache/devlake/core/models"
"github.com/apache/devlake/core/models/domainlayer/crossdomain"
+ "github.com/apache/devlake/core/plugin"
helper "github.com/apache/devlake/helpers/pluginhelper/api"
)
@@ -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)
}
@@ -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 {
@@ -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")
@@ -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
}
diff --git a/backend/server/services/project_test.go
b/backend/server/services/project_test.go
new file mode 100644
index 000000000..ae2e83e3c
--- /dev/null
+++ b/backend/server/services/project_test.go
@@ -0,0 +1,208 @@
+/*
+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 (
+ "fmt"
+ "testing"
+
+ "github.com/apache/devlake/core/dal"
+ "github.com/apache/devlake/core/errors"
+ "github.com/apache/devlake/core/models"
+ "github.com/apache/devlake/core/plugin"
+ blueprintservices
"github.com/apache/devlake/helpers/pluginhelper/services"
+ mockdal "github.com/apache/devlake/mocks/core/dal"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+)
+
+type testOrdinaryPlugin struct {
+ name string
+}
+
+func (p *testOrdinaryPlugin) Description() string { return "test ordinary
plugin" }
+func (p *testOrdinaryPlugin) RootPkgPath() string { return
"plugins/test_ordinary" }
+func (p *testOrdinaryPlugin) Name() string { return p.name }
+
+type testHookPlugin struct {
+ name string
+ deleteCalls []struct {
+ tx dal.Transaction
+ projectName string
+ }
+ deleteErr errors.Error
+}
+
+func (p *testHookPlugin) Description() string { return "test hook plugin" }
+func (p *testHookPlugin) RootPkgPath() string { return "plugins/test_hook" }
+func (p *testHookPlugin) Name() string { return p.name }
+func (p *testHookPlugin) BeforeDeleteProject(tx dal.Transaction, projectName
string) errors.Error {
+ p.deleteCalls = append(p.deleteCalls, struct {
+ tx dal.Transaction
+ projectName string
+ }{tx: tx, projectName: projectName})
+ return p.deleteErr
+}
+
+func registerProjectDeleteTestPlugin(t *testing.T, testPlugin
plugin.PluginMeta) {
+ t.Helper()
+ assert.NoError(t, plugin.RegisterPlugin(testPlugin.Name(), testPlugin))
+ t.Cleanup(func() {
+ delete(plugin.AllPlugins(), testPlugin.Name())
+ })
+}
+
+func withProjectTestDatabase(t *testing.T, testDB dal.Dal) {
+ t.Helper()
+ previousDB, previousManager := db, bpManager
+ db = testDB
+ bpManager = blueprintservices.NewBlueprintManager(testDB)
+ t.Cleanup(func() {
+ db = previousDB
+ bpManager = previousManager
+ })
+}
+
+func TestRunProjectDeleteHooks(t *testing.T) {
+ t.Run("skips ordinary plugins without ProjectDeleteHook", func(t
*testing.T) {
+ ordinary := &testOrdinaryPlugin{name: "test-ordinary-skip"}
+ registerProjectDeleteTestPlugin(t, ordinary)
+
+ tx := mockdal.NewTransaction(t)
+ assert.NoError(t, runProjectDeleteHooks(tx, "test-project"))
+ })
+
+ t.Run("invokes implementing plugins with exact transaction and project
name", func(t *testing.T) {
+ hook := &testHookPlugin{name: "test-hook-invoke"}
+ registerProjectDeleteTestPlugin(t, hook)
+
+ tx := mockdal.NewTransaction(t)
+ assert.NoError(t, runProjectDeleteHooks(tx, "test-project"))
+
+ assert.Equal(t, 1, len(hook.deleteCalls))
+ assert.Equal(t, tx, hook.deleteCalls[0].tx)
+ assert.Equal(t, "test-project", hook.deleteCalls[0].projectName)
+ })
+
+ t.Run("returns wrapped error on hook veto", func(t *testing.T) {
+ expectedErr := errors.Default.New("project delete vetoed by
plugin")
+ hook := &testHookPlugin{
+ name: "test-hook-veto",
+ deleteErr: expectedErr,
+ }
+ registerProjectDeleteTestPlugin(t, hook)
+
+ tx := mockdal.NewTransaction(t)
+ err := runProjectDeleteHooks(tx, "test-project")
+
+ assert.Error(t, err)
+ assert.ErrorIs(t, err, expectedErr)
+ assert.Contains(t, err.Error(), fmt.Sprintf("error executing
delete hook for plugin %s", hook.Name()))
+ })
+}
+
+func TestDeleteProject_RollsBackOnDeleteHookVeto(t *testing.T) {
+ hookErr := errors.Default.New("hook rejection")
+ hook := &testHookPlugin{
+ name: "test-hook-rollback-veto",
+ deleteErr: hookErr,
+ }
+ registerProjectDeleteTestPlugin(t, hook)
+
+ tx := mockdal.NewTransaction(t)
+ notFound := errors.NotFound.New("blueprint not found")
+ mockDB := mockdal.NewDal(t)
+ mockDB.On("First", mock.Anything, mock.Anything).Return(nil).Once()
+ mockDB.On("First", mock.Anything, mock.Anything).Return(notFound).Once()
+ mockDB.On("IsErrorNotFound", mock.Anything).Return(true).Twice()
+ mockDB.On("Begin").Return(tx).Once()
+ tx.On("Rollback").Return(nil).Once()
+ withProjectTestDatabase(t, mockDB)
+
+ err := DeleteProject("project-veto")
+
+ assert.Error(t, err)
+ assert.ErrorIs(t, err, hookErr)
+ assert.Contains(t, err.Error(), fmt.Sprintf("error executing delete
hook for plugin %s", hook.Name()))
+ assert.Equal(t, 1, len(hook.deleteCalls))
+ assert.Equal(t, "project-veto", hook.deleteCalls[0].projectName)
+ tx.AssertNotCalled(t, "Commit")
+}
+
+func TestDeleteProject_RollsBackOnBlueprintDeletionFailure(t *testing.T) {
+ expectedErr := errors.Default.New("unable to delete blueprint labels")
+ tx := mockdal.NewTransaction(t)
+ tx.On("First", mock.Anything, mock.Anything).Run(func(args
mock.Arguments) {
+ args.Get(0).(*models.Blueprint).ID = 42
+ }).Return(nil).Once()
+ tx.On("Delete", mock.Anything, mock.Anything).Return(expectedErr).Once()
+ tx.On("Rollback").Return(nil).Once()
+
+ mockDB := mockdal.NewDal(t)
+ mockDB.On("First", mock.Anything, mock.Anything).Run(func(args
mock.Arguments) {
+ if blueprint, ok := args.Get(0).(*models.Blueprint); ok {
+ blueprint.ID = 42
+ }
+ }).Return(nil).Twice()
+ mockDB.On("Pluck", mock.Anything, mock.Anything,
mock.Anything).Return(nil).Once()
+ mockDB.On("All", mock.Anything, mock.Anything).Return(nil)
+ mockDB.On("Count", mock.Anything).Return(int64(0), nil).Once()
+ mockDB.On("Begin").Return(tx).Once()
+ withProjectTestDatabase(t, mockDB)
+
+ err := DeleteProject("project-blueprint-failure")
+
+ assert.ErrorIs(t, err, expectedErr)
+ tx.AssertNotCalled(t, "Commit")
+}
+
+func TestDeleteProject_SuccessfulDeletionInSingleTransaction(t *testing.T) {
+ hook := &testHookPlugin{
+ name: "test-hook-success",
+ }
+ registerProjectDeleteTestPlugin(t, hook)
+
+ tx := mockdal.NewTransaction(t)
+ tx.On("First", mock.Anything, mock.Anything).Run(func(args
mock.Arguments) {
+ args.Get(0).(*models.Blueprint).ID = 42
+ }).Return(nil).Once()
+ tx.On("Delete", mock.Anything, mock.Anything).Return(nil)
+ tx.On("Delete", mock.Anything).Return(nil)
+ tx.On("Commit").Return(nil).Once()
+
+ mockDB := mockdal.NewDal(t)
+ mockDB.On("First", mock.Anything, mock.Anything).Run(func(args
mock.Arguments) {
+ if blueprint, ok := args.Get(0).(*models.Blueprint); ok {
+ blueprint.ID = 42
+ }
+ }).Return(nil).Twice()
+ mockDB.On("Pluck", mock.Anything, mock.Anything,
mock.Anything).Return(nil).Once()
+ mockDB.On("All", mock.Anything, mock.Anything).Return(nil)
+ mockDB.On("Count", mock.Anything).Return(int64(0), nil).Once()
+ mockDB.On("Begin").Return(tx).Once()
+ withProjectTestDatabase(t, mockDB)
+
+ err := DeleteProject("project-success")
+
+ assert.NoError(t, err)
+ assert.Equal(t, 1, len(hook.deleteCalls))
+ assert.Equal(t, tx, hook.deleteCalls[0].tx)
+ assert.Equal(t, "project-success", hook.deleteCalls[0].projectName)
+ assert.Len(t, tx.Calls, 11)
+ tx.AssertNotCalled(t, "Rollback")
+}