diff --git a/hathor/builder/builder.py b/hathor/builder/builder.py index 7bc00f13d..ec96e2682 100644 --- a/hathor/builder/builder.py +++ b/hathor/builder/builder.py @@ -25,6 +25,7 @@ from hathor.event import EventManager from hathor.event.storage import EventMemoryStorage, EventRocksDBStorage, EventStorage from hathor.event.websocket import EventWebsocketFactory +from hathor.execution_manager import ExecutionManager from hathor.feature_activation.bit_signaling_service import BitSignalingService from hathor.feature_activation.feature import Feature from hathor.feature_activation.feature_service import FeatureService @@ -111,6 +112,7 @@ def __init__(self) -> None: self._feature_service: Optional[FeatureService] = None self._bit_signaling_service: Optional[BitSignalingService] = None + self._consensus: Optional[ConsensusAlgorithm] = None self._daa: Optional[DifficultyAdjustmentAlgorithm] = None self._cpu_mining_service: Optional[CpuMiningService] = None @@ -149,6 +151,8 @@ def __init__(self) -> None: self._soft_voided_tx_ids: Optional[set[bytes]] = None + self._execution_manager: ExecutionManager | None = None + def build(self) -> BuildArtifacts: if self.artifacts is not None: raise ValueError('cannot call build twice') @@ -161,9 +165,7 @@ def build(self) -> BuildArtifacts: pubsub = self._get_or_create_pubsub() peer_id = self._get_peer_id() - - soft_voided_tx_ids = self._get_soft_voided_tx_ids() - consensus_algorithm = ConsensusAlgorithm(soft_voided_tx_ids, pubsub) + consensus_algorithm = self._get_or_create_consensus() p2p_manager = self._get_p2p_manager() @@ -305,6 +307,17 @@ def _get_peer_id(self) -> PeerId: return self._peer_id raise ValueError('peer_id not set') + def _get_or_create_execution_manager(self) -> ExecutionManager: + if self._execution_manager is None: + tx_storage = self._get_or_create_tx_storage() + event_manager = self._get_or_create_event_manager() + self._execution_manager = ExecutionManager( + tx_storage=tx_storage, + event_manager=event_manager, + ) + + return self._execution_manager + def _get_or_create_pubsub(self) -> PubSubManager: if self._pubsub is None: self._pubsub = PubSubManager(self._get_reactor()) @@ -445,10 +458,12 @@ def _get_or_create_event_manager(self) -> EventManager: def _get_or_create_feature_service(self) -> FeatureService: """Return the FeatureService instance set on this builder, or a new one if not set.""" if self._feature_service is None: + reactor = self._get_reactor() settings = self._get_or_create_settings() tx_storage = self._get_or_create_tx_storage() self._feature_service = FeatureService( - feature_settings=settings.FEATURE_ACTIVATION, + reactor=reactor, + settings=settings, tx_storage=tx_storage ) @@ -469,6 +484,21 @@ def _get_or_create_bit_signaling_service(self) -> BitSignalingService: return self._bit_signaling_service + def _get_or_create_consensus(self) -> ConsensusAlgorithm: + if self._consensus is None: + soft_voided_tx_ids = self._get_soft_voided_tx_ids() + pubsub = self._get_or_create_pubsub() + execution_manager = self._get_or_create_execution_manager() + feature_service = self._get_or_create_feature_service() + self._consensus = ConsensusAlgorithm( + soft_voided_tx_ids=soft_voided_tx_ids, + pubsub=pubsub, + execution_manager=execution_manager, + feature_service=feature_service, + ) + + return self._consensus + def _get_or_create_verification_service(self) -> VerificationService: if self._verification_service is None: verifiers = self._get_or_create_vertex_verifiers() diff --git a/hathor/builder/cli_builder.py b/hathor/builder/cli_builder.py index fc897867b..3684695ff 100644 --- a/hathor/builder/cli_builder.py +++ b/hathor/builder/cli_builder.py @@ -26,6 +26,7 @@ from hathor.daa import DifficultyAdjustmentAlgorithm from hathor.event import EventManager from hathor.exception import BuilderError +from hathor.execution_manager import ExecutionManager from hathor.feature_activation.bit_signaling_service import BitSignalingService from hathor.feature_activation.feature_service import FeatureService from hathor.indexes import IndexesManager, MemoryIndexesManager, RocksDBIndexesManager @@ -192,17 +193,28 @@ def create_manager(self, reactor: Reactor) -> HathorManager: full_verification = True soft_voided_tx_ids = set(settings.SOFT_VOIDED_TX_IDS) - consensus_algorithm = ConsensusAlgorithm(soft_voided_tx_ids, pubsub=pubsub) + execution_manager = ExecutionManager( + tx_storage=tx_storage, + event_manager=event_manager, + ) if self._args.x_enable_event_queue: self.log.info('--x-enable-event-queue flag provided. ' 'The events detected by the full node will be stored and can be retrieved by clients') self.feature_service = FeatureService( - feature_settings=settings.FEATURE_ACTIVATION, + reactor=reactor, + settings=settings, tx_storage=tx_storage ) + consensus_algorithm = ConsensusAlgorithm( + soft_voided_tx_ids, + pubsub=pubsub, + execution_manager=execution_manager, + feature_service=self.feature_service, + ) + bit_signaling_service = BitSignalingService( feature_settings=settings.FEATURE_ACTIVATION, feature_service=self.feature_service, diff --git a/hathor/consensus/consensus.py b/hathor/consensus/consensus.py index 307dbe0ff..80020c449 100644 --- a/hathor/consensus/consensus.py +++ b/hathor/consensus/consensus.py @@ -18,6 +18,9 @@ from hathor.consensus.block_consensus import BlockConsensusAlgorithmFactory from hathor.consensus.context import ConsensusAlgorithmContext from hathor.consensus.transaction_consensus import TransactionConsensusAlgorithmFactory +from hathor.exception import ReorgTooLargeError +from hathor.execution_manager import ExecutionManager +from hathor.feature_activation.feature_service import FeatureService from hathor.profiler import get_cpu_profiler from hathor.pubsub import HathorEvents, PubSubManager from hathor.transaction import BaseTransaction @@ -55,13 +58,22 @@ class ConsensusAlgorithm: b0 will not be propagated to the voided_by of b1, b2, and b3. """ - def __init__(self, soft_voided_tx_ids: set[bytes], pubsub: PubSubManager) -> None: + def __init__( + self, + soft_voided_tx_ids: set[bytes], + pubsub: PubSubManager, + *, + execution_manager: ExecutionManager, + feature_service: FeatureService, + ) -> None: self._settings = get_settings() self.log = logger.new() self._pubsub = pubsub + self._feature_service = feature_service self.soft_voided_tx_ids = frozenset(soft_voided_tx_ids) self.block_algorithm_factory = BlockConsensusAlgorithmFactory() self.transaction_algorithm_factory = TransactionConsensusAlgorithmFactory() + self._execution_manager = execution_manager def create_context(self) -> ConsensusAlgorithmContext: """Handy method to create a context that can be used to access block and transaction algorithms.""" @@ -75,11 +87,11 @@ def update(self, base: BaseTransaction) -> None: assert meta.validation.is_valid() try: self._unsafe_update(base) - except Exception: + except BaseException: meta.add_voided_by(self._settings.CONSENSUS_FAIL_ID) assert base.storage is not None base.storage.save_transaction(base, only_metadata=True) - raise + self._execution_manager.crash_and_exit(reason=f'Consensus update failed for tx {base.hash_hex}') def _unsafe_update(self, base: BaseTransaction) -> None: """Run a consensus update with its own context, indexes will be updated accordingly.""" @@ -130,6 +142,13 @@ def _unsafe_update(self, base: BaseTransaction) -> None: reorg_size = old_best_block.get_height() - context.reorg_common_block.get_height() assert old_best_block != new_best_block assert reorg_size > 0 + + if not self._feature_service.is_reorg_valid(context.reorg_common_block): + # Raise an exception forcing the full node to exit if the reorg is too large for Feature Activation. + raise ReorgTooLargeError( + 'Reorg is invalid. Time difference between common block and now is too large.' + ) + context.pubsub.publish(HathorEvents.REORG_STARTED, old_best_height=best_height, old_best_block=old_best_block, new_best_height=new_best_height, new_best_block=new_best_block, common_block=context.reorg_common_block, diff --git a/hathor/event/event_manager.py b/hathor/event/event_manager.py index 7338d256e..1271d4e4a 100644 --- a/hathor/event/event_manager.py +++ b/hathor/event/event_manager.py @@ -132,7 +132,7 @@ def _subscribe_events(self) -> None: for event in _SUBSCRIBE_EVENTS: self._pubsub.subscribe(event, self._handle_hathor_event) - def load_started(self): + def load_started(self) -> None: if not self._is_running: return @@ -142,7 +142,7 @@ def load_started(self): ) self._event_storage.save_node_state(NodeState.LOAD) - def load_finished(self): + def load_finished(self) -> None: if not self._is_running: return @@ -152,6 +152,15 @@ def load_finished(self): ) self._event_storage.save_node_state(NodeState.SYNC) + def full_node_crashed(self) -> None: + if not self._is_running: + return + + self._handle_event( + event_type=EventType.FULL_NODE_CRASHED, + event_args=EventArguments(), + ) + def _handle_hathor_event(self, hathor_event: HathorEvents, event_args: EventArguments) -> None: """Handles a PubSub 'HathorEvents' event.""" event_type = EventType.from_hathor_event(hathor_event) diff --git a/hathor/event/model/event_type.py b/hathor/event/model/event_type.py index 7c697fbc8..617ea74d8 100644 --- a/hathor/event/model/event_type.py +++ b/hathor/event/model/event_type.py @@ -25,6 +25,7 @@ class EventType(Enum): REORG_STARTED = 'REORG_STARTED' REORG_FINISHED = 'REORG_FINISHED' VERTEX_METADATA_CHANGED = 'VERTEX_METADATA_CHANGED' + FULL_NODE_CRASHED = 'FULL_NODE_CRASHED' @classmethod def from_hathor_event(cls, hathor_event: HathorEvents) -> 'EventType': @@ -53,4 +54,5 @@ def data_type(self) -> type[BaseEventData]: EventType.REORG_STARTED: ReorgData, EventType.REORG_FINISHED: EmptyData, EventType.VERTEX_METADATA_CHANGED: TxData, + EventType.FULL_NODE_CRASHED: EmptyData, } diff --git a/hathor/exception.py b/hathor/exception.py index 1d3d42547..808530797 100644 --- a/hathor/exception.py +++ b/hathor/exception.py @@ -49,6 +49,10 @@ class InitializationError(HathorError): """ +class ReorgTooLargeError(HathorError): + """Raised when a reorg is too large for the full node to recover.""" + + class DoubleSpendingError(InvalidNewTransaction): """Raised when a new received tx/block is not valid because of a double spending. """ diff --git a/hathor/execution_manager.py b/hathor/execution_manager.py new file mode 100644 index 000000000..ae9b7e561 --- /dev/null +++ b/hathor/execution_manager.py @@ -0,0 +1,51 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from typing import NoReturn + +from structlog import get_logger + +from hathor.event import EventManager +from hathor.transaction.storage import TransactionStorage + +logger = get_logger() + + +class ExecutionManager: + """Class to manage actions related to full node execution.""" + __slots__ = ('_log', '_tx_storage', '_event_manager') + + def __init__(self, *, tx_storage: TransactionStorage, event_manager: EventManager) -> None: + self._log = logger.new() + self._tx_storage = tx_storage + self._event_manager = event_manager + + def crash_and_exit(self, *, reason: str) -> NoReturn: + """ + Calling this function is a very extreme thing to do, so be careful. It should only be called when a + critical, unrecoverable failure happens. It crashes and exits the full node, rendering the database + corrupted, and requiring manual intervention. In other words, a restart with a clean database (from scratch + or a snapshot) will be required. + """ + self._tx_storage.full_node_crashed() + self._event_manager.full_node_crashed() + self._log.critical( + 'Critical failure occurred, causing the full node to halt execution. Manual intervention is required.', + reason=reason, + exc_info=True + ) + # We use os._exit() instead of sys.exit() or any other approaches because this is the only one Twisted + # doesn't catch. + os._exit(-1) diff --git a/hathor/feature_activation/feature_service.py b/hathor/feature_activation/feature_service.py index 4d44dd5c2..d033d5d94 100644 --- a/hathor/feature_activation/feature_service.py +++ b/hathor/feature_activation/feature_service.py @@ -13,17 +13,23 @@ # limitations under the License. from dataclasses import dataclass -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Optional, TypeAlias +from structlog import get_logger + +from hathor.conf.settings import HathorSettings from hathor.feature_activation.feature import Feature from hathor.feature_activation.model.feature_description import FeatureDescription from hathor.feature_activation.model.feature_state import FeatureState from hathor.feature_activation.settings import Settings as FeatureSettings +from hathor.util import Reactor if TYPE_CHECKING: - from hathor.transaction import Block + from hathor.transaction import Block, Transaction from hathor.transaction.storage import TransactionStorage +logger = get_logger() + @dataclass(frozen=True, slots=True) class BlockIsSignaling: @@ -41,18 +47,69 @@ class BlockIsMissingSignal: class FeatureService: - __slots__ = ('_feature_settings', '_tx_storage') + __slots__ = ('_log', '_reactor', '_settings', '_tx_storage') - def __init__(self, *, feature_settings: FeatureSettings, tx_storage: 'TransactionStorage') -> None: - self._feature_settings = feature_settings + def __init__(self, *, reactor: Reactor, settings: HathorSettings, tx_storage: 'TransactionStorage') -> None: + self._log = logger.new() + self._reactor = reactor + self._settings = settings self._tx_storage = tx_storage - def is_feature_active(self, *, block: 'Block', feature: Feature) -> bool: - """Returns whether a Feature is active at a certain block.""" + @property + def _feature_settings(self) -> FeatureSettings: + return self._settings.FEATURE_ACTIVATION + + def is_feature_active_for_block(self, *, block: 'Block', feature: Feature) -> bool: + """Return whether a Feature is active for a certain block.""" state = self.get_state(block=block, feature=feature) return state == FeatureState.ACTIVE + def is_feature_active_for_transaction(self, *, transaction: 'Transaction', feature: Feature) -> bool: + """Return whether a Feature is active for a certain transaction.""" + current_best_block = self._tx_storage.get_best_block() # TODO: This could be inside _get_first_active_block + first_active_block = self._get_first_active_block(current_best_block, feature) + + if not first_active_block: + return False + + # Equivalent to two weeks + avg_time_between_boundaries = ( + self._feature_settings.evaluation_interval * self._settings.AVG_TIME_BETWEEN_BLOCKS + ) + # We also use the MAX_FUTURE_TIMESTAMP_ALLOWED to take into account that we can receive a tx from the future + margin = self._settings.MAX_FUTURE_TIMESTAMP_ALLOWED + transaction_activation_threshold = first_active_block.timestamp + avg_time_between_boundaries + margin + + assert transaction.timestamp is not None + is_active = transaction.timestamp > transaction_activation_threshold + + return is_active + + def _get_first_active_block(self, block: 'Block', feature: Feature) -> Optional['Block']: + """ + Return the first ever block that became ACTIVE for a specific feature (which is always a boundary block), + or None if this feature is not ACTIVE. + + It recursively hops boundary blocks until we find a block that is ACTIVE and has a parent that is LOCKED_IN. + """ + if not self.is_feature_active_for_block(block=block, feature=feature): + return None + + parent = block.get_block_parent() + parent_state = self.get_state(block=parent, feature=feature) + + if parent_state is FeatureState.LOCKED_IN: + return block + + height = block.get_height() + offset_to_boundary = height % self._feature_settings.evaluation_interval + offset_to_previous_boundary = offset_to_boundary or self._feature_settings.evaluation_interval + previous_boundary_height = height - offset_to_previous_boundary + previous_boundary_block = self._get_ancestor_at_height(block=block, height=previous_boundary_height) + + return self._get_first_active_block(previous_boundary_block, feature) + def is_signaling_mandatory_features(self, block: 'Block') -> BlockSignalingState: """ Return whether a block is signaling features that are mandatory, that is, any feature currently in the @@ -203,10 +260,13 @@ def _get_ancestor_at_height(self, *, block: 'Block', height: int) -> 'Block': Given a block, returns its ancestor at a specific height. Uses the height index if the block is in the best blockchain, or search iteratively otherwise. """ - assert height < block.get_height(), ( - f"ancestor height must be lower than the block's height: {height} >= {block.get_height()}" + assert height <= block.get_height(), ( + f"ancestor height must not be greater than the block's height: {height} > {block.get_height()}" ) + if height == block.get_height(): + return block + metadata = block.get_metadata() if not metadata.voided_by and (ancestor := self._tx_storage.get_transaction_by_height(height)): @@ -216,6 +276,33 @@ def _get_ancestor_at_height(self, *, block: 'Block', height: int) -> 'Block': return _get_ancestor_iteratively(block=block, ancestor_height=height) + def is_reorg_valid(self, common_block: 'Block') -> bool: + """ + Check whether a reorg is valid, given its common block. + A reorg is considered invalid if it may include the activation threshold for transactions, + that is, if more than one evaluation interval has passed since the first reorged block. + The actual implementation is a bit more restrictive, including a margin. + """ + now = self._reactor.seconds() + # equivalent to two weeks + avg_time_between_boundaries = ( + self._feature_settings.evaluation_interval * self._settings.AVG_TIME_BETWEEN_BLOCKS + ) + # We also use the MAX_FUTURE_TIMESTAMP_ALLOWED to take into account that we can receive a tx from the future. + # This is redundant considering we also use it in is_feature_active_for_transaction(), + # but we do it here too to restrict reorgs even further. + margin = self._settings.MAX_FUTURE_TIMESTAMP_ALLOWED + is_invalid = now >= common_block.timestamp + avg_time_between_boundaries - margin + + if is_invalid: + self._log.critical( + 'Reorg is invalid. Time difference between common block and now is too large.', + current_timestamp=now, + common_block_timestamp=common_block.timestamp + ) + + return not is_invalid + def _get_ancestor_iteratively(*, block: 'Block', ancestor_height: int) -> 'Block': """Given a block, returns its ancestor at a specific height by iterating over its ancestors. This is slow.""" diff --git a/hathor/manager.py b/hathor/manager.py index eae92da9e..9d81fba0c 100644 --- a/hathor/manager.py +++ b/hathor/manager.py @@ -243,6 +243,15 @@ def start(self) -> None: self.is_started = True self.log.info('start manager', network=self.network) + + if self.tx_storage.is_full_node_crashed(): + self.log.error( + 'Error initializing node. The last time you executed your full node it wasn\'t stopped correctly. ' + 'The storage is not reliable anymore and, because of that, you must remove your storage and do a ' + 'full sync (either from scratch or from a snapshot).' + ) + sys.exit(-1) + # If it's a full verification, we save on the storage that we are starting it # this is required because if we stop the initilization in the middle, the metadata # saved on the storage is not reliable anymore, only if we finish it @@ -989,13 +998,7 @@ def on_new_tx(self, tx: BaseTransaction, *, conn: Optional[HathorProtocol] = Non tx.update_initial_metadata(save=False) self.tx_storage.save_transaction(tx) self.tx_storage.add_to_indexes(tx) - try: - self.consensus_algorithm.update(tx) - except HathorError as e: - if not fails_silently: - raise InvalidNewTransaction('consensus update failed') from e - self.log.warn('on_new_tx(): consensus update failed', tx=tx.hash_hex, exc_info=True) - return False + self.consensus_algorithm.update(tx) assert self.verification_service.validate_full( tx, @@ -1081,7 +1084,7 @@ def _log_feature_states(self, vertex: BaseTransaction) -> None: def _log_if_feature_is_active(self, block: Block, feature: Feature) -> None: """Log if a feature is ACTIVE for a block. Used as part of the Feature Activation Phased Testing.""" - if self._feature_service.is_feature_active(block=block, feature=feature): + if self._feature_service.is_feature_active_for_block(block=block, feature=feature): self.log.info('Feature is ACTIVE for block', feature=feature.value, block_height=block.get_height()) def has_sync_version_capability(self) -> bool: diff --git a/hathor/transaction/storage/transaction_storage.py b/hathor/transaction/storage/transaction_storage.py index 3a9df6be7..363397faf 100644 --- a/hathor/transaction/storage/transaction_storage.py +++ b/hathor/transaction/storage/transaction_storage.py @@ -80,6 +80,9 @@ class TransactionStorage(ABC): # Key storage attribute to save if the manager is running _manager_running_attribute: str = 'manager_running' + # Key storage attribute to save if the full node crashed + _full_node_crashed_attribute: str = 'full_node_crashed' + # Ket storage attribute to save the last time the node started _last_start_attribute: str = 'last_start' @@ -976,6 +979,14 @@ def is_running_manager(self) -> bool: """ return self.get_value(self._manager_running_attribute) == '1' + def full_node_crashed(self) -> None: + """Save on storage that the full node crashed and cannot be recovered.""" + self.add_value(self._full_node_crashed_attribute, '1') + + def is_full_node_crashed(self) -> bool: + """Return whether the full node was crashed.""" + return self.get_value(self._full_node_crashed_attribute) == '1' + def get_last_started_at(self) -> int: """ Return the timestamp when the database was last started. """ diff --git a/tests/consensus/test_consensus.py b/tests/consensus/test_consensus.py index 27daa916a..44b17d361 100644 --- a/tests/consensus/test_consensus.py +++ b/tests/consensus/test_consensus.py @@ -1,6 +1,7 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock from hathor.conf import HathorSettings +from hathor.execution_manager import ExecutionManager from hathor.simulator.utils import add_new_block, add_new_blocks, gen_new_tx from hathor.transaction.storage import TransactionMemoryStorage from tests import unittest @@ -33,10 +34,15 @@ def test_unhandled_exception(self): class MyError(Exception): pass + execution_manager_mock = Mock(spec_set=ExecutionManager) + manager.consensus_algorithm._execution_manager = execution_manager_mock manager.consensus_algorithm._unsafe_update = MagicMock(side_effect=MyError) - with self.assertRaises(MyError): - manager.propagate_tx(tx, fails_silently=False) + manager.propagate_tx(tx, fails_silently=False) + + execution_manager_mock.crash_and_exit.assert_called_once_with( + reason=f"Consensus update failed for tx {tx.hash_hex}" + ) tx2 = manager.tx_storage.get_transaction(tx.hash) meta2 = tx2.get_metadata() diff --git a/tests/execution_manager/__init__.py b/tests/execution_manager/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/execution_manager/test_execution_manager.py b/tests/execution_manager/test_execution_manager.py new file mode 100644 index 000000000..167e438b5 --- /dev/null +++ b/tests/execution_manager/test_execution_manager.py @@ -0,0 +1,42 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from unittest.mock import Mock, patch + +from hathor.event import EventManager +from hathor.execution_manager import ExecutionManager +from hathor.transaction.storage import TransactionStorage + + +def test_crash_and_exit() -> None: + tx_storage_mock = Mock(spec_set=TransactionStorage) + event_manager_mock = Mock(spec_set=EventManager) + log_mock = Mock() + manager = ExecutionManager(tx_storage=tx_storage_mock, event_manager=event_manager_mock) + manager._log = log_mock + reason = 'some critical failure' + + with patch.object(os, '_exit') as exit_mock: + manager.crash_and_exit(reason=reason) + + tx_storage_mock.full_node_crashed.assert_called_once() + event_manager_mock.full_node_crashed.assert_called_once() + log_mock.critical.assert_called_once_with( + 'Critical failure occurred, causing the full node to halt execution. Manual intervention is required.', + reason=reason, + exc_info=True + ) + + exit_mock.assert_called_once_with(-1) diff --git a/tests/feature_activation/test_feature_service.py b/tests/feature_activation/test_feature_service.py index 4a01069d3..d854ec5fe 100644 --- a/tests/feature_activation/test_feature_service.py +++ b/tests/feature_activation/test_feature_service.py @@ -17,7 +17,8 @@ import pytest -from hathor.conf import HathorSettings +from hathor.conf.get_settings import get_settings +from hathor.conf.settings import HathorSettings from hathor.feature_activation.feature import Feature from hathor.feature_activation.feature_service import ( BlockIsMissingSignal, @@ -29,12 +30,13 @@ from hathor.feature_activation.model.feature_description import FeatureDescription from hathor.feature_activation.model.feature_state import FeatureState from hathor.feature_activation.settings import Settings as FeatureSettings +from hathor.reactor.reactor_protocol import ReactorProtocol from hathor.transaction import Block from hathor.transaction.storage import TransactionStorage def _get_blocks_and_storage() -> tuple[list[Block], TransactionStorage]: - settings = HathorSettings() + settings = get_settings() genesis_hash = settings.GENESIS_BLOCK_HASH blocks: list[Block] = [] feature_activation_bits = [ @@ -104,22 +106,32 @@ def tx_storage() -> TransactionStorage: @pytest.fixture -def feature_settings() -> FeatureSettings: - return FeatureSettings( +def settings() -> HathorSettings: + settings = Mock(spec_set=HathorSettings) + settings.FEATURE_ACTIVATION = FeatureSettings( evaluation_interval=4, default_threshold=3 ) + return settings + @pytest.fixture -def service(feature_settings: FeatureSettings, tx_storage: TransactionStorage) -> FeatureService: - service = FeatureService( - feature_settings=feature_settings, +def service(settings: HathorSettings, tx_storage: TransactionStorage) -> FeatureService: + return get_service(settings.FEATURE_ACTIVATION, tx_storage) + + +def get_service(feature_settings: FeatureSettings, tx_storage: TransactionStorage) -> FeatureService: + reactor = Mock(spec_set=ReactorProtocol) + settings = Mock(spec_set=HathorSettings) + settings.FEATURE_ACTIVATION = feature_settings + + return FeatureService( + reactor=reactor, + settings=settings, tx_storage=tx_storage ) - return service - def test_get_state_genesis(block_mocks: list[Block], service: FeatureService) -> None: block = block_mocks[0] @@ -163,10 +175,7 @@ def test_get_state_from_defined( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -194,10 +203,7 @@ def test_get_state_from_started_to_failed( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -225,10 +231,7 @@ def test_get_state_from_started_to_must_signal_on_timeout( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -257,10 +260,7 @@ def test_get_state_from_started_to_locked_in_on_default_threshold( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -288,10 +288,7 @@ def test_get_state_from_started_to_locked_in_on_custom_threshold( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -327,10 +324,7 @@ def test_get_state_from_started_to_started( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -356,10 +350,7 @@ def test_get_state_from_must_signal_to_locked_in( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -388,10 +379,7 @@ def test_get_state_from_locked_in_to_active( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -420,10 +408,7 @@ def test_get_state_from_locked_in_to_locked_in( ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -445,10 +430,7 @@ def test_get_state_from_active(block_mocks: list[Block], tx_storage: Transaction ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -470,7 +452,7 @@ def test_caching_mechanism(block_mocks: list[Block], tx_storage: TransactionStor ) } ) - service = FeatureService(feature_settings=feature_settings, tx_storage=tx_storage) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] calculate_new_state_mock = Mock(wraps=service._calculate_new_state) @@ -501,13 +483,10 @@ def test_is_feature_active(block_mocks: list[Block], tx_storage: TransactionStor ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] - result = service.is_feature_active(block=block, feature=Feature.NOP_FEATURE_1) + result = service.is_feature_active_for_block(block=block, feature=Feature.NOP_FEATURE_1) assert result is True @@ -525,10 +504,7 @@ def test_get_state_from_failed(block_mocks: list[Block], tx_storage: Transaction ) } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.get_state(block=block, feature=Feature.NOP_FEATURE_1) @@ -553,10 +529,7 @@ def test_get_bits_description(tx_storage: TransactionStorage) -> None: Feature.NOP_FEATURE_2: criteria_mock_2 } ) - service = FeatureService( - feature_settings=feature_settings, - tx_storage=tx_storage - ) + service = get_service(feature_settings, tx_storage) def get_state(self: FeatureService, *, block: Block, feature: Feature) -> FeatureState: states = { @@ -579,56 +552,53 @@ def get_state(self: FeatureService, *, block: Block, feature: Feature) -> Featur @pytest.mark.parametrize( ['block_height', 'ancestor_height'], [ - (21, 21), (21, 100), (10, 15), (10, 11), - (0, 0), ] ) def test_get_ancestor_at_height_invalid( - feature_settings: FeatureSettings, + service: FeatureService, block_mocks: list[Block], - tx_storage: TransactionStorage, block_height: int, ancestor_height: int ) -> None: - service = FeatureService(feature_settings=feature_settings, tx_storage=tx_storage) block = block_mocks[block_height] with pytest.raises(AssertionError) as e: service._get_ancestor_at_height(block=block, height=ancestor_height) assert str(e.value) == ( - f"ancestor height must be lower than the block's height: {ancestor_height} >= {block_height}" + f"ancestor height must not be greater than the block's height: {ancestor_height} > {block_height}" ) @pytest.mark.parametrize( ['block_height', 'ancestor_height'], [ + (21, 21), (21, 20), (21, 10), (21, 0), (15, 10), (15, 0), (1, 0), + (0, 0) ] ) def test_get_ancestor_at_height( - feature_settings: FeatureSettings, + service: FeatureService, block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int, ancestor_height: int ) -> None: - service = FeatureService(feature_settings=feature_settings, tx_storage=tx_storage) block = block_mocks[block_height] result = service._get_ancestor_at_height(block=block, height=ancestor_height) assert result == block_mocks[ancestor_height] assert result.get_height() == ancestor_height - assert cast(Mock, tx_storage.get_transaction_by_height).call_count == 1 + assert cast(Mock, tx_storage.get_transaction_by_height).call_count <= 1 @pytest.mark.parametrize( @@ -643,13 +613,12 @@ def test_get_ancestor_at_height( ] ) def test_get_ancestor_at_height_voided( - feature_settings: FeatureSettings, + service: FeatureService, block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int, ancestor_height: int ) -> None: - service = FeatureService(feature_settings=feature_settings, tx_storage=tx_storage) block = block_mocks[block_height] block.get_metadata().voided_by = {b'some'} result = service._get_ancestor_at_height(block=block, height=ancestor_height) @@ -706,7 +675,7 @@ def test_check_must_signal( ) } ) - service = FeatureService(feature_settings=feature_settings, tx_storage=tx_storage) + service = get_service(feature_settings, tx_storage) block = block_mocks[block_height] result = service.is_signaling_mandatory_features(block) diff --git a/tests/feature_activation/test_feature_simulation.py b/tests/feature_activation/test_feature_simulation.py index 2e7e1f307..9c635857b 100644 --- a/tests/feature_activation/test_feature_simulation.py +++ b/tests/feature_activation/test_feature_simulation.py @@ -16,6 +16,7 @@ from unittest.mock import Mock, patch import pytest +from twisted.python.failure import Failure from hathor.builder import Builder from hathor.conf.get_settings import get_settings @@ -351,12 +352,24 @@ def test_reorg(self) -> None: } ) - settings = get_settings()._replace(FEATURE_ACTIVATION=feature_settings) + settings = get_settings()._replace( + FEATURE_ACTIVATION=feature_settings, + MAX_FUTURE_TIMESTAMP_ALLOWED=30, # We change this to allow a longer reorg + ) + + # We create two connected peers builder = self.get_simulator_builder().set_settings(settings) artifacts = self.simulator.create_artifacts(builder) feature_service = artifacts.feature_service manager = artifacts.manager + builder2 = self.get_simulator_builder().set_settings(settings) + artifacts2 = self.simulator.create_artifacts(builder2) + manager2 = artifacts2.manager + + connection = FakeConnection(manager, manager2) + self.simulator.add_connection(connection) + feature_resource = FeatureResource( feature_settings=feature_settings, feature_service=feature_service, @@ -365,7 +378,7 @@ def test_reorg(self) -> None: web_client = StubSite(feature_resource) # at the beginning, the feature is DEFINED: - self.simulator.run(60) + self.simulator.run(10) result = self._get_result(web_client) assert result == dict( block_height=0, @@ -386,7 +399,7 @@ def test_reorg(self) -> None: # at block 4, the feature becomes STARTED with 0% acceptance add_new_blocks(manager, 4) - self.simulator.run(60) + self.simulator.run(10) result = self._get_result(web_client) assert result == dict( block_height=4, @@ -405,10 +418,14 @@ def test_reorg(self) -> None: ] ) + # We remove the connection between the peers + connection.disconnect(Failure(Exception('testing'))) + self.simulator.remove_connection(connection) + # at block 7, acceptance is 25% (we're signaling 1 block out of 4) add_new_blocks(manager, 2) add_new_blocks(manager, 1, signal_bits=0b10) - self.simulator.run(60) + self.simulator.run(10) result = self._get_result(web_client) assert result == dict( block_height=7, @@ -431,7 +448,7 @@ def test_reorg(self) -> None: # so the feature will be locked-in in the next block add_new_blocks(manager, 1) add_new_blocks(manager, 3, signal_bits=0b10) - self.simulator.run(60) + self.simulator.run(10) result = self._get_result(web_client) assert result == dict( block_height=11, @@ -452,7 +469,7 @@ def test_reorg(self) -> None: # at block 12, the feature is locked-in add_new_blocks(manager, 1) - self.simulator.run(60) + self.simulator.run(10) result = self._get_result(web_client) assert result == dict( block_height=12, @@ -473,7 +490,7 @@ def test_reorg(self) -> None: # at block 16, the feature is activated add_new_blocks(manager, 4) - self.simulator.run(60) + self.simulator.run(10) result = self._get_result(web_client) assert result == dict( block_height=16, @@ -492,18 +509,15 @@ def test_reorg(self) -> None: ] ) - # We then create a new manager with one more block (17 vs 16), so its blockchain wins when - # both managers are connected. This causes a reorg and the feature goes back to the STARTED state. - builder2 = self.get_simulator_builder().set_settings(settings) - artifacts2 = self.simulator.create_artifacts(builder2) - manager2 = artifacts2.manager - - add_new_blocks(manager2, 17) - self.simulator.run(60) + # We then create 13 blocks on manager2, one more block than manager1 (13 vs 12, from their common block), + # so its blockchain wins when both managers are reconnected. + add_new_blocks(manager2, 13) + self.simulator.run(10) - connection = FakeConnection(manager, manager2) + # We reconnect the peers. This causes a reorg and the feature goes back to the STARTED state. + connection.reconnect() self.simulator.add_connection(connection) - self.simulator.run(60) + self.simulator.run(10) result = self._get_result(web_client) assert result == dict(