-
Notifications
You must be signed in to change notification settings - Fork 27
[DPE-6874] Poll all members in the cluster topology script #810
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
Changes from all commits
b6c5c1c
8a78fdd
1b32fe7
d642a3b
6c2b749
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dual branch config, to reuse the PR. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ on: | |
| - edited | ||
| branches: | ||
| - main | ||
| - '*/edge' | ||
|
|
||
| jobs: | ||
| check-pr: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import sys | ||
| from ssl import CERT_NONE, create_default_context | ||
| from time import sleep | ||
| from urllib.parse import urljoin | ||
| from urllib.request import urlopen | ||
|
|
||
| API_REQUEST_TIMEOUT = 5 | ||
|
|
@@ -17,6 +18,10 @@ | |
| LOG_FILE_PATH = "/var/log/cluster_topology_observer.log" | ||
|
|
||
|
|
||
| class UnreachableUnitsError(Exception): | ||
| """Cannot reach any known cluster member.""" | ||
|
|
||
|
|
||
| def dispatch(run_cmd, unit, charm_dir): | ||
| """Use the input juju-run command to dispatch a :class:`ClusterTopologyChangeEvent`.""" | ||
| dispatch_sub_cmd = "JUJU_DISPATCH_PATH=hooks/cluster_topology_change {}/dispatch" | ||
|
|
@@ -29,25 +34,43 @@ def main(): | |
|
|
||
| Watch the Patroni API cluster info. When changes are detected, dispatch the change event. | ||
| """ | ||
| patroni_url, run_cmd, unit, charm_dir = sys.argv[1:] | ||
| patroni_urls, run_cmd, unit, charm_dir = sys.argv[1:] | ||
|
|
||
| previous_cluster_topology = {} | ||
| urls = [urljoin(url, PATRONI_CLUSTER_STATUS_ENDPOINT) for url in patroni_urls.split(",")] | ||
| member_name = unit.replace("/", "-") | ||
| while True: | ||
| # Disable TLS chain verification | ||
| context = create_default_context() | ||
| context.check_hostname = False | ||
| context.verify_mode = CERT_NONE | ||
|
Comment on lines
43
to
46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you please create backlog to revise this. IMHO, we should not skip TLS:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd guess we'll also have the case where the cert is signed for a domain or vIP. which will mismatch when calling the individual unit's REST APIs.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| # Scheme is generated by the charm | ||
| resp = urlopen( # noqa: S310 | ||
| f"{patroni_url}/{PATRONI_CLUSTER_STATUS_ENDPOINT}", | ||
| timeout=API_REQUEST_TIMEOUT, | ||
| context=context, | ||
| ) | ||
| cluster_status = json.loads(resp.read()) | ||
| current_cluster_topology = { | ||
| member["name"]: member["role"] for member in cluster_status["members"] | ||
| } | ||
| cluster_status = None | ||
| for url in urls: | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Going forward, we can most probably start and asyncio event loop and call all the units together, instead of going one by one. We should be able to do the same inside the charm as well, where we poll various endpoints.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree, on a cluster with 9 nodes it might take a lot of time to switch if half of the cluster gone (5 sec API_REQUEST_TIMEOUT for each unit).
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I also think that it might be a good improvement. |
||
| try: | ||
| # Scheme is generated by the charm | ||
| resp = urlopen( # noqa: S310 | ||
| url, | ||
| timeout=API_REQUEST_TIMEOUT, | ||
| context=context, | ||
| ) | ||
| cluster_status = json.loads(resp.read()) | ||
| break | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we got a response, don't call the other known members. |
||
| except Exception as e: | ||
| print(f"Failed to contact {url} with {e}") | ||
| continue | ||
| if not cluster_status: | ||
| raise UnreachableUnitsError("Unable to reach cluster members") | ||
| current_cluster_topology = {} | ||
| urls = [] | ||
| for member in cluster_status["members"]: | ||
| current_cluster_topology[member["name"]] = member["role"] | ||
| member_url = urljoin(member["api_url"], PATRONI_CLUSTER_STATUS_ENDPOINT) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| # Call the current unit first | ||
| if member["name"] == member_name: | ||
| urls.insert(0, member_url) | ||
|
Comment on lines
+70
to
+71
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the current unit is a cluster member, add it first, so that it's called first on the next loop. |
||
| else: | ||
| urls.append(member_url) | ||
|
|
||
| # If it's the first time the cluster topology was retrieved, then store it and use | ||
| # it for subsequent checks. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -385,30 +385,22 @@ def primary_endpoint(self) -> str | None: | |
| logger.debug("primary endpoint early exit: Peer relation not joined yet.") | ||
| return None | ||
| try: | ||
| for attempt in Retrying(stop=stop_after_delay(5), wait=wait_fixed(3)): | ||
| with attempt: | ||
|
Comment on lines
-388
to
-389
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Get primary endpoint already retries. |
||
| primary = self._patroni.get_primary() | ||
| if primary is None and (standby_leader := self._patroni.get_standby_leader()): | ||
| primary = standby_leader | ||
| primary_endpoint = self._patroni.get_member_ip(primary) | ||
| # Force a retry if there is no primary or the member that was | ||
| # returned is not in the list of the current cluster members | ||
| # (like when the cluster was not updated yet after a failed switchover). | ||
| if not primary_endpoint or primary_endpoint not in self._units_ips: | ||
| # TODO figure out why peer data is not available | ||
| if ( | ||
| primary_endpoint | ||
| and len(self._units_ips) == 1 | ||
| and len(self._peers.units) > 1 | ||
| ): | ||
| logger.warning( | ||
| "Possibly incoplete peer data: Will not map primary IP to unit IP" | ||
| ) | ||
| return primary_endpoint | ||
| logger.debug( | ||
| "primary endpoint early exit: Primary IP not in cached peer list." | ||
| ) | ||
| primary_endpoint = None | ||
| primary = self._patroni.get_primary() | ||
| if primary is None and (standby_leader := self._patroni.get_standby_leader()): | ||
| primary = standby_leader | ||
| primary_endpoint = self._patroni.get_member_ip(primary) | ||
| # Force a retry if there is no primary or the member that was | ||
| # returned is not in the list of the current cluster members | ||
| # (like when the cluster was not updated yet after a failed switchover). | ||
| if not primary_endpoint or primary_endpoint not in self._units_ips: | ||
| # TODO figure out why peer data is not available | ||
| if primary_endpoint and len(self._units_ips) == 1 and len(self._peers.units) > 1: | ||
| logger.warning( | ||
| "Possibly incoplete peer data: Will not map primary IP to unit IP" | ||
| ) | ||
| return primary_endpoint | ||
| logger.debug("primary endpoint early exit: Primary IP not in cached peer list.") | ||
| primary_endpoint = None | ||
| except RetryError: | ||
| return None | ||
| else: | ||
|
|
@@ -952,6 +944,8 @@ def _units_ips(self) -> set[str]: | |
| # Get all members IPs and remove the current unit IP from the list. | ||
| addresses = {self._get_unit_ip(unit) for unit in self._peers.units} | ||
| addresses.add(self._unit_ip) | ||
| if None in addresses: | ||
| addresses.remove(None) | ||
| return addresses | ||
|
|
||
| @property | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -240,13 +240,17 @@ async def is_cluster_updated( | |
|
|
||
| # Verify that no writes to the database were missed after stopping the writes. | ||
| logger.info("checking that no writes to the database were missed after stopping the writes") | ||
| total_expected_writes = await check_writes(ops_test, use_ip_from_inside) | ||
| for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_fixed(5), reraise=True): | ||
| with attempt: | ||
| total_expected_writes = await check_writes(ops_test, use_ip_from_inside) | ||
|
Comment on lines
+243
to
+245
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fails frequently on CI. Retry will give the other units some more time to sync up after stopping continuous writes. |
||
|
|
||
| # Verify that old primary is up-to-date. | ||
| logger.info("checking that the former primary is up to date with the cluster after restarting") | ||
| assert await is_secondary_up_to_date( | ||
| ops_test, primary_name, total_expected_writes, use_ip_from_inside | ||
| ), "secondary not up to date with the cluster after restarting." | ||
| for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_fixed(5), reraise=True): | ||
| with attempt: | ||
| assert await is_secondary_up_to_date( | ||
| ops_test, primary_name, total_expected_writes, use_ip_from_inside | ||
| ), "secondary not up to date with the cluster after restarting." | ||
|
|
||
|
|
||
| async def check_writes( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dual branch config, to reuse the PR.