operator icon indicating copy to clipboard operation
operator copied to clipboard

Race condition: RuntimeError: unable to define an event with event_kind that overlaps with an existing type

Open exceptorr opened this issue 5 years ago • 7 comments

I'm writing a test for my charm, and found a race condition while initializing the Harness - the SAME code and the SAME tests are providing the different results sometimes.

GLOB sdist-make: /home/ubuntu/charm-k8s-prometheus/setup.py
py37 inst-nodeps: /home/ubuntu/charm-k8s-prometheus/.tox/dist/charm-k8s-prometheus-0.1.0.zip
py37 installed: attrs==19.3.0,charm-k8s-prometheus @ file:///home/ubuntu/charm-k8s-prometheus/.tox/dist/charm-k8s-prometheus-0.1.0.zip,coverage==5.1,flake8==3.8.2,importlib-metadata==1.6.0,mccabe==0.6.1,more-itertools==8.3.0,packaging==20.4,pkg-resources==0.0.0,pluggy==0.13.1,py==1.8.1,pyaml==20.4.0,pycodestyle==2.6.0,pyflakes==2.2.0,pyparsing==2.4.7,pytest==5.4.2,pytest-cov==2.9.0,pytest-randomly==3.4.0,PyYAML==5.3.1,six==1.15.0,wcwidth==0.1.9,zipp==3.1.0
py37 runtests: PYTHONHASHSEED='1871937448'
py37 runtests: commands[0] | flake8 --config=.flake8
py37 runtests: commands[1] | pytest -c pytest.ini
================================================================== test session starts ===================================================================
platform linux -- Python 3.7.7, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
Using --randomly-seed=1590779897
rootdir: /home/ubuntu/charm-k8s-prometheus, inifile: pytest.ini, testpaths: test
plugins: cov-2.9.0, randomly-3.4.0
collected 27 items

test/charm_test.py ...                                                                                                                             [ 11%]
test/adapters/framework_test.py ........                                                                                                           [ 40%]
test/domain_test.py .........                                                                                                                      [ 74%]
test/adapters/k8s_test.py .......                                                                                                                  [100%]

----------- coverage: platform linux, python 3.7.7-final-0 -----------
Name                        Stmts   Miss Branch BrPart  Cover
-------------------------------------------------------------
src/adapters/framework.py      60      6      6      0    91%
src/adapters/k8s.py            43      1     10      2    94%
src/charm.py                   68     10     10      3    83%
src/domain.py                  88      5     36      8    90%
src/exceptions.py               5      1      0      0    80%
src/interface_http.py          25      7      2      0    67%
-------------------------------------------------------------
TOTAL                         289     30     64     13    87%
Coverage HTML written to dir coverage-report


=================================================================== 27 passed in 1.17s ===================================================================
________________________________________________________________________ summary _________________________________________________________________________
  py37: commands succeeded
  congratulations :)
ubuntu@node10:~/charm-k8s-prometheus$ tox -epy37
GLOB sdist-make: /home/ubuntu/charm-k8s-prometheus/setup.py
py37 inst-nodeps: /home/ubuntu/charm-k8s-prometheus/.tox/dist/charm-k8s-prometheus-0.1.0.zip
py37 installed: attrs==19.3.0,charm-k8s-prometheus @ file:///home/ubuntu/charm-k8s-prometheus/.tox/dist/charm-k8s-prometheus-0.1.0.zip,coverage==5.1,flake8==3.8.2,importlib-metadata==1.6.0,mccabe==0.6.1,more-itertools==8.3.0,packaging==20.4,pkg-resources==0.0.0,pluggy==0.13.1,py==1.8.1,pyaml==20.4.0,pycodestyle==2.6.0,pyflakes==2.2.0,pyparsing==2.4.7,pytest==5.4.2,pytest-cov==2.9.0,pytest-randomly==3.4.0,PyYAML==5.3.1,six==1.15.0,wcwidth==0.1.9,zipp==3.1.0
py37 runtests: PYTHONHASHSEED='803018218'
py37 runtests: commands[0] | flake8 --config=.flake8
py37 runtests: commands[1] | pytest -c pytest.ini
================================================================== test session starts ===================================================================
platform linux -- Python 3.7.7, pytest-5.4.2, py-1.8.1, pluggy-0.13.1
Using --randomly-seed=1590779901
rootdir: /home/ubuntu/charm-k8s-prometheus, inifile: pytest.ini, testpaths: test
plugins: cov-2.9.0, randomly-3.4.0
collected 27 items

