Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions hathor/feature_activation/feature_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,30 +35,38 @@ def is_feature_active(self, *, block: Block, feature: Feature) -> bool:
return state == FeatureState.ACTIVE

def get_state(self, *, block: Block, feature: Feature) -> FeatureState:
"""Returns the state of a feature at a certain block."""
"""Returns the state of a feature at a certain block. Uses block metadata to cache states."""

# per definition, the genesis block is in the DEFINED state for all features
if block.is_genesis:
return FeatureState.DEFINED

if state := block.get_feature_state(feature=feature):
return state

# All blocks within the same evaluation interval have the same state, that is, the state is only defined for
# the block in each interval boundary. Therefore, we get the state of the previous boundary.
# the block in each interval boundary. Therefore, we get the state of the previous boundary block or calculate
# a new state if this block is a boundary 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)
previous_state = self.get_state(block=previous_boundary_block, feature=feature)
previous_boundary_state = self.get_state(block=previous_boundary_block, feature=feature)

if offset_to_boundary != 0:
return previous_state
return previous_boundary_state

return self._calculate_new_state(
new_state = self._calculate_new_state(
boundary_block=block,
feature=feature,
previous_state=previous_state
previous_state=previous_boundary_state
)

block.update_feature_state(feature=feature, state=new_state)

return new_state

def _calculate_new_state(
self,
*,
Expand Down
19 changes: 19 additions & 0 deletions hathor/transaction/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from hathor import daa
from hathor.checkpoint import Checkpoint
from hathor.conf import HathorSettings
from hathor.feature_activation.feature import Feature
from hathor.feature_activation.model.feature_state import FeatureState
from hathor.profiler import get_cpu_profiler
from hathor.transaction import BaseTransaction, TxOutput, TxVersion
from hathor.transaction.exceptions import (
Expand Down Expand Up @@ -420,3 +422,20 @@ def _get_feature_activation_bitmask(cls) -> int:
bitmask = (1 << settings.FEATURE_ACTIVATION.max_signal_bits) - 1

return bitmask

def get_feature_state(self, *, feature: Feature) -> Optional[FeatureState]:
"""Returns the state of a feature from metadata."""
metadata = self.get_metadata()
feature_states = metadata.feature_states or {}

return feature_states.get(feature)

def update_feature_state(self, *, feature: Feature, state: FeatureState) -> None:
"""Updates the state of a feature in metadata and persists it."""
assert self.storage is not None
metadata = self.get_metadata()
feature_states = metadata.feature_states or {}
feature_states[feature] = state
metadata.feature_states = feature_states

self.storage.save_transaction(self, only_metadata=True)
22 changes: 19 additions & 3 deletions hathor/transaction/transaction_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Optional

from hathor.feature_activation.feature import Feature
from hathor.feature_activation.model.feature_state import FeatureState
from hathor.transaction.validation_state import ValidationState
from hathor.util import practically_equal

Expand Down Expand Up @@ -49,6 +51,9 @@ class TransactionMetadata:
# the previous boundary block up to this block, including it. LSB is on the left.
feature_activation_bit_counts: Optional[list[int]]

# A dict of features in the feature activation process and their respective state. Must only be used by Blocks,
# is None otherwise.
feature_states: Optional[dict[Feature, FeatureState]] = None
# It must be a weakref.
_tx_ref: Optional['ReferenceType[BaseTransaction]']

Expand Down Expand Up @@ -181,9 +186,9 @@ def __eq__(self, other: Any) -> bool:
"""Override the default Equals behavior"""
if not isinstance(other, TransactionMetadata):
return False
for field in ['hash', 'conflict_with', 'voided_by', 'received_by',
'children', 'accumulated_weight', 'twins', 'score',
'first_block', 'validation', 'min_height', 'feature_activation_bit_counts']:
for field in ['hash', 'conflict_with', 'voided_by', 'received_by', 'children',
'accumulated_weight', 'twins', 'score', 'first_block', 'validation',
'min_height', 'feature_activation_bit_counts', 'feature_states']:
if (getattr(self, field) or None) != (getattr(other, field) or None):
return False

Expand Down Expand Up @@ -219,6 +224,10 @@ def to_json(self) -> dict[str, Any]:
data['height'] = self.height
data['min_height'] = self.min_height
data['feature_activation_bit_counts'] = self.feature_activation_bit_counts

if self.feature_states is not None:
data['feature_states'] = {feature.value: state.value for feature, state in self.feature_states.items()}

if self.first_block is not None:
data['first_block'] = self.first_block.hex()
else:
Expand Down Expand Up @@ -270,6 +279,13 @@ def create_from_json(cls, data: dict[str, Any]) -> 'TransactionMetadata':
meta.min_height = data.get('min_height', 0)
meta.feature_activation_bit_counts = data.get('feature_activation_bit_counts', [])

feature_states_raw = data.get('feature_states')
if feature_states_raw:
meta.feature_states = {
Feature(feature): FeatureState(feature_state)
for feature, feature_state in feature_states_raw.items()
}

first_block_raw = data.get('first_block', None)
if first_block_raw:
meta.first_block = bytes.fromhex(first_block_raw)
Expand Down
30 changes: 30 additions & 0 deletions tests/feature_activation/test_feature_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,36 @@ def test_get_state_from_active(block_mocks: list[Block], tx_storage: Transaction
assert result == FeatureState.ACTIVE


@pytest.mark.parametrize('block_height', [12, 13, 14, 15])
def test_caching_mechanism(block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int) -> None:
feature_settings = FeatureSettings.construct(
evaluation_interval=4,
features={
Feature.NOP_FEATURE_1: Criteria.construct(
bit=Mock(),
start_height=0,
timeout_height=4,
activate_on_timeout=True,
version=Mock()
)
}
)
service = FeatureService(feature_settings=feature_settings, tx_storage=tx_storage)
block = block_mocks[block_height]
calculate_new_state_mock = Mock(wraps=service._calculate_new_state)

with patch.object(FeatureService, '_calculate_new_state', calculate_new_state_mock):
result1 = service.get_state(block=block, feature=Feature.NOP_FEATURE_1)

assert result1 == FeatureState.ACTIVE
assert calculate_new_state_mock.call_count == 3

result2 = service.get_state(block=block, feature=Feature.NOP_FEATURE_1)

assert result2 == FeatureState.ACTIVE
assert calculate_new_state_mock.call_count == 3


@pytest.mark.parametrize('block_height', [12, 13, 14, 15])
def test_is_feature_active(block_mocks: list[Block], tx_storage: TransactionStorage, block_height: int) -> None:
feature_settings = FeatureSettings.construct(
Expand Down
Loading