Repository navigation
Recover from broken region connections: purge dead clients, release futures, raise the retry bound #19
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
Recover from broken region connections: purge dead clients, release futures, raise the retry bound #19
Changes from all commits
07822f5
d1bc2a1
5a2a3cf
8a48079
ff616e8
b8a71c8
accc5ca
c8f55ca
d5f9ba3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,15 +35,15 @@ | |
| ZookeeperException | ||
| ) | ||
| from .filters import _to_filter | ||
| from .helpers import region_cache_key | ||
| from .region.region import region_from_cell | ||
| from .request import request | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Retries are bounded because these requests used to retry by recursing: each | ||
| # failed attempt kept its frame, row values and built request alive, so a row | ||
| # that never succeeds exhausted memory long before Python's recursion limit. | ||
| MAX_REQUEST_ATTEMPTS = 10 | ||
| # Stops a permanently failing row spinning; not a memory limit, since the loop | ||
| # frees each attempt. Must exceed a region reconnect's attempts: 10 did not. | ||
| MAX_REQUEST_ATTEMPTS = 100 | ||
|
Copilot marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| class MainClient(object): | ||
|
|
@@ -167,27 +167,16 @@ def get(self, table, key, families={}, filters=None): | |
| """ | ||
| for attempt in range(MAX_REQUEST_ATTEMPTS): | ||
| try: | ||
| # Step 0. Set dest_region to None so if an exception is | ||
| # thrown in _find_hosting_region, the exception handling | ||
| # doesn't break trying to reference dest_region. | ||
| # Bound before the call so the except block can read it even when | ||
| # _find_hosting_region is what threw. | ||
| dest_region = None | ||
| # Step 1. Figure out where to send it. | ||
| dest_region = self._find_hosting_region(table, key) | ||
| # Step 2. Build the appropriate pb message. | ||
| rq = request.get_request(dest_region, key, families, filters) | ||
| # Step 3. Send the message and twiddle our thumbs. | ||
| response = dest_region.region_client._send_request(rq) | ||
| # Step 4. Success. | ||
| return Result(response) | ||
| except PyBaseException as e: | ||
| # Step X. Houston, we have an error. The cool thing about how | ||
| # this is coded is that exceptions know how to handle themselves. | ||
| # All we need to do is call _handle_exception and everything should | ||
| # be happy! If it cannot handle itself (unrecoverable) then it will | ||
| # re-raise the exception in the handle method and we'll die too. | ||
| # | ||
| # We pass dest_region in because the handling code needs to know | ||
| # which region or region_client it needs to reestablish. | ||
| # Reestablishes the region client so the next attempt can succeed, | ||
| # or re-raises here if the error is unrecoverable. | ||
| e._handle_exception(self, dest_region=dest_region) | ||
| if attempt == MAX_REQUEST_ATTEMPTS - 1: | ||
| raise | ||
|
|
@@ -258,8 +247,7 @@ def increment(self, table, key, values): | |
| return self._mutate(table, key, values, request.increment_request) | ||
|
|
||
| def _mutate(self, table, key, values, rq_type): | ||
| # Same exact methodology as 'get'. Because all mutate requests have | ||
| # equivalent code I've combined them into a single function. | ||
| # Shared by put/delete/append/increment; same retry shape as 'get'. | ||
| for attempt in range(MAX_REQUEST_ATTEMPTS): | ||
| try: | ||
| dest_region = None | ||
|
|
@@ -469,8 +457,8 @@ def _create_new_region(self, response, table): | |
| elif cell.qualifier == b"server": | ||
| # Grab the host, port of the Region Server that this region is | ||
| # hosted on. | ||
| server_loc = cell.value | ||
| host, port = cell.value.split(b':') | ||
| server_loc = region_cache_key(host, port) | ||
| else: | ||
| continue | ||
| # Do we have an existing client for this region server already? | ||
|
|
@@ -526,10 +514,15 @@ def _purge_client(self, region_client): | |
| # our cache, delete the reverse lookup entry and close the client | ||
| # clearing up any file descriptors. | ||
| with self._cache_lock: | ||
| key = region_cache_key(region_client.host, region_client.port) | ||
| if self.reverse_client_cache.get(key) is not region_client: | ||
| # A healthy client already took this address, so a late failure | ||
| # from the old one must retire it without evicting its successor. | ||
| region_client.close() | ||
| return | ||
| for reg in region_client.regions: | ||
| self._delete_from_region_cache(reg.table, reg.start_key) | ||
| self.reverse_client_cache.pop( | ||
| region_client.host + b":" + region_client.port, None) | ||
| self.reverse_client_cache.pop(key, None) | ||
|
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. Verified, and I am going to leave this one open rather than attempt it. Confirmed all three parts: the check-then-get at lines 465 and 467 is unlocked so eviction between them raises KeyError; eviction after selection caches a region backed by a retired client; and _cache_lock is a plain non-reentrant Lock that _add_to_region_cache already takes, so wrapping discovery in it would deadlock exactly as you warn. Stopping because the fix needs restructuring of lock usage across discovery and retirement, and I agree with your verdict that this wants human review of concurrent recovery behaviour. This is also the third consecutive round where a fix of mine exposed the next layer: working eviction surfaced the executor and bucket leaks, those surfaced the bucket claim race, and that surfaces this. The individual fixes are sound and tested, but the cascade is the signal. Leaving the PR open and flagged for a human reviewer who knows this fork, with the race documented in the description. The production incident that started this is already resolved and stable on the merged work, so nothing is waiting on it. |
||
| region_client.close() | ||
|
|
||
| def _purge_region(self, reg): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,16 @@ | ||
| from __future__ import absolute_import, print_function | ||
|
|
||
| from . import varint # noqa | ||
|
|
||
|
|
||
| def region_cache_key(host, port): | ||
| """Key for MainClient.reverse_client_cache, as a str. | ||
|
|
||
| Region servers arrive as bytes from protobuf but Client decodes them, so the | ||
| two spellings have to be normalised or a cached client is never found again. | ||
| """ | ||
| if isinstance(host, bytes): | ||
| host = host.decode('utf8') | ||
| if isinstance(port, bytes): | ||
| port = port.decode('utf8') | ||
| return host + ':' + port |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| from __future__ import absolute_import, print_function, unicode_literals | ||
|
|
||
| import threading | ||
| import unittest | ||
| from concurrent.futures import ThreadPoolExecutor | ||
|
|
||
| from pybase import exceptions | ||
| from pybase.client import MainClient | ||
| from pybase.exceptions import RegionServerException | ||
| from pybase.helpers import region_cache_key | ||
| from pybase.region.client import Client | ||
|
|
||
|
|
||
| def _region_client(host, port, with_pool=False): | ||
| client = Client.__new__(Client) | ||
| client.host = host | ||
| client.port = port | ||
| client.secondary = False | ||
| client.regions = [] | ||
| client.shutting_down = False | ||
| client.sock_pool = [] | ||
| client.thread_pool = ThreadPoolExecutor(max_workers=1) if with_pool else None | ||
| return client | ||
|
|
||
|
|
||
| def _main_with(cached): | ||
| main = MainClient.__new__(MainClient) | ||
| main.reverse_client_cache = dict(cached) | ||
| main.region_cache = {} | ||
| main._cache_lock = threading.Lock() | ||
| return main | ||
|
|
||
|
|
||
| class TestClientRetirement(unittest.TestCase): | ||
|
|
||
| def test_late_failure_does_not_evict_the_replacement(self): | ||
| """Normalised keys let a retired client's failure match its successor.""" | ||
| key = region_cache_key('rs1', '16020') | ||
| retired = _region_client('rs1', '16020') | ||
| replacement = _region_client('rs1', '16020') | ||
| main = _main_with({key: replacement}) | ||
|
|
||
| main._purge_client(retired) | ||
|
|
||
| self.assertIs(main.reverse_client_cache.get(key), replacement) | ||
|
|
||
| def test_purging_the_cached_client_still_evicts_it(self): | ||
| key = region_cache_key('rs1', '16020') | ||
| cached = _region_client('rs1', '16020') | ||
| main = _main_with({key: cached}) | ||
|
|
||
| main._purge_client(cached) | ||
|
|
||
| self.assertEqual(main.reverse_client_cache, {}) | ||
|
|
||
| def test_close_retires_the_executor(self): | ||
| """Closing sockets alone leaves the pool's worker threads running.""" | ||
| client = _region_client('rs1', '16020', with_pool=True) | ||
| pool = client.thread_pool | ||
|
|
||
| client.close() | ||
|
|
||
| self.assertTrue(pool._shutdown) | ||
|
|
||
| def test_repeated_retirement_does_not_grow_the_bucket_map(self): | ||
| """The bucket key holds the region client, so entries must be released.""" | ||
| exceptions._buckets.clear() | ||
| for _ in range(25): | ||
| client = _region_client('rs1', '16020') | ||
| error = RegionServerException(region_client=client) | ||
| exceptions._let_one_through(error, client) | ||
| exceptions._let_all_through(error, client) | ||
| self.assertEqual(len(exceptions._buckets), 0) | ||
|
|
||
| def test_bucket_round_trip_claims_and_releases_one_semaphore(self): | ||
| """Covers the claim/release round trip. The interleaving that motivated | ||
| making the claim atomic is not reproduced here; see the PR discussion.""" | ||
| exceptions._buckets.clear() | ||
| client = _region_client('rs1', '16020') | ||
| first = RegionServerException(region_client=client) | ||
| second = RegionServerException(region_client=client) | ||
|
|
||
| self.assertTrue(exceptions._let_one_through(first, client)) | ||
| # Owner finishes and retires the bucket. | ||
| exceptions._let_all_through(first, client) | ||
| # A later caller must claim a live bucket, not the retired semaphore. | ||
| self.assertTrue(exceptions._let_one_through(second, client)) | ||
| claimed = exceptions._buckets[(second.__class__.__name__, client)] | ||
| exceptions._let_all_through(second, client) | ||
| self.assertEqual(claimed._value, 1, 'released a different semaphore') | ||
|
|
||
| def test_submit_after_retirement_is_a_region_failure(self): | ||
| """RuntimeError from a retired pool would bypass the retry handlers.""" | ||
| client = _region_client('rs1', '16020', with_pool=True) | ||
| client.call_lock = threading.Lock() | ||
| client.call_id = 0 | ||
| client.thread_pool_timeout = 5 | ||
|
|
||
| class FakePb(object): | ||
| def SerializeToString(self): | ||
| return b'x' | ||
|
|
||
| class FakeRequest(object): | ||
| type = 'Get' | ||
| pb = FakePb() | ||
|
|
||
| client.close() | ||
|
|
||
| with self.assertRaises(RegionServerException): | ||
| client._send_request(FakeRequest()) | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| unittest.main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| from __future__ import absolute_import, print_function, unicode_literals | ||
|
|
||
| import unittest | ||
|
|
||
| from pybase.client import MainClient | ||
| from pybase.exceptions import RegionServerException | ||
| from pybase.helpers import region_cache_key | ||
| from pybase.region.client import Client | ||
|
|
||
|
|
||
| def _region_client(host, port): | ||
| """A Client as pybase builds it: __init__ decodes host and port to str.""" | ||
| client = Client.__new__(Client) | ||
| client.host = host.decode('utf8') if isinstance(host, bytes) else host | ||
| client.port = port.decode('utf8') if isinstance(port, bytes) else port | ||
| client.secondary = False | ||
| client.regions = [] | ||
| client.closed = False | ||
| client.close = lambda: setattr(client, 'closed', True) | ||
| return client | ||
|
|
||
|
|
||
| class TestRegionCacheKey(unittest.TestCase): | ||
| """Region servers arrive as bytes but Client stores them decoded.""" | ||
|
|
||
| def test_bytes_and_str_spellings_agree(self): | ||
| self.assertEqual(region_cache_key(b'rs1', b'16020'), | ||
| region_cache_key('rs1', '16020')) | ||
|
|
||
| def test_handler_reaches_purge_for_a_cached_client(self): | ||
| """Covers the handler calling purge at all, not the key spelling.""" | ||
| client = _region_client(b'rs1.example', b'16020') | ||
| main = MainClient.__new__(MainClient) | ||
| main.reverse_client_cache = {region_cache_key(b'rs1.example', b'16020'): client} | ||
|
|
||
| purged = [] | ||
| main._purge_client = lambda c: purged.append(c) | ||
|
|
||
| error = RegionServerException(region_client=client) | ||
| try: | ||
| error._handle_exception(main) | ||
| except RegionServerException: | ||
| pass | ||
|
|
||
| self.assertEqual(purged, [client], | ||
| 'cached client was not purged, so a retry reuses it') | ||
|
|
||
| def test_purge_removes_the_entry_the_cache_was_stored_under(self): | ||
| """Mixing a str host with a bytes separator raised, so nothing was purged.""" | ||
| client = _region_client(b'rs1.example', b'16020') | ||
| main = MainClient.__new__(MainClient) | ||
| main.reverse_client_cache = {region_cache_key(b'rs1.example', b'16020'): client} | ||
| main.region_cache = {} | ||
| main._cache_lock = __import__('threading').Lock() | ||
|
|
||
| main._purge_client(client) | ||
|
|
||
| self.assertEqual(main.reverse_client_cache, {}) | ||
| self.assertTrue(client.closed) | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| unittest.main() |
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.
Confirmed and fixed at the source in 5a2a3cf, rather than by lowering the bound.
Reproduced it first with a real ThreadPoolExecutor: 20 of 20 requests stayed reachable with gc disabled, freed only by an explicit gc.collect(). The cycle is Future -> _exception -> traceback -> _send_request frame -> Future, which the except block's implicit
del ecannot break because it is self-contained.Fix is in _send_request: drop the local in a finally so the frame stops referencing the Future. Same measurement then gives 0 of 20. The _async branch still returns the Future untouched.
You were right that this undermined the memory-neutral claim; with the cycle broken it holds on the real path.