Skip to content

Commit e3085fa

Browse files
4383melwitt
authored andcommitted
Initialize global data separately and run_once in WSGI app init
NOTE(melwitt): This is a combination of two changes to avoid intermittent test failure that was introduced by the original bug fix, and was fixed by change I2bd360dcc6501feea7baf02d4510b282205fc061. We have discovered that if an exception is raised at any point during the running of the init_application WSGI script in an apache/mod_wsgi Daemon Mode environment, it will prompt apache/mod_wsgi to re-run the script without starting a fresh python process. Because we initialize global data structures during app init, subsequent runs of the script blow up as some global data do *not* support re-initialization. It is anyway not safe to assume that init of global data is safe to run multiple times. This mod_wsgi behavior appears to be a special situation that does not behave the same as a normal reload in Daemon Mode as the script file is being reloaded upon failure instead of the daemon process being shutdown and restarted as described in the documentation [1]. In order to handle this situation, we can move the initialization of global data structures to a helper method that is decorated to run only once per python interpreter instance. This way, we will not attempt to re-initialize global data that are not safe to init more than once. Co-Authored-By: Michele Baldessari <[email protected]> Co-Authored-By: melanie witt <[email protected]> Conflicts: nova/api/openstack/wsgi_app.py NOTE(melwitt): The conflict is because change If4783adda92da33d512d7c2834f0bb2e2a9b9654 (Support sys.argv in wsgi app) is not in Victoria. Closes-Bug: #1882094 [1] https://modwsgi.readthedocs.io/en/develop/user-guides/reloading-source-code.html#reloading-in-daemon-mode Reset global wsgi app state in unit test Since I2bd360dcc6501feea7baf02d4510b282205fc061 there is a global state set during the wsgi_app init making our unit test cases non-deterministic based on the order of them. This patch makes sure that the global state is reset for each test case. Closes-Bug: #1921098 (cherry picked from commit bc2c19b) Change-Id: I2bd360dcc6501feea7baf02d4510b282205fc061 (cherry picked from commit 7c9edc0)
1 parent 2af08fb commit e3085fa

File tree

5 files changed

+256
-2
lines changed

5 files changed

+256
-2
lines changed

nova/api/openstack/wsgi_app.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929

3030
CONFIG_FILES = ['api-paste.ini', 'nova.conf']
3131

32+
LOG = logging.getLogger(__name__)
33+
3234
objects.register_all()
3335

3436

@@ -77,8 +79,12 @@ def application(environ, start_response):
7779
return application
7880

7981

80-
def init_application(name):
81-
conf_files = _get_config_files()
82+
@utils.run_once('Global data already initialized, not re-initializing.',
83+
LOG.info)
84+
def init_global_data(conf_files):
85+
# NOTE(melwitt): parse_args initializes logging and calls global rpc.init()
86+
# and db_api.configure(). The db_api.configure() call does not initiate any
87+
# connection to the database.
8288
config.parse_args([], default_config_files=conf_files)
8389

8490
logging.setup(CONF, "nova")
@@ -93,11 +99,25 @@ def init_application(name):
9399
logging.getLogger(__name__),
94100
logging.DEBUG)
95101

102+
103+
def init_application(name):
104+
conf_files = _get_config_files()
105+
106+
# NOTE(melwitt): The init_application method can be called multiple times
107+
# within a single python interpreter instance if any exception is raised
108+
# during it (example: DBConnectionError while setting up the service) and
109+
# apache/mod_wsgi reloads the init_application script. So, we initialize
110+
# global data separately and decorate the method to run only once in a
111+
# python interpreter instance.
112+
init_global_data(conf_files)
113+
96114
try:
97115
_setup_service(CONF.host, name)
98116
except exception.ServiceTooOld as exc:
99117
return error_application(exc, name)
100118

119+
# This global init is safe because if we got here, we already successfully
120+
# set up the service and setting up the profile cannot fail.
101121
service.setup_profiler(name, CONF.host)
102122

103123
conf = conf_files[0]

nova/test.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from sqlalchemy.dialects import sqlite
5555
import testtools
5656

57+
from nova.api.openstack import wsgi_app
5758
from nova.compute import rpcapi as compute_rpcapi
5859
from nova import context
5960
from nova.db.sqlalchemy import api as sqlalchemy_api
@@ -285,6 +286,10 @@ def setUp(self):
285286

286287
self.useFixture(nova_fixtures.GenericPoisonFixture())
287288

