@ -6,92 +6,122 @@ import "testing"
// Phase 05 Slice 1: Engine ownership/fencing tests
// ============================================================
// Helper: build ReplicaAssignment list from map.
func replicas ( m map [ string ] Endpoint ) [ ] ReplicaAssignment {
var out [ ] ReplicaAssignment
for id , ep := range m {
out = append ( out , ReplicaAssignment { ReplicaID : id , Endpoint : ep } )
}
return out
}
// --- Changed-address invalidation (A10) ---
func TestEngine_ChangedAddress_InvalidatesSession ( t * testing . T ) {
func TestEngine_ChangedDataAddr_PreservesSenderIdentity ( t * testing . T ) {
// THE core V2 test: DataAddr changes but stable ReplicaID stays.
// Sender must survive. Session must be invalidated (endpoint changed).
r := NewRegistry ( )
r . ApplyAssignment ( AssignmentIntent {
Endpoints : map [ string ] Endpoint {
"r1:9333" : { DataAddr : "r1:9333" , CtrlAddr : "r1:9334" , Version : 1 } ,
Replicas : [ ] ReplicaAssignme nt{
{ ReplicaID : "replica- 1" , Endpoint : Endpoint { DataAddr : "10.0.0. 1:9333" , CtrlAddr : "10.0.0. 1:9334" , Version : 1 } } ,
} ,
Epoch : 1 ,
RecoveryTargets : map [ string ] SessionKind { "r1:9333 " : SessionCatchUp } ,
RecoveryTargets : map [ string ] SessionKind { "replica- 1" : SessionCatchUp } ,
} )
s := r . Sender ( "r1:9333 " )
s := r . Sender ( "replica- 1" )
sessID := s . SessionID ( )
s . BeginConnect ( sessID )
r . Reconcile ( map [ string ] Endpoint {
"r1:9333" : { DataAddr : "r1:9333" , CtrlAddr : "r1:9445" , Version : 2 } ,
// DataAddr changes (replica restarted on different port/IP).
r . Reconcile ( [ ] ReplicaAssignment {
{ ReplicaID : "replica-1" , Endpoint : Endpoint { DataAddr : "10.0.0.2:9333" , CtrlAddr : "10.0.0.2:9334" , Version : 2 } } ,
} , 1 )
// Sender identity preserved (same pointer, same ReplicaID).
if r . Sender ( "replica-1" ) != s {
t . Fatal ( "sender identity must be preserved across DataAddr change" )
}
// Session invalidated (endpoint changed).
if s . HasActiveSession ( ) {
t . Fatal ( "session should be invalidated" )
t . Fatal ( "session should be invalidated by DataAddr change " )
}
if s . State ( ) != StateDisconnected {
t . Fatalf ( "state=%s" , s . State ( ) )
// Endpoint updated.
if s . Endpoint ( ) . DataAddr != "10.0.0.2:9333" {
t . Fatalf ( "endpoint not updated: %s" , s . Endpoint ( ) . DataAddr )
}
// New session can be attached on the updated endpoint.
result := r . ApplyAssignment ( AssignmentIntent {
Replicas : [ ] ReplicaAssignment {
{ ReplicaID : "replica-1" , Endpoint : Endpoint { DataAddr : "10.0.0.2:9333" , CtrlAddr : "10.0.0.2:9334" , Version : 2 } } ,
} ,
Epoch : 1 ,
RecoveryTargets : map [ string ] SessionKind { "replica-1" : SessionCatchUp } ,
} )
if len ( result . SessionsCreated ) != 1 {
t . Fatalf ( "should create new session: %v" , result )
}
if s . SessionID ( ) == sessID {
t . Fatal ( "new session should have different ID" )
}
t . Logf ( "DataAddr changed: sender preserved, old session invalidated, new session attached" )
}
func TestEngine_ChangedAddress_NewSessionAfterUpdate ( t * testing . T ) {
func TestEngine_ChangedCtrlAddr_InvalidatesSession ( t * testing . T ) {
r := NewRegistry ( )
r . ApplyAssignment ( AssignmentIntent {
Endpoints : map [ string ] Endpoint {
"r1:9333" : { DataAddr : "r1:9333" , Version : 1 } ,
} ,
Replicas : replicas ( map [ string ] Endpoint {
"r1" : { DataAddr : "10.0.0.1:9333" , CtrlAddr : "10.0.0.1:9334 " , Version : 1 } ,
} ) ,
Epoch : 1 ,
RecoveryTargets : map [ string ] SessionKind { "r1:9333" : SessionCatchUp } ,
RecoveryTargets : map [ string ] SessionKind { "r1" : SessionCatchUp } ,
} )
s := r . Sender ( "r1:9333" )
oldID := s . SessionID ( )
s := r . Sender ( "r1" )
sessID := s . SessionID ( )
s . BeginConnect ( sessID )
r . Reconcile ( map [ string ] Endpoint {
"r1:9333" : { DataAddr : "r1:9333" , Version : 2 } ,
} , 1 )
result := r . ApplyAssignment ( AssignmentIntent {
Endpoints : map [ string ] Endpoint { "r1:9333" : { DataAddr : "r1:9333" , Version : 2 } } ,
Epoch : 1 ,
RecoveryTargets : map [ string ] SessionKind { "r1:9333" : SessionCatchUp } ,
} )
r . Reconcile ( replicas ( map [ string ] Endpoint {
"r1" : { DataAddr : "10.0.0.1:9333" , CtrlAddr : "10.0.0.1:9445" , Version : 2 } ,
} ) , 1 )
if len ( result . SessionsCreated ) != 1 {
t . Fatalf ( "should create new session: %v" , result )
if s . HasActiveSession ( ) {
t . Fatal ( "CtrlAddr change should invalidate session" )
}
if s . SessionID ( ) == oldID {
t . Fatal ( "should have different session ID" )
if s . State ( ) != StateDisconnected {
t . Fatalf ( "state=%s" , s . State ( ) )
}
}
// --- Stale-session rejection (A3) ---
func TestEngine_StaleSessionID_RejectedAtAllAPIs ( t * testing . T ) {
s := NewSender ( "r1:9333 " , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
s := NewSender ( "r1" , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
staleID , _ := s . AttachSession ( 1 , SessionCatchUp )
s . UpdateEpoch ( 2 )
s . AttachSession ( 2 , SessionCatchUp )
if err := s . BeginConnect ( staleID ) ; err == nil {
t . Fatal ( "stale ID: BeginConnect should reject" )
t . Fatal ( "stale BeginConnect should reject" )
}
if err := s . RecordHandshake ( staleID , 0 , 10 ) ; err == nil {
t . Fatal ( "stale ID: RecordHandshake should reject" )
t . Fatal ( "stale RecordHandshake should reject" )
}
if err := s . BeginCatchUp ( staleID ) ; err == nil {
t . Fatal ( "stale ID: BeginCatchUp should reject" )
t . Fatal ( "stale BeginCatchUp should reject" )
}
if err := s . RecordCatchUpProgress ( staleID , 5 ) ; err == nil {
t . Fatal ( "stale ID: RecordCatchUpProgress should reject" )
t . Fatal ( "stale RecordCatchUpProgress should reject" )
}
if s . CompleteSessionByID ( staleID ) {
t . Fatal ( "stale ID: CompleteSessionByID should reject" )
t . Fatal ( "stale CompleteSessionByID should reject" )
}
}
func TestEngine_StaleCompletion_AfterSupersede ( t * testing . T ) {
s := NewSender ( "r1:9333 " , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
s := NewSender ( "r1" , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
id1 , _ := s . AttachSession ( 1 , SessionCatchUp )
s . UpdateEpoch ( 2 )
@ -100,12 +130,9 @@ func TestEngine_StaleCompletion_AfterSupersede(t *testing.T) {
if s . CompleteSessionByID ( id1 ) {
t . Fatal ( "stale completion must be rejected" )
}
if s . HasActiveSession ( ) != true {
if ! s . HasActiveSession ( ) {
t . Fatal ( "new session should be active" )
}
if s . State ( ) == StateInSync {
t . Fatal ( "sender should not be InSync from stale completion" )
}
}
// --- Epoch-bump invalidation (A3) ---
@ -113,37 +140,34 @@ func TestEngine_StaleCompletion_AfterSupersede(t *testing.T) {
func TestEngine_EpochBump_InvalidatesAllSessions ( t * testing . T ) {
r := NewRegistry ( )
r . ApplyAssignment ( AssignmentIntent {
Endpoints : map [ string ] Endpoint {
"r1:9333 " : { DataAddr : "r1:9333" , Version : 1 } ,
"r2:9333 " : { DataAddr : "r2:9333" , Version : 1 } ,
} ,
Replicas : replicas ( map [ string ] Endpoint {
"r1" : { DataAddr : "r1:9333" , Version : 1 } ,
"r2" : { DataAddr : "r2:9333" , Version : 1 } ,
} ) ,
Epoch : 1 ,
RecoveryTargets : map [ string ] SessionKind {
"r1:9333 " : SessionCatchUp ,
"r2:9333 " : SessionCatchUp ,
"r1" : SessionCatchUp ,
"r2" : SessionCatchUp ,
} ,
} )
count := r . InvalidateEpoch ( 2 )
if count != 2 {
t . Fatalf ( "should invalidate 2, got %d" , count )
}
if r . Sender ( "r1:9333" ) . HasActiveSession ( ) || r . Sender ( "r2:9333" ) . HasActiveSession ( ) {
t . Fatal ( "both sessions should be invalidated" )
t . Fatalf ( "invalidated=%d, want 2" , count )
}
}
func TestEngine_EpochBump_StaleAssignment_Rejected ( t * testing . T ) {
r := NewRegistry ( )
r . ApplyAssignment ( AssignmentIntent {
Endpoints : map [ string ] Endpoint { "r1:9333 " : { DataAddr : "r1:9333" , Version : 1 } } ,
Epoch : 2 ,
Replicas : replicas ( map [ string ] Endpoint { "r1" : { DataAddr : "r1:9333" , Version : 1 } } ) ,
Epoch : 2 ,
} )
result := r . ApplyAssignment ( AssignmentIntent {
Endpoints : map [ string ] Endpoint { "r1:9333 " : { DataAddr : "r1:9333" , Version : 1 } } ,
Replicas : replicas ( map [ string ] Endpoint { "r1" : { DataAddr : "r1:9333" , Version : 1 } } ) ,
Epoch : 1 ,
RecoveryTargets : map [ string ] SessionKind { "r1:9333 " : SessionCatchUp } ,
RecoveryTargets : map [ string ] SessionKind { "r1" : SessionCatchUp } ,
} )
if len ( result . SessionsFailed ) != 1 {
@ -154,7 +178,7 @@ func TestEngine_EpochBump_StaleAssignment_Rejected(t *testing.T) {
// --- Rebuild exclusivity ---
func TestEngine_Rebuild_CatchUpAPIs_Rejected ( t * testing . T ) {
s := NewSender ( "r1:9333 " , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
s := NewSender ( "r1" , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
sessID , _ := s . AttachSession ( 1 , SessionRebuild )
s . BeginConnect ( sessID )
s . RecordHandshake ( sessID , 0 , 100 )
@ -162,16 +186,13 @@ func TestEngine_Rebuild_CatchUpAPIs_Rejected(t *testing.T) {
if err := s . BeginCatchUp ( sessID ) ; err == nil {
t . Fatal ( "rebuild: BeginCatchUp should reject" )
}
if err := s . RecordCatchUpProgress ( sessID , 50 ) ; err == nil {
t . Fatal ( "rebuild: RecordCatchUpProgress should reject" )
}
if s . CompleteSessionByID ( sessID ) {
t . Fatal ( "rebuild: catch-up completion should reject" )
}
}
func TestEngine_Rebuild_FullLifecycle ( t * testing . T ) {
s := NewSender ( "r1:9333 " , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
s := NewSender ( "r1" , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
sessID , _ := s . AttachSession ( 1 , SessionRebuild )
s . BeginConnect ( sessID )
@ -193,7 +214,7 @@ func TestEngine_Rebuild_FullLifecycle(t *testing.T) {
// --- Bounded catch-up ---
func TestEngine_FrozenTarget_RejectsChase ( t * testing . T ) {
s := NewSender ( "r1:9333 " , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
s := NewSender ( "r1" , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
sessID , _ := s . AttachSession ( 1 , SessionCatchUp )
s . BeginConnect ( sessID )
@ -206,7 +227,7 @@ func TestEngine_FrozenTarget_RejectsChase(t *testing.T) {
}
func TestEngine_BudgetViolation_Escalates ( t * testing . T ) {
s := NewSender ( "r1:9333 " , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
s := NewSender ( "r1" , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
sessID , _ := s . AttachSession ( 1 , SessionCatchUp , WithBudget ( CatchUpBudget { MaxDurationTicks : 5 } ) )
s . BeginConnect ( sessID )
@ -223,31 +244,19 @@ func TestEngine_BudgetViolation_Escalates(t *testing.T) {
}
}
// --- Encapsulation: no direct state mutation ---
// --- Encapsulation ---
func TestEngine_Encapsulation_SnapshotIsReadOnly ( t * testing . T ) {
s := NewSender ( "r1:9333 " , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
s := NewSender ( "r1" , Endpoint { DataAddr : "r1:9333" , Version : 1 } , 1 )
sessID , _ := s . AttachSession ( 1 , SessionCatchUp )
snap := s . SessionSnapshot ( )
if snap == nil || ! snap . Active {
t . Fatal ( "should have active session snapshot" )
}
// Mutating the snapshot does not affect the sender.
snap . Phase = PhaseCompleted
snap . Active = false
// Sender's session is still active.
if ! s . HasActiveSession ( ) {
t . Fatal ( "sender should still have active session after snapshot mutation" )
}
snap2 := s . SessionSnapshot ( )
if snap2 . Phase == PhaseCompleted {
t . Fatal ( "snapshot mutation should not leak back to sender" )
t . Fatal ( "snapshot mutation should not affect sender" )
}
// Can still execute on the real session.
if err := s . BeginConnect ( sessID ) ; err != nil {
t . Fatalf ( "execution should still work: %v" , err )
}
@ -258,21 +267,21 @@ func TestEngine_Encapsulation_SnapshotIsReadOnly(t *testing.T) {
func TestEngine_E2E_ThreeReplicas_ThreeOutcomes ( t * testing . T ) {
r := NewRegistry ( )
r . ApplyAssignment ( AssignmentIntent {
Endpoints : map [ string ] Endpoint {
"r1:9333 " : { DataAddr : "r1:9333" , Version : 1 } ,
"r2:9333 " : { DataAddr : "r2:9333" , Version : 1 } ,
"r3:9333 " : { DataAddr : "r3:9333" , Version : 1 } ,
} ,
Replicas : replicas ( map [ string ] Endpoint {
"r1" : { DataAddr : "r1:9333" , Version : 1 } ,
"r2" : { DataAddr : "r2:9333" , Version : 1 } ,
"r3" : { DataAddr : "r3:9333" , Version : 1 } ,
} ) ,
Epoch : 1 ,
RecoveryTargets : map [ string ] SessionKind {
"r1:9333 " : SessionCatchUp ,
"r2:9333 " : SessionCatchUp ,
"r3:9333 " : SessionCatchUp ,
"r1" : SessionCatchUp ,
"r2" : SessionCatchUp ,
"r3" : SessionCatchUp ,
} ,
} )
// r1: zero-gap.
r1 := r . Sender ( "r1:9333 " )
r1 := r . Sender ( "r1" )
id1 := r1 . SessionID ( )
r1 . BeginConnect ( id1 )
o1 , _ := r1 . RecordHandshakeWithOutcome ( id1 , HandshakeResult {
@ -284,7 +293,7 @@ func TestEngine_E2E_ThreeReplicas_ThreeOutcomes(t *testing.T) {
r1 . CompleteSessionByID ( id1 )
// r2: catch-up.
r2 := r . Sender ( "r2:9333 " )
r2 := r . Sender ( "r2" )
id2 := r2 . SessionID ( )
r2 . BeginConnect ( id2 )
o2 , _ := r2 . RecordHandshakeWithOutcome ( id2 , HandshakeResult {
@ -298,7 +307,7 @@ func TestEngine_E2E_ThreeReplicas_ThreeOutcomes(t *testing.T) {
r2 . CompleteSessionByID ( id2 )
// r3: needs rebuild.
r3 := r . Sender ( "r3:9333 " )
r3 := r . Sender ( "r3" )
id3 := r3 . SessionID ( )
r3 . BeginConnect ( id3 )
o3 , _ := r3 . RecordHandshakeWithOutcome ( id3 , HandshakeResult {
@ -308,12 +317,6 @@ func TestEngine_E2E_ThreeReplicas_ThreeOutcomes(t *testing.T) {
t . Fatalf ( "r3: %s" , o3 )
}
if r1 . State ( ) != StateInSync || r2 . State ( ) != StateInSync {
t . Fatalf ( "r1=%s r2=%s" , r1 . State ( ) , r2 . State ( ) )
}
if r3 . State ( ) != StateNeedsRebuild {
t . Fatalf ( "r3=%s" , r3 . State ( ) )
}
if r . InSyncCount ( ) != 2 {
t . Fatalf ( "in_sync=%d" , r . InSyncCount ( ) )
}