test/adapters/framework_test.py ........                                                                                                           [ 29%]
test/domain_test.py .........                                                                                                                      [ 62%]
test/adapters/k8s_test.py .......                                                                                                                  [ 88%]
test/charm_test.py ..F                                                                                                                             [100%]

======================================================================== FAILURES ========================================================================
_____________________________________________ OnConfigChangedHandlerTest.test__it_blocks_until_pod_is_ready ______________________________________________

self = <charm_test.OnConfigChangedHandlerTest testMethod=test__it_blocks_until_pod_is_ready>
mock_pod_spec = <function set_juju_pod_spec at 0x7fb334d35f80>, mock_juju_pod_spec = <function build_juju_pod_spec at 0x7fb334d4c710>
mock_time = <NonCallableMagicMock name='time' spec_set='module' id='140407662251216'>
mock_k8s_mod = <NonCallableMagicMock name='k8s' spec_set='module' id='140407662251984'>
mock_build_juju_unit_status_func = <function build_juju_unit_status at 0x7fb334d5e050>

    @patch('charm.build_juju_unit_status', spec_set=True, autospec=True)
    @patch('charm.k8s', spec_set=True, autospec=True)
    @patch('charm.time', spec_set=True, autospec=True)
    @patch('charm.build_juju_pod_spec', spec_set=True, autospec=True)
    @patch('charm.set_juju_pod_spec', spec_set=True, autospec=True)
    def test__it_blocks_until_pod_is_ready(
            self,
            mock_pod_spec,
            mock_juju_pod_spec,
            mock_time,
            mock_k8s_mod,
            mock_build_juju_unit_status_func):
        # Setup
        mock_fw_adapter_cls = \
            create_autospec(framework.FrameworkAdapter, spec_set=True)
        mock_fw_adapter = mock_fw_adapter_cls.return_value

        mock_juju_unit_states = [
            MaintenanceStatus(str(uuid4())),
            MaintenanceStatus(str(uuid4())),
            ActiveStatus(str(uuid4())),
        ]
        mock_build_juju_unit_status_func.side_effect = mock_juju_unit_states

        mock_event_cls = create_autospec(EventBase, spec_set=True)
        mock_event = mock_event_cls.return_value

        harness = Harness(charm.Charm)
>       harness.begin()

test/charm_test.py:93:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
lib/ops/testing.py:121: in begin
    self._charm = TestCharm(self._framework, self._framework.meta.name)
src/charm.py:39: in __init__
    super().__init__(*args)
lib/ops/charm.py:353: in __init__
    self.on.define_event(relation_name + '_relation_created', RelationCreatedEvent)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

cls = <class 'ops.testing.Harness.begin.<locals>.TestEvents'>, event_kind = 'http_api_relation_created'
event_type = <class 'ops.charm.RelationCreatedEvent'>

    @classmethod
    def define_event(cls, event_kind, event_type):
        """Define an event on this type at runtime.

        cls: a type to define an event on.

        event_kind: an attribute name that will be used to access the
                    event. Must be a valid python identifier, not be a keyword
                    or an existing attribute.

        event_type: a type of the event to define.

        """
        prefix = 'unable to define an event with event_kind that '
        if not event_kind.isidentifier():
            raise RuntimeError(prefix + 'is not a valid python identifier: ' + event_kind)
        elif keyword.iskeyword(event_kind):
            raise RuntimeError(prefix + 'is a python keyword: ' + event_kind)
        try:
            getattr(cls, event_kind)
            raise RuntimeError(
>               prefix + 'overlaps with an existing type {} attribute: {}'.format(cls, event_kind))
E               RuntimeError: unable to define an event with event_kind that overlaps with an existing type <class 'ops.testing.Harness.begin.<locals>.TestEvents'> attribute: http_api_relation_created

