-
Notifications
You must be signed in to change notification settings - Fork 10
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
Cannot cleanly shutdown async client #25
Comments
Hello @ihorh! Thanks for raising this issue. I'm looking into this now.... |
I tried various things without involving fastapi. Whatever I have tried, it always exits the program with a crtl-c. Please can you share a complete example program using fastapi that I can run, and see if I can replicate the issue here (I'm also running a Mac but with Intel CPU)? This is the code I wrote (which exits automatically if import asyncio
import uuid
from esdbclient import RecordedEvent, AsyncioEventStoreDBClient, NewEvent, StreamState
from esdbclient.exceptions import ConsumerTooSlow
class Consumer:
def __init__(self, client, group_name, stream_name):
self._client = client
self._stop_requested = False
self._subscription_group_name = group_name
self._subscription_stream_name = stream_name
async def run(self):
while not self._stop_requested:
try:
self._subscription = await self._client.read_subscription_to_stream(
group_name=self._subscription_group_name,
stream_name=self._subscription_stream_name,
)
async for event in self._subscription:
if not self._stop_requested:
await self._handle_event(event)
except ConsumerTooSlow:
print("subscription was dropped, restarting subscription")
continue
async def _handle_event(self, event: RecordedEvent) -> None:
print("Received event:", event)
await asyncio.sleep(1)
# await self.stop()
async def stop(self):
self._stop_requested = True
await self._subscription.stop()
async def main() -> None:
client = await AsyncioEventStoreDBClient(uri="esdb://localhost:2113?Tls=false")
group_name = str(uuid.uuid4())
stream_name = str(uuid.uuid4())
await client.append_events(
stream_name=stream_name,
events=list(NewEvent(type="SomethingHappened", data=b"{}") for _ in range(100)),
current_version=StreamState.NO_STREAM,
)
await client.create_subscription_to_stream(group_name=group_name, stream_name=stream_name)
consumer = Consumer(client, group_name, stream_name)
await consumer.run()
print("Consumer has shutdown")
if __name__ == "__main__":
asyncio.get_event_loop().run_until_complete(
main()
) |
Also, I'm using https://pypi.org/project/esdbclient/1.1b1/ |
@johnbywater , thank you for looking into this so quickly. Before I create self contained example program, I just want to make sure error is not in my code, it indeed can be. Please, could you give me a hint if it is ok to use multiple instances of async client in python program? or is it better to reuse the same instance of async client as long as it connects to the same Event Store instance? |
@johnbywater , it seems to me that async client in 1.0.25 is not quite compatible with async fastapi endpoints, I have problems sending simple events from the endpoint on fairy clean environment, I should try try with 1.1b1 first. Or maybe it is (my) skills issue :-) sorry for the trouble. I'll post my updates as soon as I achieve a progress with this. |
@ihorh It's probably best to just have one client. |
quick update. it seems that in original project something is messing with I'm creating one from scratch and adding different components one-by-one until I find the cause. Currently async fastapi + async sqlalchemy (with async driver) seems to work fine together with So it is definitely not a bug in |
How are you constructing the client? I tried using the from contextlib import asynccontextmanager
from fastapi import FastAPI
from esdbclient import AsyncioEventStoreDBClient
from esdbclient.asyncio_client import _AsyncioEventStoreDBClient
esdbclient: _AsyncioEventStoreDBClient
@asynccontextmanager
async def lifespan(app: FastAPI):
# Construct the client.
global esdbclient
esdbclient = await AsyncioEventStoreDBClient(
uri=f"esdb+discover://localhost:2113?Tls=false",
)
yield
# Close the client.
await esdbclient.close()
app = FastAPI(lifespan=lifespan)
@app.get("/commit_position")
async def commit_position():
commit_position = await esdbclient.get_commit_position()
return {"commit_position": commit_position} Putting this code is a file called
I know this doesn't involve any persistent subscriptions, but can you replicate this at least? |
@johnbywater , thanks again for being so helpful. On original project I fixed most of issues (user error) and async client (1.0.25) works fine. On original project I still can reproduce issue with infinite freeze during shutdown. I reused your |
If you don't mind I'll post my research here. There were number of bug reports in uvicorn repo with similar problem. Even so some of them are marked closed, people still complain in the comments that under some circumstance these bugs are reproducible.
some responses claim that python between version 11 and 12, changed the way it is handling idle connections (not sure I get that part right). As a result on shutdown uvicorn waits until outgoing connection closes on its own and somehow under certain circumstance it can make it unresponsive to ctrl+c. In my project, whenever server hangs, I can see that process has an open connection to my (remote) EventStoreDB server to port 2113. So for now I will evaluate a theory that in my environment somehow esdbclient is not closing the connection on shutdown, or maybe my code does not reach the point where it is closing the client. And quick edit: whenever it hangs, it does not reach shutdown code in lifespan: @asynccontextmanager
async def lifespan(_: FastAPI):
...
yield
# it is not reaching this point |
Okay, so now I think I have a proof that the problem is not neither my code or It looks uvicorn's shutdown process under some hardly reproducible conditions freezes waiting for outgoing network connection to close. But because shutdown process itself is frozen it does not execute our shutdown 'hook' defined in fastapi's Here is code I've added to @asynccontextmanager
async def lifespan(_: FastAPI):
...
default_sigint_handler = signal.getsignal(signal.SIGINT)
def terminate_now(signum: int, frame: FrameType | None = None):
# do whatever you need to unblock your own tasks
print("===> shutdown signal override")
task1 = asyncio.create_task(event_handler.stop())
task2 = asyncio.create_task(esdb_client.close())
asyncio.gather(task1, task2)
default_sigint_handler(signum, frame) # type: ignore
signal.signal(signal.SIGINT, terminate_now)
yield
print("===> normal shutdown hook execution")
await event_handler.stop()
await esdb_client.close() This code makes I've found hints for a workaround here: encode/uvicorn#1579 So it seems there is nothing we can do better in our code. @johnbywater , sorry for the trouble again. At first it looked that this problem is caused by grpc aio or esdbcclient, since it appeared only when they were part of the equation. But apparently adding them to the equation just triggers I'm closing the bug if you don't mind. |
I'm testing this locally for now with fastapi + unicorn (in reload mode). I start/stop subscription in fastapi's lifecycle.
I run persistent subscription in the infinite loop like this:
no matter how I handle shutdown (ctrl+c) in code I can reliably pick a moment when client or subscription is probably half-initialised (or whatever something else happens there) and does not shutdown properly, causing entire application to hang forever.
With this shutdown code I get the best results so far, but it does not completely solves an issue:
Async client initialization is also protected by the same
self._start_stop_lock
.Whenever I see in logs following entry:
I hit
ctrl+c
immediately and almost always get application hanging, producing following output once:subsequent
ctrl+c
s don't seem to have any effectEnvironment: it is python 3.11 on MacOS M2
The text was updated successfully, but these errors were encountered: