4848
4949from __future__ import annotations
5050
51- import concurrent .futures
5251import logging
52+ import threading
53+ from typing import Any
5354
5455from grpc import StatusCode
5556
9697DEFAULT_UNHEALTHY_THRESHOLD = 3
9798DEFAULT_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" )
101107class _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 )
0 commit comments