lib/ops/framework.py:322: RuntimeError

----------- coverage: platform linux, python 3.7.7-final-0 -----------
Name                        Stmts   Miss Branch BrPart  Cover
-------------------------------------------------------------
src/adapters/framework.py      60      6      6      0    91%
src/adapters/k8s.py            43      1     10      2    94%
src/charm.py                   68     28     10      2    56%
src/domain.py                  88      5     36      8    90%
src/exceptions.py               5      1      0      0    80%
src/interface_http.py          25      7      2      0    67%
-------------------------------------------------------------
TOTAL                         289     48     64     12    81%
Coverage HTML written to dir coverage-report

================================================================ short test summary info =================================================================
FAILED test/charm_test.py::OnConfigChangedHandlerTest::test__it_blocks_until_pod_is_ready - RuntimeError: unable to define an event with event_kind tha...
============================================================== 1 failed, 26 passed in 1.15s ==============================================================
ERROR: InvocationError: '/home/ubuntu/charm-k8s-prometheus/.tox/py37/bin/pytest -c pytest.ini'
________________________________________________________________________ summary _________________________________________________________________________
ERROR:   py37: commands failed
ubuntu@node10:~/charm-k8s-prometheus$

The framework version is the latest one available:

ubuntu@node10:~/charm-k8s-prometheus/mod/operator$ git rev-parse HEAD
81e5f36571bab0efe315254f60d5a5fcf2693c8e

exceptorr avatar May 29 '20 19:05 exceptorr

Reproduces also with

harness = Harness(charm.Charm)
harness.begin()
harness.charm._stored.set_default(is_started=False)
harness.charm.on.config_changed.emit()

in the test code.

exceptorr avatar May 29 '20 19:05 exceptorr

I'm not sure what 'charm.Charm' you're using here. Given the context, I was thinking it might be: https://github.com/exceptorr/charm-k8s-prometheus/blob/master/src/charm.py#L35

But that class doesn't have a _stored attribute. And doing it with my own code:

def test_with_stored_state(self):
    class Charm(CharmBase):
        _stored = StoredState()
    harness = Harness(Charm)
    harness.begin()
    harness.charm._stored.set_default(is_started=False)
    harness.charm.on.config_changed.emit()

Doesn't show any errors. I do have an idea that I'll explore.

jameinel avatar Jun 01 '20 06:06 jameinel

My guess didn't work. I was wondering if you somehow ended up registering a StoredState for Charm twice (once for the actual Charm, and once for the wrapper around Charm that Harness creates.)

Given the sheer quantity of mocks in what I can read of your original test, it isn't clear to me that there isn't something in there that is creating a Framework and then registering things in it or not (even as an accidental side effect).

Note that it is during 'begin()' where it is failing, which doesn't have to do with 'config_changed.emit()' nor with StoredState, but just with instantiating Charm.

The actual error is because Charm.init is because it is trying to register the 'http_api_relation_created' event for the same class twice. Which sounds like you're sharing a Framework, even though Harness intentionally is creating its own Framework to use. (And it is being backed by sqlite ':memory:' which means it can't be sharing the backend with any other instance of Framework.)

I'm happy to dig further if you can create a reproducer that is independent of your charm code, or possibly even just point me to your actual charm that is failing.

jameinel avatar Jun 01 '20 06:06 jameinel

@jameinel here's a reproducer: https://github.com/exceptorr/charm-k8s-prometheus/tree/prepare-for-config-dynamic-reload - just uncomment the test back https://github.com/exceptorr/charm-k8s-prometheus/commit/ef04be0b44154d09ed3989cd96a77f9334913230#diff-62dd0694ddd90672dc34aa34f68aa827R61 and that's it!

exceptorr avatar Jun 01 '20 06:06 exceptorr

I was thinking it might be: https://github.com/exceptorr/charm-k8s-prometheus/blob/master/src/charm.py#L35 But that class doesn't have a _stored attribute

