From 7edc781d66b8e8838810272d6585e7d2baaa6693 Mon Sep 17 00:00:00 2001 From: Dmitry Shulyak Date: Wed, 4 May 2016 13:47:30 +0300 Subject: [PATCH] Add helper to preserve atomicity for database group operations All solar cli actions will be atomic, for example if user is going to create resources from composer file - all or none will be added to database If for some reason this behaviour is undesirable for particular command developer can overwrite it by using default click command: @group.command(cls=click.Command) For those who are using solar as a library - decorator and context managers are available in following module: from solar.dblayer.utils import atomic @atomic def setup(): Change-Id: I8491d90f17c25edc85f18bc7bd7e16c32c3f4561 --- solar/cli/base.py | 14 ++++++ solar/core/resource/composer.py | 1 - solar/dblayer/model.py | 7 +++ solar/dblayer/test/test_atomic.py | 81 +++++++++++++++++++++++++++++++ solar/dblayer/utils.py | 45 +++++++++++++++++ 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 solar/dblayer/test/test_atomic.py create mode 100644 solar/dblayer/utils.py diff --git a/solar/cli/base.py b/solar/cli/base.py index 7277218e..3b0024ad 100644 --- a/solar/cli/base.py +++ b/solar/cli/base.py @@ -17,6 +17,7 @@ from functools import wraps import click from solar.dblayer.model import DBLayerException +from solar.dblayer.utils import Atomic from solar.errors import SolarError @@ -45,10 +46,19 @@ class AliasedGroup(click.Group): ctx.fail('Too many matches: %s' % ', '.join(sorted(matches))) +class AtomicCommand(click.Command): + + def invoke(self, *args, **kwargs): + with Atomic(): + return super(AtomicCommand, self).invoke(*args, **kwargs) + + class BaseGroup(click.Group): + error_wrapper_enabled = False def add_command(self, cmd, name=None): + cmd.callback = self.error_wrapper(cmd.callback) return super(BaseGroup, self).add_command(cmd, name) @@ -69,3 +79,7 @@ class BaseGroup(click.Group): def handle_exception(self, e): pass + + def command(self, *args, **kwargs): + kwargs.setdefault('cls', AtomicCommand) + return super(BaseGroup, self).command(*args, **kwargs) diff --git a/solar/core/resource/composer.py b/solar/core/resource/composer.py index 71bf863e..3abddc44 100644 --- a/solar/core/resource/composer.py +++ b/solar/core/resource/composer.py @@ -100,7 +100,6 @@ def create(name, spec, inputs=None, tags=None): else: r = create_resource(name, spec, inputs=inputs, tags=tags) rs = [r] - return CreatedResources(rs) diff --git a/solar/dblayer/model.py b/solar/dblayer/model.py index 98dac57f..f93fb200 100644 --- a/solar/dblayer/model.py +++ b/solar/dblayer/model.py @@ -616,6 +616,13 @@ class ModelMeta(type): continue cls._c.lazy_save.clear() + @classmethod + def find_non_empty_lazy_saved(mcs): + for cls in mcs._defined_models: + if cls._c.lazy_save: + return cls._c.lazy_save + return None + @classmethod def session_end(mcs, result=True): mcs.save_all_lazy() diff --git a/solar/dblayer/test/test_atomic.py b/solar/dblayer/test/test_atomic.py new file mode 100644 index 00000000..9ea13ad4 --- /dev/null +++ b/solar/dblayer/test/test_atomic.py @@ -0,0 +1,81 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed 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 pytest + +from solar.dblayer.model import DBLayerException +from solar.dblayer.model import DBLayerNotFound +from solar.dblayer.model import Field +from solar.dblayer.model import Model +from solar.dblayer.model import ModelMeta +from solar.dblayer import utils + + +class T1(Model): + + fi1 = Field(str) + + +def save_multiple(key1, key2): + ex1 = T1.from_dict({'key': key1, 'fi1': 'blah blah live'}) + ex1.save_lazy() + ex2 = T1.from_dict({'key': key2, 'fi1': 'blah blah another live'}) + ex2.save_lazy() + +atomic_save_multiple = utils.atomic(save_multiple) + + +def test_one_will_be_saved(rk): + key = next(rk) + with pytest.raises(DBLayerException): + save_multiple(key, key) + ModelMeta.session_end() + + assert T1.get(key) + + +def test_atomic_none_saved(rk): + key = next(rk) + + with pytest.raises(DBLayerException): + with utils.Atomic(): + save_multiple(key, key) + + with pytest.raises(DBLayerNotFound): + assert T1.get(key) + + +def test_atomic_decorator_none_saved(rk): + key = next(rk) + + with pytest.raises(DBLayerException): + atomic_save_multiple(key, key) + + with pytest.raises(DBLayerNotFound): + assert T1.get(key) + + +def test_atomic_save_all(rk): + key1, key2 = (next(rk) for _ in range(2)) + atomic_save_multiple(key1, key2) + assert T1.get(key1) + assert T1.get(key2) + + +def test_atomic_helper_validation(rk): + key1, key2, key3 = (next(rk) for _ in range(3)) + ex1 = T1.from_dict({'key': key1, 'fi1': 'stuff'}) + ex1.save_lazy() + with pytest.raises(DBLayerException): + atomic_save_multiple(key1, key2) diff --git a/solar/dblayer/utils.py b/solar/dblayer/utils.py new file mode 100644 index 00000000..39fd69b8 --- /dev/null +++ b/solar/dblayer/utils.py @@ -0,0 +1,45 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed 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 wrapt + +from solar.dblayer.model import DBLayerException +from solar.dblayer import ModelMeta + + +class Atomic(object): + + def __enter__(self): + lazy_saved = ModelMeta.find_non_empty_lazy_saved() + if lazy_saved: + raise DBLayerException( + 'Some objects could be accidentally rolled back on failure, ' + 'Please ensure that atomic helper is initiated ' + 'before any object is saved. ' + 'See list of objects: %r', lazy_saved) + ModelMeta.session_start() + + def __exit__(self, *exc_info): + # if there was an exception - rollback immediatly, + # else catch any during save - and rollback in case of failure + try: + ModelMeta.session_end(result=not any(exc_info)) + except Exception: + ModelMeta.session_end(result=False) + + +@wrapt.decorator +def atomic(wrapped, instance, args, kwargs): + with Atomic(): + return wrapped(*args, **kwargs)