From 05412186e6d3e849cd51636288c04ba372a0d970 Mon Sep 17 00:00:00 2001 From: MoChengqian <2972013548@qq.com> Date: Thu, 21 May 2026 16:49:01 +0800 Subject: [PATCH 01/30] chore(store): add ListResources + align gorm/memory empty-index semantics --- pkg/core/manager/manager.go | 10 +++ pkg/core/manager/manager_helper.go | 18 ++++ pkg/core/manager/manager_test.go | 124 ++++++++++++++++++++++++++ pkg/core/store/store.go | 2 + pkg/store/dbcommon/gorm_store.go | 22 ++++- pkg/store/dbcommon/gorm_store_test.go | 36 +++++++- pkg/store/memory/store.go | 16 ++++ pkg/store/memory/store_test.go | 34 +++++++ 8 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 pkg/core/manager/manager_test.go diff --git a/pkg/core/manager/manager.go b/pkg/core/manager/manager.go index 3e4ce0942..27fc14697 100644 --- a/pkg/core/manager/manager.go +++ b/pkg/core/manager/manager.go @@ -33,6 +33,8 @@ type ReadOnlyResourceManager interface { GetByKey(rk model.ResourceKind, key string) (r model.Resource, exist bool, err error) // GetByKeys returns the resources with the given resource keys GetByKeys(rk model.ResourceKind, keys []string) ([]model.Resource, error) + // List returns all resources for the given resource kind. + List(rk model.ResourceKind) ([]model.Resource, error) // ListByIndexes returns the resources with the given index conditions ListByIndexes(rk model.ResourceKind, indexes []index.IndexCondition) ([]model.Resource, error) // PageListByIndexes page list the resources with the given index conditions @@ -98,6 +100,14 @@ func (rm *resourcesManager) GetByKeys(rk model.ResourceKind, keys []string) ([]m return resources, nil } +func (rm *resourcesManager) List(rk model.ResourceKind) ([]model.Resource, error) { + rs, err := rm.storeRouter.ResourceKindRoute(rk) + if err != nil { + return nil, err + } + return rs.ListResources() +} + func (rm *resourcesManager) ListByIndexes(rk model.ResourceKind, indexes []index.IndexCondition) ([]model.Resource, error) { rs, err := rm.storeRouter.ResourceKindRoute(rk) if err != nil { diff --git a/pkg/core/manager/manager_helper.go b/pkg/core/manager/manager_helper.go index 48a333631..e3990588d 100644 --- a/pkg/core/manager/manager_helper.go +++ b/pkg/core/manager/manager_helper.go @@ -59,6 +59,24 @@ func GetByKeys[T model.Resource](rm ReadOnlyResourceManager, rk model.ResourceKi return typedResources, nil } +func List[T model.Resource](rm ReadOnlyResourceManager, rk model.ResourceKind) ([]T, error) { + resources, err := rm.List(rk) + if err != nil { + return nil, err + } + + typedResources := make([]T, len(resources)) + for i, resource := range resources { + typedResource, ok := resource.(T) + if !ok { + return nil, bizerror.NewAssertionError(rk, reflect.TypeOf(typedResource).Name()) + } + typedResources[i] = typedResource + } + + return typedResources, nil +} + // ListByIndexes is a helper function of ResourceManager.ListByIndexes func ListByIndexes[T model.Resource](rm ReadOnlyResourceManager, rk model.ResourceKind, indexes []index.IndexCondition) ([]T, error) { resources, err := rm.ListByIndexes(rk, indexes) diff --git a/pkg/core/manager/manager_test.go b/pkg/core/manager/manager_test.go new file mode 100644 index 000000000..d985be5c8 --- /dev/null +++ b/pkg/core/manager/manager_test.go @@ -0,0 +1,124 @@ +/* + * 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 manager + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/apache/dubbo-admin/pkg/core/governor" + "github.com/apache/dubbo-admin/pkg/core/resource/model" + corestore "github.com/apache/dubbo-admin/pkg/core/store" + memorystore "github.com/apache/dubbo-admin/pkg/store/memory" +) + +func TestResourceManagerListUsesStoreFullList(t *testing.T) { + const kind model.ResourceKind = "TestManagerResource" + st := memorystore.NewMemoryResourceStore(kind) + require.NoError(t, st.Init(nil)) + res1 := &managerTestResource{kind: kind, key: "mesh/rule-b", mesh: "mesh", meta: metav1.ObjectMeta{Name: "rule-b"}} + res2 := &managerTestResource{kind: kind, key: "mesh/rule-a", mesh: "mesh", meta: metav1.ObjectMeta{Name: "rule-a"}} + require.NoError(t, st.Add(res1)) + require.NoError(t, st.Add(res2)) + + rm := NewResourceManager(singleStoreRouter{store: st}, noopGovernorRouter{}) + resources, err := rm.List(kind) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, "mesh/rule-a", resources[0].ResourceKey()) + require.Equal(t, "mesh/rule-b", resources[1].ResourceKey()) +} + +type managerTestResource struct { + kind model.ResourceKind + key string + mesh string + meta metav1.ObjectMeta +} + +func (r *managerTestResource) ResourceMesh() string { + return r.mesh +} + +func (r *managerTestResource) GetObjectKind() schema.ObjectKind { + return schema.EmptyObjectKind +} + +func (r *managerTestResource) DeepCopyObject() runtime.Object { + return r +} + +func (r *managerTestResource) ResourceKind() model.ResourceKind { + return r.kind +} + +func (r *managerTestResource) ResourceKey() string { + return r.key +} + +func (r *managerTestResource) ResourceMeta() metav1.ObjectMeta { + return r.meta +} + +func (r *managerTestResource) ResourceSpec() model.ResourceSpec { + return nil +} + +func (r *managerTestResource) String() string { + return r.key +} + +type singleStoreRouter struct { + store corestore.ResourceStore +} + +func (r singleStoreRouter) ResourceRoute(model.Resource) (corestore.ResourceStore, error) { + return r.store, nil +} + +func (r singleStoreRouter) ResourceKindRoute(model.ResourceKind) (corestore.ResourceStore, error) { + return r.store, nil +} + +type noopGovernorRouter struct{} + +func (noopGovernorRouter) ResourceRoute(model.Resource) (governor.RuleGovernor, error) { + return noopGovernor{}, nil +} + +func (noopGovernorRouter) ResourceMeshRoute(string) (governor.RuleGovernor, error) { + return noopGovernor{}, nil +} + +type noopGovernor struct{} + +func (noopGovernor) CreateRule(model.Resource) error { + return nil +} + +func (noopGovernor) UpdateRule(model.Resource) error { + return nil +} + +func (noopGovernor) DeleteRule(model.Resource) error { + return nil +} diff --git a/pkg/core/store/store.go b/pkg/core/store/store.go index 8923f6577..69053796a 100644 --- a/pkg/core/store/store.go +++ b/pkg/core/store/store.go @@ -34,6 +34,8 @@ import ( // ResourceStore expanded the interface of cache.Indexer and cache.Store type ResourceStore interface { Indexer + // ListResources lists all resources in this store with error propagation. + ListResources() ([]model.Resource, error) // GetByKeys get resources by keys, return list of resource. // if a resource of specified key doesn't exist in the store, resource list will not include it GetByKeys(keys []string) ([]model.Resource, error) diff --git a/pkg/store/dbcommon/gorm_store.go b/pkg/store/dbcommon/gorm_store.go index cedc20144..1a0819727 100644 --- a/pkg/store/dbcommon/gorm_store.go +++ b/pkg/store/dbcommon/gorm_store.go @@ -267,6 +267,26 @@ func (gs *GormStore) List() []interface{} { return result } +func (gs *GormStore) ListResources() ([]model.Resource, error) { + var models []ResourceModel + db := gs.pool.GetDB() + if err := db.Scopes(TableScope(gs.kind.ToString())).Model(&ResourceModel{}). + Order("resource_key ASC"). + Find(&models).Error; err != nil { + return nil, err + } + + resources := make([]model.Resource, 0, len(models)) + for _, m := range models { + resource, err := m.ToResource() + if err != nil { + return nil, err + } + resources = append(resources, resource) + } + return resources, nil +} + // ListKeys returns all resource keys of the configured kind from the database func (gs *GormStore) ListKeys() []string { var keys []string @@ -594,7 +614,7 @@ func (gs *GormStore) findByIndex(indexName, indexedValue string) ([]interface{}, func (gs *GormStore) getKeysByIndexes(indexes []index.IndexCondition) ([]string, error) { if len(indexes) == 0 { - return gs.ListKeys(), nil + return []string{}, nil } var keySet map[string]struct{} diff --git a/pkg/store/dbcommon/gorm_store_test.go b/pkg/store/dbcommon/gorm_store_test.go index b1dd1b8c6..d6b23b706 100644 --- a/pkg/store/dbcommon/gorm_store_test.go +++ b/pkg/store/dbcommon/gorm_store_test.go @@ -802,10 +802,42 @@ func TestGormStore_ListByIndexesEmpty(t *testing.T) { err = store.Add(mockRes) require.NoError(t, err) - // List with empty indexes should return all resources + // Empty index conditions preserve memory-store semantics: no indexed query means no results. resources, err := store.ListByIndexes([]index.IndexCondition{}) assert.NoError(t, err) - assert.Len(t, resources, 1) + assert.Empty(t, resources) +} + +func TestGormStore_ListResourcesSorted(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + err := store.Init(nil) + require.NoError(t, err) + + mockRes1 := &mockResource{ + Kind: "TestResource", + Key: "mesh/test-key-2", + Mesh: "mesh", + Meta: metav1.ObjectMeta{Name: "test-resource-2"}, + } + mockRes2 := &mockResource{ + Kind: "TestResource", + Key: "mesh/test-key-1", + Mesh: "mesh", + Meta: metav1.ObjectMeta{Name: "test-resource-1"}, + } + + err = store.Add(mockRes1) + require.NoError(t, err) + err = store.Add(mockRes2) + require.NoError(t, err) + + resources, err := store.ListResources() + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, "mesh/test-key-1", resources[0].ResourceKey()) + assert.Equal(t, "mesh/test-key-2", resources[1].ResourceKey()) } func TestGormStore_PageListByIndexes(t *testing.T) { diff --git a/pkg/store/memory/store.go b/pkg/store/memory/store.go index 0b392bd58..bcdb7bd45 100644 --- a/pkg/store/memory/store.go +++ b/pkg/store/memory/store.go @@ -121,6 +121,22 @@ func (rs *resourceStore) List() []interface{} { return rs.storeProxy.List() } +func (rs *resourceStore) ListResources() ([]coremodel.Resource, error) { + items := rs.storeProxy.List() + resources := make([]coremodel.Resource, 0, len(items)) + for _, item := range items { + res, ok := item.(coremodel.Resource) + if !ok { + return nil, bizerror.NewAssertionError("Resource", reflect.TypeOf(item).Name()) + } + resources = append(resources, res) + } + slice.SortBy(resources, func(r1 coremodel.Resource, r2 coremodel.Resource) bool { + return r1.ResourceKey() < r2.ResourceKey() + }) + return resources, nil +} + func (rs *resourceStore) ListKeys() []string { return rs.storeProxy.ListKeys() } diff --git a/pkg/store/memory/store_test.go b/pkg/store/memory/store_test.go index 8ad401995..9e251ccbb 100644 --- a/pkg/store/memory/store_test.go +++ b/pkg/store/memory/store_test.go @@ -227,6 +227,40 @@ func TestResourceStore_List(t *testing.T) { assert.Contains(t, list, mockRes2) } +func TestResourceStore_ListResourcesSortedAndEmptyIndexes(t *testing.T) { + store := NewMemoryResourceStore("TestResource") + err := store.Init(nil) + assert.NoError(t, err) + + mockRes1 := &mockResource{ + kind: "TestResource", + key: "mesh/test-key-2", + mesh: "mesh", + meta: metav1.ObjectMeta{Name: "test-resource-2"}, + } + mockRes2 := &mockResource{ + kind: "TestResource", + key: "mesh/test-key-1", + mesh: "mesh", + meta: metav1.ObjectMeta{Name: "test-resource-1"}, + } + + err = store.Add(mockRes1) + assert.NoError(t, err) + err = store.Add(mockRes2) + assert.NoError(t, err) + + resources, err := store.ListResources() + assert.NoError(t, err) + assert.Len(t, resources, 2) + assert.Equal(t, "mesh/test-key-1", resources[0].ResourceKey()) + assert.Equal(t, "mesh/test-key-2", resources[1].ResourceKey()) + + indexed, err := store.ListByIndexes([]index.IndexCondition{}) + assert.NoError(t, err) + assert.Empty(t, indexed) +} + func TestResourceStore_ListKeys(t *testing.T) { store := NewMemoryResourceStore("TestResource") err := store.Init(nil) From cec939e2d3701c511e40c2ca20862edb9e892b23 Mon Sep 17 00:00:00 2001 From: MoChengqian <2972013548@qq.com> Date: Thu, 21 May 2026 16:50:06 +0800 Subject: [PATCH 02/30] fix(discovery): nil-guard zk rule delete + emit registry context on events --- pkg/core/discovery/subscriber/zk_config.go | 20 +++- .../discovery/subscriber/zk_config_test.go | 97 +++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 pkg/core/discovery/subscriber/zk_config_test.go diff --git a/pkg/core/discovery/subscriber/zk_config.go b/pkg/core/discovery/subscriber/zk_config.go index 07f9a2620..656584450 100644 --- a/pkg/core/discovery/subscriber/zk_config.go +++ b/pkg/core/discovery/subscriber/zk_config.go @@ -38,6 +38,8 @@ type ZKConfigEventSubscriber struct { storeRouter store.Router } +const sourceRegistryZookeeper = "zookeeper" + func NewZKConfigEventSubscriber(eventEmitter events.Emitter, storeRouter store.Router) *ZKConfigEventSubscriber { return &ZKConfigEventSubscriber{ emitter: eventEmitter, @@ -167,7 +169,9 @@ func processConfigUpsert[T coremodel.Resource]( logger.Errorf("add rule %s to store failed, cause: %s", newRuleRes.ResourceKey(), err.Error()) return err } - emitter.Send(events.NewResourceChangedEvent(cache.Added, nil, newRuleRes)) + emitter.Send(events.NewResourceChangedEventWithContext(cache.Added, nil, newRuleRes, map[string]string{ + "source-registry": sourceRegistryZookeeper, + })) return nil } @@ -184,7 +188,9 @@ func processConfigUpsert[T coremodel.Resource]( return bizerror.NewAssertionError(reflect.TypeOf(oldMetadataRes), oldRes) } - emitter.Send(events.NewResourceChangedEvent(cache.Updated, oldMetadataRes, newRuleRes)) + emitter.Send(events.NewResourceChangedEventWithContext(cache.Updated, oldMetadataRes, newRuleRes, map[string]string{ + "source-registry": sourceRegistryZookeeper, + })) return nil } @@ -194,6 +200,10 @@ func processConfigDelete[T coremodel.Resource]( router store.Router, emitter events.Emitter) error { ruleRes := toRuleRes(configRes.Mesh, configRes.Name, configRes.Spec.NodeData) + if ruleRes == nil { + logger.Warnf("cannot derive rule resource from zk delete event, mesh: %s, nodeName: %s", configRes.Mesh, configRes.Name) + return nil + } st, err := router.ResourceKindRoute(ruleRes.ResourceKind()) if err != nil { logger.Errorf("get %s store failed, cause: %s", ruleRes.ResourceKind(), err.Error()) @@ -205,7 +215,7 @@ func processConfigDelete[T coremodel.Resource]( return err } if !exists { - logger.Infof("rule %s not exists in store, skipped deleting", ruleRes.ResourceKey()) + logger.Warnf("rule %s not exists in store for zk delete event, skipped deleting; node data may be unavailable", ruleRes.ResourceKey()) return nil } oldRuleRes, ok := oldRes.(T) @@ -217,6 +227,8 @@ func processConfigDelete[T coremodel.Resource]( logger.Errorf("delete rule %s from store failed, cause: %s", ruleRes.ResourceKey(), err.Error()) return err } - emitter.Send(events.NewResourceChangedEvent(cache.Deleted, oldRuleRes, nil)) + emitter.Send(events.NewResourceChangedEventWithContext(cache.Deleted, oldRuleRes, nil, map[string]string{ + "source-registry": sourceRegistryZookeeper, + })) return nil } diff --git a/pkg/core/discovery/subscriber/zk_config_test.go b/pkg/core/discovery/subscriber/zk_config_test.go new file mode 100644 index 000000000..42e880efb --- /dev/null +++ b/pkg/core/discovery/subscriber/zk_config_test.go @@ -0,0 +1,97 @@ +/* + * 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 subscriber + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/cache" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/events" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/resource/model" + corestore "github.com/apache/dubbo-admin/pkg/core/store" + memorystore "github.com/apache/dubbo-admin/pkg/store/memory" +) + +func TestZKConfigDeleteUsesLocalOldRule(t *testing.T) { + ruleStore := memorystore.NewMemoryResourceStore(meshresource.TagRouteKind) + require.NoError(t, ruleStore.Init(nil)) + oldRule := meshresource.NewTagRouteResourceWithAttributes("demo.tag-router", "mesh") + oldRule.Spec = &meshproto.TagRoute{Key: "demo", Priority: 7} + require.NoError(t, ruleStore.Add(oldRule)) + + emitter := &capturingEmitter{} + sub := NewZKConfigEventSubscriber(emitter, singleStoreRouter{store: ruleStore}) + zkConfig := newZKRuleConfig("demo.tag-router", "mesh", "") + + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Deleted, zkConfig, nil))) + + _, exists, err := ruleStore.GetByKey(oldRule.ResourceKey()) + require.NoError(t, err) + require.False(t, exists) + require.Len(t, emitter.events, 1) + require.Equal(t, cache.Deleted, emitter.events[0].Type()) + require.Equal(t, oldRule.ResourceKey(), emitter.events[0].OldObj().ResourceKey()) + require.Nil(t, emitter.events[0].NewObj()) +} + +func TestZKConfigDeleteMissingLocalRuleIsNoop(t *testing.T) { + ruleStore := memorystore.NewMemoryResourceStore(meshresource.TagRouteKind) + require.NoError(t, ruleStore.Init(nil)) + + emitter := &capturingEmitter{} + sub := NewZKConfigEventSubscriber(emitter, singleStoreRouter{store: ruleStore}) + zkConfig := newZKRuleConfig("demo.tag-router", "mesh", "") + + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Deleted, zkConfig, nil))) + require.Empty(t, emitter.events) +} + +type singleStoreRouter struct { + store corestore.ResourceStore +} + +func (r singleStoreRouter) ResourceRoute(model.Resource) (corestore.ResourceStore, error) { + return r.store, nil +} + +func (r singleStoreRouter) ResourceKindRoute(model.ResourceKind) (corestore.ResourceStore, error) { + if r.store == nil { + return nil, fmt.Errorf("no store configured") + } + return r.store, nil +} + +type capturingEmitter struct { + events []events.Event +} + +func (e *capturingEmitter) Send(event events.Event) { + e.events = append(e.events, event) +} + +func newZKRuleConfig(name, mesh, nodeData string) *meshresource.ZKConfigResource { + res := meshresource.NewZKConfigResourceWithAttributes(name, mesh) + res.Spec.NodeName = name + res.Spec.NodeData = nodeData + return res +} From 366033e634c19858a9900db0dec549cfe5936094 Mon Sep 17 00:00:00 2001 From: MoChengqian <2972013548@qq.com> Date: Thu, 21 May 2026 16:51:03 +0800 Subject: [PATCH 03/30] feat(versioning): backend immutable release ledger for traffic rules --- pkg/config/app/admin.go | 52 ++- pkg/config/app/admin_test.go | 44 ++ pkg/config/versioning/config.go | 96 +++++ pkg/config/versioning/config_test.go | 53 +++ pkg/console/context/context.go | 14 + pkg/console/handler/condition_rule.go | 26 +- pkg/console/handler/configurator_rule.go | 24 +- pkg/console/handler/rule_version.go | 170 ++++++++ pkg/console/handler/tag_rule.go | 24 +- pkg/console/router/router.go | 13 + pkg/console/service/condition_rule.go | 54 ++- pkg/console/service/configurator_rule.go | 54 ++- pkg/console/service/rule_version.go | 93 ++++ pkg/console/service/tag_rule.go | 55 ++- pkg/core/bootstrap/bootstrap.go | 2 + pkg/core/versioning/component.go | 148 +++++++ pkg/core/versioning/hint.go | 95 ++++ pkg/core/versioning/normalize.go | 77 ++++ pkg/core/versioning/service.go | 269 ++++++++++++ pkg/core/versioning/store.go | 211 +++++++++ pkg/core/versioning/store_gorm.go | 224 ++++++++++ pkg/core/versioning/store_gorm_test.go | 184 ++++++++ pkg/core/versioning/subscriber.go | 242 +++++++++++ pkg/core/versioning/types.go | 122 ++++++ pkg/core/versioning/versioning_test.go | 528 +++++++++++++++++++++++ 25 files changed, 2822 insertions(+), 52 deletions(-) create mode 100644 pkg/config/app/admin_test.go create mode 100644 pkg/config/versioning/config.go create mode 100644 pkg/config/versioning/config_test.go create mode 100644 pkg/console/handler/rule_version.go create mode 100644 pkg/console/service/rule_version.go create mode 100644 pkg/core/versioning/component.go create mode 100644 pkg/core/versioning/hint.go create mode 100644 pkg/core/versioning/normalize.go create mode 100644 pkg/core/versioning/service.go create mode 100644 pkg/core/versioning/store.go create mode 100644 pkg/core/versioning/store_gorm.go create mode 100644 pkg/core/versioning/store_gorm_test.go create mode 100644 pkg/core/versioning/subscriber.go create mode 100644 pkg/core/versioning/types.go create mode 100644 pkg/core/versioning/versioning_test.go diff --git a/pkg/config/app/admin.go b/pkg/config/app/admin.go index 71cf3f95f..1a1970941 100644 --- a/pkg/config/app/admin.go +++ b/pkg/config/app/admin.go @@ -31,6 +31,7 @@ import ( "github.com/apache/dubbo-admin/pkg/config/log" "github.com/apache/dubbo-admin/pkg/config/observability" "github.com/apache/dubbo-admin/pkg/config/store" + "github.com/apache/dubbo-admin/pkg/config/versioning" ) type AdminConfig struct { @@ -51,6 +52,8 @@ type AdminConfig struct { Engine *engine.Config `json:"engine" yaml:"engine"` // EventBus configuration EventBus *eventbus.Config `json:"eventBus,omitempty" yaml:"eventBus,omitempty"` + // Versioning configuration for governor-managed traffic rules. + Versioning *versioning.Config `json:"versioning,omitempty" yaml:"versioning,omitempty"` } var _ = &AdminConfig{} @@ -65,10 +68,12 @@ var DefaultAdminConfig = func() AdminConfig { Diagnostics: diagnostics.DefaultDiagnosticsConfig(), Console: console.DefaultConsoleConfig(), EventBus: &eventBusCfg, + Versioning: versioning.Default(), } } -func (c AdminConfig) Sanitize() { +func (c *AdminConfig) Sanitize() { + c.ensureDefaults() c.Engine.Sanitize() for _, d := range c.Discovery { d.Sanitize() @@ -78,9 +83,11 @@ func (c AdminConfig) Sanitize() { c.Observability.Sanitize() c.Diagnostics.Sanitize() c.Log.Sanitize() + c.Versioning.Sanitize() } -func (c AdminConfig) PreProcess() error { +func (c *AdminConfig) PreProcess() error { + c.ensureDefaults() discoveryPreProcess := func() error { for _, d := range c.Discovery { if err := d.PreProcess(); err != nil { @@ -97,10 +104,12 @@ func (c AdminConfig) PreProcess() error { c.Observability.PreProcess(), c.Diagnostics.PreProcess(), c.Log.PreProcess(), + c.Versioning.PreProcess(), ) } -func (c AdminConfig) PostProcess() error { +func (c *AdminConfig) PostProcess() error { + c.ensureDefaults() discoveryPostProcess := func() error { for _, d := range c.Discovery { if err := d.PostProcess(); err != nil { @@ -117,10 +126,12 @@ func (c AdminConfig) PostProcess() error { c.Observability.PostProcess(), c.Diagnostics.PostProcess(), c.Log.PostProcess(), + c.Versioning.PostProcess(), ) } -func (c AdminConfig) Validate() error { +func (c *AdminConfig) Validate() error { + c.ensureDefaults() if c.Log == nil { c.Log = log.DefaultLogConfig() } else if err := c.Log.Validate(); err != nil { @@ -171,9 +182,42 @@ func (c AdminConfig) Validate() error { } else if err := c.EventBus.Validate(); err != nil { return bizerror.Wrap(err, bizerror.ConfigError, "event bus config validation failed") } + if c.Versioning == nil { + c.Versioning = versioning.Default() + } else if err := c.Versioning.Validate(); err != nil { + return bizerror.Wrap(err, bizerror.ConfigError, "versioning config validation failed") + } return nil } +func (c *AdminConfig) ensureDefaults() { + if c.Log == nil { + c.Log = log.DefaultLogConfig() + } + if c.Store == nil { + c.Store = store.DefaultStoreConfig() + } + if c.Diagnostics == nil { + c.Diagnostics = diagnostics.DefaultDiagnosticsConfig() + } + if c.Console == nil { + c.Console = console.DefaultConsoleConfig() + } + if c.Observability == nil { + c.Observability = observability.DefaultObservabilityConfig() + } + if c.Engine == nil { + c.Engine = engine.DefaultResourceEngineConfig() + } + if c.EventBus == nil { + cfg := eventbus.Default() + c.EventBus = &cfg + } + if c.Versioning == nil { + c.Versioning = versioning.Default() + } +} + // FindDiscovery finds the DiscoveryConfig by id, returns nil if not found func (c AdminConfig) FindDiscovery(id string) *discovery.Config { for _, d := range c.Discovery { diff --git a/pkg/config/app/admin_test.go b/pkg/config/app/admin_test.go new file mode 100644 index 000000000..c24da1b90 --- /dev/null +++ b/pkg/config/app/admin_test.go @@ -0,0 +1,44 @@ +/* + * 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 app + +import ( + "testing" + + "github.com/apache/dubbo-admin/pkg/config/versioning" + "github.com/stretchr/testify/require" +) + +func TestAdminConfigVersioningDefaultsWhenMissing(t *testing.T) { + cfg := DefaultAdminConfig() + cfg.Versioning = nil + + require.NotPanics(t, func() { + cfg.Sanitize() + }) + require.NotNil(t, cfg.Versioning) + require.Equal(t, versioning.DefaultRollbackWaitMs, cfg.Versioning.RollbackWaitMs) + + cfg.Versioning = nil + require.NoError(t, cfg.PreProcess()) + require.NotNil(t, cfg.Versioning) + + cfg.Versioning = nil + require.NoError(t, cfg.PostProcess()) + require.NotNil(t, cfg.Versioning) +} diff --git a/pkg/config/versioning/config.go b/pkg/config/versioning/config.go new file mode 100644 index 000000000..148f1027c --- /dev/null +++ b/pkg/config/versioning/config.go @@ -0,0 +1,96 @@ +/* + * 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 versioning + +import ( + "encoding/json" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" +) + +const ( + DefaultEnabled = true + DefaultMaxVersionsPerRule = int64(5) + DefaultCoalesceWindowMs = int64(2000) + DefaultAdminHintTTLSec = int64(30) + DefaultRollbackWaitMs = int64(5000) +) + +type Config struct { + Enabled bool `json:"enabled" yaml:"enabled"` + MaxVersionsPerRule int64 `json:"maxVersionsPerRule" yaml:"maxVersionsPerRule"` + CoalesceWindowMs int64 `json:"coalesceWindowMs" yaml:"coalesceWindowMs"` + AdminHintTTLSec int64 `json:"adminHintTTLSec" yaml:"adminHintTTLSec"` + RollbackWaitMs int64 `json:"rollbackWaitTimeoutMs" yaml:"rollbackWaitTimeoutMs"` +} + +func (c *Config) UnmarshalJSON(data []byte) error { + type config Config + defaults := Default() + *c = *defaults + return json.Unmarshal(data, (*config)(c)) +} + +func Default() *Config { + return &Config{ + Enabled: DefaultEnabled, + MaxVersionsPerRule: DefaultMaxVersionsPerRule, + CoalesceWindowMs: DefaultCoalesceWindowMs, + AdminHintTTLSec: DefaultAdminHintTTLSec, + RollbackWaitMs: DefaultRollbackWaitMs, + } +} + +func (c *Config) Sanitize() { + if c.MaxVersionsPerRule <= 0 { + c.MaxVersionsPerRule = DefaultMaxVersionsPerRule + } + if c.CoalesceWindowMs < 0 { + c.CoalesceWindowMs = DefaultCoalesceWindowMs + } + if c.AdminHintTTLSec <= 0 { + c.AdminHintTTLSec = DefaultAdminHintTTLSec + } + if c.RollbackWaitMs < 0 { + c.RollbackWaitMs = DefaultRollbackWaitMs + } +} + +func (c *Config) PreProcess() error { + return nil +} + +func (c *Config) PostProcess() error { + return nil +} + +func (c *Config) Validate() error { + if c.MaxVersionsPerRule <= 0 { + return bizerror.New(bizerror.ConfigError, "versioning.maxVersionsPerRule must be greater than 0") + } + if c.CoalesceWindowMs < 0 { + return bizerror.New(bizerror.ConfigError, "versioning.coalesceWindowMs must be greater than or equal to 0") + } + if c.AdminHintTTLSec <= 0 { + return bizerror.New(bizerror.ConfigError, "versioning.adminHintTTLSec must be greater than 0") + } + if c.RollbackWaitMs < 0 { + return bizerror.New(bizerror.ConfigError, "versioning.rollbackWaitTimeoutMs must be greater than or equal to 0") + } + return nil +} diff --git a/pkg/config/versioning/config_test.go b/pkg/config/versioning/config_test.go new file mode 100644 index 000000000..85fd0b48e --- /dev/null +++ b/pkg/config/versioning/config_test.go @@ -0,0 +1,53 @@ +/* + * 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 versioning + +import ( + "testing" + + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +func TestConfigDefaultsRollbackWaitOnYAMLUnmarshal(t *testing.T) { + var cfg Config + require.NoError(t, yaml.Unmarshal([]byte("enabled: false\n"), &cfg)) + + require.False(t, cfg.Enabled) + require.Equal(t, DefaultMaxVersionsPerRule, cfg.MaxVersionsPerRule) + require.Equal(t, DefaultCoalesceWindowMs, cfg.CoalesceWindowMs) + require.Equal(t, DefaultAdminHintTTLSec, cfg.AdminHintTTLSec) + require.Equal(t, DefaultRollbackWaitMs, cfg.RollbackWaitMs) +} + +func TestConfigValidateRollbackWait(t *testing.T) { + cfg := Default() + cfg.RollbackWaitMs = 0 + require.NoError(t, cfg.Validate()) + + cfg.RollbackWaitMs = -1 + require.ErrorContains(t, cfg.Validate(), "versioning.rollbackWaitTimeoutMs") +} + +func TestConfigSanitizePreservesExplicitZeroRollbackWait(t *testing.T) { + cfg := Default() + cfg.RollbackWaitMs = 0 + cfg.Sanitize() + + require.Equal(t, int64(0), cfg.RollbackWaitMs) +} diff --git a/pkg/console/context/context.go b/pkg/console/context/context.go index f4c64ce5b..593b714cb 100644 --- a/pkg/console/context/context.go +++ b/pkg/console/context/context.go @@ -25,6 +25,7 @@ import ( "github.com/apache/dubbo-admin/pkg/console/counter" "github.com/apache/dubbo-admin/pkg/core/manager" "github.com/apache/dubbo-admin/pkg/core/runtime" + "github.com/apache/dubbo-admin/pkg/core/versioning" ) type Context interface { @@ -35,6 +36,7 @@ type Context interface { AppContext() ctx.Context LockManager() lock.Lock + RuleVersioning() versioning.Service } var _ Context = &context{} @@ -81,3 +83,15 @@ func (c *context) LockManager() lock.Lock { } return distributedLock } + +func (c *context) RuleVersioning() versioning.Service { + comp, err := c.coreRt.GetComponent(versioning.ComponentType) + if err != nil { + return nil + } + versioningComp, ok := comp.(versioning.Component) + if !ok { + return nil + } + return versioningComp.Service() +} diff --git a/pkg/console/handler/condition_rule.go b/pkg/console/handler/condition_rule.go index 653c12e71..7782c3066 100644 --- a/pkg/console/handler/condition_rule.go +++ b/pkg/console/handler/condition_rule.go @@ -94,9 +94,12 @@ func PutConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - - if err := service.UpdateConditionRule(cs, res); err != nil { - util.HandleServiceError(c, err) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err := service.UpdateConditionRuleWithOptions(cs, res, opts); err != nil { + writeVersioningResp(c, nil, err) return } else { c.JSON(http.StatusOK, model.GenConditionRuleToResp(res.Spec)) @@ -118,9 +121,12 @@ func PostConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - - if err := service.CreateConditionRule(cs, res); err != nil { - c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err := service.CreateConditionRuleWithOptions(cs, res, opts); err != nil { + writeVersioningResp(c, nil, err) return } else { c.JSON(http.StatusOK, model.GenConditionRuleToResp(res.Spec)) @@ -137,8 +143,12 @@ func DeleteConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { fmt.Sprintf("ruleName must end with %s", constants.ConditionRuleDotSuffix)))) return } - if err := service.DeleteConditionRule(cs, ruleName, mesh); err != nil { - c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err := service.DeleteConditionRuleWithOptions(cs, ruleName, mesh, opts); err != nil { + writeVersioningResp(c, nil, err) return } c.JSON(http.StatusOK, model.NewSuccessResp("")) diff --git a/pkg/console/handler/configurator_rule.go b/pkg/console/handler/configurator_rule.go index 0b806715b..b3e6e519c 100644 --- a/pkg/console/handler/configurator_rule.go +++ b/pkg/console/handler/configurator_rule.go @@ -105,8 +105,12 @@ func PutConfiguratorWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewBizErrorResp( bizerror.New(bizerror.NotFoundError, fmt.Sprintf("%s not found", ruleName)))) } - if err = service.UpdateConfigurator(ctx, res); err != nil { - c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err = service.UpdateConfiguratorWithOptions(ctx, res, opts); err != nil { + writeVersioningResp(c, nil, err) return } c.JSON(http.StatusOK, model.GenDynamicConfigToResp(res.Spec)) @@ -128,8 +132,12 @@ func PostConfiguratorWithRuleName(ctx consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - if err = service.CreateConfigurator(ctx, res); err != nil { - c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err = service.CreateConfiguratorWithOptions(ctx, res, opts); err != nil { + writeVersioningResp(c, nil, err) return } c.JSON(http.StatusOK, model.GenDynamicConfigToResp(res.Spec)) @@ -146,8 +154,12 @@ func DeleteConfiguratorWithRuleName(ctx consolectx.Context) gin.HandlerFunc { fmt.Sprintf("dynamic config name must end with %s", constants.ConfiguratorRuleDotSuffix)))) return } - if err := service.DeleteConfigurator(ctx, ruleName, mesh); err != nil { - c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err := service.DeleteConfiguratorWithOptions(ctx, ruleName, mesh, opts); err != nil { + writeVersioningResp(c, nil, err) return } c.JSON(http.StatusOK, model.NewSuccessResp("")) diff --git a/pkg/console/handler/rule_version.go b/pkg/console/handler/rule_version.go new file mode 100644 index 000000000..92023ff1a --- /dev/null +++ b/pkg/console/handler/rule_version.go @@ -0,0 +1,170 @@ +/* + * 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 handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + consolectx "github.com/apache/dubbo-admin/pkg/console/context" + "github.com/apache/dubbo-admin/pkg/console/model" + "github.com/apache/dubbo-admin/pkg/console/service" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "github.com/apache/dubbo-admin/pkg/core/versioning" +) + +type rollbackReq struct { + Reason string `json:"reason"` + ExpectedVersionID *int64 `json:"expectedVersionId"` +} + +func ListRuleVersions(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningEnabled(c, cs) { + return + } + resp, err := service.ListRuleVersions(cs, service.RuleKindName{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}) + writeVersioningResp(c, resp, err) + } +} + +func GetRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningEnabled(c, cs) { + return + } + id, ok := parseVersionID(c) + if !ok { + return + } + resp, err := service.GetRuleVersion(cs, service.RuleKindName{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}, id) + writeVersioningResp(c, resp, err) + } +} + +func DiffRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningEnabled(c, cs) { + return + } + id, ok := parseVersionID(c) + if !ok { + return + } + resp, err := service.DiffRuleVersion(cs, service.RuleKindName{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}, id, c.Query("against")) + writeVersioningResp(c, resp, err) + } +} + +func RollbackRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningEnabled(c, cs) { + return + } + id, ok := parseVersionID(c) + if !ok { + return + } + req := rollbackReq{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.InvalidArgument, err.Error()))) + return + } + resp, err := service.RollbackRuleVersion(c.Request.Context(), cs, service.RuleKindName{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}, id, req.Reason, req.ExpectedVersionID, currentUser(c)) + writeVersioningResp(c, resp, err) + } +} + +func parseExpectedVersionID(c *gin.Context) (*int64, bool) { + raw := strings.TrimSpace(c.Query("expectedVersionId")) + if raw == "" { + return nil, true + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.InvalidArgument, "expectedVersionId must be an integer"))) + return nil, false + } + return &id, true +} + +func mutationOptions(c *gin.Context) (service.RuleMutationOptions, bool) { + expected, ok := parseExpectedVersionID(c) + if !ok { + return service.RuleMutationOptions{}, false + } + return service.RuleMutationOptions{ExpectedVersionID: expected, Author: currentUser(c)}, true +} + +func parseVersionID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("versionId"), 10, 64) + if err != nil { + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.InvalidArgument, "versionId must be an integer"))) + return 0, false + } + return id, true +} + +func currentUser(c *gin.Context) string { + session := sessions.Default(c) + if user, ok := session.Get("user").(string); ok && strings.TrimSpace(user) != "" { + return user + } + return "system:unknown" +} + +func ensureVersioningEnabled(c *gin.Context, cs consolectx.Context) bool { + if cs.RuleVersioning() != nil && cs.Config().Versioning != nil && cs.Config().Versioning.Enabled { + return true + } + c.JSON(http.StatusServiceUnavailable, &model.CommonResp{ + Code: "FEATURE_DISABLED", + Message: versioning.ErrFeatureDisabled.Error(), + }) + return false +} + +func writeVersioningResp(c *gin.Context, data any, err error) { + if err == nil { + c.JSON(http.StatusOK, model.NewSuccessResp(data)) + return + } + var conflict *versioning.ConflictError + switch { + case errors.As(err, &conflict): + c.JSON(http.StatusConflict, gin.H{ + "code": "VERSION_CONFLICT", + "message": versioning.ErrVersionConflict.Error(), + "currentVersionId": conflict.CurrentVersionID, + }) + case errors.Is(err, versioning.ErrFeatureDisabled): + c.JSON(http.StatusServiceUnavailable, gin.H{"code": "FEATURE_DISABLED", "message": err.Error()}) + case errors.Is(err, versioning.ErrVersionNotFound): + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.NotFoundError, err.Error()))) + case errors.Is(err, versioning.ErrRollbackToDelete): + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.InvalidArgument, err.Error()))) + default: + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.UnknownError, err.Error()))) + } +} diff --git a/pkg/console/handler/tag_rule.go b/pkg/console/handler/tag_rule.go index a6fe3637c..343f1d216 100644 --- a/pkg/console/handler/tag_rule.go +++ b/pkg/console/handler/tag_rule.go @@ -103,8 +103,12 @@ func PutTagRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } - if err = service.UpdateTagRule(ctx, res); err != nil { - c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err = service.UpdateTagRuleWithOptions(ctx, res, opts); err != nil { + writeVersioningResp(c, nil, err) return } else { c.JSON(http.StatusOK, model.GenTagRouteResp(res.Spec)) @@ -127,8 +131,12 @@ func PostTagRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } - if err = service.CreateTagRule(ctx, res); err != nil { - c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err = service.CreateTagRuleWithOptions(ctx, res, opts); err != nil { + writeVersioningResp(c, nil, err) return } else { c.JSON(http.StatusOK, model.GenTagRouteResp(res.Spec)) @@ -145,8 +153,12 @@ func DeleteTagRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusBadRequest, model.NewBizErrorResp(err)) return } - if err := service.DeleteTagRule(ctx, ruleName, mesh); err != nil { - c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) + opts, ok := mutationOptions(c) + if !ok { + return + } + if err := service.DeleteTagRuleWithOptions(ctx, ruleName, mesh, opts); err != nil { + writeVersioningResp(c, nil, err) return } c.JSON(http.StatusOK, model.NewSuccessResp("")) diff --git a/pkg/console/router/router.go b/pkg/console/router/router.go index 24cd7d44c..971aff4bf 100644 --- a/pkg/console/router/router.go +++ b/pkg/console/router/router.go @@ -22,6 +22,7 @@ import ( consolectx "github.com/apache/dubbo-admin/pkg/console/context" "github.com/apache/dubbo-admin/pkg/console/handler" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" ) func InitRouter(r *gin.Engine, ctx consolectx.Context) { @@ -112,6 +113,10 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { { configuration := router.Group("/configurator") configuration.GET("/search", handler.ConfiguratorSearch(ctx)) + configuration.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.DynamicConfigKind)) + configuration.GET("/:ruleName/versions/:versionId", handler.GetRuleVersion(ctx, meshresource.DynamicConfigKind)) + configuration.GET("/:ruleName/versions/:versionId/diff", handler.DiffRuleVersion(ctx, meshresource.DynamicConfigKind)) + configuration.POST("/:ruleName/versions/:versionId/rollback", handler.RollbackRuleVersion(ctx, meshresource.DynamicConfigKind)) configuration.GET("/:ruleName", handler.GetConfiguratorWithRuleName(ctx)) configuration.PUT("/:ruleName", handler.PutConfiguratorWithRuleName(ctx)) configuration.POST("/:ruleName", handler.PostConfiguratorWithRuleName(ctx)) @@ -121,6 +126,10 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { { conditionRule := router.Group("/condition-rule") conditionRule.GET("/search", handler.ConditionRuleSearch(ctx)) + conditionRule.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.ConditionRouteKind)) + conditionRule.GET("/:ruleName/versions/:versionId", handler.GetRuleVersion(ctx, meshresource.ConditionRouteKind)) + conditionRule.GET("/:ruleName/versions/:versionId/diff", handler.DiffRuleVersion(ctx, meshresource.ConditionRouteKind)) + conditionRule.POST("/:ruleName/versions/:versionId/rollback", handler.RollbackRuleVersion(ctx, meshresource.ConditionRouteKind)) conditionRule.GET("/:ruleName", handler.GetConditionRuleWithRuleName(ctx)) conditionRule.PUT("/:ruleName", handler.PutConditionRuleWithRuleName(ctx)) conditionRule.POST("/:ruleName", handler.PostConditionRuleWithRuleName(ctx)) @@ -130,6 +139,10 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { { tagRule := router.Group("/tag-rule") tagRule.GET("/search", handler.TagRuleSearch(ctx)) + tagRule.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.TagRouteKind)) + tagRule.GET("/:ruleName/versions/:versionId", handler.GetRuleVersion(ctx, meshresource.TagRouteKind)) + tagRule.GET("/:ruleName/versions/:versionId/diff", handler.DiffRuleVersion(ctx, meshresource.TagRouteKind)) + tagRule.POST("/:ruleName/versions/:versionId/rollback", handler.RollbackRuleVersion(ctx, meshresource.TagRouteKind)) tagRule.GET("/:ruleName", handler.GetTagRuleWithRuleName(ctx)) tagRule.PUT("/:ruleName", handler.PutTagRuleWithRuleName(ctx)) tagRule.POST("/:ruleName", handler.PostTagRuleWithRuleName(ctx)) diff --git a/pkg/console/service/condition_rule.go b/pkg/console/service/condition_rule.go index 9fae15940..4b8cd98b6 100644 --- a/pkg/console/service/condition_rule.go +++ b/pkg/console/service/condition_rule.go @@ -31,6 +31,7 @@ import ( meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/core/store/index" + "github.com/apache/dubbo-admin/pkg/core/versioning" ) func SearchConditionRules(ctx context.Context, req *model.SearchConditionRuleReq) (*model.SearchPaginationResult, error) { @@ -108,17 +109,27 @@ func GetConditionRule(ctx context.Context, name string, mesh string) (*meshresou } func UpdateConditionRule(ctx context.Context, res *meshresource.ConditionRouteResource) error { + return UpdateConditionRuleWithOptions(ctx, res, RuleMutationOptions{}) +} + +func UpdateConditionRuleWithOptions(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return updateConditionRuleUnsafe(ctx, res) + return updateConditionRuleUnsafe(ctx, res, opts) } lockKey := lock.BuildConditionRuleLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateConditionRuleUnsafe(ctx, res) + return updateConditionRuleUnsafe(ctx, res, opts) }) } -func updateConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource) error { +func updateConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { + if err := checkExpectedVersion(ctx, RuleKindName{Kind: meshresource.ConditionRouteKind, Mesh: res.Mesh, Name: res.Name}, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationUpdate, opts); err != nil { + return err + } if err := ctx.ResourceManager().Update(res); err != nil { logger.Warnf("update %s condition failed with error: %s", res.Name, err.Error()) return err @@ -127,17 +138,27 @@ func updateConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionR } func CreateConditionRule(ctx context.Context, res *meshresource.ConditionRouteResource) error { + return CreateConditionRuleWithOptions(ctx, res, RuleMutationOptions{}) +} + +func CreateConditionRuleWithOptions(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return createConditionRuleUnsafe(ctx, res) + return createConditionRuleUnsafe(ctx, res, opts) } lockKey := lock.BuildConditionRuleLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createConditionRuleUnsafe(ctx, res) + return createConditionRuleUnsafe(ctx, res, opts) }) } -func createConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource) error { +func createConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { + if err := checkExpectedVersion(ctx, RuleKindName{Kind: meshresource.ConditionRouteKind, Mesh: res.Mesh, Name: res.Name}, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationCreate, opts); err != nil { + return err + } if err := ctx.ResourceManager().Add(res); err != nil { logger.Warnf("create %s condition failed with error: %s", res.Name, err.Error()) return err @@ -146,17 +167,32 @@ func createConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionR } func DeleteConditionRule(ctx context.Context, name string, mesh string) error { + return DeleteConditionRuleWithOptions(ctx, name, mesh, RuleMutationOptions{}) +} + +func DeleteConditionRuleWithOptions(ctx context.Context, name string, mesh string, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return deleteConditionRuleUnsafe(ctx, name, mesh) + return deleteConditionRuleUnsafe(ctx, name, mesh, opts) } lockKey := lock.BuildConditionRuleLockKey(mesh, name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteConditionRuleUnsafe(ctx, name, mesh) + return deleteConditionRuleUnsafe(ctx, name, mesh, opts) }) } -func deleteConditionRuleUnsafe(ctx context.Context, name string, mesh string) error { +func deleteConditionRuleUnsafe(ctx context.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.ConditionRouteKind, Mesh: mesh, Name: name} + res, err := getExistingRule(ctx, kindName) + if err != nil { + return err + } + if err := checkExpectedVersion(ctx, kindName, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationDelete, opts); err != nil { + return err + } if err := ctx.ResourceManager().DeleteByKey(meshresource.ConditionRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { return err } diff --git a/pkg/console/service/configurator_rule.go b/pkg/console/service/configurator_rule.go index 13dd2284d..2d3f48f4d 100644 --- a/pkg/console/service/configurator_rule.go +++ b/pkg/console/service/configurator_rule.go @@ -30,6 +30,7 @@ import ( meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/core/store/index" + "github.com/apache/dubbo-admin/pkg/core/versioning" ) func PageListConfiguratorRule(ctx consolectx.Context, req *model.SearchReq) (*model.SearchPaginationResult, error) { @@ -116,17 +117,27 @@ func GetConfigurator(ctx consolectx.Context, name string, mesh string) (*meshres } func UpdateConfigurator(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { + return UpdateConfiguratorWithOptions(ctx, res, RuleMutationOptions{}) +} + +func UpdateConfiguratorWithOptions(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return updateConfiguratorUnsafe(ctx, res) + return updateConfiguratorUnsafe(ctx, res, opts) } lockKey := lock.BuildConfiguratorRuleLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateConfiguratorUnsafe(ctx, res) + return updateConfiguratorUnsafe(ctx, res, opts) }) } -func updateConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { +func updateConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { + if err := checkExpectedVersion(ctx, RuleKindName{Kind: meshresource.DynamicConfigKind, Mesh: res.Mesh, Name: res.Name}, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationUpdate, opts); err != nil { + return err + } if err := ctx.ResourceManager().Update(res); err != nil { logger.Warnf("update %s configurator failed with error: %s", res.Name, err.Error()) return err @@ -135,17 +146,27 @@ func updateConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicC } func CreateConfigurator(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { + return CreateConfiguratorWithOptions(ctx, res, RuleMutationOptions{}) +} + +func CreateConfiguratorWithOptions(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return createConfiguratorUnsafe(ctx, res) + return createConfiguratorUnsafe(ctx, res, opts) } lockKey := lock.BuildConfiguratorRuleLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createConfiguratorUnsafe(ctx, res) + return createConfiguratorUnsafe(ctx, res, opts) }) } -func createConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { +func createConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { + if err := checkExpectedVersion(ctx, RuleKindName{Kind: meshresource.DynamicConfigKind, Mesh: res.Mesh, Name: res.Name}, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationCreate, opts); err != nil { + return err + } if err := ctx.ResourceManager().Add(res); err != nil { logger.Warnf("create %s configurator failed with error: %s", res.Name, err.Error()) return err @@ -154,17 +175,32 @@ func createConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicC } func DeleteConfigurator(ctx consolectx.Context, name string, mesh string) error { + return DeleteConfiguratorWithOptions(ctx, name, mesh, RuleMutationOptions{}) +} + +func DeleteConfiguratorWithOptions(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return deleteConfiguratorUnsafe(ctx, name, mesh) + return deleteConfiguratorUnsafe(ctx, name, mesh, opts) } lockKey := lock.BuildConfiguratorRuleLockKey(mesh, name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteConfiguratorUnsafe(ctx, name, mesh) + return deleteConfiguratorUnsafe(ctx, name, mesh, opts) }) } -func deleteConfiguratorUnsafe(ctx consolectx.Context, name string, mesh string) error { +func deleteConfiguratorUnsafe(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.DynamicConfigKind, Mesh: mesh, Name: name} + res, err := getExistingRule(ctx, kindName) + if err != nil { + return err + } + if err := checkExpectedVersion(ctx, kindName, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationDelete, opts); err != nil { + return err + } if err := ctx.ResourceManager().DeleteByKey(meshresource.DynamicConfigKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { logger.Warnf("delete %s configurator failed with error: %s", name, err.Error()) return err diff --git a/pkg/console/service/rule_version.go b/pkg/console/service/rule_version.go new file mode 100644 index 000000000..5a5d16986 --- /dev/null +++ b/pkg/console/service/rule_version.go @@ -0,0 +1,93 @@ +/* + * 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 service + +import ( + "context" + "fmt" + + consolectx "github.com/apache/dubbo-admin/pkg/console/context" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "github.com/apache/dubbo-admin/pkg/core/versioning" +) + +type RuleMutationOptions struct { + ExpectedVersionID *int64 + Author string +} + +func ruleVersioning(ctx consolectx.Context) versioning.Service { + if ctx == nil { + return nil + } + return ctx.RuleVersioning() +} + +func checkExpectedVersion(ctx consolectx.Context, kindName RuleKindName, opts RuleMutationOptions) error { + svc := ruleVersioning(ctx) + if svc == nil { + return nil + } + return svc.CheckExpected(kindName.Kind, kindName.Mesh, kindName.Name, opts.ExpectedVersionID) +} + +func putAdminHint(ctx consolectx.Context, res versionedResource, op versioning.Operation, opts RuleMutationOptions) error { + svc := ruleVersioning(ctx) + if svc == nil { + return nil + } + return svc.PutAdminHint(res, op, versioning.SourceAdmin, opts.Author, "", nil) +} + +type RuleKindName struct { + Kind coremodel.ResourceKind + Mesh string + Name string +} + +func getExistingRule(ctx consolectx.Context, kindName RuleKindName) (coremodel.Resource, error) { + key := coremodel.BuildResourceKey(kindName.Mesh, kindName.Name) + res, exists, err := ctx.ResourceManager().GetByKey(kindName.Kind, key) + if err != nil { + return nil, err + } + if !exists { + return nil, fmt.Errorf("%s %s does not exist", kindName.Kind, key) + } + return res, nil +} + +type versionedResource interface { + coremodel.Resource +} + +func ListRuleVersions(ctx consolectx.Context, kindName RuleKindName) (*versioning.ListResult, error) { + return ctx.RuleVersioning().List(kindName.Kind, kindName.Mesh, kindName.Name) +} + +func GetRuleVersion(ctx consolectx.Context, kindName RuleKindName, versionID int64) (*versioning.Version, error) { + return ctx.RuleVersioning().Get(kindName.Kind, kindName.Mesh, kindName.Name, versionID) +} + +func DiffRuleVersion(ctx consolectx.Context, kindName RuleKindName, versionID int64, against string) (*versioning.DiffResult, error) { + return ctx.RuleVersioning().Diff(kindName.Kind, kindName.Mesh, kindName.Name, versionID, against) +} + +func RollbackRuleVersion(reqCtx context.Context, ctx consolectx.Context, kindName RuleKindName, versionID int64, reason string, expected *int64, author string) (*versioning.Version, error) { + return ctx.RuleVersioning().Rollback(reqCtx, ctx.ResourceManager(), kindName.Kind, kindName.Mesh, kindName.Name, versionID, reason, expected, author) +} diff --git a/pkg/console/service/tag_rule.go b/pkg/console/service/tag_rule.go index a051117ca..aff191db2 100644 --- a/pkg/console/service/tag_rule.go +++ b/pkg/console/service/tag_rule.go @@ -30,6 +30,7 @@ import ( meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/core/store/index" + "github.com/apache/dubbo-admin/pkg/core/versioning" ) func PageListTagRule(ctx consolectx.Context, req *model.SearchReq) (*model.SearchPaginationResult, error) { @@ -114,19 +115,29 @@ func GetTagRule(ctx consolectx.Context, name string, mesh string) (*meshresource } func UpdateTagRule(ctx consolectx.Context, res *meshresource.TagRouteResource) error { + return UpdateTagRuleWithOptions(ctx, res, RuleMutationOptions{}) +} + +func UpdateTagRuleWithOptions(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return updateTagRuleUnsafe(ctx, res) + return updateTagRuleUnsafe(ctx, res, opts) } lockKey := lock.BuildTagRouteLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateTagRuleUnsafe(ctx, res) + return updateTagRuleUnsafe(ctx, res, opts) }) } -func updateTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource) error { +func updateTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { + if err := checkExpectedVersion(ctx, RuleKindName{Kind: meshresource.TagRouteKind, Mesh: res.Mesh, Name: res.Name}, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationUpdate, opts); err != nil { + return err + } err := ctx.ResourceManager().Update(res) if err != nil { logger.Warnf("update tag rule %s error: %v", res.Name, err) @@ -136,19 +147,29 @@ func updateTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResou } func CreateTagRule(ctx consolectx.Context, res *meshresource.TagRouteResource) error { + return CreateTagRuleWithOptions(ctx, res, RuleMutationOptions{}) +} + +func CreateTagRuleWithOptions(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return createTagRuleUnsafe(ctx, res) + return createTagRuleUnsafe(ctx, res, opts) } lockKey := lock.BuildTagRouteLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createTagRuleUnsafe(ctx, res) + return createTagRuleUnsafe(ctx, res, opts) }) } -func createTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource) error { +func createTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { + if err := checkExpectedVersion(ctx, RuleKindName{Kind: meshresource.TagRouteKind, Mesh: res.Mesh, Name: res.Name}, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationCreate, opts); err != nil { + return err + } err := ctx.ResourceManager().Add(res) if err != nil { logger.Warnf("create tag rule %s error: %v", res.Name, err) @@ -158,19 +179,33 @@ func createTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResou } func DeleteTagRule(ctx consolectx.Context, name string, mesh string) error { + return DeleteTagRuleWithOptions(ctx, name, mesh, RuleMutationOptions{}) +} + +func DeleteTagRuleWithOptions(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return deleteTagRuleUnsafe(ctx, name, mesh) + return deleteTagRuleUnsafe(ctx, name, mesh, opts) } lockKey := lock.BuildTagRouteLockKey(mesh, name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteTagRuleUnsafe(ctx, name, mesh) + return deleteTagRuleUnsafe(ctx, name, mesh, opts) }) } -func deleteTagRuleUnsafe(ctx consolectx.Context, name string, mesh string) error { - err := ctx.ResourceManager().DeleteByKey(meshresource.TagRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)) +func deleteTagRuleUnsafe(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.TagRouteKind, Mesh: mesh, Name: name} + res, err := getExistingRule(ctx, kindName) if err != nil { + return err + } + if err := checkExpectedVersion(ctx, kindName, opts); err != nil { + return err + } + if err := putAdminHint(ctx, res, versioning.OperationDelete, opts); err != nil { + return err + } + if err := ctx.ResourceManager().DeleteByKey(meshresource.TagRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { logger.Warnf("delete tag rule %s error: %v", name, err) return err } diff --git a/pkg/core/bootstrap/bootstrap.go b/pkg/core/bootstrap/bootstrap.go index d1ee2c0dc..a7b5439e1 100644 --- a/pkg/core/bootstrap/bootstrap.go +++ b/pkg/core/bootstrap/bootstrap.go @@ -27,6 +27,7 @@ import ( "github.com/apache/dubbo-admin/pkg/core/lock" "github.com/apache/dubbo-admin/pkg/core/logger" "github.com/apache/dubbo-admin/pkg/core/runtime" + "github.com/apache/dubbo-admin/pkg/core/versioning" "github.com/apache/dubbo-admin/pkg/diagnostics" ) @@ -130,6 +131,7 @@ func (sb *SmartBootstrapper) gatherComponents() ([]runtime.Component, error) { {"CounterManager", counter.ComponentType}, {"DiagnosticsServer", diagnostics.DiagnosticsServer}, {"DistributedLock", lock.DistributedLockComponent}, + {"RuleVersioning", versioning.ComponentType}, } for _, comp := range optionalComps { diff --git a/pkg/core/versioning/component.go b/pkg/core/versioning/component.go new file mode 100644 index 000000000..04a5a6d1d --- /dev/null +++ b/pkg/core/versioning/component.go @@ -0,0 +1,148 @@ +/* + * 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 versioning + +import ( + "fmt" + "math" + "time" + + versioningcfg "github.com/apache/dubbo-admin/pkg/config/versioning" + "github.com/apache/dubbo-admin/pkg/core/events" + "github.com/apache/dubbo-admin/pkg/core/governor" + "github.com/apache/dubbo-admin/pkg/core/manager" + "github.com/apache/dubbo-admin/pkg/core/runtime" + "gorm.io/gorm" +) + +const ComponentType runtime.ComponentType = "rule versioning" + +func init() { + runtime.RegisterComponent(&component{}) +} + +type Component interface { + runtime.Component + Service() Service +} + +type component struct { + service Service + store Store + subscribers []*Subscriber +} + +func (c *component) Type() runtime.ComponentType { + return ComponentType +} + +func (c *component) Order() int { + return math.MaxInt - 5 +} + +func (c *component) RequiredDependencies() []runtime.ComponentType { + return []runtime.ComponentType{ + runtime.EventBus, + runtime.ResourceStore, + runtime.ResourceManager, + } +} + +func (c *component) Init(ctx runtime.BuilderContext) error { + cfg := ctx.Config().Versioning + if cfg == nil { + cfg = versioningcfg.Default() + } + hints := NewAdminHintRegistry() + storeComponent, err := ctx.GetActivatedComponent(runtime.ResourceStore) + if err != nil { + return err + } + store := Store(NewMemoryStore()) + if sc, ok := storeComponent.(interface { + GetDB() (*gorm.DB, bool) + }); ok { + if db, exists := sc.GetDB(); exists { + gormStore := NewGormStore(db) + if err := gormStore.AutoMigrate(); err != nil { + return err + } + store = gormStore + } + } + c.store = store + c.service = NewServiceWithRollbackWait( + cfg.Enabled, + cfg.MaxVersionsPerRule, + time.Duration(cfg.CoalesceWindowMs)*time.Millisecond, + time.Duration(cfg.AdminHintTTLSec)*time.Second, + time.Duration(cfg.RollbackWaitMs)*time.Millisecond, + store, + hints, + ) + if !cfg.Enabled { + return nil + } + eventBusComponent, err := ctx.GetActivatedComponent(runtime.EventBus) + if err != nil { + return err + } + bus, ok := eventBusComponent.(events.EventBus) + if !ok { + return fmt.Errorf("component %s does not implement events.EventBus", runtime.EventBus) + } + for _, kind := range governor.RuleResourceKinds.Values() { + sub := NewSubscriber(kind, store, hints, cfg.MaxVersionsPerRule, time.Duration(cfg.CoalesceWindowMs)*time.Millisecond) + if err := bus.Subscribe(sub); err != nil { + return err + } + c.subscribers = append(c.subscribers, sub) + } + return nil +} + +func (c *component) Start(rt runtime.Runtime, _ <-chan struct{}) error { + cfg := rt.Config().Versioning + if cfg == nil { + cfg = versioningcfg.Default() + } + if !cfg.Enabled { + return nil + } + rmComp, err := rt.GetComponent(runtime.ResourceManager) + if err != nil { + return err + } + rm := rmComp.(manager.ResourceManagerComponent).ResourceManager() + for _, kind := range governor.RuleResourceKinds.Values() { + resources, err := rm.List(kind) + if err != nil { + return err + } + for _, res := range resources { + if err := RecordBootstrap(c.store, cfg.MaxVersionsPerRule, res); err != nil { + return err + } + } + } + return nil +} + +func (c *component) Service() Service { + return c.service +} diff --git a/pkg/core/versioning/hint.go b/pkg/core/versioning/hint.go new file mode 100644 index 000000000..e28dc021b --- /dev/null +++ b/pkg/core/versioning/hint.go @@ -0,0 +1,95 @@ +/* + * 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 versioning + +import ( + "sync" + "time" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type AdminHint struct { + RuleKind coremodel.ResourceKind + Mesh string + ResourceKey string + RuleName string + ContentHash string + SpecJSON string + Source Source + Author string + Reason string + Operation Operation + RolledBackFromID *int64 + ExpiresAt time.Time +} + +type hintKey struct { + kind coremodel.ResourceKind + resourceKey string + hash string +} + +type AdminHintRegistry struct { + mu sync.Mutex + now func() time.Time + hints map[hintKey]AdminHint +} + +func NewAdminHintRegistry() *AdminHintRegistry { + return &AdminHintRegistry{ + now: time.Now, + hints: make(map[hintKey]AdminHint), + } +} + +func (r *AdminHintRegistry) Put(kind coremodel.ResourceKind, resourceKey, contentHash string, hint AdminHint) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.hints[hintKey{kind: kind, resourceKey: resourceKey, hash: contentHash}] = hint +} + +func (r *AdminHintRegistry) Take(kind coremodel.ResourceKind, resourceKey, contentHash string) (AdminHint, bool) { + if r == nil { + return AdminHint{}, false + } + r.mu.Lock() + defer r.mu.Unlock() + key := hintKey{kind: kind, resourceKey: resourceKey, hash: contentHash} + hint, ok := r.hints[key] + if !ok { + return AdminHint{}, false + } + delete(r.hints, key) + if !hint.ExpiresAt.IsZero() && r.now().After(hint.ExpiresAt) { + return AdminHint{}, false + } + return hint, true +} + +func (r *AdminHintRegistry) pruneExpiredLocked() { + now := r.now() + for key, hint := range r.hints { + if !hint.ExpiresAt.IsZero() && now.After(hint.ExpiresAt) { + delete(r.hints, key) + } + } +} diff --git a/pkg/core/versioning/normalize.go b/pkg/core/versioning/normalize.go new file mode 100644 index 000000000..5a380f94a --- /dev/null +++ b/pkg/core/versioning/normalize.go @@ -0,0 +1,77 @@ +/* + * 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 versioning + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +const DeleteSpecJSON = "{}" + +func NormalizeSpec(spec coremodel.ResourceSpec) (string, string, error) { + if spec == nil { + return HashSpecJSON(DeleteSpecJSON), DeleteSpecJSON, nil + } + var raw []byte + if msg, ok := spec.(proto.Message); ok { + var err error + raw, err = protojson.MarshalOptions{ + UseProtoNames: false, + EmitUnpopulated: false, + }.Marshal(msg) + if err != nil { + return "", "", err + } + } else { + var err error + raw, err = json.Marshal(spec) + if err != nil { + return "", "", err + } + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return "", "", err + } + canonical, err := json.Marshal(v) + if err != nil { + return "", "", err + } + specJSON := string(canonical) + return HashSpecJSON(specJSON), specJSON, nil +} + +func HashSpecJSON(specJSON string) string { + sum := sha256.Sum256([]byte(specJSON)) + return hex.EncodeToString(sum[:]) +} + +func NormalizeResource(res coremodel.Resource) (string, string, error) { + if res == nil { + return "", "", fmt.Errorf("resource is nil") + } + return NormalizeSpec(res.ResourceSpec()) +} diff --git a/pkg/core/versioning/service.go b/pkg/core/versioning/service.go new file mode 100644 index 000000000..b152eb163 --- /dev/null +++ b/pkg/core/versioning/service.go @@ -0,0 +1,269 @@ +/* + * 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 versioning + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + "github.com/apache/dubbo-admin/pkg/core/manager" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "google.golang.org/protobuf/encoding/protojson" +) + +type Service interface { + Store() Store + Hints() *AdminHintRegistry + List(kind coremodel.ResourceKind, mesh, ruleName string) (*ListResult, error) + Get(kind coremodel.ResourceKind, mesh, ruleName string, id int64) (*Version, error) + Diff(kind coremodel.ResourceKind, mesh, ruleName string, id int64, against string) (*DiffResult, error) + CheckExpected(kind coremodel.ResourceKind, mesh, ruleName string, expected *int64) error + PutAdminHint(res coremodel.Resource, op Operation, source Source, author, reason string, rolledBackFromID *int64) error + Rollback(ctx context.Context, rm manager.ResourceManager, kind coremodel.ResourceKind, mesh, ruleName string, versionID int64, reason string, expected *int64, author string) (*Version, error) +} + +type service struct { + enabled bool + maxVersions int64 + hintTTL time.Duration + coalesceWindow time.Duration + rollbackWait time.Duration + store Store + hints *AdminHintRegistry +} + +func NewService(enabled bool, maxVersions int64, coalesceWindow, hintTTL time.Duration, store Store, hints *AdminHintRegistry) Service { + return NewServiceWithRollbackWait(enabled, maxVersions, coalesceWindow, hintTTL, 0, store, hints) +} + +func NewServiceWithRollbackWait(enabled bool, maxVersions int64, coalesceWindow, hintTTL, rollbackWait time.Duration, store Store, hints *AdminHintRegistry) Service { + return &service{ + enabled: enabled, + maxVersions: maxVersions, + hintTTL: hintTTL, + coalesceWindow: coalesceWindow, + rollbackWait: rollbackWait, + store: store, + hints: hints, + } +} + +func (s *service) Store() Store { + return s.store +} + +func (s *service) Hints() *AdminHintRegistry { + return s.hints +} + +func (s *service) ensureEnabled() error { + if !s.enabled { + return ErrFeatureDisabled + } + return nil +} + +func (s *service) List(kind coremodel.ResourceKind, mesh, ruleName string) (*ListResult, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + items, err := s.store.ListVersions(kind, coremodel.BuildResourceKey(mesh, ruleName)) + if err != nil { + return nil, err + } + return &ListResult{Items: items, Total: int64(len(items))}, nil +} + +func (s *service) Get(kind coremodel.ResourceKind, mesh, ruleName string, id int64) (*Version, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + return s.store.GetVersion(kind, coremodel.BuildResourceKey(mesh, ruleName), id) +} + +func (s *service) Diff(kind coremodel.ResourceKind, mesh, ruleName string, id int64, against string) (*DiffResult, error) { + left, err := s.Get(kind, mesh, ruleName, id) + if err != nil { + return nil, err + } + var right *Version + if against == "" || against == "current" { + meta, err := s.store.CurrentMeta(kind, coremodel.BuildResourceKey(mesh, ruleName)) + if err != nil { + return nil, err + } + if meta == nil || meta.CurrentVersion == nil { + return nil, ErrVersionNotFound + } + right, err = s.store.GetVersion(kind, coremodel.BuildResourceKey(mesh, ruleName), *meta.CurrentVersion) + if err != nil { + return nil, err + } + } else { + var againstID int64 + if _, err := fmt.Sscan(against, &againstID); err != nil { + return nil, bizerror.New(bizerror.InvalidArgument, "against must be a version id or current") + } + right, err = s.Get(kind, mesh, ruleName, againstID) + if err != nil { + return nil, err + } + } + return &DiffResult{ + Left: DiffSide{ID: left.ID, VersionNo: left.VersionNo, SpecJSON: left.SpecJSON}, + Right: DiffSide{ID: right.ID, VersionNo: right.VersionNo, SpecJSON: right.SpecJSON}, + }, nil +} + +func (s *service) CheckExpected(kind coremodel.ResourceKind, mesh, ruleName string, expected *int64) error { + if err := s.ensureEnabled(); err != nil { + return nil + } + return s.store.CheckExpectedVersion(kind, coremodel.BuildResourceKey(mesh, ruleName), expected) +} + +func (s *service) PutAdminHint(res coremodel.Resource, op Operation, source Source, author, reason string, rolledBackFromID *int64) error { + if err := s.ensureEnabled(); err != nil { + return nil + } + hash, specJSON, err := NormalizeResource(res) + if op == OperationDelete { + hash = HashSpecJSON(DeleteSpecJSON) + specJSON = DeleteSpecJSON + err = nil + } + if err != nil { + return err + } + if strings.TrimSpace(author) == "" { + author = "system:unknown" + } + s.hints.Put(res.ResourceKind(), res.ResourceKey(), hash, AdminHint{ + RuleKind: res.ResourceKind(), + Mesh: res.ResourceMesh(), + ResourceKey: res.ResourceKey(), + RuleName: res.ResourceMeta().Name, + ContentHash: hash, + SpecJSON: specJSON, + Source: source, + Author: author, + Reason: reason, + Operation: op, + RolledBackFromID: rolledBackFromID, + ExpiresAt: time.Now().Add(s.hintTTL), + }) + return nil +} + +func (s *service) Rollback(ctx context.Context, rm manager.ResourceManager, kind coremodel.ResourceKind, mesh, ruleName string, versionID int64, reason string, expected *int64, author string) (*Version, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + reason = strings.TrimSpace(reason) + if reason == "" { + return nil, bizerror.New(bizerror.InvalidArgument, "rollback reason is required") + } + if err := s.store.CheckExpectedVersion(kind, coremodel.BuildResourceKey(mesh, ruleName), expected); err != nil { + return nil, err + } + target, err := s.store.GetVersion(kind, coremodel.BuildResourceKey(mesh, ruleName), versionID) + if err != nil { + return nil, err + } + if target.Operation == OperationDelete { + return nil, ErrRollbackToDelete + } + res, err := ResourceFromSpecJSON(kind, mesh, ruleName, target.SpecJSON) + if err != nil { + return nil, err + } + fromID := target.ID + if err := s.PutAdminHint(res, OperationUpdate, SourceRollback, author, reason, &fromID); err != nil { + return nil, err + } + if err := rm.Upsert(res); err != nil { + return nil, err + } + wait := s.rollbackWait + if wait == 0 { + wait = s.coalesceWindow + 500*time.Millisecond + } + deadline := time.NewTimer(wait) + defer deadline.Stop() + tick := time.NewTicker(25 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-deadline.C: + return nil, fmt.Errorf("timeout waiting for rollback version row") + case <-tick.C: + latest, err := s.store.LatestVersion(kind, res.ResourceKey()) + if err != nil { + return nil, err + } + if latest != nil && latest.Source == SourceRollback && latest.RolledBackFromID != nil && *latest.RolledBackFromID == fromID { + return latest, nil + } + } + } +} + +func ResourceFromSpecJSON(kind coremodel.ResourceKind, mesh, ruleName, specJSON string) (coremodel.Resource, error) { + switch kind { + case meshresource.ConditionRouteKind: + res := meshresource.NewConditionRouteResourceWithAttributes(ruleName, mesh) + var spec meshproto.ConditionRoute + if err := protojson.Unmarshal([]byte(specJSON), &spec); err != nil { + if err := json.Unmarshal([]byte(specJSON), &spec); err != nil { + return nil, err + } + } + res.Spec = &spec + return res, nil + case meshresource.TagRouteKind: + res := meshresource.NewTagRouteResourceWithAttributes(ruleName, mesh) + var spec meshproto.TagRoute + if err := protojson.Unmarshal([]byte(specJSON), &spec); err != nil { + if err := json.Unmarshal([]byte(specJSON), &spec); err != nil { + return nil, err + } + } + res.Spec = &spec + return res, nil + case meshresource.DynamicConfigKind: + res := meshresource.NewDynamicConfigResourceWithAttributes(ruleName, mesh) + var spec meshproto.DynamicConfig + if err := protojson.Unmarshal([]byte(specJSON), &spec); err != nil { + if err := json.Unmarshal([]byte(specJSON), &spec); err != nil { + return nil, err + } + } + res.Spec = &spec + return res, nil + default: + return nil, bizerror.New(bizerror.InvalidArgument, "unsupported rule kind") + } +} diff --git a/pkg/core/versioning/store.go b/pkg/core/versioning/store.go new file mode 100644 index 000000000..32f7d2430 --- /dev/null +++ b/pkg/core/versioning/store.go @@ -0,0 +1,211 @@ +/* + * 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 versioning + +import ( + "sort" + "sync" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type Store interface { + InsertVersion(req InsertRequest, maxVersions int64) (*Version, error) + ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) + GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) + GetVersionByID(id int64) (*Version, error) + CurrentMeta(kind coremodel.ResourceKind, resourceKey string) (*Meta, error) + LatestVersion(kind coremodel.ResourceKind, resourceKey string) (*Version, error) + CheckExpectedVersion(kind coremodel.ResourceKind, resourceKey string, expected *int64) error +} + +type MemoryStore struct { + mu sync.Mutex + nextID int64 + versions map[int64]*Version + byRule map[ruleKey][]int64 + meta map[ruleKey]*Meta +} + +type ruleKey struct { + kind coremodel.ResourceKind + resourceKey string +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + nextID: 1, + versions: make(map[int64]*Version), + byRule: make(map[ruleKey][]int64), + meta: make(map[ruleKey]*Meta), + } +} + +func (s *MemoryStore) InsertVersion(req InsertRequest, maxVersions int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := ruleKey{kind: req.RuleKind, resourceKey: req.ResourceKey} + meta := s.meta[key] + if meta == nil { + meta = &Meta{RuleKind: req.RuleKind, ResourceKey: req.ResourceKey} + s.meta[key] = meta + } + if ids := s.byRule[key]; len(ids) > 0 { + latest := s.versions[ids[len(ids)-1]] + if latest != nil && latest.ContentHash == req.ContentHash && latest.Operation == req.Operation { + cp := *latest + if meta.CurrentVersion != nil && *meta.CurrentVersion == cp.ID { + cp.IsCurrent = true + } + return &cp, nil + } + } + now := req.CreatedAt + meta.LastVersionNo++ + id := s.nextID + s.nextID++ + v := &Version{ + ID: id, + RuleKind: req.RuleKind, + Mesh: req.Mesh, + ResourceKey: req.ResourceKey, + RuleName: req.RuleName, + VersionNo: meta.LastVersionNo, + ContentHash: req.ContentHash, + SpecJSON: req.SpecJSON, + Source: req.Source, + Operation: req.Operation, + Author: req.Author, + Reason: req.Reason, + RolledBackFromID: req.RolledBackFromID, + CreatedAt: now, + } + s.versions[id] = v + s.byRule[key] = append(s.byRule[key], id) + if req.Operation == OperationDelete { + meta.CurrentVersion = nil + } else { + current := id + meta.CurrentVersion = ¤t + } + meta.UpdatedAt = now + s.trimLocked(key, maxVersions) + cp := *v + cp.IsCurrent = meta.CurrentVersion != nil && *meta.CurrentVersion == cp.ID + return &cp, nil +} + +func (s *MemoryStore) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := ruleKey{kind: kind, resourceKey: resourceKey} + ids := append([]int64(nil), s.byRule[key]...) + sort.Slice(ids, func(i, j int) bool { + return s.versions[ids[i]].VersionNo > s.versions[ids[j]].VersionNo + }) + meta := s.meta[key] + items := make([]Version, 0, len(ids)) + for _, id := range ids { + if v := s.versions[id]; v != nil { + cp := *v + cp.IsCurrent = meta != nil && meta.CurrentVersion != nil && *meta.CurrentVersion == cp.ID + items = append(items, cp) + } + } + return items, nil +} + +func (s *MemoryStore) GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + v := s.versions[id] + if v == nil || v.RuleKind != kind || v.ResourceKey != resourceKey { + return nil, ErrVersionNotFound + } + cp := *v + if meta := s.meta[ruleKey{kind: kind, resourceKey: resourceKey}]; meta != nil && meta.CurrentVersion != nil { + cp.IsCurrent = *meta.CurrentVersion == cp.ID + } + return &cp, nil +} + +func (s *MemoryStore) GetVersionByID(id int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + v := s.versions[id] + if v == nil { + return nil, ErrVersionNotFound + } + cp := *v + if meta := s.meta[ruleKey{kind: v.RuleKind, resourceKey: v.ResourceKey}]; meta != nil && meta.CurrentVersion != nil { + cp.IsCurrent = *meta.CurrentVersion == cp.ID + } + return &cp, nil +} + +func (s *MemoryStore) CurrentMeta(kind coremodel.ResourceKind, resourceKey string) (*Meta, error) { + s.mu.Lock() + defer s.mu.Unlock() + meta := s.meta[ruleKey{kind: kind, resourceKey: resourceKey}] + if meta == nil { + return nil, nil + } + cp := *meta + return &cp, nil +} + +func (s *MemoryStore) LatestVersion(kind coremodel.ResourceKind, resourceKey string) (*Version, error) { + items, err := s.ListVersions(kind, resourceKey) + if err != nil || len(items) == 0 { + return nil, err + } + return &items[0], nil +} + +func (s *MemoryStore) CheckExpectedVersion(kind coremodel.ResourceKind, resourceKey string, expected *int64) error { + if expected == nil { + return nil + } + meta, err := s.CurrentMeta(kind, resourceKey) + if err != nil { + return err + } + if meta == nil || meta.CurrentVersion == nil || *meta.CurrentVersion != *expected { + var current *int64 + if meta != nil { + current = meta.CurrentVersion + } + return &ConflictError{CurrentVersionID: current} + } + return nil +} + +func (s *MemoryStore) trimLocked(key ruleKey, maxVersions int64) { + if maxVersions <= 0 { + return + } + ids := s.byRule[key] + if int64(len(ids)) <= maxVersions { + return + } + remove := ids[:int64(len(ids))-maxVersions] + s.byRule[key] = ids[int64(len(ids))-maxVersions:] + for _, id := range remove { + delete(s.versions, id) + } +} diff --git a/pkg/core/versioning/store_gorm.go b/pkg/core/versioning/store_gorm.go new file mode 100644 index 000000000..4810cc123 --- /dev/null +++ b/pkg/core/versioning/store_gorm.go @@ -0,0 +1,224 @@ +/* + * 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 versioning + +import ( + "errors" + "sync" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type GormStore struct { + db *gorm.DB + mu sync.Mutex +} + +func NewGormStore(db *gorm.DB) *GormStore { + return &GormStore{db: db} +} + +func (s *GormStore) AutoMigrate() error { + return s.db.AutoMigrate(&Version{}, &Meta{}) +} + +func (s *GormStore) InsertVersion(req InsertRequest, maxVersions int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + var inserted Version + err := s.db.Transaction(func(tx *gorm.DB) error { + var meta Meta + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("rule_kind = ? AND resource_key = ?", req.RuleKind, req.ResourceKey). + First(&meta).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + meta = Meta{RuleKind: req.RuleKind, ResourceKey: req.ResourceKey} + if err := tx.Create(&meta).Error; err != nil { + return err + } + } else if err != nil { + return err + } + var latest Version + err = tx.Where("rule_kind = ? AND resource_key = ?", req.RuleKind, req.ResourceKey). + Order("version_no DESC"). + First(&latest).Error + if err == nil && latest.ContentHash == req.ContentHash && latest.Operation == req.Operation { + inserted = latest + return nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + meta.LastVersionNo++ + inserted = Version{ + RuleKind: req.RuleKind, + Mesh: req.Mesh, + ResourceKey: req.ResourceKey, + RuleName: req.RuleName, + VersionNo: meta.LastVersionNo, + ContentHash: req.ContentHash, + SpecJSON: req.SpecJSON, + Source: req.Source, + Operation: req.Operation, + Author: req.Author, + Reason: req.Reason, + RolledBackFromID: req.RolledBackFromID, + CreatedAt: req.CreatedAt, + } + if err := tx.Create(&inserted).Error; err != nil { + return err + } + if req.Operation == OperationDelete { + meta.CurrentVersion = nil + } else { + current := inserted.ID + meta.CurrentVersion = ¤t + } + meta.UpdatedAt = req.CreatedAt + if err := tx.Save(&meta).Error; err != nil { + return err + } + return trimGorm(tx, req.RuleKind, req.ResourceKey, maxVersions) + }) + if err != nil { + return nil, err + } + meta, err := s.CurrentMeta(inserted.RuleKind, inserted.ResourceKey) + if err == nil && meta != nil && meta.CurrentVersion != nil { + inserted.IsCurrent = *meta.CurrentVersion == inserted.ID + } + return &inserted, nil +} + +func (s *GormStore) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) { + var items []Version + if err := s.db.Where("rule_kind = ? AND resource_key = ?", kind, resourceKey). + Order("version_no DESC"). + Find(&items).Error; err != nil { + return nil, err + } + meta, err := s.CurrentMeta(kind, resourceKey) + if err != nil { + return nil, err + } + for i := range items { + items[i].IsCurrent = meta != nil && meta.CurrentVersion != nil && *meta.CurrentVersion == items[i].ID + } + return items, nil +} + +func (s *GormStore) GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) { + var v Version + err := s.db.Where("id = ? AND rule_kind = ? AND resource_key = ?", id, kind, resourceKey).First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrVersionNotFound + } + if err != nil { + return nil, err + } + meta, err := s.CurrentMeta(kind, resourceKey) + if err != nil { + return nil, err + } + v.IsCurrent = meta != nil && meta.CurrentVersion != nil && *meta.CurrentVersion == v.ID + return &v, nil +} + +func (s *GormStore) GetVersionByID(id int64) (*Version, error) { + var v Version + err := s.db.Where("id = ?", id).First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrVersionNotFound + } + if err != nil { + return nil, err + } + meta, err := s.CurrentMeta(v.RuleKind, v.ResourceKey) + if err != nil { + return nil, err + } + v.IsCurrent = meta != nil && meta.CurrentVersion != nil && *meta.CurrentVersion == v.ID + return &v, nil +} + +func (s *GormStore) CurrentMeta(kind coremodel.ResourceKind, resourceKey string) (*Meta, error) { + var meta Meta + err := s.db.Where("rule_kind = ? AND resource_key = ?", kind, resourceKey).First(&meta).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &meta, nil +} + +func (s *GormStore) LatestVersion(kind coremodel.ResourceKind, resourceKey string) (*Version, error) { + var v Version + err := s.db.Where("rule_kind = ? AND resource_key = ?", kind, resourceKey). + Order("version_no DESC"). + First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &v, nil +} + +func (s *GormStore) CheckExpectedVersion(kind coremodel.ResourceKind, resourceKey string, expected *int64) error { + if expected == nil { + return nil + } + meta, err := s.CurrentMeta(kind, resourceKey) + if err != nil { + return err + } + if meta == nil || meta.CurrentVersion == nil || *meta.CurrentVersion != *expected { + var current *int64 + if meta != nil { + current = meta.CurrentVersion + } + return &ConflictError{CurrentVersionID: current} + } + return nil +} + +func trimGorm(tx *gorm.DB, kind coremodel.ResourceKind, resourceKey string, maxVersions int64) error { + if maxVersions <= 0 { + return nil + } + var keepIDs []int64 + if err := tx.Model(&Version{}). + Where("rule_kind = ? AND resource_key = ?", kind, resourceKey). + Order("version_no DESC"). + Limit(int(maxVersions)). + Pluck("id", &keepIDs).Error; err != nil { + return err + } + if len(keepIDs) == 0 { + return nil + } + return tx.Where("rule_kind = ? AND resource_key = ? AND id NOT IN ?", kind, resourceKey, keepIDs). + Delete(&Version{}).Error +} diff --git a/pkg/core/versioning/store_gorm_test.go b/pkg/core/versioning/store_gorm_test.go new file mode 100644 index 000000000..a209c29be --- /dev/null +++ b/pkg/core/versioning/store_gorm_test.go @@ -0,0 +1,184 @@ +/* + * 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 versioning + +import ( + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +func setupGormVersionStore(t *testing.T) *GormStore { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + store := NewGormStore(db) + require.NoError(t, store.AutoMigrate()) + require.NoError(t, store.AutoMigrate()) + return store +} + +func TestGormStoreAutoMigrateSpecJSONUsesPortableText(t *testing.T) { + store := setupGormVersionStore(t) + + columns, err := store.db.Migrator().ColumnTypes(&Version{}) + require.NoError(t, err) + for _, column := range columns { + if column.Name() != "spec_json" { + continue + } + require.Equal(t, "text", strings.ToLower(column.DatabaseTypeName())) + return + } + require.Fail(t, "spec_json column was not migrated") +} + +func TestGormStoreInsertListGetAndTrim(t *testing.T) { + store := setupGormVersionStore(t) + key := "mesh/demo.condition-router" + for i := 0; i < 4; i++ { + _, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: fmt.Sprintf(`{"priority":%d}`, i+1), + ContentHash: fmt.Sprintf("hash-%d", i+1), + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now().Add(time.Duration(i) * time.Second), + }, 2) + require.NoError(t, err) + } + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, int64(4), items[0].VersionNo) + require.True(t, items[0].IsCurrent) + _, err = store.GetVersion(meshresource.ConditionRouteKind, key, items[1].ID) + require.NoError(t, err) + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Equal(t, int64(4), meta.LastVersionNo) + + _, err = store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: `{"priority":5}`, + ContentHash: "hash-5", + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now().Add(5 * time.Second), + }, 2) + require.NoError(t, err) + meta, err = store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Equal(t, int64(5), meta.LastVersionNo) +} + +func TestGormStoreDeleteIsNotDedupedAgainstEmptyCurrentSpec(t *testing.T) { + store := setupGormVersionStore(t) + key := "mesh/demo.condition-router" + created, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationCreate, + Author: "alice", + CreatedAt: time.Now(), + }, 5) + require.NoError(t, err) + deleted, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationDelete, + Author: "alice", + CreatedAt: time.Now().Add(time.Second), + }, 5) + require.NoError(t, err) + + require.NotEqual(t, created.ID, deleted.ID) + require.Equal(t, int64(2), deleted.VersionNo) + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, OperationDelete, items[0].Operation) + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Nil(t, meta.CurrentVersion) +} + +func TestGormStoreMetaCounterConcurrencyMonotonic(t *testing.T) { + store := setupGormVersionStore(t) + key := "mesh/concurrent.condition-router" + var wg sync.WaitGroup + errCh := make(chan error, 6) + for i := 0; i < 6; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "concurrent.condition-router", + SpecJSON: fmt.Sprintf(`{"priority":%d}`, i), + ContentHash: fmt.Sprintf("hash-concurrent-%d", i), + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now().Add(time.Duration(i) * time.Millisecond), + }, 10) + errCh <- err + }(i) + } + wg.Wait() + close(errCh) + for err := range errCh { + require.NoError(t, err) + } + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 6) + seen := map[int64]bool{} + for _, item := range items { + require.False(t, seen[item.VersionNo]) + seen[item.VersionNo] = true + } + require.Len(t, seen, 6) +} diff --git a/pkg/core/versioning/subscriber.go b/pkg/core/versioning/subscriber.go new file mode 100644 index 000000000..72fbd139e --- /dev/null +++ b/pkg/core/versioning/subscriber.go @@ -0,0 +1,242 @@ +/* + * 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 versioning + +import ( + "fmt" + "sync" + "time" + + "k8s.io/client-go/tools/cache" + + "github.com/apache/dubbo-admin/pkg/core/events" + "github.com/apache/dubbo-admin/pkg/core/logger" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type Subscriber struct { + kind coremodel.ResourceKind + store Store + hints *AdminHintRegistry + maxVersions int64 + coalesceWindow time.Duration + mu sync.Mutex + pending map[string]*pendingEvent +} + +type pendingEvent struct { + event events.Event + timer *time.Timer +} + +const sourceRegistryContextKey = "source-registry" + +func NewSubscriber(kind coremodel.ResourceKind, store Store, hints *AdminHintRegistry, maxVersions int64, coalesceWindow time.Duration) *Subscriber { + return &Subscriber{ + kind: kind, + store: store, + hints: hints, + maxVersions: maxVersions, + coalesceWindow: coalesceWindow, + pending: make(map[string]*pendingEvent), + } +} + +func (s *Subscriber) ResourceKind() coremodel.ResourceKind { + return s.kind +} + +func (s *Subscriber) Name() string { + return "rule-version-" + s.kind.ToString() +} + +func (s *Subscriber) AsyncEnabled() bool { + return true +} + +func (s *Subscriber) ProcessEvent(event events.Event) error { + res := event.NewObj() + if event.Type() == cache.Deleted { + res = event.OldObj() + } + if res == nil { + return nil + } + if s.coalesceWindow <= 0 { + return s.record(event) + } + key := res.ResourceKey() + s.mu.Lock() + if p := s.pending[key]; p != nil { + p.event = event + s.mu.Unlock() + return nil + } + p := &pendingEvent{event: event} + p.timer = time.AfterFunc(s.coalesceWindow, func() { + s.flush(key) + }) + s.pending[key] = p + s.mu.Unlock() + return nil +} + +func (s *Subscriber) FlushAll() { + s.mu.Lock() + keys := make([]string, 0, len(s.pending)) + for key := range s.pending { + keys = append(keys, key) + } + s.mu.Unlock() + for _, key := range keys { + s.flush(key) + } +} + +func (s *Subscriber) flush(key string) { + s.mu.Lock() + p := s.pending[key] + if p == nil { + s.mu.Unlock() + return + } + delete(s.pending, key) + if p.timer != nil { + p.timer.Stop() + } + event := p.event + s.mu.Unlock() + if err := s.record(event); err != nil { + logger.Errorf("failed to record rule version for %s: %v", key, err) + } +} + +func (s *Subscriber) record(event events.Event) error { + res := event.NewObj() + op := OperationUpdate + switch event.Type() { + case cache.Added: + op = OperationCreate + case cache.Updated: + op = OperationUpdate + case cache.Deleted: + op = OperationDelete + res = event.OldObj() + default: + return nil + } + if res == nil { + return nil + } + hash, specJSON, err := NormalizeResource(res) + if op == OperationDelete { + hash = HashSpecJSON(DeleteSpecJSON) + specJSON = DeleteSpecJSON + err = nil + } + if err != nil { + return err + } + ruleKind := res.ResourceKind() + mesh := res.ResourceMesh() + resourceKey := res.ResourceKey() + ruleName := res.ResourceMeta().Name + source := SourceUpstream + author := "system:upstream" + reason := "" + var rolledBackFromID *int64 + if ctx := event.Context(); ctx != nil { + if registry := ctx[sourceRegistryContextKey]; registry != "" { + author = "system:" + registry + } + } + if hint, ok := s.hints.Take(res.ResourceKind(), res.ResourceKey(), hash); ok { + source = hint.Source + if source == "" { + source = SourceAdmin + } + author = hint.Author + reason = hint.Reason + if hint.Operation != "" { + op = hint.Operation + } + rolledBackFromID = hint.RolledBackFromID + if op == OperationDelete { + if hint.RuleKind != "" { + ruleKind = hint.RuleKind + } + if hint.Mesh != "" { + mesh = hint.Mesh + } + if hint.ResourceKey != "" { + resourceKey = hint.ResourceKey + } + if hint.RuleName != "" { + ruleName = hint.RuleName + } + } + } + if author == "" { + author = "system:unknown" + } + _, err = s.store.InsertVersion(InsertRequest{ + RuleKind: ruleKind, + Mesh: mesh, + ResourceKey: resourceKey, + RuleName: ruleName, + SpecJSON: specJSON, + ContentHash: hash, + Source: source, + Operation: op, + Author: author, + Reason: reason, + RolledBackFromID: rolledBackFromID, + CreatedAt: time.Now(), + }, s.maxVersions) + return err +} + +func RecordBootstrap(store Store, maxVersions int64, res coremodel.Resource) error { + meta, err := store.CurrentMeta(res.ResourceKind(), res.ResourceKey()) + if err != nil { + return err + } + if meta != nil { + return nil + } + hash, specJSON, err := NormalizeResource(res) + if err != nil { + return err + } + _, err = store.InsertVersion(InsertRequest{ + RuleKind: res.ResourceKind(), + Mesh: res.ResourceMesh(), + ResourceKey: res.ResourceKey(), + RuleName: res.ResourceMeta().Name, + SpecJSON: specJSON, + ContentHash: hash, + Source: SourceBootstrap, + Operation: OperationCreate, + Author: "system:bootstrap", + CreatedAt: time.Now(), + }, maxVersions) + if err != nil { + return fmt.Errorf("bootstrap version for %s failed: %w", res.ResourceKey(), err) + } + return nil +} diff --git a/pkg/core/versioning/types.go b/pkg/core/versioning/types.go new file mode 100644 index 000000000..85953f393 --- /dev/null +++ b/pkg/core/versioning/types.go @@ -0,0 +1,122 @@ +/* + * 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 versioning + +import ( + "errors" + "time" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type Source string + +const ( + SourceAdmin Source = "ADMIN" + SourceUpstream Source = "UPSTREAM" + SourceRollback Source = "ROLLBACK" + SourceBootstrap Source = "BOOTSTRAP" +) + +type Operation string + +const ( + OperationCreate Operation = "CREATE" + OperationUpdate Operation = "UPDATE" + OperationDelete Operation = "DELETE" +) + +var ( + ErrFeatureDisabled = errors.New("rule versioning is disabled") + ErrVersionConflict = errors.New("rule version conflict") + ErrVersionNotFound = errors.New("rule version not found") + ErrRollbackToDelete = errors.New("cannot roll back to a deleted rule version") +) + +type Version struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + RuleKind coremodel.ResourceKind `json:"ruleKind" gorm:"type:varchar(64);not null;index:idx_rk_key_created,priority:1;uniqueIndex:uk_rk_key_ver,priority:1;index:idx_rk_hash,priority:1"` + Mesh string `json:"mesh" gorm:"type:varchar(128);not null"` + ResourceKey string `json:"resourceKey" gorm:"type:varchar(512);not null;index:idx_rk_key_created,priority:2;uniqueIndex:uk_rk_key_ver,priority:2"` + RuleName string `json:"ruleName" gorm:"type:varchar(256);not null"` + VersionNo int64 `json:"versionNo" gorm:"not null;uniqueIndex:uk_rk_key_ver,priority:3"` + ContentHash string `json:"contentHash" gorm:"type:char(64);not null;index:idx_rk_hash,priority:2"` + SpecJSON string `json:"specJson" gorm:"type:text;not null"` + Source Source `json:"source" gorm:"type:varchar(16);not null"` + Operation Operation `json:"operation" gorm:"type:varchar(16);not null"` + Author string `json:"author" gorm:"type:varchar(128);not null"` + Reason string `json:"reason,omitempty" gorm:"type:varchar(1024)"` + RolledBackFromID *int64 `json:"rolledBackFromId,omitempty"` + CreatedAt time.Time `json:"createdAt" gorm:"not null;index:idx_rk_key_created,priority:3,sort:desc"` + IsCurrent bool `json:"isCurrent" gorm:"-"` +} + +func (Version) TableName() string { + return "rule_version" +} + +type Meta struct { + RuleKind coremodel.ResourceKind `json:"ruleKind" gorm:"type:varchar(64);primaryKey"` + ResourceKey string `json:"resourceKey" gorm:"type:varchar(512);primaryKey"` + CurrentVersion *int64 `json:"currentVersion"` + LastVersionNo int64 `json:"lastVersionNo" gorm:"not null;default:0"` + UpdatedAt time.Time `json:"updatedAt" gorm:"not null"` +} + +func (Meta) TableName() string { + return "rule_version_meta" +} + +type InsertRequest struct { + RuleKind coremodel.ResourceKind + Mesh string + ResourceKey string + RuleName string + SpecJSON string + ContentHash string + Source Source + Operation Operation + Author string + Reason string + RolledBackFromID *int64 + CreatedAt time.Time +} + +type ListResult struct { + Items []Version `json:"items"` + Total int64 `json:"total"` +} + +type DiffResult struct { + Left DiffSide `json:"left"` + Right DiffSide `json:"right"` +} + +type DiffSide struct { + ID int64 `json:"id"` + VersionNo int64 `json:"versionNo"` + SpecJSON string `json:"specJson"` +} + +type ConflictError struct { + CurrentVersionID *int64 +} + +func (e *ConflictError) Error() string { + return ErrVersionConflict.Error() +} diff --git a/pkg/core/versioning/versioning_test.go b/pkg/core/versioning/versioning_test.go new file mode 100644 index 000000000..2ed408cff --- /dev/null +++ b/pkg/core/versioning/versioning_test.go @@ -0,0 +1,528 @@ +/* + * 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 versioning + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/cache" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + appconfig "github.com/apache/dubbo-admin/pkg/config/app" + eventbusconfig "github.com/apache/dubbo-admin/pkg/config/eventbus" + "github.com/apache/dubbo-admin/pkg/core/events" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/resource/model" + coreruntime "github.com/apache/dubbo-admin/pkg/core/runtime" + "github.com/apache/dubbo-admin/pkg/core/store/index" +) + +func TestNormalizeSpecHashStable(t *testing.T) { + hash1, spec1, err := NormalizeSpec(&meshproto.ConditionRoute{ + Enabled: true, + Conditions: []string{"host = 127.0.0.1"}, + Key: "demo", + }) + require.NoError(t, err) + hash2, spec2, err := NormalizeSpec(&meshproto.ConditionRoute{ + Key: "demo", + Conditions: []string{"host = 127.0.0.1"}, + Enabled: true, + }) + require.NoError(t, err) + require.Equal(t, spec1, spec2) + require.Equal(t, hash1, hash2) + require.NotEmpty(t, hash1) +} + +func TestAdminHintTTLHitAndMiss(t *testing.T) { + now := time.Now() + reg := NewAdminHintRegistry() + reg.now = func() time.Time { return now } + reg.Put(meshresource.ConditionRouteKind, "m/r", "hash", AdminHint{ + Source: SourceAdmin, + Author: "alice", + ExpiresAt: now.Add(time.Second), + }) + hint, ok := reg.Take(meshresource.ConditionRouteKind, "m/r", "hash") + require.True(t, ok) + require.Equal(t, "alice", hint.Author) + _, ok = reg.Take(meshresource.ConditionRouteKind, "m/r", "hash") + require.False(t, ok) + + reg.Put(meshresource.ConditionRouteKind, "m/r", "expired", AdminHint{ExpiresAt: now.Add(-time.Second)}) + _, ok = reg.Take(meshresource.ConditionRouteKind, "m/r", "expired") + require.False(t, ok) +} + +func TestMemoryStoreRetentionCurrentPointerAndDelete(t *testing.T) { + store := NewMemoryStore() + key := "mesh/demo.condition-router" + for i := 0; i < 4; i++ { + hash, specJSON, err := NormalizeSpec(&meshproto.ConditionRoute{Key: "demo", Priority: int32(i + 1)}) + require.NoError(t, err) + _, err = store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: specJSON, + ContentHash: hash, + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now().Add(time.Duration(i) * time.Second), + }, 2) + require.NoError(t, err) + } + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, int64(4), items[0].VersionNo) + require.Equal(t, int64(3), items[1].VersionNo) + require.True(t, items[0].IsCurrent) + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Equal(t, int64(4), meta.LastVersionNo) + + _, err = store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationDelete, + Author: "alice", + CreatedAt: time.Now().Add(5 * time.Second), + }, 2) + require.NoError(t, err) + meta, err = store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Nil(t, meta.CurrentVersion) + require.Equal(t, int64(5), meta.LastVersionNo) +} + +func TestMemoryStoreDeleteIsNotDedupedAgainstEmptyCurrentSpec(t *testing.T) { + store := NewMemoryStore() + key := "mesh/demo.condition-router" + created, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationCreate, + Author: "alice", + CreatedAt: time.Now(), + }, 5) + require.NoError(t, err) + deleted, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationDelete, + Author: "alice", + CreatedAt: time.Now().Add(time.Second), + }, 5) + require.NoError(t, err) + + require.NotEqual(t, created.ID, deleted.ID) + require.Equal(t, int64(2), deleted.VersionNo) + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, OperationDelete, items[0].Operation) + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Nil(t, meta.CurrentVersion) +} + +func TestSubscriberCoalescesBursts(t *testing.T) { + store := NewMemoryStore() + hints := NewAdminHintRegistry() + sub := NewSubscriber(meshresource.ConditionRouteKind, store, hints, 5, time.Hour) + key := "mesh/demo.condition-router" + first := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + first.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + second := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + second.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, nil, first))) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, first, second))) + sub.FlushAll() + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 1) + require.Contains(t, items[0].SpecJSON, `"priority":2`) +} + +func TestSubscriberCapturesAdminAndUpstreamSources(t *testing.T) { + store := NewMemoryStore() + hints := NewAdminHintRegistry() + sub := NewSubscriber(meshresource.ConditionRouteKind, store, hints, 5, 0) + adminRes := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + adminRes.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + hash, _, err := NormalizeResource(adminRes) + require.NoError(t, err) + hints.Put(adminRes.ResourceKind(), adminRes.ResourceKey(), hash, AdminHint{ + Source: SourceAdmin, + Author: "alice", + Operation: OperationCreate, + ExpiresAt: time.Now().Add(time.Second), + }) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, adminRes))) + upstreamRes := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + upstreamRes.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, adminRes, upstreamRes))) + items, err := store.ListVersions(meshresource.ConditionRouteKind, adminRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, SourceUpstream, items[0].Source) + require.Equal(t, SourceAdmin, items[1].Source) + require.Equal(t, "alice", items[1].Author) +} + +func TestSubscriberRespectsRegistrySourceContext(t *testing.T) { + store := NewMemoryStore() + hints := NewAdminHintRegistry() + sub := NewSubscriber(meshresource.ConditionRouteKind, store, hints, 5, 0) + + upstreamRes := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + upstreamRes.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEventWithContext( + cache.Updated, + nil, + upstreamRes, + map[string]string{"source-registry": "zookeeper"}, + ))) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, upstreamRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, SourceUpstream, items[0].Source) + require.Equal(t, "system:zookeeper", items[0].Author) +} + +func TestSubscriberRecordsDeleteWithAdminHintSnapshot(t *testing.T) { + store := NewMemoryStore() + hints := NewAdminHintRegistry() + svc := NewService(true, 5, 0, time.Second, store, hints) + sub := NewSubscriber(meshresource.TagRouteKind, store, hints, 5, 0) + + res := meshresource.NewTagRouteResourceWithAttributes("demo.tag-router", "mesh") + res.Spec = &meshproto.TagRoute{ + Key: "demo", + Priority: 7, + } + require.NoError(t, svc.PutAdminHint(res, OperationDelete, SourceAdmin, "alice", "", nil)) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Deleted, res, nil))) + + items, err := store.ListVersions(meshresource.TagRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, OperationDelete, items[0].Operation) + require.Equal(t, SourceAdmin, items[0].Source) + require.Equal(t, "alice", items[0].Author) + require.Equal(t, DeleteSpecJSON, items[0].SpecJSON) + require.Equal(t, HashSpecJSON(DeleteSpecJSON), items[0].ContentHash) + require.Equal(t, res.ResourceKey(), items[0].ResourceKey) + require.Equal(t, res.ResourceMeta().Name, items[0].RuleName) + + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Deleted, res, nil))) + items, err = store.ListVersions(meshresource.TagRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) +} + +func TestServiceRollbackWaitsForAsyncEventBusSubscriber(t *testing.T) { + store := NewMemoryStore() + hints := NewAdminHintRegistry() + svc := NewServiceWithRollbackWait(true, 5, 0, time.Second, 200*time.Millisecond, store, hints) + sub := NewSubscriber(meshresource.ConditionRouteKind, store, hints, 5, 0) + bus := newTestEventBus(t) + defer bus.WaitForDone() + require.NoError(t, bus.Subscribe(sub)) + require.NoError(t, bus.Start(nil, nil)) + + original := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + original.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, original))) + + updated := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + updated.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, original, updated))) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, original.ResourceKey()) + require.NoError(t, err) + targetID := items[1].ID + current := items[0].ID + + rollback, err := svc.Rollback(context.Background(), eventBusVersionResourceManager{ + emitter: bus, + }, meshresource.ConditionRouteKind, "mesh", "demo.condition-router", targetID, "restore previous rule", ¤t, "alice") + require.NoError(t, err) + require.Equal(t, SourceRollback, rollback.Source) +} + +func TestServiceRollbackTimesOutWaitingForVersionRow(t *testing.T) { + store := NewMemoryStore() + hints := NewAdminHintRegistry() + svc := NewServiceWithRollbackWait(true, 5, 0, time.Second, 40*time.Millisecond, store, hints) + sub := NewSubscriber(meshresource.ConditionRouteKind, store, hints, 5, 0) + original := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + original.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, original))) + updated := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + updated.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, original, updated))) + items, err := store.ListVersions(meshresource.ConditionRouteKind, original.ResourceKey()) + require.NoError(t, err) + targetID := items[1].ID + current := items[0].ID + + start := time.Now() + _, err = svc.Rollback(context.Background(), fakeNoopResourceManager{}, meshresource.ConditionRouteKind, "mesh", "demo.condition-router", targetID, "restore previous rule", ¤t, "alice") + require.Error(t, err) + require.Contains(t, err.Error(), "timeout waiting for rollback version row") + require.Less(t, time.Since(start), 500*time.Millisecond) +} + +func TestServiceRollbackCreatesRollbackVersionAndExpectedConflict(t *testing.T) { + store := NewMemoryStore() + hints := NewAdminHintRegistry() + svc := NewService(true, 5, 0, time.Second, store, hints) + sub := NewSubscriber(meshresource.ConditionRouteKind, store, hints, 5, 0) + original := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + original.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, original))) + updated := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + updated.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, original, updated))) + items, err := store.ListVersions(meshresource.ConditionRouteKind, original.ResourceKey()) + require.NoError(t, err) + targetID := items[1].ID + wrongExpected := int64(999) + _, err = svc.Rollback(context.Background(), fakeVersionResourceManager{subscriber: sub}, meshresource.ConditionRouteKind, "mesh", "demo.condition-router", targetID, "restore previous rule", &wrongExpected, "alice") + var conflict *ConflictError + require.ErrorAs(t, err, &conflict) + + current := items[0].ID + rollback, err := svc.Rollback(context.Background(), fakeVersionResourceManager{subscriber: sub}, meshresource.ConditionRouteKind, "mesh", "demo.condition-router", targetID, "restore previous rule", ¤t, "alice") + require.NoError(t, err) + require.Equal(t, SourceRollback, rollback.Source) + require.NotNil(t, rollback.RolledBackFromID) + require.Equal(t, targetID, *rollback.RolledBackFromID) + require.Equal(t, "restore previous rule", rollback.Reason) +} + +func TestServiceRollbackReturnsWhenContextCanceled(t *testing.T) { + store := NewMemoryStore() + hints := NewAdminHintRegistry() + svc := NewService(true, 5, time.Hour, time.Second, store, hints) + sub := NewSubscriber(meshresource.ConditionRouteKind, store, hints, 5, 0) + original := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + original.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, original))) + updated := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + updated.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, original, updated))) + items, err := store.ListVersions(meshresource.ConditionRouteKind, original.ResourceKey()) + require.NoError(t, err) + targetID := items[1].ID + current := items[0].ID + rollbackCtx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + _, err = svc.Rollback(rollbackCtx, fakeNoopResourceManager{}, meshresource.ConditionRouteKind, "mesh", "demo.condition-router", targetID, "restore previous rule", ¤t, "alice") + + require.ErrorIs(t, err, context.Canceled) + require.Less(t, time.Since(start), 500*time.Millisecond) +} + +func TestDisabledServiceHistoryReturnsFeatureDisabled(t *testing.T) { + svc := NewService(false, 5, 0, time.Second, NewMemoryStore(), NewAdminHintRegistry()) + _, err := svc.List(meshresource.ConditionRouteKind, "mesh", "demo.condition-router") + require.ErrorIs(t, err, ErrFeatureDisabled) +} + +type fakeVersionResourceManager struct { + subscriber *Subscriber +} + +func (f fakeVersionResourceManager) GetByKey(model.ResourceKind, string) (model.Resource, bool, error) { + return nil, false, nil +} + +func (f fakeVersionResourceManager) GetByKeys(model.ResourceKind, []string) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeVersionResourceManager) List(model.ResourceKind) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeVersionResourceManager) ListByIndexes(model.ResourceKind, []index.IndexCondition) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeVersionResourceManager) PageListByIndexes(model.ResourceKind, []index.IndexCondition, model.PageReq) (*model.PageData[model.Resource], error) { + return nil, nil +} + +func (f fakeVersionResourceManager) Add(r model.Resource) error { + return f.subscriber.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, r)) +} + +func (f fakeVersionResourceManager) Update(r model.Resource) error { + return f.subscriber.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, nil, r)) +} + +func (f fakeVersionResourceManager) Upsert(r model.Resource) error { + return f.Update(r) +} + +func (f fakeVersionResourceManager) DeleteByKey(model.ResourceKind, string, string) error { + return nil +} + +type fakeNoopResourceManager struct{} + +func (f fakeNoopResourceManager) GetByKey(model.ResourceKind, string) (model.Resource, bool, error) { + return nil, false, nil +} + +func (f fakeNoopResourceManager) GetByKeys(model.ResourceKind, []string) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeNoopResourceManager) List(model.ResourceKind) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeNoopResourceManager) ListByIndexes(model.ResourceKind, []index.IndexCondition) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeNoopResourceManager) PageListByIndexes(model.ResourceKind, []index.IndexCondition, model.PageReq) (*model.PageData[model.Resource], error) { + return nil, nil +} + +func (f fakeNoopResourceManager) Add(model.Resource) error { + return nil +} + +func (f fakeNoopResourceManager) Update(model.Resource) error { + return nil +} + +func (f fakeNoopResourceManager) Upsert(model.Resource) error { + return nil +} + +func (f fakeNoopResourceManager) DeleteByKey(model.ResourceKind, string, string) error { + return nil +} + +type eventBusVersionResourceManager struct { + emitter events.Emitter +} + +func (f eventBusVersionResourceManager) GetByKey(model.ResourceKind, string) (model.Resource, bool, error) { + return nil, false, nil +} + +func (f eventBusVersionResourceManager) GetByKeys(model.ResourceKind, []string) ([]model.Resource, error) { + return nil, nil +} + +func (f eventBusVersionResourceManager) List(model.ResourceKind) ([]model.Resource, error) { + return nil, nil +} + +func (f eventBusVersionResourceManager) ListByIndexes(model.ResourceKind, []index.IndexCondition) ([]model.Resource, error) { + return nil, nil +} + +func (f eventBusVersionResourceManager) PageListByIndexes(model.ResourceKind, []index.IndexCondition, model.PageReq) (*model.PageData[model.Resource], error) { + return nil, nil +} + +func (f eventBusVersionResourceManager) Add(r model.Resource) error { + f.emitter.Send(events.NewResourceChangedEvent(cache.Added, nil, r)) + return nil +} + +func (f eventBusVersionResourceManager) Update(r model.Resource) error { + f.emitter.Send(events.NewResourceChangedEvent(cache.Updated, nil, r)) + return nil +} + +func (f eventBusVersionResourceManager) Upsert(r model.Resource) error { + return f.Update(r) +} + +func (f eventBusVersionResourceManager) DeleteByKey(model.ResourceKind, string, string) error { + return nil +} + +type testEventBus interface { + events.EventBusComponent + coreruntime.GracefulComponent +} + +func newTestEventBus(t *testing.T) testEventBus { + t.Helper() + prototype, err := coreruntime.ComponentRegistry().EventBus() + require.NoError(t, err) + bus := reflect.New(reflect.TypeOf(prototype).Elem()).Interface().(testEventBus) + bufferSize := uint(1) + require.NoError(t, bus.Init(testBuilderContext{ + cfg: appconfig.AdminConfig{ + EventBus: &eventbusconfig.Config{BufferSize: bufferSize}, + }, + })) + return bus +} + +type testBuilderContext struct { + cfg appconfig.AdminConfig +} + +func (c testBuilderContext) Config() appconfig.AdminConfig { + return c.cfg +} + +func (c testBuilderContext) GetActivatedComponent(coreruntime.ComponentType) (coreruntime.Component, error) { + return nil, nil +} + +func (c testBuilderContext) ActivateComponent(coreruntime.Component) error { + return nil +} From 43567afd7b3c3ad48ee83c24342df045c53eab96 Mon Sep 17 00:00:00 2001 From: MoChengqian <2972013548@qq.com> Date: Thu, 21 May 2026 16:52:11 +0800 Subject: [PATCH 04/30] feat(versioning): UI history drawer, diff, and rollback for rule pages --- ui-vue3/src/api/service/traffic.ts | 177 ++++++++++++++-- ui-vue3/src/base/http/request.ts | 8 +- .../views/traffic/_shared/RuleDiffEditor.vue | 88 ++++++++ .../traffic/_shared/RuleHistoryDrawer.vue | 102 +++++++++ .../traffic/_shared/RuleHistoryPanel.vue | 199 ++++++++++++++++++ .../src/views/traffic/_shared/ruleVersion.ts | 112 ++++++++++ .../src/views/traffic/dynamicConfig/index.vue | 10 +- .../traffic/dynamicConfig/tabs/YAMLView.vue | 82 ++++++-- .../traffic/dynamicConfig/tabs/formView.vue | 93 +++++++- .../src/views/traffic/routingRule/index.vue | 12 +- .../traffic/routingRule/tabs/formView.vue | 63 +++--- .../routingRule/tabs/updateByFormView.vue | 30 ++- .../routingRule/tabs/updateByYAMLView.vue | 28 ++- ui-vue3/src/views/traffic/tagRule/index.vue | 12 +- .../views/traffic/tagRule/tabs/formView.vue | 34 ++- .../traffic/tagRule/tabs/updateByFormView.vue | 30 ++- .../traffic/tagRule/tabs/updateByYAMLView.vue | 28 ++- 17 files changed, 999 insertions(+), 109 deletions(-) create mode 100644 ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue create mode 100644 ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue create mode 100644 ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue create mode 100644 ui-vue3/src/views/traffic/_shared/ruleVersion.ts diff --git a/ui-vue3/src/api/service/traffic.ts b/ui-vue3/src/api/service/traffic.ts index 9d4ff0a29..a62929ffa 100644 --- a/ui-vue3/src/api/service/traffic.ts +++ b/ui-vue3/src/api/service/traffic.ts @@ -17,6 +17,111 @@ import request from '@/base/http/request' +export type TrafficRuleKind = 'condition-rule' | 'tag-rule' | 'configurator' + +export interface RuleVersion { + id: number + ruleKind: string + mesh: string + resourceKey: string + ruleName: string + versionNo: number + contentHash: string + specJson: string + source: 'ADMIN' | 'UPSTREAM' | 'ROLLBACK' | 'BOOTSTRAP' | string + operation: 'CREATE' | 'UPDATE' | 'DELETE' | string + author: string + reason?: string + rolledBackFromId?: number + createdAt: string + isCurrent: boolean +} + +export interface RuleVersionList { + items: RuleVersion[] + total: number +} + +export interface RuleVersionDiffSide { + id: number + versionNo: number + specJson: string +} + +export interface RuleVersionDiff { + left: RuleVersionDiffSide + right: RuleVersionDiffSide +} + +export interface RuleMutationOptions { + expectedVersionId?: number +} + +export interface RuleRollbackRequest extends RuleMutationOptions { + reason: string +} + +export interface VersionConflictError { + code: 'VERSION_CONFLICT' + message: string + currentVersionId?: number | null +} + +const ruleNameForPath = (kind: TrafficRuleKind, ruleName: string): string => { + return kind === 'configurator' ? encodeURIComponent(ruleName) : ruleName +} + +const withExpectedVersion = (options?: RuleMutationOptions) => { + return options?.expectedVersionId ? { expectedVersionId: options.expectedVersionId } : undefined +} + +export const listRuleVersionsAPI = ( + kind: TrafficRuleKind, + ruleName: string +): Promise<{ code: string; data: RuleVersionList }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions`, + method: 'get' + }) +} + +export const getRuleVersionAPI = ( + kind: TrafficRuleKind, + ruleName: string, + versionId: number +): Promise<{ code: string; data: RuleVersion }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionId}`, + method: 'get' + }) +} + +export const diffRuleVersionAPI = ( + kind: TrafficRuleKind, + ruleName: string, + versionId: number, + against = 'current' +): Promise<{ code: string; data: RuleVersionDiff }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionId}/diff`, + method: 'get', + params: { against } + }) +} + +export const rollbackRuleVersionAPI = ( + kind: TrafficRuleKind, + ruleName: string, + versionId: number, + data: RuleRollbackRequest +): Promise<{ code: string; data: RuleVersion }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionId}/rollback`, + method: 'post', + data + }) +} + export const searchRoutingRule = (params: any): Promise => { return request({ url: '/condition-rule/search', @@ -34,28 +139,42 @@ export const getConditionRuleDetailAPI = (ruleName: string): Promise => { } // Delete condition routing. -export const deleteConditionRuleAPI = (ruleName: string): Promise => { +export const deleteConditionRuleAPI = ( + ruleName: string, + options?: RuleMutationOptions +): Promise => { return request({ url: `/condition-rule/${ruleName}`, - method: 'delete' + method: 'delete', + params: withExpectedVersion(options) }) } // update condition routing. -export const updateConditionRuleAPI = (ruleName: string, data: any): Promise => { +export const updateConditionRuleAPI = ( + ruleName: string, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/condition-rule/${ruleName}`, method: 'put', - data + data, + params: withExpectedVersion(options) }) } // add condition routing. -export const addConditionRuleAPI = (ruleName: string, data: any): Promise => { +export const addConditionRuleAPI = ( + ruleName: string, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/condition-rule/${ruleName}`, method: 'post', - data + data, + params: withExpectedVersion(options) }) } @@ -68,10 +187,11 @@ export const searchTagRule = (params: any): Promise => { } // Delete tag routing. -export const deleteTagRuleAPI = (ruleName: string): Promise => { +export const deleteTagRuleAPI = (ruleName: string, options?: RuleMutationOptions): Promise => { return request({ url: `/tag-rule/${ruleName}`, - method: 'delete' + method: 'delete', + params: withExpectedVersion(options) }) } @@ -83,19 +203,29 @@ export const getTagRuleDetailAPI = (ruleName: string): Promise => { }) } -export const updateTagRuleAPI = (ruleName: string, data: any): Promise => { +export const updateTagRuleAPI = ( + ruleName: string, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/tag-rule/${ruleName}`, method: 'put', - data + data, + params: withExpectedVersion(options) }) } -export const addTagRuleAPI = (ruleName: string, data: any): Promise => { +export const addTagRuleAPI = ( + ruleName: string, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/tag-rule/${ruleName}`, method: 'post', - data + data, + params: withExpectedVersion(options) }) } @@ -129,24 +259,35 @@ export const getConfiguratorDetail = (params: any): Promise => { method: 'get' }) } -export const saveConfiguratorDetail = (params: any, data: any): Promise => { +export const saveConfiguratorDetail = ( + params: any, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/configurator/${encodeURIComponent(params.name)}`, method: 'put', - data + data, + params: withExpectedVersion(options) }) } -export const addConfiguratorDetail = (params: any, data: any): Promise => { +export const addConfiguratorDetail = ( + params: any, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/configurator/${encodeURIComponent(params.name)}`, method: 'post', - data + data, + params: withExpectedVersion(options) }) } -export const delConfiguratorDetail = (params: any): Promise => { +export const delConfiguratorDetail = (params: any, options?: RuleMutationOptions): Promise => { return request({ url: `/configurator/${encodeURIComponent(params.name)}`, - method: 'delete' + method: 'delete', + params: withExpectedVersion(options) }) } diff --git a/ui-vue3/src/base/http/request.ts b/ui-vue3/src/base/http/request.ts index 094a76cdd..398ac93f3 100644 --- a/ui-vue3/src/base/http/request.ts +++ b/ui-vue3/src/base/http/request.ts @@ -39,6 +39,10 @@ const isSilentErrorUrl = (url?: string): boolean => { return SILENT_ERROR_URLS.some((silentUrl) => url.includes(silentUrl)) } +const shouldShowErrorMessage = (url?: string, code?: string): boolean => { + return !isSilentErrorUrl(url) && code !== 'VERSION_CONFLICT' +} + const service: AxiosInstance = axios.create({ baseURL: '/api/v1', timeout: 30 * 1000 @@ -82,7 +86,7 @@ response.use( // Show error toast message const errorMsg = `${response.data.code}:${response.data.message}` - if (!isSilentErrorUrl(response.config.url)) { + if (shouldShowErrorMessage(response.config.url, response.data.code)) { message.error(errorMsg) } console.error(errorMsg) @@ -120,7 +124,7 @@ response.use( } if (response?.data) { const errorMsg = `${response.data?.code}:${response.data?.message}` - if (!isSilentErrorUrl(error.config?.url)) { + if (shouldShowErrorMessage(error.config?.url, response.data?.code)) { message.error(errorMsg) } console.error(errorMsg) diff --git a/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue b/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue new file mode 100644 index 000000000..5c4e10ba1 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue @@ -0,0 +1,88 @@ + + + + + + + diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue new file mode 100644 index 000000000..a383d2921 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue @@ -0,0 +1,102 @@ + + + + + + + diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue new file mode 100644 index 000000000..14461cd33 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/ui-vue3/src/views/traffic/_shared/ruleVersion.ts b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts new file mode 100644 index 000000000..fc8dacaa2 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts @@ -0,0 +1,112 @@ +/* + * 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. + */ + +import { h } from 'vue' +import { Button, notification } from 'ant-design-vue' +import { + listRuleVersionsAPI, + type RuleVersion, + type TrafficRuleKind, + type VersionConflictError +} from '@/api/service/traffic' +import { HTTP_STATUS } from '@/base/http/constants' + +export const currentVersionIdFromItems = (items: RuleVersion[]): number | undefined => { + return items.find((item) => item.isCurrent)?.id || items[0]?.id +} + +export interface CurrentVersionState { + id?: number + versionNo?: number +} + +export const currentVersionStateFromItems = (items: RuleVersion[]): CurrentVersionState => { + const current = items.find((item) => item.isCurrent) || items[0] + return { + id: current?.id, + versionNo: current?.versionNo + } +} + +export const fetchCurrentVersionId = async ( + kind: TrafficRuleKind, + ruleName: string +): Promise => { + return (await fetchCurrentVersionState(kind, ruleName)).id +} + +export const fetchCurrentVersionState = async ( + kind: TrafficRuleKind, + ruleName: string +): Promise => { + try { + const res = await listRuleVersionsAPI(kind, ruleName) + if (res.code === HTTP_STATUS.SUCCESS) { + return currentVersionStateFromItems(res.data?.items || []) + } + } catch (e: any) { + if (e?.code !== 'FEATURE_DISABLED') { + throw e + } + } + return {} +} + +export const isVersionConflict = (e: any): e is VersionConflictError => { + return e?.code === 'VERSION_CONFLICT' +} + +export const notifyVersionConflict = ( + e: any, + options?: { reload?: () => void | Promise } +): boolean => { + if (isVersionConflict(e)) { + notification.warning({ + key: 'rule-version-conflict', + message: '版本冲突', + description: '规则已被其他操作更新,请重新加载当前版本后再提交。', + btn: options?.reload + ? () => + h( + Button, + { + type: 'link', + size: 'small', + onClick: () => { + notification.close('rule-version-conflict') + options.reload?.() + } + }, + { default: () => 'Reload' } + ) + : undefined + }) + return true + } + return false +} + +export const formatRuleSpec = (specJson?: string): string => { + if (!specJson) { + return '' + } + try { + return JSON.stringify(JSON.parse(specJson), null, 2) + } catch (e) { + return specJson + } +} diff --git a/ui-vue3/src/views/traffic/dynamicConfig/index.vue b/ui-vue3/src/views/traffic/dynamicConfig/index.vue index baad34d02..235be8dda 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/index.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/index.vue @@ -73,6 +73,7 @@ import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject' import { useRouter } from 'vue-router' import { PRIMARY_COLOR } from '@/base/constants' import { Icon } from '@iconify/vue' +import { fetchCurrentVersionState, notifyVersionConflict } from '../_shared/ruleVersion' const router = useRouter() @@ -143,8 +144,13 @@ onMounted(async () => { }) const delDynamicConfig = async (record: any) => { - await delConfiguratorDetail({ name: record.ruleName }) - await searchDomain.onSearch() + try { + const expectedVersionId = (await fetchCurrentVersionState('configurator', record.ruleName)).id + await delConfiguratorDetail({ name: record.ruleName }, { expectedVersionId }) + await searchDomain.onSearch() + } catch (e: any) { + notifyVersionConflict(e, { reload: () => searchDomain.onSearch() }) + } } provide(PROVIDE_INJECT_KEY.SEARCH_DOMAIN, searchDomain) diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue index df8ea5257..3a52978e1 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue @@ -21,19 +21,16 @@ - - - - - - - - - - - - - + + + + + current v{{ currentVersionNo }} + + + Version history + + @@ -71,11 +68,21 @@ 保存 重置 + + diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue deleted file mode 100644 index 8d5484fc3..000000000 --- a/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue deleted file mode 100644 index 3f33639f4..000000000 --- a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue +++ /dev/null @@ -1,202 +0,0 @@ - - - - - diff --git a/ui-vue3/src/views/traffic/_shared/ruleVersion.ts b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts deleted file mode 100644 index 1b0ab8ca4..000000000 --- a/ui-vue3/src/views/traffic/_shared/ruleVersion.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * 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. - */ - -import { h } from 'vue' -import { Button, notification } from 'ant-design-vue' -import { - listRuleVersionsAPI, - type RuleVersion, - type TrafficRuleKind, - type VersionConflictError -} from '@/api/service/traffic' -import { HTTP_STATUS } from '@/base/http/constants' - -export interface CurrentVersionState { - id?: number - versionNo?: number -} - -export const currentVersionStateFromItems = (items: RuleVersion[]): CurrentVersionState => { - const current = items.find((item) => item.isCurrent) - return { - id: current?.id, - versionNo: current?.versionNo - } -} - -export const fetchCurrentVersionState = async ( - kind: TrafficRuleKind, - ruleName: string -): Promise => { - try { - const res = await listRuleVersionsAPI(kind, ruleName) - if (res.code === HTTP_STATUS.SUCCESS) { - return currentVersionStateFromItems(res.data?.items || []) - } - } catch (e: any) { - if (e?.code !== 'FEATURE_DISABLED') { - throw e - } - } - return {} -} - -export const isVersionConflict = (e: any): e is VersionConflictError => { - return e?.code === 'VERSION_CONFLICT' || e?.code === 'VERSION_LEDGER_PENDING' -} - -export const notifyVersionConflict = ( - e: any, - options?: { reload?: () => void | Promise } -): boolean => { - if (isVersionConflict(e)) { - notification.warning({ - key: 'rule-version-conflict', - duration: 0, - message: '版本冲突', - description: '规则已被其他操作更新,请重新加载当前版本后再提交。', - btn: options?.reload - ? () => - h( - Button, - { - type: 'link', - size: 'small', - onClick: () => { - notification.close('rule-version-conflict') - options.reload?.() - } - }, - { default: () => 'Reload' } - ) - : undefined - }) - return true - } - return false -} - -export const formatRuleSpec = (specJson?: string): string => { - if (!specJson) { - return '' - } - try { - return JSON.stringify(JSON.parse(specJson), null, 2) - } catch (e) { - return specJson - } -} diff --git a/ui-vue3/src/views/traffic/dynamicConfig/index.vue b/ui-vue3/src/views/traffic/dynamicConfig/index.vue index 235be8dda..baad34d02 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/index.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/index.vue @@ -73,7 +73,6 @@ import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject' import { useRouter } from 'vue-router' import { PRIMARY_COLOR } from '@/base/constants' import { Icon } from '@iconify/vue' -import { fetchCurrentVersionState, notifyVersionConflict } from '../_shared/ruleVersion' const router = useRouter() @@ -144,13 +143,8 @@ onMounted(async () => { }) const delDynamicConfig = async (record: any) => { - try { - const expectedVersionId = (await fetchCurrentVersionState('configurator', record.ruleName)).id - await delConfiguratorDetail({ name: record.ruleName }, { expectedVersionId }) - await searchDomain.onSearch() - } catch (e: any) { - notifyVersionConflict(e, { reload: () => searchDomain.onSearch() }) - } + await delConfiguratorDetail({ name: record.ruleName }) + await searchDomain.onSearch() } provide(PROVIDE_INJECT_KEY.SEARCH_DOMAIN, searchDomain) diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue index 3a52978e1..df8ea5257 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue @@ -21,16 +21,19 @@ - - - - - current v{{ currentVersionNo }} - - - Version history - - + + + + + + + + + + + + + @@ -68,21 +71,11 @@ 保存 重置 - - diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue new file mode 100644 index 000000000..2ecd955f5 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue @@ -0,0 +1,165 @@ + + + + + + + diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue new file mode 100644 index 000000000..85c0b520d --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue @@ -0,0 +1,356 @@ + + + + + + + diff --git a/ui-vue3/src/views/traffic/_shared/ruleVersion.ts b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts new file mode 100644 index 000000000..a46d9872e --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts @@ -0,0 +1,288 @@ +/* + * 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. + */ + +import { h } from 'vue' +import { Button, Input, Modal, notification, Space } from 'ant-design-vue' +import { + abandonRuleVersionIntentAPI, + listRuleVersionsAPI, + repairRuleVersionIntentAPI, + type RuleVersion, + type TrafficRuleKind, + type VersionConflictError, + type VersionLedgerPendingError +} from '@/api/service/traffic' +import { HTTP_STATUS } from '@/base/http/constants' +import { i18n } from '@/base/i18n' + +export interface CurrentVersionState { + id?: string + versionNo?: number + deleted: boolean +} + +export const currentVersionStateFromItems = (items: RuleVersion[]): CurrentVersionState => { + const current = items.find((item) => item.isCurrent) + return { + id: current?.id, + versionNo: current?.versionNo, + deleted: items.length > 0 && !current + } +} + +export const rollbackExpectedVersionId = (state: CurrentVersionState): string | undefined => { + if (state.id !== undefined) { + return state.id + } + return state.deleted ? '0' : undefined +} + +export const versionDiffLabel = (prefix: string, versionNo?: number): string => + typeof versionNo === 'number' ? `${prefix} v${versionNo}` : prefix + +export const normalizeIntentReason = (reason: string): string => reason.trim() + +export const isCurrentHistoryRequest = ( + requestSeq: number, + latestSeq: number, + disposed: boolean +) => { + return !disposed && requestSeq === latestSeq +} + +export const fetchCurrentVersionState = async ( + kind: TrafficRuleKind, + ruleName: string +): Promise => { + try { + const res = await listRuleVersionsAPI(kind, ruleName) + if (res.code === HTTP_STATUS.SUCCESS) { + return currentVersionStateFromItems(res.data?.items || []) + } + } catch (e: any) { + if (e?.code !== 'FEATURE_DISABLED') { + throw e + } + } + return { deleted: false } +} + +export const isVersionConflict = (e: any): e is VersionConflictError => { + return e?.code === 'VERSION_CONFLICT' +} + +export const isVersionLedgerPending = (e: any): e is VersionLedgerPendingError => { + return e?.code === 'VERSION_LEDGER_PENDING' +} + +export const isFeatureDisabled = (e: any): boolean => { + return e?.code === 'FEATURE_DISABLED' +} + +const t = (key: string, params?: Record) => i18n.global.t(key, params) + +export const ruleVersionErrorMessage = (e: any): string => e?.message || String(e) + +const repairingIntentIds = new Set() + +const openAbandonReasonModal = ( + intentId: string, + options?: { reload?: () => void | Promise } +) => { + let reason = '' + let submitting = false + const modal = Modal.confirm({ + title: t('ruleVersionDomain.abandonIntentTitle'), + content: () => + h(Input.TextArea, { + rows: 3, + maxlength: 1024, + placeholder: t('ruleVersionDomain.abandonReasonPlaceholder'), + onChange: (event: Event) => { + reason = (event.target as HTMLTextAreaElement).value + } + }), + okText: t('ruleVersionDomain.abandon'), + cancelText: t('ruleVersionDomain.cancel'), + okButtonProps: { danger: true }, + async onOk() { + if (submitting) { + return Promise.reject() + } + const trimmed = normalizeIntentReason(reason) + if (!trimmed) { + notification.warning({ + key: 'rule-version-abandon-reason-required', + message: t('ruleVersionDomain.abandonReasonRequired') + }) + return Promise.reject() + } + submitting = true + modal.update({ okButtonProps: { danger: true, loading: true } }) + try { + await abandonRuleVersionIntentAPI(intentId, trimmed) + notification.close('rule-version-ledger-pending') + notification.close('rule-version-abandon-reason-required') + await options?.reload?.() + } catch (e: any) { + notification.error({ + key: 'rule-version-abandon-error', + message: t('ruleVersionDomain.abandonFailed'), + description: e?.message || String(e) + }) + submitting = false + modal.update({ okButtonProps: { danger: true, loading: false } }) + return Promise.reject() + } + } + }) +} + +export const notifyVersionConflict = ( + e: any, + options?: { reload?: () => void | Promise } +): boolean => { + if (isVersionConflict(e)) { + notification.warning({ + key: 'rule-version-conflict', + duration: 0, + message: t('ruleVersionDomain.versionConflict'), + description: t('ruleVersionDomain.versionConflictDescription'), + btn: options?.reload + ? () => + h( + Button, + { + type: 'link', + size: 'small', + onClick: () => { + notification.close('rule-version-conflict') + options.reload?.() + } + }, + { default: () => t('ruleVersionDomain.reload') } + ) + : undefined + }) + return true + } + return false +} + +export const notifyVersionLedgerPending = ( + e: any, + options?: { reload?: () => void | Promise } +): boolean => { + if (!isVersionLedgerPending(e)) { + return false + } + const intentId = e.intentId + notification.warning({ + key: 'rule-version-ledger-pending', + duration: 0, + message: t('ruleVersionDomain.ledgerPending'), + description: intentId + ? t('ruleVersionDomain.ledgerPendingWithIntent', { intentId }) + : t('ruleVersionDomain.ledgerPendingDescription'), + btn: intentId + ? () => + h( + Space, + {}, + { + default: () => [ + h( + Button, + { + type: 'link', + size: 'small', + onClick: async () => { + if (repairingIntentIds.has(intentId)) { + return + } + repairingIntentIds.add(intentId) + try { + await repairRuleVersionIntentAPI(intentId) + notification.close('rule-version-ledger-pending') + await options?.reload?.() + } catch (e: any) { + notification.error({ + key: 'rule-version-repair-error', + message: t('ruleVersionDomain.repairFailed'), + description: e?.message || String(e) + }) + } finally { + repairingIntentIds.delete(intentId) + } + } + }, + { default: () => t('ruleVersionDomain.repair') } + ), + h( + Button, + { + type: 'link', + size: 'small', + danger: true, + onClick: () => { + openAbandonReasonModal(intentId, options) + } + }, + { default: () => t('ruleVersionDomain.abandon') } + ) + ] + } + ) + : options?.reload + ? () => + h( + Button, + { + type: 'link', + size: 'small', + onClick: () => { + notification.close('rule-version-ledger-pending') + options.reload?.() + } + }, + { default: () => t('ruleVersionDomain.reload') } + ) + : undefined + }) + return true +} + +export const notifyRuleVersionError = ( + e: any, + options?: { reload?: () => void | Promise } +): boolean => { + if (notifyVersionLedgerPending(e, options)) { + return true + } + return notifyVersionConflict(e, options) +} + +export const formatRuleSpec = (specJson?: string): string => { + if (!specJson) { + return '' + } + try { + return JSON.stringify(JSON.parse(specJson), null, 2) + } catch (e) { + return specJson + } +} diff --git a/ui-vue3/src/views/traffic/dynamicConfig/index.vue b/ui-vue3/src/views/traffic/dynamicConfig/index.vue index baad34d02..817c90ae0 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/index.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/index.vue @@ -73,6 +73,12 @@ import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject' import { useRouter } from 'vue-router' import { PRIMARY_COLOR } from '@/base/constants' import { Icon } from '@iconify/vue' +import { message } from 'ant-design-vue' +import { + fetchCurrentVersionState, + notifyRuleVersionError, + ruleVersionErrorMessage +} from '../_shared/ruleVersion' const router = useRouter() @@ -143,8 +149,16 @@ onMounted(async () => { }) const delDynamicConfig = async (record: any) => { - await delConfiguratorDetail({ name: record.ruleName }) - await searchDomain.onSearch() + try { + const expectedVersionId = (await fetchCurrentVersionState('configurator', record.ruleName)).id + await delConfiguratorDetail({ name: record.ruleName }, { expectedVersionId }) + await searchDomain.onSearch() + } catch (e: any) { + const handled = notifyRuleVersionError(e, { reload: () => searchDomain.onSearch() }) + if (!handled) { + message.error(ruleVersionErrorMessage(e)) + } + } } provide(PROVIDE_INJECT_KEY.SEARCH_DOMAIN, searchDomain) diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue index df8ea5257..28479eb13 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue @@ -21,19 +21,16 @@ - - - - - - - - - - - - - + + + + + current v{{ currentVersionNo }} + + + {{ $t('flowControlDomain.versionRecords') }} + + @@ -71,11 +68,20 @@ 保存 重置 + + diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue index aba43cebd..d5837e7ce 100644 --- a/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue +++ b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue @@ -392,8 +392,6 @@ const updateRoutingRule = async () => { }) if (res?.code === HTTP_STATUS.SUCCESS) { message.success('update success') - // 延迟 2 秒后再获取数据,确保数据库已更新 - await new Promise((resolve) => setTimeout(resolve, 2000)) TAB_STATE.conditionRule = null await getRoutingRuleDetail() await reloadCurrentVersion() diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/updateByYAMLView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/updateByYAMLView.vue index fac916f4f..5498872aa 100644 --- a/ui-vue3/src/views/traffic/routingRule/tabs/updateByYAMLView.vue +++ b/ui-vue3/src/views/traffic/routingRule/tabs/updateByYAMLView.vue @@ -156,8 +156,6 @@ const updateRoutingRule = async () => { }) if (res.code === HTTP_STATUS.SUCCESS) { message.success('update success') - // 延迟 2 秒后再获取数据,确保数据库已更新 - await new Promise((resolve) => setTimeout(resolve, 2000)) TAB_STATE.conditionRule = null await getRoutingRuleDetail() await reloadCurrentVersion() diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/updateByFormView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/updateByFormView.vue index 2f46c4d66..c75548ddf 100644 --- a/ui-vue3/src/views/traffic/tagRule/tabs/updateByFormView.vue +++ b/ui-vue3/src/views/traffic/tagRule/tabs/updateByFormView.vue @@ -661,8 +661,6 @@ const updateTagRule = async () => { }) if (res.code === HTTP_STATUS.SUCCESS) { message.success('update success') - // 延迟 2 秒后再获取数据,确保数据库已更新 - await new Promise((resolve) => setTimeout(resolve, 2000)) TAB_STATE.tagRule = null await getTagRuleDetail() await reloadCurrentVersion() diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/updateByYAMLView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/updateByYAMLView.vue index 36c5d42f7..58146279d 100644 --- a/ui-vue3/src/views/traffic/tagRule/tabs/updateByYAMLView.vue +++ b/ui-vue3/src/views/traffic/tagRule/tabs/updateByYAMLView.vue @@ -160,8 +160,6 @@ const updateTagRule = async () => { }) if (res.code === HTTP_STATUS.SUCCESS) { message.success('update success') - // 延迟 2 秒后再获取数据,确保数据库已更新 - await new Promise((resolve) => setTimeout(resolve, 2000)) TAB_STATE.tagRule = null await getTagRuleDetail() await reloadCurrentVersion() From 1072ce076dfb4814a848a0cf5f1e5577410f62a8 Mon Sep 17 00:00:00 2001 From: MoChengqian <2972013548@qq.com> Date: Sat, 20 Jun 2026 19:29:48 +0800 Subject: [PATCH 27/30] fix(versioning): close intent recovery race windows --- api/mesh/v1alpha1/rule_intent.pb.go | 20 +- api/mesh/v1alpha1/rule_intent.proto | 5 + pkg/console/service/rule_version.go | 41 +-- .../service/rule_version_rollback_test.go | 194 ++++++++++- pkg/core/versioning/component.go | 35 +- pkg/core/versioning/resource_store_adapter.go | 10 + .../versioning/resource_store_adapter_test.go | 92 ++++++ pkg/core/versioning/resource_store_convert.go | 1 + pkg/core/versioning/resource_store_intent.go | 309 ++++++++++++++---- pkg/core/versioning/resource_store_version.go | 12 + pkg/core/versioning/service.go | 20 +- pkg/core/versioning/store.go | 3 +- pkg/core/versioning/types.go | 2 + ui-vue3/src/base/i18n/en.ts | 1 + ui-vue3/src/base/i18n/zh.ts | 7 +- .../traffic/_shared/RuleHistoryPanel.spec.ts | 159 ++++++++- .../traffic/_shared/RuleHistoryPanel.vue | 80 ++++- .../views/traffic/_shared/ruleVersion.spec.ts | 95 +++++- .../src/views/traffic/_shared/ruleVersion.ts | 32 +- 19 files changed, 991 insertions(+), 127 deletions(-) diff --git a/api/mesh/v1alpha1/rule_intent.pb.go b/api/mesh/v1alpha1/rule_intent.pb.go index 75876dd1f..4ae61d701 100644 --- a/api/mesh/v1alpha1/rule_intent.pb.go +++ b/api/mesh/v1alpha1/rule_intent.pb.go @@ -73,8 +73,12 @@ type RuleIntent struct { ObservedSpecJson string `protobuf:"bytes,19,opt,name=observed_spec_json,json=observedSpecJson,proto3" json:"observed_spec_json,omitempty"` ObservedOperation string `protobuf:"bytes,20,opt,name=observed_operation,json=observedOperation,proto3" json:"observed_operation,omitempty"` ObservedAt *timestamppb.Timestamp `protobuf:"bytes,21,opt,name=observed_at,json=observedAt,proto3" json:"observed_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Monotonic resource revision owned by RuleIntent. Status transitions and + // observed-marker writes advance it so finalization can reject stale commits + // instead of overwriting a subscriber reconcile marker. + Revision int64 `protobuf:"varint,22,opt,name=revision,proto3" json:"revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RuleIntent) Reset() { @@ -247,11 +251,18 @@ func (x *RuleIntent) GetObservedAt() *timestamppb.Timestamp { return nil } +func (x *RuleIntent) GetRevision() int64 { + if x != nil { + return x.Revision + } + return 0 +} + var File_api_mesh_v1alpha1_rule_intent_proto protoreflect.FileDescriptor const file_api_mesh_v1alpha1_rule_intent_proto_rawDesc = "" + "\n" + - "#api/mesh/v1alpha1/rule_intent.proto\x12\x13dubbo.mesh.v1alpha1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe2\x06\n" + + "#api/mesh/v1alpha1/rule_intent.proto\x12\x13dubbo.mesh.v1alpha1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfe\x06\n" + "\n" + "RuleIntent\x12(\n" + "\x10parent_rule_kind\x18\x01 \x01(\tR\x0eparentRuleKind\x12(\n" + @@ -277,7 +288,8 @@ const file_api_mesh_v1alpha1_rule_intent_proto_rawDesc = "" + "\x12observed_spec_json\x18\x13 \x01(\tR\x10observedSpecJson\x12-\n" + "\x12observed_operation\x18\x14 \x01(\tR\x11observedOperation\x12;\n" + "\vobserved_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "observedAtJ\x04\b\x04\x10\x05R\n" + + "observedAt\x12\x1a\n" + + "\brevision\x18\x16 \x01(\x03R\brevisionJ\x04\b\x04\x10\x05R\n" + "version_noB1Z/github.com/apache/dubbo-admin/api/mesh/v1alpha1b\x06proto3" var ( diff --git a/api/mesh/v1alpha1/rule_intent.proto b/api/mesh/v1alpha1/rule_intent.proto index 2eacabfb5..7bc0bf7c3 100644 --- a/api/mesh/v1alpha1/rule_intent.proto +++ b/api/mesh/v1alpha1/rule_intent.proto @@ -64,4 +64,9 @@ message RuleIntent { string observed_spec_json = 19; string observed_operation = 20; google.protobuf.Timestamp observed_at = 21; + + // Monotonic resource revision owned by RuleIntent. Status transitions and + // observed-marker writes advance it so finalization can reject stale commits + // instead of overwriting a subscriber reconcile marker. + int64 revision = 22; } diff --git a/pkg/console/service/rule_version.go b/pkg/console/service/rule_version.go index 1567de819..f8919f40e 100644 --- a/pkg/console/service/rule_version.go +++ b/pkg/console/service/rule_version.go @@ -68,7 +68,7 @@ func ruleVersioning(ctx consolectx.Context) *versioning.Service { func checkExpectedVersion(ctx consolectx.Context, kindName RuleKindName, opts RuleMutationOptions) error { svc := ruleVersioning(ctx) if svc == nil { - return nil + return versioning.ErrVersionLedgerCorrupt } return svc.CheckExpected(kindName.Kind, kindName.Mesh, kindName.Name, opts.ExpectedVersionID) } @@ -93,7 +93,7 @@ func prepareRuleMutation(ctx consolectx.Context, kindName RuleKindName, opts Rul func repairPendingIntent(ctx consolectx.Context, kindName RuleKindName, opts RuleMutationOptions) error { svc := ruleVersioning(ctx) if svc == nil { - return nil + return versioning.ErrVersionLedgerCorrupt } resourceKey := coremodel.BuildResourceKey(kindName.Mesh, kindName.Name) if err := checkMutationLease(opts); err != nil { @@ -155,10 +155,7 @@ func withRuleMutation( lockMgr := ctx.LockManager() if lockMgr == nil { - if ruleVersioning(ctx) != nil { - return lock.ErrLockUnavailable - } - return execute(opts) + return lock.ErrLockUnavailable } lockKey, err := ruleLockKey(kindName) if err != nil { @@ -183,10 +180,7 @@ type MutationCommit struct { func applyRuleMutationIntentWithOptions(ctx consolectx.Context, res coremodel.Resource, op versioning.Operation, source versioning.Source, opts RuleMutationOptions, reason string, rolledBackFromID *int64, mutate func() error) (*MutationCommit, error) { svc := ruleVersioning(ctx) if svc == nil { - if err := checkMutationLease(opts); err != nil { - return nil, err - } - return nil, mutate() + return nil, versioning.ErrVersionLedgerCorrupt } if err := checkMutationLease(opts); err != nil { return nil, err @@ -196,10 +190,7 @@ func applyRuleMutationIntentWithOptions(ctx consolectx.Context, res coremodel.Re return nil, err } if intent == nil { - if err := checkMutationLease(opts); err != nil { - return nil, err - } - return nil, mutate() + return nil, versioning.ErrVersionLedgerCorrupt } if err := checkMutationLease(opts); err != nil { return nil, err @@ -242,21 +233,23 @@ func ensureMutationIntentCommitted(ctx consolectx.Context, svc *versioning.Servi } func abandonIntentAndReconcile(ctx consolectx.Context, svc *versioning.Service, leaseCtx context.Context, intent *versioning.Intent, reason string) error { - if err := svc.AbandonIntent(leaseCtx, intent, reason); err != nil { + current, exists, err := ctx.ResourceManager().GetByKey(intent.RuleKind, intent.ResourceKey) + if err != nil { return err } + if versioning.IntentMatchesResource(intent, current, !exists) { + return bizerror.New(bizerror.InvalidArgument, "rule version intent matches the current resource; repair it instead") + } if err := lock.CheckLease(leaseCtx); err != nil { return err } - current, exists, err := ctx.ResourceManager().GetByKey(intent.RuleKind, intent.ResourceKey) - if err != nil { + if _, err := svc.ReconcileActualState(leaseCtx, intent.RuleKind, intent.ResourceKey, current, !exists, "system:reconcile"); err != nil { return err } if err := lock.CheckLease(leaseCtx); err != nil { return err } - _, err = svc.ReconcileActualState(leaseCtx, intent.RuleKind, intent.ResourceKey, current, !exists, "system:reconcile") - return err + return svc.AbandonIntent(leaseCtx, intent, reason) } func pendingLedgerError(intentID int64, cause error) error { @@ -337,18 +330,14 @@ func AbandonRuleVersionIntent(ctx consolectx.Context, intentID int64, reason str if err != nil { return err } + if intent.Status == versioning.IntentStatusFailed { + return svc.AbandonIntent(leaseCtx, intent, reason) + } if intent.Status != versioning.IntentStatusPending && intent.Status != versioning.IntentStatusApplied && intent.Status != versioning.IntentStatusOutcomeUnknown { return bizerror.New(bizerror.InvalidArgument, "only open rule version intent can be abandoned") } - current, exists, err := ctx.ResourceManager().GetByKey(intent.RuleKind, intent.ResourceKey) - if err != nil { - return err - } - if versioning.IntentMatchesResource(intent, current, !exists) { - return bizerror.New(bizerror.InvalidArgument, "rule version intent matches the current resource; repair it instead") - } if err := lock.CheckLease(leaseCtx); err != nil { return err } diff --git a/pkg/console/service/rule_version_rollback_test.go b/pkg/console/service/rule_version_rollback_test.go index 524fff38d..172c3e5f5 100644 --- a/pkg/console/service/rule_version_rollback_test.go +++ b/pkg/console/service/rule_version_rollback_test.go @@ -186,6 +186,38 @@ func (b *simpleBus) Send(event events.Event) { } } +type failingResourceStore struct { + store.ResourceStore + failNextAdd bool + failNextUpdate bool + failNextDelete bool + err error +} + +func (s *failingResourceStore) Add(obj interface{}) error { + if s.failNextAdd { + s.failNextAdd = false + return s.err + } + return s.ResourceStore.Add(obj) +} + +func (s *failingResourceStore) Update(obj interface{}) error { + if s.failNextUpdate { + s.failNextUpdate = false + return s.err + } + return s.ResourceStore.Update(obj) +} + +func (s *failingResourceStore) Delete(obj interface{}) error { + if s.failNextDelete { + s.failNextDelete = false + return s.err + } + return s.ResourceStore.Delete(obj) +} + // setupRollbackTestEnv builds an in-memory ResourceManager with versioning // subscribers for all three governor-managed rule kinds. func setupRollbackTestEnv(t *testing.T) *testContext { @@ -193,6 +225,10 @@ func setupRollbackTestEnv(t *testing.T) *testContext { } func setupRollbackTestEnvWithMax(t *testing.T, maxVersions int64) *testContext { + return setupRollbackTestEnvWithStoreWrappers(t, maxVersions, nil, nil) +} + +func setupRollbackTestEnvWithStoreWrappers(t *testing.T, maxVersions int64, wrapVersionStore, wrapIntentStore func(store.ResourceStore) store.ResourceStore) *testContext { conditionStore := memoryst.NewMemoryResourceStore(meshresource.ConditionRouteKind) tagStore := memoryst.NewMemoryResourceStore(meshresource.TagRouteKind) dynamicStore := memoryst.NewMemoryResourceStore(meshresource.DynamicConfigKind) @@ -202,13 +238,21 @@ func setupRollbackTestEnvWithMax(t *testing.T, maxVersions int64) *testContext { for _, s := range []store.ManagedResourceStore{conditionStore, tagStore, dynamicStore, versionStore, intentStore} { require.NoError(t, s.Init(nil)) } + var versioningVersionStore store.ResourceStore = versionStore + if wrapVersionStore != nil { + versioningVersionStore = wrapVersionStore(versionStore) + } + var versioningIntentStore store.ResourceStore = intentStore + if wrapIntentStore != nil { + versioningIntentStore = wrapIntentStore(intentStore) + } stores := map[coremodel.ResourceKind]store.ResourceStore{ meshresource.ConditionRouteKind: conditionStore, meshresource.TagRouteKind: tagStore, meshresource.DynamicConfigKind: dynamicStore, - meshresource.RuleVersionKind: versionStore, - meshresource.RuleIntentKind: intentStore, + meshresource.RuleVersionKind: versioningVersionStore, + meshresource.RuleIntentKind: versioningIntentStore, } storeRouter := &testRouter{stores: stores} @@ -222,7 +266,7 @@ func setupRollbackTestEnvWithMax(t *testing.T, maxVersions int64) *testContext { // Create versioning service + subscriber for each rule kind, sharing the // same adapter (RuleVersion/RuleIntent stores). - adapter := versioning.NewResourceStoreAdapter(versionStore, intentStore) + adapter := versioning.NewResourceStoreAdapter(versioningVersionStore, versioningIntentStore) versioningSvc := versioning.NewService(maxVersions, adapter) lockMgr := locallock.NewLocalLock() for _, kind := range []coremodel.ResourceKind{ @@ -247,6 +291,18 @@ func setupRollbackTestEnvWithMax(t *testing.T, maxVersions int64) *testContext { } } +func mustVersionStoreForTest(t *testing.T) store.ResourceStore { + s := memoryst.NewMemoryResourceStore(meshresource.RuleVersionKind) + require.NoError(t, s.Init(nil)) + return s +} + +func mustIntentStoreForTest(t *testing.T) store.ResourceStore { + s := memoryst.NewMemoryResourceStore(meshresource.RuleIntentKind) + require.NoError(t, s.Init(nil)) + return s +} + func beginMutationForTest(ctx *testContext, res coremodel.Resource, op versioning.Operation, source versioning.Source, author string) (*versioning.Intent, error) { var intent *versioning.Intent kindName := RuleKindName{Kind: res.ResourceKind(), Mesh: res.ResourceMesh(), Name: res.ResourceMeta().Name} @@ -308,6 +364,53 @@ func TestAdminMutationSuccessCommitsLedgerBeforeReturn(t *testing.T) { } } +func TestRuleMutationFailClosedWithoutVersioningService(t *testing.T) { + ctx := setupRollbackTestEnv(t) + ctx.versioningSvc = nil + + res := conditionFactory().build("demo-rule", "v1") + err := createRuleWithOptions(ctx, res, RuleMutationOptions{Author: "admin"}) + require.ErrorIs(t, err, versioning.ErrVersionLedgerCorrupt) + + _, exists, getErr := ctx.rm.GetByKey(res.ResourceKind(), res.ResourceKey()) + require.NoError(t, getErr) + assert.False(t, exists) +} + +func TestRuleMutationFailClosedWithoutLockManager(t *testing.T) { + ctx := setupRollbackTestEnv(t) + ctx.lockMgr = nil + + res := conditionFactory().build("demo-rule", "v1") + err := createRuleWithOptions(ctx, res, RuleMutationOptions{Author: "admin"}) + require.ErrorIs(t, err, lock.ErrLockUnavailable) + + _, exists, getErr := ctx.rm.GetByKey(res.ResourceKind(), res.ResourceKey()) + require.NoError(t, getErr) + assert.False(t, exists) +} + +func TestRuleMutationFailClosedWithoutIntentOrVersionStore(t *testing.T) { + for name, adapter := range map[string]*versioning.ResourceStoreAdapter{ + "intent-store-nil": versioning.NewResourceStoreAdapter(mustVersionStoreForTest(t), nil), + "version-store-nil": versioning.NewResourceStoreAdapter(nil, mustIntentStoreForTest(t)), + } { + t.Run(name, func(t *testing.T) { + ctx := setupRollbackTestEnv(t) + ctx.adapter = adapter + ctx.versioningSvc = versioning.NewService(5, adapter) + + res := conditionFactory().build("demo-rule", "v1") + err := createRuleWithOptions(ctx, res, RuleMutationOptions{Author: "admin"}) + require.ErrorIs(t, err, versioning.ErrVersionLedgerCorrupt) + + _, exists, getErr := ctx.rm.GetByKey(res.ResourceKind(), res.ResourceKey()) + require.NoError(t, getErr) + assert.False(t, exists) + }) + } +} + // ruleFactory builds a rule resource of a given kind with a discriminating // payload, so different "versions" produce different content hashes. type ruleFactory struct { @@ -756,6 +859,91 @@ func TestAbandonRuleVersionIntent_ReconcilesDeferredExternalState(t *testing.T) require.ErrorIs(t, err, versioning.ErrVersionIntentNotFound) } +func TestAbandonRuleVersionIntent_CrashBeforeReconcileKeepsIntentOpen(t *testing.T) { + versionErr := errors.New("version add failed before reconcile") + failingVersionStore := &failingResourceStore{err: versionErr} + ctx := setupRollbackTestEnvWithStoreWrappers(t, 5, func(base store.ResourceStore) store.ResourceStore { + failingVersionStore.ResourceStore = base + return failingVersionStore + }, nil) + + f := conditionFactory() + require.NoError(t, ctx.rm.Add(context.Background(), f.build("demo-rule", "v1"))) + intentTarget := f.build("demo-rule", "admin-pending") + intent, err := beginMutationForTest(ctx, intentTarget, versioning.OperationUpdate, versioning.SourceAdmin, "admin") + require.NoError(t, err) + require.NoError(t, ctx.rm.Update(context.Background(), f.build("demo-rule", "external-change"))) + + failingVersionStore.failNextAdd = true + err = AbandonRuleVersionIntent(ctx, intent.ID, "operator chose external state") + require.ErrorIs(t, err, versionErr) + + open, err := ctx.versioningSvc.GetIntent(intent.ID) + require.NoError(t, err) + assert.Equal(t, versioning.IntentStatusOutcomeUnknown, open.Status) +} + +func TestAbandonRuleVersionIntent_RuleVersionAddBeforeMarkFailedCrashIsRepairable(t *testing.T) { + intentErr := errors.New("mark failed crash") + failingIntentStore := &failingResourceStore{err: intentErr} + ctx := setupRollbackTestEnvWithStoreWrappers(t, 5, nil, func(base store.ResourceStore) store.ResourceStore { + failingIntentStore.ResourceStore = base + return failingIntentStore + }) + + f := conditionFactory() + require.NoError(t, ctx.rm.Add(context.Background(), f.build("demo-rule", "v1"))) + intent, err := beginMutationForTest(ctx, f.build("demo-rule", "admin-pending"), versioning.OperationUpdate, versioning.SourceAdmin, "admin") + require.NoError(t, err) + external := f.build("demo-rule", "external-change") + require.NoError(t, ctx.rm.Update(context.Background(), external)) + + failingIntentStore.failNextUpdate = true + err = AbandonRuleVersionIntent(ctx, intent.ID, "operator chose external state") + require.ErrorIs(t, err, intentErr) + + open, err := ctx.versioningSvc.GetIntent(intent.ID) + require.NoError(t, err) + require.True(t, open.ReconcileRequired) + + require.NoError(t, AbandonRuleVersionIntent(ctx, intent.ID, "operator chose external state")) + versions, err := ListRuleVersions(ctx, RuleKindName{Kind: f.kind, Name: "demo-rule"}) + require.NoError(t, err) + require.Len(t, versions.Items, 2) + hash, _, err := versioning.NormalizeResource(external) + require.NoError(t, err) + assert.Equal(t, hash, versions.Items[0].ContentHash) + _, err = ctx.versioningSvc.GetIntent(intent.ID) + require.ErrorIs(t, err, versioning.ErrVersionIntentNotFound) +} + +func TestAbandonRuleVersionIntent_MarkFailedBeforeCleanupCrashSweepsOnRetry(t *testing.T) { + cleanupErr := errors.New("cleanup failed") + failingIntentStore := &failingResourceStore{err: cleanupErr} + ctx := setupRollbackTestEnvWithStoreWrappers(t, 5, nil, func(base store.ResourceStore) store.ResourceStore { + failingIntentStore.ResourceStore = base + return failingIntentStore + }) + + f := conditionFactory() + require.NoError(t, ctx.rm.Add(context.Background(), f.build("demo-rule", "v1"))) + intent, err := beginMutationForTest(ctx, f.build("demo-rule", "admin-pending"), versioning.OperationUpdate, versioning.SourceAdmin, "admin") + require.NoError(t, err) + require.NoError(t, ctx.rm.Update(context.Background(), f.build("demo-rule", "external-change"))) + + failingIntentStore.failNextDelete = true + err = AbandonRuleVersionIntent(ctx, intent.ID, "operator chose external state") + require.ErrorIs(t, err, cleanupErr) + + terminal, err := ctx.versioningSvc.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, versioning.IntentStatusFailed, terminal.Status) + + require.NoError(t, AbandonRuleVersionIntent(ctx, intent.ID, "operator chose external state")) + _, err = ctx.versioningSvc.GetIntent(intent.ID) + require.ErrorIs(t, err, versioning.ErrVersionIntentNotFound) +} + // TestRollbackRuleVersion_DuplicateEventSingleVersion verifies that a redundant // upstream event with the rollback's content hash does not create a second // version: the rollback intent is committed exactly once. diff --git a/pkg/core/versioning/component.go b/pkg/core/versioning/component.go index 49802e0f3..5d03e624c 100644 --- a/pkg/core/versioning/component.go +++ b/pkg/core/versioning/component.go @@ -100,7 +100,7 @@ func (c *component) Init(ctx runtime.BuilderContext) error { store := NewResourceStoreAdapter(rvStore, intentStore) lockComponent, err := ctx.GetActivatedComponent(lock.DistributedLockComponent) if err != nil { - return fmt.Errorf("rule versioning requires a lock component when enabled: %w", err) + return fmt.Errorf("rule versioning requires a lock component: %w", err) } lockComp, ok := lockComponent.(*lock.Component) if !ok { @@ -108,7 +108,7 @@ func (c *component) Init(ctx runtime.BuilderContext) error { } lockMgr := lockComp.GetLock() if lockMgr == nil { - return fmt.Errorf("rule versioning requires an available lock implementation when enabled") + return fmt.Errorf("rule versioning requires an available lock implementation") } c.store = store c.lock = lockMgr @@ -154,6 +154,9 @@ func (c *component) Start(rt runtime.Runtime, stop <-chan struct{}) error { if err := c.repairOpenIntents(startCtx, rm); err != nil { return err } + if err := c.cleanupTerminalIntents(startCtx); err != nil { + return err + } if err := c.bootstrapExistingRules(startCtx, rm, cfg.MaxVersionsPerRule); err != nil { return err } @@ -232,6 +235,34 @@ func (c *component) repairOpenIntents(ctx context.Context, rm manager.ResourceMa return nil } +func (c *component) cleanupTerminalIntents(ctx context.Context) error { + intents, err := c.store.ListTerminalIntents() + if err != nil { + return err + } + for _, intent := range intents { + if err := ctx.Err(); err != nil { + return err + } + err = withRuleVersionLock(ctx, c.lock, intent.RuleKind, intent.ResourceKey, func(leaseCtx context.Context) error { + if err := lock.CheckLease(leaseCtx); err != nil { + return err + } + return c.store.CleanupIntent(intent.ID, intent.Status) + }) + if err != nil { + if errors.Is(err, ErrVersionIntentNotFound) { + continue + } + if errors.Is(err, ErrVersionNotFound) && intent.Status == IntentStatusCommitted { + return fmt.Errorf("%w: terminal committed intent %d has no RuleVersion", ErrVersionLedgerCorrupt, intent.ID) + } + return err + } + } + return nil +} + func contextWithStop(parent context.Context, stop <-chan struct{}) (context.Context, context.CancelFunc) { ctx, cancel := context.WithCancel(parent) go func() { diff --git a/pkg/core/versioning/resource_store_adapter.go b/pkg/core/versioning/resource_store_adapter.go index cc20dbe85..21d67bb18 100644 --- a/pkg/core/versioning/resource_store_adapter.go +++ b/pkg/core/versioning/resource_store_adapter.go @@ -50,7 +50,17 @@ func NewResourceStoreAdapter(versionStore, intentStore store.ResourceStore) *Res } } +func (a *ResourceStoreAdapter) ensureStores() error { + if a == nil || a.versionStore == nil || a.intentStore == nil { + return fmt.Errorf("%w: RuleVersion and RuleIntent stores are required", ErrVersionLedgerCorrupt) + } + return nil +} + func (a *ResourceStoreAdapter) CheckExpectedVersion(kind coremodel.ResourceKind, resourceKey string, expected *int64) error { + if err := a.ensureStores(); err != nil { + return err + } if expected == nil { return nil } diff --git a/pkg/core/versioning/resource_store_adapter_test.go b/pkg/core/versioning/resource_store_adapter_test.go index 71589e9a0..3da0a2436 100644 --- a/pkg/core/versioning/resource_store_adapter_test.go +++ b/pkg/core/versioning/resource_store_adapter_test.go @@ -184,6 +184,33 @@ func TestResourceStoreAdapter_CommitIntentRetryAfterIntentStatusFailure(t *testi require.ErrorIs(t, err, ErrVersionIntentNotFound) } +func TestComponent_CleanupTerminalIntentSweepAfterRestart(t *testing.T) { + versionStore, baseIntentStore, _ := newVersioningStores(t) + intentStore := &failOnceStore{ResourceStore: baseIntentStore, err: errors.New("cleanup failed")} + adapter := NewResourceStoreAdapter(versionStore, intentStore) + + intent, err := adapter.CreateIntent(context.Background(), testInsertRequest("demo-rule", "hash-a")) + require.NoError(t, err) + require.NoError(t, adapter.MarkIntentApplied(context.Background(), intent.ID)) + + intentStore.failNextDelete = true + _, err = adapter.CommitIntent(context.Background(), intent.ID, 10) + require.ErrorContains(t, err, "cleanup failed") + terminal, err := adapter.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusCommitted, terminal.Status) + + c := &component{store: adapter, lock: locallock.NewLocalLock()} + require.NoError(t, c.cleanupTerminalIntents(context.Background())) + + _, err = adapter.GetIntent(intent.ID) + require.ErrorIs(t, err, ErrVersionIntentNotFound) + versions, err := adapter.ListVersions(meshresource.ConditionRouteKind, coremodel.BuildResourceKey("", "demo-rule")) + require.NoError(t, err) + require.Len(t, versions, 1) + assert.Equal(t, intent.ID, versions[0].ID) +} + func TestService_FinalizeClosedIntentDistinguishesCommittedVersionFromCorruption(t *testing.T) { versionStore, intentStore, _ := newVersioningStores(t) adapter := NewResourceStoreAdapter(versionStore, intentStore) @@ -716,6 +743,71 @@ func TestService_FinalizeDoesNotCommitStaleSnapshotAfterNonMatchingEvent(t *test assert.NotEqual(t, intent.ID, versions[0].IntentID) } +func TestResourceStoreAdapter_CommitIntentRejectsObservedMarkerAfterMarkApplied(t *testing.T) { + versionStore, intentStore, _ := newVersioningStores(t) + adapter := NewResourceStoreAdapter(versionStore, intentStore) + + intended := testConditionRule("demo-rule", "A") + req, err := buildMutationInsertRequest(intended, OperationUpdate, SourceAdmin, "admin", "", nil, time.Unix(100, 0)) + require.NoError(t, err) + intent, err := adapter.CreateIntent(context.Background(), req) + require.NoError(t, err) + + // T1 finalizer observes actual=A and advances the intent to APPLIED. + require.NoError(t, adapter.MarkIntentApplied(context.Background(), intent.ID)) + applied, err := adapter.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusApplied, applied.Status) + require.False(t, applied.ReconcileRequired) + + // T2 subscriber observes a non-matching upstream state B before T1 commits. + external := testConditionRule("demo-rule", "B") + hash, specJSON, err := NormalizeResource(external) + require.NoError(t, err) + require.NoError(t, adapter.MarkIntentObserved(context.Background(), intent.ID, OperationUpdate, hash, specJSON)) + + // T1 must not append the stale intended A version or cleanup the intent. + _, err = adapter.CommitIntent(context.Background(), intent.ID, 10) + var pending *IntentPendingError + require.ErrorAs(t, err, &pending) + + versions, err := adapter.ListVersions(meshresource.ConditionRouteKind, coremodel.BuildResourceKey("", "demo-rule")) + require.NoError(t, err) + require.Empty(t, versions) + + open, err := adapter.GetIntent(intent.ID) + require.NoError(t, err) + assert.Equal(t, IntentStatusApplied, open.Status) + assert.True(t, open.ReconcileRequired) + assert.Equal(t, hash, open.ObservedContentHash) +} + +func TestResourceStoreAdapter_StaleObservedAndStatusUpdatesConflictOnRevision(t *testing.T) { + versionStore, intentStore, _ := newVersioningStores(t) + adapter := NewResourceStoreAdapter(versionStore, intentStore) + + intent, err := adapter.CreateIntent(context.Background(), testInsertRequest("demo-rule", "hash-a")) + require.NoError(t, err) + stale, _, err := adapter.getIntentResourceByID(intent.ID) + require.NoError(t, err) + + require.NoError(t, adapter.MarkIntentApplied(context.Background(), intent.ID)) + err = updateIntentResourceObserved(intentStore, stale, OperationUpdate, "hash-b", `{"key":"B"}`) + require.ErrorIs(t, err, ErrVersionIntentConflict) + + fresh, _, err := adapter.getIntentResourceByID(intent.ID) + require.NoError(t, err) + require.NoError(t, updateIntentResourceObserved(intentStore, fresh, OperationUpdate, "hash-b", `{"key":"B"}`)) + err = updateIntentResourceStatus(intentStore, fresh, IntentStatusCommitted, "") + require.ErrorIs(t, err, ErrVersionIntentConflict) + + open, err := adapter.GetIntent(intent.ID) + require.NoError(t, err) + assert.Equal(t, IntentStatusApplied, open.Status) + assert.True(t, open.ReconcileRequired) + assert.Equal(t, "hash-b", open.ObservedContentHash) +} + func TestService_OutcomeUnknownActualMatchCommitsFixedIntentID(t *testing.T) { versionStore, intentStore, _ := newVersioningStores(t) adapter := NewResourceStoreAdapter(versionStore, intentStore) diff --git a/pkg/core/versioning/resource_store_convert.go b/pkg/core/versioning/resource_store_convert.go index fa253aa33..96a51295f 100644 --- a/pkg/core/versioning/resource_store_convert.go +++ b/pkg/core/versioning/resource_store_convert.go @@ -179,6 +179,7 @@ func intentFromResource(res *meshresource.RuleIntentResource, id int64) *Intent ObservedSpecJSON: spec.ObservedSpecJson, ObservedOperation: Operation(spec.ObservedOperation), ObservedAt: observedAt, + Revision: spec.Revision, CreatedAt: createdAt, } } diff --git a/pkg/core/versioning/resource_store_intent.go b/pkg/core/versioning/resource_store_intent.go index e1527599d..e46ed5d5f 100644 --- a/pkg/core/versioning/resource_store_intent.go +++ b/pkg/core/versioning/resource_store_intent.go @@ -37,6 +37,9 @@ import ( ) func (a *ResourceStoreAdapter) CreateIntent(ctx context.Context, req InsertRequest) (*Intent, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } var intent *Intent err := a.withParentLock(req.RuleKind, req.ResourceKey, func() error { if err := lock.CheckLease(ctx); err != nil { @@ -99,6 +102,9 @@ func (a *ResourceStoreAdapter) createIntentLocked(ctx context.Context, req Inser } func (a *ResourceStoreAdapter) GetIntent(id int64) (*Intent, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } intentRes, parsedID, err := a.getIntentResourceByID(id) if err != nil { return nil, err @@ -107,6 +113,9 @@ func (a *ResourceStoreAdapter) GetIntent(id int64) (*Intent, error) { } func (a *ResourceStoreAdapter) OpenIntent(kind coremodel.ResourceKind, resourceKey string) (*Intent, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } intents, err := a.openIntentResources(kind, resourceKey) if err != nil { return nil, err @@ -126,91 +135,174 @@ func (a *ResourceStoreAdapter) OpenIntent(kind coremodel.ResourceKind, resourceK } func (a *ResourceStoreAdapter) MarkIntentApplied(ctx context.Context, id int64) error { + if err := a.ensureStores(); err != nil { + return err + } return a.updateIntentStatus(ctx, id, IntentStatusApplied, "") } func (a *ResourceStoreAdapter) MarkIntentOutcomeUnknown(ctx context.Context, id int64, message string) error { - _ = ctx + if err := a.ensureStores(); err != nil { + return err + } intentRes, _, err := a.getIntentResourceByID(id) if err != nil { return err } - return updateIntentResourceStatus(a.intentStore, intentRes, IntentStatusOutcomeUnknown, message) + return a.withParentLock(coremodel.ResourceKind(intentRes.Spec.ParentRuleKind), intentResourceKey(intentRes), func() error { + if err := lock.CheckLease(ctx); err != nil { + return err + } + fresh, _, err := a.getIntentResourceByID(id) + if err != nil { + return err + } + return updateIntentResourceStatus(a.intentStore, fresh, IntentStatusOutcomeUnknown, message) + }) } func (a *ResourceStoreAdapter) MarkIntentObserved(ctx context.Context, id int64, op Operation, contentHash, specJSON string) error { - _ = ctx + if err := a.ensureStores(); err != nil { + return err + } intentRes, _, err := a.getIntentResourceByID(id) if err != nil { return err } - return updateIntentResourceObserved(a.intentStore, intentRes, op, contentHash, specJSON) + return a.withParentLock(coremodel.ResourceKind(intentRes.Spec.ParentRuleKind), intentResourceKey(intentRes), func() error { + for { + if err := lock.CheckLease(ctx); err != nil { + return err + } + fresh, _, err := a.getIntentResourceByID(id) + if err != nil { + return err + } + err = updateIntentResourceObserved(a.intentStore, fresh, op, contentHash, specJSON) + if errors.Is(err, ErrVersionIntentConflict) { + continue + } + return err + } + }) } func (a *ResourceStoreAdapter) MarkIntentFailed(ctx context.Context, id int64, message string) error { + if err := a.ensureStores(); err != nil { + return err + } if err := a.updateIntentStatus(ctx, id, IntentStatusFailed, message); err != nil { return err } - a.cleanupIntent(id, IntentStatusFailed) - return nil + return a.cleanupIntent(id, IntentStatusFailed) } func (a *ResourceStoreAdapter) CommitIntent(ctx context.Context, id int64, maxVersions int64) (*Version, error) { - if err := lock.CheckLease(ctx); err != nil { + if err := a.ensureStores(); err != nil { return nil, err } - intentRes, parsedID, err := a.getIntentResourceByID(id) - if err != nil { + if err := lock.CheckLease(ctx); err != nil { return nil, err } - intent := intentFromResource(intentRes, parsedID) - - if intent.Status != IntentStatusApplied { - return nil, ErrVersionIntentNotOpen - } - - // CommitIntent appends the observed rule state as a new immutable version. - // The current version is derived from the ledger head, so fixed-ID retries - // must validate the existing RuleVersion instead of updating any side state. - version, err := a.InsertVersion(ctx, InsertRequest{ - RuleKind: intent.RuleKind, - Mesh: intent.Mesh, - ResourceKey: intent.ResourceKey, - RuleName: intent.RuleName, - SpecJSON: intent.SpecJSON, - ContentHash: intent.ContentHash, - Source: intent.Source, - Operation: intent.Operation, - Author: intent.Author, - Reason: intent.Reason, - IntentID: intent.ID, - RolledBackFromID: intent.RolledBackFromID, - CreatedAt: intent.CreatedAt, - FixedVersionID: &intent.ID, - }, maxVersions) + intentRes, _, err := a.getIntentResourceByID(id) if err != nil { return nil, err } + var version *Version + err = a.withParentLock(coremodel.ResourceKind(intentRes.Spec.ParentRuleKind), intentResourceKey(intentRes), func() error { + if err := lock.CheckLease(ctx); err != nil { + return err + } + freshRes, parsedID, err := a.getIntentResourceByID(id) + if err != nil { + return err + } + intent := intentFromResource(freshRes, parsedID) + switch intent.Status { + case IntentStatusCommitted: + committed, err := a.committedVersionForIntentLocked(intent) + if err != nil { + return err + } + version = committed + return a.cleanupIntentLocked(id, IntentStatusCommitted) + case IntentStatusApplied: + if intent.ReconcileRequired { + return &IntentPendingError{IntentID: intent.ID} + } + default: + return ErrVersionIntentNotOpen + } - if err := lock.CheckLease(ctx); err != nil { - return nil, err + // CommitIntent appends the intended state only when no durable + // subscriber marker has modified the intent since APPLIED. The fixed + // version ID makes retries idempotent if the process crashes after Add. + committed, err := a.insertVersionLocked(ctx, InsertRequest{ + RuleKind: intent.RuleKind, + Mesh: intent.Mesh, + ResourceKey: intent.ResourceKey, + RuleName: intent.RuleName, + SpecJSON: intent.SpecJSON, + ContentHash: intent.ContentHash, + Source: intent.Source, + Operation: intent.Operation, + Author: intent.Author, + Reason: intent.Reason, + IntentID: intent.ID, + RolledBackFromID: intent.RolledBackFromID, + CreatedAt: intent.CreatedAt, + FixedVersionID: &intent.ID, + }, maxVersions) + if err != nil { + return err + } + + if err := lock.CheckLease(ctx); err != nil { + return err + } + refreshed, _, err := a.getIntentResourceByID(id) + if err != nil { + return err + } + if refreshed.Spec.Revision != intent.Revision || refreshed.Spec.ReconcileRequired { + return &IntentPendingError{IntentID: intent.ID} + } + if err := updateIntentResourceStatus(a.intentStore, refreshed, IntentStatusCommitted, ""); err != nil { + return err + } + if err := a.cleanupIntentLocked(id, IntentStatusCommitted); err != nil { + return err + } + version = committed + return nil + }) + return version, err +} + +func (a *ResourceStoreAdapter) CleanupIntent(id int64, terminalStatus IntentStatus) error { + if err := a.ensureStores(); err != nil { + return err } - // Update intent status to committed - if err := updateIntentResourceStatus(a.intentStore, intentRes, IntentStatusCommitted, ""); err != nil { + return a.cleanupIntent(id, terminalStatus) +} + +func (a *ResourceStoreAdapter) ListOpenIntents() ([]Intent, error) { + if err := a.ensureStores(); err != nil { return nil, err } - a.cleanupIntent(id, IntentStatusCommitted) - - return version, nil + return a.listIntentsByStatuses(openIntentStatuses()) } -func (a *ResourceStoreAdapter) CleanupIntent(id int64, terminalStatus IntentStatus) { - a.cleanupIntent(id, terminalStatus) +func (a *ResourceStoreAdapter) ListTerminalIntents() ([]Intent, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } + return a.listIntentsByStatuses(terminalIntentStatuses()) } -func (a *ResourceStoreAdapter) ListOpenIntents() ([]Intent, error) { - var open []Intent - for _, status := range openIntentStatuses() { +func (a *ResourceStoreAdapter) listIntentsByStatuses(statuses []IntentStatus) ([]Intent, error) { + var result []Intent + for _, status := range statuses { objects, err := a.intentStore.ByIndex(index.ByRuleIntentStatusIndexName, string(status)) if err != nil { return nil, err @@ -227,11 +319,11 @@ func (a *ResourceStoreAdapter) ListOpenIntents() ([]Intent, error) { if err != nil { return nil, fmt.Errorf("%w: %v", ErrVersionLedgerCorrupt, err) } - open = append(open, *intentFromResource(intentRes, id)) + result = append(result, *intentFromResource(intentRes, id)) } } - return open, nil + return result, nil } func isOpenIntentStatus(status IntentStatus) bool { @@ -247,6 +339,10 @@ func openIntentStatuses() []IntentStatus { return []IntentStatus{IntentStatusPending, IntentStatusApplied, IntentStatusOutcomeUnknown} } +func terminalIntentStatuses() []IntentStatus { + return []IntentStatus{IntentStatusCommitted, IntentStatusFailed} +} + func (a *ResourceStoreAdapter) getIntentResourceByID(id int64) (*meshresource.RuleIntentResource, int64, error) { objects, err := a.intentStore.ByIndex(index.ByRuleIntentIDIndexName, strconv.FormatInt(id, 10)) if err != nil { @@ -310,10 +406,19 @@ func (a *ResourceStoreAdapter) updateIntentStatus(ctx context.Context, id int64, if err != nil { return err } - if err := lock.CheckLease(ctx); err != nil { - return err - } - return updateIntentResourceStatus(a.intentStore, intentRes, status, failureReason) + return a.withParentLock(coremodel.ResourceKind(intentRes.Spec.ParentRuleKind), intentResourceKey(intentRes), func() error { + if err := lock.CheckLease(ctx); err != nil { + return err + } + fresh, _, err := a.getIntentResourceByID(id) + if err != nil { + return err + } + if err := lock.CheckLease(ctx); err != nil { + return err + } + return updateIntentResourceStatus(a.intentStore, fresh, status, failureReason) + }) } func updateIntentResourceStatus(intentStore store.ResourceStore, intentRes *meshresource.RuleIntentResource, status IntentStatus, failureReason string) error { @@ -348,7 +453,10 @@ func updateIntentResourceStatus(intentStore store.ResourceStore, intentRes *mesh } } - updated := intentRes.DeepCopyObject().(*meshresource.RuleIntentResource) + updated, err := prepareIntentUpdate(intentStore, intentRes) + if err != nil { + return err + } updated.Spec.Status = string(status) if failureReason != "" { updated.Spec.FailureReason = failureReason @@ -375,7 +483,10 @@ func updateIntentResourceObserved(intentStore store.ResourceStore, intentRes *me return ErrVersionIntentNotOpen } - updated := intentRes.DeepCopyObject().(*meshresource.RuleIntentResource) + updated, err := prepareIntentUpdate(intentStore, intentRes) + if err != nil { + return err + } updated.Spec.ReconcileRequired = true updated.Spec.ObservedOperation = string(op) updated.Spec.ObservedContentHash = contentHash @@ -390,21 +501,98 @@ func updateIntentResourceObserved(intentStore store.ResourceStore, intentRes *me return nil } -func (a *ResourceStoreAdapter) cleanupIntent(id int64, terminalStatus IntentStatus) { +func prepareIntentUpdate(intentStore store.ResourceStore, intentRes *meshresource.RuleIntentResource) (*meshresource.RuleIntentResource, error) { + if intentRes == nil || intentRes.Spec == nil { + return nil, ErrVersionLedgerCorrupt + } + currentObj, exists, err := intentStore.GetByKey(intentRes.ResourceKey()) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrVersionIntentNotFound + } + current, ok := currentObj.(*meshresource.RuleIntentResource) + if !ok { + return nil, fmt.Errorf("%w: expected RuleIntentResource, got %T", ErrVersionLedgerCorrupt, currentObj) + } + if current.Spec == nil { + return nil, fmt.Errorf("%w: RuleIntent spec is nil for %s", ErrVersionLedgerCorrupt, current.Name) + } + if current.Spec.Revision != intentRes.Spec.Revision { + return nil, ErrVersionIntentConflict + } + updated := current.DeepCopyObject().(*meshresource.RuleIntentResource) + updated.Spec.Revision++ + return updated, nil +} + +func intentResourceKey(intentRes *meshresource.RuleIntentResource) string { + if intentRes == nil || intentRes.Spec == nil { + return "" + } + return coremodel.BuildResourceKey(intentRes.Spec.ParentRuleMesh, intentRes.Spec.ParentRuleName) +} + +func (a *ResourceStoreAdapter) cleanupIntent(id int64, terminalStatus IntentStatus) error { + intentRes, _, err := a.getIntentResourceByID(id) + if errors.Is(err, ErrVersionIntentNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("failed to read terminal rule version intent %d for cleanup: %w", id, err) + } + return a.withParentLock(coremodel.ResourceKind(intentRes.Spec.ParentRuleKind), intentResourceKey(intentRes), func() error { + return a.cleanupIntentLocked(id, terminalStatus) + }) +} + +func (a *ResourceStoreAdapter) cleanupIntentLocked(id int64, terminalStatus IntentStatus) error { intentRes, _, err := a.getIntentResourceByID(id) if errors.Is(err, ErrVersionIntentNotFound) { - return + return nil } if err != nil { - logger.Warnf("failed to read terminal rule version intent %d for cleanup: %v", id, err) - return + return fmt.Errorf("failed to read terminal rule version intent %d for cleanup: %w", id, err) } if intentRes.Spec == nil || IntentStatus(intentRes.Spec.Status) != terminalStatus { - return + return nil + } + if terminalStatus == IntentStatusCommitted { + intent := intentFromResource(intentRes, id) + if _, err := a.committedVersionForIntentLocked(intent); err != nil { + return err + } } if err := a.intentStore.Delete(intentRes); err != nil { - logger.Warnf("failed to cleanup terminal rule version intent %d: %v", id, err) + return fmt.Errorf("failed to cleanup terminal rule version intent %d: %w", id, err) + } + return nil +} + +func (a *ResourceStoreAdapter) committedVersionForIntentLocked(intent *Intent) (*Version, error) { + if intent == nil { + return nil, ErrVersionIntentNotFound + } + versions, err := a.ledgerState(intent.RuleKind, intent.ResourceKey) + if err != nil { + return nil, err + } + var found *Version + for i := range versions.Versions { + if versions.Versions[i].IntentID != intent.ID { + continue + } + if found != nil && found.ID != versions.Versions[i].ID { + return nil, fmt.Errorf("%w: multiple RuleVersion resources committed for intent %d", ErrVersionLedgerCorrupt, intent.ID) + } + v := versions.Versions[i] + found = &v + } + if found == nil { + return nil, ErrVersionNotFound } + return validateCommittedIntentVersion(found, intent) } func (a *ResourceStoreAdapter) multipleOpenIntentsError(kind coremodel.ResourceKind, resourceKey string, intents []*meshresource.RuleIntentResource) error { @@ -436,6 +624,7 @@ func newRuleIntentResource(req InsertRequest, id int64) *meshresource.RuleIntent Reason: req.Reason, Status: string(IntentStatusPending), CreatedAt: timestamppb.New(req.CreatedAt), + Revision: 1, } if req.RolledBackFromID != nil { intentRes.Spec.RolledBackFromId = *req.RolledBackFromID diff --git a/pkg/core/versioning/resource_store_version.go b/pkg/core/versioning/resource_store_version.go index 44a90e1b9..afc5a3793 100644 --- a/pkg/core/versioning/resource_store_version.go +++ b/pkg/core/versioning/resource_store_version.go @@ -38,6 +38,9 @@ import ( ) func (a *ResourceStoreAdapter) GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } rv, err := a.getVersionResourceForRule(kind, resourceKey, id) if err != nil { return nil, err @@ -46,6 +49,9 @@ func (a *ResourceStoreAdapter) GetVersion(kind coremodel.ResourceKind, resourceK } func (a *ResourceStoreAdapter) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } snapshot, err := a.LedgerSnapshot(kind, resourceKey) if err != nil { return nil, err @@ -54,6 +60,9 @@ func (a *ResourceStoreAdapter) ListVersions(kind coremodel.ResourceKind, resourc } func (a *ResourceStoreAdapter) LedgerSnapshot(kind coremodel.ResourceKind, resourceKey string) (*LedgerSnapshot, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } var snapshot *LedgerSnapshot err := a.withParentLock(kind, resourceKey, func() error { state, err := a.ledgerState(kind, resourceKey) @@ -115,6 +124,9 @@ func (a *ResourceStoreAdapter) ledgerState(kind coremodel.ResourceKind, resource } func (a *ResourceStoreAdapter) InsertVersion(ctx context.Context, req InsertRequest, maxVersions int64) (*Version, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } var version *Version err := a.withParentLock(req.RuleKind, req.ResourceKey, func() error { if err := lock.CheckLease(ctx); err != nil { diff --git a/pkg/core/versioning/service.go b/pkg/core/versioning/service.go index d2c8853aa..c45328868 100644 --- a/pkg/core/versioning/service.go +++ b/pkg/core/versioning/service.go @@ -195,6 +195,18 @@ func (s *Service) AbandonIntent(ctx context.Context, intent *Intent, reason stri if intent == nil { return bizerror.New(bizerror.InvalidArgument, "rule version intent is required") } + fresh, err := s.store.GetIntent(intent.ID) + if err != nil { + return err + } + if fresh.Status == IntentStatusFailed { + return s.store.CleanupIntent(fresh.ID, IntentStatusFailed) + } + if fresh.Status != IntentStatusPending && + fresh.Status != IntentStatusApplied && + fresh.Status != IntentStatusOutcomeUnknown { + return ErrVersionIntentNotOpen + } return s.store.MarkIntentFailed(ctx, intent.ID, reason) } @@ -246,11 +258,15 @@ func (s *Service) FinalizeMutation(ctx context.Context, intent *Intent, current case IntentStatusCommitted: committed, err := s.committedVersionForIntent(fresh) if err == nil { - s.store.CleanupIntent(fresh.ID, IntentStatusCommitted) + if cleanupErr := s.store.CleanupIntent(fresh.ID, IntentStatusCommitted); cleanupErr != nil { + return nil, cleanupErr + } } return committed, err case IntentStatusFailed: - s.store.CleanupIntent(fresh.ID, IntentStatusFailed) + if cleanupErr := s.store.CleanupIntent(fresh.ID, IntentStatusFailed); cleanupErr != nil { + return nil, cleanupErr + } if fresh.LastError != "" { return nil, fmt.Errorf("%w: %s", ErrVersionIntentNotOpen, fresh.LastError) } diff --git a/pkg/core/versioning/store.go b/pkg/core/versioning/store.go index ac4fc279e..ae9b24563 100644 --- a/pkg/core/versioning/store.go +++ b/pkg/core/versioning/store.go @@ -35,8 +35,9 @@ type Store interface { MarkIntentObserved(ctx context.Context, id int64, op Operation, contentHash, specJSON string) error MarkIntentFailed(ctx context.Context, id int64, message string) error CommitIntent(ctx context.Context, id int64, maxVersions int64) (*Version, error) - CleanupIntent(id int64, terminalStatus IntentStatus) + CleanupIntent(id int64, terminalStatus IntentStatus) error ListOpenIntents() ([]Intent, error) + ListTerminalIntents() ([]Intent, error) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) LedgerSnapshot(kind coremodel.ResourceKind, resourceKey string) (*LedgerSnapshot, error) GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) diff --git a/pkg/core/versioning/types.go b/pkg/core/versioning/types.go index 65f3e2277..206c179b5 100644 --- a/pkg/core/versioning/types.go +++ b/pkg/core/versioning/types.go @@ -72,6 +72,7 @@ var ( ErrVersionIntentNotOpen = errors.New("rule version intent is not open") // Intent already committed or failed ErrVersionIntentPending = errors.New("rule version intent is pending") // Another mutation in progress ErrVersionLedgerCorrupt = errors.New("rule version ledger corruption") + ErrVersionIntentConflict = errors.New("rule version intent revision conflict") ErrIntentOutcomeMismatch = errors.New("rule version intent outcome does not match current resource") ErrRollbackToDelete = errors.New("cannot roll back to a deleted rule version") ErrRollbackToCurrent = errors.New("cannot roll back to a version identical to current") @@ -132,6 +133,7 @@ type Intent struct { ObservedSpecJSON string `json:"observedSpecJson,omitempty"` ObservedOperation Operation `json:"observedOperation,omitempty"` ObservedAt time.Time `json:"observedAt,omitempty"` + Revision int64 `json:"revision"` CreatedAt time.Time `json:"createdAt"` } diff --git a/ui-vue3/src/base/i18n/en.ts b/ui-vue3/src/base/i18n/en.ts index 700c1c547..a176555da 100644 --- a/ui-vue3/src/base/i18n/en.ts +++ b/ui-vue3/src/base/i18n/en.ts @@ -588,6 +588,7 @@ const words: I18nType = { rollbackConflict: 'Version conflict: the rule was changed. Reload and try again.', rollbackPending: 'A version intent is pending. Repair or abandon it first.', rollbackFailed: 'Rollback failed', + diffFailed: 'Failed to load version diff', rollbackCurrentDisabled: 'Current version does not need rollback', rollbackDeleteDisabled: 'Delete markers cannot be rolled back', source: 'Source', diff --git a/ui-vue3/src/base/i18n/zh.ts b/ui-vue3/src/base/i18n/zh.ts index 329be2c34..19e18b177 100644 --- a/ui-vue3/src/base/i18n/zh.ts +++ b/ui-vue3/src/base/i18n/zh.ts @@ -560,6 +560,7 @@ const words: I18nType = { rollbackConflict: '版本冲突:当前规则已被其他人修改,请刷新后重试', rollbackPending: '当前规则存在未完成的版本 intent,请先修复或放弃后再重试', rollbackFailed: '回滚失败', + diffFailed: '加载版本差异失败', rollbackCurrentDisabled: '当前版本无需回滚', rollbackDeleteDisabled: '删除标记不能回滚', source: '来源', @@ -573,10 +574,10 @@ const words: I18nType = { changeReason: '变更原因', reason: '原因', none: '暂无', - reload: 'Reload', - repair: 'Repair', + reload: '重新加载', + repair: '修复', repairFailed: '修复 intent 失败', - abandon: 'Abandon', + abandon: '放弃', cancel: '取消', abandonIntentTitle: '放弃版本 intent', abandonReasonPlaceholder: '请输入放弃原因(必填)', diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts index f62970227..6147cb523 100644 --- a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts @@ -96,15 +96,34 @@ const version = ( isCurrent }) +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + const drawerStub = defineComponent({ props: ['items'], - emits: ['rollback'], + emits: ['rollback', 'diff-current'], setup(props, { emit }) { return () => h( 'div', { 'data-test': 'history-drawer' }, - (props.items as RuleVersion[]).map((item) => + (props.items as RuleVersion[]).flatMap((item) => [ + h( + 'button', + { + type: 'button', + 'data-test': `diff-${item.id}`, + onClick: () => emit('diff-current', item) + }, + `diff-${item.id}` + ), h( 'button', { @@ -114,7 +133,7 @@ const drawerStub = defineComponent({ }, `rollback-${item.id}` ) - ) + ]) ) } }) @@ -276,4 +295,138 @@ describe('RuleHistoryPanel', () => { expect(wrapper.text()).toContain('rollback-new-current') expect(wrapper.text()).not.toContain('rollback-old-current') }) + + it('ignores stale diff responses after ruleName changes', async () => { + mocks.listRuleVersionsAPI + .mockResolvedValueOnce({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('old-target', 1, 'UPDATE', false)], + total: 1, + currentVersionId: 'old-current', + currentVersionNo: 2, + deleted: false + } + }) + .mockResolvedValueOnce({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('new-target', 3, 'UPDATE', false)], + total: 1, + currentVersionId: 'new-current', + currentVersionNo: 4, + deleted: false + } + }) + const oldDiff = deferred() + mocks.diffRuleVersionAPI.mockReturnValueOnce(oldDiff.promise) + + const wrapper = mountPanel({ ruleName: 'old-rule' }) + await flushPromises() + await wrapper.get('[data-test="diff-old-target"]').trigger('click') + + await wrapper.setProps({ ruleName: 'new-rule' }) + await flushPromises() + oldDiff.resolve({ + code: HTTP_STATUS.SUCCESS, + data: { + left: { id: 'old-target', versionNo: 1, specJson: '{"key":"old"}' }, + right: { id: 'old-current', versionNo: 2, specJson: '{"key":"old-current"}' } + } + }) + await flushPromises() + + expect(wrapper.find('[data-test="rule-diff-editor"]').exists()).toBe(false) + expect(mocks.diffRuleVersionAPI).toHaveBeenCalledWith( + 'condition-rule', + 'old-rule', + 'old-target' + ) + }) + + it('ignores diff responses after drawer closes', async () => { + mocks.listRuleVersionsAPI.mockResolvedValue({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('target', 1, 'UPDATE', false)], + total: 1, + currentVersionId: 'current', + currentVersionNo: 2, + deleted: false + } + }) + const diff = deferred() + mocks.diffRuleVersionAPI.mockReturnValueOnce(diff.promise) + + const wrapper = mountPanel() + await flushPromises() + await wrapper.get('[data-test="diff-target"]').trigger('click') + await wrapper.setProps({ open: false }) + + diff.resolve({ + code: HTTP_STATUS.SUCCESS, + data: { + left: { id: 'target', versionNo: 1, specJson: '{"key":"target"}' }, + right: { id: 'current', versionNo: 2, specJson: '{"key":"current"}' } + } + }) + await flushPromises() + + expect(wrapper.find('[data-test="rule-diff-editor"]').exists()).toBe(false) + }) + + it('ignores stale rollback success after ruleName changes', async () => { + mocks.listRuleVersionsAPI + .mockResolvedValueOnce({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('old-target', 1, 'UPDATE', false)], + total: 1, + currentVersionId: 'old-current', + currentVersionNo: 2, + deleted: false + } + }) + .mockResolvedValueOnce({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('new-target', 3, 'UPDATE', false)], + total: 1, + currentVersionId: 'new-current', + currentVersionNo: 4, + deleted: false + } + }) + const rollback = deferred() + mocks.rollbackRuleVersionAPI.mockReturnValueOnce(rollback.promise) + + const wrapper = mountPanel({ ruleName: 'old-rule' }) + await flushPromises() + await wrapper.get('[data-test="rollback-old-target"]').trigger('click') + await nextTick() + await wrapper.get('[data-test="rollback-reason"]').setValue('restore old') + await wrapper.get('[data-test="modal-ok"]').trigger('click') + + await wrapper.setProps({ ruleName: 'new-rule' }) + await flushPromises() + await wrapper.get('[data-test="rollback-new-target"]').trigger('click') + await nextTick() + expect(wrapper.text()).toContain('v3') + + rollback.resolve({ + code: HTTP_STATUS.SUCCESS, + data: { + rolledBackFromId: 'old-target', + versionId: 'old-rollback', + versionNo: 5, + source: 'ROLLBACK', + committed: true + } + }) + await flushPromises() + + expect(wrapper.text()).toContain('v3') + expect(wrapper.find('[data-test="modal"]').exists()).toBe(true) + expect(mocks.listRuleVersionsAPI).toHaveBeenCalledTimes(2) + }) }) diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue index 4434bfffd..6480ee9aa 100644 --- a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue @@ -177,6 +177,7 @@ const rollbackTarget = ref(null) const rollbackReason = ref('') const rollbackLoading = ref(false) let requestSeq = 0 +let operationSeq = 0 let disposed = false const sourceLabels: Record = { @@ -190,6 +191,31 @@ const sourceLabel = (source: string) => (sourceLabels[source] ? t(sourceLabels[s const authorLabel = (author: string) => author.replace(/^system:/, '') const createdAtLabel = (createdAt: string) => dayjs(createdAt).format('YYYY/M/D HH:mm:ss') +type OperationToken = { + seq: number + open: boolean + kind: TrafficRuleKind + ruleName: string + targetId?: string +} + +const nextOperationToken = (targetId?: string): OperationToken => ({ + seq: ++operationSeq, + open: props.open, + kind: props.kind, + ruleName: props.ruleName, + targetId +}) + +const isCurrentOperation = (token: OperationToken, targetId = token.targetId) => + !disposed && + token.seq === operationSeq && + token.open && + props.open && + token.kind === props.kind && + token.ruleName === props.ruleName && + token.targetId === targetId + async function loadHistory() { const seq = ++requestSeq const kind = props.kind @@ -237,18 +263,28 @@ const openVersionJson = (item: RuleVersion) => { } const openVersionDiff = async (item: RuleVersion) => { - const res = await diffRuleVersionAPI(props.kind, props.ruleName, item.id) - if (res?.code === HTTP_STATUS.SUCCESS) { - versionDiffLeft.value = formatRuleSpec(res.data.left.specJson) - versionDiffRight.value = formatRuleSpec(res.data.right.specJson) - versionDiffLeftLabel.value = versionDiffLabel( - t('ruleVersionDomain.targetVersion'), - res.data.left?.versionNo - ) - versionDiffRightLabel.value = currentDeleted.value - ? t('ruleVersionDomain.currentDeleted') - : versionDiffLabel(t('ruleVersionDomain.currentVersion'), res.data.right?.versionNo) - versionDiffOpen.value = true + const token = nextOperationToken(item.id) + try { + const res = await diffRuleVersionAPI(token.kind, token.ruleName, item.id) + if (!isCurrentOperation(token, item.id)) { + return + } + if (res?.code === HTTP_STATUS.SUCCESS) { + versionDiffLeft.value = formatRuleSpec(res.data.left.specJson) + versionDiffRight.value = formatRuleSpec(res.data.right.specJson) + versionDiffLeftLabel.value = versionDiffLabel( + t('ruleVersionDomain.targetVersion'), + res.data.left?.versionNo + ) + versionDiffRightLabel.value = currentDeleted.value + ? t('ruleVersionDomain.currentDeleted') + : versionDiffLabel(t('ruleVersionDomain.currentVersion'), res.data.right?.versionNo) + versionDiffOpen.value = true + } + } catch (e: any) { + if (isCurrentOperation(token, item.id)) { + message.error(e?.message || t('ruleVersionDomain.diffFailed')) + } } } @@ -265,14 +301,16 @@ const handleRollbackConfirm = async () => { return } + const target = rollbackTarget.value + const token = nextOperationToken(target.id) rollbackLoading.value = true try { // Send the current version as a weak CAS guard so rollback does not // overwrite a newer change made after the drawer was opened. const res = await rollbackRuleVersionAPI( - props.kind, - props.ruleName, - rollbackTarget.value.id, + token.kind, + token.ruleName, + target.id, rollbackReason.value, rollbackExpectedVersionId({ id: currentVersionId.value, @@ -280,6 +318,9 @@ const handleRollbackConfirm = async () => { deleted: currentDeleted.value }) ) + if (!isCurrentOperation(token, target.id) || rollbackTarget.value?.id !== target.id) { + return + } if (res?.code === HTTP_STATUS.SUCCESS) { const versionNo = res.data?.versionNo message.success( @@ -291,6 +332,9 @@ const handleRollbackConfirm = async () => { await loadHistory() } } catch (e: any) { + if (!isCurrentOperation(token, target.id) || rollbackTarget.value?.id !== target.id) { + return + } if (isVersionConflict(e)) { message.error(t('ruleVersionDomain.rollbackConflict')) await loadHistory() @@ -301,7 +345,9 @@ const handleRollbackConfirm = async () => { message.error(e?.message || t('ruleVersionDomain.rollbackFailed')) } } finally { - rollbackLoading.value = false + if (isCurrentOperation(token, target.id)) { + rollbackLoading.value = false + } } } @@ -312,6 +358,7 @@ watch( loadHistory() } else { requestSeq++ + operationSeq++ loading.value = false } }, @@ -321,6 +368,7 @@ watch( onBeforeUnmount(() => { disposed = true requestSeq++ + operationSeq++ }) diff --git a/ui-vue3/src/views/traffic/_shared/ruleVersion.spec.ts b/ui-vue3/src/views/traffic/_shared/ruleVersion.spec.ts index e59dcf973..cf179233a 100644 --- a/ui-vue3/src/views/traffic/_shared/ruleVersion.spec.ts +++ b/ui-vue3/src/views/traffic/_shared/ruleVersion.spec.ts @@ -15,9 +15,43 @@ * limitations under the License. */ -import { beforeAll, describe, expect, it } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuleVersion } from '@/api/service/traffic' +const mocks = vi.hoisted(() => ({ + repairRuleVersionIntentAPI: vi.fn(), + abandonRuleVersionIntentAPI: vi.fn(), + notification: { + warning: vi.fn(), + error: vi.fn(), + close: vi.fn() + }, + modal: { + confirm: vi.fn() + } +})) + +vi.mock('@/api/service/traffic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + repairRuleVersionIntentAPI: mocks.repairRuleVersionIntentAPI, + abandonRuleVersionIntentAPI: mocks.abandonRuleVersionIntentAPI + } +}) + +vi.mock('ant-design-vue', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + notification: mocks.notification, + Modal: { + ...actual.Modal, + confirm: mocks.modal.confirm + } + } +}) + let helpers: typeof import('./ruleVersion') let ruleVersionMock: typeof import('@/mocks/handlers/ruleVersion').ruleVersionMock @@ -34,6 +68,16 @@ beforeAll(async () => { ruleVersionMock = (await import('@/mocks/handlers/ruleVersion')).ruleVersionMock }) +beforeEach(() => { + mocks.repairRuleVersionIntentAPI.mockReset() + mocks.abandonRuleVersionIntentAPI.mockReset() + mocks.notification.warning.mockReset() + mocks.notification.error.mockReset() + mocks.notification.close.mockReset() + mocks.modal.confirm.mockReset() + mocks.modal.confirm.mockReturnValue({ update: vi.fn() }) +}) + const version = ( id: string, isCurrent: boolean, @@ -165,4 +209,53 @@ describe('ruleVersion helpers', () => { expect(ruleVersionMock.shouldPend('demo-repair-success')).toBe(true) expect(ruleVersionMock.shouldPend('demo-normal')).toBe(false) }) + + it('ignores stale repair responses when operation identity is no longer current', async () => { + let current = true + const reload = vi.fn() + mocks.repairRuleVersionIntentAPI.mockResolvedValue({ code: '0000', data: {} }) + + expect( + helpers.notifyRuleVersionError( + { code: 'VERSION_LEDGER_PENDING', intentId: 'intent-1', message: 'pending' }, + { reload, isCurrent: () => current } + ) + ).toBe(true) + const pendingConfig = mocks.notification.warning.mock.calls[0][0] + const buttons = pendingConfig.btn().children.default() + + const repairPromise = buttons[0].props.onClick() + current = false + await repairPromise + + expect(mocks.repairRuleVersionIntentAPI).toHaveBeenCalledWith('intent-1') + expect(reload).not.toHaveBeenCalled() + expect(mocks.notification.close).not.toHaveBeenCalledWith('rule-version-ledger-pending') + expect(mocks.notification.error).not.toHaveBeenCalled() + }) + + it('ignores stale abandon responses when operation identity is no longer current', async () => { + let current = true + const reload = vi.fn() + mocks.abandonRuleVersionIntentAPI.mockResolvedValue({ code: '0000', data: '' }) + + helpers.notifyRuleVersionError( + { code: 'VERSION_LEDGER_PENDING', intentId: 'intent-2', message: 'pending' }, + { reload, isCurrent: () => current } + ) + const pendingConfig = mocks.notification.warning.mock.calls[0][0] + const buttons = pendingConfig.btn().children.default() + buttons[1].props.onClick() + + const modalConfig = mocks.modal.confirm.mock.calls[0][0] + modalConfig.content().props.onChange({ target: { value: 'abandon old intent' } }) + const abandonPromise = modalConfig.onOk().catch(() => undefined) + current = false + await abandonPromise + + expect(mocks.abandonRuleVersionIntentAPI).toHaveBeenCalledWith('intent-2', 'abandon old intent') + expect(reload).not.toHaveBeenCalled() + expect(mocks.notification.close).not.toHaveBeenCalledWith('rule-version-ledger-pending') + expect(mocks.notification.error).not.toHaveBeenCalled() + }) }) diff --git a/ui-vue3/src/views/traffic/_shared/ruleVersion.ts b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts index ca0906c8a..4b0883910 100644 --- a/ui-vue3/src/views/traffic/_shared/ruleVersion.ts +++ b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts @@ -107,7 +107,7 @@ const repairingIntentIds = new Set() const openAbandonReasonModal = ( intentId: string, - options?: { reload?: () => void | Promise } + options?: { reload?: () => void | Promise; isCurrent?: () => boolean } ) => { let reason = '' let submitting = false @@ -141,10 +141,20 @@ const openAbandonReasonModal = ( modal.update({ okButtonProps: { danger: true, loading: true } }) try { await abandonRuleVersionIntentAPI(intentId, trimmed) + if (options?.isCurrent && !options.isCurrent()) { + submitting = false + modal.update({ okButtonProps: { danger: true, loading: false } }) + return Promise.reject() + } notification.close('rule-version-ledger-pending') notification.close('rule-version-abandon-reason-required') await options?.reload?.() } catch (e: any) { + if (options?.isCurrent && !options.isCurrent()) { + submitting = false + modal.update({ okButtonProps: { danger: true, loading: false } }) + return Promise.reject() + } notification.error({ key: 'rule-version-abandon-error', message: t('ruleVersionDomain.abandonFailed'), @@ -160,7 +170,7 @@ const openAbandonReasonModal = ( export const notifyVersionConflict = ( e: any, - options?: { reload?: () => void | Promise } + options?: { reload?: () => void | Promise; isCurrent?: () => boolean } ): boolean => { if (isVersionConflict(e)) { notification.warning({ @@ -177,7 +187,9 @@ export const notifyVersionConflict = ( size: 'small', onClick: () => { notification.close('rule-version-conflict') - options.reload?.() + if (!options.isCurrent || options.isCurrent()) { + options.reload?.() + } } }, { default: () => t('ruleVersionDomain.reload') } @@ -191,7 +203,7 @@ export const notifyVersionConflict = ( export const notifyVersionLedgerPending = ( e: any, - options?: { reload?: () => void | Promise } + options?: { reload?: () => void | Promise; isCurrent?: () => boolean } ): boolean => { if (!isVersionLedgerPending(e)) { return false @@ -223,9 +235,15 @@ export const notifyVersionLedgerPending = ( repairingIntentIds.add(intentId) try { await repairRuleVersionIntentAPI(intentId) + if (options?.isCurrent && !options.isCurrent()) { + return + } notification.close('rule-version-ledger-pending') await options?.reload?.() } catch (e: any) { + if (options?.isCurrent && !options.isCurrent()) { + return + } notification.error({ key: 'rule-version-repair-error', message: t('ruleVersionDomain.repairFailed'), @@ -262,7 +280,9 @@ export const notifyVersionLedgerPending = ( size: 'small', onClick: () => { notification.close('rule-version-ledger-pending') - options.reload?.() + if (!options.isCurrent || options.isCurrent()) { + options.reload?.() + } } }, { default: () => t('ruleVersionDomain.reload') } @@ -274,7 +294,7 @@ export const notifyVersionLedgerPending = ( export const notifyRuleVersionError = ( e: any, - options?: { reload?: () => void | Promise } + options?: { reload?: () => void | Promise; isCurrent?: () => boolean } ): boolean => { if (notifyVersionLedgerPending(e, options)) { return true From 1ad76074078503b6b662d6c8c768863ef907c0f2 Mon Sep 17 00:00:00 2001 From: MoChengqian <2972013548@qq.com> Date: Sat, 20 Jun 2026 20:15:27 +0800 Subject: [PATCH 28/30] Fix rule intent storage CAS and subscriber fallback --- .../service/rule_version_rollback_test.go | 12 + pkg/core/store/store.go | 7 + pkg/core/versioning/component.go | 3 + pkg/core/versioning/resource_store_adapter.go | 4 + .../versioning/resource_store_adapter_test.go | 302 +++++++++++++++++- pkg/core/versioning/resource_store_intent.go | 131 ++++++-- pkg/core/versioning/service.go | 3 + pkg/core/versioning/subscriber.go | 81 ++++- pkg/core/versioning/types.go | 3 + pkg/store/dbcommon/gorm_store.go | 66 ++++ pkg/store/memory/store.go | 46 +++ 11 files changed, 614 insertions(+), 44 deletions(-) diff --git a/pkg/console/service/rule_version_rollback_test.go b/pkg/console/service/rule_version_rollback_test.go index 172c3e5f5..1927f204a 100644 --- a/pkg/console/service/rule_version_rollback_test.go +++ b/pkg/console/service/rule_version_rollback_test.go @@ -210,6 +210,18 @@ func (s *failingResourceStore) Update(obj interface{}) error { return s.ResourceStore.Update(obj) } +func (s *failingResourceStore) UpdateIfUnchanged(expected coremodel.Resource, updated coremodel.Resource) (bool, error) { + if s.failNextUpdate { + s.failNextUpdate = false + return false, s.err + } + cas, ok := s.ResourceStore.(store.ConditionalResourceStore) + if !ok { + return false, fmt.Errorf("wrapped store does not support conditional updates") + } + return cas.UpdateIfUnchanged(expected, updated) +} + func (s *failingResourceStore) Delete(obj interface{}) error { if s.failNextDelete { s.failNextDelete = false diff --git a/pkg/core/store/store.go b/pkg/core/store/store.go index 8923f6577..af99bc975 100644 --- a/pkg/core/store/store.go +++ b/pkg/core/store/store.go @@ -43,6 +43,13 @@ type ResourceStore interface { PageListByIndexes(indexes []index.IndexCondition, pq model.PageReq) (*model.PageData[model.Resource], error) } +// ConditionalResourceStore is a narrow compare-and-swap extension used by +// RuleIntent recovery records. The expected and updated resources must have the +// same key; false means another writer changed or removed the resource. +type ConditionalResourceStore interface { + UpdateIfUnchanged(expected model.Resource, updated model.Resource) (bool, error) +} + // ManagedResourceStore includes both functional interfaces and lifecycle interfaces // If there is a new type of ResourceStore, it should implement this interface type ManagedResourceStore interface { diff --git a/pkg/core/versioning/component.go b/pkg/core/versioning/component.go index 5d03e624c..4fd1b2c3f 100644 --- a/pkg/core/versioning/component.go +++ b/pkg/core/versioning/component.go @@ -98,6 +98,9 @@ func (c *component) Init(ctx runtime.BuilderContext) error { } store := NewResourceStoreAdapter(rvStore, intentStore) + if err := store.ensureStores(); err != nil { + return err + } lockComponent, err := ctx.GetActivatedComponent(lock.DistributedLockComponent) if err != nil { return fmt.Errorf("rule versioning requires a lock component: %w", err) diff --git a/pkg/core/versioning/resource_store_adapter.go b/pkg/core/versioning/resource_store_adapter.go index 21d67bb18..0a374c037 100644 --- a/pkg/core/versioning/resource_store_adapter.go +++ b/pkg/core/versioning/resource_store_adapter.go @@ -30,6 +30,7 @@ var _ Store = &ResourceStoreAdapter{} const parentLockStripes = 256 const maxIDGenerateAttempts = 16 +const maxIntentCASRetries = 8 // ResourceStoreAdapter routes RuleVersion and RuleIntent resources through the // existing resource store. Callers that mutate a parent rule must hold the @@ -54,6 +55,9 @@ func (a *ResourceStoreAdapter) ensureStores() error { if a == nil || a.versionStore == nil || a.intentStore == nil { return fmt.Errorf("%w: RuleVersion and RuleIntent stores are required", ErrVersionLedgerCorrupt) } + if _, ok := a.intentStore.(store.ConditionalResourceStore); !ok { + return fmt.Errorf("%w: RuleIntent store must support conditional updates", ErrVersionLedgerCorrupt) + } return nil } diff --git a/pkg/core/versioning/resource_store_adapter_test.go b/pkg/core/versioning/resource_store_adapter_test.go index 3da0a2436..302e52b79 100644 --- a/pkg/core/versioning/resource_store_adapter_test.go +++ b/pkg/core/versioning/resource_store_adapter_test.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "path/filepath" "strconv" "sync" "sync/atomic" @@ -30,10 +31,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + "gorm.io/driver/sqlite" "k8s.io/client-go/tools/cache" meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" "github.com/apache/dubbo-admin/pkg/common/bizerror" + storecfg "github.com/apache/dubbo-admin/pkg/config/store" "github.com/apache/dubbo-admin/pkg/core/events" corelock "github.com/apache/dubbo-admin/pkg/core/lock" "github.com/apache/dubbo-admin/pkg/core/manager" @@ -42,6 +45,7 @@ import ( "github.com/apache/dubbo-admin/pkg/core/store" "github.com/apache/dubbo-admin/pkg/core/store/index" locallock "github.com/apache/dubbo-admin/pkg/lock/local" + "github.com/apache/dubbo-admin/pkg/store/dbcommon" memoryst "github.com/apache/dubbo-admin/pkg/store/memory" ) @@ -160,7 +164,7 @@ func TestResourceStoreAdapter_CommitIntentRetryAfterIntentStatusFailure(t *testi require.NoError(t, err) require.NoError(t, adapter.MarkIntentApplied(context.Background(), intent.ID)) - failingIntentStore.failNextUpdate = true + failingIntentStore.failUpdateAfter = 2 _, err = adapter.CommitIntent(context.Background(), intent.ID, 10) require.ErrorContains(t, err, "intent status update failed") @@ -798,7 +802,7 @@ func TestResourceStoreAdapter_StaleObservedAndStatusUpdatesConflictOnRevision(t fresh, _, err := adapter.getIntentResourceByID(intent.ID) require.NoError(t, err) require.NoError(t, updateIntentResourceObserved(intentStore, fresh, OperationUpdate, "hash-b", `{"key":"B"}`)) - err = updateIntentResourceStatus(intentStore, fresh, IntentStatusCommitted, "") + err = updateIntentResourceStatus(intentStore, fresh, IntentStatusCommitting, "") require.ErrorIs(t, err, ErrVersionIntentConflict) open, err := adapter.GetIntent(intent.ID) @@ -808,6 +812,208 @@ func TestResourceStoreAdapter_StaleObservedAndStatusUpdatesConflictOnRevision(t assert.Equal(t, "hash-b", open.ObservedContentHash) } +func TestResourceStoreAdapter_GormConditionalUpdateConcurrentCommitAndObservedOnlyOneWins(t *testing.T) { + writerA, writerB, _, intentStoreA, intentStoreB := newGormVersioningAdapters(t) + + intent, err := writerA.CreateIntent(context.Background(), testInsertRequest("demo-rule", "hash-a")) + require.NoError(t, err) + require.NoError(t, writerA.MarkIntentApplied(context.Background(), intent.ID)) + + staleForCommit, _, err := writerA.getIntentResourceByID(intent.ID) + require.NoError(t, err) + staleForObserved, _, err := writerB.getIntentResourceByID(intent.ID) + require.NoError(t, err) + require.Equal(t, staleForCommit.Spec.Revision, staleForObserved.Spec.Revision) + + ready := make(chan struct{}, 2) + start := make(chan struct{}) + results := make(chan error, 2) + go func() { + ready <- struct{}{} + <-start + results <- updateIntentResourceStatus(intentStoreA, staleForCommit, IntentStatusCommitting, "") + }() + go func() { + ready <- struct{}{} + <-start + results <- updateIntentResourceObserved(intentStoreB, staleForObserved, OperationUpdate, "hash-b", `{"key":"B"}`) + }() + <-ready + <-ready + close(start) + + first := <-results + second := <-results + winners := 0 + conflicts := 0 + for _, err := range []error{first, second} { + if err == nil { + winners++ + continue + } + if errors.Is(err, ErrVersionIntentConflict) { + conflicts++ + continue + } + require.NoError(t, err) + } + require.Equal(t, 1, winners) + require.Equal(t, 1, conflicts) + + finalIntent, err := writerA.GetIntent(intent.ID) + require.NoError(t, err) + switch finalIntent.Status { + case IntentStatusCommitting: + assert.False(t, finalIntent.ReconcileRequired) + err = writerB.MarkIntentObserved(context.Background(), intent.ID, OperationUpdate, "hash-b", `{"key":"B"}`) + require.ErrorIs(t, err, ErrVersionIntentNotOpen) + case IntentStatusApplied: + assert.True(t, finalIntent.ReconcileRequired) + _, err = writerB.CommitIntent(context.Background(), intent.ID, 10) + var pending *IntentPendingError + require.ErrorAs(t, err, &pending) + default: + t.Fatalf("unexpected final intent status after concurrent CAS: %s", finalIntent.Status) + } +} + +func TestResourceStoreAdapter_CommitIntentDirtyMarkerCASRejectsBeforeAppend(t *testing.T) { + versionStore, baseIntentStore, _ := newVersioningStores(t) + commitAtCAS := make(chan struct{}) + releaseCommit := make(chan struct{}) + var once sync.Once + blockingIntentStore := &barrierCASStore{ResourceStore: baseIntentStore} + writerA := NewResourceStoreAdapter(versionStore, blockingIntentStore) + writerB := NewResourceStoreAdapter(versionStore, baseIntentStore) + + intent, err := writerA.CreateIntent(context.Background(), testInsertRequest("demo-rule", "hash-a")) + require.NoError(t, err) + require.NoError(t, writerA.MarkIntentApplied(context.Background(), intent.ID)) + + blockingIntentStore.beforeCAS = func(_, updated coremodel.Resource) { + intentRes, ok := updated.(*meshresource.RuleIntentResource) + if !ok || intentRes.Spec == nil || IntentStatus(intentRes.Spec.Status) != IntentStatusCommitting { + return + } + once.Do(func() { + close(commitAtCAS) + <-releaseCommit + }) + } + + errCh := make(chan error, 1) + go func() { + _, commitErr := writerA.CommitIntent(context.Background(), intent.ID, 10) + errCh <- commitErr + }() + <-commitAtCAS + + require.NoError(t, writerB.MarkIntentObserved(context.Background(), intent.ID, OperationUpdate, "hash-b", `{"key":"B"}`)) + close(releaseCommit) + + err = <-errCh + var pending *IntentPendingError + require.ErrorAs(t, err, &pending) + + versions, err := writerA.ListVersions(meshresource.ConditionRouteKind, coremodel.BuildResourceKey("", "demo-rule")) + require.NoError(t, err) + require.Empty(t, versions) + + open, err := writerA.GetIntent(intent.ID) + require.NoError(t, err) + assert.Equal(t, IntentStatusApplied, open.Status) + assert.True(t, open.ReconcileRequired) + assert.Equal(t, "hash-b", open.ObservedContentHash) +} + +func TestSubscriber_StaleOpenIntentAfterCleanupFallsBackToUpstreamVersion(t *testing.T) { + tests := []struct { + name string + terminal func(t *testing.T, adapter *ResourceStoreAdapter, intent *Intent, res coremodel.Resource) + }{ + { + name: "committed cleanup complete", + terminal: func(t *testing.T, adapter *ResourceStoreAdapter, intent *Intent, _ coremodel.Resource) { + t.Helper() + _, err := adapter.CommitIntent(context.Background(), intent.ID, 10) + require.NoError(t, err) + _, err = adapter.GetIntent(intent.ID) + require.ErrorIs(t, err, ErrVersionIntentNotFound) + }, + }, + { + name: "committed cleanup pending", + terminal: func(t *testing.T, adapter *ResourceStoreAdapter, intent *Intent, _ coremodel.Resource) { + t.Helper() + intentStore := adapter.intentStore.(*failOnceStore) + intentStore.failNextDelete = true + _, err := adapter.CommitIntent(context.Background(), intent.ID, 10) + require.ErrorContains(t, err, "cleanup failed") + terminalIntent, err := adapter.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusCommitted, terminalIntent.Status) + }, + }, + { + name: "failed cleanup pending", + terminal: func(t *testing.T, adapter *ResourceStoreAdapter, intent *Intent, _ coremodel.Resource) { + t.Helper() + intentStore := adapter.intentStore.(*failOnceStore) + intentStore.failNextDelete = true + err := adapter.MarkIntentFailed(context.Background(), intent.ID, "abandoned") + require.ErrorContains(t, err, "cleanup failed") + terminalIntent, err := adapter.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusFailed, terminalIntent.Status) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + versionStore, baseIntentStore, _ := newVersioningStores(t) + intentStore := &failOnceStore{ResourceStore: baseIntentStore, err: errors.New("cleanup failed")} + adapter := NewResourceStoreAdapter(versionStore, intentStore) + sub := NewSubscriber(meshresource.ConditionRouteKind, adapter, 10, locallock.NewLocalLock(), context.Background()) + + intended := testConditionRule("demo-rule", "A") + req, err := buildMutationInsertRequest(intended, OperationUpdate, SourceAdmin, "admin", "", nil, time.Unix(100, 0)) + require.NoError(t, err) + intent, err := adapter.CreateIntent(context.Background(), req) + require.NoError(t, err) + require.NoError(t, adapter.MarkIntentApplied(context.Background(), intent.ID)) + staleOpen, err := adapter.GetIntent(intent.ID) + require.NoError(t, err) + + tc.terminal(t, adapter, intent, intended) + + external := testConditionRule("demo-rule", "B") + event := normalizedRuleEvent{ + Resource: external, + Parent: ParentRef{ + Kind: external.ResourceKind(), + Mesh: external.ResourceMesh(), + Name: external.ResourceMeta().Name, + ResourceKey: external.ResourceKey(), + }, + Operation: OperationUpdate, + SpecJSON: []byte(`{"key":"B"}`), + ContentHash: "hash-b", + } + hash, normalized, err := NormalizeResource(external) + require.NoError(t, err) + event.SpecJSON = []byte(normalized) + event.ContentHash = hash + require.NoError(t, sub.handleOpenIntentEvent(staleOpen, event)) + + versions, err := adapter.ListVersions(meshresource.ConditionRouteKind, external.ResourceKey()) + require.NoError(t, err) + require.NotEmpty(t, versions) + assert.Equal(t, hash, versions[0].ContentHash) + assert.Equal(t, SourceUpstream, versions[0].Source) + }) + } +} + func TestService_OutcomeUnknownActualMatchCommitsFixedIntentID(t *testing.T) { versionStore, intentStore, _ := newVersioningStores(t) adapter := NewResourceStoreAdapter(versionStore, intentStore) @@ -941,7 +1147,7 @@ func TestResourceStoreAdapter_CommitIntentAuditMismatchLeavesIntentOpen(t *testi open, err := adapter.GetIntent(intent.ID) require.NoError(t, err) - assert.Equal(t, IntentStatusApplied, open.Status) + assert.Equal(t, IntentStatusCommitting, open.Status) versions, err := adapter.ListVersions(req.RuleKind, req.ResourceKey) require.NoError(t, err) @@ -1459,6 +1665,30 @@ func newVersioningStores(t *testing.T) (store.ResourceStore, store.ResourceStore return versionStore, intentStore, nil } +func newGormVersioningAdapters(t *testing.T) (*ResourceStoreAdapter, *ResourceStoreAdapter, store.ResourceStore, store.ResourceStore, store.ResourceStore) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "versioning.db") + dialector := sqlite.Open("file:" + dbPath + "?cache=shared&_journal_mode=WAL&_busy_timeout=5000") + pool, err := dbcommon.NewConnectionPool(dialector, storecfg.MySQL, t.Name(), dbcommon.DefaultConnectionPoolConfig()) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, pool.Close()) + }) + + versionStoreA := dbcommon.NewGormStore(meshresource.RuleVersionKind, t.Name()+"-version-a", pool) + intentStoreA := dbcommon.NewGormStore(meshresource.RuleIntentKind, t.Name()+"-intent-a", pool) + versionStoreB := dbcommon.NewGormStore(meshresource.RuleVersionKind, t.Name()+"-version-b", pool) + intentStoreB := dbcommon.NewGormStore(meshresource.RuleIntentKind, t.Name()+"-intent-b", pool) + for _, s := range []store.ManagedResourceStore{versionStoreA, intentStoreA, versionStoreB, intentStoreB} { + require.NoError(t, s.Init(nil)) + } + return NewResourceStoreAdapter(versionStoreA, intentStoreA), + NewResourceStoreAdapter(versionStoreB, intentStoreB), + versionStoreA, + intentStoreA, + intentStoreB +} + func testInsertRequest(ruleName, hash string) InsertRequest { return InsertRequest{ RuleKind: meshresource.ConditionRouteKind, @@ -1554,6 +1784,14 @@ func (s *noListKeysStore) ListKeys() []string { return nil } +func (s *noListKeysStore) UpdateIfUnchanged(expected coremodel.Resource, updated coremodel.Resource) (bool, error) { + cas, ok := s.ResourceStore.(store.ConditionalResourceStore) + if !ok { + return false, fmt.Errorf("wrapped store does not support conditional updates") + } + return cas.UpdateIfUnchanged(expected, updated) +} + type singleResourceManager struct { res coremodel.Resource } @@ -1625,12 +1863,44 @@ func (s *hookStore) Update(obj interface{}) error { return nil } +func (s *hookStore) UpdateIfUnchanged(expected coremodel.Resource, updated coremodel.Resource) (bool, error) { + cas, ok := s.ResourceStore.(store.ConditionalResourceStore) + if !ok { + return false, fmt.Errorf("wrapped store does not support conditional updates") + } + changed, err := cas.UpdateIfUnchanged(expected, updated) + if err != nil || !changed { + return changed, err + } + if s.afterUpdate != nil { + s.afterUpdate(updated) + } + return true, nil +} + +type barrierCASStore struct { + store.ResourceStore + beforeCAS func(expected coremodel.Resource, updated coremodel.Resource) +} + +func (s *barrierCASStore) UpdateIfUnchanged(expected coremodel.Resource, updated coremodel.Resource) (bool, error) { + if s.beforeCAS != nil { + s.beforeCAS(expected, updated) + } + cas, ok := s.ResourceStore.(store.ConditionalResourceStore) + if !ok { + return false, fmt.Errorf("wrapped store does not support conditional updates") + } + return cas.UpdateIfUnchanged(expected, updated) +} + type failOnceStore struct { store.ResourceStore - failNextAdd bool - failNextUpdate bool - failNextDelete bool - err error + failNextAdd bool + failNextUpdate bool + failNextDelete bool + failUpdateAfter int + err error } func (s *failOnceStore) Add(obj interface{}) error { @@ -1649,6 +1919,24 @@ func (s *failOnceStore) Update(obj interface{}) error { return s.ResourceStore.Update(obj) } +func (s *failOnceStore) UpdateIfUnchanged(expected coremodel.Resource, updated coremodel.Resource) (bool, error) { + if s.failNextUpdate { + s.failNextUpdate = false + return false, s.err + } + if s.failUpdateAfter > 0 { + s.failUpdateAfter-- + if s.failUpdateAfter == 0 { + return false, s.err + } + } + cas, ok := s.ResourceStore.(store.ConditionalResourceStore) + if !ok { + return false, fmt.Errorf("wrapped store does not support conditional updates") + } + return cas.UpdateIfUnchanged(expected, updated) +} + func (s *failOnceStore) Delete(obj interface{}) error { if s.failNextDelete { s.failNextDelete = false diff --git a/pkg/core/versioning/resource_store_intent.go b/pkg/core/versioning/resource_store_intent.go index e46ed5d5f..6c0eb44c8 100644 --- a/pkg/core/versioning/resource_store_intent.go +++ b/pkg/core/versioning/resource_store_intent.go @@ -165,12 +165,16 @@ func (a *ResourceStoreAdapter) MarkIntentObserved(ctx context.Context, id int64, if err := a.ensureStores(); err != nil { return err } + if ctx == nil { + return context.Canceled + } intentRes, _, err := a.getIntentResourceByID(id) if err != nil { return err } return a.withParentLock(coremodel.ResourceKind(intentRes.Spec.ParentRuleKind), intentResourceKey(intentRes), func() error { - for { + var lastConflict error + for attempt := 0; attempt < maxIntentCASRetries; attempt++ { if err := lock.CheckLease(ctx); err != nil { return err } @@ -180,10 +184,15 @@ func (a *ResourceStoreAdapter) MarkIntentObserved(ctx context.Context, id int64, } err = updateIntentResourceObserved(a.intentStore, fresh, op, contentHash, specJSON) if errors.Is(err, ErrVersionIntentConflict) { + lastConflict = err continue } return err } + if lastConflict != nil { + return lastConflict + } + return ErrVersionIntentConflict }) } @@ -230,13 +239,60 @@ func (a *ResourceStoreAdapter) CommitIntent(ctx context.Context, id int64, maxVe if intent.ReconcileRequired { return &IntentPendingError{IntentID: intent.ID} } + case IntentStatusCommitting: + committed, err := a.insertVersionLocked(ctx, InsertRequest{ + RuleKind: intent.RuleKind, + Mesh: intent.Mesh, + ResourceKey: intent.ResourceKey, + RuleName: intent.RuleName, + SpecJSON: intent.SpecJSON, + ContentHash: intent.ContentHash, + Source: intent.Source, + Operation: intent.Operation, + Author: intent.Author, + Reason: intent.Reason, + IntentID: intent.ID, + RolledBackFromID: intent.RolledBackFromID, + CreatedAt: intent.CreatedAt, + FixedVersionID: &intent.ID, + }, maxVersions) + if err != nil { + return err + } + if err := lock.CheckLease(ctx); err != nil { + return err + } + refreshed, _, err := a.getIntentResourceByID(id) + if err != nil { + return err + } + if err := updateIntentResourceStatus(a.intentStore, refreshed, IntentStatusCommitted, ""); err != nil { + return err + } + if err := a.cleanupIntentLocked(id, IntentStatusCommitted); err != nil { + return err + } + version = committed + return nil default: return ErrVersionIntentNotOpen } - // CommitIntent appends the intended state only when no durable - // subscriber marker has modified the intent since APPLIED. The fixed - // version ID makes retries idempotent if the process crashes after Add. + // CommitIntent first wins a storage-level CAS from APPLIED to + // COMMITTING. That CAS is the ownership boundary: a subscriber dirty + // marker racing from the same revision can win instead, but both cannot. + if err := updateIntentResourceStatus(a.intentStore, freshRes, IntentStatusCommitting, ""); err != nil { + if errors.Is(err, ErrVersionIntentConflict) { + return &IntentPendingError{IntentID: intent.ID} + } + return err + } + if err := lock.CheckLease(ctx); err != nil { + return err + } + + // The fixed version ID makes retries idempotent if the process crashes + // after the COMMITTING ownership CAS or after Add. committed, err := a.insertVersionLocked(ctx, InsertRequest{ RuleKind: intent.RuleKind, Mesh: intent.Mesh, @@ -264,9 +320,6 @@ func (a *ResourceStoreAdapter) CommitIntent(ctx context.Context, id int64, maxVe if err != nil { return err } - if refreshed.Spec.Revision != intent.Revision || refreshed.Spec.ReconcileRequired { - return &IntentPendingError{IntentID: intent.ID} - } if err := updateIntentResourceStatus(a.intentStore, refreshed, IntentStatusCommitted, ""); err != nil { return err } @@ -328,7 +381,7 @@ func (a *ResourceStoreAdapter) listIntentsByStatuses(statuses []IntentStatus) ([ func isOpenIntentStatus(status IntentStatus) bool { switch status { - case IntentStatusPending, IntentStatusApplied, IntentStatusOutcomeUnknown: + case IntentStatusPending, IntentStatusApplied, IntentStatusOutcomeUnknown, IntentStatusCommitting: return true default: return false @@ -336,7 +389,7 @@ func isOpenIntentStatus(status IntentStatus) bool { } func openIntentStatuses() []IntentStatus { - return []IntentStatus{IntentStatusPending, IntentStatusApplied, IntentStatusOutcomeUnknown} + return []IntentStatus{IntentStatusPending, IntentStatusApplied, IntentStatusOutcomeUnknown, IntentStatusCommitting} } func terminalIntentStatuses() []IntentStatus { @@ -437,14 +490,21 @@ func updateIntentResourceStatus(intentStore store.ResourceStore, intentRes *mesh if currentStatus == IntentStatusCommitted { return ErrVersionIntentNotOpen } - if !isOpenIntentStatus(currentStatus) { + if currentStatus == IntentStatusCommitting || !isOpenIntentStatus(currentStatus) { + return ErrVersionIntentNotOpen + } + case IntentStatusCommitting: + if currentStatus == IntentStatusCommitting { + return nil + } + if currentStatus != IntentStatusApplied || intentRes.Spec.ReconcileRequired { return ErrVersionIntentNotOpen } case IntentStatusCommitted: if currentStatus == IntentStatusCommitted { return nil } - if currentStatus != IntentStatusApplied { + if currentStatus != IntentStatusCommitting { return ErrVersionIntentNotOpen } case IntentStatusFailed: @@ -453,7 +513,7 @@ func updateIntentResourceStatus(intentStore store.ResourceStore, intentRes *mesh } } - updated, err := prepareIntentUpdate(intentStore, intentRes) + updated, err := prepareIntentUpdate(intentRes) if err != nil { return err } @@ -466,24 +526,30 @@ func updateIntentResourceStatus(intentStore store.ResourceStore, intentRes *mesh switch status { case IntentStatusApplied: updated.Spec.AppliedAt = now + case IntentStatusCommitting: + updated.Spec.AppliedAt = now case IntentStatusCommitted: updated.Spec.CommittedAt = now } - if err := intentStore.Update(updated); err != nil { + changed, err := conditionalIntentUpdate(intentStore, intentRes, updated) + if err != nil { return err } + if !changed { + return ErrVersionIntentConflict + } return nil } func updateIntentResourceObserved(intentStore store.ResourceStore, intentRes *meshresource.RuleIntentResource, op Operation, contentHash, specJSON string) error { currentStatus := IntentStatus(intentRes.Spec.Status) - if !isOpenIntentStatus(currentStatus) { + if currentStatus == IntentStatusCommitting || !isOpenIntentStatus(currentStatus) { return ErrVersionIntentNotOpen } - updated, err := prepareIntentUpdate(intentStore, intentRes) + updated, err := prepareIntentUpdate(intentRes) if err != nil { return err } @@ -495,38 +561,33 @@ func updateIntentResourceObserved(intentStore store.ResourceStore, intentRes *me if currentStatus == IntentStatusPending { updated.Spec.Status = string(IntentStatusOutcomeUnknown) } - if err := intentStore.Update(updated); err != nil { + changed, err := conditionalIntentUpdate(intentStore, intentRes, updated) + if err != nil { return err } + if !changed { + return ErrVersionIntentConflict + } return nil } -func prepareIntentUpdate(intentStore store.ResourceStore, intentRes *meshresource.RuleIntentResource) (*meshresource.RuleIntentResource, error) { +func prepareIntentUpdate(intentRes *meshresource.RuleIntentResource) (*meshresource.RuleIntentResource, error) { if intentRes == nil || intentRes.Spec == nil { return nil, ErrVersionLedgerCorrupt } - currentObj, exists, err := intentStore.GetByKey(intentRes.ResourceKey()) - if err != nil { - return nil, err - } - if !exists { - return nil, ErrVersionIntentNotFound - } - current, ok := currentObj.(*meshresource.RuleIntentResource) - if !ok { - return nil, fmt.Errorf("%w: expected RuleIntentResource, got %T", ErrVersionLedgerCorrupt, currentObj) - } - if current.Spec == nil { - return nil, fmt.Errorf("%w: RuleIntent spec is nil for %s", ErrVersionLedgerCorrupt, current.Name) - } - if current.Spec.Revision != intentRes.Spec.Revision { - return nil, ErrVersionIntentConflict - } - updated := current.DeepCopyObject().(*meshresource.RuleIntentResource) + updated := intentRes.DeepCopyObject().(*meshresource.RuleIntentResource) updated.Spec.Revision++ return updated, nil } +func conditionalIntentUpdate(intentStore store.ResourceStore, expected *meshresource.RuleIntentResource, updated *meshresource.RuleIntentResource) (bool, error) { + cas, ok := intentStore.(store.ConditionalResourceStore) + if !ok { + return false, fmt.Errorf("%w: RuleIntent store must support conditional updates", ErrVersionLedgerCorrupt) + } + return cas.UpdateIfUnchanged(expected, updated) +} + func intentResourceKey(intentRes *meshresource.RuleIntentResource) string { if intentRes == nil || intentRes.Spec == nil { return "" diff --git a/pkg/core/versioning/service.go b/pkg/core/versioning/service.go index c45328868..5b7320e52 100644 --- a/pkg/core/versioning/service.go +++ b/pkg/core/versioning/service.go @@ -473,6 +473,9 @@ func (s *Service) repairIntent(ctx context.Context, intent *Intent, current core return s.store.CommitIntent(ctx, intent.ID, s.maxVersions) } if !matches { + if intent.Status == IntentStatusCommitting { + return s.failIntentAfterActualReconcile(ctx, intent, current, deleted, "committing intent no longer matches actual registry state") + } return nil, ErrIntentOutcomeMismatch } if _, err := lock.RequireLease(ctx); err != nil { diff --git a/pkg/core/versioning/subscriber.go b/pkg/core/versioning/subscriber.go index ec3d453cc..ff0c72d74 100644 --- a/pkg/core/versioning/subscriber.go +++ b/pkg/core/versioning/subscriber.go @@ -160,7 +160,10 @@ func (s *Subscriber) record(ctx context.Context, event normalizedRuleEvent) erro if openIntent != nil { return s.handleOpenIntentEvent(openIntent, event) } + return s.recordVersion(ctx, event) +} +func (s *Subscriber) recordVersion(ctx context.Context, event normalizedRuleEvent) error { source := SourceUpstream author := "system:upstream" if event.Context != nil { @@ -192,7 +195,7 @@ func (s *Subscriber) record(ctx context.Context, event normalizedRuleEvent) erro CreatedAt: time.Now(), } - _, err = s.store.InsertVersion(ctx, req, s.maxVersions) + _, err := s.store.InsertVersion(ctx, req, s.maxVersions) if err != nil { return fmt.Errorf("failed to insert version: %w", err) } @@ -206,7 +209,81 @@ func (s *Subscriber) handleOpenIntentEvent(openIntent *Intent, event normalizedR return nil } logger.Infof("recording non-matching rule event for %s while rule version intent %d is open; intent close will reconcile actual state", event.Parent.ResourceKey, openIntent.ID) - return s.store.MarkIntentObserved(nil, openIntent.ID, event.Operation, event.ContentHash, string(event.SpecJSON)) + return s.markIntentObservedOrRecord(openIntent, event) +} + +func (s *Subscriber) markIntentObservedOrRecord(openIntent *Intent, event normalizedRuleEvent) error { + if s.appCtx == nil { + return context.Canceled + } + current := openIntent + for attempt := 0; attempt < maxIntentCASRetries; attempt++ { + if err := s.appCtx.Err(); err != nil { + return err + } + if current == nil || current.Status == IntentStatusCommitting { + return s.recordAfterIntentClosed(event) + } + err := s.store.MarkIntentObserved(s.appCtx, current.ID, event.Operation, event.ContentHash, string(event.SpecJSON)) + if err == nil { + return nil + } + if errors.Is(err, ErrVersionIntentNotFound) || errors.Is(err, ErrVersionIntentNotOpen) { + return s.recordAfterIntentClosed(event) + } + if !errors.Is(err, ErrVersionIntentConflict) { + return err + } + refreshed, refreshErr := s.store.OpenIntent(event.Parent.Kind, event.Parent.ResourceKey) + if refreshErr != nil { + return refreshErr + } + if refreshed != nil && intentMatchesEvent(refreshed, event) { + logger.Infof("skipping admin echo rule event for %s after intent refresh; rule version intent %d is open", event.Parent.ResourceKey, refreshed.ID) + return nil + } + current = refreshed + } + return s.recordAfterIntentClosed(event) +} + +func (s *Subscriber) recordAfterIntentClosed(event normalizedRuleEvent) error { + if s.lockMgr == nil { + return lock.ErrLockUnavailable + } + if s.appCtx == nil { + return context.Canceled + } + return withRuleVersionLock(s.appCtx, s.lockMgr, event.Parent.Kind, event.Parent.ResourceKey, func(leaseCtx context.Context) error { + for attempt := 0; attempt < maxIntentCASRetries; attempt++ { + if err := lock.CheckLease(leaseCtx); err != nil { + return err + } + openIntent, err := s.store.OpenIntent(event.Parent.Kind, event.Parent.ResourceKey) + if err != nil { + return err + } + if openIntent == nil || openIntent.Status == IntentStatusCommitting { + return s.recordVersion(leaseCtx, event) + } + if intentMatchesEvent(openIntent, event) { + logger.Infof("skipping admin echo rule event for %s after lock reacquire; rule version intent %d is open", event.Parent.ResourceKey, openIntent.ID) + return nil + } + err = s.store.MarkIntentObserved(leaseCtx, openIntent.ID, event.Operation, event.ContentHash, string(event.SpecJSON)) + if err == nil { + return nil + } + if errors.Is(err, ErrVersionIntentNotFound) || errors.Is(err, ErrVersionIntentNotOpen) { + continue + } + if errors.Is(err, ErrVersionIntentConflict) { + continue + } + return err + } + return s.recordVersion(leaseCtx, event) + }) } func intentMatchesEvent(intent *Intent, event normalizedRuleEvent) bool { diff --git a/pkg/core/versioning/types.go b/pkg/core/versioning/types.go index 206c179b5..89bc84cc4 100644 --- a/pkg/core/versioning/types.go +++ b/pkg/core/versioning/types.go @@ -51,6 +51,8 @@ const ( // - APPLIED: intended state was observed, but RuleVersion may not be durable. // - OUTCOME_UNKNOWN: registry returned an uncertain result or a conflicting // event was observed; repair must read actual state before cleanup. +// - COMMITTING: a clean APPLIED intent won the storage-level CAS for commit; +// retries must finish the fixed-ID RuleVersion or reconcile actual state. // - COMMITTED/FAILED: terminal states, cleaned up after the durable outcome. // // RPC errors and context cancellation are not registry-side fencing. They move @@ -61,6 +63,7 @@ const ( IntentStatusPending IntentStatus = "PENDING" // Intent created, mutation not yet applied IntentStatusApplied IntentStatus = "APPLIED" // Intended state observed, awaiting version commit IntentStatusOutcomeUnknown IntentStatus = "OUTCOME_UNKNOWN" // Actual registry outcome must be reconciled + IntentStatusCommitting IntentStatus = "COMMITTING" // Commit ownership acquired before fixed-ID version append IntentStatusCommitted IntentStatus = "COMMITTED" // Version successfully recorded, intent closed IntentStatusFailed IntentStatus = "FAILED" // Mutation failed or was rejected ) diff --git a/pkg/store/dbcommon/gorm_store.go b/pkg/store/dbcommon/gorm_store.go index ccf54cf2c..79283a35f 100644 --- a/pkg/store/dbcommon/gorm_store.go +++ b/pkg/store/dbcommon/gorm_store.go @@ -22,6 +22,7 @@ import ( "fmt" "reflect" "sort" + "strings" "sync" "gorm.io/gorm" @@ -48,6 +49,7 @@ type GormStore struct { } var _ store.ManagedResourceStore = &GormStore{} +var _ store.ConditionalResourceStore = &GormStore{} // NewGormStore creates a new GORM store for the specified resource kind func NewGormStore(kind model.ResourceKind, address string, pool *ConnectionPool) *GormStore { @@ -216,6 +218,70 @@ func (gs *GormStore) Update(obj interface{}) error { }) } +// UpdateIfUnchanged replaces a resource only when the stored serialized +// resource still matches expected. The conditional UPDATE and index rewrite run +// in one transaction; RowsAffected=0 is a CAS miss and leaves index rows intact. +func (gs *GormStore) UpdateIfUnchanged(expected model.Resource, updated model.Resource) (bool, error) { + if expected == nil || updated == nil { + return false, fmt.Errorf("expected and updated resources are required") + } + if expected.ResourceKind() != gs.kind || updated.ResourceKind() != gs.kind { + return false, fmt.Errorf("resource kind mismatch: expected store kind %s, got expected=%s updated=%s", gs.kind, expected.ResourceKind(), updated.ResourceKind()) + } + if expected.ResourceKey() != updated.ResourceKey() { + return false, fmt.Errorf("conditional update resource key mismatch: expected %s, updated %s", expected.ResourceKey(), updated.ResourceKey()) + } + + expectedModel, err := FromResource(expected) + if err != nil { + return false, err + } + updatedModel, err := FromResource(updated) + if err != nil { + return false, err + } + + db := gs.pool.GetDB() + var changed bool + err = db.Transaction(func(tx *gorm.DB) error { + result := tx.Scopes(TableScope(gs.kind.ToString())).Model(&ResourceModel{}). + Where("resource_key = ? AND name = ? AND mesh = ? AND data = ?", expected.ResourceKey(), expectedModel.Name, expectedModel.Mesh, expectedModel.Data). + Updates(map[string]interface{}{ + "name": updatedModel.Name, + "mesh": updatedModel.Mesh, + "data": updatedModel.Data, + }) + if result.Error != nil { + if isSQLiteLockedError(result.Error) { + changed = false + return nil + } + return result.Error + } + if result.RowsAffected == 0 { + changed = false + return nil + } + if err := gs.persistIndexEntriesTx(tx, updated, expected); err != nil { + return fmt.Errorf("failed to persist index entries for %s: %w", updated.ResourceKey(), err) + } + changed = true + return nil + }) + if err != nil { + return false, err + } + return changed, nil +} + +func isSQLiteLockedError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "database table is locked") || strings.Contains(msg, "database is locked") +} + // Delete removes a resource from the database func (gs *GormStore) Delete(obj interface{}) error { resource, ok := obj.(model.Resource) diff --git a/pkg/store/memory/store.go b/pkg/store/memory/store.go index 6402abaf8..c56a64fdb 100644 --- a/pkg/store/memory/store.go +++ b/pkg/store/memory/store.go @@ -40,10 +40,12 @@ type resourceStore struct { rk coremodel.ResourceKind storeProxy cache.Indexer prefixTrees map[string]*radix.Tree + mu sync.Mutex treesMu sync.RWMutex } var _ store.ManagedResourceStore = &resourceStore{} +var _ store.ConditionalResourceStore = &resourceStore{} func NewMemoryResourceStore(rk coremodel.ResourceKind) store.ManagedResourceStore { return &resourceStore{rk: rk} @@ -74,6 +76,8 @@ func (rs *resourceStore) Start(_ runtime.Runtime, _ <-chan struct{}) error { } func (rs *resourceStore) Add(obj interface{}) error { + rs.mu.Lock() + defer rs.mu.Unlock() if r, ok := obj.(coremodel.Resource); ok { if _, exists, err := rs.storeProxy.GetByKey(r.ResourceKey()); err != nil { return err @@ -92,6 +96,8 @@ func (rs *resourceStore) Add(obj interface{}) error { } func (rs *resourceStore) Update(obj interface{}) error { + rs.mu.Lock() + defer rs.mu.Unlock() r, ok := obj.(coremodel.Resource) var oldRes coremodel.Resource if ok { @@ -114,7 +120,45 @@ func (rs *resourceStore) Update(obj interface{}) error { return nil } +func (rs *resourceStore) UpdateIfUnchanged(expected coremodel.Resource, updated coremodel.Resource) (bool, error) { + if expected == nil || updated == nil { + return false, fmt.Errorf("expected and updated resources are required") + } + if expected.ResourceKind() != rs.rk || updated.ResourceKind() != rs.rk { + return false, fmt.Errorf("resource kind mismatch: expected store kind %s, got expected=%s updated=%s", rs.rk, expected.ResourceKind(), updated.ResourceKind()) + } + if expected.ResourceKey() != updated.ResourceKey() { + return false, fmt.Errorf("conditional update resource key mismatch: expected %s, updated %s", expected.ResourceKey(), updated.ResourceKey()) + } + + rs.mu.Lock() + defer rs.mu.Unlock() + + currentObj, exists, err := rs.storeProxy.GetByKey(expected.ResourceKey()) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + current, ok := currentObj.(coremodel.Resource) + if !ok { + return false, bizerror.NewAssertionError("Resource", reflect.TypeOf(currentObj).Name()) + } + if !reflect.DeepEqual(current, expected) { + return false, nil + } + if err := rs.storeProxy.Update(updated); err != nil { + return false, err + } + rs.removeFromTrees(current) + rs.addToTrees(updated) + return true, nil +} + func (rs *resourceStore) Delete(obj interface{}) error { + rs.mu.Lock() + defer rs.mu.Unlock() if err := rs.storeProxy.Delete(obj); err != nil { return err } @@ -141,6 +185,8 @@ func (rs *resourceStore) GetByKey(key string) (item interface{}, exists bool, er } func (rs *resourceStore) Replace(i []interface{}, s string) error { + rs.mu.Lock() + defer rs.mu.Unlock() // Clear all trees before replace rs.treesMu.Lock() for indexName := range rs.prefixTrees { From 53afc2c11185dc0e5b7bac622750a1ccdd7492df Mon Sep 17 00:00:00 2001 From: MoChengqian <2972013548@qq.com> Date: Sat, 20 Jun 2026 21:30:58 +0800 Subject: [PATCH 29/30] Fix rule version committing recovery ordering --- pkg/core/store/store.go | 5 +- pkg/core/versioning/component.go | 101 ++++++++++++- pkg/core/versioning/component_test.go | 127 ++++++++++++++++ .../versioning/resource_store_adapter_test.go | 135 +++++++++++++++++- pkg/core/versioning/resource_store_intent.go | 72 +++++++++- pkg/core/versioning/resource_store_version.go | 68 +++++++++ pkg/core/versioning/service.go | 9 +- pkg/core/versioning/store.go | 1 + pkg/core/versioning/subscriber.go | 75 ++++++++-- pkg/store/dbcommon/gorm_store.go | 6 +- pkg/store/dbcommon/gorm_store_test.go | 119 ++++++++++++++- .../traffic/dynamicConfig/tabs/formView.vue | 19 +-- .../traffic/routingRule/tabs/formView.vue | 5 +- .../routingRule/tabs/updateByFormView.vue | 34 +---- .../routingRule/tabs/updateByYAMLView.vue | 4 +- .../views/traffic/tagRule/tabs/formView.vue | 1 - .../traffic/tagRule/tabs/updateByFormView.vue | 37 +---- .../traffic/tagRule/tabs/updateByYAMLView.vue | 7 +- 18 files changed, 698 insertions(+), 127 deletions(-) diff --git a/pkg/core/store/store.go b/pkg/core/store/store.go index af99bc975..a55d02eb0 100644 --- a/pkg/core/store/store.go +++ b/pkg/core/store/store.go @@ -84,7 +84,10 @@ func ErrorResourceNotFound(rt, name, mesh string) error { return fmt.Errorf("resource not found: type=%q name=%q mesh=%q", rt, name, mesh) } -var ErrorInvalidOffset = errors.New("invalid offset") +var ( + ErrorInvalidOffset = errors.New("invalid offset") + ErrResourceStoreTransient = errors.New("resource store transient error") +) func IsResourceNotFound(err error) bool { return err != nil && strings.HasPrefix(err.Error(), "Resource not found") diff --git a/pkg/core/versioning/component.go b/pkg/core/versioning/component.go index 4fd1b2c3f..8c2fc2a02 100644 --- a/pkg/core/versioning/component.go +++ b/pkg/core/versioning/component.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "math" + "time" versioningcfg "github.com/apache/dubbo-admin/pkg/config/versioning" "github.com/apache/dubbo-admin/pkg/core/events" @@ -30,6 +31,7 @@ import ( "github.com/apache/dubbo-admin/pkg/core/logger" "github.com/apache/dubbo-admin/pkg/core/manager" meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/core/runtime" ) @@ -45,9 +47,10 @@ type Component interface { } type component struct { - service *Service - store Store - lock lock.Lock + service *Service + store Store + lock lock.Lock + reconcileRequests chan struct{} } func (c *component) Type() runtime.ComponentType { @@ -115,6 +118,7 @@ func (c *component) Init(ctx runtime.BuilderContext) error { } c.store = store c.lock = lockMgr + c.reconcileRequests = make(chan struct{}, 1) c.service = NewService( cfg.MaxVersionsPerRule, store, @@ -130,7 +134,7 @@ func (c *component) Init(ctx runtime.BuilderContext) error { return fmt.Errorf("component %s does not implement events.EventBus", runtime.EventBus) } for _, kind := range governor.RuleResourceKinds.Values() { - sub := NewSubscriber(kind, store, cfg.MaxVersionsPerRule, lockMgr, ctx.AppContext()) + sub := NewSubscriber(kind, store, cfg.MaxVersionsPerRule, lockMgr, ctx.AppContext(), c.requestReconcile) if err := bus.Subscribe(sub); err != nil { return err } @@ -163,6 +167,7 @@ func (c *component) Start(rt runtime.Runtime, stop <-chan struct{}) error { if err := c.bootstrapExistingRules(startCtx, rm, cfg.MaxVersionsPerRule); err != nil { return err } + c.startReconcileLoop(rt.AppContext(), stop, rm, cfg.MaxVersionsPerRule) return nil } @@ -171,8 +176,7 @@ func (c *component) Service() *Service { } func (c *component) bootstrapExistingRules(ctx context.Context, rm manager.ResourceManager, maxVersions int64) error { - // Bootstrap records one baseline version for pre-existing rules. It is - // idempotent across restarts: a rule with any existing version is skipped. + currentKeys := make(map[coremodel.ResourceKind]map[string]struct{}) for _, kind := range governor.RuleResourceKinds.Values() { if err := ctx.Err(); err != nil { return err @@ -183,10 +187,13 @@ func (c *component) bootstrapExistingRules(ctx context.Context, rm manager.Resou return err } if rs == nil { - // Store not available (e.g., in test), skip bootstrap for this kind continue } keys := rs.ListKeys() + currentKeys[kind] = make(map[string]struct{}, len(keys)) + for _, key := range keys { + currentKeys[kind][key] = struct{}{} + } resources, err := rs.GetByKeys(keys) if err != nil { return err @@ -200,9 +207,89 @@ func (c *component) bootstrapExistingRules(ctx context.Context, rm manager.Resou } } } + return c.reconcileDeletedRules(ctx, rm, currentKeys) +} + +func (c *component) reconcileDeletedRules(ctx context.Context, rm manager.ResourceManager, currentKeys map[coremodel.ResourceKind]map[string]struct{}) error { + for _, kind := range governor.RuleResourceKinds.Values() { + if err := ctx.Err(); err != nil { + return err + } + latest, err := c.store.ListLatestVersions(kind) + if err != nil { + return err + } + for _, head := range latest { + if err := ctx.Err(); err != nil { + return err + } + if head.Operation == OperationDelete { + continue + } + if _, exists := currentKeys[kind][head.ResourceKey]; exists { + continue + } + err := withRuleVersionLock(ctx, c.lock, kind, head.ResourceKey, func(leaseCtx context.Context) error { + intent, err := c.store.OpenIntent(kind, head.ResourceKey) + if err != nil { + return err + } + if intent != nil { + return nil + } + current, exists, err := rm.GetByKey(kind, head.ResourceKey) + if err != nil { + return err + } + if exists { + return nil + } + _, err = c.service.ReconcileActualState(leaseCtx, kind, head.ResourceKey, current, true, "system:reconcile") + return err + }) + if err != nil { + return err + } + } + } return nil } +func (c *component) requestReconcile() { + if c == nil || c.reconcileRequests == nil { + return + } + select { + case c.reconcileRequests <- struct{}{}: + default: + } +} + +func (c *component) startReconcileLoop(parent context.Context, stop <-chan struct{}, rm manager.ResourceManager, maxVersions int64) { + if c.reconcileRequests == nil { + c.reconcileRequests = make(chan struct{}, 1) + } + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-parent.Done(): + return + case <-c.reconcileRequests: + case <-ticker.C: + } + ctx, cancel := contextWithStop(parent, stop) + if err := c.bootstrapExistingRules(ctx, rm, maxVersions); err != nil { + logger.Warnf("rule version current-state reconcile failed: %v", err) + } + cancel() + } + }() +} + func (c *component) repairOpenIntents(ctx context.Context, rm manager.ResourceManager) error { intents, err := c.store.ListOpenIntents() if err != nil { diff --git a/pkg/core/versioning/component_test.go b/pkg/core/versioning/component_test.go index 86101e7b1..e07d1bb1e 100644 --- a/pkg/core/versioning/component_test.go +++ b/pkg/core/versioning/component_test.go @@ -19,11 +19,14 @@ package versioning import ( "context" + "errors" + "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/cache" appcfg "github.com/apache/dubbo-admin/pkg/config/app" "github.com/apache/dubbo-admin/pkg/config/mode" @@ -192,6 +195,130 @@ func TestComponentBootstrapExistingRulesIsIdempotentAcrossRestarts(t *testing.T) assert.Equal(t, int64(1), versions[0].VersionNo) } +func TestComponentCurrentStateReconcileRecoversSubscriberAppendFailureWithoutReplay(t *testing.T) { + baseVersionStore, intentStore, _ := newVersioningStores(t) + versionStore := &failOnceStore{ResourceStore: baseVersionStore, err: assert.AnError} + adapter := NewResourceStoreAdapter(versionStore, intentStore) + _, err := adapter.InsertVersion(context.Background(), testInsertRequest("reconcile-rule", "hash-a"), 10) + require.NoError(t, err) + + lockMgr := locallock.NewLocalLock() + reconcileRequested := make(chan struct{}, 1) + sub := NewSubscriber(meshresource.ConditionRouteKind, adapter, 10, lockMgr, context.Background(), func() { + reconcileRequested <- struct{}{} + }) + current := testConditionRule("reconcile-rule", "B") + versionStore.failNextAdd = true + err = sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, current, current)) + require.Error(t, err) + require.Len(t, reconcileRequested, 1) + + conditionStore := newRuleStoreWithResource(t, meshresource.ConditionRouteKind, current) + rm := &fakeVersioningRM{stores: map[coremodel.ResourceKind]corestore.ResourceStore{ + meshresource.ConditionRouteKind: conditionStore, + }} + c := &component{service: NewService(10, adapter), store: adapter, lock: lockMgr} + require.NoError(t, c.bootstrapExistingRules(context.Background(), rm, 10)) + + versions, err := adapter.ListVersions(meshresource.ConditionRouteKind, current.ResourceKey()) + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, SourceUpstream, versions[0].Source) + assert.Equal(t, "system:reconcile", versions[0].Author) + assert.Equal(t, HashSpecForTest(t, current), versions[0].ContentHash) + assert.Equal(t, int64(2), versions[0].VersionNo) +} + +func TestComponentCurrentStateReconcileIsIdempotentAcrossInstances(t *testing.T) { + versionStore, intentStore, _ := newVersioningStores(t) + writerA := NewResourceStoreAdapter(versionStore, intentStore) + writerB := NewResourceStoreAdapter(versionStore, intentStore) + _, err := writerA.InsertVersion(context.Background(), testInsertRequest("multi-reconcile-rule", "hash-a"), 10) + require.NoError(t, err) + + current := testConditionRule("multi-reconcile-rule", "B") + conditionStore := newRuleStoreWithResource(t, meshresource.ConditionRouteKind, current) + rm := &fakeVersioningRM{stores: map[coremodel.ResourceKind]corestore.ResourceStore{ + meshresource.ConditionRouteKind: conditionStore, + }} + lockMgr := locallock.NewLocalLock() + components := []*component{ + {service: NewService(10, writerA), store: writerA, lock: lockMgr}, + {service: NewService(10, writerB), store: writerB, lock: lockMgr}, + } + + var wg sync.WaitGroup + errCh := make(chan error, len(components)) + for _, c := range components { + wg.Add(1) + go func(c *component) { + defer wg.Done() + errCh <- c.bootstrapExistingRules(context.Background(), rm, 10) + }(c) + } + wg.Wait() + close(errCh) + for err := range errCh { + require.NoError(t, err) + } + + versions, err := writerA.ListVersions(meshresource.ConditionRouteKind, current.ResourceKey()) + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, HashSpecForTest(t, current), versions[0].ContentHash) + assert.Equal(t, int64(2), versions[0].VersionNo) +} + +func TestComponentCurrentStateReconcileRecordsDeleteWhenRegistryMissing(t *testing.T) { + versionStore, intentStore, _ := newVersioningStores(t) + adapter := NewResourceStoreAdapter(versionStore, intentStore) + _, err := adapter.InsertVersion(context.Background(), testInsertRequest("deleted-reconcile-rule", "hash-a"), 10) + require.NoError(t, err) + + emptyRuleStore := memoryst.NewMemoryResourceStore(meshresource.ConditionRouteKind) + require.NoError(t, emptyRuleStore.Init(nil)) + rm := &fakeVersioningRM{stores: map[coremodel.ResourceKind]corestore.ResourceStore{ + meshresource.ConditionRouteKind: emptyRuleStore, + }} + c := &component{service: NewService(10, adapter), store: adapter, lock: locallock.NewLocalLock()} + require.NoError(t, c.bootstrapExistingRules(context.Background(), rm, 10)) + + versions, err := adapter.ListVersions(meshresource.ConditionRouteKind, coremodel.BuildResourceKey("", "deleted-reconcile-rule")) + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, OperationDelete, versions[0].Operation) + assert.Equal(t, SourceUpstream, versions[0].Source) +} + +func TestComponentRepairCommittingThenCurrentReconcilePreservesAdminBeforeUpstream(t *testing.T) { + baseVersionStore, intentStore, _ := newVersioningStores(t) + versionStore := &failOnceStore{ResourceStore: baseVersionStore, err: errors.New("version add failed")} + adapter := NewResourceStoreAdapter(versionStore, intentStore) + intent, err := adapter.CreateIntent(context.Background(), testInsertRequest("committing-startup-rule", "hash-a")) + require.NoError(t, err) + require.NoError(t, adapter.MarkIntentApplied(context.Background(), intent.ID)) + versionStore.failNextAdd = true + _, err = adapter.CommitIntent(context.Background(), intent.ID, 10) + require.ErrorContains(t, err, "version add failed") + + current := testConditionRule("committing-startup-rule", "B") + conditionStore := newRuleStoreWithResource(t, meshresource.ConditionRouteKind, current) + rm := &fakeVersioningRM{stores: map[coremodel.ResourceKind]corestore.ResourceStore{ + meshresource.ConditionRouteKind: conditionStore, + }} + c := &component{service: NewService(10, adapter), store: adapter, lock: locallock.NewLocalLock()} + require.NoError(t, c.repairOpenIntents(context.Background(), rm)) + require.NoError(t, c.bootstrapExistingRules(context.Background(), rm, 10)) + + versions, err := adapter.ListVersions(meshresource.ConditionRouteKind, current.ResourceKey()) + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, HashSpecForTest(t, current), versions[0].ContentHash) + assert.Equal(t, SourceUpstream, versions[0].Source) + assert.Equal(t, "hash-a", versions[1].ContentHash) + assert.Equal(t, SourceAdmin, versions[1].Source) +} + func newVersioningComponentBuilder(t *testing.T) *runtime.Builder { t.Helper() cfg := appcfg.DefaultAdminConfig() diff --git a/pkg/core/versioning/resource_store_adapter_test.go b/pkg/core/versioning/resource_store_adapter_test.go index 302e52b79..4036d3469 100644 --- a/pkg/core/versioning/resource_store_adapter_test.go +++ b/pkg/core/versioning/resource_store_adapter_test.go @@ -814,6 +814,7 @@ func TestResourceStoreAdapter_StaleObservedAndStatusUpdatesConflictOnRevision(t func TestResourceStoreAdapter_GormConditionalUpdateConcurrentCommitAndObservedOnlyOneWins(t *testing.T) { writerA, writerB, _, intentStoreA, intentStoreB := newGormVersioningAdapters(t) + key := coremodel.BuildResourceKey("", "demo-rule") intent, err := writerA.CreateIntent(context.Background(), testInsertRequest("demo-rule", "hash-a")) require.NoError(t, err) @@ -866,7 +867,14 @@ func TestResourceStoreAdapter_GormConditionalUpdateConcurrentCommitAndObservedOn case IntentStatusCommitting: assert.False(t, finalIntent.ReconcileRequired) err = writerB.MarkIntentObserved(context.Background(), intent.ID, OperationUpdate, "hash-b", `{"key":"B"}`) - require.ErrorIs(t, err, ErrVersionIntentNotOpen) + require.NoError(t, err) + _, err = writerB.CommitIntent(context.Background(), intent.ID, 10) + require.NoError(t, err) + versions, err := writerB.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, "hash-b", versions[0].ContentHash) + assert.Equal(t, "hash-a", versions[1].ContentHash) case IntentStatusApplied: assert.True(t, finalIntent.ReconcileRequired) _, err = writerB.CommitIntent(context.Background(), intent.ID, 10) @@ -1014,6 +1022,131 @@ func TestSubscriber_StaleOpenIntentAfterCleanupFallsBackToUpstreamVersion(t *tes } } +func TestSubscriber_CommittingIntentFinishesAdminBeforeUpstreamSuccessor(t *testing.T) { + tests := []struct { + name string + prepareCrash func(t *testing.T, adapter *ResourceStoreAdapter, intent *Intent) + }{ + { + name: "applied to committing succeeded before fixed id add", + prepareCrash: func(t *testing.T, adapter *ResourceStoreAdapter, intent *Intent) { + t.Helper() + versionStore := adapter.versionStore.(*failOnceStore) + versionStore.failNextAdd = true + _, err := adapter.CommitIntent(context.Background(), intent.ID, 10) + require.ErrorContains(t, err, "version add failed") + }, + }, + { + name: "fixed id add succeeded before committed transition", + prepareCrash: func(t *testing.T, adapter *ResourceStoreAdapter, intent *Intent) { + t.Helper() + intentStore := adapter.intentStore.(*failOnceStore) + intentStore.failUpdateAfter = 2 + _, err := adapter.CommitIntent(context.Background(), intent.ID, 10) + require.ErrorContains(t, err, "intent update failed") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + baseVersionStore, baseIntentStore, _ := newVersioningStores(t) + versionStore := &failOnceStore{ResourceStore: baseVersionStore, err: errors.New("version add failed")} + intentStore := &failOnceStore{ResourceStore: baseIntentStore, err: errors.New("intent update failed")} + adapter := NewResourceStoreAdapter(versionStore, intentStore) + sub := NewSubscriber(meshresource.ConditionRouteKind, adapter, 10, locallock.NewLocalLock(), context.Background()) + + intent, err := adapter.CreateIntent(context.Background(), testInsertRequest("demo-rule", "hash-a")) + require.NoError(t, err) + require.NoError(t, adapter.MarkIntentApplied(context.Background(), intent.ID)) + tc.prepareCrash(t, adapter, intent) + + committing, err := adapter.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusCommitting, committing.Status) + + upstream := testConditionRule("demo-rule", "B") + event, err := normalizeRuleEvent(events.NewResourceChangedEvent(cache.Updated, upstream, upstream)) + require.NoError(t, err) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, upstream, upstream))) + + versions, err := adapter.ListVersions(meshresource.ConditionRouteKind, upstream.ResourceKey()) + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, event.ContentHash, versions[0].ContentHash) + assert.Equal(t, SourceUpstream, versions[0].Source) + assert.Equal(t, int64(2), versions[0].VersionNo) + assert.Equal(t, "hash-a", versions[1].ContentHash) + assert.Equal(t, SourceAdmin, versions[1].Source) + assert.Equal(t, intent.ID, versions[1].ID) + assert.Equal(t, int64(1), versions[1].VersionNo) + + _, err = adapter.GetIntent(intent.ID) + require.ErrorIs(t, err, ErrVersionIntentNotFound) + + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, upstream, upstream))) + retried, err := adapter.ListVersions(meshresource.ConditionRouteKind, upstream.ResourceKey()) + require.NoError(t, err) + require.Len(t, retried, 2) + }) + } +} + +func TestSubscriber_CommittingIntentSuccessorSurvivesCommitRetry(t *testing.T) { + versionStore, baseIntentStore, _ := newVersioningStores(t) + intentStore := &failOnceStore{ResourceStore: baseIntentStore, err: errors.New("intent update failed")} + writerA := NewResourceStoreAdapter(versionStore, intentStore) + writerB := NewResourceStoreAdapter(versionStore, baseIntentStore) + lockMgr := locallock.NewLocalLock() + sub := NewSubscriber(meshresource.ConditionRouteKind, writerB, 10, lockMgr, context.Background()) + + intent, err := writerA.CreateIntent(context.Background(), testInsertRequest("demo-rule", "hash-a")) + require.NoError(t, err) + require.NoError(t, writerA.MarkIntentApplied(context.Background(), intent.ID)) + intentStore.failUpdateAfter = 2 + _, err = writerA.CommitIntent(context.Background(), intent.ID, 10) + require.ErrorContains(t, err, "intent update failed") + + intended := testConditionRule("demo-rule", "A") + upstream := testConditionRule("demo-rule", "B") + var wg sync.WaitGroup + errCh := make(chan error, 2) + wg.Add(2) + go func() { + defer wg.Done() + errCh <- sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, intended, upstream)) + }() + go func() { + defer wg.Done() + errCh <- withRuleVersionLock(context.Background(), lockMgr, intent.RuleKind, intent.ResourceKey, func(leaseCtx context.Context) error { + _, err := writerA.CommitIntent(leaseCtx, intent.ID, 10) + return err + }) + }() + wg.Wait() + close(errCh) + for err := range errCh { + if err == nil || + errors.Is(err, ErrVersionIntentConflict) || + errors.Is(err, ErrVersionIntentNotFound) || + errors.Is(err, ErrVersionIntentNotOpen) { + continue + } + require.NoError(t, err) + } + + versions, err := writerA.ListVersions(meshresource.ConditionRouteKind, upstream.ResourceKey()) + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, SourceUpstream, versions[0].Source) + assert.Equal(t, SourceAdmin, versions[1].Source) + assert.Equal(t, int64(2), versions[0].VersionNo) + assert.Equal(t, int64(1), versions[1].VersionNo) + + _, err = writerA.GetIntent(intent.ID) + require.ErrorIs(t, err, ErrVersionIntentNotFound) +} + func TestService_OutcomeUnknownActualMatchCommitsFixedIntentID(t *testing.T) { versionStore, intentStore, _ := newVersioningStores(t) adapter := NewResourceStoreAdapter(versionStore, intentStore) diff --git a/pkg/core/versioning/resource_store_intent.go b/pkg/core/versioning/resource_store_intent.go index 6c0eb44c8..e8dd18a79 100644 --- a/pkg/core/versioning/resource_store_intent.go +++ b/pkg/core/versioning/resource_store_intent.go @@ -262,6 +262,14 @@ func (a *ResourceStoreAdapter) CommitIntent(ctx context.Context, id int64, maxVe if err := lock.CheckLease(ctx); err != nil { return err } + if intent.ReconcileRequired { + if _, err := a.insertObservedSuccessorLocked(ctx, intent, maxVersions); err != nil { + return err + } + if err := lock.CheckLease(ctx); err != nil { + return err + } + } refreshed, _, err := a.getIntentResourceByID(id) if err != nil { return err @@ -316,6 +324,14 @@ func (a *ResourceStoreAdapter) CommitIntent(ctx context.Context, id int64, maxVe if err := lock.CheckLease(ctx); err != nil { return err } + if intent.ReconcileRequired { + if _, err := a.insertObservedSuccessorLocked(ctx, intent, maxVersions); err != nil { + return err + } + if err := lock.CheckLease(ctx); err != nil { + return err + } + } refreshed, _, err := a.getIntentResourceByID(id) if err != nil { return err @@ -332,6 +348,50 @@ func (a *ResourceStoreAdapter) CommitIntent(ctx context.Context, id int64, maxVe return version, err } +func (a *ResourceStoreAdapter) insertObservedSuccessorLocked(ctx context.Context, intent *Intent, maxVersions int64) (*Version, error) { + if intent == nil || !intent.ReconcileRequired || intent.ObservedContentHash == "" { + return nil, nil + } + if err := lock.CheckLease(ctx); err != nil { + return nil, err + } + latest, err := a.latestVersionLocked(intent.RuleKind, intent.ResourceKey) + if err != nil && !errors.Is(err, ErrVersionNotFound) { + return nil, err + } + if latest != nil { + if latest.Operation == OperationDelete && intent.ObservedOperation == OperationDelete { + return latest, nil + } + if latest.Operation != OperationDelete && + intent.ObservedOperation != OperationDelete && + latest.ContentHash == intent.ObservedContentHash { + return latest, nil + } + } + operation := intent.ObservedOperation + if operation == "" { + operation = OperationUpdate + } + if latest == nil && operation != OperationDelete { + operation = OperationCreate + } else if latest != nil && latest.Operation == OperationDelete && operation != OperationDelete { + operation = OperationCreate + } + return a.insertVersionLocked(ctx, InsertRequest{ + RuleKind: intent.RuleKind, + Mesh: intent.Mesh, + ResourceKey: intent.ResourceKey, + RuleName: intent.RuleName, + SpecJSON: intent.ObservedSpecJSON, + ContentHash: intent.ObservedContentHash, + Operation: operation, + Source: SourceUpstream, + Author: "system:reconcile", + CreatedAt: time.Now(), + }, maxVersions) +} + func (a *ResourceStoreAdapter) CleanupIntent(id int64, terminalStatus IntentStatus) error { if err := a.ensureStores(); err != nil { return err @@ -545,7 +605,7 @@ func updateIntentResourceStatus(intentStore store.ResourceStore, intentRes *mesh func updateIntentResourceObserved(intentStore store.ResourceStore, intentRes *meshresource.RuleIntentResource, op Operation, contentHash, specJSON string) error { currentStatus := IntentStatus(intentRes.Spec.Status) - if currentStatus == IntentStatusCommitting || !isOpenIntentStatus(currentStatus) { + if !isOpenIntentStatus(currentStatus) { return ErrVersionIntentNotOpen } @@ -585,7 +645,15 @@ func conditionalIntentUpdate(intentStore store.ResourceStore, expected *meshreso if !ok { return false, fmt.Errorf("%w: RuleIntent store must support conditional updates", ErrVersionLedgerCorrupt) } - return cas.UpdateIfUnchanged(expected, updated) + var lastErr error + for attempt := 0; attempt < maxIntentCASRetries; attempt++ { + changed, err := cas.UpdateIfUnchanged(expected, updated) + if err == nil || !errors.Is(err, store.ErrResourceStoreTransient) { + return changed, err + } + lastErr = err + } + return false, lastErr } func intentResourceKey(intentRes *meshresource.RuleIntentResource) string { diff --git a/pkg/core/versioning/resource_store_version.go b/pkg/core/versioning/resource_store_version.go index afc5a3793..2c48b55d1 100644 --- a/pkg/core/versioning/resource_store_version.go +++ b/pkg/core/versioning/resource_store_version.go @@ -59,6 +59,63 @@ func (a *ResourceStoreAdapter) ListVersions(kind coremodel.ResourceKind, resourc return snapshot.Versions, nil } +func (a *ResourceStoreAdapter) ListLatestVersions(kind coremodel.ResourceKind) ([]Version, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } + keys := a.versionStore.ListKeys() + objs, err := a.versionStore.GetByKeys(keys) + if err != nil { + return nil, err + } + byParent := make(map[string][]Version) + for _, obj := range objs { + rv, ok := obj.(*meshresource.RuleVersionResource) + if !ok { + return nil, fmt.Errorf("%w: expected RuleVersionResource, got %T", ErrVersionLedgerCorrupt, obj) + } + if rv.Spec == nil { + return nil, fmt.Errorf("%w: RuleVersion spec is nil for %s", ErrVersionLedgerCorrupt, rv.ResourceKey()) + } + if rv.Spec.ParentRuleKind != string(kind) { + continue + } + id, err := versionIDFromResource(rv) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrVersionLedgerCorrupt, err) + } + v, err := protoToVersion(rv.Spec, id) + if err != nil { + return nil, err + } + byParent[v.ResourceKey] = append(byParent[v.ResourceKey], *v) + } + + latest := make([]Version, 0, len(byParent)) + for resourceKey, versions := range byParent { + seenVersionNo := make(map[int64]int64, len(versions)) + for _, version := range versions { + if previousID, ok := seenVersionNo[version.VersionNo]; ok && previousID != version.ID { + return nil, duplicateVersionNoError(kind, resourceKey, version.VersionNo, previousID, version.ID) + } + seenVersionNo[version.VersionNo] = version.ID + } + sort.Slice(versions, func(i, j int) bool { + return versions[i].VersionNo > versions[j].VersionNo + }) + if len(versions) > 0 { + latest = append(latest, versions[0]) + } + } + sort.Slice(latest, func(i, j int) bool { + if latest[i].ResourceKey == latest[j].ResourceKey { + return latest[i].VersionNo > latest[j].VersionNo + } + return latest[i].ResourceKey < latest[j].ResourceKey + }) + return latest, nil +} + func (a *ResourceStoreAdapter) LedgerSnapshot(kind coremodel.ResourceKind, resourceKey string) (*LedgerSnapshot, error) { if err := a.ensureStores(); err != nil { return nil, err @@ -75,6 +132,17 @@ func (a *ResourceStoreAdapter) LedgerSnapshot(kind coremodel.ResourceKind, resou return snapshot, err } +func (a *ResourceStoreAdapter) latestVersionLocked(kind coremodel.ResourceKind, resourceKey string) (*Version, error) { + state, err := a.ledgerState(kind, resourceKey) + if err != nil { + return nil, err + } + if state.Latest == nil { + return nil, ErrVersionNotFound + } + return state.Latest, nil +} + func (a *ResourceStoreAdapter) ledgerState(kind coremodel.ResourceKind, resourceKey string) (*ledgerState, error) { parentKey := buildParentIndexKey(kind, resourceKey) objs, err := a.versionStore.ByIndex(index.ByParentRuleIndexName, parentKey) diff --git a/pkg/core/versioning/service.go b/pkg/core/versioning/service.go index 5b7320e52..d5b42c048 100644 --- a/pkg/core/versioning/service.go +++ b/pkg/core/versioning/service.go @@ -453,6 +453,9 @@ func (s *Service) repairIntent(ctx context.Context, intent *Intent, current core if intent.ReconcileRequired { return s.resolveObservedIntent(ctx, intent, current, deleted) } + if intent.Status == IntentStatusCommitting { + return s.store.CommitIntent(ctx, intent.ID, s.maxVersions) + } matches := IntentMatchesResource(intent, current, deleted) if intent.Status == IntentStatusPending || intent.Status == IntentStatusOutcomeUnknown { if !matches { @@ -473,9 +476,6 @@ func (s *Service) repairIntent(ctx context.Context, intent *Intent, current core return s.store.CommitIntent(ctx, intent.ID, s.maxVersions) } if !matches { - if intent.Status == IntentStatusCommitting { - return s.failIntentAfterActualReconcile(ctx, intent, current, deleted, "committing intent no longer matches actual registry state") - } return nil, ErrIntentOutcomeMismatch } if _, err := lock.RequireLease(ctx); err != nil { @@ -506,6 +506,9 @@ func (s *Service) resolveObservedIntent(ctx context.Context, intent *Intent, cur } return s.store.CommitIntent(ctx, intent.ID, s.maxVersions) } + if intent.Status == IntentStatusCommitting { + return s.store.CommitIntent(ctx, intent.ID, s.maxVersions) + } return s.failIntentAfterActualReconcile(ctx, intent, current, deleted, "non-matching rule event superseded the open intent") } diff --git a/pkg/core/versioning/store.go b/pkg/core/versioning/store.go index ae9b24563..5c2eaf3c8 100644 --- a/pkg/core/versioning/store.go +++ b/pkg/core/versioning/store.go @@ -38,6 +38,7 @@ type Store interface { CleanupIntent(id int64, terminalStatus IntentStatus) error ListOpenIntents() ([]Intent, error) ListTerminalIntents() ([]Intent, error) + ListLatestVersions(kind coremodel.ResourceKind) ([]Version, error) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) LedgerSnapshot(kind coremodel.ResourceKind, resourceKey string) (*LedgerSnapshot, error) GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) diff --git a/pkg/core/versioning/subscriber.go b/pkg/core/versioning/subscriber.go index ff0c72d74..c981209ff 100644 --- a/pkg/core/versioning/subscriber.go +++ b/pkg/core/versioning/subscriber.go @@ -38,6 +38,7 @@ type Subscriber struct { maxVersions int64 lockMgr lock.Lock appCtx context.Context + onError func() } type ParentRef struct { @@ -56,13 +57,18 @@ type normalizedRuleEvent struct { Context map[string]string } -func NewSubscriber(kind coremodel.ResourceKind, store Store, maxVersions int64, lockMgr lock.Lock, appCtx context.Context) *Subscriber { +func NewSubscriber(kind coremodel.ResourceKind, store Store, maxVersions int64, lockMgr lock.Lock, appCtx context.Context, onError ...func()) *Subscriber { + var trigger func() + if len(onError) > 0 { + trigger = onError[0] + } return &Subscriber{ kind: kind, store: store, maxVersions: maxVersions, lockMgr: lockMgr, appCtx: appCtx, + onError: trigger, } } @@ -131,6 +137,14 @@ func (s *Subscriber) ProcessEvent(event events.Event) error { if normalized == nil { return nil } + err = s.processNormalizedEvent(normalized) + if err != nil && s.onError != nil { + s.onError() + } + return err +} + +func (s *Subscriber) processNormalizedEvent(normalized *normalizedRuleEvent) error { openIntent, err := s.store.OpenIntent(normalized.Parent.Kind, normalized.Parent.ResourceKey) if err != nil { return err @@ -221,11 +235,15 @@ func (s *Subscriber) markIntentObservedOrRecord(openIntent *Intent, event normal if err := s.appCtx.Err(); err != nil { return err } - if current == nil || current.Status == IntentStatusCommitting { + if current == nil { return s.recordAfterIntentClosed(event) } err := s.store.MarkIntentObserved(s.appCtx, current.ID, event.Operation, event.ContentHash, string(event.SpecJSON)) if err == nil { + if current.Status == IntentStatusCommitting { + _, err = s.store.CommitIntent(s.appCtx, current.ID, s.maxVersions) + return err + } return nil } if errors.Is(err, ErrVersionIntentNotFound) || errors.Is(err, ErrVersionIntentNotOpen) { @@ -263,7 +281,7 @@ func (s *Subscriber) recordAfterIntentClosed(event normalizedRuleEvent) error { if err != nil { return err } - if openIntent == nil || openIntent.Status == IntentStatusCommitting { + if openIntent == nil { return s.recordVersion(leaseCtx, event) } if intentMatchesEvent(openIntent, event) { @@ -272,6 +290,10 @@ func (s *Subscriber) recordAfterIntentClosed(event normalizedRuleEvent) error { } err = s.store.MarkIntentObserved(leaseCtx, openIntent.ID, event.Operation, event.ContentHash, string(event.SpecJSON)) if err == nil { + if openIntent.Status == IntentStatusCommitting { + _, err = s.store.CommitIntent(leaseCtx, openIntent.ID, s.maxVersions) + return err + } return nil } if errors.Is(err, ErrVersionIntentNotFound) || errors.Is(err, ErrVersionIntentNotOpen) { @@ -282,7 +304,14 @@ func (s *Subscriber) recordAfterIntentClosed(event normalizedRuleEvent) error { } return err } - return s.recordVersion(leaseCtx, event) + openIntent, err := s.store.OpenIntent(event.Parent.Kind, event.Parent.ResourceKey) + if err != nil { + return err + } + if openIntent == nil { + return s.recordVersion(leaseCtx, event) + } + return &IntentPendingError{IntentID: openIntent.ID} }) } @@ -315,18 +344,29 @@ func (s *Subscriber) checkDuplicate(kind coremodel.ResourceKind, resourceKey str // recordBootstrapState creates a baseline version for a rule during bootstrap. func recordBootstrapState(ctx context.Context, store Store, maxVersions int64, res coremodel.Resource) error { kind := res.ResourceKind() - versions, err := store.ListVersions(kind, res.ResourceKey()) + hash, specJSON, err := NormalizeResource(res) if err != nil { return err } - if len(versions) > 0 { - return nil - } - hash, specJSON, err := NormalizeResource(res) - if err != nil { + operation := OperationCreate + source := SourceBootstrap + author := "system:bootstrap" + latest, err := store.LatestVersion(kind, res.ResourceKey()) + if err != nil && !errors.Is(err, ErrVersionNotFound) { return err } + if latest != nil { + if latest.Operation != OperationDelete && latest.ContentHash == hash { + return nil + } + source = SourceUpstream + author = "system:reconcile" + operation = OperationUpdate + if latest.Operation == OperationDelete { + operation = OperationCreate + } + } req := InsertRequest{ RuleKind: kind, @@ -335,13 +375,13 @@ func recordBootstrapState(ctx context.Context, store Store, maxVersions int64, r RuleName: res.ResourceMeta().Name, SpecJSON: specJSON, ContentHash: hash, - Source: SourceBootstrap, - Operation: OperationCreate, - Author: "system:bootstrap", + Source: source, + Operation: operation, + Author: author, CreatedAt: time.Now(), } if _, err := store.InsertVersion(ctx, req, maxVersions); err != nil { - return fmt.Errorf("bootstrap version for %s failed: %w", res.ResourceKey(), err) + return fmt.Errorf("current-state version for %s failed: %w", res.ResourceKey(), err) } return nil } @@ -351,6 +391,13 @@ func RecordBootstrapLocked(ctx context.Context, store Store, maxVersions int64, if err := lock.CheckLease(ctx); err != nil { return err } + openIntent, err := store.OpenIntent(kind, resourceKey) + if err != nil { + return err + } + if openIntent != nil { + return nil + } current, exists, err := rm.GetByKey(kind, resourceKey) if err != nil { return err diff --git a/pkg/store/dbcommon/gorm_store.go b/pkg/store/dbcommon/gorm_store.go index 79283a35f..618e71a76 100644 --- a/pkg/store/dbcommon/gorm_store.go +++ b/pkg/store/dbcommon/gorm_store.go @@ -253,8 +253,7 @@ func (gs *GormStore) UpdateIfUnchanged(expected model.Resource, updated model.Re }) if result.Error != nil { if isSQLiteLockedError(result.Error) { - changed = false - return nil + return fmt.Errorf("%w: %v", store.ErrResourceStoreTransient, result.Error) } return result.Error } @@ -269,6 +268,9 @@ func (gs *GormStore) UpdateIfUnchanged(expected model.Resource, updated model.Re return nil }) if err != nil { + if isSQLiteLockedError(err) { + return false, fmt.Errorf("%w: %v", store.ErrResourceStoreTransient, err) + } return false, err } return changed, nil diff --git a/pkg/store/dbcommon/gorm_store_test.go b/pkg/store/dbcommon/gorm_store_test.go index a9bd04ed9..32b71c579 100644 --- a/pkg/store/dbcommon/gorm_store_test.go +++ b/pkg/store/dbcommon/gorm_store_test.go @@ -18,14 +18,18 @@ package dbcommon import ( + "context" "encoding/json" + "errors" "fmt" "os" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" + "gorm.io/gorm" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -33,6 +37,7 @@ import ( storecfg "github.com/apache/dubbo-admin/pkg/config/store" "github.com/apache/dubbo-admin/pkg/core/resource/model" + corestore "github.com/apache/dubbo-admin/pkg/core/store" "github.com/apache/dubbo-admin/pkg/core/store/index" ) @@ -118,12 +123,26 @@ func (m mockResourceList) SetItems(items []model.Resource) { // setupTestStore creates a new GormStore with an in-memory SQLite database for testing func setupTestStore(t *testing.T) (*GormStore, func()) { // Create temporary SQLite database file for better isolation and reliability - tmpFile, err := os.CreateTemp("", fmt.Sprintf("test-db-%s-*.db", t.Name())) + dbPath := tempSQLitePath(t) + dialector := sqlite.Open(dbPath) + return setupTestStoreWithDialector(t, dialector) +} + +func tempSQLitePath(t *testing.T) string { + t.Helper() + safeName := strings.NewReplacer("/", "_", "\\", "_").Replace(t.Name()) + tmpFile, err := os.CreateTemp("", fmt.Sprintf("test-db-%s-*.db", safeName)) require.NoError(t, err) dbPath := tmpFile.Name() - tmpFile.Close() + require.NoError(t, tmpFile.Close()) + t.Cleanup(func() { + _ = os.Remove(dbPath) + }) + return dbPath +} - dialector := sqlite.Open(dbPath) +func setupTestStoreWithDialector(t *testing.T, dialector gorm.Dialector) (*GormStore, func()) { + t.Helper() pool, err := NewConnectionPool(dialector, storecfg.MySQL, t.Name(), DefaultConnectionPoolConfig()) require.NoError(t, err) @@ -146,8 +165,7 @@ func setupTestStore(t *testing.T) (*GormStore, func()) { // Cleanup function cleanup := func() { - pool.Close() - os.Remove(dbPath) + _ = pool.Close() } return store, cleanup @@ -347,6 +365,97 @@ func TestGormStore_UpdateNonExistent(t *testing.T) { assert.Contains(t, err.Error(), "not found") } +func TestGormStore_UpdateIfUnchangedDistinguishesCASMissDBLockedAndSQLError(t *testing.T) { + t.Run("cas miss", func(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + require.NoError(t, store.Init(nil)) + + current := &mockResource{ + Kind: "TestResource", + Key: "cas-key", + Mesh: "default", + Meta: metav1.ObjectMeta{Name: "current"}, + } + require.NoError(t, store.Add(current)) + stale := &mockResource{ + Kind: "TestResource", + Key: "cas-key", + Mesh: "default", + Meta: metav1.ObjectMeta{Name: "stale"}, + } + updated := &mockResource{ + Kind: "TestResource", + Key: "cas-key", + Mesh: "default", + Meta: metav1.ObjectMeta{Name: "updated"}, + } + + changed, err := store.UpdateIfUnchanged(stale, updated) + require.NoError(t, err) + assert.False(t, changed) + }) + + t.Run("db locked", func(t *testing.T) { + dbPath := tempSQLitePath(t) + store, cleanup := setupTestStoreWithDialector(t, sqlite.Open(dbPath+"?_busy_timeout=1")) + defer cleanup() + require.NoError(t, store.Init(nil)) + + current := &mockResource{ + Kind: "TestResource", + Key: "locked-key", + Mesh: "default", + Meta: metav1.ObjectMeta{Name: "current"}, + } + require.NoError(t, store.Add(current)) + updated := &mockResource{ + Kind: "TestResource", + Key: "locked-key", + Mesh: "default", + Meta: metav1.ObjectMeta{Name: "updated"}, + } + + sqlDB, err := store.pool.GetDB().DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(2) + conn, err := sqlDB.Conn(context.Background()) + require.NoError(t, err) + defer conn.Close() + _, err = conn.ExecContext(context.Background(), "BEGIN EXCLUSIVE") + require.NoError(t, err) + defer conn.ExecContext(context.Background(), "ROLLBACK") + + changed, err := store.UpdateIfUnchanged(current, updated) + require.ErrorIs(t, err, corestore.ErrResourceStoreTransient) + assert.False(t, changed) + }) + + t.Run("ordinary sql error", func(t *testing.T) { + store, cleanup := setupTestStore(t) + require.NoError(t, store.Init(nil)) + current := &mockResource{ + Kind: "TestResource", + Key: "sql-error-key", + Mesh: "default", + Meta: metav1.ObjectMeta{Name: "current"}, + } + require.NoError(t, store.Add(current)) + cleanup() + updated := &mockResource{ + Kind: "TestResource", + Key: "sql-error-key", + Mesh: "default", + Meta: metav1.ObjectMeta{Name: "updated"}, + } + + changed, err := store.UpdateIfUnchanged(current, updated) + require.Error(t, err) + assert.False(t, changed) + assert.False(t, errors.Is(err, corestore.ErrResourceStoreTransient)) + }) +} + func TestGormStore_Delete(t *testing.T) { store, cleanup := setupTestStore(t) defer cleanup() diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue index 3218ff356..68851326a 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue @@ -414,10 +414,8 @@