Yeah because you were looking at the master branch, while this code is in another branch: https://github.com/exceptorr/charm-k8s-prometheus/commit/ef04be0b44154d09ed3989cd96a77f9334913230#diff-38f277fc95ad7520237f91e48ec77e8dR38

exceptorr avatar Jun 01 '20 06:06 exceptorr

So it is a case of some other part of the test suite leaving things in an unclean state. If I run just that test:

.tox/py36/bin/pytest -c pytest.ini
test/charm_test.py::OnConfigChangedHandlerTest

Then it fails with:

        harness = Harness(charm.Charm)
        harness.begin()
        harness.charm._stored.set_default(is_started=False)
>       harness.charm.on.config_changed.emit()

test/charm_test.py:101:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
lib/ops/framework.py:207: in emit
    framework._emit(event)
lib/ops/framework.py:714: in _emit
    self._reemit(event_path)
lib/ops/framework.py:757: in _reemit
    custom_handler(event)
src/charm.py:74: in on_config_changed
    on_config_changed_handler(event, self.fw_adapter, self._stored)
src/charm.py:96: in on_config_changed_handler
    juju_model = fw_adapter.get_model_name()
src/adapters/framework.py:88: in get_model_name
    return os.environ["JUJU_MODEL_NAME"]

(eg, it gets past harness.begin() but fails once it gets to your framework adapter that requires the env var JUJU_MODEL_NAME to be set, but you aren't setting it in the test.)

The issue is more that your other tests are instantiating Charm directly, into a Framework object. Which causes the 'on' attribute of your Charm class to be populated with attributes for relation_changed events (such as http_relation_changed). If you just had 2 tests that both instantiated Charm directly (with different Framework objects) you'd have exactly the same problem.

Harness already has code to work around it (rather than instantiating Charm directly it instantiates a child of Charm and charm's on class.). However, because your tests have already instantiated Charm directly somewhere else, it has already gotten its attributes populated.

With this patch:

@@ -56,58 +57,65 @@ class CharmTest(unittest.TestCase):
     def test__init__works_without_a_hitch(self,
                                           mock_framework_adapter_cls):
         # Exercise
-        charm.Charm(self.create_framework(), None)
+        class EventChild(CharmEvents):
+            pass
+        class Child(charm.Charm):
+            on = EventChild()
+        Child(self.create_framework(), None)

Running all tests fails in the same way (with JUJU_MODEL_NAME).

At Runtime, there is only ever 1 framework, so the instantiation code is ok as is. It is unfortunate that we have to do the workarounds for tests, which is what Harness already implements, but it is the other test that is leaving things in a state that the Harness can't work around.

We have discussed changing so that registered events are on the instance rather than the class, but it is non-trivial due to how bound objects, etc work in Python.

On Mon, Jun 1, 2020 at 10:57 AM Vladimir Grevtsev [email protected] wrote:

I was thinking it might be:

https://github.com/exceptorr/charm-k8s-prometheus/blob/master/src/charm.py#L35 But that class doesn't have a _stored attribute

Yeah because you were looking at the master branch, while this code is in another branch: exceptorr/charm-k8s-prometheus@ef04be0 #diff-38f277fc95ad7520237f91e48ec77e8dR38 https://github.com/exceptorr/charm-k8s-prometheus/commit/ef04be0b44154d09ed3989cd96a77f9334913230#diff-38f277fc95ad7520237f91e48ec77e8dR38

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/canonical/operator/issues/307#issuecomment-636650087, or unsubscribe https://github.com/notifications/unsubscribe-auth/AABRQ7J6SDXBKOXY25HPH53RUNGODANCNFSM4NOJI4XQ .

jameinel avatar Jun 01 '20 07:06 jameinel

The core issue here is that we need to make define_event work on the instance, not the class

chipaca avatar Jul 15 '20 12:07 chipaca

Arguably we should do the dynamism using an instance, but we're probably not going to change that at this point. Harness.begin "solves" this by dynamically inheriting from the charm class.

benhoyt avatar Apr 28 '23 11:04 benhoyt