289+
# make sure that the wsgi app is fully initialized for all testcase
290+
# instead of only once initialized for test worker
291+
wsgi_app.init_global_data.reset()
292+
288293
def _setup_cells(self):
289294
"""Setup a normal cellsv2 environment.
290295
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
2+
# not use this file except in compliance with the License. You may obtain
3+
# a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
# License for the specific language governing permissions and limitations
11+
# under the License.
12+
13+
import tempfile
14+
15+
import fixtures
16+
import mock
17+
from oslo_config import fixture as config_fixture
18+
from oslotest import base
19+
20+
from nova.api.openstack import wsgi_app
21+
from nova import test
22+
from nova.tests import fixtures as nova_fixtures
23+
24+
25+
class WSGIAppTest(base.BaseTestCase):
26+
27+
_paste_config = """
28+
[app:nova-api]
29+
use = egg:Paste#static
30+
document_root = /tmp
31+
"""
32+
33+
def setUp(self):
34+
# Ensure BaseTestCase's ConfigureLogging fixture is disabled since
35+
# we're using our own (StandardLogging).
36+
with fixtures.EnvironmentVariable('OS_LOG_CAPTURE', '0'):
37+
super(WSGIAppTest, self).setUp()
38+
self.stdlog = self.useFixture(nova_fixtures.StandardLogging())
39+
self.conf = tempfile.NamedTemporaryFile(mode='w+t')
40+
self.conf.write(self._paste_config.lstrip())
41+
self.conf.seek(0)
42+
self.conf.flush()
43+
self.addCleanup(self.conf.close)
44+
# Use of this fixture takes care of cleaning up config settings for
45+
# subsequent tests.
46+
self.useFixture(config_fixture.Config())
47+
48+
@mock.patch('sys.argv', return_value=mock.sentinel.argv)
49+
@mock.patch('nova.db.sqlalchemy.api.configure')
50+
@mock.patch('nova.api.openstack.wsgi_app._setup_service')
51+
@mock.patch('nova.api.openstack.wsgi_app._get_config_files')
52+
def test_init_application_called_twice(self, mock_get_files, mock_setup,
53+
mock_db_configure, mock_argv):
54+
"""Test that init_application can tolerate being called twice in a
55+
single python interpreter instance.
56+
57+
When nova-api is run via mod_wsgi, if any exception is raised during
58+
init_application, mod_wsgi will re-run the WSGI script without
59+
restarting the daemon process even when configured for Daemon Mode.
60+
61+
We access the database as part of init_application, so if nova-api
62+
starts up before the database is up, we'll get, for example, a
63+
DBConnectionError raised during init_application and our WSGI script
64+
will get reloaded/re-run by mod_wsgi.
65+
"""
66+
mock_get_files.return_value = [self.conf.name]
67+
mock_setup.side_effect = [test.TestingException, None]
68+
# We need to mock the global database configure() method, else we will
69+
# be affected by global database state altered by other tests that ran
70+
# before this test, causing this test to fail with
71+
# oslo_db.sqlalchemy.enginefacade.AlreadyStartedError. We can instead
72+
# mock the method to raise an exception if it's called a second time in
73+
# this test to simulate the fact that the database does not tolerate
74+
# re-init [after a database query has been made].
75+
mock_db_configure.side_effect = [None, test.TestingException]
76+
# Run init_application the first time, simulating an exception being
77+
# raised during it.
78+
self.assertRaises(test.TestingException, wsgi_app.init_application,
79+
'nova-api')
80+
# Now run init_application a second time, it should succeed since no
81+
# exception is being raised (the init of global data should not be
82+
# re-attempted).
83+
wsgi_app.init_application('nova-api')
84+
self.assertIn('Global data already initialized, not re-initializing.',
85+
self.stdlog.logger.output)

nova/tests/unit/test_utils.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,3 +1294,101 @@ def test_api_db_is_configured_but_the_service_cannot_access_db(
12941294
'not allowed to directly access the database. You should run this '
12951295
'service without the [api_database]/connection config option. The '
12961296
'service version check will only query the local cell.')
1297+
1298+
1299+
class RunOnceTests(test.NoDBTestCase):
1300+
1301+
fake_logger = mock.MagicMock()
1302+
1303+
@utils.run_once("already ran once", fake_logger)
1304+
def dummy_test_func(self, fail=False):
1305+
if fail:
1306+
raise ValueError()
1307+
return True
1308+
1309+
def setUp(self):
1310+
super(RunOnceTests, self).setUp()
1311+
self.dummy_test_func.reset()
1312+
RunOnceTests.fake_logger.reset_mock()
1313+
1314+
def test_wrapped_funtions_called_once(self):
1315+
self.assertFalse(self.dummy_test_func.called)
1316+
result = self.dummy_test_func()
1317+
self.assertTrue(result)
1318+
self.assertTrue(self.dummy_test_func.called)
1319+
1320+
# assert that on second invocation no result
1321+
# is returned and that the logger is invoked.
1322+
result = self.dummy_test_func()
1323+
RunOnceTests.fake_logger.assert_called_once()
1324+
self.assertIsNone(result)
1325+
1326+
def test_wrapped_funtions_called_once_raises(self):
1327+
self.assertFalse(self.dummy_test_func.called)
1328+
self.assertRaises(ValueError, self.dummy_test_func, fail=True)
1329+
self.assertTrue(self.dummy_test_func.called)
1330+
1331+
# assert that on second invocation no result
1332+
# is returned and that the logger is invoked.
1333+
result = self.dummy_test_func()
1334+
RunOnceTests.fake_logger.assert_called_once()
1335+
self.assertIsNone(result)
1336+
1337+
def test_wrapped_funtions_can_be_reset(self):
1338+
# assert we start with a clean state
1339+
self.assertFalse(self.dummy_test_func.called)
1340+
result = self.dummy_test_func()
1341+
self.assertTrue(result)
1342+
1343+
self.dummy_test_func.reset()
1344+
# assert we restored a clean state
1345+
self.assertFalse(self.dummy_test_func.called)
1346+
result = self.dummy_test_func()
1347+
self.assertTrue(result)
1348+
1349+
# assert that we never called the logger
1350+
RunOnceTests.fake_logger.assert_not_called()
1351+
1352+
def test_reset_calls_cleanup(self):
1353+
mock_clean = mock.Mock()
1354+
1355+
@utils.run_once("already ran once", self.fake_logger,
1356+
cleanup=mock_clean)
1357+
def f():
1358+
pass
1359+
1360+
f()
1361+
self.assertTrue(f.called)
1362+
1363+
f.reset()
1364+
self.assertFalse(f.called)
1365+
mock_clean.assert_called_once_with()
1366+
1367+
def test_clean_is_not_called_at_reset_if_wrapped_not_called(self):
1368+
mock_clean = mock.Mock()
1369+
1370+
@utils.run_once("already ran once", self.fake_logger,
1371+
cleanup=mock_clean)
1372+
def f():
1373+
pass
1374+
1375+
self.assertFalse(f.called)
1376+
1377+
f.reset()
1378+
self.assertFalse(f.called)
1379+
self.assertFalse(mock_clean.called)
1380+
1381+
def test_reset_works_even_if_cleanup_raises(self):
1382+
mock_clean = mock.Mock(side_effect=ValueError())
1383+
1384+
@utils.run_once("already ran once", self.fake_logger,
1385+
cleanup=mock_clean)
1386+
def f():
1387+
pass
1388+
1389+
f()
1390+
self.assertTrue(f.called)
1391+
1392+
self.assertRaises(ValueError, f.reset)
1393+
self.assertFalse(f.called)
1394+
mock_clean.assert_called_once_with()

nova/utils.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,3 +1103,49 @@ def raise_if_old_compute():
11031103
scope=scope,
11041104
min_service_level=current_service_version,
11051105
oldest_supported_service=oldest_supported_service_level)
1106+
1107+
1108+
def run_once(message, logger, cleanup=None):
1109+
"""This is a utility function decorator to ensure a function
1110+
is run once and only once in an interpreter instance.
1111+
1112+
Note: this is copied from the placement repo (placement/util.py)
1113+
1114+
The decorated function object can be reset by calling its
1115+
reset function. All exceptions raised by the wrapped function,
1116+
logger and cleanup function will be propagated to the caller.
1117+
"""
1118+
def outer_wrapper(func):
1119+
@functools.wraps(func)
1120+
def wrapper(*args, **kwargs):
1121+
if not wrapper.called:
1122+
# Note(sean-k-mooney): the called state is always
1123+
# updated even if the wrapped function completes
1124+
# by raising an exception. If the caller catches
1125+
# the exception it is their responsibility to call
1126+
# reset if they want to re-execute the wrapped function.
1127+
try:
1128+
return func(*args, **kwargs)
1129+
finally:
1130+
wrapper.called = True
1131+
else:
1132+
logger(message)
1133+
1134+
wrapper.called = False
1135+
1136+
def reset(wrapper, *args, **kwargs):
1137+
# Note(sean-k-mooney): we conditionally call the
1138+
# cleanup function if one is provided only when the
1139+
# wrapped function has been called previously. We catch
1140+
# and reraise any exception that may be raised and update
1141+
# the called state in a finally block to ensure its
1142+
# always updated if reset is called.
1143+
try:
1144+
if cleanup and wrapper.called:
1145+
return cleanup(*args, **kwargs)
1146+
finally:
1147+
wrapper.called = False
1148+
1149+
wrapper.reset = functools.partial(reset, wrapper)
1150+
return wrapper
1151+
return outer_wrapper

0 commit comments

Comments
 (0)