Skip to content

Commit 75dfe92

Browse files
tcoratgerclaude
andauthored
refactor(sync): collapse SyncState to enum plus single non-trivial property (leanEthereum#729)
SyncState was 163 lines for a 3-state enum. Most of the surface was trivial `state == SyncState.X` wrappers and a state-machine helper whose only caller was sync service. Net change: -225 lines across 4 files, zero behavior change. Changes: - states.py drops `is_idle`, `is_syncing`, `is_synced` properties (trivial == wrappers), `can_transition_to` (one caller), and the module-level `_VALID_TRANSITIONS` table. Keeps the enum and `accepts_gossip`, the only property doing real work. - service.py drops `SyncService.is_syncing` and `is_synced` public wrappers (zero external callers). Replaces three call sites that used the removed enum properties with direct comparisons. - service.py rewrites `_transition_to` to inline the validity check. The original 5-edge transition table becomes two invariants: no self-transitions, no IDLE -> SYNCED shortcut. Same semantics verified case-by-case against the original table. - test_states.py shrinks to cover the surviving surface (enum identity and accepts_gossip). The `_transition_to` invariants now have coverage in test_service.py, including a new self-transition test to replace the parametrized test that lived on `can_transition_to`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5c45e17 commit 75dfe92

4 files changed

Lines changed: 43 additions & 265 deletions

File tree

src/lean_spec/subspecs/sync/service.py

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -406,16 +406,6 @@ def state(self) -> SyncState:
406406
"""Current sync state."""
407407
return self._state
408408

409-
@property
410-
def is_syncing(self) -> bool:
411-
"""Check if actively syncing."""
412-
return self._state.is_syncing
413-
414-
@property
415-
def is_synced(self) -> bool:
416-
"""Check if synced with network."""
417-
return self._state.is_synced
418-
419409
def get_progress(self) -> SyncProgress:
420410
"""
421411
Get current sync progress.
@@ -722,7 +712,7 @@ async def _check_sync_trigger(self) -> None:
722712
#
723713
# If already SYNCING, we should not re-trigger.
724714
# This prevents redundant state transitions.
725-
if self._state.is_syncing:
715+
if self._state == SyncState.SYNCING:
726716
return
727717

728718
# Guard: Require peer information before syncing.
@@ -741,7 +731,7 @@ async def _check_sync_trigger(self) -> None:
741731
# network has finalized past our head, we are definitely behind.
742732
if network_finalized > head_slot:
743733
await self._transition_to(SyncState.SYNCING)
744-
elif self._state.is_idle:
734+
elif self._state == SyncState.IDLE:
745735
# Transition from IDLE even if caught up.
746736
#
747737
# IDLE -> SYNCING enables gossip processing. Even if our head matches
@@ -756,7 +746,7 @@ async def _check_sync_complete(self) -> None:
756746
finalized slot and there are no orphan blocks.
757747
"""
758748
# Guard: Only check completion while actively syncing.
759-
if not self._state.is_syncing:
749+
if self._state != SyncState.SYNCING:
760750
return
761751

762752
# Invariant: All orphan blocks must be resolved before declaring synced.
@@ -781,23 +771,19 @@ async def _check_sync_complete(self) -> None:
781771
await self._transition_to(SyncState.SYNCED)
782772

783773
async def _transition_to(self, new_state: SyncState) -> None:
784-
"""
785-
Transition to a new sync state.
774+
"""Transition to a new sync state, rejecting invalid moves.
786775
787-
Args:
788-
new_state: Target state.
776+
Two invariants are enforced:
777+
778+
- No self-transitions: a transition must change the current state.
779+
- No IDLE -> SYNCED shortcut: SYNCING must run before SYNCED is reached.
789780
790-
Raises:
791-
ValueError: If transition is not allowed.
781+
Every other (from, to) pair is allowed, including any state -> IDLE.
792782
"""
793-
# Validate the transition against the state machine rules.
794-
#
795-
# The state machine enforces valid transitions:
796-
# - IDLE -> SYNCING (start sync)
797-
# - SYNCING -> SYNCED (caught up)
798-
# - SYNCED -> SYNCING (fell behind)
799-
# - Any -> IDLE (reset)
800-
if not self._state.can_transition_to(new_state):
783+
forbidden = new_state == self._state or (
784+
self._state == SyncState.IDLE and new_state == SyncState.SYNCED
785+
)
786+
if forbidden:
801787
raise ValueError(f"Invalid state transition: {self._state.name} -> {new_state.name}")
802788

803789
self._state = new_state

src/lean_spec/subspecs/sync/states.py

Lines changed: 14 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -6,158 +6,30 @@
66

77

88
class SyncState(Enum):
9-
"""
10-
Sync service states representing the current synchronization phase.
11-
12-
This is a simple three-state machine for reactive synchronization:
13-
14-
State Machine Diagram
15-
16-
::
17-
18-
IDLE --> SYNCING --> SYNCED
19-
^ | |
20-
+---------+-----------+
21-
22-
The Lifecycle
9+
"""Three-phase progression for the sync service.
2310
24-
A newly started node follows this progression:
11+
Lifecycle:
2512
26-
1. **IDLE**: Node starts, no peers connected yet
27-
2. **SYNCING**: Peers report chain ahead of us; react to gossip blocks
28-
3. **SYNCED**: Local head reaches network finalized slot; fully synchronized
13+
IDLE -> SYNCING -> SYNCED
14+
^ | |
15+
+---------+---------+
2916
30-
How It Works
17+
- IDLE: no peers connected, or shutdown requested.
18+
- SYNCING: active block processing and backfill driven by gossip.
19+
- SYNCED: caught up to the network finalized slot.
3120
32-
- Blocks arrive via gossip
33-
- If parent is known, process immediately
34-
- If parent is unknown, cache block and fetch parent (backfill)
35-
- Backfill happens naturally within SYNCING, not as a separate state
36-
37-
Transitions
38-
39-
IDLE -> SYNCING
40-
- Triggered when: Peers connected and we need to sync
41-
- Action: Start processing gossip blocks
42-
43-
SYNCING -> SYNCED
44-
- Triggered when: local_head >= network_finalized_slot and no orphans
45-
- Action: Transition to passive mode
46-
47-
SYNCED -> SYNCING
48-
- Triggered when: Gap detected or fell behind
49-
- Action: Resume active sync
50-
51-
Any -> IDLE
52-
- Triggered when: No connected peers or shutdown requested
53-
- Action: Pause all sync activity
21+
Either active state may fall back to IDLE on disconnect.
22+
SYNCED falls back to SYNCING when a gap reappears.
5423
"""
5524

5625
IDLE = auto()
57-
"""
58-
Inactive state: no synchronization in progress.
59-
60-
The sync service enters IDLE when:
61-
62-
- **Startup**: Before any peers connect
63-
- **No peers**: All peers disconnected or unreachable
64-
- **Shutdown**: Graceful termination requested
65-
66-
While IDLE, the service waits passively. No requests are sent. The only
67-
way out is connecting to peers and receiving Status messages.
68-
"""
69-
26+
"""No peers connected, or shutdown requested."""
7027
SYNCING = auto()
71-
"""
72-
Active synchronization state: processing gossip and backfilling.
73-
74-
SYNCING is the main working state. The node receives gossip blocks and
75-
processes them, backfilling missing parents as needed.
76-
77-
In this state:
78-
79-
- Gossip blocks are processed immediately if parent is known
80-
- Unknown parents trigger backfill requests
81-
- Cached blocks are processed when parents arrive
82-
"""
83-
28+
"""Active block processing and backfill driven by gossip."""
8429
SYNCED = auto()
85-
"""
86-
Fully synchronized state: at or past network finalized slot.
87-
88-
SYNCED is the goal state. The node's head has reached or passed the
89-
network's finalized checkpoint. This means:
90-
91-
- We have all finalized blocks
92-
- We are following the chain head in real-time
93-
- No active sync activity is needed
94-
95-
In this state:
96-
97-
- Gossip blocks are still processed
98-
- Falls back to SYNCING if gaps appear
99-
"""
100-
101-
def can_transition_to(self, target: SyncState) -> bool:
102-
"""
103-
Check if transition to target state is valid.
104-
105-
State machines enforce invariants through transition rules. This method
106-
encodes those rules. Callers should check validity before transitioning
107-
to catch logic errors early.
108-
109-
Args:
110-
target: The proposed target state.
111-
112-
Returns:
113-
True if the transition is allowed by the state machine rules.
114-
"""
115-
return target in _VALID_TRANSITIONS.get(self, set())
116-
117-
@property
118-
def is_idle(self) -> bool:
119-
"""
120-
Check if this state represents inactivity.
121-
122-
Returns:
123-
True if no synchronization is in progress.
124-
"""
125-
return self == SyncState.IDLE
126-
127-
@property
128-
def is_syncing(self) -> bool:
129-
"""
130-
Check if this state represents active synchronization.
131-
132-
Returns:
133-
True if the state involves active block processing.
134-
"""
135-
return self == SyncState.SYNCING
136-
137-
@property
138-
def is_synced(self) -> bool:
139-
"""
140-
Check if this state represents full synchronization.
141-
142-
Returns:
143-
True if the node is caught up with the network.
144-
"""
145-
return self == SyncState.SYNCED
30+
"""Caught up to the network finalized slot."""
14631

14732
@property
14833
def accepts_gossip(self) -> bool:
149-
"""
150-
Check if gossip blocks should be processed in this state.
151-
152-
Returns:
153-
True if incoming gossip blocks should be processed.
154-
"""
34+
"""Whether incoming gossip blocks should be processed in this state."""
15535
return self in {SyncState.SYNCING, SyncState.SYNCED}
156-
157-
158-
_VALID_TRANSITIONS: dict[SyncState, set[SyncState]] = {
159-
SyncState.IDLE: {SyncState.SYNCING},
160-
SyncState.SYNCING: {SyncState.SYNCED, SyncState.IDLE},
161-
SyncState.SYNCED: {SyncState.SYNCING, SyncState.IDLE},
162-
}
163-
"""Valid state transitions for the sync state machine."""

tests/lean_spec/subspecs/sync/test_service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,16 @@ async def test_idle_to_synced_raises_value_error(
537537
with pytest.raises(ValueError, match="Invalid state transition"):
538538
await sync_service._transition_to(SyncState.SYNCED)
539539

540+
async def test_self_transition_raises_value_error(
541+
self,
542+
sync_service: SyncService,
543+
) -> None:
544+
"""A transition to the current state is rejected as a no-op move."""
545+
assert sync_service.state == SyncState.IDLE
546+
547+
with pytest.raises(ValueError, match="Invalid state transition"):
548+
await sync_service._transition_to(SyncState.IDLE)
549+
540550

541551
class TestIdleToCaughtUp:
542552
"""Tests for IDLE-to-SYNCING when already caught up."""

tests/lean_spec/subspecs/sync/test_states.py

Lines changed: 6 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
from __future__ import annotations
44

5-
import pytest
6-
75
from lean_spec.subspecs.sync.states import SyncState
86

97

@@ -26,110 +24,22 @@ def test_states_are_unique(self) -> None:
2624
assert len(values) == len(set(values))
2725

2826

29-
class TestSyncStateTransitions:
30-
"""Tests for state transition validation."""
31-
32-
def test_idle_can_transition_to_syncing(self) -> None:
33-
"""IDLE can transition to SYNCING."""
34-
assert SyncState.IDLE.can_transition_to(SyncState.SYNCING)
35-
36-
def test_idle_cannot_transition_to_synced(self) -> None:
37-
"""IDLE cannot transition directly to SYNCED."""
38-
assert not SyncState.IDLE.can_transition_to(SyncState.SYNCED)
39-
40-
def test_syncing_valid_transitions(self) -> None:
41-
"""SYNCING can transition to SYNCED or IDLE."""
42-
assert SyncState.SYNCING.can_transition_to(SyncState.SYNCED)
43-
assert SyncState.SYNCING.can_transition_to(SyncState.IDLE)
44-
45-
def test_synced_valid_transitions(self) -> None:
46-
"""SYNCED can transition to SYNCING or IDLE."""
47-
assert SyncState.SYNCED.can_transition_to(SyncState.SYNCING)
48-
assert SyncState.SYNCED.can_transition_to(SyncState.IDLE)
49-
50-
51-
class TestSyncStateIsSyncing:
52-
"""Tests for the is_syncing property."""
53-
54-
def test_idle_is_not_syncing(self) -> None:
55-
"""IDLE state is not actively syncing."""
56-
assert not SyncState.IDLE.is_syncing
57-
58-
def test_syncing_is_syncing(self) -> None:
59-
"""SYNCING state is actively syncing."""
60-
assert SyncState.SYNCING.is_syncing
61-
62-
def test_synced_is_not_syncing(self) -> None:
63-
"""SYNCED state is not actively syncing."""
64-
assert not SyncState.SYNCED.is_syncing
65-
66-
def test_syncing_states_set(self) -> None:
67-
"""Exactly one state is a syncing state."""
68-
syncing_states = [s for s in SyncState if s.is_syncing]
69-
assert len(syncing_states) == 1
70-
assert syncing_states[0] == SyncState.SYNCING
71-
72-
7327
class TestSyncStateAcceptsGossip:
7428
"""Tests for the accepts_gossip property."""
7529

7630
def test_idle_does_not_accept_gossip(self) -> None:
77-
"""IDLE state does not accept gossip blocks."""
31+
"""An idle service ignores incoming gossip blocks."""
7832
assert not SyncState.IDLE.accepts_gossip
7933

8034
def test_syncing_accepts_gossip(self) -> None:
81-
"""SYNCING state accepts gossip blocks."""
35+
"""An actively syncing service processes incoming gossip blocks."""
8236
assert SyncState.SYNCING.accepts_gossip
8337

8438
def test_synced_accepts_gossip(self) -> None:
85-
"""SYNCED state accepts gossip blocks."""
39+
"""A synced service keeps processing gossip blocks for live updates."""
8640
assert SyncState.SYNCED.accepts_gossip
8741

8842
def test_gossip_accepting_states_set(self) -> None:
89-
"""Exactly two states accept gossip."""
90-
gossip_states = [s for s in SyncState if s.accepts_gossip]
91-
assert len(gossip_states) == 2
92-
assert set(gossip_states) == {SyncState.SYNCING, SyncState.SYNCED}
93-
94-
95-
class TestSyncStateTransitionPaths:
96-
"""Tests for valid complete transition paths through the state machine."""
97-
98-
def test_happy_path_to_synced(self) -> None:
99-
"""Test the happy path: IDLE -> SYNCING -> SYNCED."""
100-
current = SyncState.IDLE
101-
102-
assert current.can_transition_to(SyncState.SYNCING)
103-
current = SyncState.SYNCING
104-
105-
assert current.can_transition_to(SyncState.SYNCED)
106-
current = SyncState.SYNCED
107-
108-
assert current == SyncState.SYNCED
109-
110-
def test_synced_to_syncing_cycle(self) -> None:
111-
"""Test SYNCED -> SYNCING for gap handling."""
112-
current = SyncState.SYNCED
113-
114-
assert current.can_transition_to(SyncState.SYNCING)
115-
current = SyncState.SYNCING
116-
117-
assert current.can_transition_to(SyncState.SYNCED)
118-
current = SyncState.SYNCED
119-
120-
assert current == SyncState.SYNCED
121-
122-
123-
class TestSyncStateEdgeCases:
124-
"""Tests for edge cases and invariants."""
125-
126-
@pytest.mark.parametrize("state", list(SyncState))
127-
def test_no_self_transitions(self, state: SyncState) -> None:
128-
"""No state can transition to itself."""
129-
assert not state.can_transition_to(state)
130-
131-
def test_idle_only_has_one_outgoing_transition(self) -> None:
132-
"""IDLE has exactly one valid outgoing transition."""
133-
valid_targets = [s for s in SyncState if SyncState.IDLE.can_transition_to(s)]
134-
assert len(valid_targets) == 1
135-
assert valid_targets[0] == SyncState.SYNCING
43+
"""Exactly the two non-idle states accept gossip."""
44+
gossip_states = {s for s in SyncState if s.accepts_gossip}
45+
assert gossip_states == {SyncState.SYNCING, SyncState.SYNCED}

0 commit comments

Comments
 (0)