diff --git a/placement/db_api.py b/placement/db_api.py index 6ab5d24b2..d937fcaa2 100644 --- a/placement/db_api.py +++ b/placement/db_api.py @@ -16,6 +16,7 @@ own file so the nova db_api (which has cascading imports) is not imported. from oslo_db.sqlalchemy import enginefacade from oslo_log import log as logging +from placement.util import run_once LOG = logging.getLogger(__name__) placement_context_manager = enginefacade.transaction_context() @@ -25,6 +26,8 @@ def _get_db_conf(conf_group): return dict(conf_group.items()) +@run_once("TransactionFactory already started, not reconfiguring.", + LOG.warning) def configure(conf): placement_context_manager.configure( **_get_db_conf(conf.placement_database)) diff --git a/placement/tests/unit/test_db_api.py b/placement/tests/unit/test_db_api.py new file mode 100644 index 000000000..fe9bbff38 --- /dev/null +++ b/placement/tests/unit/test_db_api.py @@ -0,0 +1,52 @@ +# 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 mock +import testtools + +from oslo_config import cfg +from oslo_config import fixture as config_fixture + +from placement import db_api + + +CONF = cfg.CONF + + +class DbApiTests(testtools.TestCase): + + def setUp(self): + super(DbApiTests, self).setUp() + self.conf_fixture = self.useFixture(config_fixture.Config(CONF)) + db_api.configure.reset() + + @mock.patch.object(db_api.placement_context_manager, "configure") + def test_can_call_configure_twice(self, configure_mock): + """This test asserts that configure can be safely called twice + which may happen if placement is run under mod_wsgi and the + wsgi application is reloaded. + """ + db_api.configure(self.conf_fixture.conf) + configure_mock.assert_called_once() + + # a second invocation of configure on a transaction context + # should raise an exception so mock this and assert its not + # called on a second invocation of db_api's configure function + configure_mock.side_effect = TypeError() + + db_api.configure(self.conf_fixture.conf) + # Note we have not reset the mock so it should + # have been called once from the first invocation of + # db_api.configure and the second invocation should not + # have called it again + configure_mock.assert_called_once() diff --git a/placement/tests/unit/test_util.py b/placement/tests/unit/test_util.py index 00b667fbe..9c6618fd4 100644 --- a/placement/tests/unit/test_util.py +++ b/placement/tests/unit/test_util.py @@ -898,3 +898,101 @@ class TestPickLastModified(testtools.TestCase): None, self.resource_provider) self.assertEqual(now, chosen_time) mock_utc.assert_called_once_with(with_timezone=True) + + +class RunOnceTests(testtools.TestCase): + + fake_logger = mock.MagicMock() + + @util.run_once("already ran once", fake_logger) + def dummy_test_func(self, fail=False): + if fail: + raise ValueError() + return True + + def setUp(self): + super(RunOnceTests, self).setUp() + self.dummy_test_func.reset() + RunOnceTests.fake_logger.reset_mock() + + def test_wrapped_funtions_called_once(self): + self.assertFalse(self.dummy_test_func.called) + result = self.dummy_test_func() + self.assertTrue(result) + self.assertTrue(self.dummy_test_func.called) + + # assert that on second invocation no result + # is returned and that the logger is invoked. + result = self.dummy_test_func() + RunOnceTests.fake_logger.assert_called_once() + self.assertIsNone(result) + + def test_wrapped_funtions_called_once_raises(self): + self.assertFalse(self.dummy_test_func.called) + self.assertRaises(ValueError, self.dummy_test_func, fail=True) + self.assertTrue(self.dummy_test_func.called) + + # assert that on second invocation no result + # is returned and that the logger is invoked. + result = self.dummy_test_func() + RunOnceTests.fake_logger.assert_called_once() + self.assertIsNone(result) + + def test_wrapped_funtions_can_be_reset(self): + # assert we start with a clean state + self.assertFalse(self.dummy_test_func.called) + result = self.dummy_test_func() + self.assertTrue(result) + + self.dummy_test_func.reset() + # assert we restored a clean state + self.assertFalse(self.dummy_test_func.called) + result = self.dummy_test_func() + self.assertTrue(result) + + # assert that we never called the logger + RunOnceTests.fake_logger.assert_not_called() + + def test_reset_calls_cleanup(self): + mock_clean = mock.Mock() + + @util.run_once("already ran once", self.fake_logger, + cleanup=mock_clean) + def f(): + pass + + f() + self.assertTrue(f.called) + + f.reset() + self.assertFalse(f.called) + mock_clean.assert_called_once_with() + + def test_clean_is_not_called_at_reset_if_wrapped_not_called(self): + mock_clean = mock.Mock() + + @util.run_once("already ran once", self.fake_logger, + cleanup=mock_clean) + def f(): + pass + + self.assertFalse(f.called) + + f.reset() + self.assertFalse(f.called) + self.assertFalse(mock_clean.called) + + def test_reset_works_even_if_cleanup_raises(self): + mock_clean = mock.Mock(side_effect=ValueError()) + + @util.run_once("already ran once", self.fake_logger, + cleanup=mock_clean) + def f(): + pass + + f() + self.assertTrue(f.called) + + self.assertRaises(ValueError, f.reset) + self.assertFalse(f.called) + mock_clean.assert_called_once_with() diff --git a/placement/util.py b/placement/util.py index 06735e619..c9b26b693 100644 --- a/placement/util.py +++ b/placement/util.py @@ -411,3 +411,46 @@ def tempdir(**kwargs): shutil.rmtree(tmpdir) except OSError as e: LOG.error('Could not remove tmpdir: %s', e) + + +def run_once(message, logger, cleanup=None): + """This is a utility function decorator to ensure a function + is run once and only once in an interpreter instance. + The decorated function object can be reset by calling its + reset function. All exceptions raised by the wrapped function, + logger and cleanup function will be propagated to the caller. + """ + def outer_wrapper(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + if not wrapper.called: + # Note(sean-k-mooney): the called state is always + # updated even if the wrapped function completes + # by raising an exception. If the caller catches + # the exception it is their responsibility to call + # reset if they want to re-execute the wrapped function. + try: + return func(*args, **kwargs) + finally: + wrapper.called = True + else: + logger(message) + + wrapper.called = False + + def reset(wrapper, *args, **kwargs): + # Note(sean-k-mooney): we conditionally call the + # cleanup function if one is provided only when the + # wrapped function has been called previously. We catch + # and reraise any exception that may be raised and update + # the called state in a finally block to ensure its + # always updated if reset is called. + try: + if cleanup and wrapper.called: + return cleanup(*args, **kwargs) + finally: + wrapper.called = False + + wrapper.reset = functools.partial(reset, wrapper) + return wrapper + return outer_wrapper