Build 060.28: implement Persistent Checkpoint and Startup Recovery
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.checkpoint.trade_stream_state_hydrator import (
|
||||
TradeStreamStateHydrator,
|
||||
TradeStreamStateHydratorProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store import (
|
||||
TradeStreamStateStore,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
|
||||
TradeStreamStateStoreInitializationError,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import (
|
||||
Trade,
|
||||
TradeAggressorSide,
|
||||
)
|
||||
from src.market_data.storage.contracts import (
|
||||
PersistentTradeCheckpoint,
|
||||
TradeCheckpointStorageProtocol,
|
||||
)
|
||||
from src.market_data.storage.exceptions import (
|
||||
MarketDataCheckpointIntegrityError,
|
||||
)
|
||||
|
||||
|
||||
VENUE = "dzengi"
|
||||
SYMBOL = "BTC/USD_LEVERAGE"
|
||||
SECOND_SYMBOL = "ETH/USD_LEVERAGE"
|
||||
BASE_TIME = datetime(2026, 8, 1, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def make_trade(
|
||||
*,
|
||||
symbol: str = SYMBOL,
|
||||
trade_id: int = 100,
|
||||
second: int = 0,
|
||||
source: str = "dzengi_websocket_trade",
|
||||
) -> Trade:
|
||||
return Trade(
|
||||
symbol=symbol,
|
||||
trade_id=trade_id,
|
||||
price=Decimal("65000.25"),
|
||||
quantity=Decimal("0.001"),
|
||||
executed_at=BASE_TIME + timedelta(seconds=second),
|
||||
aggressor_side=TradeAggressorSide.BUY,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def make_checkpoint(
|
||||
trade: Trade,
|
||||
*,
|
||||
venue: str = VENUE,
|
||||
revision: int = 1,
|
||||
) -> PersistentTradeCheckpoint:
|
||||
return PersistentTradeCheckpoint(
|
||||
venue=venue,
|
||||
trade=trade,
|
||||
revision=revision,
|
||||
updated_at=BASE_TIME + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
|
||||
CheckpointResult = PersistentTradeCheckpoint | None | BaseException
|
||||
TailResult = tuple[Trade, ...] | object | BaseException
|
||||
AdoptionResult = PersistentTradeCheckpoint | BaseException
|
||||
|
||||
|
||||
class RecordingCheckpointStorage:
|
||||
"""Настраиваемый fake полного checkpoint-контракта."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
checkpoint_results: dict[
|
||||
str,
|
||||
tuple[CheckpointResult, ...],
|
||||
]
|
||||
| None = None,
|
||||
checkpoint_tails: dict[str, TailResult] | None = None,
|
||||
latest_tails: dict[str, TailResult] | None = None,
|
||||
adoption_results: dict[str, AdoptionResult] | None = None,
|
||||
) -> None:
|
||||
self.checkpoint_results = checkpoint_results or {}
|
||||
self.checkpoint_tails = checkpoint_tails or {}
|
||||
self.latest_tails = latest_tails or {}
|
||||
self.adoption_results = adoption_results or {}
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self._checkpoint_offsets: dict[str, int] = {}
|
||||
|
||||
@property
|
||||
def operation_names(self) -> list[str]:
|
||||
return [str(call[0]) for call in self.calls]
|
||||
|
||||
def load_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
symbol: str,
|
||||
) -> PersistentTradeCheckpoint | None:
|
||||
self.calls.append(("load_checkpoint", venue, symbol))
|
||||
results = self.checkpoint_results.get(symbol, (None,))
|
||||
offset = self._checkpoint_offsets.get(symbol, 0)
|
||||
self._checkpoint_offsets[symbol] = offset + 1
|
||||
result = results[min(offset, len(results) - 1)]
|
||||
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
|
||||
return result
|
||||
|
||||
def load_checkpoint_tail(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
checkpoint: PersistentTradeCheckpoint,
|
||||
limit: int,
|
||||
) -> tuple[Trade, ...]:
|
||||
self.calls.append(
|
||||
(
|
||||
"load_checkpoint_tail",
|
||||
venue,
|
||||
checkpoint,
|
||||
limit,
|
||||
)
|
||||
)
|
||||
result = self.checkpoint_tails.get(
|
||||
checkpoint.trade.symbol,
|
||||
(checkpoint.trade,),
|
||||
)
|
||||
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
def load_latest_trade_tail(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
symbol: str,
|
||||
limit: int,
|
||||
) -> tuple[Trade, ...]:
|
||||
self.calls.append(
|
||||
("load_latest_trade_tail", venue, symbol, limit)
|
||||
)
|
||||
result = self.latest_tails.get(symbol, ())
|
||||
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
def adopt_existing_trade_as_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
trade: Trade,
|
||||
) -> PersistentTradeCheckpoint:
|
||||
self.calls.append(
|
||||
("adopt_existing_trade_as_checkpoint", venue, trade)
|
||||
)
|
||||
result = self.adoption_results.get(
|
||||
trade.symbol,
|
||||
make_checkpoint(trade, venue=venue),
|
||||
)
|
||||
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
|
||||
return result
|
||||
|
||||
def store_trade_and_advance_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
expected_trade: Trade | None,
|
||||
trade: Trade,
|
||||
observed_at: datetime,
|
||||
) -> PersistentTradeCheckpoint:
|
||||
raise AssertionError(
|
||||
"Hydrator не должен использовать writer checkpoint."
|
||||
)
|
||||
|
||||
|
||||
def make_hydrator(
|
||||
storage: RecordingCheckpointStorage,
|
||||
*,
|
||||
state_store: TradeStreamStateStore | None = None,
|
||||
window_size: int = 3,
|
||||
) -> tuple[TradeStreamStateHydrator, TradeStreamStateStore]:
|
||||
store = state_store or TradeStreamStateStore()
|
||||
return (
|
||||
TradeStreamStateHydrator(
|
||||
checkpoint_storage=storage,
|
||||
state_store=store,
|
||||
venue=VENUE,
|
||||
deduplication_window_size=window_size,
|
||||
),
|
||||
store,
|
||||
)
|
||||
|
||||
|
||||
def test_implements_protocol_uses_slots_and_constructor_has_no_io() -> None:
|
||||
storage = RecordingCheckpointStorage()
|
||||
|
||||
hydrator, _ = make_hydrator(storage)
|
||||
|
||||
assert isinstance(storage, TradeCheckpointStorageProtocol)
|
||||
assert isinstance(hydrator, TradeStreamStateHydratorProtocol)
|
||||
assert not hasattr(hydrator, "__dict__")
|
||||
assert storage.calls == []
|
||||
|
||||
|
||||
def test_existing_checkpoint_restores_bounded_tail_and_publishes_state() -> None:
|
||||
trades = (
|
||||
make_trade(trade_id=100, second=0),
|
||||
make_trade(trade_id=101, second=1),
|
||||
make_trade(trade_id=102, second=2),
|
||||
)
|
||||
checkpoint = make_checkpoint(trades[-1], revision=7)
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (checkpoint,)},
|
||||
checkpoint_tails={SYMBOL: trades},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
states = hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert len(states) == 1
|
||||
assert states[0].last_trade is trades[-1]
|
||||
assert states[0].last_trade_id == 102
|
||||
assert store.get(SYMBOL) is states[0]
|
||||
assert storage.calls == [
|
||||
("load_checkpoint", VENUE, SYMBOL),
|
||||
("load_checkpoint_tail", VENUE, checkpoint, 3),
|
||||
]
|
||||
|
||||
|
||||
def test_first_adoption_reloads_tail_before_publishing_state() -> None:
|
||||
candidate_tail = (
|
||||
make_trade(trade_id=100, second=0),
|
||||
make_trade(trade_id=101, second=1),
|
||||
)
|
||||
adopted = make_checkpoint(candidate_tail[-1])
|
||||
reloaded_tail = (
|
||||
make_trade(
|
||||
trade_id=99,
|
||||
second=-1,
|
||||
source="postgres_trade_history",
|
||||
),
|
||||
make_trade(
|
||||
trade_id=100,
|
||||
second=0,
|
||||
source="postgres_trade_history",
|
||||
),
|
||||
make_trade(
|
||||
trade_id=101,
|
||||
second=1,
|
||||
source="postgres_trade_history",
|
||||
),
|
||||
)
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (None,)},
|
||||
latest_tails={SYMBOL: candidate_tail},
|
||||
adoption_results={SYMBOL: adopted},
|
||||
checkpoint_tails={SYMBOL: reloaded_tail},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
(state,) = hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert state.last_trade is reloaded_tail[-1]
|
||||
assert store.get(SYMBOL) is state
|
||||
assert storage.calls == [
|
||||
("load_checkpoint", VENUE, SYMBOL),
|
||||
("load_latest_trade_tail", VENUE, SYMBOL, 3),
|
||||
(
|
||||
"adopt_existing_trade_as_checkpoint",
|
||||
VENUE,
|
||||
candidate_tail[-1],
|
||||
),
|
||||
("load_checkpoint_tail", VENUE, adopted, 3),
|
||||
]
|
||||
|
||||
|
||||
def test_empty_history_rechecks_checkpoint_and_uses_concurrent_value() -> None:
|
||||
trade = make_trade(trade_id=100)
|
||||
concurrent_checkpoint = make_checkpoint(trade, revision=2)
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={
|
||||
SYMBOL: (None, concurrent_checkpoint),
|
||||
},
|
||||
latest_tails={SYMBOL: ()},
|
||||
checkpoint_tails={SYMBOL: (trade,)},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
(state,) = hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert state.last_trade is trade
|
||||
assert store.get(SYMBOL) is state
|
||||
assert storage.calls == [
|
||||
("load_checkpoint", VENUE, SYMBOL),
|
||||
("load_latest_trade_tail", VENUE, SYMBOL, 3),
|
||||
("load_checkpoint", VENUE, SYMBOL),
|
||||
(
|
||||
"load_checkpoint_tail",
|
||||
VENUE,
|
||||
concurrent_checkpoint,
|
||||
3,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_empty_history_without_checkpoint_publishes_empty_state() -> None:
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (None, None)},
|
||||
latest_tails={SYMBOL: ()},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
(state,) = hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert state.last_trade is None
|
||||
assert state.last_trade_id is None
|
||||
assert store.get(SYMBOL) is state
|
||||
assert storage.operation_names == [
|
||||
"load_checkpoint",
|
||||
"load_latest_trade_tail",
|
||||
"load_checkpoint",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformed_tail",
|
||||
(
|
||||
None,
|
||||
[],
|
||||
),
|
||||
)
|
||||
def test_rejects_falsey_non_tuple_latest_tail(
|
||||
malformed_tail: object,
|
||||
) -> None:
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (None, None)},
|
||||
latest_tails={SYMBOL: malformed_tail},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
with pytest.raises(
|
||||
MarketDataCheckpointIntegrityError,
|
||||
match="non-tuple latest Trade tail",
|
||||
):
|
||||
hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert store.is_empty() is True
|
||||
assert storage.operation_names == [
|
||||
"load_checkpoint",
|
||||
"load_latest_trade_tail",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("previous_trade_id", "checkpoint_trade_id"),
|
||||
(
|
||||
(2**31 - 1, -(2**31)),
|
||||
(-1, 0),
|
||||
),
|
||||
)
|
||||
def test_restores_tail_across_signed_rollover(
|
||||
previous_trade_id: int,
|
||||
checkpoint_trade_id: int,
|
||||
) -> None:
|
||||
trades = (
|
||||
make_trade(trade_id=previous_trade_id, second=0),
|
||||
make_trade(trade_id=checkpoint_trade_id, second=1),
|
||||
)
|
||||
checkpoint = make_checkpoint(trades[-1])
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (checkpoint,)},
|
||||
checkpoint_tails={SYMBOL: trades},
|
||||
)
|
||||
hydrator, _ = make_hydrator(storage)
|
||||
|
||||
(state,) = hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert state.last_trade is trades[-1]
|
||||
assert state.last_trade_id == checkpoint_trade_id
|
||||
|
||||
|
||||
def test_rejects_tail_that_does_not_end_at_checkpoint() -> None:
|
||||
checkpoint_trade = make_trade(trade_id=102, second=2)
|
||||
checkpoint = make_checkpoint(checkpoint_trade)
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (checkpoint,)},
|
||||
checkpoint_tails={
|
||||
SYMBOL: (
|
||||
make_trade(trade_id=100, second=0),
|
||||
make_trade(trade_id=101, second=1),
|
||||
)
|
||||
},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
with pytest.raises(
|
||||
MarketDataCheckpointIntegrityError,
|
||||
match="does not end",
|
||||
):
|
||||
hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert store.contains(SYMBOL) is False
|
||||
|
||||
|
||||
def test_rejects_tail_larger_than_deduplication_window() -> None:
|
||||
trades = tuple(
|
||||
make_trade(trade_id=trade_id, second=trade_id - 100)
|
||||
for trade_id in range(100, 103)
|
||||
)
|
||||
checkpoint = make_checkpoint(trades[-1])
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (checkpoint,)},
|
||||
checkpoint_tails={SYMBOL: trades},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage, window_size=2)
|
||||
|
||||
with pytest.raises(
|
||||
MarketDataCheckpointIntegrityError,
|
||||
match="cannot hydrate",
|
||||
):
|
||||
hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert store.contains(SYMBOL) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformed_tail",
|
||||
(
|
||||
[make_trade()],
|
||||
(object(),),
|
||||
),
|
||||
)
|
||||
def test_rejects_malformed_checkpoint_tail(
|
||||
malformed_tail: object,
|
||||
) -> None:
|
||||
checkpoint = make_checkpoint(make_trade())
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (checkpoint,)},
|
||||
checkpoint_tails={SYMBOL: malformed_tail},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
with pytest.raises(MarketDataCheckpointIntegrityError):
|
||||
hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert store.contains(SYMBOL) is False
|
||||
|
||||
|
||||
def test_storage_error_is_not_swallowed_and_state_is_not_published() -> None:
|
||||
failure = RuntimeError("storage failed")
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (failure,)},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
with pytest.raises(RuntimeError, match="storage failed"):
|
||||
hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert store.contains(SYMBOL) is False
|
||||
assert storage.operation_names == ["load_checkpoint"]
|
||||
|
||||
|
||||
def test_adoption_error_is_not_swallowed_and_state_is_not_published() -> None:
|
||||
trade = make_trade()
|
||||
failure = RuntimeError("adoption failed")
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (None,)},
|
||||
latest_tails={SYMBOL: (trade,)},
|
||||
adoption_results={SYMBOL: failure},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
with pytest.raises(RuntimeError, match="adoption failed"):
|
||||
hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert store.contains(SYMBOL) is False
|
||||
assert storage.operation_names == [
|
||||
"load_checkpoint",
|
||||
"load_latest_trade_tail",
|
||||
"adopt_existing_trade_as_checkpoint",
|
||||
]
|
||||
|
||||
|
||||
def test_rejects_adopted_checkpoint_for_different_candidate() -> None:
|
||||
candidate = make_trade(trade_id=100)
|
||||
different_trade = make_trade(trade_id=101, second=1)
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={SYMBOL: (None,)},
|
||||
latest_tails={SYMBOL: (candidate,)},
|
||||
adoption_results={
|
||||
SYMBOL: make_checkpoint(different_trade),
|
||||
},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
with pytest.raises(
|
||||
MarketDataCheckpointIntegrityError,
|
||||
match="does not match candidate",
|
||||
):
|
||||
hydrator.hydrate(symbols=(SYMBOL,))
|
||||
|
||||
assert store.contains(SYMBOL) is False
|
||||
assert "load_checkpoint_tail" not in storage.operation_names
|
||||
|
||||
|
||||
def test_second_symbol_failure_does_not_publish_first_state() -> None:
|
||||
first_trade = make_trade(symbol=SYMBOL)
|
||||
first_checkpoint = make_checkpoint(first_trade)
|
||||
second_failure = RuntimeError("second symbol failed")
|
||||
storage = RecordingCheckpointStorage(
|
||||
checkpoint_results={
|
||||
SYMBOL: (first_checkpoint,),
|
||||
SECOND_SYMBOL: (second_failure,),
|
||||
},
|
||||
checkpoint_tails={SYMBOL: (first_trade,)},
|
||||
)
|
||||
hydrator, store = make_hydrator(storage)
|
||||
|
||||
with pytest.raises(RuntimeError, match="second symbol failed"):
|
||||
hydrator.hydrate(symbols=(SYMBOL, SECOND_SYMBOL))
|
||||
|
||||
assert store.contains(SYMBOL) is False
|
||||
assert store.contains(SECOND_SYMBOL) is False
|
||||
assert storage.operation_names == [
|
||||
"load_checkpoint",
|
||||
"load_checkpoint_tail",
|
||||
"load_checkpoint",
|
||||
]
|
||||
|
||||
|
||||
def test_nonempty_store_is_rejected_before_storage_io() -> None:
|
||||
storage = RecordingCheckpointStorage()
|
||||
store = TradeStreamStateStore()
|
||||
existing_state = store.get_or_create(SYMBOL)
|
||||
hydrator, _ = make_hydrator(storage, state_store=store)
|
||||
|
||||
with pytest.raises(TradeStreamStateStoreInitializationError):
|
||||
hydrator.hydrate(symbols=(SYMBOL, SECOND_SYMBOL))
|
||||
|
||||
assert store.get(SYMBOL) is existing_state
|
||||
assert store.contains(SECOND_SYMBOL) is False
|
||||
assert storage.calls == []
|
||||
@@ -78,12 +78,27 @@ class RecordingTradeObservationSink:
|
||||
) -> None:
|
||||
self.error = error
|
||||
self.observations: list[Trade] = []
|
||||
self.accepted: list[tuple[Trade, Trade | None]] = []
|
||||
self.duplicates: list[Trade] = []
|
||||
|
||||
def persist(
|
||||
def persist_accepted(
|
||||
self,
|
||||
trade: Trade,
|
||||
*,
|
||||
expected_trade: Trade | None,
|
||||
) -> None:
|
||||
self.observations.append(trade)
|
||||
self.accepted.append((trade, expected_trade))
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
def persist_duplicate(
|
||||
self,
|
||||
trade: Trade,
|
||||
) -> None:
|
||||
self.observations.append(trade)
|
||||
self.duplicates.append(trade)
|
||||
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
@@ -221,6 +236,8 @@ def test_persists_trade_before_advancing_checkpoint(
|
||||
|
||||
assert result is trade
|
||||
assert sink.observations == [trade]
|
||||
assert sink.accepted == [(trade, None)]
|
||||
assert sink.duplicates == []
|
||||
assert state.last_trade is trade
|
||||
|
||||
|
||||
@@ -247,6 +264,10 @@ def test_persistence_failure_leaves_checkpoint_unchanged(
|
||||
state = state_store.get(first_trade.symbol)
|
||||
|
||||
assert error_info.value is storage_error
|
||||
assert sink.accepted == [
|
||||
(first_trade, None),
|
||||
(failed_trade, first_trade),
|
||||
]
|
||||
assert state.last_trade is first_trade
|
||||
assert state.last_trade_id == first_trade.trade_id
|
||||
assert failed_trade.trade_id not in state._trades
|
||||
@@ -280,10 +301,59 @@ def test_valid_duplicate_is_persisted_without_checkpoint_advance(
|
||||
websocket_trade,
|
||||
rest_duplicate,
|
||||
]
|
||||
assert sink.accepted == [(websocket_trade, None)]
|
||||
assert sink.duplicates == [rest_duplicate]
|
||||
assert state.last_trade is websocket_trade
|
||||
assert state.last_trade_id == websocket_trade.trade_id
|
||||
|
||||
|
||||
def test_duplicate_persistence_failure_keeps_checkpoint(
|
||||
state_store: TradeStreamStateStore,
|
||||
) -> None:
|
||||
storage_error = RuntimeError("storage failed")
|
||||
sink = RecordingTradeObservationSink()
|
||||
controller = TradeStreamConsistencyController(
|
||||
state_store=state_store,
|
||||
trade_observation_sink=sink,
|
||||
)
|
||||
original = _trade(source="dzengi_websocket_trade")
|
||||
duplicate = _trade(source="dzengi")
|
||||
controller.accept(original)
|
||||
sink.error = storage_error
|
||||
|
||||
with pytest.raises(RuntimeError, match="storage failed") as error_info:
|
||||
controller.accept(duplicate)
|
||||
|
||||
state = state_store.get(original.symbol)
|
||||
|
||||
assert error_info.value is storage_error
|
||||
assert sink.accepted == [(original, None)]
|
||||
assert sink.duplicates == [duplicate]
|
||||
assert state.last_trade is original
|
||||
assert state.last_trade_id == original.trade_id
|
||||
|
||||
|
||||
def test_rollover_advance_uses_previous_trade_as_expected_checkpoint(
|
||||
state_store: TradeStreamStateStore,
|
||||
) -> None:
|
||||
sink = RecordingTradeObservationSink()
|
||||
controller = TradeStreamConsistencyController(
|
||||
state_store=state_store,
|
||||
trade_observation_sink=sink,
|
||||
)
|
||||
previous = _trade(trade_id=2**31 - 1)
|
||||
current = _trade(trade_id=-(2**31))
|
||||
|
||||
controller.accept(previous)
|
||||
controller.accept(current)
|
||||
|
||||
assert sink.accepted == [
|
||||
(previous, None),
|
||||
(current, previous),
|
||||
]
|
||||
assert sink.duplicates == []
|
||||
|
||||
|
||||
def test_invalid_trades_do_not_reach_persistence_sink(
|
||||
state_store: TradeStreamStateStore,
|
||||
) -> None:
|
||||
|
||||
@@ -420,6 +420,88 @@ def test_checkpoint_trade_id_matches_last_trade_id() -> None:
|
||||
assert state.last_trade.trade_id == state.last_trade_id
|
||||
|
||||
|
||||
def test_checkpoint_callback_receives_expected_previous_trade() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
first_trade = _trade(trade_id=100)
|
||||
second_trade = _trade(trade_id=101)
|
||||
calls: list[tuple[Trade, Trade | None]] = []
|
||||
|
||||
def before_checkpoint(
|
||||
trade: Trade,
|
||||
*,
|
||||
expected_trade: Trade | None,
|
||||
) -> None:
|
||||
calls.append((trade, expected_trade))
|
||||
assert state.last_trade is expected_trade
|
||||
|
||||
state.accept(
|
||||
first_trade,
|
||||
before_checkpoint=before_checkpoint,
|
||||
)
|
||||
state.accept(
|
||||
second_trade,
|
||||
before_checkpoint=before_checkpoint,
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
(first_trade, None),
|
||||
(second_trade, first_trade),
|
||||
]
|
||||
assert state.last_trade is second_trade
|
||||
|
||||
|
||||
def test_duplicate_uses_only_duplicate_callback() -> None:
|
||||
state = TradeStreamState(
|
||||
symbol="BTCUSD",
|
||||
deduplication_window_size=3,
|
||||
)
|
||||
first_trade = _trade(trade_id=100)
|
||||
latest_trade = _trade(trade_id=101)
|
||||
duplicate = _trade(
|
||||
trade_id=100,
|
||||
source="dzengi_websocket_trade",
|
||||
)
|
||||
checkpoint_calls: list[tuple[Trade, Trade | None]] = []
|
||||
duplicate_calls: list[Trade] = []
|
||||
|
||||
state.accept(first_trade)
|
||||
state.accept(latest_trade)
|
||||
result = state.accept(
|
||||
duplicate,
|
||||
before_checkpoint=lambda trade, expected_trade: (
|
||||
checkpoint_calls.append((trade, expected_trade))
|
||||
),
|
||||
on_duplicate=duplicate_calls.append,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert checkpoint_calls == []
|
||||
assert duplicate_calls == [duplicate]
|
||||
assert state.last_trade is latest_trade
|
||||
|
||||
|
||||
def test_duplicate_callback_failure_keeps_checkpoint() -> None:
|
||||
state = TradeStreamState(symbol="BTCUSD")
|
||||
original = _trade(source="dzengi_websocket_trade")
|
||||
duplicate = _trade(source="dzengi")
|
||||
storage_error = RuntimeError("storage failed")
|
||||
state.accept(original)
|
||||
|
||||
def fail_duplicate(trade: Trade) -> None:
|
||||
assert trade is duplicate
|
||||
raise storage_error
|
||||
|
||||
with pytest.raises(RuntimeError, match="storage failed") as error_info:
|
||||
state.accept(
|
||||
duplicate,
|
||||
on_duplicate=fail_duplicate,
|
||||
)
|
||||
|
||||
assert error_info.value is storage_error
|
||||
assert state.last_trade is original
|
||||
assert state.last_trade_id == original.trade_id
|
||||
|
||||
|
||||
def test_rejects_empty_symbol() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
TradeStreamState(symbol="")
|
||||
@@ -440,3 +522,184 @@ def test_rejects_non_positive_window_size(
|
||||
symbol="BTCUSD",
|
||||
deduplication_window_size=window_size,
|
||||
)
|
||||
|
||||
|
||||
def test_from_history_builds_empty_state() -> None:
|
||||
state = TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(),
|
||||
deduplication_window_size=3,
|
||||
)
|
||||
|
||||
assert state.symbol == "BTCUSD"
|
||||
assert state.deduplication_window_size == 3
|
||||
assert state.last_trade_id is None
|
||||
assert state.last_trade is None
|
||||
|
||||
|
||||
def test_from_history_restores_valid_deduplication_window() -> None:
|
||||
first_trade = _trade(trade_id=100)
|
||||
second_trade = _trade(trade_id=101)
|
||||
last_trade = _trade(trade_id=102)
|
||||
|
||||
state = TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(
|
||||
first_trade,
|
||||
second_trade,
|
||||
last_trade,
|
||||
),
|
||||
deduplication_window_size=3,
|
||||
)
|
||||
|
||||
assert state.last_trade is last_trade
|
||||
assert state.last_trade_id == last_trade.trade_id
|
||||
assert state.accept(_trade(trade_id=100)) is None
|
||||
assert state.last_trade is last_trade
|
||||
|
||||
|
||||
def test_from_history_keeps_exact_window_boundary() -> None:
|
||||
first_trade = _trade(trade_id=100)
|
||||
second_trade = _trade(trade_id=101)
|
||||
third_trade = _trade(trade_id=102)
|
||||
|
||||
state = TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(
|
||||
first_trade,
|
||||
second_trade,
|
||||
third_trade,
|
||||
),
|
||||
deduplication_window_size=3,
|
||||
)
|
||||
|
||||
next_trade = _trade(trade_id=103)
|
||||
|
||||
assert state.accept(next_trade) is next_trade
|
||||
assert state.accept(_trade(trade_id=101)) is None
|
||||
|
||||
with pytest.raises(TradeOrderingError):
|
||||
state.accept(_trade(trade_id=100))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("first_trade_id", "next_trade_id"),
|
||||
(
|
||||
(2**31 - 1, -(2**31)),
|
||||
(-1, 0),
|
||||
),
|
||||
)
|
||||
def test_from_history_restores_signed_rollover_sequence(
|
||||
first_trade_id: int,
|
||||
next_trade_id: int,
|
||||
) -> None:
|
||||
first_trade = _trade(trade_id=first_trade_id)
|
||||
next_trade = _trade(trade_id=next_trade_id)
|
||||
|
||||
state = TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(first_trade, next_trade),
|
||||
deduplication_window_size=2,
|
||||
)
|
||||
|
||||
assert state.last_trade is next_trade
|
||||
assert state.last_trade_id == next_trade_id
|
||||
assert state.accept(_trade(trade_id=first_trade_id)) is None
|
||||
|
||||
|
||||
def test_from_history_accepts_next_trade_after_restoration() -> None:
|
||||
last_history_trade = _trade(trade_id=101)
|
||||
state = TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(
|
||||
_trade(trade_id=100),
|
||||
last_history_trade,
|
||||
),
|
||||
deduplication_window_size=3,
|
||||
)
|
||||
next_trade = _trade(trade_id=102)
|
||||
|
||||
result = state.accept(next_trade)
|
||||
|
||||
assert result is next_trade
|
||||
assert state.last_trade is next_trade
|
||||
assert state.last_trade_id == next_trade.trade_id
|
||||
|
||||
|
||||
def test_from_history_rejects_identical_duplicate_strictly() -> None:
|
||||
original = _trade(trade_id=100)
|
||||
duplicate = _trade(
|
||||
trade_id=100,
|
||||
source="dzengi_websocket_trade",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
TradeConsistencyError,
|
||||
match="duplicate Trade",
|
||||
):
|
||||
TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(original, duplicate),
|
||||
deduplication_window_size=2,
|
||||
)
|
||||
|
||||
|
||||
def test_from_history_rejects_conflicting_trade_id() -> None:
|
||||
original = _trade(
|
||||
trade_id=100,
|
||||
price=Decimal("50000.00"),
|
||||
)
|
||||
conflict = _trade(
|
||||
trade_id=100,
|
||||
price=Decimal("50001.00"),
|
||||
)
|
||||
|
||||
with pytest.raises(TradeConsistencyError):
|
||||
TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(original, conflict),
|
||||
deduplication_window_size=2,
|
||||
)
|
||||
|
||||
|
||||
def test_from_history_rejects_reverse_sequence() -> None:
|
||||
with pytest.raises(TradeOrderingError):
|
||||
TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(
|
||||
_trade(trade_id=101),
|
||||
_trade(trade_id=100),
|
||||
),
|
||||
deduplication_window_size=2,
|
||||
)
|
||||
|
||||
|
||||
def test_from_history_rejects_half_cycle_sequence() -> None:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="exactly half",
|
||||
):
|
||||
TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(
|
||||
_trade(trade_id=0),
|
||||
_trade(trade_id=-(2**31)),
|
||||
),
|
||||
deduplication_window_size=2,
|
||||
)
|
||||
|
||||
|
||||
def test_from_history_rejects_history_larger_than_window() -> None:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="fit deduplication_window_size",
|
||||
):
|
||||
TradeStreamState.from_history(
|
||||
symbol="BTCUSD",
|
||||
trades=(
|
||||
_trade(trade_id=100),
|
||||
_trade(trade_id=101),
|
||||
_trade(trade_id=102),
|
||||
),
|
||||
deduplication_window_size=2,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||
@@ -12,6 +14,7 @@ from src.market_data.acquisition.consistency.trade_stream_state_store import (
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
|
||||
TradeStreamStateNotFoundError,
|
||||
TradeStreamStateStoreInitializationError,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
||||
TradeStreamStateStoreProtocol,
|
||||
@@ -140,4 +143,106 @@ def test_clear_is_idempotent_for_empty_store() -> None:
|
||||
store.clear()
|
||||
store.clear()
|
||||
|
||||
assert store.contains("BTCUSD") is False
|
||||
assert store.contains("BTCUSD") is False
|
||||
|
||||
|
||||
def test_initialize_publishes_all_states_preserving_identity() -> None:
|
||||
store = TradeStreamStateStore()
|
||||
btc_state = TradeStreamState(symbol="BTCUSD")
|
||||
eth_state = TradeStreamState(symbol="ETHUSD")
|
||||
|
||||
store.initialize((btc_state, eth_state))
|
||||
|
||||
assert store.get("BTCUSD") is btc_state
|
||||
assert store.get("ETHUSD") is eth_state
|
||||
|
||||
|
||||
def test_initialize_rejects_duplicate_symbols_atomically() -> None:
|
||||
store = TradeStreamStateStore()
|
||||
|
||||
with pytest.raises(
|
||||
TradeStreamStateStoreInitializationError,
|
||||
match="повторно",
|
||||
):
|
||||
store.initialize(
|
||||
(
|
||||
TradeStreamState(symbol="BTCUSD"),
|
||||
TradeStreamState(symbol="BTCUSD"),
|
||||
)
|
||||
)
|
||||
|
||||
assert store.contains("BTCUSD") is False
|
||||
|
||||
|
||||
def test_initialize_rejects_invalid_item_atomically() -> None:
|
||||
store = TradeStreamStateStore()
|
||||
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match="TradeStreamState",
|
||||
):
|
||||
store.initialize(
|
||||
cast(
|
||||
tuple[TradeStreamState, ...],
|
||||
(
|
||||
TradeStreamState(symbol="BTCUSD"),
|
||||
object(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert store.contains("BTCUSD") is False
|
||||
|
||||
|
||||
def test_initialize_rejects_non_empty_store() -> None:
|
||||
store = TradeStreamStateStore()
|
||||
existing_state = store.get_or_create("BTCUSD")
|
||||
|
||||
with pytest.raises(
|
||||
TradeStreamStateStoreInitializationError,
|
||||
match="уже содержит",
|
||||
):
|
||||
store.initialize(
|
||||
(TradeStreamState(symbol="ETHUSD"),)
|
||||
)
|
||||
|
||||
assert store.get("BTCUSD") is existing_state
|
||||
assert store.contains("ETHUSD") is False
|
||||
|
||||
|
||||
def test_initialize_can_be_called_only_once() -> None:
|
||||
store = TradeStreamStateStore()
|
||||
original_state = TradeStreamState(symbol="BTCUSD")
|
||||
store.initialize((original_state,))
|
||||
|
||||
with pytest.raises(
|
||||
TradeStreamStateStoreInitializationError,
|
||||
match="уже содержит",
|
||||
):
|
||||
store.initialize(
|
||||
(TradeStreamState(symbol="ETHUSD"),)
|
||||
)
|
||||
|
||||
assert store.get("BTCUSD") is original_state
|
||||
assert store.contains("ETHUSD") is False
|
||||
|
||||
|
||||
def test_empty_initialize_is_still_one_time_initialization() -> None:
|
||||
store = TradeStreamStateStore()
|
||||
store.initialize(())
|
||||
|
||||
with pytest.raises(TradeStreamStateStoreInitializationError):
|
||||
store.initialize(())
|
||||
|
||||
|
||||
def test_clear_allows_store_to_be_initialized_again() -> None:
|
||||
store = TradeStreamStateStore()
|
||||
store.initialize((TradeStreamState(symbol="BTCUSD"),))
|
||||
|
||||
store.clear()
|
||||
|
||||
eth_state = TradeStreamState(symbol="ETHUSD")
|
||||
store.initialize((eth_state,))
|
||||
|
||||
assert store.contains("BTCUSD") is False
|
||||
assert store.get("ETHUSD") is eth_state
|
||||
|
||||
@@ -266,8 +266,8 @@ def test_reconnect_restore_boundary_and_recovery_order() -> None:
|
||||
order,
|
||||
) = create_coordinator(
|
||||
symbols=(
|
||||
f" {ETH} ",
|
||||
BTC,
|
||||
f" {ETH.lower()} ",
|
||||
BTC.lower(),
|
||||
ETH,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -119,6 +119,18 @@ class FakeStateStore:
|
||||
self.remove_calls: list[str] = []
|
||||
self.clear_calls = 0
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
states: tuple[TradeStreamState, ...],
|
||||
) -> None:
|
||||
self._states = {
|
||||
state.symbol: state
|
||||
for state in states
|
||||
}
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not self._states
|
||||
|
||||
def get_or_create(
|
||||
self,
|
||||
symbol: str,
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||
TradeStreamState,
|
||||
)
|
||||
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||
TradeRecoveryResult,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.live_processing_gate import (
|
||||
RuntimeLiveProcessingGate,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_startup_recovery_coordinator import (
|
||||
RuntimeStartupRecoveryCoordinator,
|
||||
RuntimeStartupRecoveryProtocol,
|
||||
)
|
||||
|
||||
|
||||
BTC = "BTC/USD_LEVERAGE"
|
||||
ETH = "ETH/USD_LEVERAGE"
|
||||
RECOVERY_END_TIME_MS = 1_785_326_405_123
|
||||
HYDRATION_TASK_NAME = "trade-stream-state-hydration"
|
||||
RECOVERY_TASK_NAME = "trade-stream-startup-recovery"
|
||||
|
||||
|
||||
class FakeStateHydrator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
states: tuple[TradeStreamState, ...],
|
||||
error: Exception | None = None,
|
||||
started: threading.Event | None = None,
|
||||
release: threading.Event | None = None,
|
||||
) -> None:
|
||||
self._states = states
|
||||
self._error = error
|
||||
self._started = started
|
||||
self._release = release
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
self.thread_ids: list[int] = []
|
||||
|
||||
def hydrate(
|
||||
self,
|
||||
*,
|
||||
symbols: tuple[str, ...],
|
||||
) -> tuple[TradeStreamState, ...]:
|
||||
self.calls.append(symbols)
|
||||
self.thread_ids.append(threading.get_ident())
|
||||
|
||||
if self._started is not None:
|
||||
self._started.set()
|
||||
|
||||
if self._release is not None and not self._release.wait(
|
||||
timeout=2.0,
|
||||
):
|
||||
raise AssertionError("hydration release was not signalled")
|
||||
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
return self._states
|
||||
|
||||
|
||||
class FakeRecoveryCoordinator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
error: Exception | None = None,
|
||||
started: threading.Event | None = None,
|
||||
release: threading.Event | None = None,
|
||||
) -> None:
|
||||
self._error = error
|
||||
self._started = started
|
||||
self._release = release
|
||||
self.calls: list[tuple[str, int]] = []
|
||||
self.thread_ids: list[int] = []
|
||||
|
||||
def recover(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
recovery_end_time: int,
|
||||
) -> TradeRecoveryResult:
|
||||
self.calls.append(
|
||||
(
|
||||
symbol,
|
||||
recovery_end_time,
|
||||
)
|
||||
)
|
||||
self.thread_ids.append(threading.get_ident())
|
||||
|
||||
if self._started is not None:
|
||||
self._started.set()
|
||||
|
||||
if self._release is not None and not self._release.wait(
|
||||
timeout=2.0,
|
||||
):
|
||||
raise AssertionError("recovery release was not signalled")
|
||||
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
return TradeRecoveryResult(
|
||||
symbol=symbol,
|
||||
requested_start_time=recovery_end_time,
|
||||
requested_end_time=recovery_end_time,
|
||||
recovered_trades=(),
|
||||
)
|
||||
|
||||
|
||||
class RecordingClock:
|
||||
def __init__(
|
||||
self,
|
||||
value: object = RECOVERY_END_TIME_MS,
|
||||
) -> None:
|
||||
self._value = value
|
||||
self.calls = 0
|
||||
|
||||
def __call__(self) -> int:
|
||||
self.calls += 1
|
||||
return self._value # type: ignore[return-value]
|
||||
|
||||
|
||||
def create_coordinator(
|
||||
*,
|
||||
symbols: tuple[str, ...] = (BTC,),
|
||||
states: tuple[TradeStreamState, ...] | None = None,
|
||||
hydration_error: Exception | None = None,
|
||||
hydration_started: threading.Event | None = None,
|
||||
hydration_release: threading.Event | None = None,
|
||||
recovery_error: Exception | None = None,
|
||||
recovery_started: threading.Event | None = None,
|
||||
recovery_release: threading.Event | None = None,
|
||||
clock_value: object = RECOVERY_END_TIME_MS,
|
||||
) -> tuple[
|
||||
RuntimeStartupRecoveryCoordinator,
|
||||
FakeStateHydrator,
|
||||
FakeRecoveryCoordinator,
|
||||
RuntimeLiveProcessingGate,
|
||||
RecordingClock,
|
||||
]:
|
||||
hydrated_states = states or (
|
||||
TradeStreamState(symbol=BTC),
|
||||
)
|
||||
hydrator = FakeStateHydrator(
|
||||
states=hydrated_states,
|
||||
error=hydration_error,
|
||||
started=hydration_started,
|
||||
release=hydration_release,
|
||||
)
|
||||
recovery = FakeRecoveryCoordinator(
|
||||
error=recovery_error,
|
||||
started=recovery_started,
|
||||
release=recovery_release,
|
||||
)
|
||||
gate = RuntimeLiveProcessingGate()
|
||||
clock = RecordingClock(clock_value)
|
||||
coordinator = RuntimeStartupRecoveryCoordinator(
|
||||
state_hydrator=hydrator,
|
||||
recovery_coordinator=recovery,
|
||||
live_processing_gate=gate,
|
||||
symbols=symbols,
|
||||
clock=clock,
|
||||
)
|
||||
return coordinator, hydrator, recovery, gate, clock
|
||||
|
||||
|
||||
async def wait_until(
|
||||
predicate: object,
|
||||
) -> None:
|
||||
for _ in range(100):
|
||||
if callable(predicate) and predicate():
|
||||
return
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
raise AssertionError("condition was not reached")
|
||||
|
||||
|
||||
def test_implements_protocol_uses_slots_and_constructor_has_no_io() -> None:
|
||||
coordinator, hydrator, recovery, gate, clock = create_coordinator(
|
||||
symbols=(
|
||||
f" {ETH.lower()} ",
|
||||
BTC.lower(),
|
||||
ETH,
|
||||
),
|
||||
)
|
||||
|
||||
assert isinstance(coordinator, RuntimeStartupRecoveryProtocol)
|
||||
assert not hasattr(coordinator, "__dict__")
|
||||
assert coordinator.live_processing_gate is gate
|
||||
assert coordinator.symbols == (BTC, ETH)
|
||||
assert hydrator.calls == []
|
||||
assert recovery.calls == []
|
||||
assert clock.calls == 0
|
||||
assert gate.locked is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("symbols", "error_type"),
|
||||
[
|
||||
([], TypeError),
|
||||
((), ValueError),
|
||||
(("", " "), ValueError),
|
||||
((BTC, 1), TypeError),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_symbols(
|
||||
symbols: object,
|
||||
error_type: type[Exception],
|
||||
) -> None:
|
||||
with pytest.raises(error_type):
|
||||
create_coordinator(
|
||||
symbols=symbols, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_hydration_is_idempotent_and_runs_off_event_loop() -> None:
|
||||
async def scenario() -> tuple[
|
||||
tuple[TradeStreamState, ...],
|
||||
tuple[TradeStreamState, ...],
|
||||
int,
|
||||
]:
|
||||
states = (
|
||||
TradeStreamState(symbol=BTC),
|
||||
)
|
||||
coordinator, hydrator, *_ = create_coordinator(
|
||||
states=states,
|
||||
)
|
||||
event_loop_thread_id = threading.get_ident()
|
||||
|
||||
first = await coordinator.hydrate_once()
|
||||
second = await coordinator.hydrate_once()
|
||||
|
||||
assert hydrator.calls == [(BTC,)]
|
||||
assert len(hydrator.thread_ids) == 1
|
||||
return first, second, event_loop_thread_id
|
||||
|
||||
first, second, event_loop_thread_id = asyncio.run(scenario())
|
||||
|
||||
assert first is second
|
||||
assert first[0].symbol == BTC
|
||||
coordinator, hydrator, *_ = create_coordinator()
|
||||
asyncio.run(coordinator.hydrate_once())
|
||||
assert hydrator.thread_ids[0] != event_loop_thread_id
|
||||
|
||||
|
||||
def test_recovery_uses_one_clock_boundary_and_symbol_order() -> None:
|
||||
async def scenario() -> tuple[
|
||||
tuple[TradeRecoveryResult, ...],
|
||||
FakeRecoveryCoordinator,
|
||||
RecordingClock,
|
||||
int,
|
||||
]:
|
||||
coordinator, _, recovery, gate, clock = create_coordinator(
|
||||
symbols=(
|
||||
f" {ETH.lower()} ",
|
||||
BTC.lower(),
|
||||
ETH,
|
||||
),
|
||||
)
|
||||
event_loop_thread_id = threading.get_ident()
|
||||
|
||||
async with gate:
|
||||
results = await coordinator.recover_after_ack()
|
||||
|
||||
return results, recovery, clock, event_loop_thread_id
|
||||
|
||||
results, recovery, clock, event_loop_thread_id = asyncio.run(
|
||||
scenario()
|
||||
)
|
||||
|
||||
assert tuple(result.symbol for result in results) == (BTC, ETH)
|
||||
assert recovery.calls == [
|
||||
(BTC, RECOVERY_END_TIME_MS),
|
||||
(ETH, RECOVERY_END_TIME_MS),
|
||||
]
|
||||
assert clock.calls == 1
|
||||
assert len(set(recovery.thread_ids)) == 1
|
||||
assert recovery.thread_ids[0] != event_loop_thread_id
|
||||
|
||||
|
||||
def test_cancelled_hydration_waits_and_caches_success() -> None:
|
||||
async def scenario() -> tuple[
|
||||
RuntimeStartupRecoveryCoordinator,
|
||||
FakeStateHydrator,
|
||||
tuple[TradeStreamState, ...],
|
||||
]:
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
states = (
|
||||
TradeStreamState(symbol=BTC),
|
||||
)
|
||||
coordinator, hydrator, *_ = create_coordinator(
|
||||
states=states,
|
||||
hydration_started=started,
|
||||
hydration_release=release,
|
||||
)
|
||||
task = asyncio.create_task(
|
||||
coordinator.hydrate_once(),
|
||||
)
|
||||
await wait_until(started.is_set)
|
||||
joined_task = asyncio.create_task(
|
||||
coordinator.hydrate_once(),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
assert task.done() is False
|
||||
assert joined_task.done() is False
|
||||
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
cached = await joined_task
|
||||
assert await coordinator.hydrate_once() is cached
|
||||
return coordinator, hydrator, cached
|
||||
|
||||
coordinator, hydrator, cached = asyncio.run(scenario())
|
||||
|
||||
assert cached[0].symbol == BTC
|
||||
assert hydrator.calls == [(BTC,)]
|
||||
assert coordinator._hydration_task is None
|
||||
|
||||
|
||||
def test_cancelled_recovery_waits_for_worker_completion() -> None:
|
||||
async def scenario() -> tuple[
|
||||
RuntimeStartupRecoveryCoordinator,
|
||||
FakeRecoveryCoordinator,
|
||||
]:
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
coordinator, _, recovery, gate, _ = create_coordinator(
|
||||
recovery_started=started,
|
||||
recovery_release=release,
|
||||
)
|
||||
|
||||
async def run_recovery() -> None:
|
||||
async with gate:
|
||||
await coordinator.recover_after_ack()
|
||||
|
||||
task = asyncio.create_task(run_recovery())
|
||||
await wait_until(started.is_set)
|
||||
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
assert task.done() is False
|
||||
assert gate.locked is True
|
||||
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert gate.locked is False
|
||||
return coordinator, recovery
|
||||
|
||||
coordinator, recovery = asyncio.run(scenario())
|
||||
|
||||
assert recovery.calls == [(BTC, RECOVERY_END_TIME_MS)]
|
||||
assert coordinator._recovery_task is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operation",
|
||||
[
|
||||
"hydration",
|
||||
"recovery",
|
||||
],
|
||||
)
|
||||
def test_worker_error_identity_is_preserved(
|
||||
operation: str,
|
||||
) -> None:
|
||||
error = RuntimeError(f"{operation} failed")
|
||||
|
||||
async def scenario() -> RuntimeStartupRecoveryCoordinator:
|
||||
coordinator, *_ = create_coordinator(
|
||||
hydration_error=(
|
||||
error if operation == "hydration" else None
|
||||
),
|
||||
recovery_error=(
|
||||
error if operation == "recovery" else None
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as raised:
|
||||
if operation == "hydration":
|
||||
await coordinator.hydrate_once()
|
||||
else:
|
||||
await coordinator.recover_after_ack()
|
||||
|
||||
assert raised.value is error
|
||||
return coordinator
|
||||
|
||||
coordinator = asyncio.run(scenario())
|
||||
|
||||
assert coordinator._hydration_task is None
|
||||
assert coordinator._recovery_task is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("clock_value", "error_type"),
|
||||
[
|
||||
(True, TypeError),
|
||||
(1.5, TypeError),
|
||||
("1", TypeError),
|
||||
(-1, ValueError),
|
||||
],
|
||||
)
|
||||
def test_invalid_clock_result_fails_before_recovery(
|
||||
clock_value: object,
|
||||
error_type: type[Exception],
|
||||
) -> None:
|
||||
async def scenario() -> tuple[
|
||||
FakeRecoveryCoordinator,
|
||||
RecordingClock,
|
||||
RuntimeStartupRecoveryCoordinator,
|
||||
]:
|
||||
coordinator, _, recovery, _, clock = create_coordinator(
|
||||
clock_value=clock_value,
|
||||
)
|
||||
|
||||
with pytest.raises(error_type):
|
||||
await coordinator.recover_after_ack()
|
||||
|
||||
return recovery, clock, coordinator
|
||||
|
||||
recovery, clock, coordinator = asyncio.run(scenario())
|
||||
|
||||
assert recovery.calls == []
|
||||
assert clock.calls == 1
|
||||
assert coordinator._recovery_task is None
|
||||
|
||||
|
||||
def test_completed_operations_leave_no_owned_tasks() -> None:
|
||||
async def scenario() -> None:
|
||||
coordinator, *_ = create_coordinator()
|
||||
|
||||
await coordinator.hydrate_once()
|
||||
await coordinator.recover_after_ack()
|
||||
|
||||
pending_owned_tasks = {
|
||||
task.get_name()
|
||||
for task in asyncio.all_tasks()
|
||||
if task is not asyncio.current_task()
|
||||
and not task.done()
|
||||
and task.get_name()
|
||||
in {
|
||||
HYDRATION_TASK_NAME,
|
||||
RECOVERY_TASK_NAME,
|
||||
}
|
||||
}
|
||||
assert pending_owned_tasks == set()
|
||||
assert coordinator._hydration_task is None
|
||||
assert coordinator._recovery_task is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -24,10 +24,15 @@ from src.market_data.acquisition.consistency.trade_stream_consistency_controller
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store import (
|
||||
TradeStreamStateStore,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||
TradeStreamState,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
WebSocketControlMessageError,
|
||||
WebSocketMessageDecodeError,
|
||||
WebSocketMessageRoutingError,
|
||||
WebSocketStartupMarketBufferOverflowError,
|
||||
WebSocketSubscriptionAckTimeoutError,
|
||||
WebSocketTransportError,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import Trade
|
||||
@@ -37,6 +42,9 @@ from src.market_data.acquisition.runtime.runtime_events import (
|
||||
DisconnectedEvent,
|
||||
MessageReceivedEvent,
|
||||
)
|
||||
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||
TradeRecoveryResult,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.live_processing_gate import (
|
||||
RuntimeLiveProcessingGate,
|
||||
)
|
||||
@@ -66,6 +74,7 @@ from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
from src.market_data.acquisition.trade_stream_acquisition_service import (
|
||||
TradeStreamAcquisitionService,
|
||||
)
|
||||
from src.market_data.acquisition.symbols import normalize_symbol
|
||||
|
||||
|
||||
SYMBOL = "BTC/USD_LEVERAGE"
|
||||
@@ -153,6 +162,8 @@ class FakeTransport:
|
||||
self._incoming = deque(incoming)
|
||||
self._message_available = asyncio.Event()
|
||||
self.receive_calls = 0
|
||||
self.active_receivers = 0
|
||||
self.max_active_receivers = 0
|
||||
self.probe_calls = 0
|
||||
self.waiting = asyncio.Event()
|
||||
|
||||
@@ -171,24 +182,32 @@ class FakeTransport:
|
||||
async def receive(self) -> str | bytes:
|
||||
self.receive_calls += 1
|
||||
self._calls.append("transport.receive")
|
||||
self.active_receivers += 1
|
||||
self.max_active_receivers = max(
|
||||
self.max_active_receivers,
|
||||
self.active_receivers,
|
||||
)
|
||||
|
||||
try:
|
||||
while not self._incoming:
|
||||
self.waiting.set()
|
||||
await self._message_available.wait()
|
||||
self._message_available.clear()
|
||||
except asyncio.CancelledError:
|
||||
self._calls.append(
|
||||
"transport.receive.cancelled",
|
||||
)
|
||||
raise
|
||||
try:
|
||||
while not self._incoming:
|
||||
self.waiting.set()
|
||||
await self._message_available.wait()
|
||||
self._message_available.clear()
|
||||
except asyncio.CancelledError:
|
||||
self._calls.append(
|
||||
"transport.receive.cancelled",
|
||||
)
|
||||
raise
|
||||
|
||||
result = self._incoming.popleft()
|
||||
result = self._incoming.popleft()
|
||||
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
|
||||
return result
|
||||
return result
|
||||
finally:
|
||||
self.active_receivers -= 1
|
||||
|
||||
def feed(
|
||||
self,
|
||||
@@ -302,9 +321,9 @@ class FakeReconnectRecoveryCoordinator:
|
||||
self._symbols = tuple(
|
||||
sorted(
|
||||
{
|
||||
symbol.strip()
|
||||
normalize_symbol(symbol)
|
||||
for symbol in valid_symbols
|
||||
if symbol.strip()
|
||||
if normalize_symbol(symbol)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -369,6 +388,80 @@ class FakeReconnectRecoveryCoordinator:
|
||||
raise self._error
|
||||
|
||||
|
||||
class FakeStartupRecoveryCoordinator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
calls: list[str],
|
||||
live_processing_gate: RuntimeLiveProcessingGate,
|
||||
symbols: tuple[str, ...],
|
||||
hydration_error: Exception | None = None,
|
||||
recovery_error: Exception | None = None,
|
||||
hydration_release: asyncio.Event | None = None,
|
||||
recovery_release: asyncio.Event | None = None,
|
||||
) -> None:
|
||||
self._calls = calls
|
||||
self._live_processing_gate = live_processing_gate
|
||||
self._symbols = tuple(
|
||||
sorted(
|
||||
{
|
||||
normalize_symbol(symbol)
|
||||
for symbol in symbols
|
||||
if normalize_symbol(symbol)
|
||||
}
|
||||
)
|
||||
)
|
||||
self._hydration_error = hydration_error
|
||||
self._recovery_error = recovery_error
|
||||
self._hydration_release = hydration_release
|
||||
self._recovery_release = recovery_release
|
||||
self.hydration_entered = asyncio.Event()
|
||||
self.recovery_entered = asyncio.Event()
|
||||
self.hydrate_calls = 0
|
||||
self.recover_calls = 0
|
||||
|
||||
@property
|
||||
def live_processing_gate(
|
||||
self,
|
||||
) -> RuntimeLiveProcessingGate:
|
||||
return self._live_processing_gate
|
||||
|
||||
@property
|
||||
def symbols(self) -> tuple[str, ...]:
|
||||
return self._symbols
|
||||
|
||||
async def hydrate_once(self) -> tuple[TradeStreamState, ...]:
|
||||
self.hydrate_calls += 1
|
||||
self._calls.append("startup_recovery.hydrate")
|
||||
self.hydration_entered.set()
|
||||
|
||||
if self._hydration_release is not None:
|
||||
await self._hydration_release.wait()
|
||||
|
||||
if self._hydration_error is not None:
|
||||
raise self._hydration_error
|
||||
|
||||
return ()
|
||||
|
||||
async def recover_after_ack(self) -> tuple[TradeRecoveryResult, ...]:
|
||||
self.recover_calls += 1
|
||||
self._calls.append("startup_recovery.recover")
|
||||
self.recovery_entered.set()
|
||||
|
||||
if not self._live_processing_gate.locked:
|
||||
raise AssertionError(
|
||||
"Production Runtime должен удерживать startup gate."
|
||||
)
|
||||
|
||||
if self._recovery_release is not None:
|
||||
await self._recovery_release.wait()
|
||||
|
||||
if self._recovery_error is not None:
|
||||
raise self._recovery_error
|
||||
|
||||
return ()
|
||||
|
||||
|
||||
class FakeTradeStreamService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -623,6 +716,15 @@ class RuntimeDependencies:
|
||||
scheduler_uses_different_transport: bool = False,
|
||||
symbols: tuple[str, ...] = (SYMBOL,),
|
||||
recovery_symbols: tuple[str, ...] | None = None,
|
||||
startup_recovery_enabled: bool = False,
|
||||
startup_hydration_error: Exception | None = None,
|
||||
startup_recovery_error: Exception | None = None,
|
||||
startup_hydration_release: asyncio.Event | None = None,
|
||||
startup_recovery_release: asyncio.Event | None = None,
|
||||
startup_recovery_symbols: tuple[str, ...] | None = None,
|
||||
startup_recovery_uses_different_gate: bool = False,
|
||||
subscription_ack_timeout_seconds: float = 10.0,
|
||||
startup_market_buffer_capacity: int = 10_000,
|
||||
) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.session = FakeSession(
|
||||
@@ -663,6 +765,27 @@ class RuntimeDependencies:
|
||||
else recovery_symbols
|
||||
),
|
||||
)
|
||||
self.startup_recovery = (
|
||||
FakeStartupRecoveryCoordinator(
|
||||
calls=self.calls,
|
||||
live_processing_gate=(
|
||||
RuntimeLiveProcessingGate()
|
||||
if startup_recovery_uses_different_gate
|
||||
else self.live_processing_gate
|
||||
),
|
||||
symbols=(
|
||||
symbols
|
||||
if startup_recovery_symbols is None
|
||||
else startup_recovery_symbols
|
||||
),
|
||||
hydration_error=startup_hydration_error,
|
||||
recovery_error=startup_recovery_error,
|
||||
hydration_release=startup_hydration_release,
|
||||
recovery_release=startup_recovery_release,
|
||||
)
|
||||
if startup_recovery_enabled
|
||||
else None
|
||||
)
|
||||
self.supervisor = FakeRuntimeSupervisor(
|
||||
calls=self.calls,
|
||||
start_error=supervisor_start_error,
|
||||
@@ -711,6 +834,13 @@ class RuntimeDependencies:
|
||||
runtime_supervisor=self.supervisor,
|
||||
runtime_scheduler=self.scheduler,
|
||||
symbols=symbols,
|
||||
startup_recovery_coordinator=self.startup_recovery,
|
||||
subscription_ack_timeout_seconds=(
|
||||
subscription_ack_timeout_seconds
|
||||
),
|
||||
startup_market_buffer_capacity=(
|
||||
startup_market_buffer_capacity
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -773,6 +903,21 @@ def test_rejects_invalid_symbols(
|
||||
)
|
||||
|
||||
|
||||
def test_canonicalizes_case_variants_across_runtime_graph() -> None:
|
||||
dependencies = RuntimeDependencies(
|
||||
symbols=(
|
||||
f" {SYMBOL.lower()} ",
|
||||
SYMBOL,
|
||||
),
|
||||
startup_recovery_enabled=True,
|
||||
)
|
||||
|
||||
assert dependencies.runtime._symbols == (SYMBOL,)
|
||||
assert dependencies.reconnect_recovery.symbols == (SYMBOL,)
|
||||
assert dependencies.startup_recovery is not None
|
||||
assert dependencies.startup_recovery.symbols == (SYMBOL,)
|
||||
|
||||
|
||||
def test_rejects_symbols_different_from_recovery_coordinator() -> None:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
@@ -804,6 +949,577 @@ def test_rejects_scheduler_with_different_transport() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_startup_recovery_with_different_gate() -> None:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="startup recovery coordinator must share one",
|
||||
):
|
||||
RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
startup_recovery_uses_different_gate=True,
|
||||
)
|
||||
|
||||
|
||||
def test_rejects_startup_recovery_with_different_symbols() -> None:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="startup recovery coordinator must use the same symbols",
|
||||
):
|
||||
RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
startup_recovery_symbols=(ETH_SYMBOL,),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("timeout", "error_type"),
|
||||
[
|
||||
(True, TypeError),
|
||||
("10", TypeError),
|
||||
(0.0, ValueError),
|
||||
(-1.0, ValueError),
|
||||
(float("inf"), ValueError),
|
||||
(float("nan"), ValueError),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_subscription_ack_timeout(
|
||||
timeout: object,
|
||||
error_type: type[Exception],
|
||||
) -> None:
|
||||
with pytest.raises(error_type):
|
||||
RuntimeDependencies(
|
||||
subscription_ack_timeout_seconds=timeout, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capacity", "error_type"),
|
||||
[
|
||||
(True, TypeError),
|
||||
(1.0, TypeError),
|
||||
(0, ValueError),
|
||||
(-1, ValueError),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_startup_market_buffer_capacity(
|
||||
capacity: object,
|
||||
error_type: type[Exception],
|
||||
) -> None:
|
||||
with pytest.raises(error_type):
|
||||
RuntimeDependencies(
|
||||
startup_market_buffer_capacity=capacity, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_startup_recovery_preserves_boundary_order_and_one_receiver() -> None:
|
||||
first_document = {
|
||||
"destination": "internal.trade",
|
||||
"payload": {
|
||||
"symbol": SYMBOL,
|
||||
"sequence": 1,
|
||||
},
|
||||
}
|
||||
second_document = {
|
||||
"destination": "internal.trade",
|
||||
"payload": {
|
||||
"symbol": SYMBOL,
|
||||
"sequence": 2,
|
||||
},
|
||||
}
|
||||
post_ack_document = {
|
||||
"destination": "internal.trade",
|
||||
"payload": {
|
||||
"symbol": SYMBOL,
|
||||
"sequence": 3,
|
||||
},
|
||||
}
|
||||
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
recovery_release = asyncio.Event()
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
startup_recovery_release=recovery_release,
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
|
||||
await dependencies.service.subscribe_entered.wait()
|
||||
correlation_id = (
|
||||
dependencies.service.subscribe_correlation_ids[0]
|
||||
)
|
||||
assert isinstance(correlation_id, str)
|
||||
|
||||
dependencies.transport.feed(json.dumps(first_document))
|
||||
dependencies.transport.feed(json.dumps(second_document))
|
||||
dependencies.transport.feed(
|
||||
make_control_message(
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
)
|
||||
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
assert startup_recovery is not None
|
||||
await startup_recovery.recovery_entered.wait()
|
||||
|
||||
assert dependencies.live_processing_gate.locked is True
|
||||
assert dependencies.service.documents == []
|
||||
assert dependencies.transport.receive_calls == 3
|
||||
assert dependencies.supervisor.start_calls == 0
|
||||
assert dependencies.scheduler.start_calls == 0
|
||||
|
||||
dependencies.transport.feed(
|
||||
json.dumps(post_ack_document),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert dependencies.transport.receive_calls == 3
|
||||
|
||||
recovery_release.set()
|
||||
await dependencies.scheduler.started.wait()
|
||||
await wait_until(
|
||||
lambda: dependencies.service.documents
|
||||
== [
|
||||
first_document,
|
||||
second_document,
|
||||
post_ack_document,
|
||||
],
|
||||
)
|
||||
await wait_until(
|
||||
lambda: dependencies.transport.receive_calls == 5,
|
||||
)
|
||||
|
||||
assert dependencies.service.documents == [
|
||||
first_document,
|
||||
second_document,
|
||||
post_ack_document,
|
||||
]
|
||||
assert dependencies.transport.max_active_receivers == 1
|
||||
|
||||
await dependencies.runtime.stop()
|
||||
await runtime_task
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
|
||||
assert dependencies.calls.index(
|
||||
"startup_recovery.hydrate"
|
||||
) < dependencies.calls.index("session.start")
|
||||
assert dependencies.calls.index(
|
||||
"service.subscribe"
|
||||
) < dependencies.calls.index("startup_recovery.recover")
|
||||
assert dependencies.calls.index(
|
||||
"startup_recovery.recover"
|
||||
) < dependencies.calls.index("service.handle_message")
|
||||
assert dependencies.calls.index(
|
||||
"service.handle_message"
|
||||
) < dependencies.calls.index("supervisor.start")
|
||||
|
||||
|
||||
def test_startup_ack_timeout_is_terminal() -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
subscription_ack_timeout_seconds=0.01,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
WebSocketSubscriptionAckTimeoutError,
|
||||
match="Истекло время",
|
||||
):
|
||||
await dependencies.runtime.run()
|
||||
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert startup_recovery.recover_calls == 0
|
||||
assert dependencies.live_processing_gate.failed is True
|
||||
assert dependencies.supervisor.start_calls == 0
|
||||
assert dependencies.scheduler.start_calls == 0
|
||||
assert dependencies.transport.max_active_receivers == 1
|
||||
assert dependencies.runtime.state is (
|
||||
TradeStreamProductionRuntimeState.FAILED
|
||||
)
|
||||
|
||||
|
||||
def test_startup_hydration_failure_prevents_network_io() -> None:
|
||||
hydration_error = RuntimeError("startup hydration failed")
|
||||
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
startup_hydration_error=hydration_error,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="startup hydration failed",
|
||||
) as error_info:
|
||||
await dependencies.runtime.run()
|
||||
|
||||
assert error_info.value is hydration_error
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert startup_recovery.hydrate_calls == 1
|
||||
assert startup_recovery.recover_calls == 0
|
||||
assert dependencies.session.start_calls == 0
|
||||
assert dependencies.service.subscribe_calls == []
|
||||
assert dependencies.live_processing_gate.failed is True
|
||||
assert dependencies.runtime.state is (
|
||||
TradeStreamProductionRuntimeState.FAILED
|
||||
)
|
||||
|
||||
|
||||
def test_persistent_startup_connect_failure_marks_gate_failed() -> None:
|
||||
connect_error = RuntimeError("persistent connect failed")
|
||||
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
start_error=connect_error,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="persistent connect failed",
|
||||
) as error_info:
|
||||
await dependencies.runtime.run()
|
||||
|
||||
assert error_info.value is connect_error
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert startup_recovery.hydrate_calls == 1
|
||||
assert startup_recovery.recover_calls == 0
|
||||
assert dependencies.service.subscribe_calls == []
|
||||
assert dependencies.live_processing_gate.failed is True
|
||||
assert dependencies.runtime.state is (
|
||||
TradeStreamProductionRuntimeState.FAILED
|
||||
)
|
||||
|
||||
|
||||
def test_negative_startup_ack_is_terminal_before_recovery() -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
|
||||
await dependencies.service.subscribe_entered.wait()
|
||||
correlation_id = (
|
||||
dependencies.service.subscribe_correlation_ids[0]
|
||||
)
|
||||
assert isinstance(correlation_id, str)
|
||||
dependencies.transport.feed(
|
||||
make_control_message(
|
||||
correlation_id=correlation_id,
|
||||
status="ERROR",
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
WebSocketControlMessageError,
|
||||
match="отклонил",
|
||||
):
|
||||
await runtime_task
|
||||
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert startup_recovery.recover_calls == 0
|
||||
assert dependencies.live_processing_gate.failed is True
|
||||
assert dependencies.supervisor.start_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("control_document_factory", "error_match"),
|
||||
[
|
||||
(
|
||||
lambda correlation_id: {
|
||||
"correlationId": "unknown-request",
|
||||
"destination": "trades.subscribe",
|
||||
"status": "OK",
|
||||
},
|
||||
"неизвестным correlationId",
|
||||
),
|
||||
(
|
||||
lambda correlation_id: {
|
||||
"correlationId": correlation_id,
|
||||
"destination": "trades.subscribe",
|
||||
},
|
||||
"непустой строковый status",
|
||||
),
|
||||
(
|
||||
lambda correlation_id: {
|
||||
"correlationId": correlation_id,
|
||||
"destination": "unknown.destination",
|
||||
"status": "OK",
|
||||
},
|
||||
"неизвестным destination",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_startup_control_message_is_terminal(
|
||||
control_document_factory: Callable[[str], object],
|
||||
error_match: str,
|
||||
) -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
|
||||
await dependencies.service.subscribe_entered.wait()
|
||||
correlation_id = (
|
||||
dependencies.service.subscribe_correlation_ids[0]
|
||||
)
|
||||
assert isinstance(correlation_id, str)
|
||||
dependencies.transport.feed(
|
||||
json.dumps(
|
||||
control_document_factory(correlation_id),
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
WebSocketMessageRoutingError,
|
||||
match=error_match,
|
||||
):
|
||||
await runtime_task
|
||||
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert startup_recovery.recover_calls == 0
|
||||
assert dependencies.service.documents == []
|
||||
assert dependencies.live_processing_gate.failed is True
|
||||
assert dependencies.reconnect_recovery.calls == []
|
||||
|
||||
|
||||
def test_startup_transport_error_is_terminal_without_reconnect() -> None:
|
||||
transport_error = WebSocketTransportError(
|
||||
"startup transport failed",
|
||||
)
|
||||
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
incoming=(transport_error,),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
WebSocketTransportError,
|
||||
match="startup transport failed",
|
||||
) as error_info:
|
||||
await dependencies.runtime.run()
|
||||
|
||||
assert error_info.value is transport_error
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert startup_recovery.recover_calls == 0
|
||||
assert dependencies.reconnect_recovery.calls == []
|
||||
assert dependencies.live_processing_gate.failed is True
|
||||
assert dependencies.supervisor.start_calls == 0
|
||||
|
||||
|
||||
def test_startup_market_buffer_overflow_is_terminal() -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
startup_market_buffer_capacity=1,
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
|
||||
await dependencies.service.subscribe_entered.wait()
|
||||
dependencies.transport.feed(MARKET_MESSAGE)
|
||||
dependencies.transport.feed(MARKET_MESSAGE)
|
||||
|
||||
with pytest.raises(
|
||||
WebSocketStartupMarketBufferOverflowError,
|
||||
match="Переполнен буфер",
|
||||
):
|
||||
await runtime_task
|
||||
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert startup_recovery.recover_calls == 0
|
||||
assert dependencies.service.documents == []
|
||||
assert dependencies.live_processing_gate.failed is True
|
||||
|
||||
|
||||
def test_startup_recovery_failure_prevents_fifo_drain() -> None:
|
||||
recovery_error = RuntimeError("startup recovery failed")
|
||||
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
startup_recovery_error=recovery_error,
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
|
||||
await dependencies.service.subscribe_entered.wait()
|
||||
correlation_id = (
|
||||
dependencies.service.subscribe_correlation_ids[0]
|
||||
)
|
||||
assert isinstance(correlation_id, str)
|
||||
dependencies.transport.feed(MARKET_MESSAGE)
|
||||
dependencies.transport.feed(
|
||||
make_control_message(
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="startup recovery failed",
|
||||
) as error_info:
|
||||
await runtime_task
|
||||
|
||||
assert error_info.value is recovery_error
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
|
||||
assert dependencies.service.documents == []
|
||||
assert dependencies.live_processing_gate.failed is True
|
||||
assert dependencies.supervisor.start_calls == 0
|
||||
assert dependencies.scheduler.start_calls == 0
|
||||
|
||||
|
||||
def test_stop_during_startup_ack_wait_releases_boundary() -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
|
||||
await dependencies.service.subscribe_entered.wait()
|
||||
await dependencies.transport.waiting.wait()
|
||||
await asyncio.wait_for(
|
||||
dependencies.runtime.stop(),
|
||||
timeout=1.0,
|
||||
)
|
||||
await runtime_task
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
|
||||
assert dependencies.live_processing_gate.locked is False
|
||||
assert dependencies.live_processing_gate.failed is False
|
||||
assert dependencies.supervisor.start_calls == 0
|
||||
assert dependencies.transport.active_receivers == 0
|
||||
assert dependencies.runtime.state is (
|
||||
TradeStreamProductionRuntimeState.STOPPED
|
||||
)
|
||||
|
||||
|
||||
def test_stop_during_startup_hydration_prevents_connection() -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
hydration_release = asyncio.Event()
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
startup_hydration_release=hydration_release,
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
assert startup_recovery is not None
|
||||
await startup_recovery.hydration_entered.wait()
|
||||
|
||||
await asyncio.wait_for(
|
||||
dependencies.runtime.stop(),
|
||||
timeout=1.0,
|
||||
)
|
||||
await runtime_task
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
|
||||
assert dependencies.session.start_calls == 0
|
||||
assert dependencies.live_processing_gate.failed is False
|
||||
assert dependencies.runtime.state is (
|
||||
TradeStreamProductionRuntimeState.STOPPED
|
||||
)
|
||||
|
||||
|
||||
def test_stop_during_startup_recovery_prevents_fifo_drain() -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
recovery_release = asyncio.Event()
|
||||
dependencies = RuntimeDependencies(
|
||||
startup_recovery_enabled=True,
|
||||
startup_recovery_release=recovery_release,
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
|
||||
await dependencies.service.subscribe_entered.wait()
|
||||
correlation_id = (
|
||||
dependencies.service.subscribe_correlation_ids[0]
|
||||
)
|
||||
assert isinstance(correlation_id, str)
|
||||
dependencies.transport.feed(MARKET_MESSAGE)
|
||||
dependencies.transport.feed(
|
||||
make_control_message(
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
)
|
||||
|
||||
startup_recovery = dependencies.startup_recovery
|
||||
assert startup_recovery is not None
|
||||
await startup_recovery.recovery_entered.wait()
|
||||
|
||||
await asyncio.wait_for(
|
||||
dependencies.runtime.stop(),
|
||||
timeout=1.0,
|
||||
)
|
||||
await runtime_task
|
||||
return dependencies
|
||||
|
||||
dependencies = asyncio.run(scenario())
|
||||
|
||||
assert dependencies.service.documents == []
|
||||
assert dependencies.live_processing_gate.locked is False
|
||||
assert dependencies.live_processing_gate.failed is False
|
||||
assert dependencies.supervisor.start_calls == 0
|
||||
assert dependencies.runtime.state is (
|
||||
TradeStreamProductionRuntimeState.STOPPED
|
||||
)
|
||||
|
||||
|
||||
def test_locked_gate_does_not_leave_runtime_partially_started() -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies()
|
||||
@@ -893,7 +1609,12 @@ def test_scheduler_claim_blocks_external_start_during_runtime_startup() -> None:
|
||||
|
||||
def test_startup_receive_and_shutdown_order() -> None:
|
||||
async def scenario() -> RuntimeDependencies:
|
||||
dependencies = RuntimeDependencies()
|
||||
dependencies = RuntimeDependencies(
|
||||
symbols=(
|
||||
f" {SYMBOL.lower()} ",
|
||||
SYMBOL,
|
||||
),
|
||||
)
|
||||
runtime_task = asyncio.create_task(
|
||||
dependencies.runtime.run(),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -73,6 +74,12 @@ class FakeConnection:
|
||||
async def recv(self) -> str | bytes:
|
||||
return ""
|
||||
|
||||
async def ping(self) -> Awaitable[float]:
|
||||
async def wait_for_pong() -> float:
|
||||
return 0.001
|
||||
|
||||
return wait_for_pong()
|
||||
|
||||
|
||||
class RecordingConnector:
|
||||
def __init__(
|
||||
|
||||
@@ -13,9 +13,15 @@ import pytest
|
||||
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||
DzengiTradesDocumentSource,
|
||||
)
|
||||
from src.market_data.acquisition.checkpoint.trade_stream_state_hydrator import (
|
||||
TradeStreamStateHydratorProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_observation_sink_protocol import (
|
||||
TradeObservationSinkProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||
TradeStreamState,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import (
|
||||
Trade,
|
||||
TradeAggressorSide,
|
||||
@@ -44,6 +50,12 @@ from src.market_data.acquisition.runtime.runtime_events import (
|
||||
from src.market_data.acquisition.runtime.runtime_recovery_protocol import (
|
||||
RuntimeRecoveryProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||
TradeRecoveryResult,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_startup_recovery_coordinator import (
|
||||
RuntimeStartupRecoveryProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.scheduler import (
|
||||
RuntimeSchedulerProtocol,
|
||||
)
|
||||
@@ -66,6 +78,10 @@ from src.market_data.acquisition.trade_stream_runtime_composition import (
|
||||
TradeStreamRuntimeComposition,
|
||||
build_trade_stream_runtime_composition,
|
||||
)
|
||||
from src.market_data.storage.contracts import (
|
||||
PersistentTradeCheckpoint,
|
||||
TradeCheckpointStorageProtocol,
|
||||
)
|
||||
|
||||
|
||||
SYMBOL = "BTC/USD_LEVERAGE"
|
||||
@@ -326,17 +342,152 @@ class RecordingTradeObservationSink:
|
||||
) -> None:
|
||||
self._fail_on_trade_id = fail_on_trade_id
|
||||
self.observations: list[Trade] = []
|
||||
self.accepted: list[tuple[Trade, Trade | None]] = []
|
||||
self.duplicates: list[Trade] = []
|
||||
|
||||
def persist(
|
||||
def persist_accepted(
|
||||
self,
|
||||
trade: Trade,
|
||||
*,
|
||||
expected_trade: Trade | None,
|
||||
) -> None:
|
||||
self.observations.append(trade)
|
||||
self.accepted.append((trade, expected_trade))
|
||||
|
||||
if trade.trade_id == self._fail_on_trade_id:
|
||||
raise RuntimeError("storage failed")
|
||||
|
||||
def persist_duplicate(
|
||||
self,
|
||||
trade: Trade,
|
||||
) -> None:
|
||||
self.observations.append(trade)
|
||||
self.duplicates.append(trade)
|
||||
|
||||
if trade.trade_id == self._fail_on_trade_id:
|
||||
raise RuntimeError("storage failed")
|
||||
|
||||
|
||||
class RecordingCheckpointStorage:
|
||||
"""Checkpoint storage, фиксирующий нежелательный I/O."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
def load_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
symbol: str,
|
||||
) -> PersistentTradeCheckpoint | None:
|
||||
self.calls.append("load_checkpoint")
|
||||
raise AssertionError("Composition не должна читать checkpoint.")
|
||||
|
||||
def load_checkpoint_tail(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
checkpoint: PersistentTradeCheckpoint,
|
||||
limit: int,
|
||||
) -> tuple[Trade, ...]:
|
||||
self.calls.append("load_checkpoint_tail")
|
||||
raise AssertionError("Composition не должна читать Trade tail.")
|
||||
|
||||
def load_latest_trade_tail(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
symbol: str,
|
||||
limit: int,
|
||||
) -> tuple[Trade, ...]:
|
||||
self.calls.append("load_latest_trade_tail")
|
||||
raise AssertionError("Composition не должна читать Trade tail.")
|
||||
|
||||
def adopt_existing_trade_as_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
trade: Trade,
|
||||
) -> PersistentTradeCheckpoint:
|
||||
self.calls.append("adopt_existing_trade_as_checkpoint")
|
||||
raise AssertionError("Composition не должна создавать checkpoint.")
|
||||
|
||||
def store_trade_and_advance_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
expected_trade: Trade | None,
|
||||
trade: Trade,
|
||||
observed_at: datetime,
|
||||
) -> PersistentTradeCheckpoint:
|
||||
self.calls.append("store_trade_and_advance_checkpoint")
|
||||
raise AssertionError("Composition не должна записывать checkpoint.")
|
||||
|
||||
|
||||
class CheckpointBackedStorage:
|
||||
"""Checkpoint storage для проверки общего канонического ключа."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
checkpoint: PersistentTradeCheckpoint,
|
||||
) -> None:
|
||||
self._checkpoint = checkpoint
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
|
||||
def load_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
symbol: str,
|
||||
) -> PersistentTradeCheckpoint | None:
|
||||
self.calls.append(("load_checkpoint", venue, symbol))
|
||||
return self._checkpoint
|
||||
|
||||
def load_checkpoint_tail(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
checkpoint: PersistentTradeCheckpoint,
|
||||
limit: int,
|
||||
) -> tuple[Trade, ...]:
|
||||
self.calls.append(
|
||||
(
|
||||
"load_checkpoint_tail",
|
||||
venue,
|
||||
checkpoint,
|
||||
limit,
|
||||
)
|
||||
)
|
||||
return (checkpoint.trade,)
|
||||
|
||||
def load_latest_trade_tail(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
symbol: str,
|
||||
limit: int,
|
||||
) -> tuple[Trade, ...]:
|
||||
raise AssertionError("Latest Trade tail не должен запрашиваться.")
|
||||
|
||||
def adopt_existing_trade_as_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
trade: Trade,
|
||||
) -> PersistentTradeCheckpoint:
|
||||
raise AssertionError("Checkpoint не должен создаваться.")
|
||||
|
||||
def store_trade_and_advance_checkpoint(
|
||||
self,
|
||||
*,
|
||||
venue: str,
|
||||
expected_trade: Trade | None,
|
||||
trade: Trade,
|
||||
observed_at: datetime,
|
||||
) -> PersistentTradeCheckpoint:
|
||||
raise AssertionError("Checkpoint не должен записываться.")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CompositionDependencies:
|
||||
session: FakeSession
|
||||
@@ -352,6 +503,7 @@ class CompositionDependencies:
|
||||
|
||||
def create_composition(
|
||||
*,
|
||||
symbols: tuple[str, ...] = (SYMBOL,),
|
||||
trade: Trade | None = None,
|
||||
recovery_document: object = (),
|
||||
heartbeat_timeout_seconds: float = 10.0,
|
||||
@@ -359,6 +511,8 @@ def create_composition(
|
||||
max_recovery_window_ms: int = 3_599_999,
|
||||
probe_results: tuple[bool, ...] = (True,),
|
||||
trade_observation_sink: TradeObservationSinkProtocol | None = None,
|
||||
checkpoint_storage: TradeCheckpointStorageProtocol | None = None,
|
||||
checkpoint_venue: str | None = None,
|
||||
) -> tuple[
|
||||
TradeStreamRuntimeComposition,
|
||||
CompositionDependencies,
|
||||
@@ -390,7 +544,7 @@ def create_composition(
|
||||
recovery_document_source=(
|
||||
dependencies.recovery_document_source
|
||||
),
|
||||
symbols=(SYMBOL,),
|
||||
symbols=symbols,
|
||||
heartbeat_timeout_seconds=heartbeat_timeout_seconds,
|
||||
scheduler_interval_seconds=scheduler_interval_seconds,
|
||||
trade_observation_sink=trade_observation_sink,
|
||||
@@ -400,6 +554,8 @@ def create_composition(
|
||||
dependencies.recovery_end_time_clock
|
||||
),
|
||||
scheduler_sleep=dependencies.scheduler_sleep,
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
checkpoint_venue=checkpoint_venue,
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -467,6 +623,196 @@ def test_components_implement_public_protocols() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_checkpoint_components_are_absent_without_configuration() -> None:
|
||||
composition, *_ = create_composition()
|
||||
|
||||
assert composition.state_hydrator is None
|
||||
assert composition.runtime_startup_recovery_coordinator is None
|
||||
|
||||
|
||||
def test_checkpoint_components_implement_public_protocols() -> None:
|
||||
storage = RecordingCheckpointStorage()
|
||||
composition, *_ = create_composition(
|
||||
checkpoint_storage=storage,
|
||||
checkpoint_venue="Dzengi",
|
||||
)
|
||||
|
||||
assert isinstance(storage, TradeCheckpointStorageProtocol)
|
||||
assert isinstance(
|
||||
composition.state_hydrator,
|
||||
TradeStreamStateHydratorProtocol,
|
||||
)
|
||||
assert isinstance(
|
||||
composition.runtime_startup_recovery_coordinator,
|
||||
RuntimeStartupRecoveryProtocol,
|
||||
)
|
||||
|
||||
|
||||
def test_checkpoint_runtime_reuses_shared_dependency_graph() -> None:
|
||||
storage = RecordingCheckpointStorage()
|
||||
composition, dependencies = create_composition(
|
||||
checkpoint_storage=storage,
|
||||
checkpoint_venue="Dzengi",
|
||||
)
|
||||
state_hydrator = composition.state_hydrator
|
||||
startup_recovery = (
|
||||
composition.runtime_startup_recovery_coordinator
|
||||
)
|
||||
|
||||
assert state_hydrator is not None
|
||||
assert startup_recovery is not None
|
||||
assert state_hydrator._state_store is composition.state_store
|
||||
assert startup_recovery._state_hydrator is state_hydrator
|
||||
assert (
|
||||
startup_recovery._recovery_coordinator
|
||||
is composition.runtime_recovery_coordinator
|
||||
)
|
||||
assert (
|
||||
startup_recovery.live_processing_gate
|
||||
is composition.live_processing_gate
|
||||
)
|
||||
assert (
|
||||
startup_recovery.symbols
|
||||
== composition.runtime_reconnect_recovery_coordinator.symbols
|
||||
== (SYMBOL,)
|
||||
)
|
||||
assert (
|
||||
startup_recovery._clock
|
||||
is dependencies.recovery_end_time_clock
|
||||
)
|
||||
assert (
|
||||
composition.runtime_reconnect_recovery_coordinator._clock
|
||||
is dependencies.recovery_end_time_clock
|
||||
)
|
||||
|
||||
|
||||
def test_lowercase_symbol_uses_one_key_for_hydration_and_recovery() -> None:
|
||||
checkpoint = PersistentTradeCheckpoint(
|
||||
venue="dzengi",
|
||||
trade=make_trade(),
|
||||
revision=1,
|
||||
updated_at=CHECKPOINT_TIME,
|
||||
)
|
||||
storage = CheckpointBackedStorage(checkpoint)
|
||||
composition, dependencies = create_composition(
|
||||
symbols=(f" {SYMBOL.lower()} ",),
|
||||
recovery_document=[],
|
||||
checkpoint_storage=storage,
|
||||
checkpoint_venue="Dzengi",
|
||||
)
|
||||
startup_recovery = (
|
||||
composition.runtime_startup_recovery_coordinator
|
||||
)
|
||||
|
||||
assert startup_recovery is not None
|
||||
|
||||
async def scenario() -> tuple[
|
||||
tuple[TradeStreamState, ...],
|
||||
tuple[TradeRecoveryResult, ...],
|
||||
]:
|
||||
states = await startup_recovery.hydrate_once()
|
||||
|
||||
async with composition.live_processing_gate:
|
||||
results = await startup_recovery.recover_after_ack()
|
||||
|
||||
return states, results
|
||||
|
||||
states, results = asyncio.run(scenario())
|
||||
|
||||
assert startup_recovery.symbols == (SYMBOL,)
|
||||
assert (
|
||||
composition.runtime_reconnect_recovery_coordinator.symbols
|
||||
== (SYMBOL,)
|
||||
)
|
||||
assert states[0].symbol == SYMBOL
|
||||
assert composition.state_store.get(SYMBOL) is states[0]
|
||||
assert composition.state_store.contains(SYMBOL.lower()) is False
|
||||
assert results[0].symbol == SYMBOL
|
||||
assert storage.calls[0] == (
|
||||
"load_checkpoint",
|
||||
"dzengi",
|
||||
SYMBOL,
|
||||
)
|
||||
assert dependencies.recovery_document_source.calls
|
||||
assert dependencies.recovery_document_source.calls[0][0] == SYMBOL
|
||||
|
||||
|
||||
def test_case_variant_duplicates_collapse_before_hydration_worker() -> None:
|
||||
checkpoint = PersistentTradeCheckpoint(
|
||||
venue="dzengi",
|
||||
trade=make_trade(),
|
||||
revision=1,
|
||||
updated_at=CHECKPOINT_TIME,
|
||||
)
|
||||
storage = CheckpointBackedStorage(checkpoint)
|
||||
composition, _ = create_composition(
|
||||
symbols=(
|
||||
SYMBOL.lower(),
|
||||
SYMBOL,
|
||||
f" {SYMBOL.lower()} ",
|
||||
),
|
||||
checkpoint_storage=storage,
|
||||
checkpoint_venue="dzengi",
|
||||
)
|
||||
startup_recovery = (
|
||||
composition.runtime_startup_recovery_coordinator
|
||||
)
|
||||
|
||||
assert startup_recovery is not None
|
||||
|
||||
states = asyncio.run(startup_recovery.hydrate_once())
|
||||
|
||||
assert startup_recovery.symbols == (SYMBOL,)
|
||||
assert len(states) == 1
|
||||
assert states[0].symbol == SYMBOL
|
||||
assert [call[0] for call in storage.calls] == [
|
||||
"load_checkpoint",
|
||||
"load_checkpoint_tail",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("checkpoint_storage", "checkpoint_venue"),
|
||||
[
|
||||
(RecordingCheckpointStorage(), None),
|
||||
(None, "dzengi"),
|
||||
],
|
||||
)
|
||||
def test_partial_checkpoint_configuration_is_rejected_without_io(
|
||||
checkpoint_storage: TradeCheckpointStorageProtocol | None,
|
||||
checkpoint_venue: str | None,
|
||||
) -> None:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="должны быть переданы вместе",
|
||||
):
|
||||
create_composition(
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
checkpoint_venue=checkpoint_venue,
|
||||
)
|
||||
|
||||
if isinstance(checkpoint_storage, RecordingCheckpointStorage):
|
||||
assert checkpoint_storage.calls == []
|
||||
|
||||
|
||||
def test_checkpoint_composition_has_no_io_or_background_tasks() -> None:
|
||||
storage = RecordingCheckpointStorage()
|
||||
composition, dependencies = create_composition(
|
||||
checkpoint_storage=storage,
|
||||
checkpoint_venue="dzengi",
|
||||
)
|
||||
startup_recovery = (
|
||||
composition.runtime_startup_recovery_coordinator
|
||||
)
|
||||
|
||||
assert startup_recovery is not None
|
||||
assert storage.calls == []
|
||||
assert dependencies.recovery_document_source.calls == []
|
||||
assert dependencies.recovery_end_time_clock.calls == 0
|
||||
assert startup_recovery._hydration_task is None
|
||||
assert startup_recovery._recovery_task is None
|
||||
|
||||
|
||||
def test_external_dependencies_are_reused() -> None:
|
||||
composition, dependencies = create_composition()
|
||||
|
||||
@@ -548,6 +894,11 @@ def test_live_and_recovery_share_optional_persistence_sink() -> None:
|
||||
trade.trade_id
|
||||
for trade in sink.observations
|
||||
] == [100, recovered_trade_id]
|
||||
assert sink.accepted == [
|
||||
(live_trade, None),
|
||||
(recovery_result.last_trade, live_trade),
|
||||
]
|
||||
assert sink.duplicates == []
|
||||
|
||||
|
||||
def test_recovery_duplicate_updates_persistence_without_checkpoint_change(
|
||||
@@ -589,6 +940,8 @@ def test_recovery_duplicate_updates_persistence_without_checkpoint_change(
|
||||
assert len(sink.observations) == 2
|
||||
assert sink.observations[0] is live_trade
|
||||
assert sink.observations[1].source == "dzengi"
|
||||
assert sink.accepted == [(live_trade, None)]
|
||||
assert sink.duplicates == [sink.observations[1]]
|
||||
assert state.last_trade is live_trade
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user