Skip to content

Commit ba71c85

Browse files
committed
fix(bigtable): stop the accelerator health monitor cleanly and clear degraded on exit
Change-Id: I99ce4c0ffb7b12a3f1746801ddcadc4294f61b17
1 parent 6b525cb commit ba71c85

7 files changed

Lines changed: 310 additions & 58 deletions

File tree

‎packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/_accelerator_health.py‎

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@
4848

4949
from __future__ import annotations
5050

51-
import concurrent.futures
5251
import logging
52+
import threading
53+
from typing import Any
5354

5455
from grpc import StatusCode
5556

@@ -96,6 +97,11 @@
9697
DEFAULT_UNHEALTHY_THRESHOLD = 3
9798
DEFAULT_HEALTHY_THRESHOLD = 3
9899

100+
#: How long ``close()`` waits for the polling loop to finish. It is already
101+
#: cancelled (async) or signalled (sync) by then, so the only thing this covers
102+
#: is a probe in flight, bounded by the probe timeout.
103+
_CLOSE_WAIT_SECONDS = 1.0
104+
99105

100106
@CrossSync.convert_class(sync_name="_AcceleratorHealthMonitor")
101107
class _AsyncAcceleratorHealthMonitor:
@@ -122,33 +128,71 @@ def __init__(
122128
self._healthy_threshold = healthy_threshold
123129
self._consecutive_failures = 0
124130
self._consecutive_successes = 0
125-
self._closed = CrossSync.Event()
126-
self._task: CrossSync.Task[None] | None = None
127-
# A dedicated worker rather than the client's shared pool: this task
128-
# runs for the life of the Table, so it must not occupy a slot other
129-
# work is queued behind.
130-
self._sync_executor = (
131-
concurrent.futures.ThreadPoolExecutor(
132-
max_workers=1, thread_name_prefix="bigtable-accel-health"
133-
)
134-
if not CrossSync.is_async
135-
else None
136-
)
131+
# A threading.Event in both variants, deliberately not CrossSync.Event:
132+
# an asyncio.Event may only be set from the loop thread, and this one
133+
# is also set by a weakref finalizer running on whatever thread the
134+
# collection happened on. Nothing ever *awaits* it — the sync loop
135+
# blocks on it, the async loop only reads it between sleeps — so it
136+
# needs no loop affinity. See _run() and request_stop().
137+
self._closed = threading.Event()
138+
# An asyncio.Task when async; a daemon thread when sync. See start().
139+
self._task: Any = None
137140

138141
def start(self) -> None:
139142
"""Launch the polling task. Idempotent."""
140-
if self._task is None:
143+
if self._task is not None:
144+
return
145+
if CrossSync.is_async:
141146
self._task = CrossSync.create_task(
142-
self._run,
143-
sync_executor=self._sync_executor,
144-
task_name="bigtable-accelerator-health",
147+
self._run, task_name="bigtable-accelerator-health"
145148
)
149+
else:
150+
# A dedicated daemon thread rather than a ThreadPoolExecutor
151+
# worker: the loop runs for the life of the Table, and
152+
# concurrent.futures *joins* its (non-daemon) workers at
153+
# interpreter exit, so a Table that is never closed would hang the
154+
# process on the way out. A dedicated thread also keeps the loop
155+
# out of a pool slot other work is queued behind.
156+
self._task = threading.Thread(
157+
target=self._run,
158+
name="bigtable-accelerator-health",
159+
daemon=True,
160+
)
161+
self._task.start()
162+
163+
@property
164+
def is_running(self) -> bool:
165+
"""Whether the polling loop is still alive."""
166+
if self._task is None:
167+
return False
168+
if CrossSync.is_async:
169+
return not self._task.done()
170+
return self._task.is_alive()
171+
172+
def request_stop(self) -> None:
173+
"""Ask the loop to stop, without waiting for it to finish.
174+
175+
For callers that cannot await ``close()`` — notably a weakref finalizer
176+
on the owning Table, which is what stops a monitor whose Table was
177+
dropped without being closed. Setting a threading.Event is safe from
178+
any thread and during interpreter shutdown. The sync loop wakes on it
179+
immediately; the async loop sees it at its next wakeup, since only
180+
``close()`` can cancel the task outright.
181+
"""
182+
self._closed.set()
146183

147184
@CrossSync.convert
148185
async def _run(self) -> None:
149186
"""Poll until closed, or until monitoring turns out to be pointless."""
150187
while not self._closed.is_set():
151-
await CrossSync.event_wait(self._closed, timeout=self._interval)
188+
if CrossSync.is_async:
189+
# Sleep rather than wait on the event: it is a threading.Event
190+
# (see __init__), so there is nothing here to await. close()
191+
# cancels this task, which interrupts the sleep, and
192+
# request_stop() is picked up by the check below.
193+
await CrossSync.sleep(self._interval)
194+
else:
195+
self._closed.wait(timeout=self._interval)
152196
if self._closed.is_set():
153197
return
154198
if self._breaker.is_tripped:
@@ -158,9 +202,17 @@ async def _run(self) -> None:
158202
healthy = await self._probe()
159203
if healthy is None:
160204
# The daemon does not serve health checks at all. Stop probing
161-
# and leave the breaker alone: an unmonitorable daemon is not a
162-
# degraded one, and treating it as one would disable the
205+
# and leave the breaker undegraded: an unmonitorable daemon is
206+
# not a degraded one, and treating it as one would disable the
163207
# accelerator against every daemon older than this feature.
208+
#
209+
# Clearing is not redundant. An earlier streak of probes can
210+
# have degraded the breaker already — a daemon too starved to
211+
# answer even UNIMPLEMENTED before the deadline looks exactly
212+
# like an unhealthy one — and this return is the last thing
213+
# that runs, so a flag left set here is never cleared by
214+
# anyone. (A permanent trip is handled above and stays.)
215+
self._breaker.set_degraded(False)
164216
_LOGGER.info(
165217
"Accelerator daemon does not implement the gRPC health "
166218
"service; health-based fallback is disabled for this table."
@@ -221,14 +273,24 @@ def _record(self, healthy: bool) -> None:
221273

222274
@CrossSync.convert
223275
async def close(self) -> None:
224-
"""Stop polling. Does not clear a degraded flag already set."""
276+
"""Stop polling and wait for the loop to finish.
277+
278+
Does not clear a degraded flag already set. Waiting is bounded by
279+
``_CLOSE_WAIT_SECONDS``: the caller is entitled to have the monitor
280+
actually stopped when this returns, because the channel it probes over
281+
is closed immediately afterwards, and a probe still running into that
282+
would log an alarming "falling back" warning during orderly teardown.
283+
"""
225284
self._closed.set()
226-
if self._task is not None:
227-
task, self._task = self._task, None
285+
task, self._task = self._task, None
286+
if task is None:
287+
return
288+
if CrossSync.is_async:
228289
task.cancel()
229-
# Retrieve any exception so asyncio doesn't log "Task exception was never retrieved"
290+
# Retrieve any exception so asyncio doesn't log "Task exception was
291+
# never retrieved" — as a callback too, in case the wait times out
292+
# and the task completes after we stop looking.
230293
task.add_done_callback(lambda t: None if t.cancelled() else t.exception())
231-
if self._sync_executor is not None:
232-
# Don't wait: a probe in flight holds the worker for up to the probe
233-
# timeout, and closing a Table should not block on that.
234-
self._sync_executor.shutdown(wait=False)
294+
await CrossSync.wait([task], timeout=_CLOSE_WAIT_SECONDS)
295+
else:
296+
task.join(timeout=_CLOSE_WAIT_SECONDS)

‎packages/google-cloud-bigtable/google/cloud/bigtable/data/_async/client.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import sys
2424
import time
2525
import warnings
26+
import weakref
2627
from functools import partial
2728
from typing import (
2829
TYPE_CHECKING,
@@ -1476,6 +1477,12 @@ def _start_accelerator(self) -> None:
14761477
self._accelerator_client, self._accelerator_breaker
14771478
)
14781479
self._accelerator_health.start()
1480+
# close() is the orderly stop, but a Table dropped without it would
1481+
# otherwise leave the loop probing for the life of the event loop,
1482+
# holding the monitor and through it the accelerator channel. The
1483+
# finalizer is bound to the monitor, not to self, so it does not
1484+
# keep this Table alive; it only flips a flag the loop reads.
1485+
weakref.finalize(self, self._accelerator_health.request_stop)
14791486
except Exception as exc:
14801487
# Monitoring is an optimization on top of a working accelerator, so
14811488
# failing to start it must not take the accelerator down with it —

‎packages/google-cloud-bigtable/google/cloud/bigtable/data/_sync_autogen/_accelerator_health.py‎

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@
5151

5252
from __future__ import annotations
5353

54-
import concurrent.futures
5554
import logging
55+
import threading
56+
from typing import Any
5657

5758
from grpc import StatusCode
5859

@@ -61,7 +62,6 @@
6162
_grpc_code,
6263
)
6364
from google.cloud.bigtable.data._accelerator._health import ServingStatus
64-
from google.cloud.bigtable.data._cross_sync import CrossSync
6565
from google.cloud.bigtable.data._sync_autogen._accelerator_client import (
6666
_AcceleratorClient as AcceleratorClientType,
6767
)
@@ -71,6 +71,7 @@
7171
DEFAULT_PROBE_TIMEOUT = 0.25
7272
DEFAULT_UNHEALTHY_THRESHOLD = 3
7373
DEFAULT_HEALTHY_THRESHOLD = 3
74+
_CLOSE_WAIT_SECONDS = 1.0
7475

7576

7677
class _AcceleratorHealthMonitor:
@@ -97,35 +98,47 @@ def __init__(
9798
self._healthy_threshold = healthy_threshold
9899
self._consecutive_failures = 0
99100
self._consecutive_successes = 0
100-
self._closed = CrossSync._Sync_Impl.Event()
101-
self._task: CrossSync._Sync_Impl.Task[None] | None = None
102-
self._sync_executor = (
103-
concurrent.futures.ThreadPoolExecutor(
104-
max_workers=1, thread_name_prefix="bigtable-accel-health"
105-
)
106-
if not CrossSync._Sync_Impl.is_async
107-
else None
108-
)
101+
self._closed = threading.Event()
102+
self._task: Any = None
109103

110104
def start(self) -> None:
111105
"""Launch the polling task. Idempotent."""
106+
if self._task is not None:
107+
return
108+
self._task = threading.Thread(
109+
target=self._run, name="bigtable-accelerator-health", daemon=True
110+
)
111+
self._task.start()
112+
113+
@property
114+
def is_running(self) -> bool:
115+
"""Whether the polling loop is still alive."""
112116
if self._task is None:
113-
self._task = CrossSync._Sync_Impl.create_task(
114-
self._run,
115-
sync_executor=self._sync_executor,
116-
task_name="bigtable-accelerator-health",
117-
)
117+
return False
118+
return self._task.is_alive()
119+
120+
def request_stop(self) -> None:
121+
"""Ask the loop to stop, without waiting for it to finish.
122+
123+
For callers that cannot await ``close()`` — notably a weakref finalizer
124+
on the owning Table, which is what stops a monitor whose Table was
125+
dropped without being closed. Setting a threading.Event is safe from
126+
any thread and during interpreter shutdown. The sync loop wakes on it
127+
immediately; the async loop sees it at its next wakeup, since only
128+
``close()`` can cancel the task outright."""
129+
self._closed.set()
118130

119131
def _run(self) -> None:
120132
"""Poll until closed, or until monitoring turns out to be pointless."""
121133
while not self._closed.is_set():
122-
CrossSync._Sync_Impl.event_wait(self._closed, timeout=self._interval)
134+
self._closed.wait(timeout=self._interval)
123135
if self._closed.is_set():
124136
return
125137
if self._breaker.is_tripped:
126138
return
127139
healthy = self._probe()
128140
if healthy is None:
141+
self._breaker.set_degraded(False)
129142
_LOGGER.info(
130143
"Accelerator daemon does not implement the gRPC health service; health-based fallback is disabled for this table."
131144
)
@@ -175,11 +188,15 @@ def _record(self, healthy: bool) -> None:
175188
self._breaker.set_degraded(True)
176189

177190
def close(self) -> None:
178-
"""Stop polling. Does not clear a degraded flag already set."""
191+
"""Stop polling and wait for the loop to finish.
192+
193+
Does not clear a degraded flag already set. Waiting is bounded by
194+
``_CLOSE_WAIT_SECONDS``: the caller is entitled to have the monitor
195+
actually stopped when this returns, because the channel it probes over
196+
is closed immediately afterwards, and a probe still running into that
197+
would log an alarming "falling back" warning during orderly teardown."""
179198
self._closed.set()
180-
if self._task is not None:
181-
task, self._task = (self._task, None)
182-
task.cancel()
183-
task.add_done_callback(lambda t: None if t.cancelled() else t.exception())
184-
if self._sync_executor is not None:
185-
self._sync_executor.shutdown(wait=False)
199+
task, self._task = (self._task, None)
200+
if task is None:
201+
return
202+
task.join(timeout=_CLOSE_WAIT_SECONDS)

‎packages/google-cloud-bigtable/google/cloud/bigtable/data/_sync_autogen/client.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import sys
2727
import time
2828
import warnings
29+
import weakref
2930
from functools import partial
3031
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional, Sequence, Set, cast
3132

@@ -1147,6 +1148,7 @@ def _start_accelerator(self) -> None:
11471148
self._accelerator_client, self._accelerator_breaker
11481149
)
11491150
self._accelerator_health.start()
1151+
weakref.finalize(self, self._accelerator_health.request_stop)
11501152
except Exception as exc:
11511153
warnings.warn(
11521154
f"Could not start the accelerator health monitor; the accelerator will run without health-based fallback: {exc}",

0 commit comments

Comments
 (0)