724 lines
20 KiB
Python
724 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from src.bootstrap.application import (
|
|
ApplicationComposition,
|
|
run_application,
|
|
)
|
|
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
|
|
TradeStreamProductionRuntimeState,
|
|
)
|
|
|
|
|
|
class FakeBotSession:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
close_error: BaseException | None = None,
|
|
) -> None:
|
|
self.close_calls = 0
|
|
self.close_error = close_error
|
|
|
|
async def close(self) -> None:
|
|
self.close_calls += 1
|
|
|
|
if self.close_error is not None:
|
|
raise self.close_error
|
|
|
|
|
|
class FakeBot:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
close_error: BaseException | None = None,
|
|
) -> None:
|
|
self.session = FakeBotSession(
|
|
close_error=close_error,
|
|
)
|
|
|
|
|
|
class FakeDispatcher:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
return_immediately: bool = False,
|
|
error: BaseException | None = None,
|
|
calls: list[str] | None = None,
|
|
) -> None:
|
|
self.started = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
self.cancelled = asyncio.Event()
|
|
self.return_immediately = return_immediately
|
|
self.error = error
|
|
self.calls = calls
|
|
self.close_bot_session_values: list[bool] = []
|
|
|
|
async def start_polling(
|
|
self,
|
|
bot: FakeBot,
|
|
*,
|
|
close_bot_session: bool,
|
|
) -> None:
|
|
del bot
|
|
if self.calls is not None:
|
|
self.calls.append("polling.start")
|
|
self.close_bot_session_values.append(close_bot_session)
|
|
self.started.set()
|
|
|
|
try:
|
|
if not self.return_immediately:
|
|
await self.release.wait()
|
|
except asyncio.CancelledError:
|
|
self.cancelled.set()
|
|
raise
|
|
|
|
if self.error is not None:
|
|
raise self.error
|
|
|
|
|
|
class FakeRuntime:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
return_immediately: bool = False,
|
|
error: BaseException | None = None,
|
|
stop_error: BaseException | None = None,
|
|
calls: list[str] | None = None,
|
|
) -> None:
|
|
self.started = asyncio.Event()
|
|
self.release = asyncio.Event()
|
|
self.stopped = asyncio.Event()
|
|
self.return_immediately = return_immediately
|
|
self.error = error
|
|
self.stop_error = stop_error
|
|
self.calls = calls
|
|
self.stop_calls = 0
|
|
|
|
@property
|
|
def state(self) -> TradeStreamProductionRuntimeState:
|
|
return (
|
|
TradeStreamProductionRuntimeState.RUNNING
|
|
if self.started.is_set() and not self.stopped.is_set()
|
|
else TradeStreamProductionRuntimeState.STOPPED
|
|
)
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
return self.state is TradeStreamProductionRuntimeState.RUNNING
|
|
|
|
async def run(self) -> None:
|
|
if self.calls is not None:
|
|
self.calls.append("runtime.run")
|
|
self.started.set()
|
|
|
|
if not self.return_immediately:
|
|
await self.release.wait()
|
|
|
|
if self.error is not None:
|
|
raise self.error
|
|
|
|
async def stop(self) -> None:
|
|
self.stop_calls += 1
|
|
if self.calls is not None:
|
|
self.calls.append("runtime.stop")
|
|
self.release.set()
|
|
self.stopped.set()
|
|
|
|
if self.stop_error is not None:
|
|
raise self.stop_error
|
|
|
|
|
|
class BlockingStopRuntime(FakeRuntime):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
|
|
self.stop_entered = asyncio.Event()
|
|
self.stop_release = asyncio.Event()
|
|
|
|
async def stop(self) -> None:
|
|
self.stop_calls += 1
|
|
self.stop_entered.set()
|
|
await self.stop_release.wait()
|
|
self.release.set()
|
|
self.stopped.set()
|
|
|
|
|
|
class FakeStorageLifecycle:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
calls: list[str] | None = None,
|
|
start_error: BaseException | None = None,
|
|
stop_error: BaseException | None = None,
|
|
start_release: threading.Event | None = None,
|
|
stop_release: threading.Event | None = None,
|
|
) -> None:
|
|
self.calls = calls
|
|
self.start_error = start_error
|
|
self.stop_error = stop_error
|
|
self.start_release = start_release
|
|
self.stop_release = stop_release
|
|
self.start_entered = threading.Event()
|
|
self.stop_entered = threading.Event()
|
|
self.start_calls = 0
|
|
self.stop_calls = 0
|
|
self.started = False
|
|
|
|
def start(self) -> None:
|
|
self.start_calls += 1
|
|
if self.calls is not None:
|
|
self.calls.append("storage.start")
|
|
self.start_entered.set()
|
|
|
|
if self.start_release is not None:
|
|
if not self.start_release.wait(timeout=2.0):
|
|
raise TimeoutError("storage startup was not released")
|
|
|
|
if self.start_error is not None:
|
|
raise self.start_error
|
|
|
|
self.started = True
|
|
|
|
def stop(self) -> None:
|
|
self.stop_calls += 1
|
|
if self.calls is not None:
|
|
self.calls.append("storage.stop")
|
|
self.started = False
|
|
self.stop_entered.set()
|
|
|
|
if self.stop_release is not None:
|
|
if not self.stop_release.wait(timeout=2.0):
|
|
raise TimeoutError("storage shutdown was not released")
|
|
|
|
if self.stop_error is not None:
|
|
raise self.stop_error
|
|
|
|
|
|
def make_application(
|
|
*,
|
|
dispatcher: FakeDispatcher,
|
|
runtime: FakeRuntime | None,
|
|
bot: FakeBot | None = None,
|
|
storage_lifecycle: FakeStorageLifecycle | None = None,
|
|
) -> ApplicationComposition:
|
|
return ApplicationComposition(
|
|
bot=bot or FakeBot(), # type: ignore[arg-type]
|
|
dispatcher=dispatcher, # type: ignore[arg-type]
|
|
trade_stream_runtime=runtime,
|
|
market_data_storage_lifecycle=storage_lifecycle,
|
|
)
|
|
|
|
|
|
def test_storage_lifecycle_wraps_root_runtime_tasks() -> None:
|
|
async def scenario() -> None:
|
|
calls: list[str] = []
|
|
storage = FakeStorageLifecycle(calls=calls)
|
|
dispatcher = FakeDispatcher(
|
|
return_immediately=True,
|
|
calls=calls,
|
|
)
|
|
runtime = FakeRuntime(calls=calls)
|
|
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
storage_lifecycle=storage,
|
|
)
|
|
)
|
|
|
|
assert storage.start_calls == 1
|
|
assert storage.stop_calls == 1
|
|
assert calls.index("storage.start") < calls.index(
|
|
"polling.start"
|
|
)
|
|
assert calls.index("storage.start") < calls.index(
|
|
"runtime.run"
|
|
)
|
|
assert calls.index("runtime.stop") < calls.index(
|
|
"storage.stop"
|
|
)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_storage_startup_failure_prevents_root_task_start() -> None:
|
|
async def scenario() -> None:
|
|
expected = RuntimeError("storage startup failed")
|
|
storage = FakeStorageLifecycle(start_error=expected)
|
|
dispatcher = FakeDispatcher()
|
|
runtime = FakeRuntime()
|
|
bot = FakeBot()
|
|
|
|
with pytest.raises(RuntimeError) as error_info:
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
storage_lifecycle=storage,
|
|
)
|
|
)
|
|
|
|
assert error_info.value is expected
|
|
assert dispatcher.started.is_set() is False
|
|
assert runtime.started.is_set() is False
|
|
assert runtime.stop_calls == 1
|
|
assert storage.stop_calls == 1
|
|
assert bot.session.close_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_cancellation_waits_for_storage_startup_before_cleanup() -> None:
|
|
async def scenario() -> None:
|
|
start_release = threading.Event()
|
|
storage = FakeStorageLifecycle(
|
|
start_release=start_release,
|
|
)
|
|
dispatcher = FakeDispatcher()
|
|
runtime = FakeRuntime()
|
|
bot = FakeBot()
|
|
task = asyncio.create_task(
|
|
run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
storage_lifecycle=storage,
|
|
)
|
|
)
|
|
)
|
|
|
|
entered = await asyncio.to_thread(
|
|
storage.start_entered.wait,
|
|
1.0,
|
|
)
|
|
assert entered is True
|
|
task.cancel()
|
|
|
|
try:
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
assert task.done() is False
|
|
finally:
|
|
start_release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert dispatcher.started.is_set() is False
|
|
assert runtime.started.is_set() is False
|
|
assert storage.stop_calls == 1
|
|
assert bot.session.close_calls == 1
|
|
await asyncio.sleep(0)
|
|
assert not {
|
|
child.get_name()
|
|
for child in asyncio.all_tasks()
|
|
if child is not asyncio.current_task()
|
|
and not child.done()
|
|
and child.get_name()
|
|
in {
|
|
"application-shutdown",
|
|
"market-data-storage-shutdown",
|
|
"market-data-storage-startup",
|
|
"telegram-polling",
|
|
"trade-stream-runtime",
|
|
}
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_storage_shutdown_error_is_reported_and_bot_still_closes() -> None:
|
|
async def scenario() -> None:
|
|
expected = RuntimeError("storage close failed")
|
|
storage = FakeStorageLifecycle(stop_error=expected)
|
|
dispatcher = FakeDispatcher(return_immediately=True)
|
|
bot = FakeBot()
|
|
|
|
with pytest.raises(RuntimeError) as error_info:
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=None,
|
|
bot=bot,
|
|
storage_lifecycle=storage,
|
|
)
|
|
)
|
|
|
|
assert error_info.value is expected
|
|
assert storage.stop_calls == 1
|
|
assert bot.session.close_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_storage_shutdown_error_does_not_replace_runtime_error() -> None:
|
|
async def scenario() -> None:
|
|
runtime_error = RuntimeError("runtime failed")
|
|
storage_error = RuntimeError("storage close failed")
|
|
storage = FakeStorageLifecycle(stop_error=storage_error)
|
|
dispatcher = FakeDispatcher()
|
|
runtime = FakeRuntime(
|
|
return_immediately=True,
|
|
error=runtime_error,
|
|
)
|
|
|
|
with pytest.raises(RuntimeError) as error_info:
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
storage_lifecycle=storage,
|
|
)
|
|
)
|
|
|
|
assert error_info.value is runtime_error
|
|
assert any(
|
|
"cleanup also failed" in note
|
|
for note in getattr(runtime_error, "__notes__", ())
|
|
)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_repeated_cancellation_does_not_interrupt_storage_shutdown(
|
|
) -> None:
|
|
async def scenario() -> None:
|
|
stop_release = threading.Event()
|
|
storage = FakeStorageLifecycle(
|
|
stop_release=stop_release,
|
|
)
|
|
dispatcher = FakeDispatcher(return_immediately=True)
|
|
bot = FakeBot()
|
|
task = asyncio.create_task(
|
|
run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=None,
|
|
bot=bot,
|
|
storage_lifecycle=storage,
|
|
)
|
|
)
|
|
)
|
|
|
|
entered = await asyncio.to_thread(
|
|
storage.stop_entered.wait,
|
|
1.0,
|
|
)
|
|
assert entered is True
|
|
task.cancel()
|
|
task.cancel()
|
|
|
|
try:
|
|
await asyncio.sleep(0)
|
|
assert task.done() is False
|
|
finally:
|
|
stop_release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert storage.stop_calls == 1
|
|
assert bot.session.close_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_disabled_runtime_runs_only_polling_and_closes_bot() -> None:
|
|
async def scenario() -> None:
|
|
dispatcher = FakeDispatcher(return_immediately=True)
|
|
bot = FakeBot()
|
|
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=None,
|
|
bot=bot,
|
|
)
|
|
)
|
|
|
|
assert dispatcher.close_bot_session_values == [False]
|
|
assert bot.session.close_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_polling_completion_stops_and_awaits_runtime() -> None:
|
|
async def scenario() -> None:
|
|
dispatcher = FakeDispatcher(return_immediately=True)
|
|
runtime = FakeRuntime()
|
|
bot = FakeBot()
|
|
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
)
|
|
)
|
|
|
|
assert runtime.started.is_set()
|
|
assert runtime.stop_calls == 1
|
|
assert runtime.stopped.is_set()
|
|
assert bot.session.close_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_runtime_error_is_fatal_and_stops_polling() -> None:
|
|
async def scenario() -> None:
|
|
expected = RuntimeError("trade stream failed")
|
|
dispatcher = FakeDispatcher()
|
|
runtime = FakeRuntime(
|
|
return_immediately=True,
|
|
error=expected,
|
|
)
|
|
bot = FakeBot()
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
)
|
|
)
|
|
|
|
assert exc_info.value is expected
|
|
assert dispatcher.cancelled.is_set()
|
|
assert runtime.stop_calls == 1
|
|
assert bot.session.close_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_normal_runtime_completion_is_fatal() -> None:
|
|
async def scenario() -> None:
|
|
dispatcher = FakeDispatcher()
|
|
runtime = FakeRuntime(return_immediately=True)
|
|
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match="terminated unexpectedly",
|
|
):
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
)
|
|
)
|
|
|
|
assert dispatcher.cancelled.is_set()
|
|
assert runtime.stop_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_application_cancellation_performs_full_cleanup() -> None:
|
|
async def scenario() -> None:
|
|
dispatcher = FakeDispatcher()
|
|
runtime = FakeRuntime()
|
|
bot = FakeBot()
|
|
task = asyncio.create_task(
|
|
run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
)
|
|
)
|
|
)
|
|
|
|
await dispatcher.started.wait()
|
|
await runtime.started.wait()
|
|
task.cancel()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert dispatcher.cancelled.is_set()
|
|
assert runtime.stop_calls == 1
|
|
assert runtime.stopped.is_set()
|
|
assert bot.session.close_calls == 1
|
|
await asyncio.sleep(0)
|
|
assert not {
|
|
child.get_name()
|
|
for child in asyncio.all_tasks()
|
|
if child is not asyncio.current_task()
|
|
and not child.done()
|
|
and child.get_name()
|
|
in {
|
|
"telegram-polling",
|
|
"trade-stream-runtime",
|
|
"application-shutdown",
|
|
}
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_polling_error_stops_runtime_and_preserves_error() -> None:
|
|
async def scenario() -> None:
|
|
expected = RuntimeError("polling failed")
|
|
dispatcher = FakeDispatcher(
|
|
return_immediately=True,
|
|
error=expected,
|
|
)
|
|
runtime = FakeRuntime()
|
|
bot = FakeBot()
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
)
|
|
)
|
|
|
|
assert exc_info.value is expected
|
|
assert runtime.stop_calls == 1
|
|
assert runtime.stopped.is_set()
|
|
assert bot.session.close_calls == 1
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_simultaneous_root_failures_are_awaited_deterministically() -> None:
|
|
async def scenario() -> None:
|
|
polling_error = RuntimeError("polling failed")
|
|
runtime_error = ValueError("trade stream failed")
|
|
dispatcher = FakeDispatcher(
|
|
error=polling_error,
|
|
)
|
|
runtime = FakeRuntime(
|
|
error=runtime_error,
|
|
)
|
|
bot = FakeBot()
|
|
task = asyncio.create_task(
|
|
run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
)
|
|
)
|
|
)
|
|
|
|
await dispatcher.started.wait()
|
|
await runtime.started.wait()
|
|
dispatcher.release.set()
|
|
runtime.release.set()
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await task
|
|
|
|
assert exc_info.value is runtime_error
|
|
assert runtime.stop_calls == 1
|
|
assert bot.session.close_calls == 1
|
|
assert any(
|
|
"cleanup also failed" in note
|
|
for note in getattr(runtime_error, "__notes__", ())
|
|
)
|
|
await asyncio.sleep(0)
|
|
assert not {
|
|
child.get_name()
|
|
for child in asyncio.all_tasks()
|
|
if child is not asyncio.current_task()
|
|
and not child.done()
|
|
and child.get_name()
|
|
in {
|
|
"telegram-polling",
|
|
"trade-stream-runtime",
|
|
"application-shutdown",
|
|
}
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_repeated_cancellation_does_not_interrupt_cleanup() -> None:
|
|
async def scenario() -> None:
|
|
dispatcher = FakeDispatcher()
|
|
runtime = BlockingStopRuntime()
|
|
bot = FakeBot()
|
|
task = asyncio.create_task(
|
|
run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
)
|
|
)
|
|
)
|
|
|
|
await dispatcher.started.wait()
|
|
await runtime.started.wait()
|
|
task.cancel()
|
|
await runtime.stop_entered.wait()
|
|
|
|
task.cancel()
|
|
runtime.stop_release.set()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert task.cancelled() is True
|
|
assert dispatcher.cancelled.is_set()
|
|
assert runtime.stop_calls == 1
|
|
assert runtime.stopped.is_set()
|
|
assert bot.session.close_calls == 1
|
|
await asyncio.sleep(0)
|
|
assert not {
|
|
child.get_name()
|
|
for child in asyncio.all_tasks()
|
|
if child is not asyncio.current_task()
|
|
and not child.done()
|
|
and child.get_name()
|
|
in {
|
|
"telegram-polling",
|
|
"trade-stream-runtime",
|
|
"application-shutdown",
|
|
}
|
|
}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_cleanup_error_does_not_replace_runtime_error() -> None:
|
|
async def scenario() -> None:
|
|
expected = RuntimeError("trade stream failed")
|
|
dispatcher = FakeDispatcher()
|
|
runtime = FakeRuntime(
|
|
return_immediately=True,
|
|
error=expected,
|
|
stop_error=RuntimeError("stop failed"),
|
|
)
|
|
bot = FakeBot(
|
|
close_error=RuntimeError("close failed"),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await run_application(
|
|
make_application(
|
|
dispatcher=dispatcher,
|
|
runtime=runtime,
|
|
bot=bot,
|
|
)
|
|
)
|
|
|
|
assert exc_info.value is expected
|
|
assert bot.session.close_calls == 1
|
|
assert any(
|
|
"cleanup also failed" in note
|
|
for note in getattr(expected, "__notes__", ())
|
|
)
|
|
|
|
asyncio.run(scenario())
|