Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Fix database relation state handling #75

Merged
merged 14 commits into from
May 26, 2023
Merged

Conversation

carlcsaposs-canonical
Copy link
Contributor

@carlcsaposs-canonical carlcsaposs-canonical commented May 23, 2023

Issue

Fixes #73, fixes #78

Database relations can be in many states:

Before this PR, the _Relation class could be in multiple states and some properties could be accessed but some properties would cause an exception if accessed depending on the state. In other words, which properties were available was implicit.

For example, these properties

@property
def host(self) -> str:
"""MySQL cluster primary host"""
return self._endpoint.split(":")[0]
@property
def port(self) -> str:
"""MySQL cluster primary port"""
return self._endpoint.split(":")[1]
@property
def username(self) -> str:
"""Admin username"""
return self._remote_databag["username"]
@property
def password(self) -> str:
"""Admin password"""
return self._remote_databag["password"]

can only be accessed if
def is_breaking(self, event):
"""Whether relation will be broken after the current event is handled"""
return isinstance(event, ops.RelationBrokenEvent) and event.relation.id == self._id

is False and these conditions are met
if not self._interface.is_resource_created():
return
relation = Relation(self._interface)
# TODO: Refactor `Relation` so that we don't need to access private class member
if relation._remote_databag.get("endpoints") is None:
return
return relation

Solution

Create different _Relation classes for different states so that all properties/methods on the class can be called
e.g.

class _RequestedUser(_Relation):

class _CreatedUser(_Relation):

Also raise exceptions during the __init__ of those objects so that we don't have to check for exceptional cases (e.g. required key missing from relation databag, relation is breaking) when using the object—if the object initializes successfully, all of its properties/methods are safe to use

@carlcsaposs-canonical
Copy link
Contributor Author

I believe there's a similar issue with database_provides—if a relation is created via database_provides but no database is requested, the relation will be included in _requested_users when it should not be:

def _requested_users(self, *, event) -> list[_Relation]:
"""Related application charms that have requested a database & user"""
requested_users = []
for relation in self._relations:
if isinstance(event, ops.RelationBrokenEvent) and event.relation.id == relation.id:
# Relation is being removed; delete user
continue
requested_users.append(relation)
return requested_users

@carlcsaposs-canonical
Copy link
Contributor Author

PR is partially completed—I believe database_requires is fixed but database_provides is still broken

(Requested review early in the hopes of speeding up final review later since this issue currently affects openstack charmers' testing)

src/workload.py Outdated Show resolved Hide resolved
@carlcsaposs-canonical carlcsaposs-canonical changed the title Fix scale down Fix database relation state handling May 25, 2023
@carlcsaposs-canonical carlcsaposs-canonical marked this pull request as ready for review May 25, 2023 18:26
"""Database & user has not been provided to related application charm"""


class _CreatedUser(_Relation):
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels a little counterintuitive: a _CreatedUser is actually a subclass of a _Relation

same thing with _RequestedUser

Copy link
Contributor Author

Choose a reason for hiding this comment

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

what about it feels counterintuitive—the name, that it's a subclass of _Relation, or something else?
Also here's the docstring for context
"""Related application charm that has been provided with a database & user"""

Copy link
Contributor

Choose a reason for hiding this comment

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

the name is definitely a big part of it. when i saw it in reconcile_users, i was surprised that it was being compared to a Relation. i expected it to be a class that encapsulates Users, but it's not quite storing any user info (except a method delete_user)

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 agree that the name is confusing

do you have ideas for better names? I struggled to come up with names for them

would something like _RelationThatRequestedUser and _RelationWithCreatedUser be better?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

renamed in c7d24df—if anyone thinks of better names happy to rename again

@@ -180,3 +183,28 @@ def reconcile_users(
if relation not in requested_users:
relation.delete_user(shell=shell)
Copy link
Contributor

Choose a reason for hiding this comment

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

it may be a good idea to log out users created and users deleted to the debug-log

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They're already logged in lower-level methods (e.g. shell.create_application_database_and_user and self._set_databag)

# At least one relation is active—do not report about inactive relations
return
for exception in exceptions:
if isinstance(exception, remote_databag.IncompleteDatabag):
Copy link
Contributor

Choose a reason for hiding this comment

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

it might be a good idea to add a suffix Exception so that we know that remote_databag.IncompleteDatabagException is an exception (without having to trace back to the definition of the class to see that it's a subclass of an exception)

same applies to all subclasses of StatusException

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That seems a bit verbose to me. I think that's something that should be handled by the IDE instead of the naming (e.g. hovering over IncompleteDatabag shows it's a StatusException). Somewhat analogous, we don't generally name something like requested_users as requested_users_list

Also, I think it's fairly clear from the context—exceptions has type annotation list[status_exception.StatusException], the variable is called exception, and the only other usage of these classes (besides isinstance(exception, )) is in an except statement

interface: data_interfaces.DatabaseRequires | data_interfaces.DatabaseProvides,
relation: ops.Relation,
) -> None:
super().__init__(interface.fetch_relation_data()[relation.id])
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

Copy link
Contributor

@taurus-forever taurus-forever left a comment

Choose a reason for hiding this comment

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

Nice!

@carlcsaposs-canonical carlcsaposs-canonical deleted the fix-scale-down branch May 26, 2023 13:58
Copy link
Collaborator

@paulomach paulomach left a comment

Choose a reason for hiding this comment

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

lgtm, some generators adoption!

self._local_databag = relation.data[interface.local_app]
for key in ("database", "username", "password", "endpoints"):
if key not in self._local_databag:
raise _UserNotCreated
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would be useful to extent _UserNotCreated to include the missing key in the exception message, e.g.:

Suggested change
raise _UserNotCreated
raise _UserNotCreated(f"missing {key}")

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 chose not to include it since it's not being used (_UserNotCreated is always caught & it's not parsing the message) and since it would be incomplete—the exception would only include the first missing key

@@ -201,35 +198,31 @@ def _patch_service(self, *, name: str, ro_port: int, rw_port: int) -> None:

def reconcile_database_relations(self, event=None) -> None:
"""Handle database requires/provides events."""
workload_ = self.get_workload(event=event)
Copy link
Collaborator

Choose a reason for hiding this comment

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

why use of post _ ? afaik, that's commonly usud to avoid clashes with python keywords

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Conflict with workload.py (import workload)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
5 participants