Build 060.28: implement Persistent Checkpoint and Startup Recovery
This commit is contained in:
@@ -37,7 +37,10 @@ class RecordingJournal:
|
||||
del event, message, context
|
||||
|
||||
|
||||
def make_settings() -> SimpleNamespace:
|
||||
def make_settings(
|
||||
*,
|
||||
storage_enabled: bool = True,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
bot_token="test-token",
|
||||
bot_parse_mode="HTML",
|
||||
@@ -46,7 +49,9 @@ def make_settings() -> SimpleNamespace:
|
||||
exchange_name="dzengi",
|
||||
default_symbol="BTC/USD_LEVERAGE",
|
||||
trade_stream=SimpleNamespace(enabled=True),
|
||||
market_data_storage=SimpleNamespace(enabled=True),
|
||||
market_data_storage=SimpleNamespace(
|
||||
enabled=storage_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -59,9 +64,11 @@ def test_create_app_builds_one_application_composition(
|
||||
runtime = object()
|
||||
storage_lifecycle = object()
|
||||
storage_sink = object()
|
||||
storage_repository = object()
|
||||
storage = SimpleNamespace(
|
||||
lifecycle=storage_lifecycle,
|
||||
trade_observation_sink=storage_sink,
|
||||
trade_repository=storage_repository,
|
||||
)
|
||||
journal = RecordingJournal()
|
||||
observed_runtime_settings: list[object] = []
|
||||
@@ -103,9 +110,11 @@ def test_create_app_builds_one_application_composition(
|
||||
received_settings: object,
|
||||
*,
|
||||
trade_observation_sink: object,
|
||||
checkpoint_storage: object,
|
||||
) -> object:
|
||||
observed_runtime_settings.append(received_settings)
|
||||
assert trade_observation_sink is storage_sink
|
||||
assert checkpoint_storage is storage_repository
|
||||
return runtime
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -162,7 +171,7 @@ def test_runtime_build_error_is_fatal(
|
||||
monkeypatch.setattr(
|
||||
app_factory,
|
||||
"load_settings",
|
||||
make_settings,
|
||||
lambda: make_settings(storage_enabled=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_factory,
|
||||
@@ -189,8 +198,11 @@ def test_runtime_build_error_is_fatal(
|
||||
settings: object,
|
||||
*,
|
||||
trade_observation_sink: object,
|
||||
checkpoint_storage: object,
|
||||
) -> None:
|
||||
del settings, trade_observation_sink
|
||||
del settings
|
||||
assert trade_observation_sink is None
|
||||
assert checkpoint_storage is None
|
||||
raise expected
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -557,6 +557,8 @@ def test_application_cancellation_performs_full_cleanup() -> None:
|
||||
"telegram-polling",
|
||||
"trade-stream-runtime",
|
||||
"application-shutdown",
|
||||
"market-data-storage-startup",
|
||||
"market-data-storage-shutdown",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +639,8 @@ def test_simultaneous_root_failures_are_awaited_deterministically() -> None:
|
||||
"telegram-polling",
|
||||
"trade-stream-runtime",
|
||||
"application-shutdown",
|
||||
"market-data-storage-startup",
|
||||
"market-data-storage-shutdown",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +651,7 @@ def test_repeated_cancellation_does_not_interrupt_cleanup() -> None:
|
||||
async def scenario() -> None:
|
||||
dispatcher = FakeDispatcher()
|
||||
runtime = BlockingStopRuntime()
|
||||
storage = FakeStorageLifecycle()
|
||||
bot = FakeBot()
|
||||
task = asyncio.create_task(
|
||||
run_application(
|
||||
@@ -654,6 +659,7 @@ def test_repeated_cancellation_does_not_interrupt_cleanup() -> None:
|
||||
dispatcher=dispatcher,
|
||||
runtime=runtime,
|
||||
bot=bot,
|
||||
storage_lifecycle=storage,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -664,6 +670,8 @@ def test_repeated_cancellation_does_not_interrupt_cleanup() -> None:
|
||||
await runtime.stop_entered.wait()
|
||||
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
assert storage.stop_calls == 0
|
||||
runtime.stop_release.set()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
@@ -673,6 +681,7 @@ def test_repeated_cancellation_does_not_interrupt_cleanup() -> None:
|
||||
assert dispatcher.cancelled.is_set()
|
||||
assert runtime.stop_calls == 1
|
||||
assert runtime.stopped.is_set()
|
||||
assert storage.stop_calls == 1
|
||||
assert bot.session.close_calls == 1
|
||||
await asyncio.sleep(0)
|
||||
assert not {
|
||||
@@ -685,6 +694,8 @@ def test_repeated_cancellation_does_not_interrupt_cleanup() -> None:
|
||||
"telegram-polling",
|
||||
"trade-stream-runtime",
|
||||
"application-shutdown",
|
||||
"market-data-storage-startup",
|
||||
"market-data-storage-shutdown",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ from src.core.config import (
|
||||
Settings,
|
||||
TradeStreamSettings,
|
||||
)
|
||||
from src.market_data.storage.contracts import (
|
||||
TradeCheckpointStorageProtocol,
|
||||
TradeStorageProtocol,
|
||||
)
|
||||
|
||||
|
||||
def make_settings(
|
||||
@@ -159,6 +163,18 @@ def test_builds_shared_graph_without_opening_pool() -> None:
|
||||
composition.trade_observation_sink._trade_storage
|
||||
is composition.trade_repository
|
||||
)
|
||||
assert (
|
||||
composition.trade_observation_sink._checkpoint_storage
|
||||
is composition.trade_repository
|
||||
)
|
||||
assert isinstance(
|
||||
composition.trade_repository,
|
||||
TradeStorageProtocol,
|
||||
)
|
||||
assert isinstance(
|
||||
composition.trade_repository,
|
||||
TradeCheckpointStorageProtocol,
|
||||
)
|
||||
assert composition.trade_observation_sink._venue == "dzengi"
|
||||
|
||||
conninfo = conninfo_to_dict(composition.connection_pool._conninfo)
|
||||
|
||||
@@ -2,6 +2,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import src.bootstrap.trade_stream_runtime as runtime_factory
|
||||
from src.bootstrap.market_data_storage import build_market_data_storage
|
||||
from src.bootstrap.trade_stream_runtime import (
|
||||
build_trade_stream_production_runtime,
|
||||
)
|
||||
@@ -21,7 +25,16 @@ class RecordingTradeObservationSink:
|
||||
def __init__(self) -> None:
|
||||
self.observations: list[Trade] = []
|
||||
|
||||
def persist(self, trade: Trade) -> None:
|
||||
def persist_accepted(
|
||||
self,
|
||||
trade: Trade,
|
||||
*,
|
||||
expected_trade: Trade | None,
|
||||
) -> None:
|
||||
del expected_trade
|
||||
self.observations.append(trade)
|
||||
|
||||
def persist_duplicate(self, trade: Trade) -> None:
|
||||
self.observations.append(trade)
|
||||
|
||||
|
||||
@@ -29,6 +42,9 @@ def make_settings(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
api_key: str = "api-key",
|
||||
storage_enabled: bool = False,
|
||||
subscription_ack_timeout_seconds: float = 12.5,
|
||||
startup_market_buffer_capacity: int = 1_234,
|
||||
) -> Settings:
|
||||
return Settings(
|
||||
bot_token="test-token",
|
||||
@@ -58,6 +74,12 @@ def make_settings(
|
||||
heartbeat_timeout_seconds=31.0,
|
||||
scheduler_interval_seconds=6.0,
|
||||
recovery_window_ms=123_456,
|
||||
subscription_ack_timeout_seconds=(
|
||||
subscription_ack_timeout_seconds
|
||||
),
|
||||
startup_market_buffer_capacity=(
|
||||
startup_market_buffer_capacity
|
||||
),
|
||||
),
|
||||
db_host="localhost",
|
||||
db_port=5432,
|
||||
@@ -65,7 +87,7 @@ def make_settings(
|
||||
db_user="test",
|
||||
db_password="test",
|
||||
market_data_storage=MarketDataStorageSettings(
|
||||
enabled=False,
|
||||
enabled=storage_enabled,
|
||||
pool_min_size=1,
|
||||
pool_max_size=4,
|
||||
pool_timeout_seconds=10.0,
|
||||
@@ -81,6 +103,16 @@ def test_disabled_feature_does_not_build_runtime() -> None:
|
||||
assert build_trade_stream_production_runtime(settings) is None
|
||||
|
||||
|
||||
def test_enabled_storage_requires_enabled_trade_stream() -> None:
|
||||
settings = make_settings(
|
||||
enabled=False,
|
||||
storage_enabled=True,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Trade Stream"):
|
||||
build_trade_stream_production_runtime(settings)
|
||||
|
||||
|
||||
def test_builds_runtime_without_starting_lifecycle() -> None:
|
||||
runtime = build_trade_stream_production_runtime(
|
||||
make_settings(),
|
||||
@@ -120,6 +152,116 @@ def test_uses_one_shared_stateful_dependency_graph() -> None:
|
||||
assert runtime_graph._runtime_scheduler.runtime_supervisor is (
|
||||
runtime_graph._runtime_supervisor
|
||||
)
|
||||
assert runtime_graph._startup_recovery_coordinator is None
|
||||
|
||||
|
||||
def test_persistent_runtime_reuses_one_storage_graph_without_io() -> None:
|
||||
settings = make_settings(storage_enabled=True)
|
||||
storage = build_market_data_storage(settings)
|
||||
|
||||
assert storage is not None
|
||||
|
||||
runtime = build_trade_stream_production_runtime(
|
||||
settings,
|
||||
trade_observation_sink=storage.trade_observation_sink,
|
||||
checkpoint_storage=storage.trade_repository,
|
||||
)
|
||||
|
||||
assert isinstance(runtime, TradeStreamProductionRuntime)
|
||||
runtime_graph: Any = runtime
|
||||
startup_recovery = runtime_graph._startup_recovery_coordinator
|
||||
live_controller = (
|
||||
runtime_graph._trade_stream_service._consistency_controller
|
||||
)
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert (
|
||||
startup_recovery._state_hydrator._checkpoint_storage
|
||||
is storage.trade_repository
|
||||
)
|
||||
assert (
|
||||
storage.trade_observation_sink._trade_storage
|
||||
is storage.trade_repository
|
||||
)
|
||||
assert (
|
||||
storage.trade_observation_sink._checkpoint_storage
|
||||
is storage.trade_repository
|
||||
)
|
||||
assert live_controller._trade_observation_sink is (
|
||||
storage.trade_observation_sink
|
||||
)
|
||||
assert startup_recovery._state_hydrator._state_store is (
|
||||
live_controller._state_store
|
||||
)
|
||||
assert startup_recovery._state_hydrator._venue == "dzengi"
|
||||
assert storage.trade_observation_sink._venue == "dzengi"
|
||||
assert storage.connection_pool.is_open is False
|
||||
assert storage.lifecycle.started is False
|
||||
assert startup_recovery._hydration_task is None
|
||||
assert startup_recovery._recovery_task is None
|
||||
assert runtime_graph._startup_task is None
|
||||
assert runtime_graph._receive_task is None
|
||||
assert runtime_graph._scheduler_task is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("include_sink", "include_checkpoint"),
|
||||
(
|
||||
(False, False),
|
||||
(True, False),
|
||||
(False, True),
|
||||
),
|
||||
)
|
||||
def test_enabled_storage_rejects_incomplete_runtime_graph_before_transport(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
include_sink: bool,
|
||||
include_checkpoint: bool,
|
||||
) -> None:
|
||||
settings = make_settings(storage_enabled=True)
|
||||
storage = build_market_data_storage(settings)
|
||||
|
||||
assert storage is not None
|
||||
|
||||
def unexpected_transport(**kwargs: object) -> None:
|
||||
del kwargs
|
||||
raise AssertionError("Transport graph must not be created.")
|
||||
|
||||
monkeypatch.setattr(
|
||||
runtime_factory,
|
||||
"DzengiWebSocketTransport",
|
||||
unexpected_transport,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="requires both"):
|
||||
build_trade_stream_production_runtime(
|
||||
settings,
|
||||
trade_observation_sink=(
|
||||
storage.trade_observation_sink
|
||||
if include_sink
|
||||
else None
|
||||
),
|
||||
checkpoint_storage=(
|
||||
storage.trade_repository
|
||||
if include_checkpoint
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
assert storage.connection_pool.is_open is False
|
||||
|
||||
|
||||
def test_disabled_storage_rejects_checkpoint_dependency() -> None:
|
||||
settings = make_settings()
|
||||
persistent_settings = make_settings(storage_enabled=True)
|
||||
storage = build_market_data_storage(persistent_settings)
|
||||
|
||||
assert storage is not None
|
||||
|
||||
with pytest.raises(RuntimeError, match="requires enabled"):
|
||||
build_trade_stream_production_runtime(
|
||||
settings,
|
||||
checkpoint_storage=storage.trade_repository,
|
||||
)
|
||||
|
||||
|
||||
def test_optional_storage_sink_is_shared_by_live_and_recovery() -> None:
|
||||
@@ -172,6 +314,8 @@ def test_applies_explicit_transport_and_runtime_settings() -> None:
|
||||
"ETH/USD_LEVERAGE",
|
||||
)
|
||||
assert runtime_graph._runtime_scheduler.interval_seconds == 6.0
|
||||
assert runtime_graph._subscription_ack_timeout_seconds == 12.5
|
||||
assert runtime_graph._startup_market_buffer_capacity == 1_234
|
||||
assert (
|
||||
runtime_graph._runtime_supervisor._heartbeat_monitor.timeout_seconds
|
||||
== 31.0
|
||||
|
||||
Reference in New Issue
Block a user