Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

fix(framework): add warning on lost observer weakref #1142

Merged
merged 5 commits into from
Mar 10, 2024

Conversation

PietroPasotti
Copy link
Contributor

@PietroPasotti PietroPasotti commented Mar 1, 2024

So this is a tricky one.

At times charmers are tempted to write:

        class MyObj(ops.Object):
            def __init__(self, charm, *args: typing.Any):
                super().__init__(charm, "obj")
                framework.observe(charm.on.start, self._on_start)

            def _on_start(self, _):
                raise RuntimeError()  # never reached!!! 


        class MyCharm(ops.CharmBase):
            def __init__(self, *args: typing.Any):
                super().__init__(*args)
                MyObj(self)  # not assigned!
                framework.observe(self.on.start, self._on_start)

            def _on_start(self, event: ops.EventBase):
                pass  # is reached

and expect that both _on_start methods will be triggered, while due to some subtle implementation details, MyObj._on_start never fires because the notice is silently dropped if the weakref stored in Framework._observer has been gc'd between charm instantiation time and Framework._emit time.

Long story short, I'm not sure why that codepath is so permissive and we don't error out altogether, that is, I don't see a legitimate reason why _observer is a weakref dict instead of a regular dict, as that predates me by a bunch. Perhaps @jameinel has some more context there?

For now, I'd propose adding a warning there so that people have something to go on with when that happens. We can also consider raising an exception, or making _observer a regular dict if those are viable alternatives.

@benhoyt
Copy link
Collaborator

benhoyt commented Mar 3, 2024

Hmm, I don't understand why this should be a weakref either. I would have expected an observe call would keep the object alive so it can call it.

@jameinel put up this PR back in Nov 2019 that changed this from a regular dict to a weakref dict. It explains that it was done, and that "it's safe", but not really why it was done:

This means that setting up an observe() won't keep your object alive
indefinitely. This is safe, because we don't ever iterate the _observer
slice, and we already handled the case where we have a registered
observer_path but no matching observer.

John, can you remember (or think of) why we would have made this a weakref dict?

@benhoyt
Copy link
Collaborator

benhoyt commented Mar 3, 2024

In terms of this PR, if there's a good reason for this to be a weakref, I don't mind adding the warning, but I want to get to the bottom of why we use weakref at all first.

@jameinel
Copy link
Member

jameinel commented Mar 3, 2024

So we intentionally didn't want just a ref from observe() to be the thing that holds objects resident in memory. Both because it is a bit weird for TestObject() to actually have a side effect, but also because of how the actual event structure works.
We are taking the "Object Path" to figure out what to fire (especially in the case of something like defer()).
As such, we want to have a proper tree path where objects are referenced by their parent objects.

@PietroPasotti
Copy link
Contributor Author

So we intentionally didn't want just a ref from observe() to be the thing that holds objects resident in memory. Both because it is a bit weird for TestObject() to actually have a side effect, but also because of how the actual event structure works. We are taking the "Object Path" to figure out what to fire (especially in the case of something like defer()). As such, we want to have a proper tree path where objects are referenced by their parent objects.

This sounds reasonable and makes sense to me. At the same time it feels like we shouldn't allow refs to simply disappear from _observer without as much as a warning. Unless registering children Objects without holding references to them is a legitimate use case for some other reason.

Copy link
Collaborator

@benhoyt benhoyt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor changes requested.

ops/framework.py Outdated Show resolved Hide resolved
@@ -128,6 +128,30 @@ def _on_start(self, event: ops.EventBase):
# check that the event has been seen by the observer
self.assertIsInstance(charm.seen, ops.StartEvent)

def test_observe_nested_object_noninstantiated(self):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def test_observe_nested_object_noninstantiated(self):
def test_observer_not_referenced_warning(self):

Also, should this test live in test_framework.py, as the relevant code lives in framework.py?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it, but it felt way too complicated to implement it without referring to the charm object. If you look at the test immediately above it, I feel it makes sense to keep it here as they're very similar.
If you really want that that's a significant rework of the test.

test/test_charm.py Outdated Show resolved Hide resolved
Co-authored-by: Ben Hoyt <benhoyt@gmail.com>
@PietroPasotti PietroPasotti requested a review from benhoyt March 8, 2024 09:09
@benhoyt benhoyt changed the title warning on lost observer weakref fix(framework): add warning on lost observer weakref Mar 10, 2024
Copy link
Collaborator

@benhoyt benhoyt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair -- thanks!

@benhoyt benhoyt merged commit bc0bf8f into canonical:main Mar 10, 2024
28 checks passed
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants