I am building a worker class that connects to an external event stream using asyncio. It is a single stream, but several consumer may enable it. The goal is to only maintain the connection while one or more consumer requires it.
My requirements are as follow:
- The worker instance is created dynamically first time a consumer requires it.
- When other consumers then require it, they re-use the same worker instance.
- When the last consumer closes the stream, it cleans up its resources.
This sounds easy enough. However, the startup sequence is causing me issues, because it is itself asynchronous. Thus, assuming this interface:
class Stream:
async def start(self, *, timeout=DEFAULT_TIMEOUT):
pass
async def stop(self):
pass
I have the following scenarios:
Scenario 1 - exception at startup
- Consumer 1 requests worker to start.
- Worker startup sequence begins
- Consumer 2 requests worker to start.
- Worker startup sequence raises an exception.
- Both consumer should see the exception as the result of their call to start().
Scenario 2 - partial asynchronous cancellation
- Consumer 1 requests worker to start.
- Worker startup sequence begins
- Consumer 2 requests worker to start.
- Consumer 1 gets cancelled.
- Worker startup sequence completes.
- Consumer 2 should see a successful start.
Scenario 3 - complete asynchronous cancellation
- Consumer 1 requests worker to start.
- Worker startup sequence begins
- Consumer 2 requests worker to start.
- Consumer 1 gets cancelled.
- Consumer 2 gets cancelled.
- Worker startup sequence must be cancelled as a result.
I struggle to cover all scenarios without getting any race condition and a spaghetti mess of either bare Future or Event objects.
Here is an attempt at writing start(). It relies on _worker() setting an asyncio.Event named self._worker_ready when it completes the startup sequence:
async def start(self, timeout=None):
assert not self.closing
if not self._task:
self._task = asyncio.ensure_future(self._worker())
# Wait until worker is ready, has failed, or timeout triggers
try:
self._waiting_start += 1
wait_ready = asyncio.ensure_future(self._worker_ready.wait())
done, pending = await asyncio.wait(
[self._task, wait_ready],
return_when=asyncio.FIRST_COMPLETED, timeout=timeout
)
except asyncio.CancelledError:
wait_ready.cancel()
if self._waiting_start == 1:
self.closing = True
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task # let worker shutdown
raise
finally:
self._waiting_start -= 1
# worker failed to start - either throwing or timeout triggering
if not self._worker_ready.is_set():
self.closing = True
self._task.cancel()
wait_ready.cancel()
try:
await self._task # let worker shutdown
except asyncio.CancelledError:
raise FeedTimeoutError('stream failed to start within %ss' % timeout)
else:
assert False, 'worker must propagate the exception'
That seems to work, but it seems too complex, and is really hard to test: the worker has many await points, leading to combinatoric explosion if I am to try all possible cancellation points and execution orders.
I need a better way. I am thus wondering:
- Are my requirements reasonable?
- Is there a common pattern to do this?
- Does my question raise some code smell?